1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 19:04:46 +00:00

14 Commits

Author SHA1 Message Date
a83e2bb797 Merge pull request #128 from robonen/vue-0.0.13
Vue 0.0.13
2026-02-15 05:32:31 +07:00
9bece480ca test(web/vue): update event listener tests to use globalThis and improve assertions 2026-02-15 05:30:58 +07:00
c48de9a3d1 feat(web/vue): update version to 0.0.13 and add useTabLeader composable with tests 2026-02-15 05:29:08 +07:00
624e12ed96 Merge pull request #127 from robonen/stdlib-fix-reusing
refactor(core/stdlib): update state machine classes to use consistent property names and improve type safety
2026-02-15 03:28:09 +07:00
3380d90cee refactor(core/stdlib): update state machine classes to use consistent property names and improve type safety 2026-02-15 03:26:42 +07:00
bb644579ca Merge pull request #126 from robonen/shared-build-config
chore: update package versions and integrate shared tsdown configuration
2026-02-15 03:18:28 +07:00
e7d1021d27 chore: remove tsdown dependency from importers in pnpm-lock.yaml 2026-02-15 03:15:36 +07:00
1782184761 chore: update package versions and integrate shared tsdown configuration 2026-02-15 03:13:49 +07:00
70d96b7f39 Merge pull request #122 from robonen/renovate/node-24.x
chore(deps): update node.js to v24
2026-02-15 02:55:35 +07:00
renovate[bot]
9587c92e50 chore(deps): update node.js to v24 2026-02-14 19:53:12 +00:00
678c18a08d Merge pull request #125 from robonen/stdlib-updates
feat(core/stdlib): implement LinkedList, PriorityQueue, and Queue dat…
2026-02-15 02:52:11 +07:00
68afec40b7 chore(core/stdlib): fix lint 2026-02-15 02:50:54 +07:00
50b1498f3e fix(core/stdlib): rename dirs 2026-02-15 02:45:59 +07:00
7b5da22290 feat(core/stdlib): implement LinkedList, PriorityQueue, and Queue data structures 2026-02-15 02:36:41 +07:00
60 changed files with 5582 additions and 89 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@robonen/oxlint",
"version": "0.0.1",
"version": "0.0.2",
"license": "Apache-2.0",
"description": "Composable oxlint configuration presets",
"keywords": [
@@ -18,7 +18,7 @@
},
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=22.18.0"
"node": ">=24.13.1"
},
"type": "module",
"files": [
@@ -40,6 +40,7 @@
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
},

View File

@@ -1,6 +1,3 @@
{
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"rootDir": "."
}
"extends": "@robonen/tsconfig/tsconfig.json"
}

View File

@@ -1,9 +1,7 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
});

View File

@@ -0,0 +1,30 @@
{
"name": "@robonen/tsdown",
"version": "0.0.1",
"private": true,
"license": "Apache-2.0",
"description": "Shared tsdown configuration for @robonen packages",
"keywords": [
"tsdown",
"config",
"build"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown"
},
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"tsdown": "catalog:"
}
}

View File

@@ -0,0 +1,13 @@
import type { Options } from 'tsdown';
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
export const sharedConfig = {
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
outputOptions: {
banner: BANNER,
},
} satisfies Options;

View File

@@ -0,0 +1,3 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@robonen/platform",
"version": "0.0.3",
"version": "0.0.4",
"license": "Apache-2.0",
"description": "Platform dependent utilities for javascript development",
"keywords": [
@@ -47,6 +47,7 @@
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
}

View File

@@ -1,12 +1,10 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: {
browsers: 'src/browsers/index.ts',
multi: 'src/multi/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
});

View File

@@ -1,6 +1,6 @@
{
"name": "@robonen/stdlib",
"version": "0.0.7",
"version": "0.0.9",
"license": "Apache-2.0",
"description": "A collection of tools, utilities, and helpers for TypeScript",
"keywords": [
@@ -42,6 +42,7 @@
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
}

View File

@@ -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;
}

View File

@@ -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<AsyncCommand> {
async execute(command: AsyncCommand): Promise<void> {
await command.execute();
this.pushUndo(command);
}
async undo(): Promise<boolean> {
const command = this.undoStack.pop();
if (!command)
return false;
await command.undo();
this.moveToRedo(command);
return true;
}
async redo(): Promise<boolean> {
const command = this.redoStack.pop();
if (!command)
return false;
await command.execute();
this.moveToUndo(command);
return true;
}
async batch(commands: AsyncCommand[]): Promise<void> {
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);
}
}

View File

@@ -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<C> {
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);
}
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,3 @@
export type * from './types';
export * from './sync';
export * from './async';

View File

@@ -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<Command> {
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);
}
}

View File

@@ -0,0 +1,11 @@
import type { MaybePromise } from '../../../types';
export interface Command {
execute: () => void;
undo: () => void;
}
export interface AsyncCommand {
execute: () => MaybePromise<void>;
undo: () => MaybePromise<void>;
}

View File

