mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 19:04:46 +00:00
Compare commits
1 Commits
renovate/p
...
b80482426c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80482426c |
12
.github/workflows/ci.yaml
vendored
12
.github/workflows/ci.yaml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -31,11 +31,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
run: pnpm build && pnpm test
|
||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -13,12 +13,12 @@ jobs:
|
||||
name: Check version changes and publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# @robonen/oxlint
|
||||
|
||||
Composable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) configuration presets.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install -D @robonen/oxlint oxlint
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Create `oxlint.config.ts` in your project root:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(
|
||||
compose(base, typescript, vue, vitest, imports),
|
||||
);
|
||||
```
|
||||
|
||||
Append custom rules after presets to override them:
|
||||
|
||||
```ts
|
||||
compose(base, typescript, {
|
||||
rules: { 'eslint/no-console': 'off' },
|
||||
ignorePatterns: ['dist'],
|
||||
});
|
||||
```
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | Description |
|
||||
| ------------ | -------------------------------------------------- |
|
||||
| `base` | Core eslint, oxc, unicorn rules |
|
||||
| `typescript` | TypeScript-specific rules (via overrides) |
|
||||
| `vue` | Vue 3 Composition API / `<script setup>` rules |
|
||||
| `vitest` | Test file rules (via overrides) |
|
||||
| `imports` | Import rules (cycles, duplicates, ordering) |
|
||||
| `node` | Node.js-specific rules |
|
||||
|
||||
## API
|
||||
|
||||
### `compose(...configs: OxlintConfig[]): OxlintConfig`
|
||||
|
||||
Merges multiple configs into one:
|
||||
|
||||
- **plugins** — union (deduplicated)
|
||||
- **rules / categories** — last wins
|
||||
- **overrides / ignorePatterns** — concatenated
|
||||
- **env / globals** — shallow merge
|
||||
- **settings** — deep merge
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports));
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"name": "@robonen/oxlint",
|
||||
"version": "0.0.2",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Composable oxlint configuration presets",
|
||||
"keywords": [
|
||||
"oxlint",
|
||||
"oxc",
|
||||
"linter",
|
||||
"config",
|
||||
"presets"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "configs/oxlint"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint": ">=1.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { OxlintConfig } from './types';
|
||||
|
||||
/**
|
||||
* Deep merge two objects. Arrays are concatenated, objects are recursively merged.
|
||||
*/
|
||||
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key of Object.keys(source)) {
|
||||
const targetValue = target[key];
|
||||
const sourceValue = source[key];
|
||||
|
||||
if (
|
||||
typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)
|
||||
&& typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
else {
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose multiple oxlint configurations into a single config.
|
||||
*
|
||||
* - `plugins` — union (deduplicated)
|
||||
* - `categories` — later configs override earlier
|
||||
* - `rules` — later configs override earlier
|
||||
* - `overrides` — concatenated
|
||||
* - `env` — merged (later overrides earlier)
|
||||
* - `globals` — merged (later overrides earlier)
|
||||
* - `settings` — deep-merged
|
||||
* - `ignorePatterns` — concatenated
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { compose, base, typescript, vue } from '@robonen/oxlint';
|
||||
* import { defineConfig } from 'oxlint';
|
||||
*
|
||||
* export default defineConfig(
|
||||
* compose(base, typescript, vue, {
|
||||
* rules: { 'eslint/no-console': 'off' },
|
||||
* }),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function compose(...configs: OxlintConfig[]): OxlintConfig {
|
||||
const result: OxlintConfig = {};
|
||||
|
||||
for (const config of configs) {
|
||||
// Plugins — union with dedup
|
||||
if (config.plugins?.length) {
|
||||
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
|
||||
}
|
||||
|
||||
// Categories — shallow merge
|
||||
if (config.categories) {
|
||||
result.categories = { ...result.categories, ...config.categories };
|
||||
}
|
||||
|
||||
// Rules — shallow merge (later overrides earlier)
|
||||
if (config.rules) {
|
||||
result.rules = { ...result.rules, ...config.rules };
|
||||
}
|
||||
|
||||
// Overrides — concatenate
|
||||
if (config.overrides?.length) {
|
||||
result.overrides = [...(result.overrides ?? []), ...config.overrides];
|
||||
}
|
||||
|
||||
// Env — shallow merge
|
||||
if (config.env) {
|
||||
result.env = { ...result.env, ...config.env };
|
||||
}
|
||||
|
||||
// Globals — shallow merge
|
||||
if (config.globals) {
|
||||
result.globals = { ...result.globals, ...config.globals };
|
||||
}
|
||||
|
||||
// Settings — deep merge
|
||||
if (config.settings) {
|
||||
result.settings = deepMerge(
|
||||
(result.settings ?? {}) as Record<string, unknown>,
|
||||
config.settings as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
// Ignore patterns — concatenate
|
||||
if (config.ignorePatterns?.length) {
|
||||
result.ignorePatterns = [...(result.ignorePatterns ?? []), ...config.ignorePatterns];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/* Compose */
|
||||
export { compose } from './compose';
|
||||
|
||||
/* Presets */
|
||||
export { base, typescript, vue, vitest, imports, node } from './presets';
|
||||
|
||||
/* Types */
|
||||
export type {
|
||||
OxlintConfig,
|
||||
OxlintOverride,
|
||||
OxlintEnv,
|
||||
OxlintGlobals,
|
||||
AllowWarnDeny,
|
||||
DummyRule,
|
||||
DummyRuleMap,
|
||||
RuleCategories,
|
||||
} from './types';
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Base configuration for any JavaScript/TypeScript project.
|
||||
*
|
||||
* Enables `correctness` category and opinionated rules from
|
||||
* `eslint`, `oxc`, and `unicorn` plugins.
|
||||
*/
|
||||
export const base: OxlintConfig = {
|
||||
plugins: ['eslint', 'oxc', 'unicorn'],
|
||||
|
||||
categories: {
|
||||
correctness: 'error',
|
||||
},
|
||||
|
||||
rules: {
|
||||
/* ── eslint core ──────────────────────────────────────── */
|
||||
'eslint/eqeqeq': 'error',
|
||||
'eslint/no-console': 'warn',
|
||||
'eslint/no-debugger': 'error',
|
||||
'eslint/no-eval': 'error',
|
||||
'eslint/no-var': 'error',
|
||||
'eslint/prefer-const': 'error',
|
||||
'eslint/prefer-template': 'warn',
|
||||
'eslint/no-useless-constructor': 'warn',
|
||||
'eslint/no-useless-rename': 'warn',
|
||||
'eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'eslint/no-self-compare': 'error',
|
||||
'eslint/no-template-curly-in-string': 'warn',
|
||||
'eslint/no-throw-literal': 'error',
|
||||
'eslint/no-return-assign': 'warn',
|
||||
'eslint/no-else-return': 'warn',
|
||||
'eslint/no-lonely-if': 'warn',
|
||||
'eslint/no-unneeded-ternary': 'warn',
|
||||
'eslint/prefer-object-spread': 'warn',
|
||||
'eslint/prefer-exponentiation-operator': 'warn',
|
||||
'eslint/no-useless-computed-key': 'warn',
|
||||
'eslint/no-useless-concat': 'warn',
|
||||
'eslint/curly': 'off',
|
||||
|
||||
/* ── unicorn ──────────────────────────────────────────── */
|
||||
'unicorn/prefer-node-protocol': 'error',
|
||||
'unicorn/no-instanceof-array': 'error',
|
||||
'unicorn/no-new-array': 'error',
|
||||
'unicorn/prefer-array-flat-map': 'warn',
|
||||
'unicorn/prefer-array-flat': 'warn',
|
||||
'unicorn/prefer-includes': 'warn',
|
||||
'unicorn/prefer-string-slice': 'warn',
|
||||
'unicorn/prefer-string-starts-ends-with': 'warn',
|
||||
'unicorn/throw-new-error': 'error',
|
||||
'unicorn/error-message': 'warn',
|
||||
'unicorn/no-useless-spread': 'warn',
|
||||
'unicorn/no-useless-undefined': 'off',
|
||||
'unicorn/prefer-optional-catch-binding': 'warn',
|
||||
'unicorn/prefer-type-error': 'warn',
|
||||
'unicorn/no-thenable': 'error',
|
||||
'unicorn/prefer-number-properties': 'warn',
|
||||
'unicorn/prefer-global-this': 'warn',
|
||||
|
||||
/* ── oxc ──────────────────────────────────────────────── */
|
||||
'oxc/no-accumulating-spread': 'warn',
|
||||
'oxc/bad-comparison-sequence': 'error',
|
||||
'oxc/bad-min-max-func': 'error',
|
||||
'oxc/bad-object-literal-comparison': 'error',
|
||||
'oxc/const-comparisons': 'error',
|
||||
'oxc/double-comparisons': 'error',
|
||||
'oxc/erasing-op': 'error',
|
||||
'oxc/missing-throw': 'error',
|
||||
'oxc/bad-bitwise-operator': 'error',
|
||||
'oxc/bad-char-at-comparison': 'error',
|
||||
'oxc/bad-replace-all-arg': 'error',
|
||||
},
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Import plugin rules for clean module boundaries.
|
||||
*/
|
||||
export const imports: OxlintConfig = {
|
||||
plugins: ['import'],
|
||||
|
||||
rules: {
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-self-import': 'error',
|
||||
'import/no-cycle': 'warn',
|
||||
'import/first': 'warn',
|
||||
'import/no-mutable-exports': 'error',
|
||||
'import/no-amd': 'error',
|
||||
'import/no-commonjs': 'warn',
|
||||
'import/no-empty-named-blocks': 'warn',
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
|
||||
},
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export { base } from './base';
|
||||
export { typescript } from './typescript';
|
||||
export { vue } from './vue';
|
||||
export { vitest } from './vitest';
|
||||
export { imports } from './imports';
|
||||
export { node } from './node';
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Node.js-specific rules.
|
||||
*/
|
||||
export const node: OxlintConfig = {
|
||||
plugins: ['node'],
|
||||
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
|
||||
rules: {
|
||||
'node/no-exports-assign': 'error',
|
||||
'node/no-new-require': 'error',
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* TypeScript-specific rules.
|
||||
*
|
||||
* Applied via `overrides` for `*.ts`, `*.tsx`, `*.mts`, `*.cts` files.
|
||||
*/
|
||||
export const typescript: OxlintConfig = {
|
||||
plugins: ['typescript'],
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
|
||||
rules: {
|
||||
'typescript/consistent-type-imports': 'error',
|
||||
'typescript/no-explicit-any': 'off',
|
||||
'typescript/no-non-null-assertion': 'off',
|
||||
'typescript/prefer-as-const': 'error',
|
||||
'typescript/no-empty-object-type': 'warn',
|
||||
'typescript/no-wrapper-object-types': 'error',
|
||||
'typescript/no-duplicate-enum-values': 'error',
|
||||
'typescript/no-unsafe-declaration-merging': 'error',
|
||||
'typescript/no-import-type-side-effects': 'error',
|
||||
'typescript/no-useless-empty-export': 'warn',
|
||||
'typescript/no-inferrable-types': 'warn',
|
||||
'typescript/prefer-function-type': 'warn',
|
||||
'typescript/ban-tslint-comment': 'error',
|
||||
'typescript/consistent-type-definitions': ['warn', 'interface'],
|
||||
'typescript/prefer-for-of': 'warn',
|
||||
'typescript/no-unnecessary-type-constraint': 'warn',
|
||||
'typescript/adjacent-overload-signatures': 'warn',
|
||||
'typescript/array-type': ['warn', { default: 'array-simple' }],
|
||||
'typescript/no-this-alias': 'error',
|
||||
'typescript/triple-slash-reference': 'error',
|
||||
'typescript/no-namespace': 'error',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Vitest rules for test files.
|
||||
*
|
||||
* Applied via `overrides` for common test file patterns.
|
||||
*/
|
||||
export const vitest: OxlintConfig = {
|
||||
plugins: ['vitest'],
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/*.test.{ts,tsx,js,jsx}',
|
||||
'**/*.spec.{ts,tsx,js,jsx}',
|
||||
'**/test/**/*.{ts,tsx,js,jsx}',
|
||||
'**/__tests__/**/*.{ts,tsx,js,jsx}',
|
||||
],
|
||||
rules: {
|
||||
'vitest/no-conditional-tests': 'warn',
|
||||
'vitest/no-import-node-test': 'error',
|
||||
'vitest/prefer-to-be-truthy': 'warn',
|
||||
'vitest/prefer-to-be-falsy': 'warn',
|
||||
'vitest/prefer-to-be-object': 'warn',
|
||||
'vitest/prefer-to-have-length': 'warn',
|
||||
'vitest/consistent-test-filename': 'warn',
|
||||
'vitest/prefer-describe-function-title': 'warn',
|
||||
|
||||
/* relax strict rules in tests */
|
||||
'eslint/no-unused-vars': 'off',
|
||||
'typescript/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { OxlintConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Vue.js-specific rules.
|
||||
*
|
||||
* Enforces Composition API with `<script setup>` and type-based declarations.
|
||||
*/
|
||||
export const vue: OxlintConfig = {
|
||||
plugins: ['vue'],
|
||||
|
||||
rules: {
|
||||
'vue/no-arrow-functions-in-watch': 'error',
|
||||
'vue/no-deprecated-destroyed-lifecycle': 'error',
|
||||
'vue/no-export-in-script-setup': 'error',
|
||||
'vue/no-lifecycle-after-await': 'error',
|
||||
'vue/no-multiple-slot-args': 'error',
|
||||
'vue/no-import-compiler-macros': 'error',
|
||||
'vue/define-emits-declaration': ['error', 'type-based'],
|
||||
'vue/define-props-declaration': ['error', 'type-based'],
|
||||
'vue/prefer-import-from-vue': 'error',
|
||||
'vue/no-required-prop-with-default': 'warn',
|
||||
'vue/valid-define-emits': 'error',
|
||||
'vue/valid-define-props': 'error',
|
||||
'vue/require-typed-ref': 'warn',
|
||||
},
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Re-exported configuration types from `oxlint`.
|
||||
*
|
||||
* Keeps the preset API in sync with the oxlint CLI without
|
||||
* maintaining a separate copy of the types.
|
||||
*
|
||||
* @see https://oxc.rs/docs/guide/usage/linter/config-file-reference.html
|
||||
*/
|
||||
export type {
|
||||
OxlintConfig,
|
||||
OxlintOverride,
|
||||
OxlintEnv,
|
||||
OxlintGlobals,
|
||||
AllowWarnDeny,
|
||||
DummyRule,
|
||||
DummyRuleMap,
|
||||
RuleCategories,
|
||||
} from 'oxlint';
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compose } from '../src/compose';
|
||||
import type { OxlintConfig } from '../src/types';
|
||||
|
||||
describe('compose', () => {
|
||||
it('should return empty config when no configs provided', () => {
|
||||
expect(compose()).toEqual({});
|
||||
});
|
||||
|
||||
it('should return the same config when one config provided', () => {
|
||||
const config: OxlintConfig = {
|
||||
plugins: ['eslint'],
|
||||
rules: { 'eslint/no-console': 'warn' },
|
||||
};
|
||||
const result = compose(config);
|
||||
expect(result.plugins).toEqual(['eslint']);
|
||||
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
|
||||
});
|
||||
|
||||
it('should merge plugins with dedup', () => {
|
||||
const a: OxlintConfig = { plugins: ['eslint', 'oxc'] };
|
||||
const b: OxlintConfig = { plugins: ['oxc', 'typescript'] };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
|
||||
});
|
||||
|
||||
it('should override rules from later configs', () => {
|
||||
const a: OxlintConfig = { rules: { 'eslint/no-console': 'error', 'eslint/eqeqeq': 'warn' } };
|
||||
const b: OxlintConfig = { rules: { 'eslint/no-console': 'off' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.rules).toEqual({
|
||||
'eslint/no-console': 'off',
|
||||
'eslint/eqeqeq': 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('should override categories from later configs', () => {
|
||||
const a: OxlintConfig = { categories: { correctness: 'error', suspicious: 'warn' } };
|
||||
const b: OxlintConfig = { categories: { suspicious: 'off' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.categories).toEqual({
|
||||
correctness: 'error',
|
||||
suspicious: 'off',
|
||||
});
|
||||
});
|
||||
|
||||
it('should concatenate overrides', () => {
|
||||
const a: OxlintConfig = {
|
||||
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
|
||||
};
|
||||
const b: OxlintConfig = {
|
||||
overrides: [{ files: ['**/*.test.ts'], rules: { 'eslint/no-unused-vars': 'off' } }],
|
||||
};
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.overrides).toHaveLength(2);
|
||||
expect(result.overrides?.[0]?.files).toEqual(['**/*.ts']);
|
||||
expect(result.overrides?.[1]?.files).toEqual(['**/*.test.ts']);
|
||||
});
|
||||
|
||||
it('should merge env', () => {
|
||||
const a: OxlintConfig = { env: { browser: true } };
|
||||
const b: OxlintConfig = { env: { node: true } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.env).toEqual({ browser: true, node: true });
|
||||
});
|
||||
|
||||
it('should merge globals', () => {
|
||||
const a: OxlintConfig = { globals: { MY_VAR: 'readonly' } };
|
||||
const b: OxlintConfig = { globals: { ANOTHER: 'writable' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.globals).toEqual({ MY_VAR: 'readonly', ANOTHER: 'writable' });
|
||||
});
|
||||
|
||||
it('should deep merge settings', () => {
|
||||
const a: OxlintConfig = {
|
||||
settings: {
|
||||
react: { version: '18.2.0' },
|
||||
next: { rootDir: 'apps/' },
|
||||
},
|
||||
};
|
||||
const b: OxlintConfig = {
|
||||
settings: {
|
||||
react: { linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }] },
|
||||
},
|
||||
};
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.settings).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }],
|
||||
},
|
||||
next: { rootDir: 'apps/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should concatenate ignorePatterns', () => {
|
||||
const a: OxlintConfig = { ignorePatterns: ['dist'] };
|
||||
const b: OxlintConfig = { ignorePatterns: ['node_modules', 'coverage'] };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.ignorePatterns).toEqual(['dist', 'node_modules', 'coverage']);
|
||||
});
|
||||
|
||||
it('should handle composing all presets together', () => {
|
||||
const base: OxlintConfig = {
|
||||
plugins: ['eslint', 'oxc'],
|
||||
categories: { correctness: 'error' },
|
||||
rules: { 'eslint/no-console': 'warn' },
|
||||
};
|
||||
const ts: OxlintConfig = {
|
||||
plugins: ['typescript'],
|
||||
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
|
||||
};
|
||||
const custom: OxlintConfig = {
|
||||
rules: { 'eslint/no-console': 'off' },
|
||||
ignorePatterns: ['dist'],
|
||||
};
|
||||
|
||||
const result = compose(base, ts, custom);
|
||||
|
||||
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
|
||||
expect(result.categories).toEqual({ correctness: 'error' });
|
||||
expect(result.rules).toEqual({ 'eslint/no-console': 'off' });
|
||||
expect(result.overrides).toHaveLength(1);
|
||||
expect(result.ignorePatterns).toEqual(['dist']);
|
||||
});
|
||||
|
||||
it('should skip undefined/empty fields', () => {
|
||||
const a: OxlintConfig = { plugins: ['eslint'] };
|
||||
const b: OxlintConfig = { rules: { 'eslint/no-console': 'warn' } };
|
||||
|
||||
const result = compose(a, b);
|
||||
expect(result.plugins).toEqual(['eslint']);
|
||||
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
|
||||
expect(result.overrides).toBeUndefined();
|
||||
expect(result.env).toBeUndefined();
|
||||
expect(result.settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,27 +1,45 @@
|
||||
# @robonen/tsconfig
|
||||
|
||||
Shared base TypeScript configuration.
|
||||
Базовый конфигурационный файл для TypeScript
|
||||
|
||||
## Install
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
pnpm install -D @robonen/tsconfig
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Extend from it in your `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
```
|
||||
|
||||
## What's Included
|
||||
## Описание основных параметров
|
||||
|
||||
- **Target / Module**: ESNext with Bundler resolution
|
||||
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
|
||||
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
|
||||
- **Declarations**: `declaration` enabled
|
||||
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`
|
||||
```json
|
||||
{
|
||||
"module": "Preserve", // использовать ту же версию модуля, что и сборщик
|
||||
"noEmit": true, // не генерировать файлы
|
||||
"moduleResolution": "Bundler", // разрешение модулей на основе сборщика
|
||||
"target": "ESNext", // целевая версия JavaScript
|
||||
|
||||
|
||||
"skipLibCheck": true, // не проверять типы, заданные во всех файлах описания типов (*.d.ts)
|
||||
"esModuleInterop": true, // создать хелперы __importStar и __importDefault для обеспечения совместимости с экосистемой Babel и включить allowSyntheticDefaultImports для совместимости с системой типов
|
||||
"allowSyntheticDefaultImports": true, // разрешить импортировать модули не имеющие внутри себя "import default"
|
||||
"allowJs": true, // разрешить импортировать файлы JavaScript
|
||||
"resolveJsonModule": true, // разрешить импортировать файлы JSON
|
||||
"moduleDetection": "force", // заставляет TypeScript рассматривать все файлы как модули. Это помогает избежать ошибок cannot redeclare block-scoped variable»
|
||||
"isolatedModules": true, // орабатывать каждый файл, как отдельный изолированный модуль
|
||||
"removeComments": false, // удалять комментарии из исходного кода
|
||||
"verbatimModuleSyntax": true, // сохранять синтаксис модулей в исходном коде (важно при импорте типов)
|
||||
"useDefineForClassFields": true, // использование классов стандарта TC39, а не TypeScript
|
||||
"strict": true, // включить все строгие проверки (noImplicitAny, noImplicitThis, alwaysStrict, strictNullChecks, strictFunctionTypes, strictPropertyInitialization)
|
||||
"noUncheckedIndexedAccess": true, // запрещает доступ к массиву или объекту без предварительной проверки того, определен ли он
|
||||
"declaration": true, // генерировать файлы описания типов (*.d.ts)
|
||||
|
||||
"composite": true, // указывает TypeScript создавать файлы .tsbuildinfo. Это сообщает TypeScript, что ваш проект является частью монорепозитория, а также помогает кэшировать сборки для более быстрой работы
|
||||
"sourceMap": true, // генерировать карту исходного кода
|
||||
"declarationMap": true // генерировать карту исходного кода для файлов описания типов (*.d.ts)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/tsconfig"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.1"
|
||||
},
|
||||
"files": [
|
||||
"**tsconfig.json"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
@@ -1,23 +1 @@
|
||||
# @robonen/platform
|
||||
|
||||
Platform-dependent utilities for browser & multi-runtime environments.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/platform
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Entry | Utilities | Description |
|
||||
| ------------------ | ------------- | -------------------------------- |
|
||||
| `@robonen/platform/browsers` | `focusGuard` | Browser-specific helpers |
|
||||
| `@robonen/platform/multi` | `global` | Cross-runtime (Node/Bun/Deno) utilities |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { focusGuard } from '@robonen/platform/browsers';
|
||||
import { global } from '@robonen/platform/multi';
|
||||
```
|
||||
16
core/platform/build.config.ts
Normal file
16
core/platform/build.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'src/browsers',
|
||||
'src/multi',
|
||||
],
|
||||
clean: true,
|
||||
declaration: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(
|
||||
compose(base, typescript, imports, {
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/multi/global/index.ts'],
|
||||
rules: {
|
||||
'unicorn/prefer-global-this': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/platform",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.3",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Platform dependent utilities for javascript development",
|
||||
"keywords": [
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/platform"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -29,26 +29,22 @@
|
||||
"exports": {
|
||||
"./browsers": {
|
||||
"types": "./dist/browsers.d.ts",
|
||||
"import": "./dist/browsers.js",
|
||||
"import": "./dist/browsers.mjs",
|
||||
"require": "./dist/browsers.cjs"
|
||||
},
|
||||
"./multi": {
|
||||
"types": "./dist/multi.d.ts",
|
||||
"import": "./dist/multi.js",
|
||||
"import": "./dist/multi.mjs",
|
||||
"require": "./dist/multi.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export function focusGuard(namespace = 'focus-guard') {
|
||||
export function focusGuard(namespace: string = 'focus-guard') {
|
||||
const guardAttr = `data-${namespace}`;
|
||||
|
||||
const createGuard = () => {
|
||||
@@ -39,7 +39,7 @@ export function focusGuard(namespace = 'focus-guard') {
|
||||
};
|
||||
}
|
||||
|
||||
export function createGuardAttrs(namespace = 'focus-guard') {
|
||||
export function createGuardAttrs(namespace: string) {
|
||||
const element = document.createElement('span');
|
||||
|
||||
element.setAttribute(namespace, '');
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// eslint-disable
|
||||
|
||||
export interface DebounceOptions {
|
||||
/**
|
||||
* Call the function on the leading edge of the timeout, instead of waiting for the trailing edge
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: {
|
||||
browsers: 'src/browsers/index.ts',
|
||||
multi: 'src/multi/index.ts',
|
||||
},
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,32 +1 @@
|
||||
# @robonen/stdlib
|
||||
|
||||
Standard library of platform-independent utilities for TypeScript.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/stdlib
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Utilities |
|
||||
| --------------- | --------------------------------------------------------------- |
|
||||
| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` |
|
||||
| **async** | `sleep`, `tryIt` |
|
||||
| **bits** | `flags` |
|
||||
| **collections** | `get` |
|
||||
| **math** | `clamp`, `lerp`, `remap` + BigInt variants |
|
||||
| **objects** | `omit`, `pick` |
|
||||
| **patterns** | `pubsub` |
|
||||
| **structs** | `stack` |
|
||||
| **sync** | `mutex` |
|
||||
| **text** | `levenshteinDistance`, `trigramDistance` |
|
||||
| **types** | JS & TS type utilities |
|
||||
| **utils** | `timestamp`, `noop` |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { first, sleep, clamp } from '@robonen/stdlib';
|
||||
```
|
||||
9
core/stdlib/build.config.ts
Normal file
9
core/stdlib/build.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
rollup: {
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/stdlib",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.7",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A collection of tools, utilities, and helpers for TypeScript",
|
||||
"keywords": [
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/stdlib"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -29,21 +29,18 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint -c oxlint.config.ts",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
"pathe": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface AsyncPoolOptions {
|
||||
export type AsyncPoolOptions = {
|
||||
concurrency?: number;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// eslint-disable
|
||||
export interface RetryOptions {
|
||||
times?: number;
|
||||
delay?: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface BitVectorLike {
|
||||
export interface BitVector {
|
||||
getBit(index: number): boolean;
|
||||
setBit(index: number): void;
|
||||
clearBit(index: number): void;
|
||||
@@ -12,7 +12,7 @@ export interface BitVectorLike {
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export class BitVector extends Uint8Array implements BitVectorLike {
|
||||
export class BitVector extends Uint8Array implements BitVector {
|
||||
constructor(size: number) {
|
||||
super(Math.ceil(size / 8));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Collection, Path } from '../../types';
|
||||
import { type Collection, type Path } from '../../types';
|
||||
|
||||
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
|
||||
K extends keyof O
|
||||
@@ -9,7 +9,7 @@ export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
|
||||
|
||||
export type ExtractFromArray<A extends readonly any[], K> =
|
||||
any[] extends A
|
||||
? A extends ReadonlyArray<infer T>
|
||||
? A extends readonly (infer T)[]
|
||||
? T | undefined
|
||||
: undefined
|
||||
: K extends keyof A
|
||||
|
||||
@@ -46,13 +46,13 @@ describe('clamp', () => {
|
||||
|
||||
it('handle NaN and Infinity', () => {
|
||||
// value is NaN
|
||||
expect(clamp(Number.NaN, 0, 100)).toBe(Number.NaN);
|
||||
expect(clamp(NaN, 0, 100)).toBe(NaN);
|
||||
|
||||
// min is NaN
|
||||
expect(clamp(50, Number.NaN, 100)).toBe(Number.NaN);
|
||||
expect(clamp(50, NaN, 100)).toBe(NaN);
|
||||
|
||||
// max is NaN
|
||||
expect(clamp(50, 0, Number.NaN)).toBe(Number.NaN);
|
||||
expect(clamp(50, 0, NaN)).toBe(NaN);
|
||||
|
||||
// value is Infinity
|
||||
expect(clamp(Infinity, 0, 100)).toBe(100);
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -18,7 +11,7 @@ const SCALE_N = BigInt(SCALE);
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export function lerpBigInt(start: bigint, end: bigint, t: number) {
|
||||
return start + ((end - start) * BigInt(Math.round(t * SCALE))) / SCALE_N;
|
||||
return start + ((end - start) * BigInt(t * 10000)) / 10000n;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,5 +27,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) * SCALE_N / (end - start)) / SCALE;
|
||||
return start === end ? 0 : Number((value - start) * 10000n / (end - start)) / 10000;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isArray } from '../../types';
|
||||
import type { Arrayable } from '../../types';
|
||||
import { isArray, type Arrayable } from '../../types';
|
||||
|
||||
/**
|
||||
* @name omit
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isArray } from '../../types';
|
||||
import type { Arrayable } from '../../types';
|
||||
import { isArray, type Arrayable } from '../../types';
|
||||
|
||||
/**
|
||||
* @name pick
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export type * from './types';
|
||||
export * from './sync';
|
||||
export * from './async';
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { MaybePromise } from '../../../types';
|
||||
|
||||
export interface Command {
|
||||
execute: () => void;
|
||||
undo: () => void;
|
||||
}
|
||||
|
||||
export interface AsyncCommand {
|
||||
execute: () => MaybePromise<void>;
|
||||
undo: () => MaybePromise<void>;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export type * from './types';
|
||||
export * from './sync';
|
||||
export * from './async';
|
||||
@@ -1,134 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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];
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { AnyFunction } from '../../../types';
|
||||
|
||||
export type Subscriber = AnyFunction;
|
||||
|
||||
export type EventHandlerMap = Record<PropertyKey, Subscriber>;
|
||||
|
||||
/**
|
||||
* @name PubSub
|
||||
* @category Patterns
|
||||
@@ -5,9 +11,9 @@
|
||||
*
|
||||
* @since 0.0.2
|
||||
*
|
||||
* @template Events - Event map where keys are event names and values are listener signatures
|
||||
* @template Events - Event map where all values are function types
|
||||
*/
|
||||
export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
|
||||
export class PubSub<Events extends EventHandlerMap> {
|
||||
/**
|
||||
* Events map
|
||||
*
|
||||
@@ -1,3 +1 @@
|
||||
export * from './behavioral/Command';
|
||||
export * from './behavioral/PubSub';
|
||||
export * from './behavioral/StateMachine';
|
||||
export * from './behavioral/pubsub';
|
||||
@@ -1,229 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
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--;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 };
|
||||
@@ -1,207 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1 @@
|
||||
export * from './BinaryHeap';
|
||||
export * from './CircularBuffer';
|
||||
export * from './Deque';
|
||||
export * from './LinkedList';
|
||||
export * from './PriorityQueue';
|
||||
export * from './Queue';
|
||||
export * from './Stack';
|
||||
export * from './stack';
|
||||
@@ -1,12 +1,9 @@
|
||||
import { last } from '../../arrays';
|
||||
import { isArray } from '../../types';
|
||||
import type { StackLike } from './types';
|
||||
|
||||
export type { StackLike } from './types';
|
||||
|
||||
export interface StackOptions {
|
||||
export type StackOptions = {
|
||||
maxSize?: number;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @name Stack
|
||||
@@ -17,7 +14,7 @@ export interface StackOptions {
|
||||
*
|
||||
* @template T The type of elements stored in the stack
|
||||
*/
|
||||
export class Stack<T> implements StackLike<T> {
|
||||
export class Stack<T> implements Iterable<T>, AsyncIterable<T> {
|
||||
/**
|
||||
* The maximum number of elements that the stack can hold
|
||||
*
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MaybePromise } from "../../types";
|
||||
|
||||
/**
|
||||
* @name SyncMutex
|
||||
* @category Utils
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { get } from '../../collections';
|
||||
import { isFunction } from '../../types';
|
||||
import type { Collection, Path, PathToType, Stringable, Trim, UnionToIntersection } from '../../types';
|
||||
import { isFunction, type Path, type PathToType, type Stringable, type Trim, type UnionToIntersection } from '../../types';
|
||||
|
||||
/**
|
||||
* Type of a value that will be used to replace a placeholder in a template.
|
||||
@@ -56,7 +55,7 @@ export type GenerateTypes<T extends string, Target = string> = UnionToIntersecti
|
||||
|
||||
export function templateObject<
|
||||
T extends string,
|
||||
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection
|
||||
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue>
|
||||
>(template: T, args: A, fallback?: TemplateFallback) {
|
||||
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
|
||||
const value = get(args, key)?.toString();
|
||||
|
||||
@@ -11,7 +11,7 @@ export type Trigrams = Map<string, number>;
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function trigramProfile(text: string): Trigrams {
|
||||
text = `\n\n${text}\n\n`;
|
||||
text = '\n\n' + text + '\n\n';
|
||||
|
||||
const trigrams = new Map<string, number>();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('casts', () => {
|
||||
expect(toString(null)).toBe('[object Null]');
|
||||
expect(toString(/abc/)).toBe('[object RegExp]');
|
||||
expect(toString(new Date())).toBe('[object Date]');
|
||||
expect(toString(new Error('test'))).toBe('[object Error]');
|
||||
expect(toString(new Error())).toBe('[object Error]');
|
||||
expect(toString(new Promise(() => {}))).toBe('[object Promise]');
|
||||
expect(toString(new Map())).toBe('[object Map]');
|
||||
expect(toString(new Set())).toBe('[object Set]');
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('complex', () => {
|
||||
|
||||
describe('isError', () => {
|
||||
it('true if the value is an error', () => {
|
||||
expect(isError(new Error('test'))).toBe(true);
|
||||
expect(isError(new Error())).toBe(true);
|
||||
});
|
||||
|
||||
it('false if the value is not an error', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString } from './casts';
|
||||
import { toString } from '.';
|
||||
|
||||
/**
|
||||
* @name isFunction
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString } from './casts';
|
||||
import { toString } from '.';
|
||||
|
||||
/**
|
||||
* @name isObject
|
||||
|
||||
@@ -35,35 +35,35 @@ describe('collections', () => {
|
||||
describe('PathToType', () => {
|
||||
it('convert simple object path', () => {
|
||||
type actual = PathToType<['user', 'name']>;
|
||||
interface expected { user: { name: unknown } }
|
||||
type expected = { user: { name: unknown } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert simple array path', () => {
|
||||
type actual = PathToType<['user', '0']>;
|
||||
interface expected { user: unknown[] }
|
||||
type expected = { user: unknown[] };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert complex object path', () => {
|
||||
type actual = PathToType<['user', 'addresses', '0', 'street']>;
|
||||
interface expected { user: { addresses: Array<{ street: unknown }> } }
|
||||
type expected = { user: { addresses: { street: unknown }[] } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert double dot path', () => {
|
||||
type actual = PathToType<['user', '', 'name']>;
|
||||
interface expected { user: { '': { name: unknown } } }
|
||||
type expected = { user: { '': { name: unknown } } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
it('convert to custom target', () => {
|
||||
type actual = PathToType<['user', 'name'], string>;
|
||||
interface expected { user: { name: string } }
|
||||
type expected = { user: { name: string } };
|
||||
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export type PathToType<T extends string[], Target = unknown> =
|
||||
T extends [infer Head, ...infer Rest]
|
||||
? Head extends `${number}`
|
||||
? Rest extends string[]
|
||||
? Array<PathToType<Rest, Target>>
|
||||
? PathToType<Rest, Target>[]
|
||||
: never
|
||||
: Rest extends string[]
|
||||
? { [K in Head & string]: PathToType<Rest, Target> }
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('string', () => {
|
||||
expectTypeOf(Number(1)).toExtend<Stringable>();
|
||||
expectTypeOf(String(1)).toExtend<Stringable>();
|
||||
expectTypeOf(Symbol()).toExtend<Stringable>();
|
||||
expectTypeOf([1]).toExtend<Stringable>();
|
||||
expectTypeOf(new Array(1)).toExtend<Stringable>();
|
||||
expectTypeOf(new Object()).toExtend<Stringable>();
|
||||
expectTypeOf(new Date()).toExtend<Stringable>();
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,21 +1 @@
|
||||
# @robonen/renovate
|
||||
|
||||
Shared [Renovate](https://docs.renovatebot.com/) configuration preset.
|
||||
|
||||
## Usage
|
||||
|
||||
Reference it in your `renovate.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>robonen/tools//infra/renovate/default.json"]
|
||||
}
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
- Extends `config:base` and `group:allNonMajor`
|
||||
- Semantic commit type: `chore`
|
||||
- Range strategy: `bump`
|
||||
- Auto-approves & auto-merges minor, patch, pin, and digest updates (scheduled 1–3 AM)
|
||||
@@ -16,9 +16,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/renovate"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.1"
|
||||
},
|
||||
"files": [
|
||||
"default.json"
|
||||
@@ -27,6 +27,6 @@
|
||||
"test": "renovate-config-validator ./default.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"renovate": "^43.12.0"
|
||||
"renovate": "^41.173.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.1"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"citty": "^0.2.1",
|
||||
"citty": "^0.1.6",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "catalog:",
|
||||
"scule": "^1.3.0",
|
||||
@@ -32,7 +32,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"create": "jiti ./bin/cli.ts"
|
||||
|
||||
5749
pnpm-lock.yaml
generated
5749
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,13 @@ packages:
|
||||
- core/*
|
||||
- infra/*
|
||||
- web/*
|
||||
- docs
|
||||
|
||||
catalog:
|
||||
'@vitest/coverage-v8': ^4.0.18
|
||||
'@vitest/coverage-v8': ^4.0.14
|
||||
'@vue/test-utils': ^2.4.6
|
||||
jsdom: ^28.0.0
|
||||
oxlint: ^1.2.0
|
||||
tsdown: ^0.12.5
|
||||
vitest: ^4.0.18
|
||||
'@vitest/ui': ^4.0.18
|
||||
vue: ^3.5.28
|
||||
nuxt: ^4.3.1
|
||||
jsdom: ^27.2.0
|
||||
pathe: ^2.0.3
|
||||
unbuild: 3.6.1
|
||||
vitest: ^4.0.14
|
||||
'@vitest/ui': ^4.0.14
|
||||
vue: ^3.5.25
|
||||
|
||||
@@ -3,11 +3,16 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'configs/oxlint/vitest.config.ts',
|
||||
'core/stdlib/vitest.config.ts',
|
||||
'core/platform/vitest.config.ts',
|
||||
'web/vue/vitest.config.ts',
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
typecheck: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
environment: 'jsdom',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['core/*', 'web/*'],
|
||||
|
||||
@@ -1,28 +1 @@
|
||||
# @robonen/vue
|
||||
|
||||
Collection of composables and utilities for Vue 3.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/vue
|
||||
```
|
||||
|
||||
## Composables
|
||||
|
||||
| Category | Composables |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
|
||||
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
|
||||
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
||||
| **math** | `useClamp` |
|
||||
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
|
||||
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
|
||||
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
|
||||
| **utilities** | `useOffsetPagination` |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { useToggle, useEventListener } from '@robonen/vue';
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user