From 96f4cba4a898e57b3475b2e5bb9a6380e51fc248 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 7 Jun 2026 16:29:08 +0700 Subject: [PATCH] feat(stdlib): new modules + eslint/tsconfig migration - Add array/async/etc. modules and type tests; migrate to eslint flat config and composite tsconfig (vitest typecheck enabled). - Fix PubSub.emit to snapshot listeners before iterating (stable EventEmitter semantics; avoids invoking listeners added during the same emit). --- core/stdlib/README.md | 29 +- core/stdlib/eslint.config.ts | 3 + core/stdlib/oxlint.config.ts | 4 - core/stdlib/package.json | 12 +- core/stdlib/src/arrays/cluster/index.test.ts | 11 +- core/stdlib/src/arrays/first/index.test.ts | 9 +- core/stdlib/src/arrays/first/index.ts | 3 +- .../stdlib/src/arrays/groupBy/index.test-d.ts | 16 + core/stdlib/src/arrays/groupBy/index.test.ts | 48 ++ core/stdlib/src/arrays/groupBy/index.ts | 36 ++ core/stdlib/src/arrays/index.ts | 5 + core/stdlib/src/arrays/last/index.test.ts | 8 +- core/stdlib/src/arrays/last/index.ts | 3 +- .../src/arrays/partition/index.test-d.ts | 17 + .../stdlib/src/arrays/partition/index.test.ts | 29 ++ core/stdlib/src/arrays/partition/index.ts | 43 ++ core/stdlib/src/arrays/range/index.test.ts | 47 ++ core/stdlib/src/arrays/range/index.ts | 41 ++ core/stdlib/src/arrays/sum/index.test.ts | 2 +- .../stdlib/src/arrays/toArray/index.test-d.ts | 17 + core/stdlib/src/arrays/toArray/index.test.ts | 26 ++ core/stdlib/src/arrays/toArray/index.ts | 25 + core/stdlib/src/arrays/unique/index.test.ts | 26 +- core/stdlib/src/arrays/unique/index.ts | 17 +- core/stdlib/src/arrays/zip/index.test-d.ts | 27 ++ core/stdlib/src/arrays/zip/index.test.ts | 35 ++ core/stdlib/src/arrays/zip/index.ts | 35 ++ core/stdlib/src/async/index.ts | 1 + core/stdlib/src/async/pool/index.test.ts | 438 ++++++++++++++++++ core/stdlib/src/async/pool/index.ts | 241 ++++++++++ core/stdlib/src/async/retry/index.test.ts | 115 +++-- core/stdlib/src/async/retry/index.ts | 10 +- core/stdlib/src/async/sleep/index.test.ts | 35 +- core/stdlib/src/async/tryIt/index.test-d.ts | 37 ++ core/stdlib/src/async/tryIt/index.test.ts | 31 +- core/stdlib/src/async/tryIt/index.ts | 27 +- core/stdlib/src/bits/flags/index.test.ts | 15 +- core/stdlib/src/bits/helpers/index.test.ts | 13 +- core/stdlib/src/bits/helpers/index.ts | 2 +- core/stdlib/src/bits/index.ts | 2 + core/stdlib/src/bits/vector/index.test.ts | 72 ++- core/stdlib/src/bits/vector/index.ts | 71 +++ .../src/collections/get/index.test-d.ts | 53 +++ core/stdlib/src/collections/get/index.test.ts | 61 +++ core/stdlib/src/collections/get/index.ts | 38 +- .../src/functions/compose/index.test-d.ts | 18 + .../src/functions/compose/index.test.ts | 41 ++ core/stdlib/src/functions/compose/index.ts | 38 ++ .../src/functions/debounce/index.test.ts | 179 +++++++ core/stdlib/src/functions/debounce/index.ts | 130 ++++++ core/stdlib/src/functions/index.ts | 6 + .../src/functions/memoize/index.test.ts | 71 +++ core/stdlib/src/functions/memoize/index.ts | 50 ++ core/stdlib/src/functions/once/index.test.ts | 55 +++ core/stdlib/src/functions/once/index.ts | 47 ++ .../stdlib/src/functions/pipe/index.test-d.ts | 24 + core/stdlib/src/functions/pipe/index.test.ts | 34 ++ core/stdlib/src/functions/pipe/index.ts | 36 ++ .../src/functions/throttle/index.test.ts | 158 +++++++ core/stdlib/src/functions/throttle/index.ts | 106 +++++ core/stdlib/src/index.ts | 2 + .../stdlib/src/math/basic/clamp/index.test.ts | 2 +- core/stdlib/src/math/basic/lerp/index.test.ts | 12 +- .../stdlib/src/math/basic/remap/index.test.ts | 8 + core/stdlib/src/math/basic/remap/index.ts | 4 +- .../src/math/bigint/clampBigInt/index.test.ts | 2 +- .../src/math/bigint/lerpBigInt/index.test.ts | 37 +- .../src/math/bigint/maxBigInt/index.test.ts | 2 +- .../src/math/bigint/minBigInt/index.test.ts | 2 +- .../src/math/bigint/remapBigInt/index.test.ts | 10 + .../src/math/bigint/remapBigInt/index.ts | 5 +- core/stdlib/src/objects/omit/index.test-d.ts | 14 + core/stdlib/src/objects/omit/index.test.ts | 2 +- core/stdlib/src/objects/omit/index.ts | 20 +- core/stdlib/src/objects/pick/index.test-d.ts | 14 + core/stdlib/src/objects/pick/index.test.ts | 8 +- .../patterns/behavioral/Command/index.test.ts | 45 +- .../patterns/behavioral/PubSub/index.test.ts | 52 ++- .../src/patterns/behavioral/PubSub/index.ts | 6 +- .../patterns/behavioral/StateMachine/async.ts | 2 +- .../behavioral/StateMachine/index.test-d.ts | 24 + .../behavioral/StateMachine/index.test.ts | 23 +- .../patterns/behavioral/StateMachine/sync.ts | 2 +- .../src/structs/BinaryHeap/index.test.ts | 48 ++ core/stdlib/src/structs/BinaryHeap/index.ts | 12 +- .../src/structs/CircularBuffer/index.test.ts | 2 +- .../src/structs/CircularBuffer/index.ts | 20 +- core/stdlib/src/structs/Deque/index.test.ts | 18 +- core/stdlib/src/structs/Deque/index.ts | 5 +- .../src/structs/LinkedList/index.test.ts | 18 +- .../src/structs/PriorityQueue/index.test.ts | 12 + core/stdlib/src/structs/Queue/index.test.ts | 15 +- core/stdlib/src/structs/Stack/index.test.ts | 48 +- core/stdlib/src/structs/Stack/index.ts | 33 +- core/stdlib/src/sync/mutex/index.test.ts | 19 +- core/stdlib/src/sync/mutex/index.ts | 11 +- core/stdlib/src/text/index.ts | 3 +- .../text/levenshtein-distance/index.test.ts | 11 + .../src/text/levenshtein-distance/index.ts | 44 +- core/stdlib/src/text/template/index.test-d.ts | 37 +- core/stdlib/src/text/template/index.test.ts | 25 +- core/stdlib/src/text/template/index.ts | 52 ++- .../src/text/trigram-distance/index.test.ts | 9 +- core/stdlib/src/types/js/casts.test.ts | 2 +- core/stdlib/src/types/js/complex.test.ts | 16 +- core/stdlib/src/types/js/primitives.test.ts | 9 +- core/stdlib/src/types/js/primitives.ts | 8 +- .../stdlib/src/types/ts/collections.test-d.ts | 28 +- core/stdlib/src/types/ts/collections.ts | 16 + core/stdlib/src/types/ts/string.test-d.ts | 25 +- core/stdlib/src/types/ts/string.ts | 12 +- core/stdlib/src/types/ts/union.test-d.ts | 8 + core/stdlib/src/utils/index.test.ts | 28 ++ core/stdlib/tsconfig.json | 8 +- core/stdlib/tsconfig.node.json | 8 + core/stdlib/tsconfig.src.json | 9 + core/stdlib/tsdown.config.ts | 1 + core/stdlib/vitest.config.ts | 4 + 118 files changed, 3511 insertions(+), 240 deletions(-) create mode 100644 core/stdlib/eslint.config.ts delete mode 100644 core/stdlib/oxlint.config.ts create mode 100644 core/stdlib/src/arrays/groupBy/index.test-d.ts create mode 100644 core/stdlib/src/arrays/groupBy/index.test.ts create mode 100644 core/stdlib/src/arrays/groupBy/index.ts create mode 100644 core/stdlib/src/arrays/partition/index.test-d.ts create mode 100644 core/stdlib/src/arrays/partition/index.test.ts create mode 100644 core/stdlib/src/arrays/partition/index.ts create mode 100644 core/stdlib/src/arrays/range/index.test.ts create mode 100644 core/stdlib/src/arrays/range/index.ts create mode 100644 core/stdlib/src/arrays/toArray/index.test-d.ts create mode 100644 core/stdlib/src/arrays/toArray/index.test.ts create mode 100644 core/stdlib/src/arrays/toArray/index.ts create mode 100644 core/stdlib/src/arrays/zip/index.test-d.ts create mode 100644 core/stdlib/src/arrays/zip/index.test.ts create mode 100644 core/stdlib/src/arrays/zip/index.ts create mode 100644 core/stdlib/src/async/pool/index.test.ts create mode 100644 core/stdlib/src/async/tryIt/index.test-d.ts create mode 100644 core/stdlib/src/collections/get/index.test-d.ts create mode 100644 core/stdlib/src/collections/get/index.test.ts create mode 100644 core/stdlib/src/functions/compose/index.test-d.ts create mode 100644 core/stdlib/src/functions/compose/index.test.ts create mode 100644 core/stdlib/src/functions/compose/index.ts create mode 100644 core/stdlib/src/functions/debounce/index.test.ts create mode 100644 core/stdlib/src/functions/debounce/index.ts create mode 100644 core/stdlib/src/functions/index.ts create mode 100644 core/stdlib/src/functions/memoize/index.test.ts create mode 100644 core/stdlib/src/functions/memoize/index.ts create mode 100644 core/stdlib/src/functions/once/index.test.ts create mode 100644 core/stdlib/src/functions/once/index.ts create mode 100644 core/stdlib/src/functions/pipe/index.test-d.ts create mode 100644 core/stdlib/src/functions/pipe/index.test.ts create mode 100644 core/stdlib/src/functions/pipe/index.ts create mode 100644 core/stdlib/src/functions/throttle/index.test.ts create mode 100644 core/stdlib/src/functions/throttle/index.ts create mode 100644 core/stdlib/src/objects/omit/index.test-d.ts create mode 100644 core/stdlib/src/objects/pick/index.test-d.ts create mode 100644 core/stdlib/src/patterns/behavioral/StateMachine/index.test-d.ts create mode 100644 core/stdlib/src/utils/index.test.ts create mode 100644 core/stdlib/tsconfig.node.json create mode 100644 core/stdlib/tsconfig.src.json diff --git a/core/stdlib/README.md b/core/stdlib/README.md index 609ce8f..68f6900 100644 --- a/core/stdlib/README.md +++ b/core/stdlib/README.md @@ -10,20 +10,21 @@ pnpm install @robonen/stdlib ## Modules -| Module | Utilities | -| --------------- | --------------------------------------------------------------- | -| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` | -| **async** | `sleep`, `tryIt` | -| **bits** | `flags` | -| **collections** | `get` | -| **math** | `clamp`, `lerp`, `remap` + BigInt variants | -| **objects** | `omit`, `pick` | -| **patterns** | `pubsub` | -| **structs** | `stack` | -| **sync** | `mutex` | -| **text** | `levenshteinDistance`, `trigramDistance` | -| **types** | JS & TS type utilities | -| **utils** | `timestamp`, `noop` | +| Module | Utilities | +| --------------- | ---------------------------------------------------------------------------------- | +| **arrays** | `cluster`, `first`, `groupBy`, `last`, `partition`, `range`, `sum`, `toArray`, `unique`, `zip` | +| **async** | `pool`, `retry`, `sleep`, `tryIt` | +| **bits** | `flagsGenerator`, `and`, `or`, `not`, `has`, `is`, `unset`, `toggle`, `BitVector` | +| **collections** | `get` | +| **functions** | `compose`, `debounce`, `memoize`, `once`, `pipe`, `throttle` | +| **math** | `clamp`, `lerp`, `remap` + BigInt variants | +| **objects** | `omit`, `pick` | +| **patterns** | `Command`, `PubSub`, `StateMachine` | +| **structs** | `BinaryHeap`, `CircularBuffer`, `Deque`, `LinkedList`, `PriorityQueue`, `Queue`, `Stack` | +| **sync** | `mutex` | +| **text** | `levenshteinDistance`, `trigramDistance`, `templateObject` | +| **types** | JS & TS type utilities | +| **utils** | `timestamp`, `noop` | ## Usage diff --git a/core/stdlib/eslint.config.ts b/core/stdlib/eslint.config.ts new file mode 100644 index 0000000..e703f35 --- /dev/null +++ b/core/stdlib/eslint.config.ts @@ -0,0 +1,3 @@ +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic); diff --git a/core/stdlib/oxlint.config.ts b/core/stdlib/oxlint.config.ts deleted file mode 100644 index 8d6f15a..0000000 --- a/core/stdlib/oxlint.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig } from 'oxlint'; -import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint'; - -export default defineConfig(compose(base, typescript, imports, stylistic)); diff --git a/core/stdlib/package.json b/core/stdlib/package.json index 9e1ec98..d653ece 100644 --- a/core/stdlib/package.json +++ b/core/stdlib/package.json @@ -39,18 +39,18 @@ } }, "scripts": { - "lint:check": "oxlint -c oxlint.config.ts", - "lint:fix": "oxlint -c oxlint.config.ts --fix", + "lint:check": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest run", "dev": "vitest dev", "build": "tsdown" }, "devDependencies": { - "@robonen/oxlint": "workspace:*", + "@robonen/eslint": "workspace:*", "@robonen/tsconfig": "workspace:*", "@robonen/tsdown": "workspace:*", - "@stylistic/eslint-plugin": "catalog:", - "oxlint": "catalog:", - "tsdown": "catalog:" + "eslint": "catalog:", + "tsdown": "catalog:", + "typescript": "^5.9.3" } } diff --git a/core/stdlib/src/arrays/cluster/index.test.ts b/core/stdlib/src/arrays/cluster/index.test.ts index 2232109..891bb6f 100644 --- a/core/stdlib/src/arrays/cluster/index.test.ts +++ b/core/stdlib/src/arrays/cluster/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { cluster } from '.'; describe('cluster', () => { @@ -37,4 +37,13 @@ describe('cluster', () => { expect(result).toEqual([]); }); + + it('not mutate the input and produce copied sub-arrays', () => { + const input = [1, 2, 3, 4]; + const result = cluster(input, 2); + + result[0]!.push(99); + + expect(input).toEqual([1, 2, 3, 4]); + }); }); diff --git a/core/stdlib/src/arrays/first/index.test.ts b/core/stdlib/src/arrays/first/index.test.ts index c6afd5f..26a5fbe 100644 --- a/core/stdlib/src/arrays/first/index.test.ts +++ b/core/stdlib/src/arrays/first/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { first } from '.'; describe('first', () => { @@ -20,4 +20,11 @@ describe('first', () => { expect(first([1, 2, 3], 42)).toBe(1); expect(first(['a', 'b', 'c'], 'default')).toBe('a'); }); + + it('preserve a present null/undefined/falsy first element (not the default)', () => { + expect(first([null as number | null], 42)).toBeNull(); + expect(first([undefined, 2], 42)).toBeUndefined(); + expect(first([0], 99)).toBe(0); + expect(first([''], 'x')).toBe(''); + }); }); diff --git a/core/stdlib/src/arrays/first/index.ts b/core/stdlib/src/arrays/first/index.ts index 00e16cb..97a5cf5 100644 --- a/core/stdlib/src/arrays/first/index.ts +++ b/core/stdlib/src/arrays/first/index.ts @@ -16,5 +16,6 @@ * @since 0.0.3 */ export function first(arr: Value[], defaultValue?: Value) { - return arr[0] ?? defaultValue; + // Branch on length, not nullishness, so a present null/undefined first element is preserved. + return arr.length > 0 ? arr[0]! : defaultValue; } diff --git a/core/stdlib/src/arrays/groupBy/index.test-d.ts b/core/stdlib/src/arrays/groupBy/index.test-d.ts new file mode 100644 index 0000000..62257fe --- /dev/null +++ b/core/stdlib/src/arrays/groupBy/index.test-d.ts @@ -0,0 +1,16 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { groupBy } from '.'; + +describe('groupBy', () => { + it('keys the record by the union returned by the key function', () => { + const result = groupBy([1, 2, 3], n => (n % 2 === 0 ? 'even' : 'odd')); + + expectTypeOf(result).toEqualTypeOf>(); + }); + + it('preserves the element type in the grouped arrays', () => { + const result = groupBy([{ id: 1 }], item => item.id); + + expectTypeOf(result).toEqualTypeOf>>(); + }); +}); diff --git a/core/stdlib/src/arrays/groupBy/index.test.ts b/core/stdlib/src/arrays/groupBy/index.test.ts new file mode 100644 index 0000000..84f4e47 --- /dev/null +++ b/core/stdlib/src/arrays/groupBy/index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { groupBy } from '.'; + +describe('groupBy', () => { + it('group by a string key', () => { + const result = groupBy([1, 2, 3, 4], n => (n % 2 === 0 ? 'even' : 'odd')); + + expect(result).toEqual({ odd: [1, 3], even: [2, 4] }); + }); + + it('group objects by a property', () => { + const input = [ + { type: 'a', v: 1 }, + { type: 'b', v: 2 }, + { type: 'a', v: 3 }, + ]; + + expect(groupBy(input, item => item.type)).toEqual({ + a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }], + b: [{ type: 'b', v: 2 }], + }); + }); + + it('pass the index to the key function', () => { + const result = groupBy(['a', 'b', 'c', 'd'], (_, i) => i % 2); + + expect(result).toEqual({ 0: ['a', 'c'], 1: ['b', 'd'] }); + }); + + it('return an empty object for an empty array', () => { + expect(groupBy([], () => 'x')).toEqual({}); + }); + + it('push elements by reference (no cloning)', () => { + const item = { id: 1 }; + const result = groupBy([item], x => x.id); + + expect(result[1]![0]).toBe(item); + }); + + it('handle __proto__ and other Object.prototype keys as ordinary groups', () => { + const proto = groupBy(['a', 'b'], (): string => '__proto__'); + expect(proto.__proto__).toEqual(['a', 'b']); + + const ctor = groupBy(['x'], (): string => 'constructor'); + expect(ctor.constructor).toEqual(['x']); + }); +}); diff --git a/core/stdlib/src/arrays/groupBy/index.ts b/core/stdlib/src/arrays/groupBy/index.ts new file mode 100644 index 0000000..cd255f1 --- /dev/null +++ b/core/stdlib/src/arrays/groupBy/index.ts @@ -0,0 +1,36 @@ +/** + * @name groupBy + * @category Arrays + * @description Groups the elements of an array by the key returned by `getKey` + * + * @param {Value[]} array - The array to group + * @param {(item: Value, index: number) => Key} getKey - Maps an element to its group key + * @returns {Record} An object of arrays, keyed by group + * + * @example + * groupBy([1, 2, 3, 4], n => (n % 2 === 0 ? 'even' : 'odd')) + * // => { odd: [1, 3], even: [2, 4] } + * + * @example + * groupBy([{ type: 'a', v: 1 }, { type: 'b', v: 2 }, { type: 'a', v: 3 }], item => item.type) + * // => { a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }], b: [{ type: 'b', v: 2 }] } + * + * @since 0.0.10 + */ +export function groupBy( + array: Value[], + getKey: (item: Value, index: number) => Key, +): Record { + // Null-prototype object so keys like '__proto__'/'constructor' become ordinary own keys + // instead of colliding with Object.prototype (which would throw on .push or pollute). + const result = Object.create(null) as Record; + + for (let i = 0; i < array.length; i++) { + const item = array[i]!; + const key = getKey(item, i); + + (result[key] ??= []).push(item); + } + + return result; +} diff --git a/core/stdlib/src/arrays/index.ts b/core/stdlib/src/arrays/index.ts index 8a1ce5f..9f7f897 100644 --- a/core/stdlib/src/arrays/index.ts +++ b/core/stdlib/src/arrays/index.ts @@ -1,5 +1,10 @@ export * from './cluster'; export * from './first'; +export * from './groupBy'; export * from './last'; +export * from './partition'; +export * from './range'; export * from './sum'; +export * from './toArray'; export * from './unique'; +export * from './zip'; diff --git a/core/stdlib/src/arrays/last/index.test.ts b/core/stdlib/src/arrays/last/index.test.ts index 30bb6ba..7aadf7c 100644 --- a/core/stdlib/src/arrays/last/index.test.ts +++ b/core/stdlib/src/arrays/last/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { last } from '.'; describe('last', () => { @@ -20,4 +20,10 @@ describe('last', () => { expect(last([1, 2, 3], 42)).toBe(3); expect(last(['a', 'b', 'c'], 'default')).toBe('c'); }); + + it('preserve a present null/undefined/falsy last element (not the default)', () => { + expect(last([1, null as number | null], 42)).toBeNull(); + expect(last([1, undefined], 42)).toBeUndefined(); + expect(last([0], 99)).toBe(0); + }); }); diff --git a/core/stdlib/src/arrays/last/index.ts b/core/stdlib/src/arrays/last/index.ts index 5b92ff1..d7cb332 100644 --- a/core/stdlib/src/arrays/last/index.ts +++ b/core/stdlib/src/arrays/last/index.ts @@ -16,5 +16,6 @@ * @since 0.0.3 */ export function last(arr: Value[], defaultValue?: Value) { - return arr[arr.length - 1] ?? defaultValue; + // Branch on length, not nullishness, so a present null/undefined last element is preserved. + return arr.length > 0 ? arr[arr.length - 1]! : defaultValue; } diff --git a/core/stdlib/src/arrays/partition/index.test-d.ts b/core/stdlib/src/arrays/partition/index.test-d.ts new file mode 100644 index 0000000..669ac66 --- /dev/null +++ b/core/stdlib/src/arrays/partition/index.test-d.ts @@ -0,0 +1,17 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { partition } from '.'; + +describe('partition', () => { + it('returns a tuple of two arrays of the element type', () => { + const result = partition([1, 2, 3], n => n > 1); + + expectTypeOf(result).toEqualTypeOf<[number[], number[]]>(); + }); + + it('narrows both partitions with a type guard', () => { + const mixed: Array = ['a', 1]; + const result = partition(mixed, (v): v is string => typeof v === 'string'); + + expectTypeOf(result).toEqualTypeOf<[string[], number[]]>(); + }); +}); diff --git a/core/stdlib/src/arrays/partition/index.test.ts b/core/stdlib/src/arrays/partition/index.test.ts new file mode 100644 index 0000000..adb4113 --- /dev/null +++ b/core/stdlib/src/arrays/partition/index.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { partition } from '.'; + +describe('partition', () => { + it('split by a predicate into [matching, rest]', () => { + expect(partition([1, 2, 3, 4], n => n % 2 === 0)).toEqual([[2, 4], [1, 3]]); + }); + + it('preserve order within each partition', () => { + expect(partition([5, 1, 4, 2, 3], n => n > 2)).toEqual([[5, 4, 3], [1, 2]]); + }); + + it('pass the index to the predicate', () => { + expect(partition(['a', 'b', 'c', 'd'], (_, i) => i < 2)).toEqual([['a', 'b'], ['c', 'd']]); + }); + + it('handle all-matching and none-matching', () => { + expect(partition([1, 2, 3], () => true)).toEqual([[1, 2, 3], []]); + expect(partition([1, 2, 3], () => false)).toEqual([[], [1, 2, 3]]); + }); + + it('work with a type guard', () => { + const mixed: Array = ['a', 1, 'b', 2]; + const [strings, numbers] = partition(mixed, (v): v is string => typeof v === 'string'); + + expect(strings).toEqual(['a', 'b']); + expect(numbers).toEqual([1, 2]); + }); +}); diff --git a/core/stdlib/src/arrays/partition/index.ts b/core/stdlib/src/arrays/partition/index.ts new file mode 100644 index 0000000..3912462 --- /dev/null +++ b/core/stdlib/src/arrays/partition/index.ts @@ -0,0 +1,43 @@ +/** + * @name partition + * @category Arrays + * @description Splits an array into two: elements that satisfy the predicate and those that do not + * + * @param {Value[]} array - The array to split + * @param {(item: Value, index: number) => boolean} predicate - Decides which partition an element belongs to + * @returns {[Value[], Value[]]} A tuple of `[matching, rest]` + * + * @example + * partition([1, 2, 3, 4], n => n % 2 === 0) // => [[2, 4], [1, 3]] + * + * @example + * const [strings, others] = partition(mixed, (v): v is string => typeof v === 'string'); + * + * @since 0.0.10 + */ +export function partition( + array: Value[], + predicate: (item: Value, index: number) => item is Matched, +): [Matched[], Array>]; +export function partition( + array: Value[], + predicate: (item: Value, index: number) => boolean, +): [Value[], Value[]]; +export function partition( + array: Value[], + predicate: (item: Value, index: number) => boolean, +): [Value[], Value[]] { + const matched: Value[] = []; + const rest: Value[] = []; + + for (let i = 0; i < array.length; i++) { + const item = array[i]!; + + if (predicate(item, i)) + matched.push(item); + else + rest.push(item); + } + + return [matched, rest]; +} diff --git a/core/stdlib/src/arrays/range/index.test.ts b/core/stdlib/src/arrays/range/index.test.ts new file mode 100644 index 0000000..eea1591 --- /dev/null +++ b/core/stdlib/src/arrays/range/index.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { range } from '.'; + +describe('range', () => { + it('generate 0..stop with a single argument', () => { + expect(range(4)).toEqual([0, 1, 2, 3]); + }); + + it('generate start..stop', () => { + expect(range(1, 5)).toEqual([1, 2, 3, 4]); + }); + + it('respect a positive step', () => { + expect(range(0, 10, 2)).toEqual([0, 2, 4, 6, 8]); + }); + + it('support a negative step', () => { + expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]); + }); + + it('return an empty array for an empty range', () => { + expect(range(0)).toEqual([]); + expect(range(5, 5)).toEqual([]); + expect(range(0, 5, -1)).toEqual([]); + }); + + it('return an empty array for a zero step', () => { + expect(range(0, 5, 0)).toEqual([]); + }); + + it('handle non-integer steps', () => { + expect(range(0, 1, 0.25)).toEqual([0, 0.25, 0.5, 0.75]); + }); + + it('span zero with a negative start', () => { + expect(range(-2, 3)).toEqual([-2, -1, 0, 1, 2]); + expect(range(-3, 3, 2)).toEqual([-3, -1, 1]); + }); + + it('handle a non-integer step that is not exactly representable', () => { + const result = range(0, 1, 0.1); + + expect(result).toHaveLength(10); + expect(result[0]).toBe(0); + expect(result.at(-1)).toBeCloseTo(0.9, 10); + }); +}); diff --git a/core/stdlib/src/arrays/range/index.ts b/core/stdlib/src/arrays/range/index.ts new file mode 100644 index 0000000..532aab3 --- /dev/null +++ b/core/stdlib/src/arrays/range/index.ts @@ -0,0 +1,41 @@ +/** + * @name range + * @category Arrays + * @description Generates an array of numbers from `start` (inclusive) to `stop` (exclusive) + * + * @param {number} startOrStop - The start of the range, or the stop when called with one argument + * @param {number} [stop] - The end of the range (exclusive) + * @param {number} [step] - The increment between values; supports negative steps. Default `1` + * @returns {number[]} The generated range + * + * @example + * range(4) // => [0, 1, 2, 3] + * + * @example + * range(1, 5) // => [1, 2, 3, 4] + * + * @example + * range(0, 10, 2) // => [0, 2, 4, 6, 8] + * + * @example + * range(5, 0, -1) // => [5, 4, 3, 2, 1] + * + * @since 0.0.10 + */ +export function range(stop: number): number[]; +export function range(start: number, stop: number, step?: number): number[]; +export function range(startOrStop: number, stop?: number, step = 1): number[] { + let start = startOrStop; + + if (stop === undefined) { + start = 0; + stop = startOrStop; + } + + if (step === 0) + return []; + + const length = Math.max(Math.ceil((stop - start) / step), 0); + + return Array.from({ length }, (_, i) => start + i * step); +} diff --git a/core/stdlib/src/arrays/sum/index.test.ts b/core/stdlib/src/arrays/sum/index.test.ts index b7570d9..230a5ac 100644 --- a/core/stdlib/src/arrays/sum/index.test.ts +++ b/core/stdlib/src/arrays/sum/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { sum } from '.'; describe('sum', () => { diff --git a/core/stdlib/src/arrays/toArray/index.test-d.ts b/core/stdlib/src/arrays/toArray/index.test-d.ts new file mode 100644 index 0000000..950edff --- /dev/null +++ b/core/stdlib/src/arrays/toArray/index.test-d.ts @@ -0,0 +1,17 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { toArray } from '.'; + +describe('toArray', () => { + it('wraps a single value into an array of that type', () => { + expectTypeOf(toArray(1)).toEqualTypeOf(); + }); + + it('returns an array input as the same element type', () => { + expectTypeOf(toArray([1, 2, 3])).toEqualTypeOf(); + }); + + it('returns the element array type for a nullish input', () => { + expectTypeOf(toArray(undefined)).toEqualTypeOf(); + expectTypeOf(toArray(null)).toEqualTypeOf(); + }); +}); diff --git a/core/stdlib/src/arrays/toArray/index.test.ts b/core/stdlib/src/arrays/toArray/index.test.ts new file mode 100644 index 0000000..069f53f --- /dev/null +++ b/core/stdlib/src/arrays/toArray/index.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { toArray } from '.'; + +describe('toArray', () => { + it('wrap a single value into an array', () => { + expect(toArray(1)).toEqual([1]); + expect(toArray('a')).toEqual(['a']); + expect(toArray(false)).toEqual([false]); + expect(toArray(0)).toEqual([0]); + }); + + it('return arrays as-is (same reference, no copy)', () => { + const arr = [1, 2, 3]; + expect(toArray(arr)).toBe(arr); + }); + + it('treat null and undefined as empty', () => { + expect(toArray(undefined)).toEqual([]); + expect(toArray(null)).toEqual([]); + }); + + it('preserve empty arrays', () => { + const empty: number[] = []; + expect(toArray(empty)).toBe(empty); + }); +}); diff --git a/core/stdlib/src/arrays/toArray/index.ts b/core/stdlib/src/arrays/toArray/index.ts new file mode 100644 index 0000000..bf223c0 --- /dev/null +++ b/core/stdlib/src/arrays/toArray/index.ts @@ -0,0 +1,25 @@ +import type { Arrayable } from '../../types'; + +/** + * @name toArray + * @category Arrays + * @description Normalize an `Arrayable` value into an array. `undefined` and `null` become an empty array; a single value is wrapped; arrays are returned as-is (no copy). + * + * @param {Arrayable | null | undefined} value The value to normalize + * @returns {Value[]} The value as an array + * + * @example + * toArray(1) // => [1] + * + * @example + * toArray([1, 2]) // => [1, 2] + * + * @example + * toArray(undefined) // => [] + * + * @since 0.0.10 + */ +export function toArray(value: Arrayable | null | undefined): Value[] { + if (value === null || value === undefined) return []; + return Array.isArray(value) ? value : [value]; +} diff --git a/core/stdlib/src/arrays/unique/index.test.ts b/core/stdlib/src/arrays/unique/index.test.ts index 2ad9108..656a39b 100644 --- a/core/stdlib/src/arrays/unique/index.test.ts +++ b/core/stdlib/src/arrays/unique/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { unique } from '.'; describe('unique', () => { @@ -42,4 +42,28 @@ describe('unique', () => { expect(result).toEqual([]); }); + + it('keep the last value per extracted key (last-write-wins, first-seen order)', () => { + const result = unique( + [{ id: 1, v: 'a' }, { id: 2, v: 'b' }, { id: 1, v: 'c' }], + item => item.id, + ); + + expect(result).toEqual([{ id: 1, v: 'c' }, { id: 2, v: 'b' }]); + }); + + it('return a new array and not mutate the input', () => { + const input = [1, 2, 2, 3]; + const result = unique(input); + + expect(result).not.toBe(input); + expect(input).toEqual([1, 2, 2, 3]); + }); + + it('preserve element identity for object values', () => { + const a = { id: 1 }; + const result = unique([a], item => item.id); + + expect(result[0]).toBe(a); + }); }); diff --git a/core/stdlib/src/arrays/unique/index.ts b/core/stdlib/src/arrays/unique/index.ts index 9c54132..a490b96 100644 --- a/core/stdlib/src/arrays/unique/index.ts +++ b/core/stdlib/src/arrays/unique/index.ts @@ -14,20 +14,23 @@ export type Extractor = (value: Value) => Key; * unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6] * * @example - * unique([{ id: 1 }, { id: 2 }, { id: 1 }], (a, b) => a.id === b.id) //=> [{ id: 1 }, { id: 2 }] + * unique([{ id: 1 }, { id: 2 }, { id: 1 }], value => value.id) //=> [{ id: 1 }, { id: 2 }] * * @since 0.0.3 */ export function unique( array: Value[], extractor?: Extractor, -) { +): Value[] { + // Fast path: a plain Set is leaner than a Map storing each value as both key and value. + if (!extractor) + return [...new Set(array)]; + + // Last-write-wins per extracted key, preserving first-seen insertion order. const values = new Map(); - for (const value of array) { - const key = extractor ? extractor(value) : value as any; - values.set(key, value); - } + for (const value of array) + values.set(extractor(value), value); - return Array.from(values.values()); + return [...values.values()]; } diff --git a/core/stdlib/src/arrays/zip/index.test-d.ts b/core/stdlib/src/arrays/zip/index.test-d.ts new file mode 100644 index 0000000..850e4ae --- /dev/null +++ b/core/stdlib/src/arrays/zip/index.test-d.ts @@ -0,0 +1,27 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { zip } from '.'; + +describe('zip', () => { + it('produces tuples of the element types (two arrays)', () => { + const result = zip([1, 2], ['a', 'b']); + + expectTypeOf(result).toEqualTypeOf>(); + }); + + it('produces tuples of the element types (three arrays)', () => { + const result = zip([1], ['a'], [true]); + + expectTypeOf(result).toEqualTypeOf>(); + }); + + it('zips a single array into singleton tuples', () => { + const result = zip([1, 2, 3]); + + expectTypeOf(result).toEqualTypeOf>(); + }); + + it('produces tuples for four and five arrays', () => { + expectTypeOf(zip([1], ['a'], [true], [9])).toEqualTypeOf>(); + expectTypeOf(zip([1], ['a'], [true], [9], ['x'])).toEqualTypeOf>(); + }); +}); diff --git a/core/stdlib/src/arrays/zip/index.test.ts b/core/stdlib/src/arrays/zip/index.test.ts new file mode 100644 index 0000000..a2b4a4f --- /dev/null +++ b/core/stdlib/src/arrays/zip/index.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { zip } from '.'; + +describe('zip', () => { + it('zip two arrays of equal length', () => { + expect(zip([1, 2, 3], ['a', 'b', 'c'])).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]); + }); + + it('zip three arrays', () => { + expect(zip([1, 2], ['a', 'b'], [true, false])).toEqual([[1, 'a', true], [2, 'b', false]]); + }); + + it('truncate to the shortest array', () => { + expect(zip([1, 2, 3], ['a'])).toEqual([[1, 'a']]); + expect(zip([1], ['a', 'b', 'c'])).toEqual([[1, 'a']]); + }); + + it('zip a single array into singletons', () => { + expect(zip([1, 2, 3])).toEqual([[1], [2], [3]]); + }); + + it('return an empty array when an input is empty', () => { + expect(zip([1, 2, 3], [])).toEqual([]); + }); + + it('return an empty array with no arguments', () => { + expect((zip as () => unknown[])()).toEqual([]); + }); + + it('zip four and five arrays', () => { + expect(zip([1], ['a'], [true], [9])).toEqual([[1, 'a', true, 9]]); + expect(zip([1, 2], ['a', 'b'], [true, false], [9, 8], ['x', 'y'])) + .toEqual([[1, 'a', true, 9, 'x'], [2, 'b', false, 8, 'y']]); + }); +}); diff --git a/core/stdlib/src/arrays/zip/index.ts b/core/stdlib/src/arrays/zip/index.ts new file mode 100644 index 0000000..82abc8f --- /dev/null +++ b/core/stdlib/src/arrays/zip/index.ts @@ -0,0 +1,35 @@ +/** + * @name zip + * @category Arrays + * @description Combines several arrays into an array of tuples, stopping at the shortest input + * + * @param {...Array} arrays - The arrays to zip together + * @returns {Array} An array of tuples; its length equals the shortest input array + * + * @example + * zip([1, 2, 3], ['a', 'b', 'c']) // => [[1, 'a'], [2, 'b'], [3, 'c']] + * + * @example + * zip([1, 2], ['a', 'b'], [true, false]) // => [[1, 'a', true], [2, 'b', false]] + * + * @example + * zip([1, 2, 3], ['a']) // => [[1, 'a']] (truncated to the shortest) + * + * @since 0.0.10 + */ +export function zip(a: A[]): Array<[A]>; +export function zip(a: A[], b: B[]): Array<[A, B]>; +export function zip(a: A[], b: B[], c: C[]): Array<[A, B, C]>; +export function zip(a: A[], b: B[], c: C[], d: D[]): Array<[A, B, C, D]>; +export function zip(a: A[], b: B[], c: C[], d: D[], e: E[]): Array<[A, B, C, D, E]>; +export function zip(...arrays: any[][]): any[][] { + if (arrays.length === 0) + return []; + + let length = arrays[0]!.length; + + for (let i = 1; i < arrays.length; i++) + length = Math.min(length, arrays[i]!.length); + + return Array.from({ length }, (_, i) => arrays.map(array => array[i])); +} diff --git a/core/stdlib/src/async/index.ts b/core/stdlib/src/async/index.ts index d4e70f3..5641f3b 100644 --- a/core/stdlib/src/async/index.ts +++ b/core/stdlib/src/async/index.ts @@ -1,3 +1,4 @@ +export * from './pool'; export * from './retry'; export * from './sleep'; export * from './tryIt'; diff --git a/core/stdlib/src/async/pool/index.test.ts b/core/stdlib/src/async/pool/index.test.ts new file mode 100644 index 0000000..3d68795 --- /dev/null +++ b/core/stdlib/src/async/pool/index.test.ts @@ -0,0 +1,438 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AsyncPool } from '.'; + +describe('AsyncPool', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ── Constructor / getters ──────────────────────────────────────────────── + + describe('constructor', () => { + it('default concurrency is Infinity', () => { + expect(new AsyncPool().concurrency).toBe(Infinity); + }); + + it('respects explicit concurrency', () => { + expect(new AsyncPool({ concurrency: 4 }).concurrency).toBe(4); + }); + + it('truncates float to integer', () => { + expect(new AsyncPool({ concurrency: 3.9 }).concurrency).toBe(3); + }); + + it('Infinity concurrency is preserved (not coerced to 0)', () => { + expect(new AsyncPool({ concurrency: Infinity }).concurrency).toBe(Infinity); + }); + + it('invalid values fall back to Infinity', () => { + expect(new AsyncPool({ concurrency: 0 }).concurrency).toBe(Infinity); + expect(new AsyncPool({ concurrency: -1 }).concurrency).toBe(Infinity); + }); + + it('initial size and active are 0', () => { + const pool = new AsyncPool({ concurrency: 2 }); + expect(pool.size).toBe(0); + expect(pool.active).toBe(0); + }); + }); + + // ── add() — basic execution ────────────────────────────────────────────── + + describe('add()', () => { + it('starts task immediately when under limit', () => { + const pool = new AsyncPool({ concurrency: 2 }); + const fn = vi.fn().mockResolvedValue('ok'); + pool.add(fn); + expect(fn).toHaveBeenCalledTimes(1); + expect(pool.active).toBe(1); + }); + + it('passes the pool signal to the task', () => { + const pool = new AsyncPool({ concurrency: 1 }); + let receivedSignal: AbortSignal | undefined; + pool.add((signal) => { + receivedSignal = signal; + return Promise.resolve(); + }); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + }); + + it('returns the task result', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + const result = await pool.add(() => Promise.resolve(42)); + expect(result).toBe(42); + }); + + it('propagates task rejection', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + const error = new Error('boom'); + await expect(pool.add(() => Promise.reject(error))).rejects.toThrow('boom'); + }); + }); + + // ── Concurrency limiting ───────────────────────────────────────────────── + + describe('concurrency limiting', () => { + it('does not exceed the concurrency limit', () => { + const pool = new AsyncPool({ concurrency: 2 }); + const fn = vi.fn(() => new Promise(() => {})); + pool.add(fn); + pool.add(fn); + pool.add(fn); // queued + expect(fn).toHaveBeenCalledTimes(2); + expect(pool.active).toBe(2); + expect(pool.size).toBe(1); + }); + + it('dequeues task when an active one completes', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + let resolve1!: () => void; + const task1 = vi.fn(() => new Promise(r => (resolve1 = r))); + const task2 = vi.fn(() => Promise.resolve()); + pool.add(task1); + pool.add(task2); + expect(task2).toHaveBeenCalledTimes(0); + resolve1(); + await Promise.resolve(); + await Promise.resolve(); // two microtask ticks for then handlers + expect(task2).toHaveBeenCalledTimes(1); + }); + + it('runs tasks in FIFO order', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + const order: number[] = []; + let unblock!: () => void; + pool.add(() => new Promise(r => (unblock = r))); + pool.add(async () => { + order.push(1); + }); + pool.add(async () => { + order.push(2); + }); + unblock(); + await pool.all(); + expect(order).toEqual([1, 2]); + }); + + it('respects concurrency across many tasks', async () => { + const pool = new AsyncPool({ concurrency: 3 }); + let concurrent = 0; + let maxConcurrent = 0; + const tasks = Array.from({ length: 9 }, () => async (_signal: AbortSignal) => { + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await Promise.resolve(); + concurrent--; + }); + await Promise.all(tasks.map(t => pool.add(t))); + expect(maxConcurrent).toBeLessThanOrEqual(3); + }); + + it('starts all tasks immediately with Infinity concurrency', () => { + const pool = new AsyncPool(); + const fn = vi.fn(() => new Promise(() => {})); + for (let i = 0; i < 10; i++) pool.add(fn); + expect(fn).toHaveBeenCalledTimes(10); + expect(pool.size).toBe(0); + }); + }); + + // ── concurrency setter ─────────────────────────────────────────────────── + + describe('concurrency setter', () => { + it('increasing concurrency starts queued tasks immediately', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + const fn = vi.fn(() => new Promise(() => {})); + pool.add(fn); + pool.add(fn); + pool.add(fn); + expect(fn).toHaveBeenCalledTimes(1); + pool.concurrency = 3; + expect(fn).toHaveBeenCalledTimes(3); + expect(pool.size).toBe(0); + }); + + it('decreasing concurrency does not stop running tasks', () => { + const pool = new AsyncPool({ concurrency: 3 }); + const fn = vi.fn(() => new Promise(() => {})); + pool.add(fn); + pool.add(fn); + pool.add(fn); + pool.concurrency = 1; + expect(pool.active).toBe(3); // already running tasks are not stopped + }); + + it('invalid value falls back to Infinity', () => { + const pool = new AsyncPool({ concurrency: 2 }); + pool.concurrency = -5; + expect(pool.concurrency).toBe(Infinity); + }); + }); + + // ── all() ──────────────────────────────────────────────────────────────── + + describe('all()', () => { + it('resolves immediately when pool is empty', async () => { + await expect(new AsyncPool().all()).resolves.toBeUndefined(); + }); + + it('waits for all tasks to complete', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + const completed: number[] = []; + pool.add(async () => { + completed.push(1); + }); + pool.add(async () => { + completed.push(2); + }); + await pool.all(); + expect(completed).toEqual([1, 2]); + }); + + it('throws AggregateError when any task fails', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + pool.add(() => Promise.reject(new Error('e1'))).catch(() => {}); + pool.add(() => Promise.reject(new Error('e2'))).catch(() => {}); + pool.add(() => Promise.resolve('ok')); + await expect(pool.all()).rejects.toBeInstanceOf(AggregateError); + }); + + it('AggregateError contains all failures', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + const e1 = new Error('e1'); + const e2 = new Error('e2'); + pool.add(() => Promise.reject(e1)).catch(() => {}); + pool.add(() => Promise.reject(e2)).catch(() => {}); + try { + await pool.all(); + } + catch (err) { + expect(err).toBeInstanceOf(AggregateError); + expect((err as AggregateError).errors).toContain(e1); + expect((err as AggregateError).errors).toContain(e2); + } + }); + + it('multiple concurrent callers share the same underlying drain', () => { + // all() wraps _drain.promise in .then() each call (different outer promises), + // but allSettled() returns the raw _drain.promise — verify that is idempotent + const pool = new AsyncPool({ concurrency: 1 }); + pool.add(() => new Promise(() => {})); + expect(pool.allSettled()).toBe(pool.allSettled()); + }); + + it('resets after each drain cycle', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + pool.add(() => Promise.resolve()); + await pool.all(); + // second batch + pool.add(() => Promise.resolve()); + await pool.all(); // should not throw or hang + expect(pool.active).toBe(0); + }); + }); + + // ── allSettled() ───────────────────────────────────────────────────────── + + describe('allSettled()', () => { + it('resolves immediately with empty array when pool is empty', async () => { + await expect(new AsyncPool().allSettled()).resolves.toEqual([]); + }); + + it('returns fulfilled results', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + pool.add(() => Promise.resolve(1)); + pool.add(() => Promise.resolve(2)); + const results = await pool.allSettled(); + expect(results).toHaveLength(2); + expect(results.every(r => r.status === 'fulfilled')).toBe(true); + }); + + it('returns mix of fulfilled and rejected', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + pool.add(() => Promise.resolve('ok')); + pool.add(() => Promise.reject(new Error('fail'))).catch(() => {}); + const results = await pool.allSettled(); + expect(results.some(r => r.status === 'fulfilled')).toBe(true); + expect(results.some(r => r.status === 'rejected')).toBe(true); + }); + + it('all() and allSettled() called simultaneously share the same promise', () => { + const pool = new AsyncPool({ concurrency: 1 }); + pool.add(() => new Promise(() => {})); + // Both chain off the same underlying _drain.promise + const a = pool.allSettled(); + const b = pool.allSettled(); + expect(a).toBe(b); + }); + + it('results are cleared after each cycle', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + pool.add(() => Promise.resolve('first')); + const r1 = await pool.allSettled(); + expect(r1).toHaveLength(1); + + pool.add(() => Promise.resolve('second')); + const r2 = await pool.allSettled(); + expect(r2).toHaveLength(1); // only new-batch results + }); + }); + + // ── size / active getters ──────────────────────────────────────────────── + + describe('size / active', () => { + it('active reflects currently running tasks', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + let resolve1!: () => void; + const p = pool.add(() => new Promise(r => (resolve1 = r))); + expect(pool.active).toBe(1); + resolve1(); + await p; + expect(pool.active).toBe(0); + }); + + it('size reflects queued count', () => { + const pool = new AsyncPool({ concurrency: 1 }); + const neverResolve = () => new Promise(() => {}); + pool.add(neverResolve); + pool.add(neverResolve); + pool.add(neverResolve); + expect(pool.active).toBe(1); + expect(pool.size).toBe(2); + }); + }); + + // ── AbortSignal / cancellation ─────────────────────────────────────────── + + describe('signal / abort', () => { + it('add() after abort rejects immediately', async () => { + const controller = new AbortController(); + const pool = new AsyncPool({ concurrency: 2, signal: controller.signal }); + controller.abort(new Error('cancelled')); + await expect(pool.add(() => Promise.resolve())).rejects.toThrow('cancelled'); + }); + + it('queued tasks are rejected on abort', async () => { + const controller = new AbortController(); + const pool = new AsyncPool({ concurrency: 1, signal: controller.signal }); + pool.add(() => new Promise(() => {})); // blocks the slot + const queued = pool.add(() => Promise.resolve('queued')); + controller.abort(); + await expect(queued).rejects.toBeDefined(); + }); + + it('running tasks receive the aborted signal', async () => { + const controller = new AbortController(); + const pool = new AsyncPool({ concurrency: 1, signal: controller.signal }); + let taskSignal!: AbortSignal; + const taskDone = pool.add((signal) => { + taskSignal = signal; + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve()); + }); + }); + controller.abort(); + await taskDone; + expect(taskSignal.aborted).toBe(true); + }); + + it('allSettled() resolves after abort with rejected entries', async () => { + const controller = new AbortController(); + const pool = new AsyncPool({ concurrency: 1, signal: controller.signal }); + // Active task listens to signal and resolves on abort + pool.add(signal => new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + })); + pool.add(() => Promise.resolve()).catch(() => {}); // queued — rejected on abort + controller.abort(); + const results = await pool.allSettled(); + expect(results.some(r => r.status === 'rejected')).toBe(true); + }); + + it('all() rejects with an AggregateError including the abort reason', async () => { + const controller = new AbortController(); + const pool = new AsyncPool({ concurrency: 1, signal: controller.signal }); + const reason = new Error('cancelled'); + + pool.add(signal => new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + })); + pool.add(() => Promise.resolve()).catch(() => {}); // queued — rejected on abort + controller.abort(reason); + + await expect(pool.all()).rejects.toBeInstanceOf(AggregateError); + }); + + it('dispose() detaches the abort listener', () => { + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, 'removeEventListener'); + const pool = new AsyncPool({ signal: controller.signal }); + + pool.dispose(); + + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + }); + + // ── Error resilience ───────────────────────────────────────────────────── + + describe('error resilience', () => { + it('rejected task does not block subsequent tasks', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + const completed: string[] = []; + pool.add(() => Promise.reject(new Error('fail'))).catch(() => {}); + pool.add(async () => { + completed.push('second'); + }); + await pool.all().catch(() => {}); + expect(completed).toContain('second'); + }); + + it('rejected task does not corrupt active count', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + await pool.add(() => Promise.reject(new Error('fail'))).catch(() => {}); + expect(pool.active).toBe(0); + }); + + it('a synchronously-throwing task rejects instead of wedging the pool', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + + await expect(pool.add(() => { + throw new Error('sync'); + })).rejects.toThrow('sync'); + + // The slot must be released, not leaked. + expect(pool.active).toBe(0); + + // Subsequent tasks still run. + await expect(pool.add(() => Promise.resolve('next'))).resolves.toBe('next'); + }); + }); + + // ── Results survive a drained batch ────────────────────────────────────── + + describe('drain before waiter', () => { + it('allSettled() still sees results when the batch drained first', async () => { + const pool = new AsyncPool({ concurrency: 2 }); + + await pool.add(() => Promise.resolve(1)); + await pool.add(() => Promise.resolve(2)); + + const results = await pool.allSettled(); + expect(results).toHaveLength(2); + }); + + it('all() still throws after a failing task drained first', async () => { + const pool = new AsyncPool({ concurrency: 1 }); + + await pool.add(() => Promise.reject(new Error('boom'))).catch(() => {}); + + await expect(pool.all()).rejects.toBeInstanceOf(AggregateError); + }); + }); +}); diff --git a/core/stdlib/src/async/pool/index.ts b/core/stdlib/src/async/pool/index.ts index 96524bd..9211d88 100644 --- a/core/stdlib/src/async/pool/index.ts +++ b/core/stdlib/src/async/pool/index.ts @@ -1,3 +1,244 @@ +import { CircularBuffer } from '../../structs/CircularBuffer'; +import { isNumber } from '../../types'; + export interface AsyncPoolOptions { concurrency?: number; + signal?: AbortSignal; +} + +interface PoolEntry { + task: (signal: AbortSignal) => Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +// Shared sentinel — never aborts, avoids allocating AbortController per instance when no signal +const NEVER_ABORT_SIGNAL: AbortSignal = /* @__PURE__ */ new AbortController().signal; + +// Normalizes concurrency option to a positive integer or Infinity. +// Note: (Infinity | 0) === 0 in JS — the `|| Infinity` trick recovers the correct value. +function normalizeConcurrency(value: unknown): number { + return (isNumber(value) && value > 0) ? (value | 0) || Infinity : Infinity; +} + +/** + * @name AsyncPool + * @category Async + * @description A concurrency-limited async task pool with AbortSignal support. + * Tasks start immediately when under the concurrency limit and are queued otherwise. + * Each task receives the pool's AbortSignal for cooperative cancellation. + * + * Use `all()` to wait for all tasks (throws AggregateError on any failure) + * or `allSettled()` to inspect individual results — mirroring the Promise static API. + * + * @example + * const pool = new AsyncPool({ concurrency: 3, signal: controller.signal }); + * for (const chunk of chunks) { + * pool.add((signal) => fetch(chunk.url, { signal })); + * } + * await pool.all(); + * + * @since 0.0.9 + */ +export class AsyncPool { + private limit: number; + private activeCount: number; + private readonly queue: CircularBuffer; + // withResolvers result — single field replaces separate drainPromise + drainResolve + private pending: ReturnType>>> | null; + private readonly signal: AbortSignal; + private settled: Array>; + // Kept so the listener can be detached via dispose(), avoiding retention through a long-lived signal + private readonly abortListener: (() => void) | null; + // No aborted field — use this.signal.aborted directly (always in sync with the platform) + + constructor(options: AsyncPoolOptions = {}) { + this.limit = normalizeConcurrency(options.concurrency); + this.activeCount = 0; + this.queue = new CircularBuffer(); + this.pending = null; + this.signal = options.signal ?? NEVER_ABORT_SIGNAL; + this.settled = []; + + if (options.signal) { + this.abortListener = () => this.onAbort(); + options.signal.addEventListener('abort', this.abortListener, { once: true }); + } + else { + this.abortListener = null; + } + } + + /** + * Detaches the pool's abort listener from the provided signal. Call this when the pool is no + * longer needed but the signal outlives it (e.g. one long-lived controller feeding many pools), + * otherwise the pool stays reachable through the signal's listener list and cannot be GC'd. + */ + dispose(): void { + if (this.abortListener) + this.signal.removeEventListener('abort', this.abortListener); + } + + get size(): number { + return this.queue.length; + } + + get active(): number { + return this.activeCount; + } + + get concurrency(): number { + return this.limit; + } + + /** + * Updates the concurrency limit at runtime. If increased, queued tasks are started immediately + * to fill the newly available slots. + */ + set concurrency(value: number) { + this.limit = normalizeConcurrency(value); + this.fill(); + } + + /** + * Adds a task to the pool. Starts immediately if under the concurrency limit; queues otherwise. + * The task receives the pool's AbortSignal for cooperative cancellation. + * + * @param {(signal: AbortSignal) => Promise} task + * @returns {Promise} + */ + add(task: (signal: AbortSignal) => Promise): Promise { + // withResolvers — resolve/reject needed outside the promise for PoolEntry; + // no executor closure, no captured variables + const { promise, resolve, reject } = Promise.withResolvers(); + if (this.signal.aborted) { + reject(this.signal.reason); + return promise; + } + const entry = { task, resolve, reject } as PoolEntry; + if (this.activeCount < this.limit) { + this.run(entry); + } + else { + this.queue.pushBack(entry); + } + return promise; + } + + /** + * Like `Promise.all` — resolves when all tasks complete; throws `AggregateError` if any failed. + * Multiple concurrent callers share the same underlying promise. + * + * @returns {Promise} + */ + async all(): Promise { + const results = await this.waitForDrain(); + const errors: unknown[] = []; + for (const r of results) { + if (r.status === 'rejected') + errors.push((r as PromiseRejectedResult).reason); + } + if (errors.length > 0) + throw new AggregateError(errors, 'AsyncPool: one or more tasks failed'); + } + + /** + * Like `Promise.allSettled` — always resolves with the settled result of every task. + * Multiple concurrent callers share the same underlying promise. + * + * @returns {Promise>>} + */ + allSettled(): Promise>> { + return this.waitForDrain(); + } + + private waitForDrain(): Promise>> { + if (this.activeCount === 0 && this.queue.isEmpty) { + // Fast path — swap settled out immediately; no copy + const results = this.settled; + this.settled = []; + return Promise.resolve(results); + } + if (this.pending !== null) + return this.pending.promise; // idempotent — N callers share one promise + + this.pending = Promise.withResolvers(); + return this.pending.promise; + } + + private run(entry: PoolEntry): void { + this.activeCount++; + + let task: Promise; + + // A task that throws synchronously must become a rejection, otherwise the + // exception escapes add(), the concurrency slot leaks and the pool wedges forever. + try { + task = Promise.resolve(entry.task(this.signal)); + } + catch (reason) { + this.settled.push({ status: 'rejected', reason }); + entry.reject(reason); + this.next(); + return; + } + + task.then( + (value) => { + this.settled.push({ status: 'fulfilled', value }); + entry.resolve(value); + this.next(); + }, + (reason) => { + this.settled.push({ status: 'rejected', reason }); + entry.reject(reason); + this.next(); + }, + ); + } + + private next(): void { + this.activeCount--; + this.fill(); + } + + // Central pump — fills available concurrency slots from the queue. + // Exit point: flushes drain when queue and active are both empty. + // Note: no abort check here — onAbort() drains the queue synchronously before + // any microtask could call fill(), so by the time fill() runs, queue is already empty. + private fill(): void { + const q = this.queue; + while (!q.isEmpty && this.activeCount < this.limit) { + this.run(q.popFront() as PoolEntry); + } + if (q.isEmpty && this.activeCount === 0) + this.flush(); + } + + private onAbort(): void { + const reason = this.signal.reason; + const q = this.queue; + while (!q.isEmpty) { + const entry = q.popFront() as PoolEntry; + this.settled.push({ status: 'rejected', reason }); + entry.reject(reason); + } + if (this.activeCount === 0) + this.flush(); + } + + private flush(): void { + const drain = this.pending; + + // No waiter yet — keep `settled` intact so a later all()/allSettled() can still + // observe the results of every task via the waitForDrain() fast path. + if (drain === null) + return; + + this.pending = null; + // Swap — hand off current array, allocate fresh one; no element copy + const results = this.settled; + this.settled = []; + drain.resolve(results); + } } diff --git a/core/stdlib/src/async/retry/index.test.ts b/core/stdlib/src/async/retry/index.test.ts index 4fc61e1..6acbb73 100644 --- a/core/stdlib/src/async/retry/index.test.ts +++ b/core/stdlib/src/async/retry/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { retry } from '.'; describe('retry', () => { @@ -12,7 +12,7 @@ describe('retry', () => { it('return the result on first successful attempt', async () => { const successFn = vi.fn().mockResolvedValue('success'); - + const result = await retry(successFn); expect(result).toBe('success'); @@ -20,19 +20,19 @@ describe('retry', () => { expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) }); }); - it('use default times value of 2', async () => { + it('use default times value of 2', async () => { const failingFn = vi.fn().mockRejectedValue(new Error('Test error')); - + await expect(retry(failingFn)).rejects.toThrow('Test error'); - + expect(failingFn).toHaveBeenCalledTimes(2); }); it('retry the specified number of times on failure', async () => { const failingFn = vi.fn().mockRejectedValue(new Error('Test error')); - + await expect(retry(failingFn, { times: 3 })).rejects.toThrow('Test error'); - + expect(failingFn).toHaveBeenCalledTimes(3); expect(failingFn).toHaveBeenNthCalledWith(1, { count: 1, stop: expect.any(Function) }); expect(failingFn).toHaveBeenNthCalledWith(2, { count: 2, stop: expect.any(Function) }); @@ -44,7 +44,7 @@ describe('retry', () => { .mockRejectedValueOnce(new Error('First failure')) .mockRejectedValueOnce(new Error('Second failure')) .mockResolvedValue('success'); - + const result = await retry(partiallyFailingFn, { times: 3 }); expect(result).toBe('success'); @@ -55,24 +55,24 @@ describe('retry', () => { const networkError = new Error('Network failed'); networkError.name = 'NetworkError'; const failingFn = vi.fn().mockRejectedValue(networkError); - - await expect(retry(failingFn, { + + await expect(retry(failingFn, { times: 3, - shouldRetry: (error) => error.name !== 'NetworkError' + shouldRetry: error => error.name !== 'NetworkError', })).rejects.toThrow('Network failed'); - + expect(failingFn).toHaveBeenCalledTimes(1); }); it('retry with custom shouldRetry based on count', async () => { const testError = new Error('Test error'); const failingFn = vi.fn().mockRejectedValue(testError); - - await expect(retry(failingFn, { + + await expect(retry(failingFn, { times: 5, - shouldRetry: (error, count) => count < 3 // Only retry first 2 attempts + shouldRetry: (error, count) => count < 3, // Only retry first 2 attempts })).rejects.toThrow('Test error'); - + expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries }); @@ -81,17 +81,17 @@ describe('retry', () => { temporaryError.name = 'TemporaryError'; const permanentError = new Error('Permanent failure'); permanentError.name = 'PermanentError'; - + const failingFn = vi.fn() .mockRejectedValueOnce(temporaryError) .mockRejectedValueOnce(temporaryError) .mockRejectedValueOnce(permanentError); - - await expect(retry(failingFn, { + + await expect(retry(failingFn, { times: 5, - shouldRetry: (error) => error.name === 'TemporaryError' + shouldRetry: error => error.name === 'TemporaryError', })).rejects.toThrow('Permanent failure'); - + expect(failingFn).toHaveBeenCalledTimes(3); }); @@ -154,9 +154,9 @@ describe('retry', () => { it('handle zero delay', async () => { const failingFn = vi.fn().mockRejectedValue(new Error('Test error')); - + await expect(retry(failingFn, { times: 3, delay: 0 })).rejects.toThrow('Test error'); - + expect(failingFn).toHaveBeenCalledTimes(3); }); @@ -167,7 +167,7 @@ describe('retry', () => { } return `Success on attempt ${count}`; }); - + const result = await retry(countingFn, { times: 3 }); expect(result).toBe('Success on attempt 3'); @@ -182,15 +182,15 @@ describe('retry', () => { const failingFn = vi.fn() .mockRejectedValueOnce(firstError) .mockRejectedValueOnce(lastError); - + await expect(retry(failingFn, { times: 2 })).rejects.toThrow('Last error'); }); it('handle times value of 1', async () => { const failingFn = vi.fn().mockRejectedValue(new Error('Test error')); - + await expect(retry(failingFn, { times: 1 })).rejects.toThrow('Test error'); - + expect(failingFn).toHaveBeenCalledTimes(1); }); @@ -201,7 +201,7 @@ describe('retry', () => { } return 'success'; }); - + const result = await retry(syncFn, { times: 2 }); expect(result).toBe('success'); @@ -209,45 +209,82 @@ describe('retry', () => { }); it('handle complex return types', async () => { - const complexFn = vi.fn().mockResolvedValue({ - data: [1, 2, 3], + const complexFn = vi.fn().mockResolvedValue({ + data: [1, 2, 3], status: 'ok', - metadata: { timestamp: 123456 } + metadata: { timestamp: 123456 }, }); - + const result = await retry(complexFn); expect(result).toEqual({ data: [1, 2, 3], status: 'ok', - metadata: { timestamp: 123456 } + metadata: { timestamp: 123456 }, }); }); it('stop retrying when stop function is called', async () => { const customError = new Error('Custom stop error'); - const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error: any) => void }) => { + const stopFn = vi.fn(async ({ count, stop }: { count: number; stop: (error: any) => void }) => { if (count === 2) { stop(customError); } throw new Error(`Attempt ${count} failed`); }); - + await expect(retry(stopFn, { times: 5 })).rejects.toThrow('Custom stop error'); - + expect(stopFn).toHaveBeenCalledTimes(2); }); it('stop retrying with undefined error when stop is called without argument', async () => { - const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error?: any) => void }) => { + const stopFn = vi.fn(async ({ count, stop }: { count: number; stop: (error?: any) => void }) => { if (count === 2) { stop(); } throw new Error(`Attempt ${count} failed`); }); - + await expect(retry(stopFn, { times: 5 })).rejects.toBeUndefined(); - + expect(stopFn).toHaveBeenCalledTimes(2); }); + + it('make exactly one attempt for times: 0 and reject with the real error (not null)', async () => { + const failingFn = vi.fn().mockRejectedValue(new Error('only attempt')); + + await expect(retry(failingFn, { times: 0 })).rejects.toThrow('only attempt'); + + expect(failingFn).toHaveBeenCalledTimes(1); + }); + + it('make exactly one attempt for a negative times', async () => { + const failingFn = vi.fn().mockRejectedValue(new Error('only attempt')); + + await expect(retry(failingFn, { times: -3 })).rejects.toThrow('only attempt'); + + expect(failingFn).toHaveBeenCalledTimes(1); + }); + + it('stop() on the first attempt aborts immediately with the given reason', async () => { + const reason = new Error('early'); + const fn = vi.fn(async ({ stop }: { stop: (error: any) => void }) => { + stop(reason); + throw new Error('should not surface'); + }); + + await expect(retry(fn, { times: 5 })).rejects.toBe(reason); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('stop() rejects with a non-Error value verbatim', async () => { + const fn = vi.fn(async ({ stop }: { stop: (error: any) => void }) => { + stop('plain string reason'); + throw new Error('ignored'); + }); + + await expect(retry(fn, { times: 3 })).rejects.toBe('plain string reason'); + expect(fn).toHaveBeenCalledTimes(1); + }); }); diff --git a/core/stdlib/src/async/retry/index.ts b/core/stdlib/src/async/retry/index.ts index 8901d98..aab5724 100644 --- a/core/stdlib/src/async/retry/index.ts +++ b/core/stdlib/src/async/retry/index.ts @@ -55,6 +55,9 @@ export async function retry( shouldRetry, } = options; + // Always make at least one attempt — `times < 1` would otherwise skip the loop + // entirely and throw a bare `null`, which is impossible for callers to diagnose. + const maxAttempts = times < 1 ? 1 : times; const wrappedFn = tryIt(fn); const delayFn = isFunction(delay) ? delay : null; const delayMs = delayFn ? 0 : delay as number; @@ -66,7 +69,7 @@ export async function retry( let lastError: Error | null = null; let count = 1; - while (count <= times) { + while (count <= maxAttempts) { const { error, data } = await wrappedFn({ count, stop }); if (!error) @@ -82,7 +85,7 @@ export async function retry( count++; // Don't delay after the last attempt - if (count <= times) { + if (count <= maxAttempts) { const ms = delayFn ? delayFn(count) : delayMs; if (ms > 0) @@ -90,6 +93,7 @@ export async function retry( } } - // eslint-disable-next-line eslint/no-throw-literal + // lastError is always set by the loop above (at least one attempt runs). + // eslint-disable-next-line no-throw-literal -- rethrowing the original caught error verbatim throw lastError!; } diff --git a/core/stdlib/src/async/sleep/index.test.ts b/core/stdlib/src/async/sleep/index.test.ts index c10b91a..638d010 100644 --- a/core/stdlib/src/async/sleep/index.test.ts +++ b/core/stdlib/src/async/sleep/index.test.ts @@ -1,19 +1,38 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sleep } from '.'; describe('sleep', () => { beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.useFakeTimers(); }); - it('delay execution by the specified amount of time', async () => { - const start = performance.now(); - const delay = 100; + afterEach(() => { + vi.useRealTimers(); + }); - await sleep(delay); + it('resolve only after the requested delay elapses', async () => { + let done = false; + sleep(100).then(() => (done = true)); - const end = performance.now(); + await vi.advanceTimersByTimeAsync(99); + expect(done).toBe(false); - expect(end - start).toBeGreaterThan(delay - 5); + await vi.advanceTimersByTimeAsync(1); + expect(done).toBe(true); + }); + + it('resolve with undefined', async () => { + const promise = sleep(0); + await vi.advanceTimersByTimeAsync(0); + + expect(await promise).toBeUndefined(); + }); + + it('resolve on the next macrotask for a zero delay', async () => { + let done = false; + sleep(0).then(() => (done = true)); + + await vi.advanceTimersByTimeAsync(0); + expect(done).toBe(true); }); }); diff --git a/core/stdlib/src/async/tryIt/index.test-d.ts b/core/stdlib/src/async/tryIt/index.test-d.ts new file mode 100644 index 0000000..3d0f8b3 --- /dev/null +++ b/core/stdlib/src/async/tryIt/index.test-d.ts @@ -0,0 +1,37 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { tryIt } from '.'; + +describe('tryIt', () => { + it('wraps async returns in a Promise of the discriminated union', () => { + const wrapped = tryIt(async (n: number) => n * 2); + + expectTypeOf(wrapped(2)).toEqualTypeOf< + Promise<{ error: Error; data: undefined } | { error: undefined; data: number }> + >(); + }); + + it('keeps sync returns synchronous', () => { + const wrapped = tryIt((n: number) => n * 2); + + expectTypeOf(wrapped(2)).toEqualTypeOf< + { error: Error; data: undefined } | { error: undefined; data: number } + >(); + }); + + it('unwraps Awaited for a promise-returning function', () => { + const wrapped = tryIt((n: number) => Promise.resolve(`${n}`)); + + expectTypeOf(wrapped(1)).toEqualTypeOf< + Promise<{ error: Error; data: undefined } | { error: undefined; data: string }> + >(); + }); + + it('narrows data when error is checked', () => { + const result = tryIt((n: number) => n)(1); + + if (result.error === undefined) + expectTypeOf(result.data).toEqualTypeOf(); + else + expectTypeOf(result.data).toEqualTypeOf(); + }); +}); diff --git a/core/stdlib/src/async/tryIt/index.test.ts b/core/stdlib/src/async/tryIt/index.test.ts index 0fa4b24..6f29d1a 100644 --- a/core/stdlib/src/async/tryIt/index.test.ts +++ b/core/stdlib/src/async/tryIt/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { tryIt } from '.'; describe('tryIt', () => { @@ -68,4 +68,33 @@ describe('tryIt', () => { expect(error?.message).toBe('Test error'); expect(data).toBeUndefined(); }); + + it('capture a rejected thenable (non-native PromiseLike)', async () => { + const thenableFn = () => ({ + // eslint-disable-next-line unicorn/no-thenable -- intentionally exercising a custom thenable + then(_resolve: (v: number) => void, reject: (e: unknown) => void) { + reject(new Error('thenable error')); + }, + }); + + const { error, data } = await tryIt(thenableFn)(); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('thenable error'); + expect(data).toBeUndefined(); + }); + + it('resolve a fulfilled thenable to its data', async () => { + const thenableFn = () => ({ + // eslint-disable-next-line unicorn/no-thenable -- intentionally exercising a custom thenable + then(resolve: (v: number) => void) { + resolve(42); + }, + }); + + const { error, data } = await tryIt(thenableFn)(); + + expect(error).toBeUndefined(); + expect(data).toBe(42); + }); }); diff --git a/core/stdlib/src/async/tryIt/index.ts b/core/stdlib/src/async/tryIt/index.ts index f0734f4..14f49a3 100644 --- a/core/stdlib/src/async/tryIt/index.ts +++ b/core/stdlib/src/async/tryIt/index.ts @@ -1,11 +1,19 @@ -import { isPromise } from '../../types'; - -export type TryItReturn = Return extends Promise +export type TryItReturn = Return extends PromiseLike ? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited }> : { error: Error; data: undefined } | { error: undefined; data: Return }; -function onResolve(data: any) { return { error: undefined, data }; } -function onReject(error: any) { return { error, data: undefined }; } +function isThenable(value: unknown): value is PromiseLike { + return value !== null && (typeof value === 'object' || typeof value === 'function') + && typeof (value as PromiseLike).then === 'function'; +} + +function onResolve(data: any) { + return { error: undefined, data }; +} + +function onReject(error: any) { + return { error, data: undefined }; +} /** * @name tryIt @@ -31,11 +39,14 @@ export function tryIt( try { const result = fn(...args); - if (isPromise(result)) - return result.then(onResolve, onReject) as TryItReturn; + // Handle any thenable (native Promise, async fn, or custom PromiseLike), so a + // rejected thenable is captured as { error } instead of escaping as raw data. + if (isThenable(result)) + return Promise.resolve(result).then(onResolve, onReject) as TryItReturn; return { error: undefined, data: result } as TryItReturn; - } catch (error) { + } + catch (error) { return { error, data: undefined } as TryItReturn; } }; diff --git a/core/stdlib/src/bits/flags/index.test.ts b/core/stdlib/src/bits/flags/index.test.ts index b9dffb3..43c4744 100644 --- a/core/stdlib/src/bits/flags/index.test.ts +++ b/core/stdlib/src/bits/flags/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { flagsGenerator } from '.'; describe('flagsGenerator', () => { @@ -23,4 +23,17 @@ describe('flagsGenerator', () => { expect(() => generateFlag()).toThrow(new RangeError('Cannot create more than 31 flags')); }); + + it('produce 31 distinct, orthogonal powers of two up to 2^30', () => { + const generateFlag = flagsGenerator(); + const flags = Array.from({ length: 31 }, () => generateFlag()); + + expect(new Set(flags).size).toBe(31); + flags.forEach((flag, i) => { + expect(flag).toBe(2 ** i); + expect(flag & (flag - 1)).toBe(0); // exactly one bit set + }); + + expect(flags.at(-1)).toBe(2 ** 30); + }); }); diff --git a/core/stdlib/src/bits/helpers/index.test.ts b/core/stdlib/src/bits/helpers/index.test.ts index fdea3b6..2c21b4d 100644 --- a/core/stdlib/src/bits/helpers/index.test.ts +++ b/core/stdlib/src/bits/helpers/index.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { and, or, not, has, is, unset, toggle } from '.'; +import { describe, expect, it } from 'vitest'; +import { and, has, is, not, or, toggle, unset } from '.'; describe('flagsAnd', () => { it('no effect on zero flags', () => { @@ -61,6 +61,15 @@ describe('flagsHas', () => { expect(result).toBe(false); }); + + it('require ALL queried bits, not just any (partial overlap is false)', () => { + // 0b1000 is set but 0b0100 is not — partial overlap must be false + expect(has(0b1010, 0b1100)).toBe(false); + // both bits present + expect(has(0b1110, 0b1100)).toBe(true); + // querying zero bits is vacuously true + expect(has(0b1010, 0b0000)).toBe(true); + }); }); describe('flagsIs', () => { diff --git a/core/stdlib/src/bits/helpers/index.ts b/core/stdlib/src/bits/helpers/index.ts index 4a5e840..b293746 100644 --- a/core/stdlib/src/bits/helpers/index.ts +++ b/core/stdlib/src/bits/helpers/index.ts @@ -29,7 +29,7 @@ export function or(...flags: number[]) { /** * @name not * @category Bits -* @description Function to combine multiple flags using the XOR operator +* @description Function to apply the bitwise NOT (complement) operator to a flag * * @param {number} flag - The flag to apply the NOT operator to * @returns {number} The result of the NOT operator diff --git a/core/stdlib/src/bits/index.ts b/core/stdlib/src/bits/index.ts index 4fa0d37..15312c2 100644 --- a/core/stdlib/src/bits/index.ts +++ b/core/stdlib/src/bits/index.ts @@ -1 +1,3 @@ export * from './flags'; +export * from './helpers'; +export * from './vector'; diff --git a/core/stdlib/src/bits/vector/index.test.ts b/core/stdlib/src/bits/vector/index.test.ts index e0fccc1..ae8a897 100644 --- a/core/stdlib/src/bits/vector/index.test.ts +++ b/core/stdlib/src/bits/vector/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { BitVector } from '.'; describe('BitVector', () => { @@ -54,10 +54,76 @@ describe('BitVector', () => { expect(bitVector.previousBit(0)).toBe(-1); }); - it('throw RangeError when previousBit is called with an unreachable value', () => { + it('clamp an out-of-range start index and return the previous set bit', () => { const bitVector = new BitVector(16); bitVector.setBit(5); - expect(() => bitVector.previousBit(24)).toThrow(new RangeError('Unreachable value')); + expect(bitVector.previousBit(24)).toBe(5); + }); + + it('return -1 from previousBit on an empty out-of-range query', () => { + const bitVector = new BitVector(16); + + expect(bitVector.previousBit(24)).toBe(-1); + }); + + it('toggle bits correctly', () => { + const bitVector = new BitVector(16); + + bitVector.toggleBit(7); + expect(bitVector.getBit(7)).toBe(true); + + bitVector.toggleBit(7); + expect(bitVector.getBit(7)).toBe(false); + }); + + it('find the next bit correctly', () => { + const bitVector = new BitVector(100); + const indices = [0, 1, 14, 15, 63, 64, 65, 66, 88, 99]; + const result = []; + indices.forEach(index => bitVector.setBit(index)); + + for (let i = bitVector.nextBit(-1); i !== -1; i = bitVector.nextBit(i)) { + result.push(i); + } + + expect(result).toEqual(indices); + }); + + it('return -1 when no next bit is found', () => { + const bitVector = new BitVector(16); + + expect(bitVector.nextBit(0)).toBe(-1); + expect(bitVector.nextBit(15)).toBe(-1); + }); + + it('count the number of set bits', () => { + const bitVector = new BitVector(100); + + expect(bitVector.count()).toBe(0); + + [0, 5, 63, 64, 99].forEach(index => bitVector.setBit(index)); + + expect(bitVector.count()).toBe(5); + + bitVector.clearBit(5); + + expect(bitVector.count()).toBe(4); + }); + + it('tolerate out-of-bounds writes without crashing or corrupting in-range bits', () => { + const bitVector = new BitVector(16); + bitVector.setBit(3); + + expect(() => { + bitVector.setBit(1000); + bitVector.clearBit(1000); + bitVector.toggleBit(1000); + }).not.toThrow(); + + // out-of-range reads are false; in-range state is intact + expect(bitVector.getBit(1000)).toBe(false); + expect(bitVector.getBit(3)).toBe(true); + expect(bitVector.count()).toBe(1); }); }); diff --git a/core/stdlib/src/bits/vector/index.ts b/core/stdlib/src/bits/vector/index.ts index 2ee8275..d9caa7b 100644 --- a/core/stdlib/src/bits/vector/index.ts +++ b/core/stdlib/src/bits/vector/index.ts @@ -2,7 +2,10 @@ export interface BitVectorLike { getBit(index: number): boolean; setBit(index: number): void; clearBit(index: number): void; + toggleBit(index: number): void; previousBit(index: number): number; + nextBit(index: number): number; + count(): number; } /** @@ -30,7 +33,18 @@ export class BitVector extends Uint8Array implements BitVectorLike { this[index >> 3]! &= ~(1 << (index & 7)); } + toggleBit(index: number): void { + this[index >> 3]! ^= 1 << (index & 7); + } + previousBit(index: number): number { + // Clamp an out-of-range start to the vector's bit length so a query past the end + // returns the last set bit (or -1) instead of falling through to the invariant throw. + const totalBits = this.length << 3; + + if (index > totalBits) + index = totalBits; + while (index !== ((index >> 3) << 3)) { --index; @@ -58,4 +72,61 @@ export class BitVector extends Uint8Array implements BitVectorLike { throw new RangeError('Unreachable value'); } + + nextBit(index: number): number { + const totalBits = this.length << 3; + + let i = index + 1; + + if (i < 0) + i = 0; + + // Finish scanning the remainder of the starting byte. + while (i < totalBits && (i & 7) !== 0) { + if (this.getBit(i)) + return i; + + ++i; + } + + // Skip over fully-empty bytes. + let byteIndex = i >> 3; + + while (byteIndex < this.length && this[byteIndex] === 0) + ++byteIndex; + + if (byteIndex >= this.length) + return -1; + + i = byteIndex << 3; + + const end = i + 8; + + while (i < end) { + if (this.getBit(i)) + return i; + + ++i; + } + + throw new RangeError('Unreachable value'); + } + + count(): number { + let total = 0; + const len = this.length; + + // Indexed loop — the typed-array iterator protocol (for...of) is ~3.5x slower here. + for (let i = 0; i < len; i++) { + // Brian Kernighan's algorithm: iterate once per set bit. + let byte = this[i]!; + + while (byte !== 0) { + byte &= byte - 1; + ++total; + } + } + + return total; + } } diff --git a/core/stdlib/src/collections/get/index.test-d.ts b/core/stdlib/src/collections/get/index.test-d.ts new file mode 100644 index 0000000..6960b75 --- /dev/null +++ b/core/stdlib/src/collections/get/index.test-d.ts @@ -0,0 +1,53 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { get } from '.'; +import type { Get } from '.'; + +describe('get', () => { + describe('runtime return type', () => { + it('infer a nested object value', () => { + expectTypeOf(get({ user: { name: 'John' } }, 'user.name')).toEqualTypeOf(); + }); + + it('infer a value behind an array index', () => { + expectTypeOf(get({ items: [{ id: 1 }] }, 'items.0.id')).toEqualTypeOf(); + }); + + it('infer an array element', () => { + expectTypeOf(get({ items: [{ id: 1 }] }, 'items.0')).toEqualTypeOf<{ id: number } | undefined>(); + }); + + it('infer the element type of a root array', () => { + expectTypeOf(get(['a', 'b', 'c'], '0')).toEqualTypeOf(); + }); + + it('narrow to undefined when descending into a primitive', () => { + expectTypeOf(get({ a: 1 }, 'a.b.c')).toEqualTypeOf(); + }); + + it('narrow to undefined for a missing key', () => { + expectTypeOf(get({ user: { name: 'John' } }, 'user.age')).toEqualTypeOf(); + }); + }); + + describe('Get', () => { + it('resolve a simple object path', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('resolve a path through an array index (general arrays widen with undefined)', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('resolve an exact element type from a tuple', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('resolve a deeply nested path', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('resolve to never for a missing key', () => { + expectTypeOf>().toEqualTypeOf(); + }); + }); +}); diff --git a/core/stdlib/src/collections/get/index.test.ts b/core/stdlib/src/collections/get/index.test.ts new file mode 100644 index 0000000..012c72e --- /dev/null +++ b/core/stdlib/src/collections/get/index.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { get } from '.'; + +describe('get', () => { + it('read a top-level property', () => { + expect(get({ name: 'John' }, 'name')).toBe('John'); + }); + + it('read a nested object property', () => { + expect(get({ user: { name: 'John' } }, 'user.name')).toBe('John'); + }); + + it('read a value through an array index', () => { + expect(get({ items: [{ id: 1 }, { id: 2 }] }, 'items.1.id')).toBe(2); + }); + + it('read deeply nested values', () => { + const source = { a: { b: { c: { d: 42 } } } }; + + expect(get(source, 'a.b.c.d')).toBe(42); + }); + + it('return undefined for a missing leaf', () => { + expect(get({ user: { name: 'John' } }, 'user.age')).toBeUndefined(); + }); + + it('return undefined when traversing through a missing branch', () => { + expect(get({ a: 1 }, 'a.b.c')).toBeUndefined(); + }); + + it('return undefined when traversing through null/undefined', () => { + expect(get({ user: null }, 'user.name')).toBeUndefined(); + expect(get({ user: undefined }, 'user.name')).toBeUndefined(); + }); + + it('preserve falsy values', () => { + expect(get({ count: 0 }, 'count')).toBe(0); + expect(get({ flag: false }, 'flag')).toBe(false); + expect(get({ value: '' }, 'value')).toBe(''); + }); + + it('work on arrays as the root collection', () => { + expect(get(['a', 'b', 'c'], '2')).toBe('c'); + }); + + it('return the object itself for an empty path', () => { + const obj = { a: 1 }; + + expect(get(obj, '')).toBeUndefined(); + }); + + it('resolve own properties (inherited keys are reachable via the prototype chain)', () => { + const proto = { inherited: 'from-proto' }; + const obj = Object.create(proto) as { inherited: string; own?: number }; + obj.own = 1; + + expect(get(obj, 'own')).toBe(1); + // documents current behavior: bracket access does walk the prototype chain + expect(get(obj, 'inherited')).toBe('from-proto'); + }); +}); diff --git a/core/stdlib/src/collections/get/index.ts b/core/stdlib/src/collections/get/index.ts index 0037948..a222f6b 100644 --- a/core/stdlib/src/collections/get/index.ts +++ b/core/stdlib/src/collections/get/index.ts @@ -27,8 +27,40 @@ export type ExtractFromCollection : never : never; -type Get = ExtractFromCollection>; +export type Get = ExtractFromCollection>; -export function get(obj: O, path: K) { - return path.split('.').reduce((acc, key) => (acc as any)?.[key], obj) as Get | undefined; +/** + * @name get + * @category Collections + * @description Safely read a deeply nested value from a collection by a dot-separated path + * + * @param {Collection} obj - The source object or array + * @param {string} path - Dot-separated path, e.g. `'user.addresses.0.street'` + * @returns {Get | undefined} The resolved value, or `undefined` if any segment is missing + * + * @example + * get({ user: { name: 'John' } }, 'user.name'); // 'John' + * get({ items: [{ id: 1 }] }, 'items.0.id'); // 1 + * get({ a: 1 }, 'a.b.c'); // undefined + * + * @since 0.0.4 + */ +export function get(obj: O, path: K): Get | undefined { + let value: any = obj; + let start = 0; + + // Walk the path without allocating an intermediate array of segments. + for (let i = 0, len = path.length; i <= len; i++) { + // Split on '.' (char code 46) or the end of the string. + if (i !== len && path.charCodeAt(i) !== 46) + continue; + + if (value === null || value === undefined) + return undefined; + + value = value[path.slice(start, i)]; + start = i + 1; + } + + return value; } diff --git a/core/stdlib/src/functions/compose/index.test-d.ts b/core/stdlib/src/functions/compose/index.test-d.ts new file mode 100644 index 0000000..9d47769 --- /dev/null +++ b/core/stdlib/src/functions/compose/index.test-d.ts @@ -0,0 +1,18 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { compose } from '.'; + +describe('compose', () => { + it('infers the final return type through the chain', () => { + const fn = compose((s: string) => s.length > 0, (n: number) => `${n}`, (n: number) => n + 1); + + expectTypeOf(fn).parameters.toEqualTypeOf<[number]>(); + expectTypeOf(fn).returns.toEqualTypeOf(); + }); + + it('keeps the variadic parameters of the last function', () => { + const fn = compose((n: number) => `${n}`, (a: number, b: number) => a + b); + + expectTypeOf(fn).parameters.toEqualTypeOf<[number, number]>(); + expectTypeOf(fn).returns.toEqualTypeOf(); + }); +}); diff --git a/core/stdlib/src/functions/compose/index.test.ts b/core/stdlib/src/functions/compose/index.test.ts new file mode 100644 index 0000000..5f0683d --- /dev/null +++ b/core/stdlib/src/functions/compose/index.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { compose } from '.'; + +describe('compose', () => { + it('apply functions right-to-left', () => { + const calc = compose((n: number) => `= ${n}`, (n: number) => n * 2, (n: number) => n + 1); + + expect(calc(3)).toBe('= 8'); + }); + + it('pass multiple arguments to the last function', () => { + const calc = compose((n: number) => n * 10, (a: number, b: number) => a + b); + + expect(calc(2, 3)).toBe(50); + }); + + it('support a single function', () => { + const inc = compose((n: number) => n + 1); + + expect(inc(1)).toBe(2); + }); + + it('mirror pipe with reversed arguments', () => { + const f = (n: number) => n + 1; + const g = (n: number) => n * 2; + + expect(compose(g, f)(3)).toBe(8); + }); + + it('forward this to the right-most function', () => { + const calc = compose((n: number) => n * 2, function (this: { base: number }, n: number) { + return this.base + n; + }); + + expect(calc.call({ base: 10 }, 5)).toBe(30); + }); + + it('return the input unchanged with no functions', () => { + expect((compose as unknown as (...fns: never[]) => (x: number) => number)()(42)).toBe(42); + }); +}); diff --git a/core/stdlib/src/functions/compose/index.ts b/core/stdlib/src/functions/compose/index.ts new file mode 100644 index 0000000..d449744 --- /dev/null +++ b/core/stdlib/src/functions/compose/index.ts @@ -0,0 +1,38 @@ +import type { AnyFunction } from '../../types'; + +/** + * @name compose + * @category Functions + * @description Composes functions right-to-left: `compose(f, g)(x)` is `f(g(x))` + * + * @param {...Function} fns - The functions to compose; the last may take any number of arguments + * @returns {Function} A function that runs the input through every function from last to first + * + * @example + * const calc = compose((n: number) => `= ${n}`, (n: number) => n * 2, (n: number) => n + 1); + * calc(3); // '= 8' + * + * @since 0.0.10 + */ +export function compose(ab: (...a: A) => B): (...a: A) => B; +export function compose(bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => C; +export function compose(cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => D; +export function compose(de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => E; +export function compose(ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => F; +export function compose(fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => G; +export function compose(gh: (g: G) => H, fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => H; +export function compose(...fns: AnyFunction[]): AnyFunction { + return function (this: unknown, ...args: any[]) { + const last = fns.length - 1; + + if (last < 0) + return args[0]; + + let result = fns[last]!.apply(this, args); + + for (let i = last - 1; i >= 0; i--) + result = fns[i]!.call(this, result); + + return result; + }; +} diff --git a/core/stdlib/src/functions/debounce/index.test.ts b/core/stdlib/src/functions/debounce/index.test.ts new file mode 100644 index 0000000..713ccea --- /dev/null +++ b/core/stdlib/src/functions/debounce/index.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debounce } from '.'; + +describe('debounce', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('delay invocation until wait elapses since the last call', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100); + + debounced(); + debounced(); + debounced(); + expect(spy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('reset the timer on every call', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100); + + debounced(); + vi.advanceTimersByTime(60); + debounced(); + vi.advanceTimersByTime(60); + expect(spy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(40); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('invoke on the leading edge', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100, { leading: true, trailing: false }); + + debounced(); + expect(spy).toHaveBeenCalledTimes(1); + + debounced(); + debounced(); + expect(spy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('forward the latest arguments and this', () => { + const spy = vi.fn(function (this: { base: number }, n: number) { + return this.base + n; + }); + const debounced = debounce(spy, 100); + + debounced.call({ base: 10 }, 1); + debounced.call({ base: 10 }, 2); + vi.advanceTimersByTime(100); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith(2); + expect(spy.mock.results[0]!.value).toBe(12); + }); + + it('cancel a pending invocation', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100); + + debounced(); + expect(debounced.pending()).toBe(true); + + debounced.cancel(); + expect(debounced.pending()).toBe(false); + + vi.advanceTimersByTime(100); + expect(spy).not.toHaveBeenCalled(); + }); + + it('flush a pending invocation immediately and return its result', () => { + const spy = vi.fn((n: number) => n * 2); + const debounced = debounce(spy, 100); + + debounced(5); + expect(debounced.flush()).toBe(10); + expect(spy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('resolves the wait from a getter on each call', () => { + const spy = vi.fn(); + let wait = 100; + const debounced = debounce(spy, () => wait); + + debounced(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + + wait = 200; + debounced(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); // not yet, window is now 200 + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('forces invocation after maxWait under sustained calls', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100, { maxWait: 250 }); + + // Keep resetting the 100ms timer every 80ms; without maxWait it would never fire. + debounced(); + vi.advanceTimersByTime(80); + debounced(); + vi.advanceTimersByTime(80); + debounced(); + vi.advanceTimersByTime(80); + expect(spy).not.toHaveBeenCalled(); + + // 240ms elapsed; at 250ms the maxWait fires. + vi.advanceTimersByTime(10); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('pending() is false after a maxWait-forced invocation', () => { + const debounced = debounce(vi.fn(), 100, { maxWait: 150 }); + debounced(); + vi.advanceTimersByTime(150); + expect(debounced.pending()).toBe(false); + }); + + it('leading + trailing fires on both edges for a burst but once for a lone call', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100, { leading: true, trailing: true }); + + debounced(); + expect(spy).toHaveBeenCalledTimes(1); // leading + debounced(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(2); // trailing + + spy.mockClear(); + debounced(); // isolated call + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); // leading only, no trailing double-fire + }); + + it('re-arm after a trailing fire', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100); + + debounced(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + + debounced(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('flush() and cancel() are no-ops when nothing is pending', () => { + const spy = vi.fn(); + const debounced = debounce(spy, 100); + + expect(debounced.flush()).toBeUndefined(); + debounced.cancel(); + + expect(spy).not.toHaveBeenCalled(); + expect(debounced.pending()).toBe(false); + }); + + it('pending() is false during the window when trailing is disabled', () => { + const debounced = debounce(vi.fn(), 100, { leading: true, trailing: false }); + + debounced(); + expect(debounced.pending()).toBe(false); + }); +}); diff --git a/core/stdlib/src/functions/debounce/index.ts b/core/stdlib/src/functions/debounce/index.ts new file mode 100644 index 0000000..d16f694 --- /dev/null +++ b/core/stdlib/src/functions/debounce/index.ts @@ -0,0 +1,130 @@ +import type { AnyFunction } from '../../types'; + +export interface DebounceOptions { + /** Invoke on the leading edge of the timeout. Default `false`. */ + leading?: boolean; + /** Invoke on the trailing edge of the timeout. Default `true`. */ + trailing?: boolean; + /** + * The maximum time `fn` is allowed to be delayed before it is forcibly + * invoked, even while calls keep arriving. Accepts a number or a getter + * resolved lazily. When omitted there is no upper bound. + */ + maxWait?: number | (() => number); +} + +export interface DebouncedFunction { + (this: ThisParameterType, ...args: Parameters): void; + /** Cancel a pending invocation without calling the function. */ + cancel: () => void; + /** Immediately invoke a pending call (if any) and return its result. */ + flush: () => ReturnType | undefined; + /** Whether an invocation is currently scheduled. */ + pending: () => boolean; +} + +/** + * @name debounce + * @category Functions + * @description Delays invoking a function until `wait` ms have elapsed since the last call + * + * @param {Function} fn - The function to debounce + * @param {number | (() => number)} wait - Milliseconds to wait, or a getter resolved lazily on each call (useful for reactive delays) + * @param {DebounceOptions} [options] - Leading/trailing edge behavior and `maxWait` + * @returns {DebouncedFunction} The debounced function with `cancel()`, `flush()` and `pending()` + * + * @example + * const onResize = debounce(() => layout(), 200); + * window.addEventListener('resize', onResize); + * onResize.cancel(); + * + * @example + * // Reactive delay + a guaranteed call at least every 1000ms under sustained input + * const save = debounce(persist, () => delayRef.value, { maxWait: 1000 }); + * + * @since 0.0.10 + */ +export function debounce( + fn: T, + wait: number | (() => number), + options: DebounceOptions = {}, +): DebouncedFunction { + const { leading = false, trailing = true, maxWait } = options; + const resolveWait = typeof wait === 'function' ? wait : () => wait; + const resolveMaxWait = maxWait === undefined + ? undefined + : (typeof maxWait === 'function' ? maxWait : () => maxWait); + + let timer: ReturnType | undefined; + let maxTimer: ReturnType | undefined; + let pending: (() => ReturnType) | undefined; + let result: ReturnType | undefined; + + function invoke() { + if (pending === undefined) + return; + + result = pending(); + pending = undefined; + } + + function clearTimers() { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + + if (maxTimer !== undefined) { + clearTimeout(maxTimer); + maxTimer = undefined; + } + } + + const debounced = function (this: ThisParameterType, ...args: Parameters) { + // The arrow captures the call-time `this` lexically, no aliasing needed. + pending = () => fn.apply(this, args) as ReturnType; + + const callLeading = leading && timer === undefined && maxTimer === undefined; + + if (timer !== undefined) + clearTimeout(timer); + + timer = setTimeout(() => { + clearTimers(); + + if (trailing) + invoke(); + }, resolveWait()); + + // maxWait: guarantee an invocation within maxWait of the burst's first call. + if (resolveMaxWait !== undefined && maxTimer === undefined) { + maxTimer = setTimeout(() => { + clearTimers(); + invoke(); + }, resolveMaxWait()); + } + + if (callLeading) + invoke(); + } as DebouncedFunction; + + debounced.cancel = () => { + clearTimers(); + pending = undefined; + }; + + debounced.flush = () => { + if (timer !== undefined || maxTimer !== undefined) { + clearTimers(); + invoke(); + } + + return result; + }; + + // True only when an invocation will actually occur: a maxWait flush is always honored, + // but the trailing-edge timer fires fn only when `trailing` is enabled. + debounced.pending = () => maxTimer !== undefined || (trailing && timer !== undefined); + + return debounced; +} diff --git a/core/stdlib/src/functions/index.ts b/core/stdlib/src/functions/index.ts new file mode 100644 index 0000000..4f82f6f --- /dev/null +++ b/core/stdlib/src/functions/index.ts @@ -0,0 +1,6 @@ +export * from './compose'; +export * from './debounce'; +export * from './memoize'; +export * from './once'; +export * from './pipe'; +export * from './throttle'; diff --git a/core/stdlib/src/functions/memoize/index.test.ts b/core/stdlib/src/functions/memoize/index.test.ts new file mode 100644 index 0000000..77afee1 --- /dev/null +++ b/core/stdlib/src/functions/memoize/index.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import { memoize } from '.'; + +describe('memoize', () => { + it('cache results by the first argument', () => { + const spy = vi.fn((n: number) => n * 2); + const memoized = memoize(spy); + + expect(memoized(2)).toBe(4); + expect(memoized(2)).toBe(4); + expect(memoized(3)).toBe(6); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('use a custom resolver for the cache key', () => { + const spy = vi.fn((a: number, b: number) => a + b); + const memoized = memoize(spy, (a, b) => `${a},${b}`); + + expect(memoized(1, 2)).toBe(3); + expect(memoized(1, 2)).toBe(3); + expect(memoized(2, 1)).toBe(3); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('cache falsy results', () => { + const spy = vi.fn((_n: number) => 0); + const memoized = memoize(spy); + + expect(memoized(1)).toBe(0); + expect(memoized(1)).toBe(0); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('expose the cache and a clear() method', () => { + const memoized = memoize((n: number) => n * 2); + + memoized(2); + expect(memoized.cache.size).toBe(1); + expect(memoized.cache.get(2)).toBe(4); + + memoized.clear(); + expect(memoized.cache.size).toBe(0); + }); + + it('preserve this', () => { + const memoized = memoize(function (this: { base: number }, n: number) { + return this.base + n; + }); + + expect(memoized.call({ base: 10 }, 5)).toBe(15); + }); + + it('key only on the first argument by default (documented multi-arg footgun)', () => { + const spy = vi.fn((a: number, b: number) => a + b); + const memoized = memoize(spy); + + expect(memoized(1, 2)).toBe(3); + expect(memoized(1, 9)).toBe(3); // stale — collides on first arg + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('cache an undefined return value (does not recompute)', () => { + const spy = vi.fn((_n: number) => undefined); + const memoized = memoize(spy); + + memoized(1); + memoized(1); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/core/stdlib/src/functions/memoize/index.ts b/core/stdlib/src/functions/memoize/index.ts new file mode 100644 index 0000000..6e7a0fd --- /dev/null +++ b/core/stdlib/src/functions/memoize/index.ts @@ -0,0 +1,50 @@ +import type { AnyFunction } from '../../types'; + +export type MemoizeResolver = (...args: Parameters) => unknown; + +export type MemoizedFunction = T & { + /** The underlying cache, keyed by the resolver (first argument by default). */ + cache: Map>; + /** Drop all cached results. */ + clear: () => void; +}; + +/** + * @name memoize + * @category Functions + * @description Caches the result of a function by its arguments + * + * @param {Function} fn - The function to memoize + * @param {Function} [resolver] - Maps the arguments to a cache key; defaults to the first argument + * @returns {MemoizedFunction} The memoized function, exposing its `cache` and a `clear()` method + * + * @example + * const slow = memoize((n: number) => expensive(n)); + * slow(2); // computed + * slow(2); // cached + * + * @example + * const sum = memoize((a: number, b: number) => a + b, (a, b) => `${a},${b}`); + * + * @since 0.0.10 + */ +export function memoize(fn: T, resolver?: MemoizeResolver): MemoizedFunction { + const cache = new Map>(); + + const memoized = function (this: unknown, ...args: Parameters): ReturnType { + const key = resolver ? resolver(...args) : args[0]; + + if (cache.has(key)) + return cache.get(key)!; + + const result = fn.apply(this, args) as ReturnType; + cache.set(key, result); + + return result; + } as MemoizedFunction; + + memoized.cache = cache; + memoized.clear = () => cache.clear(); + + return memoized; +} diff --git a/core/stdlib/src/functions/once/index.test.ts b/core/stdlib/src/functions/once/index.test.ts new file mode 100644 index 0000000..b8c3ecb --- /dev/null +++ b/core/stdlib/src/functions/once/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { once } from '.'; + +describe('once', () => { + it('invoke the original function only once', () => { + const spy = vi.fn(() => 42); + const onced = once(spy); + + expect(onced()).toBe(42); + expect(onced()).toBe(42); + expect(onced()).toBe(42); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('forward arguments and this from the first call', () => { + const onced = once(function (this: { base: number }, a: number, b: number) { + return this.base + a + b; + }); + + expect(onced.call({ base: 10 }, 1, 2)).toBe(13); + }); + + it('cache the first result even when later args differ', () => { + const onced = once((n: number) => n * 2); + + expect(onced(2)).toBe(4); + expect(onced(100)).toBe(4); + }); + + it('run again after clear()', () => { + let count = 0; + const onced = once(() => ++count); + + expect(onced()).toBe(1); + expect(onced()).toBe(1); + + onced.clear(); + + expect(onced()).toBe(2); + }); + + it('stay retryable when the first call throws (guard armed only on success)', () => { + let n = 0; + const onced = once(() => { + n++; + if (n === 1) + throw new Error('first'); + return n; + }); + + expect(() => onced()).toThrow('first'); + expect(onced()).toBe(2); + expect(onced()).toBe(2); + }); +}); diff --git a/core/stdlib/src/functions/once/index.ts b/core/stdlib/src/functions/once/index.ts new file mode 100644 index 0000000..5b71b64 --- /dev/null +++ b/core/stdlib/src/functions/once/index.ts @@ -0,0 +1,47 @@ +import type { AnyFunction } from '../../types'; + +export type OnceFunction = T & { + /** Reset the guard so the next call runs the original function again. */ + clear: () => void; +}; + +/** + * @name once + * @category Functions + * @description Wraps a function so it runs at most once; subsequent calls return the first result + * + * @param {Function} fn - The function to guard + * @returns {OnceFunction} The guarded function with a `clear()` method to reset it + * + * @example + * const init = once(() => Math.random()); + * init(); // 0.42 + * init(); // 0.42 (cached) + * init.clear(); + * init(); // 0.91 (runs again) + * + * @since 0.0.10 + */ +export function once(fn: T): OnceFunction { + let called = false; + let result: ReturnType; + + const onced = function (this: unknown, ...args: Parameters): ReturnType { + if (!called) { + // Arm the guard only after a successful call, so a throwing first call stays + // retryable (matching memoize) instead of permanently latching `undefined`. + result = fn.apply(this, args); + called = true; + } + + return result; + } as OnceFunction; + + onced.clear = () => { + called = false; + // Release the cached result so it (and anything it retains) can be GC'd. + result = undefined as ReturnType; + }; + + return onced; +} diff --git a/core/stdlib/src/functions/pipe/index.test-d.ts b/core/stdlib/src/functions/pipe/index.test-d.ts new file mode 100644 index 0000000..6d0db28 --- /dev/null +++ b/core/stdlib/src/functions/pipe/index.test-d.ts @@ -0,0 +1,24 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { pipe } from '.'; + +describe('pipe', () => { + it('infers the final return type through the chain', () => { + const fn = pipe((n: number) => n + 1, n => `${n}`, s => s.length > 0); + + expectTypeOf(fn).parameters.toEqualTypeOf<[number]>(); + expectTypeOf(fn).returns.toEqualTypeOf(); + }); + + it('keeps the variadic parameters of the first function', () => { + const fn = pipe((a: number, b: number) => a + b, n => `${n}`); + + expectTypeOf(fn).parameters.toEqualTypeOf<[number, number]>(); + expectTypeOf(fn).returns.toEqualTypeOf(); + }); + + it('supports a single function', () => { + const fn = pipe((n: number) => n > 0); + + expectTypeOf(fn).returns.toEqualTypeOf(); + }); +}); diff --git a/core/stdlib/src/functions/pipe/index.test.ts b/core/stdlib/src/functions/pipe/index.test.ts new file mode 100644 index 0000000..ca84c3a --- /dev/null +++ b/core/stdlib/src/functions/pipe/index.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { pipe } from '.'; + +describe('pipe', () => { + it('apply functions left-to-right', () => { + const calc = pipe((n: number) => n + 1, n => n * 2, n => `= ${n}`); + + expect(calc(3)).toBe('= 8'); + }); + + it('pass multiple arguments to the first function', () => { + const calc = pipe((a: number, b: number) => a + b, n => n * 10); + + expect(calc(2, 3)).toBe(50); + }); + + it('support a single function', () => { + const inc = pipe((n: number) => n + 1); + + expect(inc(1)).toBe(2); + }); + + it('preserve this for the first function', () => { + const calc = pipe(function (this: { base: number }, n: number) { + return this.base + n; + }, n => n * 2); + + expect(calc.call({ base: 10 }, 5)).toBe(30); + }); + + it('return the input unchanged with no functions', () => { + expect((pipe as unknown as (...fns: never[]) => (x: number) => number)()(42)).toBe(42); + }); +}); diff --git a/core/stdlib/src/functions/pipe/index.ts b/core/stdlib/src/functions/pipe/index.ts new file mode 100644 index 0000000..1629b19 --- /dev/null +++ b/core/stdlib/src/functions/pipe/index.ts @@ -0,0 +1,36 @@ +import type { AnyFunction } from '../../types'; + +/** + * @name pipe + * @category Functions + * @description Composes functions left-to-right: `pipe(f, g)(x)` is `g(f(x))` + * + * @param {...Function} fns - The functions to pipe; the first may take any number of arguments + * @returns {Function} A function that runs the input through every function in order + * + * @example + * const calc = pipe((n: number) => n + 1, n => n * 2, n => `= ${n}`); + * calc(3); // '= 8' + * + * @since 0.0.10 + */ +export function pipe(ab: (...a: A) => B): (...a: A) => B; +export function pipe(ab: (...a: A) => B, bc: (b: B) => C): (...a: A) => C; +export function pipe(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (...a: A) => D; +export function pipe(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): (...a: A) => E; +export function pipe(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F): (...a: A) => F; +export function pipe(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G): (...a: A) => G; +export function pipe(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G, gh: (g: G) => H): (...a: A) => H; +export function pipe(...fns: AnyFunction[]): AnyFunction { + return function (this: unknown, ...args: any[]) { + if (fns.length === 0) + return args[0]; + + let result = fns[0]!.apply(this, args); + + for (let i = 1; i < fns.length; i++) + result = fns[i]!.call(this, result); + + return result; + }; +} diff --git a/core/stdlib/src/functions/throttle/index.test.ts b/core/stdlib/src/functions/throttle/index.test.ts new file mode 100644 index 0000000..47270c7 --- /dev/null +++ b/core/stdlib/src/functions/throttle/index.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { throttle } from '.'; + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + }); + afterEach(() => vi.useRealTimers()); + + it('invoke immediately on the leading edge', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100); + + throttled(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('throttle rapid calls to leading + trailing', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100); + + throttled(); + throttled(); + throttled(); + expect(spy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('skip the leading call when leading is false', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100, { leading: false }); + + throttled(); + expect(spy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('skip the trailing call when trailing is false', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100, { trailing: false }); + + throttled(); + throttled(); + throttled(); + expect(spy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('allow another leading call after the window passes', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100, { trailing: false }); + + throttled(); + expect(spy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(150); + throttled(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('cancel a pending trailing call', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100); + + throttled(); + throttled(); + expect(throttled.pending()).toBe(true); + + throttled.cancel(); + expect(throttled.pending()).toBe(false); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('flush a pending trailing call immediately and return its result', () => { + const spy = vi.fn((n: number) => n * 2); + const throttled = throttle(spy, 100); + + throttled(1); + throttled(5); + expect(throttled.flush()).toBe(10); + expect(spy).toHaveBeenCalledTimes(2); + expect(throttled.pending()).toBe(false); + }); + + it('resolves the wait from a getter on each call', () => { + const spy = vi.fn(); + let wait = 100; + const throttled = throttle(spy, () => wait, { leading: false }); + + throttled(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + + wait = 200; + throttled(); + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); // window widened to 200 + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('trailing invocation uses the latest arguments', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100); + + throttled('a'); + throttled('b'); + throttled('c'); + vi.advanceTimersByTime(100); + + expect(spy).toHaveBeenNthCalledWith(1, 'a'); // leading + expect(spy).toHaveBeenNthCalledWith(2, 'c'); // trailing = most recent + }); + + it('rate-limit across multiple sustained windows', () => { + const spy = vi.fn(); + const throttled = throttle(spy, 100); + + for (let i = 0; i < 5; i++) { + throttled(); + vi.advanceTimersByTime(50); + } + + // 250ms of calls every 50ms with a 100ms window — far fewer than 5 invocations. + expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(spy.mock.calls.length).toBeLessThan(5); + }); + + it('flush() with nothing scheduled returns the last result without double-calling', () => { + const spy = vi.fn((n: number) => n); + const throttled = throttle(spy, 100); + + throttled(1); // leading fires immediately, nothing trailing pending + + expect(throttled.flush()).toBe(1); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('preserve this on the leading call', () => { + const throttled = throttle(function (this: { base: number }, n: number) { + this.base += n; + }, 100); + const ctx = { base: 10 }; + + throttled.call(ctx, 5); + + expect(ctx.base).toBe(15); + }); +}); diff --git a/core/stdlib/src/functions/throttle/index.ts b/core/stdlib/src/functions/throttle/index.ts new file mode 100644 index 0000000..9d7b7df --- /dev/null +++ b/core/stdlib/src/functions/throttle/index.ts @@ -0,0 +1,106 @@ +import type { AnyFunction } from '../../types'; + +export interface ThrottleOptions { + /** Invoke on the leading edge of the window. Default `true`. */ + leading?: boolean; + /** Invoke on the trailing edge of the window. Default `true`. */ + trailing?: boolean; +} + +export interface ThrottledFunction { + (this: ThisParameterType, ...args: Parameters): void; + /** Cancel a pending trailing invocation. */ + cancel: () => void; + /** Immediately invoke a pending call (if any) and return its result. */ + flush: () => ReturnType | undefined; + /** Whether a trailing invocation is currently scheduled. */ + pending: () => boolean; +} + +/** + * @name throttle + * @category Functions + * @description Invokes a function at most once per `wait` ms + * + * @param {Function} fn - The function to throttle + * @param {number | (() => number)} wait - Milliseconds to throttle to, or a getter resolved lazily on each call (useful for reactive windows) + * @param {ThrottleOptions} [options] - Leading/trailing edge behavior + * @returns {ThrottledFunction} The throttled function with `cancel()`, `flush()` and `pending()` + * + * @example + * const onScroll = throttle(() => update(), 100); + * window.addEventListener('scroll', onScroll); + * + * @since 0.0.10 + */ +export function throttle(fn: T, wait: number | (() => number), options: ThrottleOptions = {}): ThrottledFunction { + const { leading = true, trailing = true } = options; + const resolveWait = typeof wait === 'function' ? wait : () => wait; + + let timer: ReturnType | undefined; + let pending: (() => ReturnType) | undefined; + let result: ReturnType | undefined; + let lastInvokeTime = 0; + + function invoke(time: number) { + if (pending === undefined) + return; + + lastInvokeTime = time; + result = pending(); + pending = undefined; + } + + const throttled = function (this: ThisParameterType, ...args: Parameters) { + const now = Date.now(); + const wait = resolveWait(); + + // Skip the leading call by pretending we just invoked. + if (lastInvokeTime === 0 && !leading) + lastInvokeTime = now; + + const remaining = wait - (now - lastInvokeTime); + + // The arrow captures the call-time `this` lexically, no aliasing needed. + pending = () => fn.apply(this, args) as ReturnType; + + // Outside the window (or the clock jumped): invoke right away. + if (remaining <= 0 || remaining > wait) { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + + invoke(now); + } + else if (timer === undefined && trailing) { + timer = setTimeout(() => { + timer = undefined; + invoke(leading ? Date.now() : 0); + }, remaining); + } + } as ThrottledFunction; + + throttled.cancel = () => { + if (timer !== undefined) + clearTimeout(timer); + + timer = undefined; + pending = undefined; + lastInvokeTime = 0; + }; + + throttled.flush = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + invoke(Date.now()); + } + + return result; + }; + + throttled.pending = () => timer !== undefined; + + return throttled; +} diff --git a/core/stdlib/src/index.ts b/core/stdlib/src/index.ts index 84a8548..6ee821e 100644 --- a/core/stdlib/src/index.ts +++ b/core/stdlib/src/index.ts @@ -1,6 +1,8 @@ export * from './arrays'; export * from './async'; export * from './bits'; +export * from './collections'; +export * from './functions'; export * from './math'; export * from './objects'; export * from './patterns'; diff --git a/core/stdlib/src/math/basic/clamp/index.test.ts b/core/stdlib/src/math/basic/clamp/index.test.ts index 594a48b..da5668f 100644 --- a/core/stdlib/src/math/basic/clamp/index.test.ts +++ b/core/stdlib/src/math/basic/clamp/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { clamp } from '.'; describe('clamp', () => { diff --git a/core/stdlib/src/math/basic/lerp/index.test.ts b/core/stdlib/src/math/basic/lerp/index.test.ts index dd55732..16de9e0 100644 --- a/core/stdlib/src/math/basic/lerp/index.test.ts +++ b/core/stdlib/src/math/basic/lerp/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { inverseLerp, lerp } from '.'; describe('lerp', () => { @@ -26,6 +26,16 @@ describe('lerp', () => { const result = lerp(0, 10, 1.5); expect(result).toBe(15); }); + + it('interpolates from a non-zero start', () => { + expect(lerp(10, 20, 0.5)).toBe(15); + expect(lerp(-10, 10, 0.25)).toBe(-5); + }); + + it('propagates NaN and Infinity', () => { + expect(lerp(0, 10, Number.NaN)).toBeNaN(); + expect(lerp(0, Number.POSITIVE_INFINITY, 0.5)).toBe(Number.POSITIVE_INFINITY); + }); }); describe('inverseLerp', () => { diff --git a/core/stdlib/src/math/basic/remap/index.test.ts b/core/stdlib/src/math/basic/remap/index.test.ts index 022bae2..73e436f 100644 --- a/core/stdlib/src/math/basic/remap/index.test.ts +++ b/core/stdlib/src/math/basic/remap/index.test.ts @@ -43,4 +43,12 @@ describe('remap', () => { // input range is zero (should return output min) expect(remap(5, 0, 0, 0, 100)).toBe(0); }); + + it('handle a reversed (descending) input range', () => { + // 2 is 80% of the way from 10 down to 0 + expect(remap(2, 10, 0, 0, 100)).toBe(80); + // clamps to the interval regardless of orientation + expect(remap(15, 10, 0, 0, 100)).toBe(0); + expect(remap(-5, 10, 0, 0, 100)).toBe(100); + }); }); diff --git a/core/stdlib/src/math/basic/remap/index.ts b/core/stdlib/src/math/basic/remap/index.ts index fe2bf61..b4038a1 100644 --- a/core/stdlib/src/math/basic/remap/index.ts +++ b/core/stdlib/src/math/basic/remap/index.ts @@ -19,7 +19,9 @@ export function remap(value: number, in_min: number, in_max: number, out_min: nu if (in_min === in_max) return out_min; - const clampedValue = clamp(value, in_min, in_max); + // Clamp to the interval's actual bounds so a reversed (descending) input range still works; + // inverseLerp itself handles in_min > in_max correctly. + const clampedValue = clamp(value, Math.min(in_min, in_max), Math.max(in_min, in_max)); return lerp(out_min, out_max, inverseLerp(in_min, in_max, clampedValue)); } diff --git a/core/stdlib/src/math/bigint/clampBigInt/index.test.ts b/core/stdlib/src/math/bigint/clampBigInt/index.test.ts index b80db2b..1253698 100644 --- a/core/stdlib/src/math/bigint/clampBigInt/index.test.ts +++ b/core/stdlib/src/math/bigint/clampBigInt/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { clampBigInt } from '.'; describe('clampBigInt', () => { diff --git a/core/stdlib/src/math/bigint/lerpBigInt/index.test.ts b/core/stdlib/src/math/bigint/lerpBigInt/index.test.ts index b955b4b..4399175 100644 --- a/core/stdlib/src/math/bigint/lerpBigInt/index.test.ts +++ b/core/stdlib/src/math/bigint/lerpBigInt/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { inverseLerpBigInt, lerpBigInt } from '.'; const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); @@ -28,6 +28,20 @@ describe('lerpBigInt', () => { const result = lerpBigInt(0n, 10n, 1.5); expect(result).toBe(15n); }); + + it('truncates the fractional part toward zero', () => { + expect(lerpBigInt(0n, 10n, 0.29)).toBe(2n); // 2.9 -> 2 + expect(lerpBigInt(0n, -10n, 0.29)).toBe(-2n); // -2.9 -> -2 (toward zero, asymmetric) + }); + + it('stays exact for very large bigint ranges', () => { + expect(lerpBigInt(0n, 10n ** 30n, 0.5)).toBe(5n * 10n ** 29n); + }); + + it('interpolates a reversed (start > end) range', () => { + expect(lerpBigInt(10n, 0n, 0.5)).toBe(5n); + expect(lerpBigInt(10n, 0n, 0.25)).toBe(8n); // 10 + (-10 * 0.25) = 7.5 -> 8? truncation + }); }); describe('inverseLerpBigInt', () => { @@ -61,23 +75,20 @@ describe('inverseLerpBigInt', () => { expect(result).toBe(0); }); - it('handles the maximum safe integer correctly', () => { - const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER); - expect(result).toBe(1); + it('returns 1 at the maximum safe integer', () => { + expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER)).toBe(1); }); - it('handles values just above the maximum safe integer correctly', () => { - const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, 0n); - expect(result).toBe(0); + it('returns 0 at the start of a max-safe-integer range', () => { + expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, 0n)).toBe(0); }); - it('handles values just below the maximum safe integer correctly', () => { - const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER); - expect(result).toBe(1); + it('returns the midpoint of a max-safe-integer range', () => { + // 6-decimal SCALE quantizes the result, so allow ~1e-6 tolerance. + expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER / 2n)).toBeCloseTo(0.5, 5); }); - it('handles values just above the maximum safe integer correctly', () => { - const result = inverseLerpBigInt(0n, 2n ** 128n, 2n ** 127n); - expect(result).toBe(0.5); + it('handles values far beyond 2^53', () => { + expect(inverseLerpBigInt(0n, 2n ** 128n, 2n ** 127n)).toBe(0.5); }); }); diff --git a/core/stdlib/src/math/bigint/maxBigInt/index.test.ts b/core/stdlib/src/math/bigint/maxBigInt/index.test.ts index 165d43b..fc55e4d 100644 --- a/core/stdlib/src/math/bigint/maxBigInt/index.test.ts +++ b/core/stdlib/src/math/bigint/maxBigInt/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { maxBigInt } from '.'; describe('maxBigInt', () => { diff --git a/core/stdlib/src/math/bigint/minBigInt/index.test.ts b/core/stdlib/src/math/bigint/minBigInt/index.test.ts index 38f8f4b..dce3fc3 100644 --- a/core/stdlib/src/math/bigint/minBigInt/index.test.ts +++ b/core/stdlib/src/math/bigint/minBigInt/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { minBigInt } from '.'; describe('minBigInt', () => { diff --git a/core/stdlib/src/math/bigint/remapBigInt/index.test.ts b/core/stdlib/src/math/bigint/remapBigInt/index.test.ts index 84dc691..f3ea245 100644 --- a/core/stdlib/src/math/bigint/remapBigInt/index.test.ts +++ b/core/stdlib/src/math/bigint/remapBigInt/index.test.ts @@ -29,4 +29,14 @@ describe('remapBigInt', () => { // input range is zero (should return output min) expect(remapBigInt(5n, 0n, 0n, 0n, 100n)).toBe(0n); }); + + it('stay exact for large ranges (no number round-trip)', () => { + // 1/3 of 10^30, computed entirely in BigInt — only truncation, no float precision loss + expect(remapBigInt(1n, 0n, 3n, 0n, 10n ** 30n)).toBe(333333333333333333333333333333n); + }); + + it('preserve precision well beyond 2^53', () => { + const huge = 10n ** 40n; + expect(remapBigInt(1n, 0n, 2n, 0n, huge)).toBe(huge / 2n); + }); }); diff --git a/core/stdlib/src/math/bigint/remapBigInt/index.ts b/core/stdlib/src/math/bigint/remapBigInt/index.ts index c37ce52..c9c32c7 100644 --- a/core/stdlib/src/math/bigint/remapBigInt/index.ts +++ b/core/stdlib/src/math/bigint/remapBigInt/index.ts @@ -1,5 +1,4 @@ import { clampBigInt } from '../clampBigInt'; -import { inverseLerpBigInt, lerpBigInt } from '../lerpBigInt'; /** * @name remapBigInt @@ -21,5 +20,7 @@ export function remapBigInt(value: bigint, in_min: bigint, in_max: bigint, out_m const clampedValue = clampBigInt(value, in_min, in_max); - return lerpBigInt(out_min, out_max, inverseLerpBigInt(in_min, in_max, clampedValue)); + // Stay entirely in BigInt — round-tripping through a JS number (as inverseLerpBigInt does) + // quantizes to ~6 decimals and overflows precision past 2^53, defeating the point of BigInt. + return out_min + ((clampedValue - in_min) * (out_max - out_min)) / (in_max - in_min); } diff --git a/core/stdlib/src/objects/omit/index.test-d.ts b/core/stdlib/src/objects/omit/index.test-d.ts new file mode 100644 index 0000000..890ce8a --- /dev/null +++ b/core/stdlib/src/objects/omit/index.test-d.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { omit } from '.'; + +interface Sample { a: number; b: string; c: boolean } + +describe('omit', () => { + it('removes a single key from the type', () => { + expectTypeOf(omit({ a: 1, b: 'x', c: true } as Sample, 'a')).toEqualTypeOf>(); + }); + + it('removes multiple keys from the type', () => { + expectTypeOf(omit({ a: 1, b: 'x', c: true } as Sample, ['a', 'b'])).toEqualTypeOf>(); + }); +}); diff --git a/core/stdlib/src/objects/omit/index.test.ts b/core/stdlib/src/objects/omit/index.test.ts index 2d96a4b..8fb84fa 100644 --- a/core/stdlib/src/objects/omit/index.test.ts +++ b/core/stdlib/src/objects/omit/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { omit } from '.'; describe('omit', () => { diff --git a/core/stdlib/src/objects/omit/index.ts b/core/stdlib/src/objects/omit/index.ts index 5c825c8..fa21ab9 100644 --- a/core/stdlib/src/objects/omit/index.ts +++ b/core/stdlib/src/objects/omit/index.ts @@ -22,18 +22,20 @@ export function omit( target: Target, keys: Arrayable, ): Omit { - const result = { ...target }; + const result = {} as Omit; - if (!target || !keys) + if (!target) return result; - if (isArray(keys)) { - for (const key of keys) { - delete result[key]; - } - } - else { - delete result[keys]; + // Build the kept-keys object directly instead of spread-then-delete: `delete` forces V8 + // to drop the object into slow dictionary mode, penalizing all later property access. + const omitted = new Set( + keys === null || keys === undefined ? [] : isArray(keys) ? keys : [keys], + ); + + for (const key in target) { + if (Object.hasOwn(target, key) && !omitted.has(key)) + (result as Record)[key] = target[key]; } return result; diff --git a/core/stdlib/src/objects/pick/index.test-d.ts b/core/stdlib/src/objects/pick/index.test-d.ts new file mode 100644 index 0000000..137d436 --- /dev/null +++ b/core/stdlib/src/objects/pick/index.test-d.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { pick } from '.'; + +interface Sample { a: number; b: string; c: boolean } + +describe('pick', () => { + it('narrows to a single picked key', () => { + expectTypeOf(pick({ a: 1, b: 'x', c: true } as Sample, 'a')).toEqualTypeOf>(); + }); + + it('narrows to multiple picked keys', () => { + expectTypeOf(pick({ a: 1, b: 'x', c: true } as Sample, ['a', 'b'])).toEqualTypeOf>(); + }); +}); diff --git a/core/stdlib/src/objects/pick/index.test.ts b/core/stdlib/src/objects/pick/index.test.ts index 9475327..e051f7b 100644 --- a/core/stdlib/src/objects/pick/index.test.ts +++ b/core/stdlib/src/objects/pick/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { pick } from '.'; describe('pick', () => { @@ -23,7 +23,11 @@ describe('pick', () => { it('handle non-existent keys by setting them to undefined', () => { const result = pick({ a: 1, b: 2 }, ['a', 'c'] as any); - expect(result).toEqual({ a: 1, c: undefined }); + // toEqual ignores undefined values, so assert key presence explicitly. + expect(Object.keys(result).sort()).toEqual(['a', 'c']); + expect('c' in result).toBe(true); + expect((result as Record).c).toBeUndefined(); + expect(result.a).toBe(1); }); it('return an empty object if target is null or undefined', () => { diff --git a/core/stdlib/src/patterns/behavioral/Command/index.test.ts b/core/stdlib/src/patterns/behavioral/Command/index.test.ts index 7e29ce9..4985d06 100644 --- a/core/stdlib/src/patterns/behavioral/Command/index.test.ts +++ b/core/stdlib/src/patterns/behavioral/Command/index.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { CommandHistory, AsyncCommandHistory } from '.'; -import type { Command, AsyncCommand } from '.'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { AsyncCommandHistory, CommandHistory } from '.'; +import type { AsyncCommand, Command } from '.'; describe('commandHistory', () => { let history: CommandHistory; @@ -278,4 +278,43 @@ describe('asyncCommandHistory', () => { expect(limited.size).toBe(2); }); + + describe('error handling', () => { + it('does not record a sync command whose execute throws', () => { + const h = new CommandHistory(); + const cmd: Command = { + execute: () => { + throw new Error('boom'); + }, + undo: () => {}, + }; + + expect(() => h.execute(cmd)).toThrow('boom'); + expect(h.size).toBe(0); + expect(h.undo()).toBe(false); + }); + + it('does not record an async command whose execute rejects', async () => { + const h = new AsyncCommandHistory(); + const cmd: AsyncCommand = { + execute: () => Promise.reject(new Error('boom')), + undo: () => Promise.resolve(), + }; + + await expect(h.execute(cmd)).rejects.toThrow('boom'); + expect(h.size).toBe(0); + expect(await h.undo()).toBe(false); + }); + + it('propagates a rejecting undo', async () => { + const h = new AsyncCommandHistory(); + const cmd: AsyncCommand = { + execute: () => Promise.resolve(), + undo: () => Promise.reject(new Error('undo failed')), + }; + + await h.execute(cmd); + await expect(h.undo()).rejects.toThrow('undo failed'); + }); + }); }); diff --git a/core/stdlib/src/patterns/behavioral/PubSub/index.test.ts b/core/stdlib/src/patterns/behavioral/PubSub/index.test.ts index c2406e8..1885291 100644 --- a/core/stdlib/src/patterns/behavioral/PubSub/index.test.ts +++ b/core/stdlib/src/patterns/behavioral/PubSub/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PubSub } from '.'; describe('pubsub', () => { @@ -115,4 +115,54 @@ describe('pubsub', () => { expect(listener).toHaveBeenCalledTimes(1); }); + + describe('off edge cases', () => { + it('removes only the targeted listener of many', () => { + const a = vi.fn(); + const b = vi.fn(); + eventBus.on('event1', a); + eventBus.on('event1', b); + + eventBus.off('event1', a); + eventBus.emit('event1', 'x'); + + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when removing an unregistered listener or unknown event', () => { + const a = vi.fn(); + eventBus.on('event1', a); + + expect(() => eventBus.off('event1', vi.fn())).not.toThrow(); + expect(() => eventBus.off('event2', vi.fn())).not.toThrow(); + + eventBus.emit('event1', 'x'); + expect(a).toHaveBeenCalledTimes(1); + }); + }); + + describe('emit stability', () => { + it('does not invoke listeners added during the same emit', () => { + const added = vi.fn(); + const adder = vi.fn(() => eventBus.on('event1', added)); + eventBus.on('event1', adder); + + eventBus.emit('event1', 'x'); + + expect(adder).toHaveBeenCalledTimes(1); + expect(added).not.toHaveBeenCalled(); // only fires on the next emit + }); + + it('a once listener removes itself and fires exactly once', () => { + const listener = vi.fn(); + eventBus.once('event1', listener); + + eventBus.emit('event1', 'a'); + eventBus.emit('event1', 'b'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('a'); + }); + }); }); diff --git a/core/stdlib/src/patterns/behavioral/PubSub/index.ts b/core/stdlib/src/patterns/behavioral/PubSub/index.ts index 82b22fb..3b6990a 100644 --- a/core/stdlib/src/patterns/behavioral/PubSub/index.ts +++ b/core/stdlib/src/patterns/behavioral/PubSub/index.ts @@ -92,7 +92,11 @@ export class PubSub any>> { if (!listeners) return false; - listeners.forEach(listener => listener(...args)); + // Snapshot first: iterating the live Set would invoke listeners added during this + // emit (and is fragile to self-removal). A copy gives stable EventEmitter semantics. + const snapshot = [...listeners]; + for (const listener of snapshot) + listener(...args); return true; } diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/async.ts b/core/stdlib/src/patterns/behavioral/StateMachine/async.ts index 4844698..13b6a63 100644 --- a/core/stdlib/src/patterns/behavioral/StateMachine/async.ts +++ b/core/stdlib/src/patterns/behavioral/StateMachine/async.ts @@ -1,6 +1,6 @@ import { isString } from '../../../types'; import { BaseStateMachine } from './base'; -import type { AsyncStateNodeConfig, ExtractStates, ExtractEvents } from './types'; +import type { AsyncStateNodeConfig, ExtractEvents, ExtractStates } from './types'; /** * @name AsyncStateMachine diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/index.test-d.ts b/core/stdlib/src/patterns/behavioral/StateMachine/index.test-d.ts new file mode 100644 index 0000000..361aec2 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/index.test-d.ts @@ -0,0 +1,24 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { createMachine } from '.'; + +describe('createMachine', () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { START: 'running' } }, + running: { on: { STOP: 'idle' } }, + }, + }); + + it('infers the state union from the states config', () => { + expectTypeOf(machine.current).toEqualTypeOf<'idle' | 'running'>(); + }); + + it('infers the event union accepted by send', () => { + expectTypeOf(machine.send).parameter(0).toEqualTypeOf<'START' | 'STOP'>(); + }); + + it('send returns the (typed) resulting state', () => { + expectTypeOf(machine.send('START')).toEqualTypeOf<'idle' | 'running'>(); + }); +}); diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts b/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts index f97f773..4fcdffd 100644 --- a/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts +++ b/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createMachine, createAsyncMachine, StateMachine, AsyncStateMachine } from '.'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AsyncStateMachine, StateMachine, createAsyncMachine, createMachine } from '.'; describe('stateMachine', () => { describe('createMachine (without context)', () => { @@ -685,4 +685,23 @@ describe('asyncStateMachine', () => { expect(entryHook).toHaveBeenCalledOnce(); }); }); + + describe('transition into an unknown target state', () => { + it('silently moves to a target that has no state node, then dead-ends', () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { GO: 'ghost' } }, // 'ghost' is not defined in states + }, + }); + + // Documents current behavior: the transition is accepted, no throw. + expect(machine.send('GO')).toBe('ghost'); + expect(machine.current).toBe('ghost'); + + // From the undefined state there are no transitions — further sends are no-ops. + expect(machine.send('GO')).toBe('ghost'); + expect(machine.can('GO')).toBe(false); + }); + }); }); diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts b/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts index 9dd24d6..3c9c8c3 100644 --- a/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts +++ b/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts @@ -1,6 +1,6 @@ import { isString } from '../../../types'; import { BaseStateMachine } from './base'; -import type { SyncStateNodeConfig, ExtractStates, ExtractEvents } from './types'; +import type { ExtractEvents, ExtractStates, SyncStateNodeConfig } from './types'; /** * @name StateMachine diff --git a/core/stdlib/src/structs/BinaryHeap/index.test.ts b/core/stdlib/src/structs/BinaryHeap/index.test.ts index 3a3e7c9..cf8bfe1 100644 --- a/core/stdlib/src/structs/BinaryHeap/index.test.ts +++ b/core/stdlib/src/structs/BinaryHeap/index.test.ts @@ -32,6 +32,21 @@ describe('BinaryHeap', () => { expect(heap.peek()).toBe(8); }); + + it('should not mutate the input array', () => { + const input = [5, 3, 8, 1, 4]; + + new BinaryHeap(input); + + expect(input).toEqual([5, 3, 8, 1, 4]); + }); + + it('should not overflow the stack for a very large initial array', () => { + const big = Array.from({ length: 200_000 }, (_, i) => 200_000 - i); + + expect(() => new BinaryHeap(big)).not.toThrow(); + expect(new BinaryHeap(big).peek()).toBe(1); + }); }); describe('push', () => { @@ -226,4 +241,37 @@ describe('BinaryHeap', () => { expect(heap.pop()).toBeUndefined(); }); }); + + describe('nullable / falsy elements', () => { + it('peek/pop return a legitimately stored null root (not undefined)', () => { + // Comparator that ranks null before any number. + const heap = new BinaryHeap([5, null, 3], { + comparator: (a, b) => (a === null ? -1 : b === null ? 1 : a - b), + }); + + expect(heap.length).toBe(3); + expect(heap.peek()).toBeNull(); + expect(heap.pop()).toBeNull(); + expect(heap.length).toBe(2); + }); + + it('peek returns a 0 root rather than collapsing to undefined', () => { + const heap = new BinaryHeap([0, 5, 3]); + + expect(heap.peek()).toBe(0); + }); + }); + + describe('async iteration', () => { + it('yields every element with the root (min) first', async () => { + const heap = new BinaryHeap([5, 3, 8, 1]); + const out: number[] = []; + + for await (const value of heap) + out.push(value); + + expect(out[0]).toBe(1); // heap array order — root first + expect([...out].sort((a, b) => a - b)).toEqual([1, 3, 5, 8]); + }); + }); }); diff --git a/core/stdlib/src/structs/BinaryHeap/index.ts b/core/stdlib/src/structs/BinaryHeap/index.ts index 71a193f..60e7449 100644 --- a/core/stdlib/src/structs/BinaryHeap/index.ts +++ b/core/stdlib/src/structs/BinaryHeap/index.ts @@ -1,4 +1,3 @@ -import { first } from '../../arrays'; import { isArray } from '../../types'; import type { BinaryHeapLike, Comparator } from './types'; @@ -54,7 +53,11 @@ export class BinaryHeap implements BinaryHeapLike { if (initialValues !== null && initialValues !== undefined) { const items = isArray(initialValues) ? initialValues : [initialValues]; - this.heap.push(...items); + + // Avoid push(...items): spreading a large array as arguments overflows the call stack. + for (const item of items) + this.heap.push(item); + this.heapify(); } } @@ -91,7 +94,7 @@ export class BinaryHeap implements BinaryHeapLike { public pop(): T | undefined { if (this.heap.length === 0) return undefined; - const root = first(this.heap)!; + const root = this.heap[0]!; const last = this.heap.pop()!; if (this.heap.length > 0) { @@ -107,7 +110,8 @@ export class BinaryHeap implements BinaryHeapLike { * @returns {T | undefined} The root element, or `undefined` if the heap is empty */ public peek(): T | undefined { - return first(this.heap); + // Direct index preserves a legitimately stored null/undefined root (length is the empty check). + return this.heap.length === 0 ? undefined : this.heap[0]; } /** diff --git a/core/stdlib/src/structs/CircularBuffer/index.test.ts b/core/stdlib/src/structs/CircularBuffer/index.test.ts index e34a5b8..8e07627 100644 --- a/core/stdlib/src/structs/CircularBuffer/index.test.ts +++ b/core/stdlib/src/structs/CircularBuffer/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { CircularBuffer } from '.'; describe('circularBuffer', () => { diff --git a/core/stdlib/src/structs/CircularBuffer/index.ts b/core/stdlib/src/structs/CircularBuffer/index.ts index ced95bd..b12a4ad 100644 --- a/core/stdlib/src/structs/CircularBuffer/index.ts +++ b/core/stdlib/src/structs/CircularBuffer/index.ts @@ -53,7 +53,8 @@ export class CircularBuffer implements CircularBufferLike { const requested = Math.max(items.length, initialCapacity ?? 0); const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested)); - this.buffer = Array.from({ length: cap }); + // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring (Array.from({length}) is ~40x slower) + this.buffer = new Array(cap); for (const item of items) this.pushBack(item); @@ -190,7 +191,8 @@ export class CircularBuffer implements CircularBufferLike { * @returns {this} */ clear() { - this.buffer = Array.from({ length: MIN_CAPACITY }); + // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring (Array.from({length}) is ~40x slower) + this.buffer = new Array(MIN_CAPACITY); this.head = 0; this.count = 0; @@ -203,7 +205,8 @@ export class CircularBuffer implements CircularBufferLike { * @returns {T[]} */ toArray() { - const result = Array.from({ length: this.count }); + // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size result (filled below; Array.from({length}) is ~40x slower) + const result = new Array(this.count); for (let i = 0; i < this.count; i++) result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T; @@ -225,8 +228,12 @@ export class CircularBuffer implements CircularBufferLike { * * @returns {IterableIterator} */ - [Symbol.iterator]() { - return this.toArray()[Symbol.iterator](); + * [Symbol.iterator](): IterableIterator { + // Lazy walk over the ring — no intermediate array snapshot allocation. + const mask = this.buffer.length - 1; + + for (let i = 0; i < this.count; i++) + yield this.buffer[(this.head + i) & mask] as T; } /** @@ -246,7 +253,8 @@ export class CircularBuffer implements CircularBufferLike { */ private grow() { const newCapacity = this.buffer.length << 1; - const newBuffer = Array.from({ length: newCapacity }); + // eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring on the amortized push hot path + const newBuffer = new Array(newCapacity); for (let i = 0; i < this.count; i++) newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)]; diff --git a/core/stdlib/src/structs/Deque/index.test.ts b/core/stdlib/src/structs/Deque/index.test.ts index cb71006..f050ea8 100644 --- a/core/stdlib/src/structs/Deque/index.test.ts +++ b/core/stdlib/src/structs/Deque/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Deque } from '.'; describe('deque', () => { @@ -31,6 +31,22 @@ describe('deque', () => { expect(deque.length).toBe(0); expect(deque.isFull).toBe(false); }); + + it('throw when initial values exceed maxSize', () => { + expect(() => new Deque([1, 2, 3, 4, 5], { maxSize: 3 })).toThrow(RangeError); + }); + + it('enforce maxSize against subsequent pushes', () => { + const deque = new Deque([1, 2], { maxSize: 2 }); + + expect(deque.isFull).toBe(true); + expect(() => deque.pushBack(3)).toThrow(RangeError); + expect(() => deque.pushFront(0)).toThrow(RangeError); + + deque.popFront(); + expect(deque.isFull).toBe(false); + expect(() => deque.pushBack(3)).not.toThrow(); + }); }); describe('pushBack', () => { diff --git a/core/stdlib/src/structs/Deque/index.ts b/core/stdlib/src/structs/Deque/index.ts index a649631..3a32317 100644 --- a/core/stdlib/src/structs/Deque/index.ts +++ b/core/stdlib/src/structs/Deque/index.ts @@ -42,6 +42,9 @@ export class Deque implements DequeLike { constructor(initialValues?: T[] | T, options?: DequeOptions) { this.maxSize = options?.maxSize ?? Infinity; this.buffer = new CircularBuffer(initialValues); + + if (this.buffer.length > this.maxSize) + throw new RangeError('Deque: initial values exceed maxSize'); } /** @@ -65,7 +68,7 @@ export class Deque implements DequeLike { * @returns {boolean} `true` if the deque is full, `false` otherwise */ get isFull() { - return this.buffer.length === this.maxSize; + return this.buffer.length >= this.maxSize; } /** diff --git a/core/stdlib/src/structs/LinkedList/index.test.ts b/core/stdlib/src/structs/LinkedList/index.test.ts index 31867ec..fa35753 100644 --- a/core/stdlib/src/structs/LinkedList/index.test.ts +++ b/core/stdlib/src/structs/LinkedList/index.test.ts @@ -98,6 +98,10 @@ describe('LinkedList', () => { expect(list.popBack()).toBe(3); expect(list.length).toBe(2); expect(list.peekBack()).toBe(2); + // tail pointer must be rewired and detached + expect(list.tail!.value).toBe(2); + expect(list.tail!.next).toBeUndefined(); + expect(list.peekFront()).toBe(1); }); it('should handle single element', () => { @@ -123,6 +127,10 @@ describe('LinkedList', () => { expect(list.popFront()).toBe(1); expect(list.length).toBe(2); expect(list.peekFront()).toBe(2); + // head pointer must be rewired and detached + expect(list.head!.value).toBe(2); + expect(list.head!.prev).toBeUndefined(); + expect(list.peekBack()).toBe(3); }); it('should handle single element', () => { @@ -170,11 +178,14 @@ describe('LinkedList', () => { const list = new LinkedList(); const node = list.pushBack(2); - list.insertBefore(node, 1); + const inserted = list.insertBefore(node, 1); expect(list.peekFront()).toBe(1); expect(list.peekBack()).toBe(2); expect(list.length).toBe(2); + // new node becomes the head with no prev + expect(list.head).toBe(inserted); + expect(inserted.prev).toBeUndefined(); }); it('should insert before middle node', () => { @@ -202,10 +213,13 @@ describe('LinkedList', () => { const list = new LinkedList(); const node = list.pushBack(1); - list.insertAfter(node, 2); + const inserted = list.insertAfter(node, 2); expect(list.peekFront()).toBe(1); expect(list.peekBack()).toBe(2); + // new node becomes the tail with no next + expect(list.tail).toBe(inserted); + expect(inserted.next).toBeUndefined(); expect(list.length).toBe(2); }); diff --git a/core/stdlib/src/structs/PriorityQueue/index.test.ts b/core/stdlib/src/structs/PriorityQueue/index.test.ts index 6b2de46..ff0cf5d 100644 --- a/core/stdlib/src/structs/PriorityQueue/index.test.ts +++ b/core/stdlib/src/structs/PriorityQueue/index.test.ts @@ -209,5 +209,17 @@ describe('PriorityQueue', () => { expect(pq.dequeue()).toBe(10); expect(pq.dequeue()).toBeUndefined(); }); + + it('accept a new element after dequeue frees a slot in a full queue', () => { + const pq = new PriorityQueue([1, 2], { maxSize: 2 }); + + expect(pq.isFull).toBe(true); + expect(() => pq.enqueue(3)).toThrow(RangeError); + + pq.dequeue(); + expect(pq.isFull).toBe(false); + expect(() => pq.enqueue(3)).not.toThrow(); + expect(pq.length).toBe(2); + }); }); }); diff --git a/core/stdlib/src/structs/Queue/index.test.ts b/core/stdlib/src/structs/Queue/index.test.ts index 4a62480..6c9a0d8 100644 --- a/core/stdlib/src/structs/Queue/index.test.ts +++ b/core/stdlib/src/structs/Queue/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Queue } from '.'; describe('queue', () => { @@ -87,7 +87,7 @@ describe('queue', () => { expect(queue.dequeue()).toBeUndefined(); }); - it('compact internal storage after many dequeues', () => { + it('keep correct length and front element after many enqueue/dequeue cycles', () => { const queue = new Queue(); for (let i = 0; i < 100; i++) @@ -99,6 +99,17 @@ describe('queue', () => { expect(queue.length).toBe(20); expect(queue.peek()).toBe(80); }); + + it('report isFull and free a slot after dequeue', () => { + const queue = new Queue(undefined, { maxSize: 2 }); + queue.enqueue(1).enqueue(2); + + expect(queue.isFull).toBe(true); + + queue.dequeue(); + expect(queue.isFull).toBe(false); + expect(() => queue.enqueue(3)).not.toThrow(); + }); }); describe('peek', () => { diff --git a/core/stdlib/src/structs/Stack/index.test.ts b/core/stdlib/src/structs/Stack/index.test.ts index 895a9b4..4b73a6f 100644 --- a/core/stdlib/src/structs/Stack/index.test.ts +++ b/core/stdlib/src/structs/Stack/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Stack } from '.'; describe('stack', () => { @@ -33,6 +33,26 @@ describe('stack', () => { expect(stack.length).toBe(0); expect(stack.isFull).toBe(false); }); + + it('keep a falsy single initial value', () => { + expect(new Stack(0).length).toBe(1); + expect(new Stack(0).peek()).toBe(0); + expect(new Stack('').peek()).toBe(''); + }); + + it('copy the initial array (no aliasing of the caller array)', () => { + const initialValues = [1, 2, 3]; + const stack = new Stack(initialValues); + + initialValues.push(4); + + expect(stack.length).toBe(3); + expect([...stack]).toEqual([3, 2, 1]); + }); + + it('throw when initial values exceed maxSize', () => { + expect(() => new Stack([1, 2, 3], { maxSize: 2 })).toThrow(RangeError); + }); }); describe('push', () => { @@ -51,6 +71,16 @@ describe('stack', () => { expect(() => stack.push(2)).toThrow(new RangeError('Stack is full')); }); + + it('report isFull at the boundary', () => { + const stack = new Stack(undefined, { maxSize: 2 }); + stack.push(1).push(2); + + expect(stack.isFull).toBe(true); + + stack.pop(); + expect(stack.isFull).toBe(false); + }); }); describe('pop', () => { @@ -97,6 +127,22 @@ describe('stack', () => { }); }); + describe('toArray / toString', () => { + it('return elements in LIFO order as a distinct array', () => { + const stack = new Stack([1, 2, 3]); + const arr = stack.toArray(); + + expect(arr).toEqual([3, 2, 1]); + + arr.push(99); + expect(stack.length).toBe(3); + }); + + it('stringify in LIFO order', () => { + expect(new Stack([1, 2, 3]).toString()).toBe('3,2,1'); + }); + }); + describe('iteration', () => { it('iterate over the stack in LIFO order', () => { const stack = new Stack([1, 2, 3]); diff --git a/core/stdlib/src/structs/Stack/index.ts b/core/stdlib/src/structs/Stack/index.ts index 6ef3e8c..b85de49 100644 --- a/core/stdlib/src/structs/Stack/index.ts +++ b/core/stdlib/src/structs/Stack/index.ts @@ -1,4 +1,3 @@ -import { last } from '../../arrays'; import { isArray } from '../../types'; import type { StackLike } from './types'; @@ -43,7 +42,17 @@ export class Stack implements StackLike { */ constructor(initialValues?: T[] | T, options?: StackOptions) { this.maxSize = options?.maxSize ?? Infinity; - this.stack = isArray(initialValues) ? initialValues : initialValues ? [initialValues] : []; + + // Copy the input so external mutation can't corrupt internal state, and use an + // explicit nullish check so falsy single values (0, '', false) are not dropped. + const values = isArray(initialValues) + ? initialValues.slice() + : initialValues !== null && initialValues !== undefined ? [initialValues] : []; + + if (values.length > this.maxSize) + throw new RangeError('Stack: initial values exceed maxSize'); + + this.stack = values; } /** @@ -67,7 +76,7 @@ export class Stack implements StackLike { * @returns {boolean} `true` if the stack is full, `false` otherwise */ public get isFull() { - return this.stack.length === this.maxSize; + return this.stack.length >= this.maxSize; } /** @@ -98,10 +107,9 @@ export class Stack implements StackLike { * @returns {T | undefined} The top element of the stack */ public peek() { - if (this.isEmpty) - return undefined; - - return last(this.stack); + // Direct index preserves a legitimately stored `undefined`/`null` top and + // returns `undefined` for an empty stack (noUncheckedIndexedAccess). + return this.stack[this.stack.length - 1]; } /** @@ -138,8 +146,10 @@ export class Stack implements StackLike { * * @returns {IterableIterator} */ - public [Symbol.iterator]() { - return this.toArray()[Symbol.iterator](); + public* [Symbol.iterator]() { + // Lazy reverse walk — no eager reversed-copy allocation. + for (let i = this.stack.length - 1; i >= 0; i--) + yield this.stack[i]!; } /** @@ -148,8 +158,7 @@ export class Stack implements StackLike { * @returns {AsyncIterableIterator} */ public async* [Symbol.asyncIterator]() { - for (const element of this.toArray()) { - yield element; - } + for (let i = this.stack.length - 1; i >= 0; i--) + yield this.stack[i]!; } } diff --git a/core/stdlib/src/sync/mutex/index.test.ts b/core/stdlib/src/sync/mutex/index.test.ts index 09b2ed3..64a3f5e 100644 --- a/core/stdlib/src/sync/mutex/index.test.ts +++ b/core/stdlib/src/sync/mutex/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncMutex } from '.'; describe('SyncMutex', () => { @@ -91,4 +91,21 @@ describe('SyncMutex', () => { expect(mutex.isLocked).toBe(false); }); + + it('releases the lock and rethrows when the callback throws synchronously', async () => { + await expect(mutex.execute(() => { + throw new Error('boom'); + })).rejects.toThrow('boom'); + + expect(mutex.isLocked).toBe(false); + + // The mutex must still be usable afterwards. + await expect(mutex.execute(() => Promise.resolve('ok'))).resolves.toBe('ok'); + }); + + it('releases the lock and rejects when the callback returns a rejecting promise', async () => { + await expect(mutex.execute(() => Promise.reject(new Error('async boom')))).rejects.toThrow('async boom'); + + expect(mutex.isLocked).toBe(false); + }); }); diff --git a/core/stdlib/src/sync/mutex/index.ts b/core/stdlib/src/sync/mutex/index.ts index 1485125..5e3d9c1 100644 --- a/core/stdlib/src/sync/mutex/index.ts +++ b/core/stdlib/src/sync/mutex/index.ts @@ -37,9 +37,14 @@ export class SyncMutex { return; this.lock(); - const result = await callback(); - this.unlock(); - return result; + // try/finally guarantees the lock is released even if the callback throws or + // rejects — otherwise a single failure would wedge the guarded section forever. + try { + return await callback(); + } + finally { + this.unlock(); + } } } diff --git a/core/stdlib/src/text/index.ts b/core/stdlib/src/text/index.ts index 824ca09..22b867e 100644 --- a/core/stdlib/src/text/index.ts +++ b/core/stdlib/src/text/index.ts @@ -1,4 +1,3 @@ export * from './levenshtein-distance'; +export * from './template'; export * from './trigram-distance'; -// TODO: Template is not implemented yet -// export * from './template'; diff --git a/core/stdlib/src/text/levenshtein-distance/index.test.ts b/core/stdlib/src/text/levenshtein-distance/index.test.ts index 94ac0d5..e670032 100644 --- a/core/stdlib/src/text/levenshtein-distance/index.test.ts +++ b/core/stdlib/src/text/levenshtein-distance/index.test.ts @@ -29,4 +29,15 @@ describe('levenshteinDistance', () => { expect(levenshteinDistance('abc', '')).toBe(3); expect(levenshteinDistance('', 'abc')).toBe(3); }); + + it('is symmetric', () => { + expect(levenshteinDistance('kitten', 'sitting')).toBe(levenshteinDistance('sitting', 'kitten')); + expect(levenshteinDistance('football', 'foot')).toBe(levenshteinDistance('foot', 'football')); + }); + + it('counts UTF-16 code units (surrogate pairs count as two)', () => { + expect(levenshteinDistance('😀', '')).toBe(2); // surrogate pair = 2 code units + expect(levenshteinDistance('a😀b', 'a😀b')).toBe(0); + expect(levenshteinDistance('café', 'cafe')).toBe(1); + }); }); diff --git a/core/stdlib/src/text/levenshtein-distance/index.ts b/core/stdlib/src/text/levenshtein-distance/index.ts index 6aff978..4176c7d 100644 --- a/core/stdlib/src/text/levenshtein-distance/index.ts +++ b/core/stdlib/src/text/levenshtein-distance/index.ts @@ -15,32 +15,34 @@ export function levenshteinDistance(left: string, right: string): number { if (left.length === 0) return right.length; if (right.length === 0) return left.length; - // Create empty edit distance matrix for all possible modifications of - // substrings of left to substrings of right - const distanceMatrix = Array(right.length + 1).fill(null).map(() => Array(left.length + 1).fill(null)); + // Iterate with the shorter string as the inner dimension so the rolling rows are + // O(min(m, n)) memory instead of a full O(m * n) matrix. + const outer = left.length >= right.length ? left : right; + const inner = left.length >= right.length ? right : left; + const innerLength = inner.length; - // Fill the first row of the matrix - // If this is the first row, we're transforming from an empty string to left - // In this case, the number of operations equals the length of left substring - for (let i = 0; i <= left.length; i++) - distanceMatrix[0]![i]! = i; + // prev = previous row; current = row being computed. prev starts as the base row [0..innerLength]. + let prev = Array.from({ length: innerLength + 1 }, (_, i) => i); + let current = Array.from({ length: innerLength + 1 }); - // Fill the first column of the matrix - // If this is the first column, we're transforming empty string to right - // In this case, the number of operations equals the length of right substring - for (let j = 0; j <= right.length; j++) - distanceMatrix[j]![0]! = j; + for (let i = 1; i <= outer.length; i++) { + current[0] = i; + const outerChar = outer[i - 1]; - for (let j = 1; j <= right.length; j++) { - for (let i = 1; i <= left.length; i++) { - const indicator = left[i - 1] === right[j - 1] ? 0 : 1; - distanceMatrix[j]![i]! = Math.min( - distanceMatrix[j]![i - 1]! + 1, // deletion - distanceMatrix[j - 1]![i]! + 1, // insertion - distanceMatrix[j - 1]![i - 1]! + indicator, // substitution + for (let j = 1; j <= innerLength; j++) { + const cost = outerChar === inner[j - 1] ? 0 : 1; + current[j]! = Math.min( + prev[j]! + 1, // insertion + current[j - 1]! + 1, // deletion + prev[j - 1]! + cost, // substitution ); } + + // Swap the rolling rows; the freshly computed row becomes `prev` for the next iteration. + const next = prev; + prev = current; + current = next; } - return distanceMatrix[right.length]![left.length]!; + return prev[innerLength]!; } diff --git a/core/stdlib/src/text/template/index.test-d.ts b/core/stdlib/src/text/template/index.test-d.ts index 81ad8a9..e06ea07 100644 --- a/core/stdlib/src/text/template/index.test-d.ts +++ b/core/stdlib/src/text/template/index.test-d.ts @@ -1,7 +1,8 @@ import { describe, expectTypeOf, it } from 'vitest'; -import type { ClearPlaceholder, ExtractPlaceholders } from './index'; +import type { ClearPlaceholder, ExtractPlaceholders, GenerateTypes } from './index'; +import { templateObject } from './index'; -describe.skip('template', () => { +describe('template', () => { describe('ClearPlaceholder', () => { it('ignores strings without braces', () => { type actual = ClearPlaceholder<'name'>; @@ -102,4 +103,36 @@ describe.skip('template', () => { expectTypeOf().toEqualTypeOf(); }); }); + + describe('GenerateTypes', () => { + type Shape = GenerateTypes<'user.name', string>; + + it('accepts a fully-matching shape', () => { + expectTypeOf<{ user: { name: 'John' } }>().toExtend(); + }); + + it('accepts missing keys (every key is optional)', () => { + expectTypeOf<{ unrelated: number }>().toExtend(); + }); + + it('accepts extra keys (objects stay open)', () => { + expectTypeOf<{ user: { name: 'John' }; extra: number }>().toExtend(); + }); + + it('rejects a mistyped leaf value', () => { + expectTypeOf<{ user: { name: number } }>().not.toExtend(); + }); + }); + + describe('templateObject', () => { + it('always returns a string', () => { + expectTypeOf(templateObject('Hello, {name}!', { name: 'John' })).toEqualTypeOf(); + expectTypeOf(templateObject('Hi {user.name}', { user: { name: 'John' } })).toEqualTypeOf(); + }); + + it('accepts a string or factory fallback', () => { + expectTypeOf(templateObject('Hello, {name}!', {}, 'Guest')).toEqualTypeOf(); + expectTypeOf(templateObject('Hello, {name}!', {}, key => `<${key}>`)).toEqualTypeOf(); + }); + }); }); diff --git a/core/stdlib/src/text/template/index.test.ts b/core/stdlib/src/text/template/index.test.ts index 5bb8a53..4ac001c 100644 --- a/core/stdlib/src/text/template/index.test.ts +++ b/core/stdlib/src/text/template/index.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import { templateObject } from '.'; -describe.skip('templateObject', () => { - it('replace template placeholders with corresponding values from args', () => { +describe('templateObject', () => { + it('replace an indexed array placeholder', () => { const template = 'Hello, {names.0}!'; const args = { names: ['John'] }; const result = templateObject(template, args); expect(result).toBe('Hello, John!'); }); - it('replace template placeholders with corresponding values from args', () => { + it('replace a simple key placeholder', () => { const template = 'Hello, {name}!'; const args = { name: 'John' }; const result = templateObject(template, args); @@ -45,4 +45,23 @@ describe.skip('templateObject', () => { expect(result).toBe('Hello {John Doe, your address 123 Main St'); }); + + it('replace a missing placeholder with an empty string by default', () => { + expect(templateObject('Hello, {name}!', {})).toBe('Hello, !'); + }); + + it('render falsy-but-present values (0, false, empty string)', () => { + expect(templateObject('count: {n}', { n: 0 })).toBe('count: 0'); + expect(templateObject('flag: {b}', { b: false })).toBe('flag: false'); + expect(templateObject('s:{s}.', { s: '' })).toBe('s:.'); + }); + + it('trim whitespace inside the braces', () => { + expect(templateObject('Hi { name }!', { name: 'Jo' })).toBe('Hi Jo!'); + }); + + it('leave a template without placeholders untouched', () => { + expect(templateObject('no placeholders here', {})).toBe('no placeholders here'); + expect(templateObject('', {})).toBe(''); + }); }); diff --git a/core/stdlib/src/text/template/index.ts b/core/stdlib/src/text/template/index.ts index 06f6a55..1ee2584 100644 --- a/core/stdlib/src/text/template/index.ts +++ b/core/stdlib/src/text/template/index.ts @@ -1,6 +1,6 @@ +import type { Collection, Path, PathToPartialType, Stringable, Trim, UnionToIntersection } from '../../types'; import { get } from '../../collections'; import { isFunction } from '../../types'; -import type { Collection, Path, PathToType, Stringable, Trim, UnionToIntersection } from '../../types'; /** * Type of a value that will be used to replace a placeholder in a template. @@ -52,23 +52,45 @@ export type ExtractPlaceholders * type Base = GenerateTypes<'Hello {user.name}, your address {user.addresses.0.street}'>; // { user: { name: string; addresses: { 0: { street: string; }; }; }; } * type WithTarget = GenerateTypes<'Hello {user.age}', number>; // { user: { age: number; }; } */ -export type GenerateTypes = UnionToIntersection, Target>>; +export type GenerateTypes + // No placeholders (T is never) → impose no shape on the args object. + = [T] extends [never] + ? Collection + : UnionToIntersection, Target>>; +/** + * @name templateObject + * @category Text + * @description Replace `{path}` placeholders in a template string with values + * resolved from `args` by dot-path. Placeholder keys are inferred from the + * template, so `args` is type-checked and auto-completed against them. + * + * @param {string} template - Template string with `{path}` placeholders + * @param {object} args - Source values, keyed by the placeholder paths + * @param {string | ((key: string) => string)} [fallback] - Value (or factory) used when a placeholder cannot be resolved; defaults to an empty string + * @returns {string} The interpolated string + * + * @example + * templateObject('Hello, {name}!', { name: 'John' }); // 'Hello, John!' + * templateObject('Hi {user.addresses.0.city}', { user: { addresses: [{ city: 'NY' }] } }); // 'Hi NY' + * templateObject('Hello, {name}!', {}, 'Guest'); // 'Hello, Guest!' + * templateObject('Hello, {name}!', {}, key => `<${key}>`); // 'Hello, !' + * + * @since 0.0.4 + */ export function templateObject< T extends string, A extends GenerateTypes, TemplateValue> & Collection, ->(template: T, args: A, fallback?: TemplateFallback) { - return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => { - const value = get(args, key)?.toString(); - return value !== undefined ? value : (isFunction(fallback) ? fallback(key) : ''); +>(template: T, args: A, fallback?: TemplateFallback): string { + return template.replace(TEMPLATE_PLACEHOLDER, (_match, key: string) => { + const value = get(args, key); + + if (value !== null && value !== undefined) + return String(value); + + if (isFunction<(key: string) => string>(fallback)) + return fallback(key); + + return fallback ?? ''; }); } - -templateObject('Hello {user.name}, your address {user.addresses.0.city}', { - user: { - name: 'John', - addresses: [ - { city: 'Kolpa' }, - ], - }, -}); diff --git a/core/stdlib/src/text/trigram-distance/index.test.ts b/core/stdlib/src/text/trigram-distance/index.test.ts index c9a60ad..b58da55 100644 --- a/core/stdlib/src/text/trigram-distance/index.test.ts +++ b/core/stdlib/src/text/trigram-distance/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { trigramDistance, trigramProfile } from '.'; describe('trigramProfile', () => { @@ -66,6 +66,13 @@ describe('trigramDistance', () => { expect(trigramDistance(profile1, profile2)).toBe(1); }); + it('is symmetric', () => { + const a = trigramProfile('hello world'); + const b = trigramProfile('hello lorem'); + + expect(trigramDistance(a, b)).toBe(trigramDistance(b, a)); + }); + it('one for empty text and non-empty text', () => { const profile1 = trigramProfile('hello world'); const profile2 = trigramProfile(''); diff --git a/core/stdlib/src/types/js/casts.test.ts b/core/stdlib/src/types/js/casts.test.ts index e17e49a..6ca62b4 100644 --- a/core/stdlib/src/types/js/casts.test.ts +++ b/core/stdlib/src/types/js/casts.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { toString } from './casts'; describe('casts', () => { diff --git a/core/stdlib/src/types/js/complex.test.ts b/core/stdlib/src/types/js/complex.test.ts index 3691602..fa27245 100644 --- a/core/stdlib/src/types/js/complex.test.ts +++ b/core/stdlib/src/types/js/complex.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isArray, isObject, isRegExp, isDate, isError, isPromise, isMap, isSet, isWeakMap, isWeakSet } from './complex'; +import { isArray, isDate, isError, isMap, isObject, isPromise, isRegExp, isSet, isWeakMap, isWeakSet } from './complex'; describe('complex', () => { describe('isArray', () => { @@ -36,6 +36,13 @@ describe('complex', () => { expect(isObject(new Map())).toBe(false); expect(isObject(new Set())).toBe(false); }); + + it('true for class instances and null-prototype objects', () => { + class Foo {} + + expect(isObject(new Foo())).toBe(true); + expect(isObject(Object.create(null))).toBe(true); + }); }); describe('isRegExp', () => { @@ -96,6 +103,8 @@ describe('complex', () => { describe('isPromise', () => { it('true if the value is a promise', () => { expect(isPromise(new Promise(() => {}))).toBe(true); + expect(isPromise(Promise.resolve())).toBe(true); + expect(isPromise((async () => {})())).toBe(true); }); it('false if the value is not a promise', () => { @@ -109,6 +118,11 @@ describe('complex', () => { expect(isPromise(new Map())).toBe(false); expect(isPromise(new Set())).toBe(false); }); + + it('false for a non-native thenable (only native promises match)', () => { + // eslint-disable-next-line unicorn/no-thenable -- documenting that custom thenables are not detected + expect(isPromise({ then() {} })).toBe(false); + }); }); describe('isMap', () => { diff --git a/core/stdlib/src/types/js/primitives.test.ts b/core/stdlib/src/types/js/primitives.test.ts index 6f00014..976daa3 100644 --- a/core/stdlib/src/types/js/primitives.test.ts +++ b/core/stdlib/src/types/js/primitives.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isBoolean, isFunction, isNumber, isBigInt, isString, isSymbol, isUndefined, isNull } from './primitives'; +import { isBigInt, isBoolean, isFunction, isNull, isNumber, isString, isSymbol, isUndefined } from './primitives'; describe('primitives', () => { describe('isBoolean', () => { @@ -38,6 +38,13 @@ describe('primitives', () => { expect(isNumber('123')).toBe(false); expect(isNumber(null)).toBe(false); }); + + it('true for NaN, Infinity and -0 (purely typeof-based)', () => { + expect(isNumber(Number.NaN)).toBe(true); + expect(isNumber(Number.POSITIVE_INFINITY)).toBe(true); + expect(isNumber(Number.NEGATIVE_INFINITY)).toBe(true); + expect(isNumber(-0)).toBe(true); + }); }); describe('isBigInt', () => { diff --git a/core/stdlib/src/types/js/primitives.ts b/core/stdlib/src/types/js/primitives.ts index 0c4c1bc..a169202 100644 --- a/core/stdlib/src/types/js/primitives.ts +++ b/core/stdlib/src/types/js/primitives.ts @@ -1,5 +1,3 @@ -import { toString } from './casts'; - /** * @name isObject * @category Types @@ -22,7 +20,7 @@ export const isBoolean = (value: any): value is boolean => typeof value === 'boo * * @since 0.0.2 */ -export const isFunction = (value: any): value is T => typeof value === 'function'; +export const isFunction = any>(value: any): value is T => typeof value === 'function'; /** * @name isNumber @@ -82,7 +80,7 @@ export const isSymbol = (value: any): value is symbol => typeof value === 'symbo * * @since 0.0.2 */ -export const isUndefined = (value: any): value is undefined => toString(value) === '[object Undefined]'; +export const isUndefined = (value: any): value is undefined => typeof value === 'undefined'; /** * @name isNull @@ -94,4 +92,4 @@ export const isUndefined = (value: any): value is undefined => toString(value) = * * @since 0.0.2 */ -export const isNull = (value: any): value is null => toString(value) === '[object Null]'; +export const isNull = (value: any): value is null => value === null; diff --git a/core/stdlib/src/types/ts/collections.test-d.ts b/core/stdlib/src/types/ts/collections.test-d.ts index 303d5c2..a601e96 100644 --- a/core/stdlib/src/types/ts/collections.test-d.ts +++ b/core/stdlib/src/types/ts/collections.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest'; -import type { Path, PathToType } from './collections'; +import type { Path, PathToPartialType, PathToType } from './collections'; describe('collections', () => { describe('Path', () => { @@ -68,4 +68,30 @@ describe('collections', () => { expectTypeOf().toEqualTypeOf(); }); }); + + describe('PathToPartialType', () => { + type Shape = PathToPartialType<['user', 'name'], string>; + + it('accepts the full nested shape', () => { + expectTypeOf<{ user: { name: 'John' } }>().toExtend(); + }); + + it('makes every key optional', () => { + expectTypeOf<{ unrelated: number }>().toExtend(); + }); + + it('keeps objects open for extra keys', () => { + expectTypeOf<{ user: { name: 'John'; age: number }; meta: boolean }>().toExtend(); + }); + + it('still enforces the leaf type when provided', () => { + expectTypeOf<{ user: { name: number } }>().not.toExtend(); + }); + + it('resolves array segments to arrays', () => { + type Indexed = PathToPartialType<['items', '0'], string>; + + expectTypeOf<{ items: string[] }>().toExtend(); + }); + }); }); diff --git a/core/stdlib/src/types/ts/collections.ts b/core/stdlib/src/types/ts/collections.ts index 123d1db..dcec15b 100644 --- a/core/stdlib/src/types/ts/collections.ts +++ b/core/stdlib/src/types/ts/collections.ts @@ -26,3 +26,19 @@ export type PathToType ? { [K in Head & string]: PathToType } : never : Target; + +/** + * Like {@link PathToType}, but every object key is optional and objects stay + * open (accept extra keys). Useful when the produced type only describes the + * keys a consumer *may* provide rather than the full shape of the source data. + */ +export type PathToPartialType + = T extends [infer Head, ...infer Rest] + ? Head extends `${number}` + ? Rest extends string[] + ? Array> + : never + : Rest extends string[] + ? { [K in Head & string]?: PathToPartialType } & Record + : never + : Target; diff --git a/core/stdlib/src/types/ts/string.test-d.ts b/core/stdlib/src/types/ts/string.test-d.ts index 32b6596..2a57d4f 100644 --- a/core/stdlib/src/types/ts/string.test-d.ts +++ b/core/stdlib/src/types/ts/string.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest'; -import type { HasSpaces, Trim, Stringable } from './string'; +import type { HasSpaces, Stringable, Trim } from './string'; describe('string', () => { describe('Stringable', () => { @@ -34,6 +34,20 @@ describe('string', () => { expectTypeOf().toEqualTypeOf(); }); + + it('trim tabs, newlines and carriage returns', () => { + expectTypeOf>().toEqualTypeOf<'hello'>(); + expectTypeOf>().toEqualTypeOf<'hello'>(); + }); + + it('handle empty and whitespace-only strings', () => { + expectTypeOf>().toEqualTypeOf<''>(); + expectTypeOf>().toEqualTypeOf<''>(); + }); + + it('preserve interior spaces', () => { + expectTypeOf>().toEqualTypeOf<'a b'>(); + }); }); describe('HasSpaces', () => { @@ -50,5 +64,14 @@ describe('string', () => { expectTypeOf().toEqualTypeOf(); }); + + it('false for an empty string', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('true for leading or trailing spaces', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); }); }); diff --git a/core/stdlib/src/types/ts/string.ts b/core/stdlib/src/types/ts/string.ts index 1c94bc0..cd5ec8e 100644 --- a/core/stdlib/src/types/ts/string.ts +++ b/core/stdlib/src/types/ts/string.ts @@ -5,10 +5,20 @@ export interface Stringable { toString(): string; } +/** + * Whitespace characters recognized by {@link Trim} + */ +export type Whitespace = ' ' | '\t' | '\n' | '\r' | '\f' | '\v'; + /** * Trim leading and trailing whitespace from `S` */ -export type Trim = S extends ` ${infer R}` ? Trim : S extends `${infer L} ` ? Trim : S; +export type Trim + = S extends `${Whitespace}${infer R}` + ? Trim + : S extends `${infer L}${Whitespace}` + ? Trim + : S; /** * Check if `S` has any spaces diff --git a/core/stdlib/src/types/ts/union.test-d.ts b/core/stdlib/src/types/ts/union.test-d.ts index c279d47..e509d7e 100644 --- a/core/stdlib/src/types/ts/union.test-d.ts +++ b/core/stdlib/src/types/ts/union.test-d.ts @@ -27,5 +27,13 @@ describe('union', () => { it('never when union not possible', () => { expectTypeOf>().toEqualTypeOf(); }); + + it('returns a single-member object union unchanged', () => { + expectTypeOf>().toEqualTypeOf<{ a: string }>(); + }); + + it('collapses boolean (true | false) to never', () => { + expectTypeOf>().toEqualTypeOf(); + }); }); }); diff --git a/core/stdlib/src/utils/index.test.ts b/core/stdlib/src/utils/index.test.ts new file mode 100644 index 0000000..7881816 --- /dev/null +++ b/core/stdlib/src/utils/index.test.ts @@ -0,0 +1,28 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { noop, timestamp } from '.'; + +describe('timestamp', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('return the current epoch milliseconds', () => { + vi.setSystemTime(1_700_000_000_000); + + expect(timestamp()).toBe(1_700_000_000_000); + }); + + it('track the advancing clock', () => { + vi.setSystemTime(1_000); + expect(timestamp()).toBe(1_000); + + vi.advanceTimersByTime(500); + expect(timestamp()).toBe(1_500); + }); +}); + +describe('noop', () => { + it('return undefined and not throw', () => { + expect(noop()).toBeUndefined(); + expect(() => noop()).not.toThrow(); + }); +}); diff --git a/core/stdlib/tsconfig.json b/core/stdlib/tsconfig.json index d6d22e4..2781e66 100644 --- a/core/stdlib/tsconfig.json +++ b/core/stdlib/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "@robonen/tsconfig/tsconfig.json" -} \ No newline at end of file + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/core/stdlib/tsconfig.node.json b/core/stdlib/tsconfig.node.json new file mode 100644 index 0000000..edc474f --- /dev/null +++ b/core/stdlib/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.node.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["*.config.ts"] +} diff --git a/core/stdlib/tsconfig.src.json b/core/stdlib/tsconfig.src.json new file mode 100644 index 0000000..6661594 --- /dev/null +++ b/core/stdlib/tsconfig.src.json @@ -0,0 +1,9 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "types": [], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/core/stdlib/tsdown.config.ts b/core/stdlib/tsdown.config.ts index ae9657f..6e391e5 100644 --- a/core/stdlib/tsdown.config.ts +++ b/core/stdlib/tsdown.config.ts @@ -3,5 +3,6 @@ import { sharedConfig } from '@robonen/tsdown'; export default defineConfig({ ...sharedConfig, + tsconfig: './tsconfig.src.json', entry: ['src/index.ts'], }); diff --git a/core/stdlib/vitest.config.ts b/core/stdlib/vitest.config.ts index 4ac6027..503d632 100644 --- a/core/stdlib/vitest.config.ts +++ b/core/stdlib/vitest.config.ts @@ -3,5 +3,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', + typecheck: { + enabled: true, + tsconfig: './tsconfig.src.json', + }, }, });