@@ -1,9 +1,3 @@
import type { AnyFunction } from '../../../types';
export type Subscriber = AnyFunction;
export type EventHandlerMap = Record<PropertyKey, Subscriber>;
/**
* @name PubSub
* @category Patterns
@@ -11,9 +5,9 @@ export type EventHandlerMap = Record<PropertyKey, Subscriber>;
*
* @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<Events extends EventHandlerMap> {
export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
/**
* Events map
*

View File

@@ -0,0 +1,133 @@
import { isString } from '../../../types';
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<States, Events, Context, AsyncStateNodeConfig<Context>> {
/**
* 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<States> {
const stateNode = this.states[this.currentState];
if (!stateNode?.on)
return this.currentState;
const transition = stateNode.on[event];
if (transition === undefined)
return this.currentState;
let target: string;
if (isString(transition)) {
target = transition;
} else {
if (transition.guard && !(await transition.guard(this.context)))
return this.currentState;
await transition.action?.(this.context);
target = transition.target;
}
await stateNode.exit?.(this.context);
this.currentState = target as States;
await this.states[this.currentState]?.entry?.(this.context);
return this.currentState;
}
/**
* Check if an event can trigger a transition, awaiting async guards
*
* @param event - Event to check
*/
async can(event: Events): Promise<boolean> {
const stateNode = this.states[this.currentState];
if (!stateNode?.on)
return false;
const transition = stateNode.on[event];
if (transition === undefined)
return false;
if (!isString(transition) && 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<string, AsyncStateNodeConfig<Context>>,
Context,
>(config: {
initial: NoInfer<ExtractStates<States>>;
context: Context;
states: States;
}): AsyncStateMachine<ExtractStates<States>, ExtractEvents<States>, Context>;
export function createAsyncMachine<
const States extends Record<string, AsyncStateNodeConfig<undefined>>,
>(config: {
initial: NoInfer<ExtractStates<States>>;
states: States;
}): AsyncStateMachine<ExtractStates<States>, ExtractEvents<States>, undefined>;
export function createAsyncMachine(config: {
initial: string;
context?: unknown;
states: Record<string, AsyncStateNodeConfig<any>>;
}): AsyncStateMachine {
return new AsyncStateMachine(
config.initial,
config.states,
config.context as undefined,
);
}

View File

@@ -0,0 +1,44 @@
/**
* 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 currentState: States;
protected readonly states: Record<string, NodeConfig>;
/** Machine context */
readonly context: Context;
constructor(
initial: States,
states: Record<string, NodeConfig>,
context: Context,
) {
this.currentState = initial;
this.context = context;
this.states = states;
}
/** Current state of the machine */
get current(): States {
return this.currentState;
}
/**
* Check if the machine is in a specific state
*
* @param state - State to check
*/
matches(state: States): boolean {
return this.currentState === state;
}
}

View File

@@ -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<typeof createSimpleMachine>;
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();
});
});
});

View File

@@ -0,0 +1,3 @@
export type * from './types';
export * from './sync';
export * from './async';

View File

@@ -0,0 +1,134 @@
import { isString } from '../../../types';
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<States, Events, Context, SyncStateNodeConfig<Context>> {
/**
* 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.currentState];
if (!stateNode?.on)
return this.currentState;
const transition = stateNode.on[event];
if (transition === undefined)
return this.currentState;
let target: string;
if (isString(transition)) {
target = transition;
} else {
if (transition.guard && !transition.guard(this.context))
return this.currentState;
transition.action?.(this.context);
target = transition.target;
}
stateNode.exit?.(this.context);
this.currentState = target as States;
this.states[this.currentState]?.entry?.(this.context);
return this.currentState;
}
/**
* 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.currentState];
if (!stateNode?.on)
return false;
const transition = stateNode.on[event];
if (transition === undefined)
return false;
if (!isString(transition) && 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<string, SyncStateNodeConfig<Context>>,
Context,
>(config: {
initial: NoInfer<ExtractStates<States>>;
context: Context;
states: States;
}): StateMachine<ExtractStates<States>, ExtractEvents<States>, Context>;
export function createMachine<
const States extends Record<string, SyncStateNodeConfig<undefined>>,
>(config: {
initial: NoInfer<ExtractStates<States>>;
states: States;
}): StateMachine<ExtractStates<States>, ExtractEvents<States>, undefined>;
export function createMachine(config: {
initial: string;
context?: unknown;
states: Record<string, SyncStateNodeConfig<any>>;
}): StateMachine {
return new StateMachine(
config.initial,
config.states,
config.context as undefined,
);
}

View File

@@ -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\<boolean\>)
* @template Action - Action return type (void or MaybePromise\<void\>)
*/
export interface 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<Context, Guard, Action>;
/**
* Configuration for a state node
*
* @template Context - Machine context type
* @template Guard - Guard return type
* @template Hook - Hook return type (entry/exit/action)
*/
export interface StateNodeConfig<
Context,
Guard = boolean,
Hook = void,
> {
/** Map of event names to transitions */
on?: Record<string, Transition<Context, Guard, Hook>>;
/** 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<Context> = StateNodeConfig<Context, boolean, void>;
/** Async state node config — guards return MaybePromise\<boolean\>, hooks return MaybePromise\<void\> */
export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromise<boolean>, MaybePromise<void>>;
export type ExtractStates<T> = keyof T & string;
export type ExtractEvents<T> = {
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, any>> }
? E
: never;
}[keyof T];

View File

@@ -1 +1,3 @@
export * from './behavioral/pubsub';
export * from './behavioral/Command';
export * from './behavioral/PubSub';
export * from './behavioral/StateMachine';

View File

@@ -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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<Task>(
[
{ 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<number>();
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();
});
});
});

View File

@@ -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<T> {
comparator?: Comparator<T>;
}
/**
* 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<any> = (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<T> implements BinaryHeapLike<T> {
/**
* The comparator function used to order elements
*
* @private
* @type {Comparator<T>}
*/
private readonly comparator: Comparator<T>;
/**
* 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<T>} [options] Heap configuration
*/
constructor(initialValues?: T[] | T, options?: BinaryHeapOptions<T>) {
this.comparator = options?.comparator ?? defaultComparator;
if (initialValues !== null && initialValues !== undefined) {
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<T> {
yield* this.heap;
}
/**
* Async iterator over heap elements in heap order
*/
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
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);
}
}
}

