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..75f5efd --- /dev/null +++ b/src/string/index.ts @@ -0,0 +1,73 @@ +/** + * 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..3594f7d 100644 --- a/tests/result/result.test.ts +++ b/tests/result/result.test.ts @@ -1327,3 +1327,67 @@ 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..b030ebe 100644 --- a/tests/union/union.test.ts +++ b/tests/union/union.test.ts @@ -118,3 +118,98 @@ 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(); + }); +});