diff --git a/core/stdlib/package.json b/core/stdlib/package.json index 770369d..6b0c7d1 100644 --- a/core/stdlib/package.json +++ b/core/stdlib/package.json @@ -1,6 +1,6 @@ { "name": "@robonen/stdlib", - "version": "0.0.7", + "version": "0.0.8", "license": "Apache-2.0", "description": "A collection of tools, utilities, and helpers for TypeScript", "keywords": [ diff --git a/core/stdlib/src/math/bigint/lerpBigInt/index.ts b/core/stdlib/src/math/bigint/lerpBigInt/index.ts index 8772b27..3ba5f48 100644 --- a/core/stdlib/src/math/bigint/lerpBigInt/index.ts +++ b/core/stdlib/src/math/bigint/lerpBigInt/index.ts @@ -1,3 +1,10 @@ +/** + * Precision scale for bigint interpolation (6 decimal places). + * BigInt has no overflow, so higher precision is free. + */ +const SCALE = 1_000_000; +const SCALE_N = BigInt(SCALE); + /** * @name lerpBigInt * @category Math @@ -11,7 +18,7 @@ * @since 0.0.2 */ export function lerpBigInt(start: bigint, end: bigint, t: number) { - return start + ((end - start) * BigInt(t * 10000)) / 10000n; + return start + ((end - start) * BigInt(Math.round(t * SCALE))) / SCALE_N; } /** @@ -27,5 +34,5 @@ export function lerpBigInt(start: bigint, end: bigint, t: number) { * @since 0.0.2 */ export function inverseLerpBigInt(start: bigint, end: bigint, value: bigint) { - return start === end ? 0 : Number((value - start) * 10000n / (end - start)) / 10000; + return start === end ? 0 : Number((value - start) * SCALE_N / (end - start)) / SCALE; } \ No newline at end of file diff --git a/core/stdlib/src/patterns/behavioral/Command/async.ts b/core/stdlib/src/patterns/behavioral/Command/async.ts new file mode 100644 index 0000000..6d71b3c --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/Command/async.ts @@ -0,0 +1,55 @@ +import { BaseCommandHistory } from './base'; +import type { AsyncCommand } from './types'; + +/** + * @name AsyncCommandHistory + * @category Patterns + * @description Async command history with undo/redo/batch support + * + * @since 0.0.8 + */ +export class AsyncCommandHistory extends BaseCommandHistory { + async execute(command: AsyncCommand): Promise { + await command.execute(); + this.pushUndo(command); + } + + async undo(): Promise { + const command = this.undoStack.pop(); + + if (!command) + return false; + + await command.undo(); + this.moveToRedo(command); + + return true; + } + + async redo(): Promise { + const command = this.redoStack.pop(); + + if (!command) + return false; + + await command.execute(); + this.moveToUndo(command); + + return true; + } + + async batch(commands: AsyncCommand[]): Promise { + const macro: AsyncCommand = { + execute: async () => { + for (const c of commands) + await c.execute(); + }, + undo: async () => { + for (let i = commands.length - 1; i >= 0; i--) + await commands[i]!.undo(); + }, + }; + + await this.execute(macro); + } +} diff --git a/core/stdlib/src/patterns/behavioral/Command/base.ts b/core/stdlib/src/patterns/behavioral/Command/base.ts new file mode 100644 index 0000000..15f8b7e --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/Command/base.ts @@ -0,0 +1,49 @@ +/** + * @name BaseCommandHistory + * @category Patterns + * @description Base class with shared undo/redo stack management + * + * @since 0.0.8 + */ +export abstract class BaseCommandHistory { + protected undoStack: C[] = []; + protected redoStack: C[] = []; + protected readonly maxSize: number; + + constructor(options?: { maxSize?: number }) { + this.maxSize = options?.maxSize ?? Infinity; + } + + get size(): number { + return this.undoStack.length; + } + + get canUndo(): boolean { + return this.undoStack.length > 0; + } + + get canRedo(): boolean { + return this.redoStack.length > 0; + } + + clear(): void { + this.undoStack.length = 0; + this.redoStack.length = 0; + } + + protected pushUndo(command: C): void { + this.undoStack.push(command); + this.redoStack.length = 0; + + if (this.undoStack.length > this.maxSize) + this.undoStack.splice(0, this.undoStack.length - this.maxSize); + } + + protected moveToRedo(command: C): void { + this.redoStack.push(command); + } + + protected moveToUndo(command: C): void { + this.undoStack.push(command); + } +} diff --git a/core/stdlib/src/patterns/behavioral/Command/index.test.ts b/core/stdlib/src/patterns/behavioral/Command/index.test.ts new file mode 100644 index 0000000..c6691b5 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/Command/index.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CommandHistory, AsyncCommandHistory } from '.'; +import type { Command, AsyncCommand } from '.'; + +describe('commandHistory', () => { + let history: CommandHistory; + let items: string[]; + + function addItem(item: string): Command { + return { + execute: () => { items.push(item); }, + undo: () => { items.pop(); }, + }; + } + + beforeEach(() => { + history = new CommandHistory(); + items = []; + }); + + describe('execute', () => { + it('executes a command', () => { + history.execute(addItem('a')); + + expect(items).toEqual(['a']); + }); + + it('tracks size', () => { + history.execute(addItem('a')); + history.execute(addItem('b')); + + expect(history.size).toBe(2); + }); + + it('clears redo stack on new execute', () => { + history.execute(addItem('a')); + history.undo(); + + expect(history.canRedo).toBe(true); + + history.execute(addItem('b')); + + expect(history.canRedo).toBe(false); + }); + }); + + describe('undo', () => { + it('undoes the last command', () => { + history.execute(addItem('a')); + history.execute(addItem('b')); + history.undo(); + + expect(items).toEqual(['a']); + }); + + it('returns true when undo was performed', () => { + history.execute(addItem('a')); + + expect(history.undo()).toBe(true); + }); + + it('returns false when nothing to undo', () => { + expect(history.undo()).toBe(false); + }); + + it('multiple undos', () => { + history.execute(addItem('a')); + history.execute(addItem('b')); + history.execute(addItem('c')); + history.undo(); + history.undo(); + history.undo(); + + expect(items).toEqual([]); + expect(history.canUndo).toBe(false); + }); + }); + + describe('redo', () => { + it('redoes the last undone command', () => { + history.execute(addItem('a')); + history.undo(); + history.redo(); + + expect(items).toEqual(['a']); + }); + + it('returns true when redo was performed', () => { + history.execute(addItem('a')); + history.undo(); + + expect(history.redo()).toBe(true); + }); + + it('returns false when nothing to redo', () => { + expect(history.redo()).toBe(false); + }); + + it('undo then redo multiple times', () => { + history.execute(addItem('a')); + history.execute(addItem('b')); + history.undo(); + history.undo(); + history.redo(); + history.redo(); + + expect(items).toEqual(['a', 'b']); + }); + }); + + describe('batch', () => { + it('executes multiple commands', () => { + history.batch([addItem('a'), addItem('b'), addItem('c')]); + + expect(items).toEqual(['a', 'b', 'c']); + }); + + it('undoes all batched commands in one step', () => { + history.batch([addItem('a'), addItem('b'), addItem('c')]); + history.undo(); + + expect(items).toEqual([]); + }); + + it('counts as single history entry', () => { + history.batch([addItem('a'), addItem('b'), addItem('c')]); + + expect(history.size).toBe(1); + }); + + it('undoes batch in reverse order', () => { + const order: string[] = []; + + history.batch([ + { execute: () => order.push('exec-1'), undo: () => order.push('undo-1') }, + { execute: () => order.push('exec-2'), undo: () => order.push('undo-2') }, + { execute: () => order.push('exec-3'), undo: () => order.push('undo-3') }, + ]); + history.undo(); + + expect(order).toEqual(['exec-1', 'exec-2', 'exec-3', 'undo-3', 'undo-2', 'undo-1']); + }); + }); + + describe('maxSize', () => { + it('limits undo stack', () => { + const limited = new CommandHistory({ maxSize: 2 }); + items = []; + + limited.execute(addItem('a')); + limited.execute(addItem('b')); + limited.execute(addItem('c')); + + expect(limited.size).toBe(2); + expect(limited.canUndo).toBe(true); + + limited.undo(); + limited.undo(); + + expect(limited.canUndo).toBe(false); + expect(items).toEqual(['a']); + }); + }); + + describe('clear', () => { + it('clears all history', () => { + history.execute(addItem('a')); + history.execute(addItem('b')); + history.undo(); + history.clear(); + + expect(history.canUndo).toBe(false); + expect(history.canRedo).toBe(false); + expect(history.size).toBe(0); + }); + }); + + describe('canUndo / canRedo', () => { + it('initially false', () => { + expect(history.canUndo).toBe(false); + expect(history.canRedo).toBe(false); + }); + + it('canUndo after execute', () => { + history.execute(addItem('a')); + + expect(history.canUndo).toBe(true); + expect(history.canRedo).toBe(false); + }); + + it('canRedo after undo', () => { + history.execute(addItem('a')); + history.undo(); + + expect(history.canUndo).toBe(false); + expect(history.canRedo).toBe(true); + }); + }); +}); + +describe('asyncCommandHistory', () => { + let history: AsyncCommandHistory; + let items: string[]; + + function addItemAsync(item: string): AsyncCommand { + return { + execute: async () => { + await new Promise((r) => setTimeout(r, 5)); + items.push(item); + }, + undo: async () => { + await new Promise((r) => setTimeout(r, 5)); + items.pop(); + }, + }; + } + + beforeEach(() => { + history = new AsyncCommandHistory(); + items = []; + }); + + it('executes async command', async () => { + await history.execute(addItemAsync('a')); + + expect(items).toEqual(['a']); + }); + + it('undoes async command', async () => { + await history.execute(addItemAsync('a')); + await history.undo(); + + expect(items).toEqual([]); + }); + + it('redoes async command', async () => { + await history.execute(addItemAsync('a')); + await history.undo(); + await history.redo(); + + expect(items).toEqual(['a']); + }); + + it('batches async commands', async () => { + await history.batch([addItemAsync('a'), addItemAsync('b'), addItemAsync('c')]); + + expect(items).toEqual(['a', 'b', 'c']); + expect(history.size).toBe(1); + }); + + it('undoes async batch', async () => { + await history.batch([addItemAsync('a'), addItemAsync('b')]); + await history.undo(); + + expect(items).toEqual([]); + }); + + it('works with sync commands too', async () => { + await history.execute({ + execute: () => { items.push('sync'); }, + undo: () => { items.pop(); }, + }); + + expect(items).toEqual(['sync']); + + await history.undo(); + + expect(items).toEqual([]); + }); + + it('respects maxSize', async () => { + const limited = new AsyncCommandHistory({ maxSize: 2 }); + items = []; + + await limited.execute(addItemAsync('a')); + await limited.execute(addItemAsync('b')); + await limited.execute(addItemAsync('c')); + + expect(limited.size).toBe(2); + }); +}); diff --git a/core/stdlib/src/patterns/behavioral/Command/index.ts b/core/stdlib/src/patterns/behavioral/Command/index.ts new file mode 100644 index 0000000..96c01bd --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/Command/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; +export * from './sync'; +export * from './async'; diff --git a/core/stdlib/src/patterns/behavioral/Command/sync.ts b/core/stdlib/src/patterns/behavioral/Command/sync.ts new file mode 100644 index 0000000..0e1b137 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/Command/sync.ts @@ -0,0 +1,52 @@ +import { BaseCommandHistory } from './base'; +import type { Command } from './types'; + +/** + * @name CommandHistory + * @category Patterns + * @description Command history with undo/redo/batch support + * + * @since 0.0.8 + */ +export class CommandHistory extends BaseCommandHistory { + execute(command: Command): void { + command.execute(); + this.pushUndo(command); + } + + undo(): boolean { + const command = this.undoStack.pop(); + + if (!command) + return false; + + command.undo(); + this.moveToRedo(command); + + return true; + } + + redo(): boolean { + const command = this.redoStack.pop(); + + if (!command) + return false; + + command.execute(); + this.moveToUndo(command); + + return true; + } + + batch(commands: Command[]): void { + const macro: Command = { + execute: () => commands.forEach((c) => c.execute()), + undo: () => { + for (let i = commands.length - 1; i >= 0; i--) + commands[i]!.undo(); + }, + }; + + this.execute(macro); + } +} diff --git a/core/stdlib/src/patterns/behavioral/Command/types.ts b/core/stdlib/src/patterns/behavioral/Command/types.ts new file mode 100644 index 0000000..d64e680 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/Command/types.ts @@ -0,0 +1,11 @@ +import type { MaybePromise } from '../../../types'; + +export type Command = { + execute: () => void; + undo: () => void; +}; + +export type AsyncCommand = { + execute: () => MaybePromise; + undo: () => MaybePromise; +}; diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/async.ts b/core/stdlib/src/patterns/behavioral/StateMachine/async.ts new file mode 100644 index 0000000..5bedd0b --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/async.ts @@ -0,0 +1,132 @@ +import { BaseStateMachine } from './base'; +import type { AsyncStateNodeConfig, ExtractStates, ExtractEvents } from './types'; + +/** + * @name AsyncStateMachine + * @category Patterns + * @description Async finite state machine with support for async guards, actions, and hooks + * + * @since 0.0.8 + * + * @template States - Union of state names + * @template Events - Union of event names + * @template Context - Machine context type + */ +export class AsyncStateMachine< + States extends string = string, + Events extends string = string, + Context = undefined, +> extends BaseStateMachine> { + /** + * Send an event to the machine, awaiting async guards, actions, and hooks + * + * @param event - Event name + * @returns The current state after processing the event + */ + async send(event: Events): Promise { + const stateNode = this._states[this._current]; + + if (!stateNode?.on) + return this._current; + + const transition = stateNode.on[event]; + + if (transition === undefined) + return this._current; + + let target: string; + + if (typeof transition === 'string') { + target = transition; + } else { + if (transition.guard && !(await transition.guard(this._context))) + return this._current; + + await transition.action?.(this._context); + target = transition.target; + } + + await stateNode.exit?.(this._context); + this._current = target as States; + await this._states[this._current]?.entry?.(this._context); + + return this._current; + } + + /** + * Check if an event can trigger a transition, awaiting async guards + * + * @param event - Event to check + */ + async can(event: Events): Promise { + const stateNode = this._states[this._current]; + + if (!stateNode?.on) + return false; + + const transition = stateNode.on[event]; + + if (transition === undefined) + return false; + + if (typeof transition !== 'string' && transition.guard) + return await transition.guard(this._context); + + return true; + } +} + +/** + * Create a type-safe async finite state machine with context + * + * @example + * ```ts + * const machine = createAsyncMachine({ + * initial: 'idle', + * context: { data: '' }, + * states: { + * idle: { + * on: { + * FETCH: { + * target: 'loaded', + * guard: async () => await hasPermission(), + * action: async (ctx) => { ctx.data = await fetchData(); }, + * }, + * }, + * }, + * loaded: { + * entry: async (ctx) => { await saveToCache(ctx.data); }, + * }, + * }, + * }); + * + * await machine.send('FETCH'); // 'loaded' + * ``` + */ +export function createAsyncMachine< + const States extends Record>, + Context, +>(config: { + initial: NoInfer>; + context: Context; + states: States; +}): AsyncStateMachine, ExtractEvents, Context>; + +export function createAsyncMachine< + const States extends Record>, +>(config: { + initial: NoInfer>; + states: States; +}): AsyncStateMachine, ExtractEvents, undefined>; + +export function createAsyncMachine(config: { + initial: string; + context?: unknown; + states: Record>; +}): AsyncStateMachine { + return new AsyncStateMachine( + config.initial, + config.states, + config.context as undefined, + ); +} diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/base.ts b/core/stdlib/src/patterns/behavioral/StateMachine/base.ts new file mode 100644 index 0000000..d48d713 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/base.ts @@ -0,0 +1,47 @@ +/** + * Base class for state machines — holds shared state, getters, and matches() + * + * @template States - Union of state names + * @template Events - Union of event names + * @template Context - Machine context type + * @template NodeConfig - State node configuration type + */ +export class BaseStateMachine< + States extends string, + Events extends string, + Context, + NodeConfig, +> { + protected _current: States; + protected _context: Context; + protected _states: Record; + + constructor( + initial: States, + states: Record, + context: Context, + ) { + this._current = initial; + this._context = context; + this._states = states; + } + + /** Current state of the machine */ + get current(): States { + return this._current; + } + + /** Machine context */ + get context(): Context { + return this._context; + } + + /** + * Check if the machine is in a specific state + * + * @param state - State to check + */ + matches(state: States): boolean { + return this._current === state; + } +} diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts b/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts new file mode 100644 index 0000000..070cde1 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts @@ -0,0 +1,688 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createMachine, createAsyncMachine, StateMachine, AsyncStateMachine } from '.'; + +describe('stateMachine', () => { + describe('createMachine (without context)', () => { + let machine: ReturnType; + + function createSimpleMachine() { + return createMachine({ + initial: 'idle', + states: { + idle: { + on: { + START: 'running', + }, + }, + running: { + on: { + STOP: 'idle', + PAUSE: 'paused', + }, + }, + paused: { + on: { + RESUME: 'running', + STOP: 'idle', + }, + }, + }, + }); + } + + beforeEach(() => { + machine = createSimpleMachine(); + }); + + it('initializes with the initial state', () => { + expect(machine.current).toBe('idle'); + }); + + it('transitions on send', () => { + machine.send('START'); + + expect(machine.current).toBe('running'); + }); + + it('returns new state from send', () => { + const result = machine.send('START'); + + expect(result).toBe('running'); + }); + + it('handles multiple transitions', () => { + machine.send('START'); + machine.send('PAUSE'); + machine.send('RESUME'); + machine.send('STOP'); + + expect(machine.current).toBe('idle'); + }); + + it('ignores unhandled events', () => { + machine.send('STOP'); + + expect(machine.current).toBe('idle'); + }); + + it('ignores events not defined in current state', () => { + machine.send('PAUSE'); + + expect(machine.current).toBe('idle'); + }); + + it('matches current state', () => { + expect(machine.matches('idle')).toBe(true); + expect(machine.matches('running')).toBe(false); + + machine.send('START'); + + expect(machine.matches('idle')).toBe(false); + expect(machine.matches('running')).toBe(true); + }); + + it('checks if event can be handled', () => { + expect(machine.can('START')).toBe(true); + expect(machine.can('STOP')).toBe(false); + expect(machine.can('PAUSE')).toBe(false); + + machine.send('START'); + + expect(machine.can('START')).toBe(false); + expect(machine.can('STOP')).toBe(true); + expect(machine.can('PAUSE')).toBe(true); + }); + }); + + describe('createMachine (with context)', () => { + function createContextMachine() { + return createMachine({ + initial: 'idle', + context: { count: 0, log: '' }, + states: { + idle: { + on: { + START: { + target: 'running', + action: (ctx) => { ctx.count = 0; }, + }, + }, + }, + running: { + on: { + INCREMENT: { + target: 'running', + action: (ctx) => { ctx.count++; }, + }, + STOP: 'idle', + }, + }, + }, + }); + } + + it('provides typed context', () => { + const machine = createContextMachine(); + + expect(machine.context).toEqual({ count: 0, log: '' }); + }); + + it('runs action on transition', () => { + const machine = createContextMachine(); + + machine.send('START'); + machine.send('INCREMENT'); + machine.send('INCREMENT'); + machine.send('INCREMENT'); + + expect(machine.context.count).toBe(3); + }); + + it('resets context via action', () => { + const machine = createContextMachine(); + + machine.send('START'); + machine.send('INCREMENT'); + machine.send('INCREMENT'); + machine.send('STOP'); + machine.send('START'); + + expect(machine.context.count).toBe(0); + }); + }); + + describe('guards', () => { + function createGuardedMachine() { + return createMachine({ + initial: 'idle', + context: { retries: 0 }, + states: { + idle: { + on: { + TRY: { + target: 'attempting', + action: (ctx) => { ctx.retries++; }, + }, + }, + }, + attempting: { + on: { + FAIL: { + target: 'idle', + guard: (ctx) => ctx.retries < 3, + }, + SUCCESS: 'done', + }, + }, + done: {}, + }, + }); + } + + it('allows transition when guard returns true', () => { + const machine = createGuardedMachine(); + + machine.send('TRY'); + machine.send('FAIL'); + + expect(machine.current).toBe('idle'); + expect(machine.context.retries).toBe(1); + }); + + it('blocks transition when guard returns false', () => { + const machine = createGuardedMachine(); + + machine.send('TRY'); + machine.send('FAIL'); + machine.send('TRY'); + machine.send('FAIL'); + machine.send('TRY'); + machine.send('FAIL'); + + expect(machine.current).toBe('attempting'); + expect(machine.context.retries).toBe(3); + }); + + it('reflects guard in can()', () => { + const machine = createGuardedMachine(); + + machine.send('TRY'); + expect(machine.can('FAIL')).toBe(true); + + machine.send('FAIL'); + machine.send('TRY'); + machine.send('FAIL'); + machine.send('TRY'); + + expect(machine.can('FAIL')).toBe(false); + }); + }); + + describe('entry/exit hooks', () => { + it('calls exit on previous state and entry on next state', () => { + const exitIdle = vi.fn(); + const enterRunning = vi.fn(); + + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { START: 'running' }, + exit: exitIdle, + }, + running: { + on: { STOP: 'idle' }, + entry: enterRunning, + }, + }, + }); + + machine.send('START'); + + expect(exitIdle).toHaveBeenCalledOnce(); + expect(enterRunning).toHaveBeenCalledOnce(); + }); + + it('does not call hooks when transition is blocked by guard', () => { + const exitHook = vi.fn(); + const entryHook = vi.fn(); + + const machine = createMachine({ + initial: 'locked', + context: { unlocked: false }, + states: { + locked: { + on: { + UNLOCK: { + target: 'unlocked', + guard: (ctx) => ctx.unlocked, + }, + }, + exit: exitHook, + }, + unlocked: { + entry: entryHook, + }, + }, + }); + + machine.send('UNLOCK'); + + expect(exitHook).not.toHaveBeenCalled(); + expect(entryHook).not.toHaveBeenCalled(); + expect(machine.current).toBe('locked'); + }); + + it('calls hooks with context', () => { + const entryHook = vi.fn(); + + const machine = createMachine({ + initial: 'idle', + context: { value: 42 }, + states: { + idle: { + on: { GO: 'active' }, + }, + active: { + entry: entryHook, + }, + }, + }); + + machine.send('GO'); + + expect(entryHook).toHaveBeenCalledWith({ value: 42 }); + }); + + it('calls exit and entry on self-transitions', () => { + const exitHook = vi.fn(); + const entryHook = vi.fn(); + + const machine = createMachine({ + initial: 'active', + states: { + active: { + on: { REFRESH: 'active' }, + entry: entryHook, + exit: exitHook, + }, + }, + }); + + machine.send('REFRESH'); + + expect(exitHook).toHaveBeenCalledOnce(); + expect(entryHook).toHaveBeenCalledOnce(); + }); + }); + + describe('StateMachine class', () => { + it('can be instantiated directly', () => { + const machine = new StateMachine<'on' | 'off', 'TOGGLE'>( + 'off', + { + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, + }, + undefined, + ); + + expect(machine.current).toBe('off'); + + machine.send('TOGGLE'); + + expect(machine.current).toBe('on'); + + machine.send('TOGGLE'); + + expect(machine.current).toBe('off'); + }); + }); + + describe('edge cases', () => { + it('handles state with no transitions', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { GO: 'end' }, + }, + end: {}, + }, + }); + + machine.send('GO'); + + expect(machine.current).toBe('end'); + expect(machine.send('GO')).toBe('end'); + }); + + it('handles action that modifies context before guard on next transition', () => { + const machine = createMachine({ + initial: 'a', + context: { step: 0 }, + states: { + a: { + on: { + NEXT: { + target: 'b', + action: (ctx) => { ctx.step = 1; }, + }, + }, + }, + b: { + on: { + NEXT: { + target: 'c', + guard: (ctx) => ctx.step === 1, + action: (ctx) => { ctx.step = 2; }, + }, + }, + }, + c: {}, + }, + }); + + machine.send('NEXT'); + machine.send('NEXT'); + + expect(machine.current).toBe('c'); + expect(machine.context.step).toBe(2); + }); + }); +}); + +describe('asyncStateMachine', () => { + describe('createAsyncMachine (without context)', () => { + it('handles simple string transitions', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + states: { + idle: { on: { START: 'running' } }, + running: { on: { STOP: 'idle' } }, + }, + }); + + const result = await machine.send('START'); + + expect(result).toBe('running'); + expect(machine.current).toBe('running'); + }); + + it('ignores unhandled events', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + states: { + idle: { on: { START: 'running' } }, + running: {}, + }, + }); + + const result = await machine.send('STOP'); + + expect(result).toBe('idle'); + }); + }); + + describe('async guards', () => { + it('allows transition on async guard returning true', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + context: { allowed: true }, + states: { + idle: { + on: { + GO: { + target: 'active', + guard: async (ctx) => ctx.allowed, + }, + }, + }, + active: {}, + }, + }); + + await machine.send('GO'); + + expect(machine.current).toBe('active'); + }); + + it('blocks transition on async guard returning false', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + context: { allowed: false }, + states: { + idle: { + on: { + GO: { + target: 'active', + guard: async (ctx) => ctx.allowed, + }, + }, + }, + active: {}, + }, + }); + + await machine.send('GO'); + + expect(machine.current).toBe('idle'); + }); + }); + + describe('async actions', () => { + it('awaits async action before entering target', async () => { + const order: string[] = []; + + const machine = createAsyncMachine({ + initial: 'idle', + context: { data: '' }, + states: { + idle: { + on: { + FETCH: { + target: 'done', + action: async (ctx) => { + await new Promise((r) => setTimeout(r, 10)); + ctx.data = 'fetched'; + order.push('action'); + }, + }, + }, + }, + done: { + entry: () => { order.push('entry'); }, + }, + }, + }); + + await machine.send('FETCH'); + + expect(machine.context.data).toBe('fetched'); + expect(order).toEqual(['action', 'entry']); + }); + }); + + describe('async entry/exit hooks', () => { + it('awaits async exit and entry hooks in order', async () => { + const order: string[] = []; + + const machine = createAsyncMachine({ + initial: 'a', + states: { + a: { + on: { GO: 'b' }, + exit: async () => { + await new Promise((r) => setTimeout(r, 10)); + order.push('exit-a'); + }, + }, + b: { + entry: async () => { + await new Promise((r) => setTimeout(r, 10)); + order.push('entry-b'); + }, + }, + }, + }); + + await machine.send('GO'); + + expect(machine.current).toBe('b'); + expect(order).toEqual(['exit-a', 'entry-b']); + }); + + it('does not call hooks when async guard blocks', async () => { + const exitHook = vi.fn(); + const entryHook = vi.fn(); + + const machine = createAsyncMachine({ + initial: 'locked', + context: { unlocked: false }, + states: { + locked: { + on: { + UNLOCK: { + target: 'unlocked', + guard: async (ctx) => ctx.unlocked, + }, + }, + exit: exitHook, + }, + unlocked: { + entry: entryHook, + }, + }, + }); + + await machine.send('UNLOCK'); + + expect(exitHook).not.toHaveBeenCalled(); + expect(entryHook).not.toHaveBeenCalled(); + expect(machine.current).toBe('locked'); + }); + }); + + describe('can()', () => { + it('evaluates async guard', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + context: { ready: true }, + states: { + idle: { + on: { + GO: { + target: 'active', + guard: async (ctx) => ctx.ready, + }, + }, + }, + active: {}, + }, + }); + + expect(await machine.can('GO')).toBe(true); + + machine.context.ready = false; + + expect(await machine.can('GO')).toBe(false); + }); + + it('returns false for undefined events', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + states: { + idle: { on: { START: 'running' } }, + running: {}, + }, + }); + + expect(await machine.can('STOP')).toBe(false); + }); + + it('returns true for transitions without guard', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + states: { + idle: { on: { START: 'running' } }, + running: {}, + }, + }); + + expect(await machine.can('START')).toBe(true); + }); + }); + + describe('matches()', () => { + it('checks current state synchronously', async () => { + const machine = createAsyncMachine({ + initial: 'idle', + states: { + idle: { on: { GO: 'active' } }, + active: {}, + }, + }); + + expect(machine.matches('idle')).toBe(true); + + await machine.send('GO'); + + expect(machine.matches('active')).toBe(true); + expect(machine.matches('idle')).toBe(false); + }); + }); + + describe('AsyncStateMachine class', () => { + it('can be instantiated directly', async () => { + const machine = new AsyncStateMachine<'on' | 'off', 'TOGGLE'>( + 'off', + { + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, + }, + undefined, + ); + + expect(machine.current).toBe('off'); + + await machine.send('TOGGLE'); + + expect(machine.current).toBe('on'); + + await machine.send('TOGGLE'); + + expect(machine.current).toBe('off'); + }); + }); + + describe('sync callbacks work too', () => { + it('handles sync guard/action/hooks in async machine', async () => { + const entryHook = vi.fn(); + + const machine = createAsyncMachine({ + initial: 'idle', + context: { count: 0 }, + states: { + idle: { + on: { + GO: { + target: 'active', + guard: (ctx) => ctx.count === 0, + action: (ctx) => { ctx.count++; }, + }, + }, + }, + active: { + entry: entryHook, + }, + }, + }); + + await machine.send('GO'); + + expect(machine.current).toBe('active'); + expect(machine.context.count).toBe(1); + expect(entryHook).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/index.ts b/core/stdlib/src/patterns/behavioral/StateMachine/index.ts new file mode 100644 index 0000000..96c01bd --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; +export * from './sync'; +export * from './async'; diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts b/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts new file mode 100644 index 0000000..14a1459 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/sync.ts @@ -0,0 +1,133 @@ +import { BaseStateMachine } from './base'; +import type { SyncStateNodeConfig, ExtractStates, ExtractEvents } from './types'; + +/** + * @name StateMachine + * @category Patterns + * @description Simple, performant, and type-safe finite state machine + * + * @since 0.0.8 + * + * @template States - Union of state names + * @template Events - Union of event names + * @template Context - Machine context type + */ +export class StateMachine< + States extends string = string, + Events extends string = string, + Context = undefined, +> extends BaseStateMachine> { + /** + * Send an event to the machine, potentially causing a state transition + * + * @param event - Event name + * @returns The current state after processing the event + */ + send(event: Events): States { + const stateNode = this._states[this._current]; + + if (!stateNode?.on) + return this._current; + + const transition = stateNode.on[event]; + + if (transition === undefined) + return this._current; + + let target: string; + + if (typeof transition === 'string') { + target = transition; + } else { + if (transition.guard && !transition.guard(this._context)) + return this._current; + + transition.action?.(this._context); + target = transition.target; + } + + stateNode.exit?.(this._context); + this._current = target as States; + this._states[this._current]?.entry?.(this._context); + + return this._current; + } + + /** + * Check if an event can trigger a transition from the current state + * + * @param event - Event to check + */ + can(event: Events): boolean { + const stateNode = this._states[this._current]; + + if (!stateNode?.on) + return false; + + const transition = stateNode.on[event]; + + if (transition === undefined) + return false; + + if (typeof transition !== 'string' && transition.guard) + return transition.guard(this._context); + + return true; + } +} + +/** + * Create a type-safe synchronous finite state machine with context + * + * @example + * ```ts + * const machine = createMachine({ + * initial: 'idle', + * context: { retries: 0 }, + * states: { + * idle: { + * on: { START: 'running' }, + * }, + * running: { + * on: { + * FAIL: { + * target: 'idle', + * guard: (ctx) => ctx.retries < 3, + * action: (ctx) => { ctx.retries++; }, + * }, + * STOP: 'idle', + * }, + * }, + * }, + * }); + * + * machine.send('START'); // 'running' + * ``` + */ +export function createMachine< + const States extends Record>, + Context, +>(config: { + initial: NoInfer>; + context: Context; + states: States; +}): StateMachine, ExtractEvents, Context>; + +export function createMachine< + const States extends Record>, +>(config: { + initial: NoInfer>; + states: States; +}): StateMachine, ExtractEvents, undefined>; + +export function createMachine(config: { + initial: string; + context?: unknown; + states: Record>; +}): StateMachine { + return new StateMachine( + config.initial, + config.states, + config.context as undefined, + ); +} diff --git a/core/stdlib/src/patterns/behavioral/StateMachine/types.ts b/core/stdlib/src/patterns/behavioral/StateMachine/types.ts new file mode 100644 index 0000000..9a72834 --- /dev/null +++ b/core/stdlib/src/patterns/behavioral/StateMachine/types.ts @@ -0,0 +1,64 @@ +import type { MaybePromise } from '../../../types'; + +/** + * Configuration for a state transition + * + * @template Context - Machine context type + * @template Guard - Guard return type (boolean or MaybePromise\) + * @template Action - Action return type (void or MaybePromise\) + */ +export type TransitionConfig< + Context, + Guard = boolean, + Action = void, +> = { + /** Target state to transition to */ + target: string; + /** Guard condition — transition only occurs if this returns true */ + guard?: (context: Context) => Guard; + /** Side effect executed during transition (before entering target state) */ + action?: (context: Context) => Action; +}; + +/** + * A transition can be a target state name or a detailed configuration + */ +export type Transition< + Context, + Guard = boolean, + Action = void, +> = string | TransitionConfig; + +/** + * Configuration for a state node + * + * @template Context - Machine context type + * @template Guard - Guard return type + * @template Hook - Hook return type (entry/exit/action) + */ +export type StateNodeConfig< + Context, + Guard = boolean, + Hook = void, +> = { + /** Map of event names to transitions */ + on?: Record>; + /** Hook called when entering this state */ + entry?: (context: Context) => Hook; + /** Hook called when exiting this state */ + exit?: (context: Context) => Hook; +}; + +/** Sync state node config — guards return boolean, hooks return void */ +export type SyncStateNodeConfig = StateNodeConfig; + +/** Async state node config — guards return MaybePromise\, hooks return MaybePromise\ */ +export type AsyncStateNodeConfig = StateNodeConfig, MaybePromise>; + +export type ExtractStates = keyof T & string; + +export type ExtractEvents = { + [K in keyof T]: T[K] extends { readonly on?: Readonly> } + ? E + : never; +}[keyof T]; diff --git a/core/stdlib/src/patterns/behavioral/pubsub/index.ts b/core/stdlib/src/patterns/behavioral/pubsub/index.ts index c1016bf..fc47f04 100644 --- a/core/stdlib/src/patterns/behavioral/pubsub/index.ts +++ b/core/stdlib/src/patterns/behavioral/pubsub/index.ts @@ -1,9 +1,3 @@ -import type { AnyFunction } from '../../../types'; - -export type Subscriber = AnyFunction; - -export type EventHandlerMap = Record; - /** * @name PubSub * @category Patterns @@ -11,9 +5,9 @@ export type EventHandlerMap = Record; * * @since 0.0.2 * - * @template Events - Event map where all values are function types + * @template Events - Event map where keys are event names and values are listener signatures */ -export class PubSub { +export class PubSub any>> { /** * Events map * diff --git a/core/stdlib/src/patterns/index.ts b/core/stdlib/src/patterns/index.ts index e509dbc..6c0db50 100644 --- a/core/stdlib/src/patterns/index.ts +++ b/core/stdlib/src/patterns/index.ts @@ -1 +1,3 @@ -export * from './behavioral/pubsub'; \ No newline at end of file +export * from './behavioral/Command'; +export * from './behavioral/PubSub'; +export * from './behavioral/StateMachine'; \ No newline at end of file diff --git a/core/stdlib/src/structs/BinaryHeap/index.test.ts b/core/stdlib/src/structs/BinaryHeap/index.test.ts new file mode 100644 index 0000000..02c44ef --- /dev/null +++ b/core/stdlib/src/structs/BinaryHeap/index.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { BinaryHeap } from '.'; + +describe('BinaryHeap', () => { + describe('constructor', () => { + it('should create an empty heap', () => { + const heap = new BinaryHeap(); + + expect(heap.length).toBe(0); + expect(heap.isEmpty).toBe(true); + }); + + it('should create a heap from single value', () => { + const heap = new BinaryHeap(42); + + expect(heap.length).toBe(1); + expect(heap.peek()).toBe(42); + }); + + it('should create a heap from array (heapify)', () => { + const heap = new BinaryHeap([5, 3, 8, 1, 4]); + + expect(heap.length).toBe(5); + expect(heap.peek()).toBe(1); + }); + + it('should accept a custom comparator for max-heap', () => { + const heap = new BinaryHeap([5, 3, 8, 1, 4], { + comparator: (a, b) => b - a, + }); + + expect(heap.peek()).toBe(8); + }); + }); + + describe('push', () => { + it('should insert elements maintaining heap property', () => { + const heap = new BinaryHeap(); + + heap.push(5); + heap.push(3); + heap.push(8); + heap.push(1); + + expect(heap.peek()).toBe(1); + expect(heap.length).toBe(4); + }); + + it('should handle duplicate values', () => { + const heap = new BinaryHeap(); + + heap.push(3); + heap.push(3); + heap.push(3); + + expect(heap.length).toBe(3); + expect(heap.peek()).toBe(3); + }); + }); + + describe('pop', () => { + it('should return undefined for empty heap', () => { + const heap = new BinaryHeap(); + + expect(heap.pop()).toBeUndefined(); + }); + + it('should extract elements in min-heap order', () => { + const heap = new BinaryHeap([5, 3, 8, 1, 4, 2, 7, 6]); + const sorted: number[] = []; + + while (!heap.isEmpty) { + sorted.push(heap.pop()!); + } + + expect(sorted).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('should extract elements in max-heap order with custom comparator', () => { + const heap = new BinaryHeap([5, 3, 8, 1, 4], { + comparator: (a, b) => b - a, + }); + const sorted: number[] = []; + + while (!heap.isEmpty) { + sorted.push(heap.pop()!); + } + + expect(sorted).toEqual([8, 5, 4, 3, 1]); + }); + + it('should handle single element', () => { + const heap = new BinaryHeap(42); + + expect(heap.pop()).toBe(42); + expect(heap.isEmpty).toBe(true); + }); + }); + + describe('peek', () => { + it('should return undefined for empty heap', () => { + const heap = new BinaryHeap(); + + expect(heap.peek()).toBeUndefined(); + }); + + it('should return root without removing it', () => { + const heap = new BinaryHeap([5, 3, 1]); + + expect(heap.peek()).toBe(1); + expect(heap.length).toBe(3); + }); + }); + + describe('clear', () => { + it('should remove all elements', () => { + const heap = new BinaryHeap([1, 2, 3]); + + const result = heap.clear(); + + expect(heap.length).toBe(0); + expect(heap.isEmpty).toBe(true); + expect(result).toBe(heap); + }); + }); + + describe('toArray', () => { + it('should return empty array for empty heap', () => { + const heap = new BinaryHeap(); + + expect(heap.toArray()).toEqual([]); + }); + + it('should return a shallow copy', () => { + const heap = new BinaryHeap([3, 1, 2]); + const arr = heap.toArray(); + + arr.push(99); + + expect(heap.length).toBe(3); + }); + }); + + describe('toString', () => { + it('should return formatted string', () => { + const heap = new BinaryHeap([1, 2, 3]); + + expect(heap.toString()).toBe('BinaryHeap(3)'); + }); + }); + + describe('iterator', () => { + it('should iterate over heap elements', () => { + const heap = new BinaryHeap([5, 3, 8, 1]); + const elements = [...heap]; + + expect(elements.length).toBe(4); + expect(elements[0]).toBe(1); + }); + }); + + describe('custom comparator', () => { + it('should work with string length comparator', () => { + const heap = new BinaryHeap(['banana', 'apple', 'kiwi', 'fig'], { + comparator: (a, b) => a.length - b.length, + }); + + expect(heap.pop()).toBe('fig'); + expect(heap.pop()).toBe('kiwi'); + }); + + it('should work with object comparator', () => { + interface Task { + priority: number; + name: string; + } + + const heap = new BinaryHeap( + [ + { priority: 3, name: 'low' }, + { priority: 1, name: 'high' }, + { priority: 2, name: 'medium' }, + ], + { comparator: (a, b) => a.priority - b.priority }, + ); + + expect(heap.pop()?.name).toBe('high'); + expect(heap.pop()?.name).toBe('medium'); + expect(heap.pop()?.name).toBe('low'); + }); + }); + + describe('heapify', () => { + it('should correctly heapify large arrays', () => { + const values = Array.from({ length: 1000 }, () => Math.random() * 1000 | 0); + const heap = new BinaryHeap(values); + const sorted: number[] = []; + + while (!heap.isEmpty) { + sorted.push(heap.pop()!); + } + + const expected = [...values].sort((a, b) => a - b); + + expect(sorted).toEqual(expected); + }); + }); + + describe('interleaved operations', () => { + it('should maintain heap property with mixed push and pop', () => { + const heap = new BinaryHeap(); + + heap.push(10); + heap.push(5); + expect(heap.pop()).toBe(5); + + heap.push(3); + heap.push(7); + expect(heap.pop()).toBe(3); + + heap.push(1); + expect(heap.pop()).toBe(1); + expect(heap.pop()).toBe(7); + expect(heap.pop()).toBe(10); + expect(heap.pop()).toBeUndefined(); + }); + }); +}); diff --git a/core/stdlib/src/structs/BinaryHeap/index.ts b/core/stdlib/src/structs/BinaryHeap/index.ts new file mode 100644 index 0000000..4758051 --- /dev/null +++ b/core/stdlib/src/structs/BinaryHeap/index.ts @@ -0,0 +1,220 @@ +import { first } from '../../arrays'; +import { isArray } from '../../types'; +import type { BinaryHeapLike, Comparator } from './types'; + +export type { BinaryHeapLike, Comparator } from './types'; + +export interface BinaryHeapOptions { + comparator?: Comparator; +} + +/** + * Default min-heap comparator for numeric values + * + * @param {number} a First element + * @param {number} b Second element + * @returns {number} Negative if a < b, positive if a > b, zero if equal + */ +const defaultComparator: Comparator = (a: number, b: number) => a - b; + +/** + * @name BinaryHeap + * @category Data Structures + * @description Binary heap backed by a flat array with configurable comparator + * + * @since 0.0.8 + * + * @template T The type of elements stored in the heap + */ +export class BinaryHeap implements BinaryHeapLike { + /** + * The comparator function used to order elements + * + * @private + * @type {Comparator} + */ + private readonly comparator: Comparator; + + /** + * Internal flat array backing the heap + * + * @private + * @type {T[]} + */ + private readonly heap: T[] = []; + + /** + * Creates an instance of BinaryHeap + * + * @param {(T[] | T)} [initialValues] The initial values to heapify + * @param {BinaryHeapOptions} [options] Heap configuration + */ + constructor(initialValues?: T[] | T, options?: BinaryHeapOptions) { + this.comparator = options?.comparator ?? defaultComparator; + + if (initialValues != null) { + const items = isArray(initialValues) ? initialValues : [initialValues]; + this.heap.push(...items); + this.heapify(); + } + } + + /** + * Gets the number of elements in the heap + * @returns {number} The number of elements in the heap + */ + public get length(): number { + return this.heap.length; + } + + /** + * Checks if the heap is empty + * @returns {boolean} `true` if the heap is empty, `false` otherwise + */ + public get isEmpty(): boolean { + return this.heap.length === 0; + } + + /** + * Pushes an element into the heap + * @param {T} element The element to insert + */ + public push(element: T): void { + this.heap.push(element); + this.siftUp(this.heap.length - 1); + } + + /** + * Removes and returns the root element (min or max depending on comparator) + * @returns {T | undefined} The root element, or `undefined` if the heap is empty + */ + public pop(): T | undefined { + if (this.heap.length === 0) return undefined; + + const root = first(this.heap)!; + const last = this.heap.pop()!; + + if (this.heap.length > 0) { + this.heap[0] = last; + this.siftDown(0); + } + + return root; + } + + /** + * Returns the root element without removing it + * @returns {T | undefined} The root element, or `undefined` if the heap is empty + */ + public peek(): T | undefined { + return first(this.heap); + } + + /** + * Removes all elements from the heap + * @returns {this} The heap instance for chaining + */ + public clear(): this { + this.heap.length = 0; + return this; + } + + /** + * Returns a shallow copy of the heap elements as an array (heap order, not sorted) + * @returns {T[]} Array of elements in heap order + */ + public toArray(): T[] { + return this.heap.slice(); + } + + /** + * Returns a string representation of the heap + * @returns {string} String representation + */ + public toString(): string { + return `BinaryHeap(${this.heap.length})`; + } + + /** + * Iterator over heap elements in heap order + */ + public *[Symbol.iterator](): Iterator { + yield* this.heap; + } + + /** + * Async iterator over heap elements in heap order + */ + public async *[Symbol.asyncIterator](): AsyncIterator { + for (const element of this.heap) + yield element; + } + + /** + * Restores heap property by sifting an element up + * + * @private + * @param {number} index The index of the element to sift up + */ + private siftUp(index: number): void { + const heap = this.heap; + const cmp = this.comparator; + + while (index > 0) { + const parent = (index - 1) >> 1; + + if (cmp(heap[index]!, heap[parent]!) >= 0) break; + + const temp = heap[index]!; + heap[index] = heap[parent]!; + heap[parent] = temp; + + index = parent; + } + } + + /** + * Restores heap property by sifting an element down + * + * @private + * @param {number} index The index of the element to sift down + */ + private siftDown(index: number): void { + const heap = this.heap; + const cmp = this.comparator; + const length = heap.length; + + while (true) { + let smallest = index; + const left = 2 * index + 1; + const right = 2 * index + 2; + + if (left < length && cmp(heap[left]!, heap[smallest]!) < 0) { + smallest = left; + } + + if (right < length && cmp(heap[right]!, heap[smallest]!) < 0) { + smallest = right; + } + + if (smallest === index) break; + + const temp = heap[index]!; + heap[index] = heap[smallest]!; + heap[smallest] = temp; + + index = smallest; + } + } + + /** + * Builds heap from unordered array in O(n) using Floyd's algorithm + * + * @private + */ + private heapify(): void { + for (let i = (this.heap.length >> 1) - 1; i >= 0; i--) { + this.siftDown(i); + } + } +} diff --git a/core/stdlib/src/structs/BinaryHeap/types.ts b/core/stdlib/src/structs/BinaryHeap/types.ts new file mode 100644 index 0000000..ae09b7b --- /dev/null +++ b/core/stdlib/src/structs/BinaryHeap/types.ts @@ -0,0 +1,13 @@ +export type Comparator = (a: T, b: T) => number; + +export interface BinaryHeapLike extends Iterable, AsyncIterable { + readonly length: number; + readonly isEmpty: boolean; + + push(element: T): void; + pop(): T | undefined; + peek(): T | undefined; + clear(): this; + toArray(): T[]; + toString(): string; +} diff --git a/core/stdlib/src/structs/CircularBuffer/index.test.ts b/core/stdlib/src/structs/CircularBuffer/index.test.ts new file mode 100644 index 0000000..e34a5b8 --- /dev/null +++ b/core/stdlib/src/structs/CircularBuffer/index.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from 'vitest'; +import { CircularBuffer } from '.'; + +describe('circularBuffer', () => { + describe('constructor', () => { + it('create an empty buffer', () => { + const buf = new CircularBuffer(); + + expect(buf.length).toBe(0); + expect(buf.isEmpty).toBe(true); + expect(buf.capacity).toBeGreaterThanOrEqual(4); + }); + + it('create a buffer with initial array', () => { + const buf = new CircularBuffer([1, 2, 3]); + + expect(buf.length).toBe(3); + expect(buf.peekFront()).toBe(1); + expect(buf.peekBack()).toBe(3); + }); + + it('create a buffer with a single value', () => { + const buf = new CircularBuffer(42); + + expect(buf.length).toBe(1); + expect(buf.peekFront()).toBe(42); + }); + + it('create a buffer with initial capacity hint', () => { + const buf = new CircularBuffer(undefined, 32); + + expect(buf.capacity).toBe(32); + }); + + it('round capacity up to next power of two', () => { + const buf = new CircularBuffer(undefined, 5); + + expect(buf.capacity).toBe(8); + }); + }); + + describe('pushBack / popFront', () => { + it('FIFO order', () => { + const buf = new CircularBuffer(); + buf.pushBack(1); + buf.pushBack(2); + buf.pushBack(3); + + expect(buf.popFront()).toBe(1); + expect(buf.popFront()).toBe(2); + expect(buf.popFront()).toBe(3); + }); + }); + + describe('pushFront / popBack', () => { + it('LIFO order', () => { + const buf = new CircularBuffer(); + buf.pushFront(1); + buf.pushFront(2); + buf.pushFront(3); + + expect(buf.popBack()).toBe(1); + expect(buf.popBack()).toBe(2); + expect(buf.popBack()).toBe(3); + }); + }); + + describe('popFront', () => { + it('return undefined if empty', () => { + const buf = new CircularBuffer(); + + expect(buf.popFront()).toBeUndefined(); + }); + }); + + describe('popBack', () => { + it('return undefined if empty', () => { + const buf = new CircularBuffer(); + + expect(buf.popBack()).toBeUndefined(); + }); + }); + + describe('peekFront / peekBack', () => { + it('return elements without removing', () => { + const buf = new CircularBuffer([1, 2, 3]); + + expect(buf.peekFront()).toBe(1); + expect(buf.peekBack()).toBe(3); + expect(buf.length).toBe(3); + }); + + it('return undefined if empty', () => { + const buf = new CircularBuffer(); + + expect(buf.peekFront()).toBeUndefined(); + expect(buf.peekBack()).toBeUndefined(); + }); + }); + + describe('get', () => { + it('access element by logical index', () => { + const buf = new CircularBuffer([10, 20, 30]); + + expect(buf.get(0)).toBe(10); + expect(buf.get(1)).toBe(20); + expect(buf.get(2)).toBe(30); + }); + + it('return undefined for out-of-bounds', () => { + const buf = new CircularBuffer([1, 2]); + + expect(buf.get(-1)).toBeUndefined(); + expect(buf.get(2)).toBeUndefined(); + }); + + it('work correctly after wrap-around', () => { + const buf = new CircularBuffer(undefined, 4); + + buf.pushBack(1); + buf.pushBack(2); + buf.pushBack(3); + buf.pushBack(4); + buf.popFront(); + buf.popFront(); + buf.pushBack(5); + buf.pushBack(6); + + expect(buf.get(0)).toBe(3); + expect(buf.get(1)).toBe(4); + expect(buf.get(2)).toBe(5); + expect(buf.get(3)).toBe(6); + }); + }); + + describe('clear', () => { + it('clear the buffer', () => { + const buf = new CircularBuffer([1, 2, 3]); + buf.clear(); + + expect(buf.length).toBe(0); + expect(buf.isEmpty).toBe(true); + }); + + it('return this for chaining', () => { + const buf = new CircularBuffer([1]); + + expect(buf.clear()).toBe(buf); + }); + }); + + describe('auto-grow', () => { + it('grow when capacity is exceeded', () => { + const buf = new CircularBuffer(); + const initialCapacity = buf.capacity; + + for (let i = 0; i < initialCapacity + 1; i++) + buf.pushBack(i); + + expect(buf.length).toBe(initialCapacity + 1); + expect(buf.capacity).toBe(initialCapacity * 2); + }); + + it('preserve order after grow', () => { + const buf = new CircularBuffer(undefined, 4); + + buf.pushBack(1); + buf.pushBack(2); + buf.popFront(); + buf.pushBack(3); + buf.pushBack(4); + buf.pushBack(5); + buf.pushBack(6); + + expect(buf.toArray()).toEqual([2, 3, 4, 5, 6]); + }); + }); + + describe('wrap-around', () => { + it('handle wrap-around correctly', () => { + const buf = new CircularBuffer(undefined, 4); + + buf.pushBack(1); + buf.pushBack(2); + buf.pushBack(3); + buf.pushBack(4); + buf.popFront(); + buf.popFront(); + buf.pushBack(5); + buf.pushBack(6); + + expect(buf.toArray()).toEqual([3, 4, 5, 6]); + }); + + it('handle alternating front/back', () => { + const buf = new CircularBuffer(); + + buf.pushFront(3); + buf.pushBack(4); + buf.pushFront(2); + buf.pushBack(5); + buf.pushFront(1); + + expect(buf.toArray()).toEqual([1, 2, 3, 4, 5]); + }); + }); + + describe('toArray', () => { + it('return elements front to back', () => { + const buf = new CircularBuffer([1, 2, 3]); + + expect(buf.toArray()).toEqual([1, 2, 3]); + }); + + it('return empty array if empty', () => { + const buf = new CircularBuffer(); + + expect(buf.toArray()).toEqual([]); + }); + }); + + describe('toString', () => { + it('return comma-separated string', () => { + const buf = new CircularBuffer([1, 2, 3]); + + expect(buf.toString()).toBe('1,2,3'); + }); + }); + + describe('iteration', () => { + it('iterate front to back', () => { + const buf = new CircularBuffer([1, 2, 3]); + + expect([...buf]).toEqual([1, 2, 3]); + }); + + it('iterate asynchronously', async () => { + const buf = new CircularBuffer([1, 2, 3]); + const elements: number[] = []; + + for await (const element of buf) + elements.push(element); + + expect(elements).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/core/stdlib/src/structs/CircularBuffer/index.ts b/core/stdlib/src/structs/CircularBuffer/index.ts new file mode 100644 index 0000000..856c064 --- /dev/null +++ b/core/stdlib/src/structs/CircularBuffer/index.ts @@ -0,0 +1,277 @@ +import { isArray } from '../../types'; +import type { CircularBufferLike } from './types'; + +export type { CircularBufferLike } from './types'; + +const MIN_CAPACITY = 4; + +/** + * @name CircularBuffer + * @category Data Structures + * @description A circular (ring) buffer with automatic growth, O(1) push/pop on both ends + * + * @since 0.0.8 + * + * @template T The type of elements stored in the buffer + */ +export class CircularBuffer implements CircularBufferLike { + /** + * The internal storage + * + * @private + * @type {(T | undefined)[]} + */ + private buffer: (T | undefined)[]; + + /** + * The index of the front element + * + * @private + * @type {number} + */ + private head: number; + + /** + * The number of elements in the buffer + * + * @private + * @type {number} + */ + private count: number; + + /** + * Creates an instance of CircularBuffer + * + * @param {(T[] | T)} [initialValues] The initial values to add to the buffer + * @param {number} [initialCapacity] The initial capacity hint (rounded up to next power of two) + */ + constructor(initialValues?: T[] | T, initialCapacity?: number) { + this.head = 0; + this.count = 0; + + const items = isArray(initialValues) ? initialValues : initialValues !== undefined ? [initialValues] : []; + const requested = Math.max(items.length, initialCapacity ?? 0); + const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested)); + + this.buffer = new Array(cap); + + for (const item of items) + this.pushBack(item); + } + + /** + * Gets the number of elements in the buffer + * @returns {number} + */ + get length() { + return this.count; + } + + /** + * Gets the current capacity of the buffer + * @returns {number} + */ + get capacity() { + return this.buffer.length; + } + + /** + * Checks if the buffer is empty + * @returns {boolean} + */ + get isEmpty() { + return this.count === 0; + } + + /** + * Checks if the buffer is at capacity (before auto-grow) + * @returns {boolean} + */ + get isFull() { + return this.count === this.buffer.length; + } + + /** + * Adds an element to the back of the buffer + * @param {T} element The element to add + */ + pushBack(element: T) { + if (this.count === this.buffer.length) + this.grow(); + + this.buffer[(this.head + this.count) & (this.buffer.length - 1)] = element; + this.count++; + } + + /** + * Adds an element to the front of the buffer + * @param {T} element The element to add + */ + pushFront(element: T) { + if (this.count === this.buffer.length) + this.grow(); + + this.head = (this.head - 1 + this.buffer.length) & (this.buffer.length - 1); + this.buffer[this.head] = element; + this.count++; + } + + /** + * Removes and returns the back element + * @returns {T | undefined} The back element, or undefined if empty + */ + popBack() { + if (this.isEmpty) + return undefined; + + const index = (this.head + this.count - 1) & (this.buffer.length - 1); + const element = this.buffer[index]; + + this.buffer[index] = undefined; + this.count--; + + return element; + } + + /** + * Removes and returns the front element + * @returns {T | undefined} The front element, or undefined if empty + */ + popFront() { + if (this.isEmpty) + return undefined; + + const element = this.buffer[this.head]; + + this.buffer[this.head] = undefined; + this.head = (this.head + 1) & (this.buffer.length - 1); + this.count--; + + return element; + } + + /** + * Returns the back element without removing it + * @returns {T | undefined} + */ + peekBack() { + if (this.isEmpty) + return undefined; + + return this.buffer[(this.head + this.count - 1) & (this.buffer.length - 1)]; + } + + /** + * Returns the front element without removing it + * @returns {T | undefined} + */ + peekFront() { + if (this.isEmpty) + return undefined; + + return this.buffer[this.head]; + } + + /** + * Gets element at logical index (0 = front) + * @param {number} index The logical index + * @returns {T | undefined} + */ + get(index: number) { + if (index < 0 || index >= this.count) + return undefined; + + return this.buffer[(this.head + index) & (this.buffer.length - 1)]; + } + + /** + * Clears the buffer + * + * @returns {this} + */ + clear() { + this.buffer = new Array(MIN_CAPACITY); + this.head = 0; + this.count = 0; + + return this; + } + + /** + * Converts the buffer to an array from front to back + * + * @returns {T[]} + */ + toArray() { + const result = new Array(this.count); + + for (let i = 0; i < this.count; i++) + result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T; + + return result; + } + + /** + * Returns a string representation + * + * @returns {string} + */ + toString() { + return this.toArray().toString(); + } + + /** + * Returns an iterator (front to back) + * + * @returns {IterableIterator} + */ + [Symbol.iterator]() { + return this.toArray()[Symbol.iterator](); + } + + /** + * Returns an async iterator (front to back) + * + * @returns {AsyncIterableIterator} + */ + async *[Symbol.asyncIterator]() { + for (const element of this) + yield element; + } + + /** + * Doubles the buffer capacity and linearizes elements + * + * @private + */ + private grow() { + const newCapacity = this.buffer.length << 1; + const newBuffer = new Array(newCapacity); + + for (let i = 0; i < this.count; i++) + newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)]; + + this.buffer = newBuffer; + this.head = 0; + } +} + +/** + * Returns the next power of two >= n + * + * @param {number} n + * @returns {number} + */ +function nextPowerOfTwo(n: number): number { + if (n <= 0) + return 1; + + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + + return n + 1; +} diff --git a/core/stdlib/src/structs/CircularBuffer/types.ts b/core/stdlib/src/structs/CircularBuffer/types.ts new file mode 100644 index 0000000..5d51c28 --- /dev/null +++ b/core/stdlib/src/structs/CircularBuffer/types.ts @@ -0,0 +1,17 @@ +export interface CircularBufferLike extends Iterable, AsyncIterable { + readonly length: number; + readonly capacity: number; + readonly isEmpty: boolean; + readonly isFull: boolean; + + pushBack(element: T): void; + pushFront(element: T): void; + popBack(): T | undefined; + popFront(): T | undefined; + peekBack(): T | undefined; + peekFront(): T | undefined; + get(index: number): T | undefined; + clear(): this; + toArray(): T[]; + toString(): string; +} diff --git a/core/stdlib/src/structs/Deque/index.test.ts b/core/stdlib/src/structs/Deque/index.test.ts new file mode 100644 index 0000000..cb71006 --- /dev/null +++ b/core/stdlib/src/structs/Deque/index.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect } from 'vitest'; +import { Deque } from '.'; + +describe('deque', () => { + describe('constructor', () => { + it('create an empty deque if no initial values are provided', () => { + const deque = new Deque(); + + expect(deque.length).toBe(0); + expect(deque.isEmpty).toBe(true); + }); + + it('create a deque with the provided initial values', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.length).toBe(3); + expect(deque.peekFront()).toBe(1); + expect(deque.peekBack()).toBe(3); + }); + + it('create a deque with a single initial value', () => { + const deque = new Deque(42); + + expect(deque.length).toBe(1); + expect(deque.peekFront()).toBe(42); + }); + + it('create a deque with the provided options', () => { + const deque = new Deque(undefined, { maxSize: 5 }); + + expect(deque.length).toBe(0); + expect(deque.isFull).toBe(false); + }); + }); + + describe('pushBack', () => { + it('add an element to the back', () => { + const deque = new Deque(); + deque.pushBack(1).pushBack(2); + + expect(deque.peekFront()).toBe(1); + expect(deque.peekBack()).toBe(2); + expect(deque.length).toBe(2); + }); + + it('throw an error if the deque is full', () => { + const deque = new Deque(undefined, { maxSize: 1 }); + deque.pushBack(1); + + expect(() => deque.pushBack(2)).toThrow(new RangeError('Deque is full')); + }); + + it('return this for chaining', () => { + const deque = new Deque(); + + expect(deque.pushBack(1)).toBe(deque); + }); + }); + + describe('pushFront', () => { + it('add an element to the front', () => { + const deque = new Deque(); + deque.pushFront(1).pushFront(2); + + expect(deque.peekFront()).toBe(2); + expect(deque.peekBack()).toBe(1); + expect(deque.length).toBe(2); + }); + + it('throw an error if the deque is full', () => { + const deque = new Deque(undefined, { maxSize: 1 }); + deque.pushFront(1); + + expect(() => deque.pushFront(2)).toThrow(new RangeError('Deque is full')); + }); + + it('return this for chaining', () => { + const deque = new Deque(); + + expect(deque.pushFront(1)).toBe(deque); + }); + }); + + describe('popBack', () => { + it('remove and return the back element', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.popBack()).toBe(3); + expect(deque.length).toBe(2); + }); + + it('return undefined if the deque is empty', () => { + const deque = new Deque(); + + expect(deque.popBack()).toBeUndefined(); + }); + }); + + describe('popFront', () => { + it('remove and return the front element', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.popFront()).toBe(1); + expect(deque.length).toBe(2); + }); + + it('return undefined if the deque is empty', () => { + const deque = new Deque(); + + expect(deque.popFront()).toBeUndefined(); + }); + }); + + describe('peekBack', () => { + it('return the back element without removing it', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.peekBack()).toBe(3); + expect(deque.length).toBe(3); + }); + + it('return undefined if the deque is empty', () => { + const deque = new Deque(); + + expect(deque.peekBack()).toBeUndefined(); + }); + }); + + describe('peekFront', () => { + it('return the front element without removing it', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.peekFront()).toBe(1); + expect(deque.length).toBe(3); + }); + + it('return undefined if the deque is empty', () => { + const deque = new Deque(); + + expect(deque.peekFront()).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('clear the deque', () => { + const deque = new Deque([1, 2, 3]); + deque.clear(); + + expect(deque.length).toBe(0); + expect(deque.isEmpty).toBe(true); + }); + + it('return this for chaining', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.clear()).toBe(deque); + }); + }); + + describe('toArray', () => { + it('return elements from front to back', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.toArray()).toEqual([1, 2, 3]); + }); + + it('return correct order after mixed operations', () => { + const deque = new Deque(); + deque.pushBack(2); + deque.pushBack(3); + deque.pushFront(1); + deque.pushFront(0); + + expect(deque.toArray()).toEqual([0, 1, 2, 3]); + }); + }); + + describe('toString', () => { + it('return comma-separated string', () => { + const deque = new Deque([1, 2, 3]); + + expect(deque.toString()).toBe('1,2,3'); + }); + }); + + describe('iteration', () => { + it('iterate in front-to-back order', () => { + const deque = new Deque([1, 2, 3]); + + expect([...deque]).toEqual([1, 2, 3]); + }); + + it('iterate asynchronously', async () => { + const deque = new Deque([1, 2, 3]); + const elements: number[] = []; + + for await (const element of deque) + elements.push(element); + + expect(elements).toEqual([1, 2, 3]); + }); + }); + + describe('circular buffer behavior', () => { + it('handle wrap-around correctly', () => { + const deque = new Deque(); + + for (let i = 0; i < 4; i++) + deque.pushBack(i); + + deque.popFront(); + deque.popFront(); + deque.pushBack(4); + deque.pushBack(5); + + expect(deque.toArray()).toEqual([2, 3, 4, 5]); + }); + + it('grow the buffer when needed', () => { + const deque = new Deque(); + + for (let i = 0; i < 100; i++) + deque.pushBack(i); + + expect(deque.length).toBe(100); + expect(deque.peekFront()).toBe(0); + expect(deque.peekBack()).toBe(99); + }); + + it('handle alternating front/back operations', () => { + const deque = new Deque(); + + deque.pushFront(3); + deque.pushBack(4); + deque.pushFront(2); + deque.pushBack(5); + deque.pushFront(1); + + expect(deque.toArray()).toEqual([1, 2, 3, 4, 5]); + + expect(deque.popFront()).toBe(1); + expect(deque.popBack()).toBe(5); + expect(deque.toArray()).toEqual([2, 3, 4]); + }); + }); + + describe('mixed operations', () => { + it('use as a stack (LIFO)', () => { + const deque = new Deque(); + deque.pushBack(1).pushBack(2).pushBack(3); + + expect(deque.popBack()).toBe(3); + expect(deque.popBack()).toBe(2); + expect(deque.popBack()).toBe(1); + }); + + it('use as a queue (FIFO)', () => { + const deque = new Deque(); + deque.pushBack(1).pushBack(2).pushBack(3); + + expect(deque.popFront()).toBe(1); + expect(deque.popFront()).toBe(2); + expect(deque.popFront()).toBe(3); + }); + + it('reuse deque after clear', () => { + const deque = new Deque([1, 2, 3]); + deque.clear(); + deque.pushBack(4); + + expect(deque.length).toBe(1); + expect(deque.peekFront()).toBe(4); + }); + + it('maxSize limits capacity', () => { + const deque = new Deque(undefined, { maxSize: 3 }); + deque.pushBack(1).pushBack(2).pushBack(3); + + expect(deque.isFull).toBe(true); + expect(() => deque.pushFront(0)).toThrow(new RangeError('Deque is full')); + + deque.popFront(); + deque.pushFront(0); + + expect(deque.toArray()).toEqual([0, 2, 3]); + }); + }); +}); diff --git a/core/stdlib/src/structs/Deque/index.ts b/core/stdlib/src/structs/Deque/index.ts new file mode 100644 index 0000000..05847bb --- /dev/null +++ b/core/stdlib/src/structs/Deque/index.ts @@ -0,0 +1,180 @@ +import { CircularBuffer } from '../CircularBuffer'; +import type { DequeLike } from './types'; + +export type { DequeLike } from './types'; + +export interface DequeOptions { + maxSize?: number; +} + +/** + * @name Deque + * @category Data Structures + * @description Represents a double-ended queue backed by a circular buffer + * + * @since 0.0.8 + * + * @template T The type of elements stored in the deque + */ +export class Deque implements DequeLike { + /** + * The maximum number of elements that the deque can hold + * + * @private + * @type {number} + */ + private readonly maxSize: number; + + /** + * The underlying circular buffer + * + * @private + * @type {CircularBuffer} + */ + private readonly buffer: CircularBuffer; + + /** + * Creates an instance of Deque + * + * @param {(T[] | T)} [initialValues] The initial values to add to the deque + * @param {DequeOptions} [options] The options for the deque + */ + constructor(initialValues?: T[] | T, options?: DequeOptions) { + this.maxSize = options?.maxSize ?? Infinity; + this.buffer = new CircularBuffer(initialValues); + } + + /** + * Gets the number of elements in the deque + * @returns {number} The number of elements in the deque + */ + get length() { + return this.buffer.length; + } + + /** + * Checks if the deque is empty + * @returns {boolean} `true` if the deque is empty, `false` otherwise + */ + get isEmpty() { + return this.buffer.isEmpty; + } + + /** + * Checks if the deque is full + * @returns {boolean} `true` if the deque is full, `false` otherwise + */ + get isFull() { + return this.buffer.length === this.maxSize; + } + + /** + * Adds an element to the back of the deque + * @param {T} element The element to add + * @returns {this} + * @throws {RangeError} If the deque is full + */ + pushBack(element: T) { + if (this.isFull) + throw new RangeError('Deque is full'); + + this.buffer.pushBack(element); + + return this; + } + + /** + * Adds an element to the front of the deque + * @param {T} element The element to add + * @returns {this} + * @throws {RangeError} If the deque is full + */ + pushFront(element: T) { + if (this.isFull) + throw new RangeError('Deque is full'); + + this.buffer.pushFront(element); + + return this; + } + + /** + * Removes and returns the back element of the deque + * @returns {T | undefined} The back element, or undefined if empty + */ + popBack() { + return this.buffer.popBack(); + } + + /** + * Removes and returns the front element of the deque + * @returns {T | undefined} The front element, or undefined if empty + */ + popFront() { + return this.buffer.popFront(); + } + + /** + * Returns the back element without removing it + * @returns {T | undefined} The back element, or undefined if empty + */ + peekBack() { + return this.buffer.peekBack(); + } + + /** + * Returns the front element without removing it + * @returns {T | undefined} The front element, or undefined if empty + */ + peekFront() { + return this.buffer.peekFront(); + } + + /** + * Clears the deque + * + * @returns {this} + */ + clear() { + this.buffer.clear(); + + return this; + } + + /** + * Converts the deque to an array from front to back + * + * @returns {T[]} + */ + toArray() { + return this.buffer.toArray(); + } + + /** + * Returns a string representation of the deque + * + * @returns {string} + */ + toString() { + return this.buffer.toString(); + } + + /** + * Returns an iterator for the deque (front to back) + * + * @returns {IterableIterator} + */ + [Symbol.iterator]() { + return this.buffer[Symbol.iterator](); + } + + /** + * Returns an async iterator for the deque (front to back) + * + * @returns {AsyncIterableIterator} + */ + async *[Symbol.asyncIterator]() { + for (const element of this.buffer) + yield element; + } +} diff --git a/core/stdlib/src/structs/Deque/types.ts b/core/stdlib/src/structs/Deque/types.ts new file mode 100644 index 0000000..400df45 --- /dev/null +++ b/core/stdlib/src/structs/Deque/types.ts @@ -0,0 +1,15 @@ +export interface DequeLike extends Iterable, AsyncIterable { + readonly length: number; + readonly isEmpty: boolean; + readonly isFull: boolean; + + pushBack(element: T): this; + pushFront(element: T): this; + popBack(): T | undefined; + popFront(): T | undefined; + peekBack(): T | undefined; + peekFront(): T | undefined; + clear(): this; + toArray(): T[]; + toString(): string; +} diff --git a/core/stdlib/src/structs/LinkedList/index.test.ts b/core/stdlib/src/structs/LinkedList/index.test.ts new file mode 100644 index 0000000..a8ef9ed --- /dev/null +++ b/core/stdlib/src/structs/LinkedList/index.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it } from 'vitest'; + +import { LinkedList } from '.'; + +describe('LinkedList', () => { + describe('constructor', () => { + it('should create an empty list', () => { + const list = new LinkedList(); + + expect(list.length).toBe(0); + expect(list.isEmpty).toBe(true); + expect(list.head).toBeUndefined(); + expect(list.tail).toBeUndefined(); + }); + + it('should create a list from single value', () => { + const list = new LinkedList(42); + + expect(list.length).toBe(1); + expect(list.peekFront()).toBe(42); + expect(list.peekBack()).toBe(42); + }); + + it('should create a list from array', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.length).toBe(3); + expect(list.peekFront()).toBe(1); + expect(list.peekBack()).toBe(3); + }); + }); + + describe('pushBack', () => { + it('should append to empty list', () => { + const list = new LinkedList(); + + const node = list.pushBack(1); + + expect(list.length).toBe(1); + expect(node.value).toBe(1); + expect(list.head).toBe(node); + expect(list.tail).toBe(node); + }); + + it('should append to non-empty list', () => { + const list = new LinkedList([1, 2]); + + list.pushBack(3); + + expect(list.length).toBe(3); + expect(list.peekBack()).toBe(3); + expect(list.peekFront()).toBe(1); + }); + + it('should return the created node', () => { + const list = new LinkedList(); + + const node = list.pushBack(5); + + expect(node.value).toBe(5); + expect(node.prev).toBeUndefined(); + expect(node.next).toBeUndefined(); + }); + }); + + describe('pushFront', () => { + it('should prepend to empty list', () => { + const list = new LinkedList(); + + const node = list.pushFront(1); + + expect(list.length).toBe(1); + expect(list.head).toBe(node); + expect(list.tail).toBe(node); + }); + + it('should prepend to non-empty list', () => { + const list = new LinkedList([2, 3]); + + list.pushFront(1); + + expect(list.length).toBe(3); + expect(list.peekFront()).toBe(1); + expect(list.peekBack()).toBe(3); + }); + }); + + describe('popBack', () => { + it('should return undefined for empty list', () => { + const list = new LinkedList(); + + expect(list.popBack()).toBeUndefined(); + }); + + it('should remove and return last value', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.popBack()).toBe(3); + expect(list.length).toBe(2); + expect(list.peekBack()).toBe(2); + }); + + it('should handle single element', () => { + const list = new LinkedList(1); + + expect(list.popBack()).toBe(1); + expect(list.isEmpty).toBe(true); + expect(list.head).toBeUndefined(); + expect(list.tail).toBeUndefined(); + }); + }); + + describe('popFront', () => { + it('should return undefined for empty list', () => { + const list = new LinkedList(); + + expect(list.popFront()).toBeUndefined(); + }); + + it('should remove and return first value', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.popFront()).toBe(1); + expect(list.length).toBe(2); + expect(list.peekFront()).toBe(2); + }); + + it('should handle single element', () => { + const list = new LinkedList(1); + + expect(list.popFront()).toBe(1); + expect(list.isEmpty).toBe(true); + expect(list.head).toBeUndefined(); + expect(list.tail).toBeUndefined(); + }); + }); + + describe('peekBack', () => { + it('should return undefined for empty list', () => { + const list = new LinkedList(); + + expect(list.peekBack()).toBeUndefined(); + }); + + it('should return last value without removing', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.peekBack()).toBe(3); + expect(list.length).toBe(3); + }); + }); + + describe('peekFront', () => { + it('should return undefined for empty list', () => { + const list = new LinkedList(); + + expect(list.peekFront()).toBeUndefined(); + }); + + it('should return first value without removing', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.peekFront()).toBe(1); + expect(list.length).toBe(3); + }); + }); + + describe('insertBefore', () => { + it('should insert before head', () => { + const list = new LinkedList(); + const node = list.pushBack(2); + + list.insertBefore(node, 1); + + expect(list.peekFront()).toBe(1); + expect(list.peekBack()).toBe(2); + expect(list.length).toBe(2); + }); + + it('should insert before middle node', () => { + const list = new LinkedList([1, 3]); + const tail = list.tail!; + + list.insertBefore(tail, 2); + + expect(list.toArray()).toEqual([1, 2, 3]); + }); + + it('should return the created node', () => { + const list = new LinkedList(); + const existing = list.pushBack(2); + + const newNode = list.insertBefore(existing, 1); + + expect(newNode.value).toBe(1); + expect(newNode.next).toBe(existing); + }); + }); + + describe('insertAfter', () => { + it('should insert after tail', () => { + const list = new LinkedList(); + const node = list.pushBack(1); + + list.insertAfter(node, 2); + + expect(list.peekFront()).toBe(1); + expect(list.peekBack()).toBe(2); + expect(list.length).toBe(2); + }); + + it('should insert after middle node', () => { + const list = new LinkedList([1, 3]); + const head = list.head!; + + list.insertAfter(head, 2); + + expect(list.toArray()).toEqual([1, 2, 3]); + }); + + it('should return the created node', () => { + const list = new LinkedList(); + const existing = list.pushBack(1); + + const newNode = list.insertAfter(existing, 2); + + expect(newNode.value).toBe(2); + expect(newNode.prev).toBe(existing); + }); + }); + + describe('remove', () => { + it('should remove head node', () => { + const list = new LinkedList([1, 2, 3]); + const head = list.head!; + + const value = list.remove(head); + + expect(value).toBe(1); + expect(list.length).toBe(2); + expect(list.peekFront()).toBe(2); + }); + + it('should remove tail node', () => { + const list = new LinkedList([1, 2, 3]); + const tail = list.tail!; + + const value = list.remove(tail); + + expect(value).toBe(3); + expect(list.length).toBe(2); + expect(list.peekBack()).toBe(2); + }); + + it('should remove middle node', () => { + const list = new LinkedList([1, 2, 3]); + const middle = list.head!.next!; + + const value = list.remove(middle); + + expect(value).toBe(2); + expect(list.toArray()).toEqual([1, 3]); + }); + + it('should remove single element', () => { + const list = new LinkedList(); + const node = list.pushBack(1); + + list.remove(node); + + expect(list.isEmpty).toBe(true); + expect(list.head).toBeUndefined(); + expect(list.tail).toBeUndefined(); + }); + + it('should detach the removed node', () => { + const list = new LinkedList([1, 2, 3]); + const middle = list.head!.next!; + + list.remove(middle); + + expect(middle.prev).toBeUndefined(); + expect(middle.next).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('should remove all elements', () => { + const list = new LinkedList([1, 2, 3]); + + const result = list.clear(); + + expect(list.length).toBe(0); + expect(list.isEmpty).toBe(true); + expect(list.head).toBeUndefined(); + expect(list.tail).toBeUndefined(); + expect(result).toBe(list); + }); + }); + + describe('toArray', () => { + it('should return empty array for empty list', () => { + const list = new LinkedList(); + + expect(list.toArray()).toEqual([]); + }); + + it('should return values from head to tail', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.toArray()).toEqual([1, 2, 3]); + }); + }); + + describe('toString', () => { + it('should return comma-separated values', () => { + const list = new LinkedList([1, 2, 3]); + + expect(list.toString()).toBe('1,2,3'); + }); + }); + + describe('iterator', () => { + it('should iterate from head to tail', () => { + const list = new LinkedList([1, 2, 3]); + + expect([...list]).toEqual([1, 2, 3]); + }); + + it('should yield nothing for empty list', () => { + const list = new LinkedList(); + + expect([...list]).toEqual([]); + }); + }); + + describe('async iterator', () => { + it('should async iterate from head to tail', async () => { + const list = new LinkedList([1, 2, 3]); + const result: number[] = []; + + for await (const value of list) + result.push(value); + + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('node linking', () => { + it('should maintain correct prev/next references', () => { + const list = new LinkedList(); + const a = list.pushBack(1); + const b = list.pushBack(2); + const c = list.pushBack(3); + + expect(a.next).toBe(b); + expect(b.prev).toBe(a); + expect(b.next).toBe(c); + expect(c.prev).toBe(b); + expect(a.prev).toBeUndefined(); + expect(c.next).toBeUndefined(); + }); + + it('should update links after removal', () => { + const list = new LinkedList(); + const a = list.pushBack(1); + const b = list.pushBack(2); + const c = list.pushBack(3); + + list.remove(b); + + expect(a.next).toBe(c); + expect(c.prev).toBe(a); + }); + }); + + describe('interleaved operations', () => { + it('should handle mixed push/pop from both ends', () => { + const list = new LinkedList(); + + list.pushBack(1); + list.pushBack(2); + list.pushFront(0); + + expect(list.popFront()).toBe(0); + expect(list.popBack()).toBe(2); + expect(list.popFront()).toBe(1); + expect(list.isEmpty).toBe(true); + }); + + it('should handle insert and remove by node reference', () => { + const list = new LinkedList(); + const a = list.pushBack(1); + const c = list.pushBack(3); + const b = list.insertAfter(a, 2); + const d = list.insertBefore(c, 2.5); + + expect(list.toArray()).toEqual([1, 2, 2.5, 3]); + + list.remove(b); + list.remove(d); + + expect(list.toArray()).toEqual([1, 3]); + }); + }); +}); diff --git a/core/stdlib/src/structs/LinkedList/index.ts b/core/stdlib/src/structs/LinkedList/index.ts new file mode 100644 index 0000000..4c8d88c --- /dev/null +++ b/core/stdlib/src/structs/LinkedList/index.ts @@ -0,0 +1,324 @@ +import { isArray } from '../../types'; +import type { LinkedListLike, LinkedListNode } from './types'; + +export type { LinkedListLike, LinkedListNode } from './types'; + +/** + * Creates a new doubly linked list node + * + * @template T The type of the value + * @param {T} value The value to store + * @returns {LinkedListNode} The created node + */ +function createNode(value: T): LinkedListNode { + return { value, prev: undefined, next: undefined }; +} + +/** + * @name LinkedList + * @category Data Structures + * @description Doubly linked list with O(1) push/pop on both ends and O(1) insert/remove by node reference + * + * @since 0.0.8 + * + * @template T The type of elements stored in the list + */ +export class LinkedList implements LinkedListLike { + /** + * The number of elements in the list + * + * @private + * @type {number} + */ + private count: number = 0; + + /** + * The first node in the list + * + * @private + * @type {LinkedListNode | undefined} + */ + private first: LinkedListNode | undefined; + + /** + * The last node in the list + * + * @private + * @type {LinkedListNode | undefined} + */ + private last: LinkedListNode | undefined; + + /** + * Creates an instance of LinkedList + * + * @param {(T[] | T)} [initialValues] The initial values to add to the list + */ + constructor(initialValues?: T[] | T) { + if (initialValues != null) { + const items = isArray(initialValues) ? initialValues : [initialValues]; + + for (const item of items) + this.pushBack(item); + } + } + + /** + * Gets the number of elements in the list + * @returns {number} The number of elements in the list + */ + public get length(): number { + return this.count; + } + + /** + * Checks if the list is empty + * @returns {boolean} `true` if the list is empty, `false` otherwise + */ + public get isEmpty(): boolean { + return this.count === 0; + } + + /** + * Gets the first node + * @returns {LinkedListNode | undefined} The first node, or `undefined` if the list is empty + */ + public get head(): LinkedListNode | undefined { + return this.first; + } + + /** + * Gets the last node + * @returns {LinkedListNode | undefined} The last node, or `undefined` if the list is empty + */ + public get tail(): LinkedListNode | undefined { + return this.last; + } + + /** + * Appends a value to the end of the list + * @param {T} value The value to append + * @returns {LinkedListNode} The created node + */ + public pushBack(value: T): LinkedListNode { + const node = createNode(value); + + if (this.last) { + node.prev = this.last; + this.last.next = node; + this.last = node; + } else { + this.first = node; + this.last = node; + } + + this.count++; + + return node; + } + + /** + * Prepends a value to the beginning of the list + * @param {T} value The value to prepend + * @returns {LinkedListNode} The created node + */ + public pushFront(value: T): LinkedListNode { + const node = createNode(value); + + if (this.first) { + node.next = this.first; + this.first.prev = node; + this.first = node; + } else { + this.first = node; + this.last = node; + } + + this.count++; + + return node; + } + + /** + * Removes and returns the last value + * @returns {T | undefined} The last value, or `undefined` if the list is empty + */ + public popBack(): T | undefined { + if (!this.last) return undefined; + + const node = this.last; + + this.detach(node); + + return node.value; + } + + /** + * Removes and returns the first value + * @returns {T | undefined} The first value, or `undefined` if the list is empty + */ + public popFront(): T | undefined { + if (!this.first) return undefined; + + const node = this.first; + + this.detach(node); + + return node.value; + } + + /** + * Returns the last value without removing it + * @returns {T | undefined} The last value, or `undefined` if the list is empty + */ + public peekBack(): T | undefined { + return this.last?.value; + } + + /** + * Returns the first value without removing it + * @returns {T | undefined} The first value, or `undefined` if the list is empty + */ + public peekFront(): T | undefined { + return this.first?.value; + } + + /** + * Inserts a value before the given node + * @param {LinkedListNode} node The reference node + * @param {T} value The value to insert + * @returns {LinkedListNode} The created node + */ + public insertBefore(node: LinkedListNode, value: T): LinkedListNode { + const newNode = createNode(value); + + newNode.next = node; + newNode.prev = node.prev; + + if (node.prev) { + node.prev.next = newNode; + } else { + this.first = newNode; + } + + node.prev = newNode; + this.count++; + + return newNode; + } + + /** + * Inserts a value after the given node + * @param {LinkedListNode} node The reference node + * @param {T} value The value to insert + * @returns {LinkedListNode} The created node + */ + public insertAfter(node: LinkedListNode, value: T): LinkedListNode { + const newNode = createNode(value); + + newNode.prev = node; + newNode.next = node.next; + + if (node.next) { + node.next.prev = newNode; + } else { + this.last = newNode; + } + + node.next = newNode; + this.count++; + + return newNode; + } + + /** + * Removes a node from the list by reference in O(1) + * @param {LinkedListNode} node The node to remove + * @returns {T} The value of the removed node + */ + public remove(node: LinkedListNode): T { + this.detach(node); + + return node.value; + } + + /** + * Removes all elements from the list + * @returns {this} The list instance for chaining + */ + public clear(): this { + this.first = undefined; + this.last = undefined; + this.count = 0; + + return this; + } + + /** + * Returns a shallow copy of the list values as an array + * @returns {T[]} Array of values from head to tail + */ + public toArray(): T[] { + const result = new Array(this.count); + let current = this.first; + let i = 0; + + while (current) { + result[i++] = current.value; + current = current.next; + } + + return result; + } + + /** + * Returns a string representation of the list + * @returns {string} String representation + */ + public toString(): string { + return this.toArray().toString(); + } + + /** + * Iterator over list values from head to tail + */ + public *[Symbol.iterator](): Iterator { + let current = this.first; + + while (current) { + yield current.value; + current = current.next; + } + } + + /** + * Async iterator over list values from head to tail + */ + public async *[Symbol.asyncIterator](): AsyncIterator { + for (const value of this) + yield value; + } + + /** + * Detaches a node from the list, updating head/tail and count + * + * @private + * @param {LinkedListNode} node The node to detach + */ + private detach(node: LinkedListNode): void { + if (node.prev) { + node.prev.next = node.next; + } else { + this.first = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.last = node.prev; + } + + node.prev = undefined; + node.next = undefined; + this.count--; + } +} diff --git a/core/stdlib/src/structs/LinkedList/types.ts b/core/stdlib/src/structs/LinkedList/types.ts new file mode 100644 index 0000000..79e3b1b --- /dev/null +++ b/core/stdlib/src/structs/LinkedList/types.ts @@ -0,0 +1,28 @@ +export interface LinkedListNode { + value: T; + prev: LinkedListNode | undefined; + next: LinkedListNode | undefined; +} + +export interface LinkedListLike extends Iterable, AsyncIterable { + readonly length: number; + readonly isEmpty: boolean; + + readonly head: LinkedListNode | undefined; + readonly tail: LinkedListNode | undefined; + + pushBack(value: T): LinkedListNode; + pushFront(value: T): LinkedListNode; + popBack(): T | undefined; + popFront(): T | undefined; + peekBack(): T | undefined; + peekFront(): T | undefined; + + insertBefore(node: LinkedListNode, value: T): LinkedListNode; + insertAfter(node: LinkedListNode, value: T): LinkedListNode; + remove(node: LinkedListNode): T; + + clear(): this; + toArray(): T[]; + toString(): string; +} diff --git a/core/stdlib/src/structs/PriorityQueue/index.test.ts b/core/stdlib/src/structs/PriorityQueue/index.test.ts new file mode 100644 index 0000000..c842799 --- /dev/null +++ b/core/stdlib/src/structs/PriorityQueue/index.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; + +import { PriorityQueue } from '.'; + +describe('PriorityQueue', () => { + describe('constructor', () => { + it('should create an empty queue', () => { + const pq = new PriorityQueue(); + + expect(pq.length).toBe(0); + expect(pq.isEmpty).toBe(true); + expect(pq.isFull).toBe(false); + }); + + it('should create a queue from single value', () => { + const pq = new PriorityQueue(42); + + expect(pq.length).toBe(1); + expect(pq.peek()).toBe(42); + }); + + it('should create a queue from array', () => { + const pq = new PriorityQueue([5, 3, 8, 1, 4]); + + expect(pq.length).toBe(5); + expect(pq.peek()).toBe(1); + }); + + it('should throw if initial values exceed maxSize', () => { + expect(() => new PriorityQueue([1, 2, 3], { maxSize: 2 })) + .toThrow('Initial values exceed maxSize'); + }); + }); + + describe('enqueue', () => { + it('should enqueue elements by priority', () => { + const pq = new PriorityQueue(); + + pq.enqueue(5); + pq.enqueue(1); + pq.enqueue(3); + + expect(pq.peek()).toBe(1); + expect(pq.length).toBe(3); + }); + + it('should throw when queue is full', () => { + const pq = new PriorityQueue(undefined, { maxSize: 2 }); + + pq.enqueue(1); + pq.enqueue(2); + + expect(() => pq.enqueue(3)).toThrow('PriorityQueue is full'); + }); + }); + + describe('dequeue', () => { + it('should return undefined for empty queue', () => { + const pq = new PriorityQueue(); + + expect(pq.dequeue()).toBeUndefined(); + }); + + it('should dequeue elements in priority order (min-heap)', () => { + const pq = new PriorityQueue([5, 3, 8, 1, 4]); + const result: number[] = []; + + while (!pq.isEmpty) { + result.push(pq.dequeue()!); + } + + expect(result).toEqual([1, 3, 4, 5, 8]); + }); + + it('should dequeue elements in priority order (max-heap)', () => { + const pq = new PriorityQueue([5, 3, 8, 1, 4], { + comparator: (a, b) => b - a, + }); + const result: number[] = []; + + while (!pq.isEmpty) { + result.push(pq.dequeue()!); + } + + expect(result).toEqual([8, 5, 4, 3, 1]); + }); + }); + + describe('peek', () => { + it('should return undefined for empty queue', () => { + const pq = new PriorityQueue(); + + expect(pq.peek()).toBeUndefined(); + }); + + it('should return highest-priority element without removing', () => { + const pq = new PriorityQueue([5, 1, 3]); + + expect(pq.peek()).toBe(1); + expect(pq.length).toBe(3); + }); + }); + + describe('isFull', () => { + it('should be false when no maxSize', () => { + const pq = new PriorityQueue([1, 2, 3]); + + expect(pq.isFull).toBe(false); + }); + + it('should be true when at maxSize', () => { + const pq = new PriorityQueue([1, 2], { maxSize: 2 }); + + expect(pq.isFull).toBe(true); + }); + + it('should become false after dequeue', () => { + const pq = new PriorityQueue([1, 2], { maxSize: 2 }); + + pq.dequeue(); + + expect(pq.isFull).toBe(false); + }); + }); + + describe('clear', () => { + it('should remove all elements', () => { + const pq = new PriorityQueue([1, 2, 3]); + + const result = pq.clear(); + + expect(pq.length).toBe(0); + expect(pq.isEmpty).toBe(true); + expect(result).toBe(pq); + }); + }); + + describe('toArray', () => { + it('should return empty array for empty queue', () => { + const pq = new PriorityQueue(); + + expect(pq.toArray()).toEqual([]); + }); + + it('should return a shallow copy', () => { + const pq = new PriorityQueue([3, 1, 2]); + const arr = pq.toArray(); + + arr.push(99); + + expect(pq.length).toBe(3); + }); + }); + + describe('toString', () => { + it('should return formatted string', () => { + const pq = new PriorityQueue([1, 2, 3]); + + expect(pq.toString()).toBe('PriorityQueue(3)'); + }); + }); + + describe('iterator', () => { + it('should iterate over elements', () => { + const pq = new PriorityQueue([5, 3, 1]); + const elements = [...pq]; + + expect(elements.length).toBe(3); + }); + }); + + describe('custom comparator', () => { + it('should work with object priority', () => { + interface Job { + priority: number; + name: string; + } + + const pq = new PriorityQueue( + [ + { priority: 3, name: 'low' }, + { priority: 1, name: 'critical' }, + { priority: 2, name: 'normal' }, + ], + { comparator: (a, b) => a.priority - b.priority }, + ); + + expect(pq.dequeue()?.name).toBe('critical'); + expect(pq.dequeue()?.name).toBe('normal'); + expect(pq.dequeue()?.name).toBe('low'); + }); + }); + + describe('interleaved operations', () => { + it('should maintain priority with mixed enqueue and dequeue', () => { + const pq = new PriorityQueue(); + + pq.enqueue(10); + pq.enqueue(5); + expect(pq.dequeue()).toBe(5); + + pq.enqueue(3); + pq.enqueue(7); + expect(pq.dequeue()).toBe(3); + + pq.enqueue(1); + expect(pq.dequeue()).toBe(1); + expect(pq.dequeue()).toBe(7); + expect(pq.dequeue()).toBe(10); + expect(pq.dequeue()).toBeUndefined(); + }); + }); +}); diff --git a/core/stdlib/src/structs/PriorityQueue/index.ts b/core/stdlib/src/structs/PriorityQueue/index.ts new file mode 100644 index 0000000..e1cee9f --- /dev/null +++ b/core/stdlib/src/structs/PriorityQueue/index.ts @@ -0,0 +1,144 @@ +import { BinaryHeap } from '../BinaryHeap'; +import type { Comparator, PriorityQueueLike } from './types'; + +export type { PriorityQueueLike } from './types'; +export type { Comparator } from './types'; + +export interface PriorityQueueOptions { + comparator?: Comparator; + maxSize?: number; +} + +/** + * @name PriorityQueue + * @category Data Structures + * @description Priority queue backed by a binary heap with configurable comparator and optional max size + * + * @since 0.0.8 + * + * @template T The type of elements stored in the queue + */ +export class PriorityQueue implements PriorityQueueLike { + /** + * The maximum number of elements the queue can hold + * + * @private + * @type {number} + */ + private readonly maxSize: number; + + /** + * Internal binary heap backing the queue + * + * @private + * @type {BinaryHeap} + */ + private readonly heap: BinaryHeap; + + /** + * Creates an instance of PriorityQueue + * + * @param {(T[] | T)} [initialValues] The initial values to add to the queue + * @param {PriorityQueueOptions} [options] Queue configuration + */ + constructor(initialValues?: T[] | T, options?: PriorityQueueOptions) { + this.maxSize = options?.maxSize ?? Infinity; + this.heap = new BinaryHeap(initialValues, { comparator: options?.comparator }); + + if (this.heap.length > this.maxSize) { + throw new RangeError('Initial values exceed maxSize'); + } + } + + /** + * Gets the number of elements in the queue + * @returns {number} The number of elements in the queue + */ + public get length(): number { + return this.heap.length; + } + + /** + * Checks if the queue is empty + * @returns {boolean} `true` if the queue is empty, `false` otherwise + */ + public get isEmpty(): boolean { + return this.heap.isEmpty; + } + + /** + * Checks if the queue is full + * @returns {boolean} `true` if the queue has reached maxSize, `false` otherwise + */ + public get isFull(): boolean { + return this.heap.length >= this.maxSize; + } + + /** + * Enqueues an element by priority + * @param {T} element The element to enqueue + * @throws {RangeError} If the queue is full + */ + public enqueue(element: T): void { + if (this.isFull) + throw new RangeError('PriorityQueue is full'); + + this.heap.push(element); + } + + /** + * Dequeues the highest-priority element + * @returns {T | undefined} The highest-priority element, or `undefined` if empty + */ + public dequeue(): T | undefined { + return this.heap.pop(); + } + + /** + * Returns the highest-priority element without removing it + * @returns {T | undefined} The highest-priority element, or `undefined` if empty + */ + public peek(): T | undefined { + return this.heap.peek(); + } + + /** + * Removes all elements from the queue + * @returns {this} The queue instance for chaining + */ + public clear(): this { + this.heap.clear(); + return this; + } + + /** + * Returns a shallow copy of elements in heap order + * @returns {T[]} Array of elements + */ + public toArray(): T[] { + return this.heap.toArray(); + } + + /** + * Returns a string representation of the queue + * @returns {string} String representation + */ + public toString(): string { + return `PriorityQueue(${this.heap.length})`; + } + + /** + * Iterator over queue elements in heap order + */ + public *[Symbol.iterator](): Iterator { + yield* this.heap; + } + + /** + * Async iterator over queue elements in heap order + */ + public async *[Symbol.asyncIterator](): AsyncIterator { + for (const element of this.heap) + yield element; + } +} diff --git a/core/stdlib/src/structs/PriorityQueue/types.ts b/core/stdlib/src/structs/PriorityQueue/types.ts new file mode 100644 index 0000000..46144b7 --- /dev/null +++ b/core/stdlib/src/structs/PriorityQueue/types.ts @@ -0,0 +1,16 @@ +import type { Comparator } from '../BinaryHeap'; + +export interface PriorityQueueLike extends Iterable, AsyncIterable { + readonly length: number; + readonly isEmpty: boolean; + readonly isFull: boolean; + + enqueue(element: T): void; + dequeue(): T | undefined; + peek(): T | undefined; + clear(): this; + toArray(): T[]; + toString(): string; +} + +export type { Comparator }; diff --git a/core/stdlib/src/structs/Queue/index.test.ts b/core/stdlib/src/structs/Queue/index.test.ts new file mode 100644 index 0000000..4a62480 --- /dev/null +++ b/core/stdlib/src/structs/Queue/index.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest'; +import { Queue } from '.'; + +describe('queue', () => { + describe('constructor', () => { + it('create an empty queue if no initial values are provided', () => { + const queue = new Queue(); + + expect(queue.length).toBe(0); + expect(queue.isEmpty).toBe(true); + }); + + it('create a queue with the provided initial values', () => { + const queue = new Queue([1, 2, 3]); + + expect(queue.length).toBe(3); + expect(queue.peek()).toBe(1); + }); + + it('create a queue with a single initial value', () => { + const queue = new Queue(42); + + expect(queue.length).toBe(1); + expect(queue.peek()).toBe(42); + }); + + it('create a queue with the provided options', () => { + const queue = new Queue(undefined, { maxSize: 5 }); + + expect(queue.length).toBe(0); + expect(queue.isFull).toBe(false); + }); + }); + + describe('enqueue', () => { + it('add an element to the back of the queue', () => { + const queue = new Queue(); + queue.enqueue(1); + + expect(queue.length).toBe(1); + expect(queue.peek()).toBe(1); + }); + + it('maintain FIFO order', () => { + const queue = new Queue(); + queue.enqueue(1).enqueue(2).enqueue(3); + + expect(queue.peek()).toBe(1); + }); + + it('throw an error if the queue is full', () => { + const queue = new Queue(undefined, { maxSize: 1 }); + queue.enqueue(1); + + expect(() => queue.enqueue(2)).toThrow(new RangeError('Queue is full')); + }); + + it('return this for chaining', () => { + const queue = new Queue(); + const result = queue.enqueue(1); + + expect(result).toBe(queue); + }); + }); + + describe('dequeue', () => { + it('remove and return the front element', () => { + const queue = new Queue([1, 2, 3]); + const element = queue.dequeue(); + + expect(element).toBe(1); + expect(queue.length).toBe(2); + }); + + it('return undefined if the queue is empty', () => { + const queue = new Queue(); + + expect(queue.dequeue()).toBeUndefined(); + }); + + it('maintain FIFO order across multiple dequeues', () => { + const queue = new Queue([1, 2, 3]); + + expect(queue.dequeue()).toBe(1); + expect(queue.dequeue()).toBe(2); + expect(queue.dequeue()).toBe(3); + expect(queue.dequeue()).toBeUndefined(); + }); + + it('compact internal storage after many dequeues', () => { + const queue = new Queue(); + + for (let i = 0; i < 100; i++) + queue.enqueue(i); + + for (let i = 0; i < 80; i++) + queue.dequeue(); + + expect(queue.length).toBe(20); + expect(queue.peek()).toBe(80); + }); + }); + + describe('peek', () => { + it('return the front element without removing it', () => { + const queue = new Queue([1, 2, 3]); + + expect(queue.peek()).toBe(1); + expect(queue.length).toBe(3); + }); + + it('return undefined if the queue is empty', () => { + const queue = new Queue(); + + expect(queue.peek()).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('clear the queue', () => { + const queue = new Queue([1, 2, 3]); + queue.clear(); + + expect(queue.length).toBe(0); + expect(queue.isEmpty).toBe(true); + }); + + it('return this for chaining', () => { + const queue = new Queue([1, 2, 3]); + + expect(queue.clear()).toBe(queue); + }); + }); + + describe('toArray', () => { + it('return elements in FIFO order', () => { + const queue = new Queue([1, 2, 3]); + + expect(queue.toArray()).toEqual([1, 2, 3]); + }); + + it('return correct array after dequeues', () => { + const queue = new Queue([1, 2, 3, 4, 5]); + queue.dequeue(); + queue.dequeue(); + + expect(queue.toArray()).toEqual([3, 4, 5]); + }); + }); + + describe('toString', () => { + it('return comma-separated string', () => { + const queue = new Queue([1, 2, 3]); + + expect(queue.toString()).toBe('1,2,3'); + }); + }); + + describe('iteration', () => { + it('iterate over the queue in FIFO order', () => { + const queue = new Queue([1, 2, 3]); + + expect([...queue]).toEqual([1, 2, 3]); + }); + + it('iterate correctly after dequeues', () => { + const queue = new Queue([1, 2, 3, 4]); + queue.dequeue(); + + expect([...queue]).toEqual([2, 3, 4]); + }); + + it('iterate over the queue asynchronously in FIFO order', async () => { + const queue = new Queue([1, 2, 3]); + const elements: number[] = []; + + for await (const element of queue) + elements.push(element); + + expect(elements).toEqual([1, 2, 3]); + }); + }); + + describe('mixed operations', () => { + it('interleave enqueue and dequeue', () => { + const queue = new Queue(); + + queue.enqueue(1); + queue.enqueue(2); + expect(queue.dequeue()).toBe(1); + + queue.enqueue(3); + expect(queue.dequeue()).toBe(2); + expect(queue.dequeue()).toBe(3); + expect(queue.isEmpty).toBe(true); + }); + + it('reuse queue after clear', () => { + const queue = new Queue([1, 2, 3]); + queue.clear(); + queue.enqueue(4); + + expect(queue.length).toBe(1); + expect(queue.peek()).toBe(4); + }); + }); +}); diff --git a/core/stdlib/src/structs/Queue/index.ts b/core/stdlib/src/structs/Queue/index.ts new file mode 100644 index 0000000..50f6cf7 --- /dev/null +++ b/core/stdlib/src/structs/Queue/index.ts @@ -0,0 +1,140 @@ +import { Deque } from '../Deque'; +import type { QueueLike } from './types'; + +export type { QueueLike } from './types'; + +export interface QueueOptions { + maxSize?: number; +} + +/** + * @name Queue + * @category Data Structures + * @description Represents a queue data structure (FIFO) backed by a Deque + * + * @since 0.0.8 + * + * @template T The type of elements stored in the queue + */ +export class Queue implements QueueLike { + /** + * The underlying deque + * + * @private + * @type {Deque} + */ + private readonly deque: Deque; + + /** + * Creates an instance of Queue + * + * @param {(T[] | T)} [initialValues] The initial values to add to the queue + * @param {QueueOptions} [options] The options for the queue + */ + constructor(initialValues?: T[] | T, options?: QueueOptions) { + this.deque = new Deque(initialValues, options); + } + + /** + * Gets the number of elements in the queue + * @returns {number} The number of elements in the queue + */ + get length() { + return this.deque.length; + } + + /** + * Checks if the queue is empty + * @returns {boolean} `true` if the queue is empty, `false` otherwise + */ + get isEmpty() { + return this.deque.isEmpty; + } + + /** + * Checks if the queue is full + * @returns {boolean} `true` if the queue is full, `false` otherwise + */ + get isFull() { + return this.deque.isFull; + } + + /** + * Adds an element to the back of the queue + * @param {T} element The element to enqueue + * @returns {this} + * @throws {RangeError} If the queue is full + */ + enqueue(element: T) { + if (this.deque.isFull) + throw new RangeError('Queue is full'); + + this.deque.pushBack(element); + + return this; + } + + /** + * Removes and returns the front element of the queue + * @returns {T | undefined} The front element, or undefined if the queue is empty + */ + dequeue() { + return this.deque.popFront(); + } + + /** + * Returns the front element without removing it + * @returns {T | undefined} The front element, or undefined if the queue is empty + */ + peek() { + return this.deque.peekFront(); + } + + /** + * Clears the queue + * + * @returns {this} + */ + clear() { + this.deque.clear(); + + return this; + } + + /** + * Converts the queue to an array in FIFO order + * + * @returns {T[]} + */ + toArray() { + return this.deque.toArray(); + } + + /** + * Returns a string representation of the queue + * + * @returns {string} + */ + toString() { + return this.deque.toString(); + } + + /** + * Returns an iterator for the queue + * + * @returns {IterableIterator} + */ + [Symbol.iterator]() { + return this.deque[Symbol.iterator](); + } + + /** + * Returns an async iterator for the queue + * + * @returns {AsyncIterableIterator} + */ + async *[Symbol.asyncIterator]() { + for (const element of this.deque) + yield element; + } +} diff --git a/core/stdlib/src/structs/Queue/types.ts b/core/stdlib/src/structs/Queue/types.ts new file mode 100644 index 0000000..c2d0107 --- /dev/null +++ b/core/stdlib/src/structs/Queue/types.ts @@ -0,0 +1,12 @@ +export interface QueueLike extends Iterable, AsyncIterable { + readonly length: number; + readonly isEmpty: boolean; + readonly isFull: boolean; + + enqueue(element: T): this; + dequeue(): T | undefined; + peek(): T | undefined; + clear(): this; + toArray(): T[]; + toString(): string; +} diff --git a/core/stdlib/src/structs/index.ts b/core/stdlib/src/structs/index.ts index d34d808..1f304c9 100644 --- a/core/stdlib/src/structs/index.ts +++ b/core/stdlib/src/structs/index.ts @@ -1 +1,7 @@ -export * from './stack'; \ No newline at end of file +export * from './BinaryHeap'; +export * from './CircularBuffer'; +export * from './Deque'; +export * from './LinkedList'; +export * from './PriorityQueue'; +export * from './Queue'; +export * from './Stack'; \ No newline at end of file diff --git a/core/stdlib/src/structs/stack/index.ts b/core/stdlib/src/structs/stack/index.ts index 4780a80..05fee93 100644 --- a/core/stdlib/src/structs/stack/index.ts +++ b/core/stdlib/src/structs/stack/index.ts @@ -1,5 +1,8 @@ import { last } from '../../arrays'; import { isArray } from '../../types'; +import type { StackLike } from './types'; + +export type { StackLike } from './types'; export interface StackOptions { maxSize?: number; @@ -14,7 +17,7 @@ export interface StackOptions { * * @template T The type of elements stored in the stack */ -export class Stack implements Iterable, AsyncIterable { +export class Stack implements StackLike { /** * The maximum number of elements that the stack can hold * diff --git a/core/stdlib/src/structs/stack/types.ts b/core/stdlib/src/structs/stack/types.ts new file mode 100644 index 0000000..65cd14d --- /dev/null +++ b/core/stdlib/src/structs/stack/types.ts @@ -0,0 +1,12 @@ +export interface StackLike extends Iterable, AsyncIterable { + readonly length: number; + readonly isEmpty: boolean; + readonly isFull: boolean; + + push(element: T): this; + pop(): T | undefined; + peek(): T | undefined; + clear(): this; + toArray(): T[]; + toString(): string; +}