View File

@@ -0,0 +1,13 @@
export type Comparator<T> = (a: T, b: T) => number;
export interface BinaryHeapLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly isEmpty: boolean;
push(element: T): void;
pop(): T | undefined;
peek(): T | undefined;
clear(): this;
toArray(): T[];
toString(): string;
}

View File

@@ -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<number>();
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<number>(undefined, 32);
expect(buf.capacity).toBe(32);
});
it('round capacity up to next power of two', () => {
const buf = new CircularBuffer<number>(undefined, 5);
expect(buf.capacity).toBe(8);
});
});
describe('pushBack / popFront', () => {
it('FIFO order', () => {
const buf = new CircularBuffer<number>();
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<number>();
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<number>();
expect(buf.popFront()).toBeUndefined();
});
});
describe('popBack', () => {
it('return undefined if empty', () => {
const buf = new CircularBuffer<number>();
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<number>();
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<number>(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<number>();
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<number>(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<number>(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<number>();
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<number>();
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]);
});
});
});

View File

@@ -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<T> implements CircularBufferLike<T> {
/**
* The internal storage
*
* @private
* @type {(T | undefined)[]}
*/
private buffer: Array<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 = Array.from<T | undefined>({ length: 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 = Array.from<T | undefined>({ length: 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 = Array.from<T>({ length: 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<T>}
*/
[Symbol.iterator]() {
return this.toArray()[Symbol.iterator]();
}
/**
* Returns an async iterator (front to back)
*
* @returns {AsyncIterableIterator<T>}
*/
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 = Array.from<T | undefined>({ length: 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;
}

View File

@@ -0,0 +1,17 @@
export interface CircularBufferLike<T> extends Iterable<T>, AsyncIterable<T> {
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;
}

View File

@@ -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<number>();
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<number>(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<number>();
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<number>(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<number>();
expect(deque.pushBack(1)).toBe(deque);
});
});
describe('pushFront', () => {
it('add an element to the front', () => {
const deque = new Deque<number>();
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<number>(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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>(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]);
});
});
});

View File

@@ -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<T> implements DequeLike<T> {
/**
* The maximum number of elements that the deque can hold
*
* @private
* @type {number}
*/
private readonly maxSize: number;
/**
* The underlying circular buffer
*
* @private
* @type {CircularBuffer<T>}
*/
private readonly buffer: CircularBuffer<T>;
/**
* 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<T>}
*/
[Symbol.iterator]() {
return this.buffer[Symbol.iterator]();
}
/**
* Returns an async iterator for the deque (front to back)
*
* @returns {AsyncIterableIterator<T>}
*/
async *[Symbol.asyncIterator]() {
for (const element of this.buffer)
yield element;
}
}

View File

@@ -0,0 +1,15 @@
export interface DequeLike<T> extends Iterable<T>, AsyncIterable<T> {
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;
}

View File

@@ -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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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]);
});
});
});

View File

@@ -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<T>} The created node
*/
function createNode<T>(value: T): LinkedListNode<T> {
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<T> implements LinkedListLike<T> {
/**
* The number of elements in the list
*
* @private
* @type {number}
*/
private count = 0;
/**
* The first node in the list
*
* @private
* @type {LinkedListNode<T> | undefined}
*/
private first: LinkedListNode<T> | undefined;
/**
* The last node in the list
*
* @private
* @type {LinkedListNode<T> | undefined}
*/
private last: LinkedListNode<T> | 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 && initialValues !== undefined) {
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<T> | undefined} The first node, or `undefined` if the list is empty
*/
public get head(): LinkedListNode<T> | undefined {
return this.first;
}
/**
* Gets the last node
* @returns {LinkedListNode<T> | undefined} The last node, or `undefined` if the list is empty
*/
public get tail(): LinkedListNode<T> | undefined {
return this.last;
}
/**
* Appends a value to the end of the list
* @param {T} value The value to append
* @returns {LinkedListNode<T>} The created node
*/
public pushBack(value: T): LinkedListNode<T> {
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<T>} The created node
*/
public pushFront(value: T): LinkedListNode<T> {
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<T>} node The reference node
* @param {T} value The value to insert
* @returns {LinkedListNode<T>} The created node
*/
public insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
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<T>} node The reference node
* @param {T} value The value to insert
* @returns {LinkedListNode<T>} The created node
*/
public insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
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<T>} node The node to remove
* @returns {T} The value of the removed node
*/
public remove(node: LinkedListNode<T>): 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 = Array.from<T>({ length: 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<T> {
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<T> {
for (const value of this)
yield value;
}
/**
* Detaches a node from the list, updating head/tail and count
*
* @private
* @param {LinkedListNode<T>} node The node to detach
*/
private detach(node: LinkedListNode<T>): 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--;
}
}

View File

@@ -0,0 +1,28 @@
export interface LinkedListNode<T> {
value: T;
prev: LinkedListNode<T> | undefined;
next: LinkedListNode<T> | undefined;
}
export interface LinkedListLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly isEmpty: boolean;
readonly head: LinkedListNode<T> | undefined;
readonly tail: LinkedListNode<T> | undefined;
pushBack(value: T): LinkedListNode<T>;
pushFront(value: T): LinkedListNode<T>;
popBack(): T | undefined;
popFront(): T | undefined;
peekBack(): T | undefined;
peekFront(): T | undefined;
insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
remove(node: LinkedListNode<T>): T;
clear(): this;
toArray(): T[];
toString(): string;
}

