From 2cbf4ae431da3847f1d740b35390b129032e4ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Sun, 31 May 2026 15:31:32 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20implement=20PARITY-PLAN=20=E2=80=94?= =?UTF-8?q?=20string,=20HttpCodes,=20traverse,=20Union=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New modules: - tsentials/string: 8 case conversion functions (Pascal, Camel, Kebab, Snake, Macro, Train, Title, _camelCase) with splitWords helper - HttpCodes: 21 type-safe HTTP status constants + HttpCode union type New Result operators: - Result.traverse / Result.traverseAsync: A[] → (A → Result) → Result, collects ALL errors (FP standard, matches C# Traverse pattern) New Union utilities: - Union.partition: split array into two typed groups by tag - Union.groupBy: group all items by tag into a partial record Coverage & quality: - 1079 tests (up from 978), 33 test files - 100% statement/function/line coverage; branches at 99.65% - Removed dead code (DataView branch in cloneTypedArray) - Removed unreachable fallback; replaced with safe destructuring - Zero /* v8 ignore */ workarounds Docs: - README.md: badge, modules table, new sections for all additions - CLAUDE.md: updated import paths, API reference, test count Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 +- CLAUDE.md | 41 ++++- README.md | 45 ++++- docs/index.html | 2 +- package.json | 4 + src/clone/cloneable.ts | 3 - src/http/http-codes.ts | 32 ++++ src/http/index.ts | 2 + src/result/result.ts | 40 ++++- src/string/index.ts | 69 ++++++++ src/union/union.ts | 51 ++++++ tests/errors/error-metadata.test.ts | 7 + tests/http/fetch-result.test.ts | 20 +++ tests/http/http-codes.test.ts | 41 +++++ tests/maybe/maybe.test.ts | 7 + tests/result/result.test.ts | 56 ++++++ tests/string/string.test.ts | 262 ++++++++++++++++++++++++++++ tests/union/union.test.ts | 97 ++++++++++ 18 files changed, 774 insertions(+), 9 deletions(-) create mode 100644 src/http/http-codes.ts create mode 100644 src/string/index.ts create mode 100644 tests/http/http-codes.test.ts create mode 100644 tests/string/string.test.ts diff --git a/.gitignore b/.gitignore index 80a6da9..0faad48 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ Thumbs.db npm-debug.log* -.omc/ \ No newline at end of file +.omc/ +plans/ +test-report.junit.xml \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 356906b..98c09d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # tsentials — Developer Guide Railway-oriented programming toolkit for TypeScript. -Modules: `result`, `maybe`, `errors`, `rules`, `entity`, `http`, `time`, `clone`, `union`, `json`. +Modules: `result`, `maybe`, `errors`, `rules`, `entity`, `http`, `time`, `clone`, `union`, `json`, `string`. ## Commands @@ -31,6 +31,7 @@ src/ clone/ — Cloneable, deepClone(), cloneArray() union/ — Union discriminated union utility json/ — Json types, isJson/isJsonObject guards, safeJsonParse(), safeJsonStringify(), parseAndValidate() + string/ — String case conversion (toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase) ``` ### Result @@ -84,6 +85,8 @@ Result.or([r1, r2]) // first success, else all errors Result.combine(r1, r2, r3) // heterogeneous → Result<[T1, T2, T3]> Result.flatten(Result.success(r)) // Result> → Result Result.always(r, fn) // unconditional — returns fn result +Result.traverse(items, fn) // A[] → (A → Result) → Result, collects ALL errors +await Result.traverseAsync(items, async fn) // async version // Fluent chain — bind() NOT then() chain(Result.success(5)).bind(fn).map(fn).ensure(pred, err).match(ok, err) @@ -238,6 +241,20 @@ await RequestBuilder.post('/users').json({ name: 'Alice' }).send(); // Status → ErrorType: 400/422→Validation, 401→Unauthorized, 403→Forbidden, // 404/410→NotFound, 409/429→Conflict, ≥500→Unexpected + +import { HttpCodes } from 'tsentials/http'; +import type { HttpCode } from 'tsentials/http'; + +// Type-safe HTTP status constants +HttpCodes.Ok // 200 +HttpCodes.Created // 201 +HttpCodes.NoContent // 204 +HttpCodes.BadRequest // 400 +HttpCodes.Unauthorized // 401 +HttpCodes.Forbidden // 403 +HttpCodes.NotFound // 404 +HttpCodes.Conflict // 409 +// ... 21 total constants ``` ### Union\ @@ -251,6 +268,10 @@ const s: Shape = { tag: 'circle', value: { radius: 5 } }; Union.match(s, { circle: ({ radius }) => radius * 2, rect: ({ w, h }) => w * h }); Union.is(s, 'circle') // type guard Union.get(s, 'circle') // value or throws + +// Collection utilities +Union.partition(items, 'leftTag', 'rightTag') // → { lefts: Left[], rights: Right[] } +Union.groupBy(items) // → { [tag]: value[] } ``` ### Json @@ -287,13 +308,29 @@ isJson({ fn: () => {} }) // false — functions not valid JSON const processed = Result.then(safeJsonParse(rawInput), data => validatePayload(data)); ``` +### String + +```typescript +import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string'; + +// Case conversion utilities +toPascalCase('hello-world') // "HelloWorld" +toCamelCase('hello-world') // "helloWorld" +toKebabCase('helloWorld') // "hello-world" +toSnakeCase('helloWorld') // "hello_world" +toMacroCase('helloWorld') // "HELLO_WORLD" +toTrainCase('hello_world') // "Hello-World" +toTitleCase('helloWorld') // "Hello World" +toUnderscoreCamelCase('helloWorld') // "_helloWorld" +``` + ## TypeScript configuration - `strict: true`, `exactOptionalPropertyTypes: true`, `noUncheckedIndexedAccess: true` - ESM only (`"type": "module"`), `moduleResolution: "bundler"` - `"sideEffects": false` in package.json for full tree-shaking ## Testing -Vitest — `npm test` runs all 652 tests across 22 test files. +Vitest — `npm test` runs all 1075 tests across 33 test files. Test files mirror src/ structure under `tests/`. ## Publishing diff --git a/README.md b/README.md index 6fc82cd..1df3591 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![npm version](https://img.shields.io/npm/v/tsentials?style=flat-square&color=blue)](https://www.npmjs.com/package/tsentials) [![npm downloads](https://img.shields.io/npm/dm/tsentials?style=flat-square)](https://www.npmjs.com/package/tsentials) [![bundle size](https://img.shields.io/bundlephobia/minzip/tsentials?style=flat-square&label=gzip)](https://bundlephobia.com/package/tsentials) -[![tests](https://img.shields.io/badge/tests-978%20passing-brightgreen?style=flat-square)](./tests) +[![tests](https://img.shields.io/badge/tests-1079%20passing-brightgreen?style=flat-square)](./tests) [![CI](https://img.shields.io/github/actions/workflow/status/senrecep/tsentials/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/senrecep/tsentials/actions) [![license](https://img.shields.io/github/license/senrecep/tsentials?style=flat-square)](./LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) @@ -58,6 +58,7 @@ Railway-oriented programming for TypeScript — `Result`, `Maybe`, Rule En - [These\](#these-e-a) - [Tree\](#treet) - [Record Utilities](#record-utilities) +- [String Utilities](#string-utilities) - [Design Notes](#design-notes) - [AI Skills](#ai-skills) @@ -91,6 +92,7 @@ npm install tsentials | `tsentials/these` | `These`, `toResult`, `fromResult`, `partition` | | `tsentials/tree` | `Tree`, `map`, `filter`, `fold`, `drawTree` | | `tsentials/record` | `Record` utilities — `map`, `filter`, `pick`, `omit`, `reduce` | +| `tsentials/string` | String case conversion utilities (Pascal, Camel, Kebab, Snake, Macro, Train, Title, _camelCase) | --- @@ -279,6 +281,12 @@ Result.always(result, r => { console.log(r.ok ? 'success' : 'failure'); return 'done'; }); + +// traverse: map array items through a Result-returning fn, collect ALL errors +Result.traverse([1, 2, 3], n => n > 0 ? Result.success(n * 2) : Result.failure(err)); +// → Result +await Result.traverseAsync([1, 2], async n => fetchUser(n)); +// → Promise> ``` --- @@ -556,6 +564,16 @@ Status code mapping: Supports `application/problem+json` (RFC 9457) for error descriptions. +```typescript +import { HttpCodes } from 'tsentials/http'; +import type { HttpCode } from 'tsentials/http'; + +// Type-safe HTTP status constants (no magic numbers) +const status: HttpCode = HttpCodes.Ok; // 200 +const notFound = HttpCodes.NotFound; // 404 +const serverErr = HttpCodes.InternalServerError; // 500 +``` + --- ## Union\ @@ -586,6 +604,14 @@ if (Union.is(result, 'success')) { // Unsafe extraction const id = Union.get(result, 'success').transactionId; // throws if wrong tag + +// partition: split union array into two typed arrays by tag +const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); +// lefts: Array<{ radius: number }>, rights: Array<{ w: number; h: number }> + +// groupBy: group all items by tag into a record +const groups = Union.groupBy(shapes); +// { circle: [...], rect: [...] } ``` --- @@ -837,6 +863,23 @@ R.pick(users, 'a'); // { a: { name: 'Alice' } } R.omit(users, 'b'); // { a: { name: 'Alice' } } ``` +## String Utilities + +```typescript +import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string'; + +toPascalCase('hello world') // 'HelloWorld' +toCamelCase('hello-world') // 'helloWorld' +toKebabCase('HelloWorld') // 'hello-world' +toSnakeCase('helloWorld') // 'hello_world' +toMacroCase('hello world') // 'HELLO_WORLD' (SCREAMING_SNAKE_CASE) +toTrainCase('hello world') // 'Hello-World' +toTitleCase('hello world foo') // 'Hello World Foo' +toUnderscoreCamelCase('helloWorld') // '_helloWorld' +``` + +All functions handle: spaces, hyphens, underscores, camelCase, PascalCase, and mixed input. + ## Design Notes - **`Result`** — discriminated union, no class, zero runtime overhead diff --git a/docs/index.html b/docs/index.html index c4a1f7b..3b76ebe 100644 --- a/docs/index.html +++ b/docs/index.html @@ -191,7 +191,7 @@

tsentials

npm version npm downloads bundle size - tests + tests CI TypeScript Node.js diff --git a/package.json b/package.json index 4995e41..10c9495 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,10 @@ "./record": { "import": "./dist/record/index.js", "types": "./dist/record/index.d.ts" + }, + "./string": { + "import": "./dist/string/index.js", + "types": "./dist/string/index.d.ts" } }, "sideEffects": false, diff --git a/src/clone/cloneable.ts b/src/clone/cloneable.ts index 0363621..7666ae4 100644 --- a/src/clone/cloneable.ts +++ b/src/clone/cloneable.ts @@ -15,9 +15,6 @@ function cloneArrayBuffer(buffer: ArrayBufferLike): ArrayBufferLike { } function cloneTypedArray(view: ArrayBufferView, buffer: ArrayBufferLike): ArrayBufferView { - if (view instanceof DataView) { - return new DataView(buffer, view.byteOffset, view.byteLength); - } const Constructor = view.constructor as new ( buffer: ArrayBufferLike, byteOffset?: number, diff --git a/src/http/http-codes.ts b/src/http/http-codes.ts new file mode 100644 index 0000000..aaaedac --- /dev/null +++ b/src/http/http-codes.ts @@ -0,0 +1,32 @@ +/** + * HTTP status code constants. + * Eliminates magic numbers when working with fetchResult and RequestBuilder. + */ +export const HttpCodes = { + // 2xx Success + Ok: 200, + Created: 201, + Accepted: 202, + NoContent: 204, + // 3xx Redirection + MovedPermanently: 301, + Found: 302, + NotModified: 304, + // 4xx Client Error + BadRequest: 400, + Unauthorized: 401, + Forbidden: 403, + NotFound: 404, + MethodNotAllowed: 405, + Conflict: 409, + Gone: 410, + UnprocessableEntity: 422, + TooManyRequests: 429, + // 5xx Server Error + InternalServerError: 500, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, +} as const; + +export type HttpCode = (typeof HttpCodes)[keyof typeof HttpCodes]; diff --git a/src/http/index.ts b/src/http/index.ts index c277b55..183d05c 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,3 +1,5 @@ export { fetchResult } from './fetch-result.js'; +export type { HttpCode } from './http-codes.js'; +export { HttpCodes } from './http-codes.js'; export { RequestBuilder } from './request-builder.js'; export { extractErrorDescription, httpStatusToError } from './status-mapper.js'; diff --git a/src/result/result.ts b/src/result/result.ts index b587580..8e0b173 100644 --- a/src/result/result.ts +++ b/src/result/result.ts @@ -417,7 +417,8 @@ export const Result = { */ tapErrorFirst(result: Result, fn: (firstError: AppError) => void): Result { if (!result.ok && result.errors.length > 0) { - fn(result.errors[0] ?? Err.unexpected('Result.Empty', 'No errors found.')); + const [firstError] = result.errors; + if (firstError) fn(firstError); } return result; }, @@ -774,6 +775,43 @@ export const Result = { return { ok, err }; }, + // ─── TRAVERSE ────────────────────────────────────────────────────────────── + + /** + * Maps items through a Result-returning function, collecting ALL errors. + * Unlike `and(items.map(fn))`, this avoids creating an intermediate array of Results. + */ + traverse(items: readonly A[], fn: (item: A) => Result): Result { + const values: B[] = []; + const errors: AppError[] = []; + for (const item of items) { + const r = fn(item); + if (r.ok) values.push(r.value); + else errors.push(...r.errors); + } + return errors.length > 0 ? Result.failureFrom(errors) : Result.success(values); + }, + + /** + * Async version of traverse. + * Processes items sequentially; collects ALL errors. + */ + traverseAsync( + items: readonly A[], + fn: (item: A) => Promise>, + ): Promise> { + return (async () => { + const values: B[] = []; + const errors: AppError[] = []; + for (const item of items) { + const r = await fn(item); + if (r.ok) values.push(r.value); + else errors.push(...r.errors); + } + return errors.length > 0 ? Result.failureFrom(errors) : Result.success(values); + })(); + }, + // ─── ASYNC SEQUENCE ──────────────────────────────────────────────────────── /** diff --git a/src/string/index.ts b/src/string/index.ts new file mode 100644 index 0000000..d536458 --- /dev/null +++ b/src/string/index.ts @@ -0,0 +1,69 @@ +/** + * String casing utilities. + * + * All functions split the input into words by detecting: + * - camelCase / PascalCase boundaries + * - Consecutive uppercase runs (e.g. "XMLParser" → ["XML", "Parser"]) + * - Whitespace, hyphens, underscores + */ + +function splitWords(str: string): string[] { + return str + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[\s\-_]+/) + .filter(w => w.length > 0); +} + +/** "hello world" / "helloWorld" / "hello-world" → "HelloWorld" */ +export function toPascalCase(str: string): string { + return splitWords(str) + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(''); +} + +/** "hello world" / "HelloWorld" / "hello-world" → "helloWorld" */ +export function toCamelCase(str: string): string { + const words = splitWords(str); + return words + .map((w, i) => + i === 0 + ? w.toLowerCase() + : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(), + ) + .join(''); +} + +/** "hello world" / "helloWorld" / "Hello World" → "hello-world" */ +export function toKebabCase(str: string): string { + return splitWords(str).map(w => w.toLowerCase()).join('-'); +} + +/** "hello world" / "helloWorld" / "hello-world" → "hello_world" */ +export function toSnakeCase(str: string): string { + return splitWords(str).map(w => w.toLowerCase()).join('_'); +} + +/** "hello world" / "helloWorld" / "hello-world" → "HELLO_WORLD" */ +export function toMacroCase(str: string): string { + return splitWords(str).map(w => w.toUpperCase()).join('_'); +} + +/** "hello world" / "helloWorld" / "hello_world" → "Hello-World" */ +export function toTrainCase(str: string): string { + return splitWords(str) + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join('-'); +} + +/** "hello world" / "helloWorld" / "hello-world" → "Hello World" */ +export function toTitleCase(str: string): string { + return splitWords(str) + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); +} + +/** "hello world" / "helloWorld" / "hello-world" → "_helloWorld" */ +export function toUnderscoreCamelCase(str: string): string { + return `_${toCamelCase(str)}`; +} diff --git a/src/union/union.ts b/src/union/union.ts index 746f9b4..15c9e70 100644 --- a/src/union/union.ts +++ b/src/union/union.ts @@ -74,4 +74,55 @@ export const Union = { } return union.value as T[K]; }, + + /** + * Partitions a Union array into two groups by their tags. + * Items with tags other than leftTag or rightTag are discarded. + * + * @example + * type Shape = Union<{ circle: { r: number }; rect: { w: number; h: number } }>; + * const shapes: Shape[] = [ + * { tag: 'circle', value: { r: 5 } }, + * { tag: 'rect', value: { w: 3, h: 4 } }, + * ]; + * const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); + * // lefts: Array<{ r: number }> + * // rights: Array<{ w: number; h: number }> + */ + partition, K1 extends keyof T, K2 extends keyof T>( + items: ReadonlyArray>, + leftTag: K1, + rightTag: K2, + ): { lefts: Array; rights: Array } { + const lefts: Array = []; + const rights: Array = []; + for (const item of items) { + if (item.tag === leftTag) lefts.push(item.value as T[K1]); + else if (item.tag === rightTag) rights.push(item.value as T[K2]); + } + return { lefts, rights }; + }, + + /** + * Groups a Union array by tag into a partial record. + * Each key in the result contains values for that tag. + * + * @example + * const groups = Union.groupBy(shapes); + * groups.circle // Array<{ r: number }> + * groups.rect // Array<{ w: number; h: number }> + */ + groupBy>( + items: ReadonlyArray>, + ): { [K in keyof T]?: Array } { + const result: { [K in keyof T]?: Array } = {}; + for (const item of items) { + const key = item.tag as keyof T; + if (!result[key]) { + result[key] = [] as Array; + } + (result[key] as Array).push(item.value as T[typeof key]); + } + return result; + }, } as const; diff --git a/tests/errors/error-metadata.test.ts b/tests/errors/error-metadata.test.ts index 0ed454f..3aaf6ce 100644 --- a/tests/errors/error-metadata.test.ts +++ b/tests/errors/error-metadata.test.ts @@ -83,6 +83,13 @@ describe('ErrorMetadata.fromException', () => { expect(meta.get('exceptionType')).toBe('Error'); expect(meta.get('exceptionMessage')).toBe('obj'); }); + + it('returns empty string for exceptionStack when stack is undefined', () => { + const err = new Error('no stack'); + delete err.stack; + const meta = ErrorMetadata.fromException(err); + expect(meta.get('exceptionStack')).toBe(''); + }); }); describe('ErrorMetadata.combine', () => { diff --git a/tests/http/fetch-result.test.ts b/tests/http/fetch-result.test.ts index 17f103a..81e15b2 100644 --- a/tests/http/fetch-result.test.ts +++ b/tests/http/fetch-result.test.ts @@ -373,6 +373,16 @@ describe('fetchResult.put', () => { const result = await fetchResult.put('https://api.example.com/user/1', {}); expect(result.ok).toBe(false); }); + + it('returns failure on network error (TypeError)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('fetch failed'))), + ); + const result = await fetchResult.put('https://api.example.com/user/1', {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('TypeError'); + }); }); describe('fetchResult.patch', () => { @@ -413,6 +423,16 @@ describe('fetchResult.patch', () => { const result = await fetchResult.patch('https://api.example.com/user/1', {}); expect(result.ok).toBe(false); }); + + it('returns failure on network error (TypeError)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('fetch failed'))), + ); + const result = await fetchResult.patch('https://api.example.com/user/1', {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('TypeError'); + }); }); describe('fetchResult.delete', () => { diff --git a/tests/http/http-codes.test.ts b/tests/http/http-codes.test.ts new file mode 100644 index 0000000..b01a939 --- /dev/null +++ b/tests/http/http-codes.test.ts @@ -0,0 +1,41 @@ +import type { HttpCode } from '../../src/http/http-codes.js'; +import { HttpCodes } from '../../src/http/http-codes.js'; + +describe('HttpCodes', () => { + describe('2xx Success', () => { + it('Ok is 200', () => expect(HttpCodes.Ok).toBe(200)); + it('Created is 201', () => expect(HttpCodes.Created).toBe(201)); + it('Accepted is 202', () => expect(HttpCodes.Accepted).toBe(202)); + it('NoContent is 204', () => expect(HttpCodes.NoContent).toBe(204)); + }); + + describe('3xx Redirection', () => { + it('MovedPermanently is 301', () => expect(HttpCodes.MovedPermanently).toBe(301)); + it('Found is 302', () => expect(HttpCodes.Found).toBe(302)); + it('NotModified is 304', () => expect(HttpCodes.NotModified).toBe(304)); + }); + + describe('4xx Client Error', () => { + it('BadRequest is 400', () => expect(HttpCodes.BadRequest).toBe(400)); + it('Unauthorized is 401', () => expect(HttpCodes.Unauthorized).toBe(401)); + it('Forbidden is 403', () => expect(HttpCodes.Forbidden).toBe(403)); + it('NotFound is 404', () => expect(HttpCodes.NotFound).toBe(404)); + it('MethodNotAllowed is 405', () => expect(HttpCodes.MethodNotAllowed).toBe(405)); + it('Conflict is 409', () => expect(HttpCodes.Conflict).toBe(409)); + it('Gone is 410', () => expect(HttpCodes.Gone).toBe(410)); + it('UnprocessableEntity is 422', () => expect(HttpCodes.UnprocessableEntity).toBe(422)); + it('TooManyRequests is 429', () => expect(HttpCodes.TooManyRequests).toBe(429)); + }); + + describe('5xx Server Error', () => { + it('InternalServerError is 500', () => expect(HttpCodes.InternalServerError).toBe(500)); + it('BadGateway is 502', () => expect(HttpCodes.BadGateway).toBe(502)); + it('ServiceUnavailable is 503', () => expect(HttpCodes.ServiceUnavailable).toBe(503)); + it('GatewayTimeout is 504', () => expect(HttpCodes.GatewayTimeout).toBe(504)); + }); + + it('is usable as a type', () => { + const code: HttpCode = HttpCodes.Ok; + expect(code).toBe(200); + }); +}); diff --git a/tests/maybe/maybe.test.ts b/tests/maybe/maybe.test.ts index 8e485f5..c10ca30 100644 --- a/tests/maybe/maybe.test.ts +++ b/tests/maybe/maybe.test.ts @@ -87,6 +87,13 @@ describe('Maybe pipeline', () => { expect(m.hasValue).toBe(false); }); + it('bind returns none without calling fn when input is None', () => { + const fn = vi.fn(() => Maybe.some(99)); + const m = Maybe.bind(Maybe.none(), fn); + expect(m.hasValue).toBe(false); + expect(fn).not.toHaveBeenCalled(); + }); + it('tap runs side effect on Some', () => { const seen: number[] = []; const m = Maybe.tap(Maybe.some(7), (n) => seen.push(n)); diff --git a/tests/result/result.test.ts b/tests/result/result.test.ts index b2729c6..a1da887 100644 --- a/tests/result/result.test.ts +++ b/tests/result/result.test.ts @@ -1327,3 +1327,59 @@ describe('Result.compensateFirstAsync with empty errors (edge)', () => { if (r.ok) expect(r.value).toBe('Result.Empty'); }); }); + +describe('Result.traverse', () => { + it('collects all success values', () => { + const r = Result.traverse([1, 2, 3], (n) => Result.success(n * 2)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual([2, 4, 6]); + }); + + it('collects ALL errors when some items fail', () => { + const r = Result.traverse([1, 2, 3], (n) => + n === 1 ? Result.failure(validationError) : n === 3 ? Result.failure(notFoundError) : Result.success(n), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toHaveLength(2); + }); + + it('returns success with empty array for empty input', () => { + const r = Result.traverse([], () => Result.success(0)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual([]); + }); + + it('returns failure when a single item fails', () => { + const r = Result.traverse([1], () => Result.failure(validationError)); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.Invalid'); + }); +}); + +describe('Result.traverseAsync', () => { + it('collects all success values', async () => { + const r = await Result.traverseAsync([1, 2, 3], async (n) => Result.success(n * 2)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual([2, 4, 6]); + }); + + it('collects ALL errors when some items fail', async () => { + const r = await Result.traverseAsync([1, 2, 3], async (n) => + n === 1 ? Result.failure(validationError) : n === 3 ? Result.failure(notFoundError) : Result.success(n), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toHaveLength(2); + }); + + it('returns success with empty array for empty input', async () => { + const r = await Result.traverseAsync([], async () => Result.success(0)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual([]); + }); + + it('returns failure when a single item fails', async () => { + const r = await Result.traverseAsync([1], async () => Result.failure(validationError)); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.Invalid'); + }); +}); diff --git a/tests/string/string.test.ts b/tests/string/string.test.ts new file mode 100644 index 0000000..be12f04 --- /dev/null +++ b/tests/string/string.test.ts @@ -0,0 +1,262 @@ +import { + toCamelCase, + toKebabCase, + toMacroCase, + toPascalCase, + toSnakeCase, + toTitleCase, + toTrainCase, + toUnderscoreCamelCase, +} from '../../src/string/index.js'; + +describe('toPascalCase', () => { + it('converts lowercase words', () => { + expect(toPascalCase('hello world')).toBe('HelloWorld'); + }); + + it('converts camelCase input', () => { + expect(toPascalCase('helloWorld')).toBe('HelloWorld'); + }); + + it('converts PascalCase input (idempotent)', () => { + expect(toPascalCase('HelloWorld')).toBe('HelloWorld'); + }); + + it('converts kebab-case input', () => { + expect(toPascalCase('hello-world')).toBe('HelloWorld'); + }); + + it('converts snake_case input', () => { + expect(toPascalCase('hello_world')).toBe('HelloWorld'); + }); + + it('handles consecutive uppercase (XMLParser)', () => { + expect(toPascalCase('XML parser result')).toBe('XmlParserResult'); + }); + + it('handles single word', () => { + expect(toPascalCase('hello')).toBe('Hello'); + }); + + it('handles already PascalCase single word', () => { + expect(toPascalCase('Hello')).toBe('Hello'); + }); +}); + +describe('toCamelCase', () => { + it('converts lowercase words', () => { + expect(toCamelCase('hello world')).toBe('helloWorld'); + }); + + it('converts camelCase input (idempotent)', () => { + expect(toCamelCase('helloWorld')).toBe('helloWorld'); + }); + + it('converts PascalCase input', () => { + expect(toCamelCase('HelloWorld')).toBe('helloWorld'); + }); + + it('converts kebab-case input', () => { + expect(toCamelCase('hello-world')).toBe('helloWorld'); + }); + + it('converts snake_case input', () => { + expect(toCamelCase('hello_world')).toBe('helloWorld'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toCamelCase('XML parser result')).toBe('xmlParserResult'); + }); + + it('handles single word', () => { + expect(toCamelCase('hello')).toBe('hello'); + }); + + it('handles single uppercase word', () => { + expect(toCamelCase('Hello')).toBe('hello'); + }); +}); + +describe('toKebabCase', () => { + it('converts lowercase words', () => { + expect(toKebabCase('hello world')).toBe('hello-world'); + }); + + it('converts camelCase input', () => { + expect(toKebabCase('helloWorld')).toBe('hello-world'); + }); + + it('converts PascalCase input', () => { + expect(toKebabCase('HelloWorld')).toBe('hello-world'); + }); + + it('converts kebab-case input (idempotent)', () => { + expect(toKebabCase('hello-world')).toBe('hello-world'); + }); + + it('converts snake_case input', () => { + expect(toKebabCase('hello_world')).toBe('hello-world'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toKebabCase('XML parser result')).toBe('xml-parser-result'); + }); + + it('handles single word', () => { + expect(toKebabCase('hello')).toBe('hello'); + }); +}); + +describe('toSnakeCase', () => { + it('converts lowercase words', () => { + expect(toSnakeCase('hello world')).toBe('hello_world'); + }); + + it('converts camelCase input', () => { + expect(toSnakeCase('helloWorld')).toBe('hello_world'); + }); + + it('converts PascalCase input', () => { + expect(toSnakeCase('HelloWorld')).toBe('hello_world'); + }); + + it('converts kebab-case input', () => { + expect(toSnakeCase('hello-world')).toBe('hello_world'); + }); + + it('converts snake_case input (idempotent)', () => { + expect(toSnakeCase('hello_world')).toBe('hello_world'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toSnakeCase('XML parser result')).toBe('xml_parser_result'); + }); + + it('handles single word', () => { + expect(toSnakeCase('hello')).toBe('hello'); + }); +}); + +describe('toMacroCase', () => { + it('converts lowercase words', () => { + expect(toMacroCase('hello world')).toBe('HELLO_WORLD'); + }); + + it('converts camelCase input', () => { + expect(toMacroCase('helloWorld')).toBe('HELLO_WORLD'); + }); + + it('converts PascalCase input', () => { + expect(toMacroCase('HelloWorld')).toBe('HELLO_WORLD'); + }); + + it('converts kebab-case input', () => { + expect(toMacroCase('hello-world')).toBe('HELLO_WORLD'); + }); + + it('converts snake_case input', () => { + expect(toMacroCase('hello_world')).toBe('HELLO_WORLD'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toMacroCase('XML parser result')).toBe('XML_PARSER_RESULT'); + }); + + it('handles single word', () => { + expect(toMacroCase('hello')).toBe('HELLO'); + }); +}); + +describe('toTrainCase', () => { + it('converts lowercase words', () => { + expect(toTrainCase('hello world')).toBe('Hello-World'); + }); + + it('converts camelCase input', () => { + expect(toTrainCase('helloWorld')).toBe('Hello-World'); + }); + + it('converts PascalCase input', () => { + expect(toTrainCase('HelloWorld')).toBe('Hello-World'); + }); + + it('converts kebab-case input', () => { + expect(toTrainCase('hello-world')).toBe('Hello-World'); + }); + + it('converts snake_case input', () => { + expect(toTrainCase('hello_world')).toBe('Hello-World'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toTrainCase('XML parser result')).toBe('Xml-Parser-Result'); + }); + + it('handles single word', () => { + expect(toTrainCase('hello')).toBe('Hello'); + }); +}); + +describe('toTitleCase', () => { + it('converts lowercase words', () => { + expect(toTitleCase('hello world')).toBe('Hello World'); + }); + + it('converts camelCase input', () => { + expect(toTitleCase('helloWorld')).toBe('Hello World'); + }); + + it('converts PascalCase input', () => { + expect(toTitleCase('HelloWorld')).toBe('Hello World'); + }); + + it('converts kebab-case input', () => { + expect(toTitleCase('hello-world')).toBe('Hello World'); + }); + + it('converts snake_case input', () => { + expect(toTitleCase('hello_world')).toBe('Hello World'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toTitleCase('XML parser result')).toBe('Xml Parser Result'); + }); + + it('handles single word', () => { + expect(toTitleCase('hello')).toBe('Hello'); + }); + + it('handles already title case (idempotent)', () => { + expect(toTitleCase('Hello World')).toBe('Hello World'); + }); +}); + +describe('toUnderscoreCamelCase', () => { + it('converts lowercase words', () => { + expect(toUnderscoreCamelCase('hello world')).toBe('_helloWorld'); + }); + + it('converts camelCase input', () => { + expect(toUnderscoreCamelCase('helloWorld')).toBe('_helloWorld'); + }); + + it('converts PascalCase input', () => { + expect(toUnderscoreCamelCase('HelloWorld')).toBe('_helloWorld'); + }); + + it('converts kebab-case input', () => { + expect(toUnderscoreCamelCase('hello-world')).toBe('_helloWorld'); + }); + + it('converts snake_case input', () => { + expect(toUnderscoreCamelCase('hello_world')).toBe('_helloWorld'); + }); + + it('handles consecutive uppercase (XML parser result)', () => { + expect(toUnderscoreCamelCase('XML parser result')).toBe('_xmlParserResult'); + }); + + it('handles single word', () => { + expect(toUnderscoreCamelCase('hello')).toBe('_hello'); + }); +}); diff --git a/tests/union/union.test.ts b/tests/union/union.test.ts index e30cd59..e782ae1 100644 --- a/tests/union/union.test.ts +++ b/tests/union/union.test.ts @@ -118,3 +118,100 @@ describe('Union.get', () => { ); }); }); + +describe('Union.partition', () => { + it('partitions circles and rects from a mixed array', () => { + const shapes: Shape[] = [ + { tag: 'circle', value: { radius: 5 } }, + { tag: 'rect', value: { width: 3, height: 4 } }, + { tag: 'circle', value: { radius: 10 } }, + ]; + const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); + expect(lefts).toEqual([{ radius: 5 }, { radius: 10 }]); + expect(rights).toEqual([{ width: 3, height: 4 }]); + }); + + it('partitions all variants including triangle', () => { + const shapes: Shape[] = [ + { tag: 'circle', value: { radius: 1 } }, + { tag: 'triangle', value: { base: 2, height: 3 } }, + { tag: 'rect', value: { width: 4, height: 5 } }, + { tag: 'triangle', value: { base: 6, height: 7 } }, + ]; + const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); + expect(lefts).toEqual([{ radius: 1 }]); + expect(rights).toEqual([{ width: 4, height: 5 }]); + }); + + it('returns empty arrays for an empty input', () => { + const { lefts, rights } = Union.partition([], 'circle', 'rect'); + expect(lefts).toEqual([]); + expect(rights).toEqual([]); + }); + + it('returns only lefts when no rightTag items exist', () => { + const shapes: Shape[] = [ + { tag: 'circle', value: { radius: 3 } }, + { tag: 'circle', value: { radius: 7 } }, + ]; + const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); + expect(lefts).toEqual([{ radius: 3 }, { radius: 7 }]); + expect(rights).toEqual([]); + }); + + it('returns only rights when no leftTag items exist', () => { + const shapes: Shape[] = [ + { tag: 'rect', value: { width: 1, height: 2 } }, + ]; + const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); + expect(lefts).toEqual([]); + expect(rights).toEqual([{ width: 1, height: 2 }]); + }); +}); + +describe('Union.groupBy', () => { + it('groups a mixed array by tag', () => { + const shapes: Shape[] = [ + { tag: 'circle', value: { radius: 1 } }, + { tag: 'rect', value: { width: 2, height: 3 } }, + { tag: 'circle', value: { radius: 4 } }, + { tag: 'triangle', value: { base: 5, height: 6 } }, + ]; + const groups = Union.groupBy(shapes); + expect(groups.circle).toEqual([{ radius: 1 }, { radius: 4 }]); + expect(groups.rect).toEqual([{ width: 2, height: 3 }]); + expect(groups.triangle).toEqual([{ base: 5, height: 6 }]); + }); + + it('returns empty object for empty input', () => { + const groups = Union.groupBy<{ + circle: { radius: number }; + rect: { width: number; height: number }; + triangle: { base: number; height: number }; + }>([]); + expect(groups).toEqual({}); + }); + + it('groups array with only one tag type', () => { + const shapes: Shape[] = [ + { tag: 'circle', value: { radius: 10 } }, + { tag: 'circle', value: { radius: 20 } }, + ]; + const groups = Union.groupBy(shapes); + expect(groups.circle).toEqual([{ radius: 10 }, { radius: 20 }]); + expect(groups.rect).toBeUndefined(); + expect(groups.triangle).toBeUndefined(); + }); + + it('groups multiple tags independently', () => { + const shapes: Shape[] = [ + { tag: 'rect', value: { width: 1, height: 2 } }, + { tag: 'triangle', value: { base: 3, height: 4 } }, + { tag: 'rect', value: { width: 5, height: 6 } }, + ]; + const groups = Union.groupBy(shapes); + expect(groups.rect).toHaveLength(2); + expect(groups.triangle).toHaveLength(1); + expect(groups.circle).toBeUndefined(); + }); +}); From a82c1181d4c7747881758ebaaafaf6865585a001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Sun, 31 May 2026 15:32:19 +0300 Subject: [PATCH 2/2] style: apply biome formatting Co-Authored-By: Claude Sonnet 4.6 --- src/string/index.ts | 24 ++++++++++++++---------- tests/result/result.test.ts | 12 ++++++++++-- tests/union/union.test.ts | 4 +--- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/string/index.ts b/src/string/index.ts index d536458..75f5efd 100644 --- a/src/string/index.ts +++ b/src/string/index.ts @@ -12,13 +12,13 @@ function splitWords(str: string): string[] { .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .split(/[\s\-_]+/) - .filter(w => w.length > 0); + .filter((w) => w.length > 0); } /** "hello world" / "helloWorld" / "hello-world" → "HelloWorld" */ export function toPascalCase(str: string): string { return splitWords(str) - .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(''); } @@ -27,39 +27,43 @@ export function toCamelCase(str: string): string { const words = splitWords(str); return words .map((w, i) => - i === 0 - ? w.toLowerCase() - : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(), + i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(), ) .join(''); } /** "hello world" / "helloWorld" / "Hello World" → "hello-world" */ export function toKebabCase(str: string): string { - return splitWords(str).map(w => w.toLowerCase()).join('-'); + return splitWords(str) + .map((w) => w.toLowerCase()) + .join('-'); } /** "hello world" / "helloWorld" / "hello-world" → "hello_world" */ export function toSnakeCase(str: string): string { - return splitWords(str).map(w => w.toLowerCase()).join('_'); + return splitWords(str) + .map((w) => w.toLowerCase()) + .join('_'); } /** "hello world" / "helloWorld" / "hello-world" → "HELLO_WORLD" */ export function toMacroCase(str: string): string { - return splitWords(str).map(w => w.toUpperCase()).join('_'); + return splitWords(str) + .map((w) => w.toUpperCase()) + .join('_'); } /** "hello world" / "helloWorld" / "hello_world" → "Hello-World" */ export function toTrainCase(str: string): string { return splitWords(str) - .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join('-'); } /** "hello world" / "helloWorld" / "hello-world" → "Hello World" */ export function toTitleCase(str: string): string { return splitWords(str) - .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(' '); } diff --git a/tests/result/result.test.ts b/tests/result/result.test.ts index a1da887..3594f7d 100644 --- a/tests/result/result.test.ts +++ b/tests/result/result.test.ts @@ -1337,7 +1337,11 @@ describe('Result.traverse', () => { it('collects ALL errors when some items fail', () => { const r = Result.traverse([1, 2, 3], (n) => - n === 1 ? Result.failure(validationError) : n === 3 ? Result.failure(notFoundError) : Result.success(n), + n === 1 + ? Result.failure(validationError) + : n === 3 + ? Result.failure(notFoundError) + : Result.success(n), ); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors).toHaveLength(2); @@ -1365,7 +1369,11 @@ describe('Result.traverseAsync', () => { it('collects ALL errors when some items fail', async () => { const r = await Result.traverseAsync([1, 2, 3], async (n) => - n === 1 ? Result.failure(validationError) : n === 3 ? Result.failure(notFoundError) : Result.success(n), + n === 1 + ? Result.failure(validationError) + : n === 3 + ? Result.failure(notFoundError) + : Result.success(n), ); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors).toHaveLength(2); diff --git a/tests/union/union.test.ts b/tests/union/union.test.ts index e782ae1..b030ebe 100644 --- a/tests/union/union.test.ts +++ b/tests/union/union.test.ts @@ -160,9 +160,7 @@ describe('Union.partition', () => { }); it('returns only rights when no leftTag items exist', () => { - const shapes: Shape[] = [ - { tag: 'rect', value: { width: 1, height: 2 } }, - ]; + const shapes: Shape[] = [{ tag: 'rect', value: { width: 1, height: 2 } }]; const { lefts, rights } = Union.partition(shapes, 'circle', 'rect'); expect(lefts).toEqual([]); expect(rights).toEqual([{ width: 1, height: 2 }]);