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).
This commit is contained in:
+15
-14
@@ -10,20 +10,21 @@ pnpm install @robonen/stdlib
|
|||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| Module | Utilities |
|
| Module | Utilities |
|
||||||
| --------------- | --------------------------------------------------------------- |
|
| --------------- | ---------------------------------------------------------------------------------- |
|
||||||
| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` |
|
| **arrays** | `cluster`, `first`, `groupBy`, `last`, `partition`, `range`, `sum`, `toArray`, `unique`, `zip` |
|
||||||
| **async** | `sleep`, `tryIt` |
|
| **async** | `pool`, `retry`, `sleep`, `tryIt` |
|
||||||
| **bits** | `flags` |
|
| **bits** | `flagsGenerator`, `and`, `or`, `not`, `has`, `is`, `unset`, `toggle`, `BitVector` |
|
||||||
| **collections** | `get` |
|
| **collections** | `get` |
|
||||||
| **math** | `clamp`, `lerp`, `remap` + BigInt variants |
|
| **functions** | `compose`, `debounce`, `memoize`, `once`, `pipe`, `throttle` |
|
||||||
| **objects** | `omit`, `pick` |
|
| **math** | `clamp`, `lerp`, `remap` + BigInt variants |
|
||||||
| **patterns** | `pubsub` |
|
| **objects** | `omit`, `pick` |
|
||||||
| **structs** | `stack` |
|
| **patterns** | `Command`, `PubSub`, `StateMachine` |
|
||||||
| **sync** | `mutex` |
|
| **structs** | `BinaryHeap`, `CircularBuffer`, `Deque`, `LinkedList`, `PriorityQueue`, `Queue`, `Stack` |
|
||||||
| **text** | `levenshteinDistance`, `trigramDistance` |
|
| **sync** | `mutex` |
|
||||||
| **types** | JS & TS type utilities |
|
| **text** | `levenshteinDistance`, `trigramDistance`, `templateObject` |
|
||||||
| **utils** | `timestamp`, `noop` |
|
| **types** | JS & TS type utilities |
|
||||||
|
| **utils** | `timestamp`, `noop` |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
|
||||||
|
|
||||||
|
export default compose(base, typescript, imports, stylistic);
|
||||||
@@ -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));
|
|
||||||
@@ -39,18 +39,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint:check": "oxlint -c oxlint.config.ts",
|
"lint:check": "eslint .",
|
||||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vitest dev",
|
"dev": "vitest dev",
|
||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@robonen/oxlint": "workspace:*",
|
"@robonen/eslint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
"@stylistic/eslint-plugin": "catalog:",
|
"eslint": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"tsdown": "catalog:"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { cluster } from '.';
|
import { cluster } from '.';
|
||||||
|
|
||||||
describe('cluster', () => {
|
describe('cluster', () => {
|
||||||
@@ -37,4 +37,13 @@ describe('cluster', () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { first } from '.';
|
import { first } from '.';
|
||||||
|
|
||||||
describe('first', () => {
|
describe('first', () => {
|
||||||
@@ -20,4 +20,11 @@ describe('first', () => {
|
|||||||
expect(first([1, 2, 3], 42)).toBe(1);
|
expect(first([1, 2, 3], 42)).toBe(1);
|
||||||
expect(first(['a', 'b', 'c'], 'default')).toBe('a');
|
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('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,5 +16,6 @@
|
|||||||
* @since 0.0.3
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
export function first<Value>(arr: Value[], defaultValue?: Value) {
|
export function first<Value>(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Record<'even' | 'odd', number[]>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the element type in the grouped arrays', () => {
|
||||||
|
const result = groupBy([{ id: 1 }], item => item.id);
|
||||||
|
|
||||||
|
expectTypeOf(result).toEqualTypeOf<Record<number, Array<{ id: number }>>>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Key, Value[]>} 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<Value, Key extends PropertyKey>(
|
||||||
|
array: Value[],
|
||||||
|
getKey: (item: Value, index: number) => Key,
|
||||||
|
): Record<Key, Value[]> {
|
||||||
|
// 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<Key, Value[]>;
|
||||||
|
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
const item = array[i]!;
|
||||||
|
const key = getKey(item, i);
|
||||||
|
|
||||||
|
(result[key] ??= []).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
export * from './cluster';
|
export * from './cluster';
|
||||||
export * from './first';
|
export * from './first';
|
||||||
|
export * from './groupBy';
|
||||||
export * from './last';
|
export * from './last';
|
||||||
|
export * from './partition';
|
||||||
|
export * from './range';
|
||||||
export * from './sum';
|
export * from './sum';
|
||||||
|
export * from './toArray';
|
||||||
export * from './unique';
|
export * from './unique';
|
||||||
|
export * from './zip';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { last } from '.';
|
import { last } from '.';
|
||||||
|
|
||||||
describe('last', () => {
|
describe('last', () => {
|
||||||
@@ -20,4 +20,10 @@ describe('last', () => {
|
|||||||
expect(last([1, 2, 3], 42)).toBe(3);
|
expect(last([1, 2, 3], 42)).toBe(3);
|
||||||
expect(last(['a', 'b', 'c'], 'default')).toBe('c');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,5 +16,6 @@
|
|||||||
* @since 0.0.3
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
export function last<Value>(arr: Value[], defaultValue?: Value) {
|
export function last<Value>(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | number> = ['a', 1];
|
||||||
|
const result = partition(mixed, (v): v is string => typeof v === 'string');
|
||||||
|
|
||||||
|
expectTypeOf(result).toEqualTypeOf<[string[], number[]]>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | number> = ['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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Value, Matched extends Value>(
|
||||||
|
array: Value[],
|
||||||
|
predicate: (item: Value, index: number) => item is Matched,
|
||||||
|
): [Matched[], Array<Exclude<Value, Matched>>];
|
||||||
|
export function partition<Value>(
|
||||||
|
array: Value[],
|
||||||
|
predicate: (item: Value, index: number) => boolean,
|
||||||
|
): [Value[], Value[]];
|
||||||
|
export function partition<Value>(
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { sum } from '.';
|
import { sum } from '.';
|
||||||
|
|
||||||
describe('sum', () => {
|
describe('sum', () => {
|
||||||
|
|||||||
@@ -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<number[]>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an array input as the same element type', () => {
|
||||||
|
expectTypeOf(toArray([1, 2, 3])).toEqualTypeOf<number[]>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the element array type for a nullish input', () => {
|
||||||
|
expectTypeOf(toArray<string>(undefined)).toEqualTypeOf<string[]>();
|
||||||
|
expectTypeOf(toArray<number>(null)).toEqualTypeOf<number[]>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Arrayable } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name toArray
|
||||||
|
* @category Arrays
|
||||||
|
* @description Normalize an `Arrayable<T>` 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<Value> | 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>(value: Arrayable<Value> | null | undefined): Value[] {
|
||||||
|
if (value === null || value === undefined) return [];
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { unique } from '.';
|
import { unique } from '.';
|
||||||
|
|
||||||
describe('unique', () => {
|
describe('unique', () => {
|
||||||
@@ -42,4 +42,28 @@ describe('unique', () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,20 +14,23 @@ export type Extractor<Value, Key extends UniqueKey> = (value: Value) => Key;
|
|||||||
* unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6]
|
* unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6]
|
||||||
*
|
*
|
||||||
* @example
|
* @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
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
export function unique<Value, Key extends UniqueKey>(
|
export function unique<Value, Key extends UniqueKey>(
|
||||||
array: Value[],
|
array: Value[],
|
||||||
extractor?: Extractor<Value, Key>,
|
extractor?: Extractor<Value, Key>,
|
||||||
) {
|
): 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<Key, Value>();
|
const values = new Map<Key, Value>();
|
||||||
|
|
||||||
for (const value of array) {
|
for (const value of array)
|
||||||
const key = extractor ? extractor(value) : value as any;
|
values.set(extractor(value), value);
|
||||||
values.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(values.values());
|
return [...values.values()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Array<[number, string]>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces tuples of the element types (three arrays)', () => {
|
||||||
|
const result = zip([1], ['a'], [true]);
|
||||||
|
|
||||||
|
expectTypeOf(result).toEqualTypeOf<Array<[number, string, boolean]>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zips a single array into singleton tuples', () => {
|
||||||
|
const result = zip([1, 2, 3]);
|
||||||
|
|
||||||
|
expectTypeOf(result).toEqualTypeOf<Array<[number]>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces tuples for four and five arrays', () => {
|
||||||
|
expectTypeOf(zip([1], ['a'], [true], [9])).toEqualTypeOf<Array<[number, string, boolean, number]>>();
|
||||||
|
expectTypeOf(zip([1], ['a'], [true], [9], ['x'])).toEqualTypeOf<Array<[number, string, boolean, number, string]>>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: A[]): Array<[A]>;
|
||||||
|
export function zip<A, B>(a: A[], b: B[]): Array<[A, B]>;
|
||||||
|
export function zip<A, B, C>(a: A[], b: B[], c: C[]): Array<[A, B, C]>;
|
||||||
|
export function zip<A, B, C, D>(a: A[], b: B[], c: C[], d: D[]): Array<[A, B, C, D]>;
|
||||||
|
export function zip<A, B, C, D, E>(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]));
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './pool';
|
||||||
export * from './retry';
|
export * from './retry';
|
||||||
export * from './sleep';
|
export * from './sleep';
|
||||||
export * from './tryIt';
|
export * from './tryIt';
|
||||||
|
|||||||
@@ -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<void>(() => {}));
|
||||||
|
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<void>(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<void>(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<void>(() => {}));
|
||||||
|
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<void>(() => {}));
|
||||||
|
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<void>(() => {}));
|
||||||
|
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<void>(() => {}));
|
||||||
|
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<void>(() => {}));
|
||||||
|
// 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<void>(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<void>(() => {});
|
||||||
|
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<void>(() => {})); // 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<void>((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<void>((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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,244 @@
|
|||||||
|
import { CircularBuffer } from '../../structs/CircularBuffer';
|
||||||
|
import { isNumber } from '../../types';
|
||||||
|
|
||||||
export interface AsyncPoolOptions {
|
export interface AsyncPoolOptions {
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PoolEntry<T = any> {
|
||||||
|
task: (signal: AbortSignal) => Promise<T>;
|
||||||
|
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<PoolEntry>;
|
||||||
|
// withResolvers result — single field replaces separate drainPromise + drainResolve
|
||||||
|
private pending: ReturnType<typeof Promise.withResolvers<Array<PromiseSettledResult<unknown>>>> | null;
|
||||||
|
private readonly signal: AbortSignal;
|
||||||
|
private settled: Array<PromiseSettledResult<unknown>>;
|
||||||
|
// 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<PoolEntry>();
|
||||||
|
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<T>} task
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
add<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||||
|
// withResolvers — resolve/reject needed outside the promise for PoolEntry;
|
||||||
|
// no executor closure, no captured variables
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||||
|
if (this.signal.aborted) {
|
||||||
|
reject(this.signal.reason);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
const entry = { task, resolve, reject } as PoolEntry<T>;
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
async all(): Promise<void> {
|
||||||
|
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<Array<PromiseSettledResult<unknown>>>}
|
||||||
|
*/
|
||||||
|
allSettled(): Promise<Array<PromiseSettledResult<unknown>>> {
|
||||||
|
return this.waitForDrain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForDrain(): Promise<Array<PromiseSettledResult<unknown>>> {
|
||||||
|
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<unknown>;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '.';
|
import { retry } from '.';
|
||||||
|
|
||||||
describe('retry', () => {
|
describe('retry', () => {
|
||||||
@@ -20,7 +20,7 @@ describe('retry', () => {
|
|||||||
expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
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'));
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
await expect(retry(failingFn)).rejects.toThrow('Test error');
|
await expect(retry(failingFn)).rejects.toThrow('Test error');
|
||||||
@@ -58,7 +58,7 @@ describe('retry', () => {
|
|||||||
|
|
||||||
await expect(retry(failingFn, {
|
await expect(retry(failingFn, {
|
||||||
times: 3,
|
times: 3,
|
||||||
shouldRetry: (error) => error.name !== 'NetworkError'
|
shouldRetry: error => error.name !== 'NetworkError',
|
||||||
})).rejects.toThrow('Network failed');
|
})).rejects.toThrow('Network failed');
|
||||||
|
|
||||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
@@ -70,7 +70,7 @@ describe('retry', () => {
|
|||||||
|
|
||||||
await expect(retry(failingFn, {
|
await expect(retry(failingFn, {
|
||||||
times: 5,
|
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');
|
})).rejects.toThrow('Test error');
|
||||||
|
|
||||||
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||||
@@ -89,7 +89,7 @@ describe('retry', () => {
|
|||||||
|
|
||||||
await expect(retry(failingFn, {
|
await expect(retry(failingFn, {
|
||||||
times: 5,
|
times: 5,
|
||||||
shouldRetry: (error) => error.name === 'TemporaryError'
|
shouldRetry: error => error.name === 'TemporaryError',
|
||||||
})).rejects.toThrow('Permanent failure');
|
})).rejects.toThrow('Permanent failure');
|
||||||
|
|
||||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
@@ -212,7 +212,7 @@ describe('retry', () => {
|
|||||||
const complexFn = vi.fn().mockResolvedValue({
|
const complexFn = vi.fn().mockResolvedValue({
|
||||||
data: [1, 2, 3],
|
data: [1, 2, 3],
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
metadata: { timestamp: 123456 }
|
metadata: { timestamp: 123456 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await retry(complexFn);
|
const result = await retry(complexFn);
|
||||||
@@ -220,13 +220,13 @@ describe('retry', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
data: [1, 2, 3],
|
data: [1, 2, 3],
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
metadata: { timestamp: 123456 }
|
metadata: { timestamp: 123456 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stop retrying when stop function is called', async () => {
|
it('stop retrying when stop function is called', async () => {
|
||||||
const customError = new Error('Custom stop error');
|
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) {
|
if (count === 2) {
|
||||||
stop(customError);
|
stop(customError);
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ describe('retry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stop retrying with undefined error when stop is called without argument', async () => {
|
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) {
|
if (count === 2) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
@@ -250,4 +250,41 @@ describe('retry', () => {
|
|||||||
|
|
||||||
expect(stopFn).toHaveBeenCalledTimes(2);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export async function retry<Return>(
|
|||||||
shouldRetry,
|
shouldRetry,
|
||||||
} = options;
|
} = 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 wrappedFn = tryIt(fn);
|
||||||
const delayFn = isFunction(delay) ? delay : null;
|
const delayFn = isFunction(delay) ? delay : null;
|
||||||
const delayMs = delayFn ? 0 : delay as number;
|
const delayMs = delayFn ? 0 : delay as number;
|
||||||
@@ -66,7 +69,7 @@ export async function retry<Return>(
|
|||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
let count = 1;
|
let count = 1;
|
||||||
|
|
||||||
while (count <= times) {
|
while (count <= maxAttempts) {
|
||||||
const { error, data } = await wrappedFn({ count, stop });
|
const { error, data } = await wrappedFn({ count, stop });
|
||||||
|
|
||||||
if (!error)
|
if (!error)
|
||||||
@@ -82,7 +85,7 @@ export async function retry<Return>(
|
|||||||
count++;
|
count++;
|
||||||
|
|
||||||
// Don't delay after the last attempt
|
// Don't delay after the last attempt
|
||||||
if (count <= times) {
|
if (count <= maxAttempts) {
|
||||||
const ms = delayFn ? delayFn(count) : delayMs;
|
const ms = delayFn ? delayFn(count) : delayMs;
|
||||||
|
|
||||||
if (ms > 0)
|
if (ms > 0)
|
||||||
@@ -90,6 +93,7 @@ export async function retry<Return>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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!;
|
throw lastError!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { sleep } from '.';
|
import { sleep } from '.';
|
||||||
|
|
||||||
describe('sleep', () => {
|
describe('sleep', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delay execution by the specified amount of time', async () => {
|
afterEach(() => {
|
||||||
const start = performance.now();
|
vi.useRealTimers();
|
||||||
const delay = 100;
|
});
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<number>();
|
||||||
|
else
|
||||||
|
expectTypeOf(result.data).toEqualTypeOf<undefined>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { tryIt } from '.';
|
import { tryIt } from '.';
|
||||||
|
|
||||||
describe('tryIt', () => {
|
describe('tryIt', () => {
|
||||||
@@ -68,4 +68,33 @@ describe('tryIt', () => {
|
|||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(data).toBeUndefined();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { isPromise } from '../../types';
|
export type TryItReturn<Return> = Return extends PromiseLike<any>
|
||||||
|
|
||||||
export type TryItReturn<Return> = Return extends Promise<any>
|
|
||||||
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
|
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
|
||||||
: { error: Error; data: undefined } | { error: undefined; data: Return };
|
: { error: Error; data: undefined } | { error: undefined; data: Return };
|
||||||
|
|
||||||
function onResolve(data: any) { return { error: undefined, data }; }
|
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
||||||
function onReject(error: any) { return { error, data: undefined }; }
|
return value !== null && (typeof value === 'object' || typeof value === 'function')
|
||||||
|
&& typeof (value as PromiseLike<unknown>).then === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResolve(data: any) {
|
||||||
|
return { error: undefined, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReject(error: any) {
|
||||||
|
return { error, data: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name tryIt
|
* @name tryIt
|
||||||
@@ -31,11 +39,14 @@ export function tryIt<Args extends any[], Return>(
|
|||||||
try {
|
try {
|
||||||
const result = fn(...args);
|
const result = fn(...args);
|
||||||
|
|
||||||
if (isPromise(result))
|
// Handle any thenable (native Promise, async fn, or custom PromiseLike), so a
|
||||||
return result.then(onResolve, onReject) as TryItReturn<Return>;
|
// 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>;
|
||||||
|
|
||||||
return { error: undefined, data: result } as TryItReturn<Return>;
|
return { error: undefined, data: result } as TryItReturn<Return>;
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
return { error, data: undefined } as TryItReturn<Return>;
|
return { error, data: undefined } as TryItReturn<Return>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { flagsGenerator } from '.';
|
import { flagsGenerator } from '.';
|
||||||
|
|
||||||
describe('flagsGenerator', () => {
|
describe('flagsGenerator', () => {
|
||||||
@@ -23,4 +23,17 @@ describe('flagsGenerator', () => {
|
|||||||
|
|
||||||
expect(() => generateFlag()).toThrow(new RangeError('Cannot create more than 31 flags'));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { and, or, not, has, is, unset, toggle } from '.';
|
import { and, has, is, not, or, toggle, unset } from '.';
|
||||||
|
|
||||||
describe('flagsAnd', () => {
|
describe('flagsAnd', () => {
|
||||||
it('no effect on zero flags', () => {
|
it('no effect on zero flags', () => {
|
||||||
@@ -61,6 +61,15 @@ describe('flagsHas', () => {
|
|||||||
|
|
||||||
expect(result).toBe(false);
|
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', () => {
|
describe('flagsIs', () => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function or(...flags: number[]) {
|
|||||||
/**
|
/**
|
||||||
* @name not
|
* @name not
|
||||||
* @category Bits
|
* @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
|
* @param {number} flag - The flag to apply the NOT operator to
|
||||||
* @returns {number} The result of the NOT operator
|
* @returns {number} The result of the NOT operator
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export * from './flags';
|
export * from './flags';
|
||||||
|
export * from './helpers';
|
||||||
|
export * from './vector';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { BitVector } from '.';
|
import { BitVector } from '.';
|
||||||
|
|
||||||
describe('BitVector', () => {
|
describe('BitVector', () => {
|
||||||
@@ -54,10 +54,76 @@ describe('BitVector', () => {
|
|||||||
expect(bitVector.previousBit(0)).toBe(-1);
|
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);
|
const bitVector = new BitVector(16);
|
||||||
bitVector.setBit(5);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ export interface BitVectorLike {
|
|||||||
getBit(index: number): boolean;
|
getBit(index: number): boolean;
|
||||||
setBit(index: number): void;
|
setBit(index: number): void;
|
||||||
clearBit(index: number): void;
|
clearBit(index: number): void;
|
||||||
|
toggleBit(index: number): void;
|
||||||
previousBit(index: number): number;
|
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));
|
this[index >> 3]! &= ~(1 << (index & 7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleBit(index: number): void {
|
||||||
|
this[index >> 3]! ^= 1 << (index & 7);
|
||||||
|
}
|
||||||
|
|
||||||
previousBit(index: number): number {
|
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)) {
|
while (index !== ((index >> 3) << 3)) {
|
||||||
--index;
|
--index;
|
||||||
|
|
||||||
@@ -58,4 +72,61 @@ export class BitVector extends Uint8Array implements BitVectorLike {
|
|||||||
|
|
||||||
throw new RangeError('Unreachable value');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | undefined>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infer a value behind an array index', () => {
|
||||||
|
expectTypeOf(get({ items: [{ id: 1 }] }, 'items.0.id')).toEqualTypeOf<number | undefined>();
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string | undefined>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrow to undefined when descending into a primitive', () => {
|
||||||
|
expectTypeOf(get({ a: 1 }, 'a.b.c')).toEqualTypeOf<undefined>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrow to undefined for a missing key', () => {
|
||||||
|
expectTypeOf(get({ user: { name: 'John' } }, 'user.age')).toEqualTypeOf<undefined>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Get', () => {
|
||||||
|
it('resolve a simple object path', () => {
|
||||||
|
expectTypeOf<Get<{ user: { name: string } }, 'user.name'>>().toEqualTypeOf<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve a path through an array index (general arrays widen with undefined)', () => {
|
||||||
|
expectTypeOf<Get<{ list: number[] }, 'list.0'>>().toEqualTypeOf<number | undefined>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve an exact element type from a tuple', () => {
|
||||||
|
expectTypeOf<Get<{ pair: [string, number] }, 'pair.1'>>().toEqualTypeOf<number>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve a deeply nested path', () => {
|
||||||
|
expectTypeOf<Get<{ a: { b: { c: boolean } } }, 'a.b.c'>>().toEqualTypeOf<boolean>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve to never for a missing key', () => {
|
||||||
|
expectTypeOf<Get<{ a: number }, 'b'>>().toEqualTypeOf<never>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,8 +27,40 @@ export type ExtractFromCollection<O, K>
|
|||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
type Get<O, K> = ExtractFromCollection<O, Path<K>>;
|
export type Get<O, K> = ExtractFromCollection<O, Path<K>>;
|
||||||
|
|
||||||
export function get<O extends Collection, K extends string>(obj: O, path: K) {
|
/**
|
||||||
return path.split('.').reduce((acc, key) => (acc as any)?.[key], obj) as Get<O, K> | 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<O, K> | 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<O extends Collection, K extends string>(obj: O, path: K): Get<O, K> | 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<boolean>();
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<A extends any[], B>(ab: (...a: A) => B): (...a: A) => B;
|
||||||
|
export function compose<A extends any[], B, C>(bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => C;
|
||||||
|
export function compose<A extends any[], B, C, D>(cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => D;
|
||||||
|
export function compose<A extends any[], B, C, D, E>(de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => E;
|
||||||
|
export function compose<A extends any[], B, C, D, E, F>(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<A extends any[], B, C, D, E, F, G>(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<A extends any[], B, C, D, E, F, G, H>(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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T extends AnyFunction> {
|
||||||
|
(this: ThisParameterType<T>, ...args: Parameters<T>): void;
|
||||||
|
/** Cancel a pending invocation without calling the function. */
|
||||||
|
cancel: () => void;
|
||||||
|
/** Immediately invoke a pending call (if any) and return its result. */
|
||||||
|
flush: () => ReturnType<T> | 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<T extends AnyFunction>(
|
||||||
|
fn: T,
|
||||||
|
wait: number | (() => number),
|
||||||
|
options: DebounceOptions = {},
|
||||||
|
): DebouncedFunction<T> {
|
||||||
|
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<typeof setTimeout> | undefined;
|
||||||
|
let maxTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let pending: (() => ReturnType<T>) | undefined;
|
||||||
|
let result: ReturnType<T> | 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<T>, ...args: Parameters<T>) {
|
||||||
|
// The arrow captures the call-time `this` lexically, no aliasing needed.
|
||||||
|
pending = () => fn.apply(this, args) as ReturnType<T>;
|
||||||
|
|
||||||
|
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<T>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './compose';
|
||||||
|
export * from './debounce';
|
||||||
|
export * from './memoize';
|
||||||
|
export * from './once';
|
||||||
|
export * from './pipe';
|
||||||
|
export * from './throttle';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AnyFunction } from '../../types';
|
||||||
|
|
||||||
|
export type MemoizeResolver<T extends AnyFunction> = (...args: Parameters<T>) => unknown;
|
||||||
|
|
||||||
|
export type MemoizedFunction<T extends AnyFunction> = T & {
|
||||||
|
/** The underlying cache, keyed by the resolver (first argument by default). */
|
||||||
|
cache: Map<unknown, ReturnType<T>>;
|
||||||
|
/** 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<T extends AnyFunction>(fn: T, resolver?: MemoizeResolver<T>): MemoizedFunction<T> {
|
||||||
|
const cache = new Map<unknown, ReturnType<T>>();
|
||||||
|
|
||||||
|
const memoized = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
|
||||||
|
const key = resolver ? resolver(...args) : args[0];
|
||||||
|
|
||||||
|
if (cache.has(key))
|
||||||
|
return cache.get(key)!;
|
||||||
|
|
||||||
|
const result = fn.apply(this, args) as ReturnType<T>;
|
||||||
|
cache.set(key, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} as MemoizedFunction<T>;
|
||||||
|
|
||||||
|
memoized.cache = cache;
|
||||||
|
memoized.clear = () => cache.clear();
|
||||||
|
|
||||||
|
return memoized;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AnyFunction } from '../../types';
|
||||||
|
|
||||||
|
export type OnceFunction<T extends AnyFunction> = 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<T extends AnyFunction>(fn: T): OnceFunction<T> {
|
||||||
|
let called = false;
|
||||||
|
let result: ReturnType<T>;
|
||||||
|
|
||||||
|
const onced = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
|
||||||
|
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<T>;
|
||||||
|
|
||||||
|
onced.clear = () => {
|
||||||
|
called = false;
|
||||||
|
// Release the cached result so it (and anything it retains) can be GC'd.
|
||||||
|
result = undefined as ReturnType<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return onced;
|
||||||
|
}
|
||||||
@@ -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<boolean>();
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a single function', () => {
|
||||||
|
const fn = pipe((n: number) => n > 0);
|
||||||
|
|
||||||
|
expectTypeOf(fn).returns.toEqualTypeOf<boolean>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<A extends any[], B>(ab: (...a: A) => B): (...a: A) => B;
|
||||||
|
export function pipe<A extends any[], B, C>(ab: (...a: A) => B, bc: (b: B) => C): (...a: A) => C;
|
||||||
|
export function pipe<A extends any[], B, C, D>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (...a: A) => D;
|
||||||
|
export function pipe<A extends any[], B, C, D, E>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): (...a: A) => E;
|
||||||
|
export function pipe<A extends any[], B, C, D, E, F>(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<A extends any[], B, C, D, E, F, G>(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<A extends any[], B, C, D, E, F, G, H>(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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T extends AnyFunction> {
|
||||||
|
(this: ThisParameterType<T>, ...args: Parameters<T>): void;
|
||||||
|
/** Cancel a pending trailing invocation. */
|
||||||
|
cancel: () => void;
|
||||||
|
/** Immediately invoke a pending call (if any) and return its result. */
|
||||||
|
flush: () => ReturnType<T> | 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<T extends AnyFunction>(fn: T, wait: number | (() => number), options: ThrottleOptions = {}): ThrottledFunction<T> {
|
||||||
|
const { leading = true, trailing = true } = options;
|
||||||
|
const resolveWait = typeof wait === 'function' ? wait : () => wait;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let pending: (() => ReturnType<T>) | undefined;
|
||||||
|
let result: ReturnType<T> | undefined;
|
||||||
|
let lastInvokeTime = 0;
|
||||||
|
|
||||||
|
function invoke(time: number) {
|
||||||
|
if (pending === undefined)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lastInvokeTime = time;
|
||||||
|
result = pending();
|
||||||
|
pending = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const throttled = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
|
||||||
|
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<T>;
|
||||||
|
|
||||||
|
// 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<T>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export * from './arrays';
|
export * from './arrays';
|
||||||
export * from './async';
|
export * from './async';
|
||||||
export * from './bits';
|
export * from './bits';
|
||||||
|
export * from './collections';
|
||||||
|
export * from './functions';
|
||||||
export * from './math';
|
export * from './math';
|
||||||
export * from './objects';
|
export * from './objects';
|
||||||
export * from './patterns';
|
export * from './patterns';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { clamp } from '.';
|
import { clamp } from '.';
|
||||||
|
|
||||||
describe('clamp', () => {
|
describe('clamp', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { inverseLerp, lerp } from '.';
|
import { inverseLerp, lerp } from '.';
|
||||||
|
|
||||||
describe('lerp', () => {
|
describe('lerp', () => {
|
||||||
@@ -26,6 +26,16 @@ describe('lerp', () => {
|
|||||||
const result = lerp(0, 10, 1.5);
|
const result = lerp(0, 10, 1.5);
|
||||||
expect(result).toBe(15);
|
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', () => {
|
describe('inverseLerp', () => {
|
||||||
|
|||||||
@@ -43,4 +43,12 @@ describe('remap', () => {
|
|||||||
// input range is zero (should return output min)
|
// input range is zero (should return output min)
|
||||||
expect(remap(5, 0, 0, 0, 100)).toBe(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export function remap(value: number, in_min: number, in_max: number, out_min: nu
|
|||||||
if (in_min === in_max)
|
if (in_min === in_max)
|
||||||
return out_min;
|
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));
|
return lerp(out_min, out_max, inverseLerp(in_min, in_max, clampedValue));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { clampBigInt } from '.';
|
import { clampBigInt } from '.';
|
||||||
|
|
||||||
describe('clampBigInt', () => {
|
describe('clampBigInt', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { inverseLerpBigInt, lerpBigInt } from '.';
|
import { inverseLerpBigInt, lerpBigInt } from '.';
|
||||||
|
|
||||||
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
@@ -28,6 +28,20 @@ describe('lerpBigInt', () => {
|
|||||||
const result = lerpBigInt(0n, 10n, 1.5);
|
const result = lerpBigInt(0n, 10n, 1.5);
|
||||||
expect(result).toBe(15n);
|
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', () => {
|
describe('inverseLerpBigInt', () => {
|
||||||
@@ -61,23 +75,20 @@ describe('inverseLerpBigInt', () => {
|
|||||||
expect(result).toBe(0);
|
expect(result).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles the maximum safe integer correctly', () => {
|
it('returns 1 at the maximum safe integer', () => {
|
||||||
const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER);
|
expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER)).toBe(1);
|
||||||
expect(result).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles values just above the maximum safe integer correctly', () => {
|
it('returns 0 at the start of a max-safe-integer range', () => {
|
||||||
const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, 0n);
|
expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, 0n)).toBe(0);
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles values just below the maximum safe integer correctly', () => {
|
it('returns the midpoint of a max-safe-integer range', () => {
|
||||||
const result = inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER);
|
// 6-decimal SCALE quantizes the result, so allow ~1e-6 tolerance.
|
||||||
expect(result).toBe(1);
|
expect(inverseLerpBigInt(0n, MAX_SAFE_INTEGER, MAX_SAFE_INTEGER / 2n)).toBeCloseTo(0.5, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles values just above the maximum safe integer correctly', () => {
|
it('handles values far beyond 2^53', () => {
|
||||||
const result = inverseLerpBigInt(0n, 2n ** 128n, 2n ** 127n);
|
expect(inverseLerpBigInt(0n, 2n ** 128n, 2n ** 127n)).toBe(0.5);
|
||||||
expect(result).toBe(0.5);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { maxBigInt } from '.';
|
import { maxBigInt } from '.';
|
||||||
|
|
||||||
describe('maxBigInt', () => {
|
describe('maxBigInt', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { minBigInt } from '.';
|
import { minBigInt } from '.';
|
||||||
|
|
||||||
describe('minBigInt', () => {
|
describe('minBigInt', () => {
|
||||||
|
|||||||
@@ -29,4 +29,14 @@ describe('remapBigInt', () => {
|
|||||||
// input range is zero (should return output min)
|
// input range is zero (should return output min)
|
||||||
expect(remapBigInt(5n, 0n, 0n, 0n, 100n)).toBe(0n);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { clampBigInt } from '../clampBigInt';
|
import { clampBigInt } from '../clampBigInt';
|
||||||
import { inverseLerpBigInt, lerpBigInt } from '../lerpBigInt';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name remapBigInt
|
* @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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Omit<Sample, 'a'>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes multiple keys from the type', () => {
|
||||||
|
expectTypeOf(omit({ a: 1, b: 'x', c: true } as Sample, ['a', 'b'])).toEqualTypeOf<Omit<Sample, 'a' | 'b'>>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { omit } from '.';
|
import { omit } from '.';
|
||||||
|
|
||||||
describe('omit', () => {
|
describe('omit', () => {
|
||||||
|
|||||||
@@ -22,18 +22,20 @@ export function omit<Target extends object, Key extends keyof Target>(
|
|||||||
target: Target,
|
target: Target,
|
||||||
keys: Arrayable<Key>,
|
keys: Arrayable<Key>,
|
||||||
): Omit<Target, Key> {
|
): Omit<Target, Key> {
|
||||||
const result = { ...target };
|
const result = {} as Omit<Target, Key>;
|
||||||
|
|
||||||
if (!target || !keys)
|
if (!target)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
if (isArray(keys)) {
|
// Build the kept-keys object directly instead of spread-then-delete: `delete` forces V8
|
||||||
for (const key of keys) {
|
// to drop the object into slow dictionary mode, penalizing all later property access.
|
||||||
delete result[key];
|
const omitted = new Set<PropertyKey>(
|
||||||
}
|
keys === null || keys === undefined ? [] : isArray(keys) ? keys : [keys],
|
||||||
}
|
);
|
||||||
else {
|
|
||||||
delete result[keys];
|
for (const key in target) {
|
||||||
|
if (Object.hasOwn(target, key) && !omitted.has(key))
|
||||||
|
(result as Record<PropertyKey, unknown>)[key] = target[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -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<Pick<Sample, 'a'>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to multiple picked keys', () => {
|
||||||
|
expectTypeOf(pick({ a: 1, b: 'x', c: true } as Sample, ['a', 'b'])).toEqualTypeOf<Pick<Sample, 'a' | 'b'>>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { pick } from '.';
|
import { pick } from '.';
|
||||||
|
|
||||||
describe('pick', () => {
|
describe('pick', () => {
|
||||||
@@ -23,7 +23,11 @@ describe('pick', () => {
|
|||||||
it('handle non-existent keys by setting them to undefined', () => {
|
it('handle non-existent keys by setting them to undefined', () => {
|
||||||
const result = pick({ a: 1, b: 2 }, ['a', 'c'] as any);
|
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<string, unknown>).c).toBeUndefined();
|
||||||
|
expect(result.a).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('return an empty object if target is null or undefined', () => {
|
it('return an empty object if target is null or undefined', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { CommandHistory, AsyncCommandHistory } from '.';
|
import { AsyncCommandHistory, CommandHistory } from '.';
|
||||||
import type { Command, AsyncCommand } from '.';
|
import type { AsyncCommand, Command } from '.';
|
||||||
|
|
||||||
describe('commandHistory', () => {
|
describe('commandHistory', () => {
|
||||||
let history: CommandHistory;
|
let history: CommandHistory;
|
||||||
@@ -278,4 +278,43 @@ describe('asyncCommandHistory', () => {
|
|||||||
|
|
||||||
expect(limited.size).toBe(2);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { PubSub } from '.';
|
import { PubSub } from '.';
|
||||||
|
|
||||||
describe('pubsub', () => {
|
describe('pubsub', () => {
|
||||||
@@ -115,4 +115,54 @@ describe('pubsub', () => {
|
|||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
|
|||||||
if (!listeners)
|
if (!listeners)
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isString } from '../../../types';
|
import { isString } from '../../../types';
|
||||||
import { BaseStateMachine } from './base';
|
import { BaseStateMachine } from './base';
|
||||||
import type { AsyncStateNodeConfig, ExtractStates, ExtractEvents } from './types';
|
import type { AsyncStateNodeConfig, ExtractEvents, ExtractStates } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name AsyncStateMachine
|
* @name AsyncStateMachine
|
||||||
|
|||||||
@@ -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'>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createMachine, createAsyncMachine, StateMachine, AsyncStateMachine } from '.';
|
import { AsyncStateMachine, StateMachine, createAsyncMachine, createMachine } from '.';
|
||||||
|
|
||||||
describe('stateMachine', () => {
|
describe('stateMachine', () => {
|
||||||
describe('createMachine (without context)', () => {
|
describe('createMachine (without context)', () => {
|
||||||
@@ -685,4 +685,23 @@ describe('asyncStateMachine', () => {
|
|||||||
expect(entryHook).toHaveBeenCalledOnce();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isString } from '../../../types';
|
import { isString } from '../../../types';
|
||||||
import { BaseStateMachine } from './base';
|
import { BaseStateMachine } from './base';
|
||||||
import type { SyncStateNodeConfig, ExtractStates, ExtractEvents } from './types';
|
import type { ExtractEvents, ExtractStates, SyncStateNodeConfig } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name StateMachine
|
* @name StateMachine
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ describe('BinaryHeap', () => {
|
|||||||
|
|
||||||
expect(heap.peek()).toBe(8);
|
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', () => {
|
describe('push', () => {
|
||||||
@@ -226,4 +241,37 @@ describe('BinaryHeap', () => {
|
|||||||
expect(heap.pop()).toBeUndefined();
|
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<number | null>([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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { first } from '../../arrays';
|
|
||||||
import { isArray } from '../../types';
|
import { isArray } from '../../types';
|
||||||
import type { BinaryHeapLike, Comparator } from './types';
|
import type { BinaryHeapLike, Comparator } from './types';
|
||||||
|
|
||||||
@@ -54,7 +53,11 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
|
|||||||
|
|
||||||
if (initialValues !== null && initialValues !== undefined) {
|
if (initialValues !== null && initialValues !== undefined) {
|
||||||
const items = isArray(initialValues) ? initialValues : [initialValues];
|
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();
|
this.heapify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
|
|||||||
public pop(): T | undefined {
|
public pop(): T | undefined {
|
||||||
if (this.heap.length === 0) return undefined;
|
if (this.heap.length === 0) return undefined;
|
||||||
|
|
||||||
const root = first(this.heap)!;
|
const root = this.heap[0]!;
|
||||||
const last = this.heap.pop()!;
|
const last = this.heap.pop()!;
|
||||||
|
|
||||||
if (this.heap.length > 0) {
|
if (this.heap.length > 0) {
|
||||||
@@ -107,7 +110,8 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
|
|||||||
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
|
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
|
||||||
*/
|
*/
|
||||||
public peek(): T | undefined {
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CircularBuffer } from '.';
|
import { CircularBuffer } from '.';
|
||||||
|
|
||||||
describe('circularBuffer', () => {
|
describe('circularBuffer', () => {
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
|
|||||||
const requested = Math.max(items.length, initialCapacity ?? 0);
|
const requested = Math.max(items.length, initialCapacity ?? 0);
|
||||||
const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested));
|
const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested));
|
||||||
|
|
||||||
this.buffer = Array.from<T | undefined>({ length: cap });
|
// eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring (Array.from({length}) is ~40x slower)
|
||||||
|
this.buffer = new Array<T | undefined>(cap);
|
||||||
|
|
||||||
for (const item of items)
|
for (const item of items)
|
||||||
this.pushBack(item);
|
this.pushBack(item);
|
||||||
@@ -190,7 +191,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
|
|||||||
* @returns {this}
|
* @returns {this}
|
||||||
*/
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
this.buffer = Array.from<T | undefined>({ 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<T | undefined>(MIN_CAPACITY);
|
||||||
this.head = 0;
|
this.head = 0;
|
||||||
this.count = 0;
|
this.count = 0;
|
||||||
|
|
||||||
@@ -203,7 +205,8 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
|
|||||||
* @returns {T[]}
|
* @returns {T[]}
|
||||||
*/
|
*/
|
||||||
toArray() {
|
toArray() {
|
||||||
const result = Array.from<T>({ 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<T>(this.count);
|
||||||
|
|
||||||
for (let i = 0; i < this.count; i++)
|
for (let i = 0; i < this.count; i++)
|
||||||
result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T;
|
result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T;
|
||||||
@@ -225,8 +228,12 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
|
|||||||
*
|
*
|
||||||
* @returns {IterableIterator<T>}
|
* @returns {IterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
[Symbol.iterator]() {
|
* [Symbol.iterator](): IterableIterator<T> {
|
||||||
return this.toArray()[Symbol.iterator]();
|
// 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<T> implements CircularBufferLike<T> {
|
|||||||
*/
|
*/
|
||||||
private grow() {
|
private grow() {
|
||||||
const newCapacity = this.buffer.length << 1;
|
const newCapacity = this.buffer.length << 1;
|
||||||
const newBuffer = Array.from<T | undefined>({ length: newCapacity });
|
// eslint-disable-next-line unicorn/no-new-array -- preallocate exact-size ring on the amortized push hot path
|
||||||
|
const newBuffer = new Array<T | undefined>(newCapacity);
|
||||||
|
|
||||||
for (let i = 0; i < this.count; i++)
|
for (let i = 0; i < this.count; i++)
|
||||||
newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)];
|
newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Deque } from '.';
|
import { Deque } from '.';
|
||||||
|
|
||||||
describe('deque', () => {
|
describe('deque', () => {
|
||||||
@@ -31,6 +31,22 @@ describe('deque', () => {
|
|||||||
expect(deque.length).toBe(0);
|
expect(deque.length).toBe(0);
|
||||||
expect(deque.isFull).toBe(false);
|
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', () => {
|
describe('pushBack', () => {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export class Deque<T> implements DequeLike<T> {
|
|||||||
constructor(initialValues?: T[] | T, options?: DequeOptions) {
|
constructor(initialValues?: T[] | T, options?: DequeOptions) {
|
||||||
this.maxSize = options?.maxSize ?? Infinity;
|
this.maxSize = options?.maxSize ?? Infinity;
|
||||||
this.buffer = new CircularBuffer(initialValues);
|
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<T> implements DequeLike<T> {
|
|||||||
* @returns {boolean} `true` if the deque is full, `false` otherwise
|
* @returns {boolean} `true` if the deque is full, `false` otherwise
|
||||||
*/
|
*/
|
||||||
get isFull() {
|
get isFull() {
|
||||||
return this.buffer.length === this.maxSize;
|
return this.buffer.length >= this.maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ describe('LinkedList', () => {
|
|||||||
expect(list.popBack()).toBe(3);
|
expect(list.popBack()).toBe(3);
|
||||||
expect(list.length).toBe(2);
|
expect(list.length).toBe(2);
|
||||||
expect(list.peekBack()).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', () => {
|
it('should handle single element', () => {
|
||||||
@@ -123,6 +127,10 @@ describe('LinkedList', () => {
|
|||||||
expect(list.popFront()).toBe(1);
|
expect(list.popFront()).toBe(1);
|
||||||
expect(list.length).toBe(2);
|
expect(list.length).toBe(2);
|
||||||
expect(list.peekFront()).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', () => {
|
it('should handle single element', () => {
|
||||||
@@ -170,11 +178,14 @@ describe('LinkedList', () => {
|
|||||||
const list = new LinkedList<number>();
|
const list = new LinkedList<number>();
|
||||||
const node = list.pushBack(2);
|
const node = list.pushBack(2);
|
||||||
|
|
||||||
list.insertBefore(node, 1);
|
const inserted = list.insertBefore(node, 1);
|
||||||
|
|
||||||
expect(list.peekFront()).toBe(1);
|
expect(list.peekFront()).toBe(1);
|
||||||
expect(list.peekBack()).toBe(2);
|
expect(list.peekBack()).toBe(2);
|
||||||
expect(list.length).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', () => {
|
it('should insert before middle node', () => {
|
||||||
@@ -202,10 +213,13 @@ describe('LinkedList', () => {
|
|||||||
const list = new LinkedList<number>();
|
const list = new LinkedList<number>();
|
||||||
const node = list.pushBack(1);
|
const node = list.pushBack(1);
|
||||||
|
|
||||||
list.insertAfter(node, 2);
|
const inserted = list.insertAfter(node, 2);
|
||||||
|
|
||||||
expect(list.peekFront()).toBe(1);
|
expect(list.peekFront()).toBe(1);
|
||||||
expect(list.peekBack()).toBe(2);
|
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);
|
expect(list.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -209,5 +209,17 @@ describe('PriorityQueue', () => {
|
|||||||
expect(pq.dequeue()).toBe(10);
|
expect(pq.dequeue()).toBe(10);
|
||||||
expect(pq.dequeue()).toBeUndefined();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Queue } from '.';
|
import { Queue } from '.';
|
||||||
|
|
||||||
describe('queue', () => {
|
describe('queue', () => {
|
||||||
@@ -87,7 +87,7 @@ describe('queue', () => {
|
|||||||
expect(queue.dequeue()).toBeUndefined();
|
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<number>();
|
const queue = new Queue<number>();
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++)
|
for (let i = 0; i < 100; i++)
|
||||||
@@ -99,6 +99,17 @@ describe('queue', () => {
|
|||||||
expect(queue.length).toBe(20);
|
expect(queue.length).toBe(20);
|
||||||
expect(queue.peek()).toBe(80);
|
expect(queue.peek()).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('report isFull and free a slot after dequeue', () => {
|
||||||
|
const queue = new Queue<number>(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', () => {
|
describe('peek', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Stack } from '.';
|
import { Stack } from '.';
|
||||||
|
|
||||||
describe('stack', () => {
|
describe('stack', () => {
|
||||||
@@ -33,6 +33,26 @@ describe('stack', () => {
|
|||||||
expect(stack.length).toBe(0);
|
expect(stack.length).toBe(0);
|
||||||
expect(stack.isFull).toBe(false);
|
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', () => {
|
describe('push', () => {
|
||||||
@@ -51,6 +71,16 @@ describe('stack', () => {
|
|||||||
|
|
||||||
expect(() => stack.push(2)).toThrow(new RangeError('Stack is full'));
|
expect(() => stack.push(2)).toThrow(new RangeError('Stack is full'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('report isFull at the boundary', () => {
|
||||||
|
const stack = new Stack<number>(undefined, { maxSize: 2 });
|
||||||
|
stack.push(1).push(2);
|
||||||
|
|
||||||
|
expect(stack.isFull).toBe(true);
|
||||||
|
|
||||||
|
stack.pop();
|
||||||
|
expect(stack.isFull).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pop', () => {
|
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', () => {
|
describe('iteration', () => {
|
||||||
it('iterate over the stack in LIFO order', () => {
|
it('iterate over the stack in LIFO order', () => {
|
||||||
const stack = new Stack<number>([1, 2, 3]);
|
const stack = new Stack<number>([1, 2, 3]);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { last } from '../../arrays';
|
|
||||||
import { isArray } from '../../types';
|
import { isArray } from '../../types';
|
||||||
import type { StackLike } from './types';
|
import type { StackLike } from './types';
|
||||||
|
|
||||||
@@ -43,7 +42,17 @@ export class Stack<T> implements StackLike<T> {
|
|||||||
*/
|
*/
|
||||||
constructor(initialValues?: T[] | T, options?: StackOptions) {
|
constructor(initialValues?: T[] | T, options?: StackOptions) {
|
||||||
this.maxSize = options?.maxSize ?? Infinity;
|
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<T> implements StackLike<T> {
|
|||||||
* @returns {boolean} `true` if the stack is full, `false` otherwise
|
* @returns {boolean} `true` if the stack is full, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isFull() {
|
public get isFull() {
|
||||||
return this.stack.length === this.maxSize;
|
return this.stack.length >= this.maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,10 +107,9 @@ export class Stack<T> implements StackLike<T> {
|
|||||||
* @returns {T | undefined} The top element of the stack
|
* @returns {T | undefined} The top element of the stack
|
||||||
*/
|
*/
|
||||||
public peek() {
|
public peek() {
|
||||||
if (this.isEmpty)
|
// Direct index preserves a legitimately stored `undefined`/`null` top and
|
||||||
return undefined;
|
// returns `undefined` for an empty stack (noUncheckedIndexedAccess).
|
||||||
|
return this.stack[this.stack.length - 1];
|
||||||
return last(this.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,8 +146,10 @@ export class Stack<T> implements StackLike<T> {
|
|||||||
*
|
*
|
||||||
* @returns {IterableIterator<T>}
|
* @returns {IterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
public [Symbol.iterator]() {
|
public* [Symbol.iterator]() {
|
||||||
return this.toArray()[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<T> implements StackLike<T> {
|
|||||||
* @returns {AsyncIterableIterator<T>}
|
* @returns {AsyncIterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
public async* [Symbol.asyncIterator]() {
|
public async* [Symbol.asyncIterator]() {
|
||||||
for (const element of this.toArray()) {
|
for (let i = this.stack.length - 1; i >= 0; i--)
|
||||||
yield element;
|
yield this.stack[i]!;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { SyncMutex } from '.';
|
import { SyncMutex } from '.';
|
||||||
|
|
||||||
describe('SyncMutex', () => {
|
describe('SyncMutex', () => {
|
||||||
@@ -91,4 +91,21 @@ describe('SyncMutex', () => {
|
|||||||
|
|
||||||
expect(mutex.isLocked).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ export class SyncMutex {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
this.lock();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from './levenshtein-distance';
|
export * from './levenshtein-distance';
|
||||||
|
export * from './template';
|
||||||
export * from './trigram-distance';
|
export * from './trigram-distance';
|
||||||
// TODO: Template is not implemented yet
|
|
||||||
// export * from './template';
|
|
||||||
|
|||||||
@@ -29,4 +29,15 @@ describe('levenshteinDistance', () => {
|
|||||||
expect(levenshteinDistance('abc', '')).toBe(3);
|
expect(levenshteinDistance('abc', '')).toBe(3);
|
||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,32 +15,34 @@ export function levenshteinDistance(left: string, right: string): number {
|
|||||||
if (left.length === 0) return right.length;
|
if (left.length === 0) return right.length;
|
||||||
if (right.length === 0) return left.length;
|
if (right.length === 0) return left.length;
|
||||||
|
|
||||||
// Create empty edit distance matrix for all possible modifications of
|
// Iterate with the shorter string as the inner dimension so the rolling rows are
|
||||||
// substrings of left to substrings of right
|
// O(min(m, n)) memory instead of a full O(m * n) matrix.
|
||||||
const distanceMatrix = Array(right.length + 1).fill(null).map(() => Array(left.length + 1).fill(null));
|
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
|
// prev = previous row; current = row being computed. prev starts as the base row [0..innerLength].
|
||||||
// If this is the first row, we're transforming from an empty string to left
|
let prev = Array.from({ length: innerLength + 1 }, (_, i) => i);
|
||||||
// In this case, the number of operations equals the length of left substring
|
let current = Array.from<number>({ length: innerLength + 1 });
|
||||||
for (let i = 0; i <= left.length; i++)
|
|
||||||
distanceMatrix[0]![i]! = i;
|
|
||||||
|
|
||||||
// Fill the first column of the matrix
|
for (let i = 1; i <= outer.length; i++) {
|
||||||
// If this is the first column, we're transforming empty string to right
|
current[0] = i;
|
||||||
// In this case, the number of operations equals the length of right substring
|
const outerChar = outer[i - 1];
|
||||||
for (let j = 0; j <= right.length; j++)
|
|
||||||
distanceMatrix[j]![0]! = j;
|
|
||||||
|
|
||||||
for (let j = 1; j <= right.length; j++) {
|
for (let j = 1; j <= innerLength; j++) {
|
||||||
for (let i = 1; i <= left.length; i++) {
|
const cost = outerChar === inner[j - 1] ? 0 : 1;
|
||||||
const indicator = left[i - 1] === right[j - 1] ? 0 : 1;
|
current[j]! = Math.min(
|
||||||
distanceMatrix[j]![i]! = Math.min(
|
prev[j]! + 1, // insertion
|
||||||
distanceMatrix[j]![i - 1]! + 1, // deletion
|
current[j - 1]! + 1, // deletion
|
||||||
distanceMatrix[j - 1]![i]! + 1, // insertion
|
prev[j - 1]! + cost, // substitution
|
||||||
distanceMatrix[j - 1]![i - 1]! + indicator, // 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]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expectTypeOf, it } from 'vitest';
|
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', () => {
|
describe('ClearPlaceholder', () => {
|
||||||
it('ignores strings without braces', () => {
|
it('ignores strings without braces', () => {
|
||||||
type actual = ClearPlaceholder<'name'>;
|
type actual = ClearPlaceholder<'name'>;
|
||||||
@@ -102,4 +103,36 @@ describe.skip('template', () => {
|
|||||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GenerateTypes', () => {
|
||||||
|
type Shape = GenerateTypes<'user.name', string>;
|
||||||
|
|
||||||
|
it('accepts a fully-matching shape', () => {
|
||||||
|
expectTypeOf<{ user: { name: 'John' } }>().toExtend<Shape>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts missing keys (every key is optional)', () => {
|
||||||
|
expectTypeOf<{ unrelated: number }>().toExtend<Shape>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts extra keys (objects stay open)', () => {
|
||||||
|
expectTypeOf<{ user: { name: 'John' }; extra: number }>().toExtend<Shape>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a mistyped leaf value', () => {
|
||||||
|
expectTypeOf<{ user: { name: number } }>().not.toExtend<Shape>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templateObject', () => {
|
||||||
|
it('always returns a string', () => {
|
||||||
|
expectTypeOf(templateObject('Hello, {name}!', { name: 'John' })).toEqualTypeOf<string>();
|
||||||
|
expectTypeOf(templateObject('Hi {user.name}', { user: { name: 'John' } })).toEqualTypeOf<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a string or factory fallback', () => {
|
||||||
|
expectTypeOf(templateObject('Hello, {name}!', {}, 'Guest')).toEqualTypeOf<string>();
|
||||||
|
expectTypeOf(templateObject('Hello, {name}!', {}, key => `<${key}>`)).toEqualTypeOf<string>();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user