View File

@@ -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<number>();
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<number>();
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<number>(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<number>();
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<number>();
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<number>();
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<Job>(
[
{ 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<number>();
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();
});
});
});

View File

@@ -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<T> {
comparator?: Comparator<T>;
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<T> implements PriorityQueueLike<T> {
/**
* 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<T>}
*/
private readonly heap: BinaryHeap<T>;
/**
* Creates an instance of PriorityQueue
*
* @param {(T[] | T)} [initialValues] The initial values to add to the queue
* @param {PriorityQueueOptions<T>} [options] Queue configuration
*/
constructor(initialValues?: T[] | T, options?: PriorityQueueOptions<T>) {
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<T> {
yield* this.heap;
}
/**
* Async iterator over queue elements in heap order
*/
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
for (const element of this.heap)
yield element;
}
}

View File

@@ -0,0 +1,16 @@
import type { Comparator } from '../BinaryHeap';
export interface PriorityQueueLike<T> extends Iterable<T>, AsyncIterable<T> {
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 };

View File

@@ -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<number>();
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<number>(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<number>();
queue.enqueue(1);
expect(queue.length).toBe(1);
expect(queue.peek()).toBe(1);
});
it('maintain FIFO order', () => {
const queue = new Queue<number>();
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<number>(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<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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);
});
});
});

View File

@@ -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<T> implements QueueLike<T> {
/**
* The underlying deque
*
* @private
* @type {Deque<T>}
*/
private readonly deque: Deque<T>;
/**
* 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<T>}
*/
[Symbol.iterator]() {
return this.deque[Symbol.iterator]();
}
/**
* Returns an async iterator for the queue
*
* @returns {AsyncIterableIterator<T>}
*/
async *[Symbol.asyncIterator]() {
for (const element of this.deque)
yield element;
}
}

View File

@@ -0,0 +1,12 @@
export interface QueueLike<T> extends Iterable<T>, AsyncIterable<T> {
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;
}

View File

