mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Compare commits
1 Commits
copilot/ad
...
023544cbb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
023544cbb0 |
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -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
|
||||
2
.github/workflows/publish.yaml
vendored
2
.github/workflows/publish.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Check version changes and publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -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.20.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"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';
|
||||
```
|
||||
# @robonen/platform
|
||||
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.20.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"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';
|
||||
```
|
||||
# @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.20.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"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,69 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { cancellablePromise, CancelledError } from '.';
|
||||
|
||||
describe('cancellablePromise', () => {
|
||||
it('resolve the promise normally when not cancelled', async () => {
|
||||
const { promise } = cancellablePromise(Promise.resolve('data'));
|
||||
|
||||
await expect(promise).resolves.toBe('data');
|
||||
});
|
||||
|
||||
it('reject the promise normally when not cancelled', async () => {
|
||||
const error = new Error('test-error');
|
||||
const { promise } = cancellablePromise(Promise.reject(error));
|
||||
|
||||
await expect(promise).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('reject with CancelledError when cancelled before resolve', async () => {
|
||||
const { promise, cancel } = cancellablePromise(
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
|
||||
);
|
||||
|
||||
cancel();
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(CancelledError);
|
||||
await expect(promise).rejects.toThrow('Promise was cancelled');
|
||||
});
|
||||
|
||||
it('reject with CancelledError with custom reason', async () => {
|
||||
const { promise, cancel } = cancellablePromise(
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
|
||||
);
|
||||
|
||||
cancel('Request aborted');
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(CancelledError);
|
||||
await expect(promise).rejects.toThrow('Request aborted');
|
||||
});
|
||||
|
||||
it('cancel prevents then callback from being called', async () => {
|
||||
const onFulfilled = vi.fn();
|
||||
|
||||
const { promise, cancel } = cancellablePromise(
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
|
||||
);
|
||||
|
||||
const chained = promise.then(onFulfilled).catch(() => {});
|
||||
|
||||
cancel();
|
||||
|
||||
await chained;
|
||||
|
||||
expect(onFulfilled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CancelledError has correct name property', () => {
|
||||
const error = new CancelledError();
|
||||
|
||||
expect(error.name).toBe('CancelledError');
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toBe('Promise was cancelled');
|
||||
});
|
||||
|
||||
it('CancelledError accepts custom message', () => {
|
||||
const error = new CancelledError('Custom reason');
|
||||
|
||||
expect(error.message).toBe('Custom reason');
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
export class CancelledError extends Error {
|
||||
constructor(reason?: string) {
|
||||
super(reason ?? 'Promise was cancelled');
|
||||
this.name = 'CancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CancellablePromise<T> {
|
||||
promise: Promise<T>;
|
||||
cancel: (reason?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name cancellablePromise
|
||||
* @category Async
|
||||
* @description Wraps a promise with a cancel capability, allowing the promise to be rejected with a CancelledError
|
||||
*
|
||||
* @param {Promise<T>} promise - The promise to make cancellable
|
||||
* @returns {CancellablePromise<T>} - An object with the wrapped promise and a cancel function
|
||||
*
|
||||
* @example
|
||||
* const { promise, cancel } = cancellablePromise(fetch('/api/data'));
|
||||
* cancel(); // Rejects with CancelledError
|
||||
*
|
||||
* @example
|
||||
* const { promise, cancel } = cancellablePromise(longRunningTask());
|
||||
* setTimeout(() => cancel('Timeout'), 5000);
|
||||
* const [error] = await tryIt(() => promise)();
|
||||
*
|
||||
* @since 0.0.10
|
||||
*/
|
||||
export function cancellablePromise<T>(promise: Promise<T>): CancellablePromise<T> {
|
||||
let rejectPromise: (reason: CancelledError) => void;
|
||||
|
||||
const wrappedPromise = new Promise<T>((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
|
||||
promise.then(resolve, reject);
|
||||
});
|
||||
|
||||
const cancel = (reason?: string) => {
|
||||
rejectPromise(new CancelledError(reason));
|
||||
};
|
||||
|
||||
return {
|
||||
promise: wrappedPromise,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './cancellablePromise';
|
||||
export * from './sleep';
|
||||
export * from './tryIt';
|
||||
|
||||
@@ -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)
|
||||
# @robonen/renovate
|
||||
@@ -16,9 +16,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/renovate"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"files": [
|
||||
"default.json"
|
||||
@@ -27,6 +27,6 @@
|
||||
"test": "renovate-config-validator ./default.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"renovate": "^43.12.0"
|
||||
"renovate": "^42.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.0",
|
||||
"@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"
|
||||
|
||||
6041
pnpm-lock.yaml
generated
6041
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user