@@ -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<T> implements Iterable<T>, AsyncIterable<T> {
export class Stack<T> implements StackLike<T> {
/**
* The maximum number of elements that the stack can hold
*

View File

@@ -0,0 +1,12 @@
export interface StackLike<T> extends Iterable<T>, AsyncIterable<T> {
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;
}

View File

@@ -1 +1,7 @@
export * from './stack';
export * from './BinaryHeap';
export * from './CircularBuffer';
export * from './Deque';
export * from './LinkedList';
export * from './PriorityQueue';
export * from './Queue';
export * from './Stack';

View File

@@ -1,9 +1,7 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
});

View File

@@ -18,7 +18,7 @@
},
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=22.18.0"
"node": ">=24.13.1"
},
"files": [
"default.json"

View File

@@ -17,11 +17,11 @@
},
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=22.18.0"
"node": ">=24.13.1"
},
"type": "module",
"devDependencies": {
"@types/node": "^22.19.11",
"@types/node": "^24.10.13",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"citty": "^0.2.1",

73
pnpm-lock.yaml generated
View File

@@ -36,8 +36,8 @@ importers:
.:
devDependencies:
'@types/node':
specifier: ^22.19.11
version: 22.19.11
specifier: ^24.10.13
version: 24.10.13
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.0.18(vitest@4.0.18)
@@ -58,7 +58,7 @@ importers:
version: 1.3.0
vitest:
specifier: 'catalog:'
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
configs/oxlint:
devDependencies:
@@ -68,6 +68,9 @@ importers:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../tsdown
oxlint:
specifier: 'catalog:'
version: 1.47.0
@@ -77,6 +80,15 @@ importers:
configs/tsconfig: {}
configs/tsdown:
devDependencies:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../tsconfig
tsdown:
specifier: 'catalog:'
version: 0.12.9(typescript@5.8.3)
core/platform:
devDependencies:
'@robonen/oxlint':
@@ -85,6 +97,9 @@ importers:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
oxlint:
specifier: 'catalog:'
version: 1.47.0
@@ -100,6 +115,9 @@ importers:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
oxlint:
specifier: 'catalog:'
version: 1.47.0
@@ -131,6 +149,9 @@ importers:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
@@ -1615,8 +1636,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
'@types/node@24.10.13':
resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -4017,8 +4038,8 @@ packages:
underscore@1.13.6:
resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.21.0:
resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==}
@@ -6311,13 +6332,13 @@ snapshots:
'@types/bunyan@1.8.11':
dependencies:
'@types/node': 22.19.11
'@types/node': 24.10.13
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.0.4
'@types/keyv': 3.1.4
'@types/node': 22.19.11
'@types/node': 24.10.13
'@types/responselike': 1.0.3
'@types/chai@5.2.2':
@@ -6338,7 +6359,7 @@ snapshots:
'@types/keyv@3.1.4':
dependencies:
'@types/node': 22.19.11
'@types/node': 24.10.13
'@types/mdast@4.0.4':
dependencies:
@@ -6350,9 +6371,9 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@22.19.11':
'@types/node@24.10.13':
dependencies:
undici-types: 6.21.0
undici-types: 7.16.0
'@types/normalize-package-data@2.4.4': {}
@@ -6360,7 +6381,7 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
'@types/node': 22.19.11
'@types/node': 24.10.13
'@types/semver@7.5.8': {}
@@ -6370,7 +6391,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 22.19.11
'@types/node': 24.10.13
optional: true
'@vitest/coverage-v8@4.0.18(vitest@4.0.18)':
@@ -6385,7 +6406,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
'@vitest/expect@4.0.18':
dependencies:
@@ -6396,13 +6417,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.18(vite@7.1.4(@types/node@22.19.11)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2))':
'@vitest/mocker@4.0.18(vite@7.1.4(@types/node@24.10.13)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.1.4(@types/node@22.19.11)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2)
vite: 7.1.4(@types/node@24.10.13)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2)
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -6430,7 +6451,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
'@vitest/utils@4.0.18':
dependencies:
@@ -8503,7 +8524,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 22.19.11
'@types/node': 24.10.13
long: 5.2.3
protocols@2.0.1: {}
@@ -9283,7 +9304,7 @@ snapshots:
underscore@1.13.6: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.21.0: {}
@@ -9365,7 +9386,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite@7.1.4(@types/node@22.19.11)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2):
vite@7.1.4(@types/node@24.10.13)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.9
fdir: 6.5.0(picomatch@4.0.3)
@@ -9374,16 +9395,16 @@ snapshots:
rollup: 4.50.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.11
'@types/node': 24.10.13
fsevents: 2.3.3
jiti: 2.6.1
terser: 5.44.0
yaml: 2.8.2
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.1.4(@types/node@22.19.11)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(vite@7.1.4(@types/node@24.10.13)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
@@ -9400,11 +9421,11 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.1.4(@types/node@22.19.11)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2)
vite: 7.1.4(@types/node@24.10.13)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 22.19.11
'@types/node': 24.10.13
'@vitest/ui': 4.0.18(vitest@4.0.18)
jsdom: 28.0.0
transitivePeerDependencies:

View File

@@ -1,6 +1,6 @@
{
"name": "@robonen/vue",
"version": "0.0.11",
"version": "0.0.13",
"license": "Apache-2.0",
"description": "Collection of powerful tools for Vue",
"keywords": [
@@ -40,6 +40,7 @@
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@vue/test-utils": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"

View File

@@ -1,3 +1,4 @@
export * from './useEventListener';
export * from './useFocusGuard';
export * from './useSupported';
export * from './useTabLeader';

View File

@@ -0,0 +1,375 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useEventListener } from '.';
const mountWithEventListener = (
setup: () => Record<string, any> | void,
) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(useEventListener, () => {
let component: ReturnType<typeof mountWithEventListener>;
afterEach(() => {
component?.unmount();
});
it('register and trigger a listener on an explicit target', async () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
it('remove listener when stop is called', async () => {
const listener = vi.fn();
const target = document.createElement('div');
let stop: () => void;
component = mountWithEventListener(() => {
stop = useEventListener(target, 'click', listener);
});
await nextTick();
stop!();
target.dispatchEvent(new Event('click'));
expect(listener).not.toHaveBeenCalled();
});
it('remove listener when component is unmounted', async () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
component.unmount();
target.dispatchEvent(new Event('click'));
expect(listener).not.toHaveBeenCalled();
});
it('register multiple events at once', async () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, ['click', 'focus'], listener);
});
await nextTick();
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('focus'));
expect(listener).toHaveBeenCalledTimes(2);
});
it('register multiple listeners at once', async () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', [listener1, listener2]);
});
await nextTick();
target.dispatchEvent(new Event('click'));
expect(listener1).toHaveBeenCalledOnce();
expect(listener2).toHaveBeenCalledOnce();
});
it('register multiple events and multiple listeners', async () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, ['click', 'focus'], [listener1, listener2]);
});
await nextTick();
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('focus'));
expect(listener1).toHaveBeenCalledTimes(2);
expect(listener2).toHaveBeenCalledTimes(2);
});
it('react to a reactive target change', async () => {
const listener = vi.fn();
const el1 = document.createElement('div');
const el2 = document.createElement('div');
const target = ref<HTMLElement>(el1);
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
el1.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
target.value = el2;
await nextTick();
// Old target should no longer trigger listener
el1.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
// New target should trigger listener
el2.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(2);
});
it('cleanup when reactive target becomes null', async () => {
const listener = vi.fn();
const el = document.createElement('div');
const target = ref<HTMLElement | null>(el);
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
target.value = null;
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
});
it('return noop when target is undefined', () => {
const listener = vi.fn();
const stop = useEventListener(undefined as any, 'click', listener);
expect(stop).toBeTypeOf('function');
stop(); // should not throw
expect(listener).not.toHaveBeenCalled();
});
it('pass options to addEventListener', async () => {
const target = document.createElement('div');
const addSpy = vi.spyOn(target, 'addEventListener');
component = mountWithEventListener(() => {
useEventListener(target, 'click', () => {}, { capture: true });
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), { capture: true });
});
it('use window as default target when event string is passed directly', async () => {
const listener = vi.fn();
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const removeSpy = vi.spyOn(globalThis, 'removeEventListener');
component = mountWithEventListener(() => {
useEventListener('click', listener);
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
component.unmount();
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
addSpy.mockRestore();
removeSpy.mockRestore();
});
it('use window as default target when event array is passed directly', async () => {
const listener = vi.fn();
const addSpy = vi.spyOn(globalThis, 'addEventListener');
component = mountWithEventListener(() => {
useEventListener(['click', 'keydown'], listener);
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), undefined);
addSpy.mockRestore();
});
it('work with document target', async () => {
const listener = vi.fn();
component = mountWithEventListener(() => {
useEventListener(document, 'click', listener);
});
await nextTick();
document.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
it('auto cleanup when effectScope is disposed', async () => {
const listener = vi.fn();
const target = document.createElement('div');
const scope = effectScope();
scope.run(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
scope.stop();
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
});
it('re-register when reactive options change', async () => {
const target = document.createElement('div');
const listener = vi.fn();
const options = ref<boolean | AddEventListenerOptions>(false);
const addSpy = vi.spyOn(target, 'addEventListener');
const removeSpy = vi.spyOn(target, 'removeEventListener');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener, options);
});
await nextTick();
expect(addSpy).toHaveBeenCalledTimes(1);
expect(addSpy).toHaveBeenLastCalledWith('click', listener, false);
options.value = true;
await nextTick();
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(addSpy).toHaveBeenCalledTimes(2);
expect(addSpy).toHaveBeenLastCalledWith('click', listener, true);
});
it('pass correct arguments to removeEventListener on stop', async () => {
const listener = vi.fn();
const options = { capture: true };
const target = document.createElement('div');
const removeSpy = vi.spyOn(target, 'removeEventListener');
let stop: () => void;
component = mountWithEventListener(() => {
stop = useEventListener(target, 'click', listener, options);
});
await nextTick();
stop!();
expect(removeSpy).toHaveBeenCalledWith('click', listener, { capture: true });
});
it('remove all listeners for all events on stop', async () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const events = ['click', 'scroll', 'blur'];
const options = { capture: true };
const target = document.createElement('div');
const removeSpy = vi.spyOn(target, 'removeEventListener');
let stop: () => void;
component = mountWithEventListener(() => {
stop = useEventListener(target, events, [listener1, listener2], options);
});
await nextTick();
stop!();
expect(removeSpy).toHaveBeenCalledTimes(events.length * 2);
for (const event of events) {
expect(removeSpy).toHaveBeenCalledWith(event, listener1, { capture: true });
expect(removeSpy).toHaveBeenCalledWith(event, listener2, { capture: true });
}
});
it('clone object options to prevent reactive mutation issues', async () => {
const target = document.createElement('div');
const listener = vi.fn();
const options = ref<AddEventListenerOptions>({ capture: true });
const addSpy = vi.spyOn(target, 'addEventListener');
const removeSpy = vi.spyOn(target, 'removeEventListener');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener, options);
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', listener, { capture: true });
// Change options reactively — old removal should use the snapshotted options
options.value = { capture: false };
await nextTick();
expect(removeSpy).toHaveBeenCalledWith('click', listener, { capture: true });
expect(addSpy).toHaveBeenLastCalledWith('click', listener, { capture: false });
});
it('not listen when reactive target starts as null', async () => {
const listener = vi.fn();
const target = ref<HTMLElement | null>(null);
const el = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).not.toHaveBeenCalled();
// Set target later
target.value = el;
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
});

View File

@@ -1,9 +1,9 @@
import { isArray, isString, noop } from '@robonen/stdlib';
import { first, isArray, isObject, isString, noop } from '@robonen/stdlib';
import type { Arrayable, VoidFunction } from '@robonen/stdlib';
import { toValue, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { defaultWindow } from '@/types';
// TODO: wip
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
interface InferEventTarget<Events> {
addEventListener: (event: Events, listener?: any, options?: any) => any;
@@ -105,7 +105,7 @@ export function useEventListener(...args: any[]) {
let listeners: Arrayable<Function>;
let _options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
if (isString(args[0]) || isArray(args[0])) {
if (isString(first(args)) || isArray(first(args))) {
[events, listeners, _options] = args;
target = defaultWindow;
} else {
@@ -128,11 +128,40 @@ export function useEventListener(...args: any[]) {
cleanups.length = 0;
}
const _register = (el: any, event: string, listener: any, options: any) => {
el.addEventListener(event, listener, options);
return () => el.removeEventListener(event, listener, options);
const _register = (el: EventTarget, event: string, listener: Function, options: boolean | AddEventListenerOptions | undefined) => {
el.addEventListener(event, listener as EventListener, options);
return () => el.removeEventListener(event, listener as EventListener, options);
}
void _cleanup;
void _register;
const stopWatch = watch(
() => [toValue(target), toValue(_options)] as const,
([el, options]) => {
_cleanup();
if (!el)
return;
// Clone object options to avoid reactive mutation between add/remove
const optionsClone = isObject(options) ? { ...options } : options;
const eventsArray = events as string[];
const listenersArray = listeners as Function[];
cleanups.push(
...eventsArray.flatMap((event) =>
listenersArray.map((listener) => _register(el, event, listener, optionsClone)),
),
);
},
{ immediate: true, flush: 'post' },
);
const stop = () => {
stopWatch();
_cleanup();
}
tryOnScopeDispose(stop);
return stop;
}

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { useTabLeader } from '.';
type LockGrantedCallback = (lock: unknown) => Promise<void>;
interface MockLockRequest {
key: string;
callback: LockGrantedCallback;
resolve: () => void;
signal?: AbortSignal;
}
const pendingRequests: MockLockRequest[] = [];
let heldLocks: Set<string>;
function setupLocksMock() {
heldLocks = new Set();
const mockLocks = {
request: vi.fn(async (key: string, options: { signal?: AbortSignal }, callback: LockGrantedCallback) => {
if (options.signal?.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
if (heldLocks.has(key)) {
// Queue the request — lock is held
return new Promise<void>((resolve) => {
const request: MockLockRequest = { key, callback, resolve, signal: options.signal };
options.signal?.addEventListener('abort', () => {
const index = pendingRequests.indexOf(request);
if (index > -1) pendingRequests.splice(index, 1);
resolve();
});
pendingRequests.push(request);
});
}
heldLocks.add(key);
const result = callback({} as unknown);
// When the callback promise resolves (lock released), grant to next waiter
result.then(() => {
heldLocks.delete(key);
grantNextLock(key);
});
return result;
}),
};
Object.defineProperty(navigator, 'locks', {
value: mockLocks,
writable: true,
configurable: true,
});
}
function grantNextLock(key: string) {
const index = pendingRequests.findIndex((r) => r.key === key);
if (index === -1) return;
const [request] = pendingRequests.splice(index, 1);
if (!request) return;
heldLocks.add(key);
const result = request.callback({} as unknown);
result.then(() => {
heldLocks.delete(key);
request.resolve();
grantNextLock(key);
});
}
const mountWithComposable = (setup: () => Record<string, any> | void) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(useTabLeader, () => {
let component: ReturnType<typeof mountWithComposable>;
beforeEach(() => {
pendingRequests.length = 0;
setupLocksMock();
});
afterEach(() => {
component?.unmount();
});
it('acquire leadership when lock is available', async () => {
component = mountWithComposable(() => {
const { isLeader, isSupported } = useTabLeader('test-leader');
return { isLeader, isSupported };
});
await nextTick();
expect(component.vm.isSupported).toBeTruthy();
expect(component.vm.isLeader).toBeTruthy();
});
it('not grant leadership when another tab holds the lock', async () => {
const scope1 = effectScope();
let leader1: ReturnType<typeof useTabLeader>;
scope1.run(() => {
leader1 = useTabLeader('exclusive');
});
const scope2 = effectScope();
let leader2: ReturnType<typeof useTabLeader>;
scope2.run(() => {
leader2 = useTabLeader('exclusive');
});
await nextTick();
expect(leader1!.isLeader.value).toBeTruthy();
expect(leader2!.isLeader.value).toBeFalsy();
scope1.stop();
scope2.stop();
});
it('transfer leadership when the leader releases the lock', async () => {
const scope1 = effectScope();
let leader1: ReturnType<typeof useTabLeader>;
scope1.run(() => {
leader1 = useTabLeader('transfer');
});
const scope2 = effectScope();
let leader2: ReturnType<typeof useTabLeader>;
scope2.run(() => {
leader2 = useTabLeader('transfer');
});
await nextTick();
expect(leader1!.isLeader.value).toBeTruthy();
expect(leader2!.isLeader.value).toBeFalsy();
// Leader 1 releases (e.g., tab closes)
scope1.stop();
await nextTick();
expect(leader1!.isLeader.value).toBeFalsy();
expect(leader2!.isLeader.value).toBeTruthy();
scope2.stop();
});
it('manually release and re-acquire leadership', async () => {
const scope = effectScope();
let leader: ReturnType<typeof useTabLeader>;
scope.run(() => {
leader = useTabLeader('manual');
});
await nextTick();
expect(leader!.isLeader.value).toBeTruthy();
leader!.release();
await nextTick();
expect(leader!.isLeader.value).toBeFalsy();
leader!.acquire();
await nextTick();
expect(leader!.isLeader.value).toBeTruthy();
scope.stop();
});
it('not acquire when immediate is false', async () => {
const scope = effectScope();
let leader: ReturnType<typeof useTabLeader>;
scope.run(() => {
leader = useTabLeader('deferred', { immediate: false });
});
await nextTick();
expect(leader!.isLeader.value).toBeFalsy();
expect(navigator.locks.request).not.toHaveBeenCalled();
leader!.acquire();
await nextTick();
expect(leader!.isLeader.value).toBeTruthy();
scope.stop();
});
it('fallback to isLeader always false when locks API is not supported', async () => {
Object.defineProperty(navigator, 'locks', {
value: undefined,
writable: true,
configurable: true,
});
component = mountWithComposable(() => {
const { isLeader, isSupported } = useTabLeader('unsupported');
return { isLeader, isSupported };
});
await nextTick();
expect(component.vm.isSupported).toBeFalsy();
expect(component.vm.isLeader).toBeFalsy();
});
});

View File

@@ -0,0 +1,115 @@
import { ref, readonly } from 'vue';
import type { Ref, DeepReadonly, ComputedRef } from 'vue';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseTabLeaderOptions {
/**
* Immediately attempt to acquire leadership on creation
* @default true
*/
immediate?: boolean;
}
export interface UseTabLeaderReturn {
/**
* Whether the current tab is the leader
*/
isLeader: DeepReadonly<Ref<boolean>>;
/**
* Whether the Web Locks API is supported
*/
isSupported: ComputedRef<boolean>;
/**
* Manually acquire leadership
*/
acquire: () => void;
/**
* Manually release leadership
*/
release: () => void;
}
/**
* @name useTabLeader
* @category Browser
* @description Elects a single leader tab using the Web Locks API.
* Only one tab at a time holds the lock for a given key.
* When the leader tab closes or the scope is disposed, another tab automatically becomes the leader.
*
* @param {string} key A unique lock name identifying the leader group
* @param {UseTabLeaderOptions} [options={}] Options
* @returns {UseTabLeaderReturn} Leader state and controls
*
* @example
* const { isLeader } = useTabLeader('payment-polling');
*
* watchEffect(() => {
* if (isLeader.value) {
* // Only this tab performs polling
* startPolling();
* } else {
* stopPolling();
* }
* });
*
* @since 0.0.13
*/
export function useTabLeader(key: string, options: UseTabLeaderOptions = {}): UseTabLeaderReturn {
const { immediate = true } = options;
const isLeader = ref(false);
const isSupported = useSupported(() => navigator?.locks);
let releaseResolve: (() => void) | null = null;
let abortController: AbortController | null = null;
function acquire() {
if (!isSupported.value || abortController) return;
abortController = new AbortController();
navigator.locks.request(
key,
{ signal: abortController.signal },
() => {
isLeader.value = true;
return new Promise<void>((resolve) => {
releaseResolve = resolve;
});
},
).catch((error: unknown) => {
// AbortError is expected when release() is called before lock is acquired
if (error instanceof DOMException && error.name === 'AbortError') return;
throw error;
});
}
function release() {
isLeader.value = false;
if (releaseResolve) {
releaseResolve();
releaseResolve = null;
}
if (abortController) {
abortController.abort();
abortController = null;
}
}
if (immediate) {
acquire();
}
tryOnScopeDispose(release);
return {
isLeader: readonly(isLeader),
isSupported,
acquire,
release,
};
}

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick, watch } from 'vue';
import { mount } from '@vue/test-utils';
import { broadcastedRef } from '.';
type MessageHandler = ((event: MessageEvent) => void) | null;
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
name: string;
onmessage: MessageHandler = null;
closed = false;
constructor(name: string) {
this.name = name;
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown) {
if (this.closed) return;
for (const instance of MockBroadcastChannel.instances) {
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
instance.onmessage(new MessageEvent('message', { data }));
}
}
}
close() {
this.closed = true;
const index = MockBroadcastChannel.instances.indexOf(this);
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
}
}
const mountWithRef = (setup: () => Record<string, any> | void) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(broadcastedRef, () => {
let component: ReturnType<typeof mountWithRef>;
beforeEach(() => {
MockBroadcastChannel.instances = [];
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
});
afterEach(() => {
component?.unmount();
vi.unstubAllGlobals();
});
it('create a ref with the initial value', () => {
component = mountWithRef(() => {
const count = broadcastedRef('test-key', 42);
expect(count.value).toBe(42);
});
});
it('broadcast value changes to other channels with the same key', () => {
const ref1 = broadcastedRef('shared', 0);
const ref2 = broadcastedRef('shared', 0);
ref1.value = 100;
expect(ref2.value).toBe(100);
});
it('not broadcast to channels with a different key', () => {
const ref1 = broadcastedRef('key-a', 0);
const ref2 = broadcastedRef('key-b', 0);
ref1.value = 100;
expect(ref2.value).toBe(0);
});
it('receive values from other channels and trigger reactivity', async () => {
const callback = vi.fn();
component = mountWithRef(() => {
const data = broadcastedRef('reactive-test', 'initial');
watch(data, callback, { flush: 'sync' });
});
const sender = broadcastedRef('reactive-test', '');
sender.value = 'updated';
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
});
it('not broadcast initial value by default', () => {
const ref1 = broadcastedRef('no-immediate', 'first');
const ref2 = broadcastedRef('no-immediate', 'second');
expect(ref1.value).toBe('first');
expect(ref2.value).toBe('second');
});
it('broadcast initial value when immediate is true', () => {
const ref1 = broadcastedRef('immediate-test', 'existing');
broadcastedRef('immediate-test', 'new-value', { immediate: true });
expect(ref1.value).toBe('new-value');
});
it('close channel on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
broadcastedRef('dispose-test', 0);
});
expect(MockBroadcastChannel.instances).toHaveLength(1);
scope.stop();
expect(MockBroadcastChannel.instances).toHaveLength(0);
});
it('handle complex object values via structured clone', () => {
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
ref1.value = { status: 'paid', amount: 99.99 };
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
});
it('fallback to a regular ref when BroadcastChannel is not available', () => {
vi.stubGlobal('BroadcastChannel', undefined);
const data = broadcastedRef('fallback', 'value');
expect(data.value).toBe('value');
data.value = 'updated';
expect(data.value).toBe('updated');
});
});

View File

@@ -1,4 +1,15 @@
import { customRef, onScopeDispose } from 'vue';
import { customRef, ref } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface BroadcastedRefOptions {
/**
* Immediately broadcast the initial value to other tabs on creation
* @default false
*/
immediate?: boolean;
}
/**
* @name broadcastedRef
@@ -7,35 +18,51 @@ import { customRef, onScopeDispose } from 'vue';
*
* @param {string} key The channel key to use for broadcasting
* @param {T} initialValue The initial value of the ref
* @param {BroadcastedRefOptions} [options={}] Options
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
*
* @example
* const count = broadcastedRef('counter', 0);
*
* @since 0.0.1
* @example
* const state = broadcastedRef('payment-status', { status: 'pending' });
*
* @since 0.0.13
*/
export function broadcastedRef<T>(key: string, initialValue: T) {
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
const { immediate = false } = options;
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
return ref(initialValue) as Ref<T>;
}
const channel = new BroadcastChannel(key);
let value = initialValue;
onScopeDispose(channel.close);
return customRef<T>((track, trigger) => {
channel.onmessage = (event) => {
track();
return event.data;
const data = customRef<T>((track, trigger) => {
channel.onmessage = (event: MessageEvent<T>) => {
value = event.data;
trigger();
};
channel.postMessage(initialValue);
return {
get() {
return initialValue;
track();
return value;
},
set(newValue: T) {
initialValue = newValue;
value = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
if (immediate) {
channel.postMessage(initialValue);
}
tryOnScopeDispose(() => channel.close());
return data;
}

View File

@@ -1,11 +1,9 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
external: ['vue'],
noExternal: [/^@robonen\//],
});