mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Compare commits
6 Commits
copilot/ad
...
5fa38110b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fa38110b7 | |||
| 4574bae0b6 | |||
| a996eb74b9 | |||
| bcc9cb2915 | |||
| 41d5e18f6b | |||
| abd6605db3 |
409
.github/skills/monorepo/SKILL.md
vendored
Normal file
409
.github/skills/monorepo/SKILL.md
vendored
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
---
|
||||||
|
name: monorepo
|
||||||
|
description: "Manage the @robonen/tools monorepo. Use when: installing dependencies, creating new packages, linting, building, testing, publishing, or scaffolding workspace packages. Covers pnpm catalogs, tsdown builds, oxlint presets, vitest projects, JSR/NPM publishing."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Monorepo Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is a pnpm workspace monorepo (`@robonen/tools`) with shared configs, strict dependency management via catalogs, and automated publishing.
|
||||||
|
|
||||||
|
## Workspace Layout
|
||||||
|
|
||||||
|
| Directory | Purpose | Examples |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| `configs/*` | Shared tooling configs | `oxlint`, `tsconfig`, `tsdown` |
|
||||||
|
| `core/*` | Platform-agnostic TS libraries | `stdlib`, `platform`, `encoding` |
|
||||||
|
| `vue/*` | Vue 3 packages | `primitives`, `toolkit` |
|
||||||
|
| `docs` | Nuxt 4 documentation site | — |
|
||||||
|
| `infra/*` | Infrastructure configs | `renovate` |
|
||||||
|
|
||||||
|
## Installing Dependencies
|
||||||
|
|
||||||
|
**Always use pnpm. Never use npm or yarn.**
|
||||||
|
|
||||||
|
### Add a dependency to a specific package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Runtime dependency
|
||||||
|
pnpm -C <package-path> add <dep-name>
|
||||||
|
|
||||||
|
# Dev dependency
|
||||||
|
pnpm -C <package-path> add -D <dep-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```bash
|
||||||
|
pnpm -C core/stdlib add -D oxlint
|
||||||
|
pnpm -C vue/primitives add vue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use catalogs for shared versions
|
||||||
|
|
||||||
|
Versions shared across multiple packages MUST use the pnpm catalog system. The catalog is defined in `pnpm-workspace.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
catalog:
|
||||||
|
vitest: ^4.0.18
|
||||||
|
tsdown: ^0.21.0
|
||||||
|
oxlint: ^1.2.0
|
||||||
|
vue: ^3.5.28
|
||||||
|
# ... etc
|
||||||
|
```
|
||||||
|
|
||||||
|
In `package.json`, reference catalog versions with the `catalog:` protocol:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "catalog:",
|
||||||
|
"oxlint": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to add to catalog:** If the dependency is used in 2+ packages, add it to `pnpm-workspace.yaml` under `catalog:` and use `catalog:` in each `package.json`.
|
||||||
|
|
||||||
|
**When NOT to use catalog:** Package-specific dependencies used in only one package (e.g., `citty` in root).
|
||||||
|
|
||||||
|
### Internal workspace dependencies
|
||||||
|
|
||||||
|
Reference sibling packages with the workspace protocol:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@robonen/oxlint": "workspace:*",
|
||||||
|
"@robonen/tsconfig": "workspace:*",
|
||||||
|
"@robonen/tsdown": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nearly every package depends on these three shared config packages.
|
||||||
|
|
||||||
|
### After installing
|
||||||
|
|
||||||
|
Always run `pnpm install` at the root after editing `pnpm-workspace.yaml` or any `package.json` manually.
|
||||||
|
|
||||||
|
## Creating a New Package
|
||||||
|
|
||||||
|
> **Note:** The existing `bin/cli.ts` (`pnpm create`) is outdated — it generates Vite configs instead of tsdown and lacks oxlint/vitest setup. Follow the manual steps below instead.
|
||||||
|
|
||||||
|
### 1. Create the directory
|
||||||
|
|
||||||
|
Choose the correct parent based on package type:
|
||||||
|
- `core/<name>` — Platform-agnostic TypeScript library
|
||||||
|
- `vue/<name>` — Vue 3 library (needs jsdom, vue deps)
|
||||||
|
- `configs/<name>` — Shared configuration package
|
||||||
|
|
||||||
|
### 2. Create `package.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@robonen/<name>",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"description": "",
|
||||||
|
"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:check": "oxlint -c oxlint.config.ts",
|
||||||
|
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "vitest dev",
|
||||||
|
"build": "tsdown"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@robonen/oxlint": "workspace:*",
|
||||||
|
"@robonen/tsconfig": "workspace:*",
|
||||||
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
|
"oxlint": "catalog:",
|
||||||
|
"tsdown": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For Vue packages, also add:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "catalog:",
|
||||||
|
"@vue/shared": "catalog:"
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/test-utils": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For packages with sub-path exports (like `core/platform`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
"./browsers": {
|
||||||
|
"types": "./dist/browsers.d.ts",
|
||||||
|
"import": "./dist/browsers.js",
|
||||||
|
"require": "./dist/browsers.cjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create `tsconfig.json`
|
||||||
|
|
||||||
|
Node/core package:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue package (needs DOM types and path aliases):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create `tsdown.config.ts`
|
||||||
|
|
||||||
|
Standard:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue package (externalize vue, bundle internal deps):
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
deps: {
|
||||||
|
neverBundle: ['vue'],
|
||||||
|
alwaysBundle: [/^@robonen\//, '@vue/shared'],
|
||||||
|
},
|
||||||
|
inputOptions: {
|
||||||
|
resolve: {
|
||||||
|
alias: { '@vue/shared': '@vue/shared/dist/shared.esm-bundler.js' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: { __DEV__: 'false' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create `oxlint.config.ts`
|
||||||
|
|
||||||
|
Standard (node packages):
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'oxlint';
|
||||||
|
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||||
|
|
||||||
|
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Create `vitest.config.ts`
|
||||||
|
|
||||||
|
Node package:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue package:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
define: { __DEV__: 'true' },
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, './src') },
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Create `jsr.json` (for publishable packages)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||||
|
"name": "@robonen/<name>",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"exports": "./src/index.ts"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Create source files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p src
|
||||||
|
touch src/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Register with vitest projects
|
||||||
|
|
||||||
|
Add the new `vitest.config.ts` path to the root `vitest.config.ts` `projects` array.
|
||||||
|
|
||||||
|
### 10. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -C <package-path> build
|
||||||
|
pnpm -C <package-path> lint
|
||||||
|
pnpm -C <package-path> test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linting
|
||||||
|
|
||||||
|
Uses **oxlint** (not ESLint) with composable presets from `@robonen/oxlint`.
|
||||||
|
|
||||||
|
### Run linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check lint errors (no auto-fix)
|
||||||
|
pnpm -C <package-path> lint:check
|
||||||
|
|
||||||
|
# Auto-fix lint errors
|
||||||
|
pnpm -C <package-path> lint:fix
|
||||||
|
|
||||||
|
# Check all packages
|
||||||
|
pnpm lint:check
|
||||||
|
|
||||||
|
# Fix all packages
|
||||||
|
pnpm lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available presets
|
||||||
|
|
||||||
|
| Preset | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `base` | ESLint core + Oxc + Unicorn rules |
|
||||||
|
| `typescript` | TypeScript rules (via overrides on `*.ts` files) |
|
||||||
|
| `imports` | Import ordering, cycles, duplicates |
|
||||||
|
| `stylistic` | Code style via `@stylistic/eslint-plugin` |
|
||||||
|
| `vue` | Vue 3 Composition API rules |
|
||||||
|
| `vitest` | Test file rules |
|
||||||
|
| `node` | Node.js-specific rules |
|
||||||
|
|
||||||
|
Compose presets in `oxlint.config.ts`:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'oxlint';
|
||||||
|
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
||||||
|
|
||||||
|
export default defineConfig(compose(base, typescript, imports));
|
||||||
|
|
||||||
|
**Recommended:** Include `stylistic` preset for code formatting:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'oxlint';
|
||||||
|
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||||
|
|
||||||
|
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||||
|
```
|
||||||
|
|
||||||
|
When using `stylistic`, add `@stylistic/eslint-plugin` to devDependencies:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@stylistic/eslint-plugin": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build a specific package
|
||||||
|
pnpm -C <package-path> build
|
||||||
|
|
||||||
|
# Build all packages
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
All packages use `tsdown` with shared config from `@robonen/tsdown`. Output: ESM (`.js`/`.mjs`) + CJS (`.cjs`) + type declarations (`.d.ts`). Every bundle includes an Apache-2.0 license banner.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests in a specific package
|
||||||
|
pnpm -C <package-path> test
|
||||||
|
|
||||||
|
# Run all tests (via vitest projects)
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Interactive test UI
|
||||||
|
pnpm test:ui
|
||||||
|
|
||||||
|
# Watch mode in a package
|
||||||
|
pnpm -C <package-path> dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses **vitest** with project-based configuration. Root `vitest.config.ts` lists all package vitest configs as projects.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Publishing is **automated** via GitHub Actions on push to `master`:
|
||||||
|
|
||||||
|
1. CI builds and tests all packages
|
||||||
|
2. Publish workflow compares each package's `version` in `package.json` against npm registry
|
||||||
|
3. If version changed → `pnpm publish --access public`
|
||||||
|
|
||||||
|
**To publish:** Bump the `version` in `package.json` (and `jsr.json` if present), then merge to `master`.
|
||||||
|
|
||||||
|
**NPM scope:** All packages publish under `@robonen/`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm docs:dev # Start Nuxt dev server
|
||||||
|
pnpm docs:generate # Generate static site
|
||||||
|
pnpm docs:preview # Preview generated site
|
||||||
|
pnpm docs:extract # Extract API docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **ESM-first:** All packages use `"type": "module"`
|
||||||
|
- **Strict TypeScript:** `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true`
|
||||||
|
- **License:** Apache-2.0 for all published packages
|
||||||
|
- **Node version:** ≥24.13.1 (set in `engines` and CI)
|
||||||
|
- **pnpm version:** Pinned in `packageManager` field
|
||||||
|
- **No barrel re-exports of entire modules** — export explicitly
|
||||||
|
- **`__DEV__` global:** `false` in builds, `true` in tests (Vue packages only)
|
||||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint:check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
@@ -41,6 +41,19 @@ compose(base, typescript, {
|
|||||||
| `imports` | Import rules (cycles, duplicates, ordering) |
|
| `imports` | Import rules (cycles, duplicates, ordering) |
|
||||||
| `node` | Node.js-specific rules |
|
| `node` | Node.js-specific rules |
|
||||||
|
|
||||||
|
## Rules Documentation
|
||||||
|
|
||||||
|
Подробные описания правил и `good/bad` примеры вынесены в отдельную директорию:
|
||||||
|
|
||||||
|
- `rules/README.md`
|
||||||
|
- `rules/base.md`
|
||||||
|
- `rules/typescript.md`
|
||||||
|
- `rules/vue.md`
|
||||||
|
- `rules/vitest.md`
|
||||||
|
- `rules/imports.md`
|
||||||
|
- `rules/node.md`
|
||||||
|
- `rules/stylistic.md`
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### `compose(...configs: OxlintConfig[]): OxlintConfig`
|
### `compose(...configs: OxlintConfig[]): OxlintConfig`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'oxlint';
|
import { defineConfig } from 'oxlint';
|
||||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||||
|
|
||||||
export default defineConfig(compose(base, typescript, imports));
|
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "configs/oxlint"
|
"directory": "configs/oxlint"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
@@ -26,13 +26,19 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": {
|
||||||
"import": "./dist/index.js",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "oxlint -c oxlint.config.ts",
|
"lint:check": "oxlint -c oxlint.config.ts",
|
||||||
|
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vitest dev",
|
"dev": "vitest dev",
|
||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
@@ -41,11 +47,18 @@
|
|||||||
"@robonen/oxlint": "workspace:*",
|
"@robonen/oxlint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"oxlint": ">=1.0.0"
|
"oxlint": ">=1.0.0",
|
||||||
|
"@stylistic/eslint-plugin": ">=4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@stylistic/eslint-plugin": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
21
configs/oxlint/rules/README.md
Normal file
21
configs/oxlint/rules/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Rules Reference
|
||||||
|
|
||||||
|
Документация по preset-ам `@robonen/oxlint`: что включает каждый preset и какие правила чаще всего влияют на код.
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
- [base](./base.md)
|
||||||
|
- [typescript](./typescript.md)
|
||||||
|
- [vue](./vue.md)
|
||||||
|
- [vitest](./vitest.md)
|
||||||
|
- [imports](./imports.md)
|
||||||
|
- [node](./node.md)
|
||||||
|
- [stylistic](./stylistic.md)
|
||||||
|
|
||||||
|
## Как читать
|
||||||
|
|
||||||
|
- `Purpose` — зачем preset подключать.
|
||||||
|
- `Key Rules` — ключевые правила из preset-а (не полный dump).
|
||||||
|
- `Examples` — минимальные `good/bad` примеры.
|
||||||
|
|
||||||
|
Для точного источника правил см. файлы в `configs/oxlint/src/presets/*.ts`.
|
||||||
34
configs/oxlint/rules/base.md
Normal file
34
configs/oxlint/rules/base.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# base preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Базовый quality-профиль для JS/TS-проектов: корректность, анти-паттерны, безопасные дефолты.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `eslint/eqeqeq`: запрещает `==`, требует `===`.
|
||||||
|
- `eslint/no-unused-vars`: запрещает неиспользуемые переменные (кроме `_name`).
|
||||||
|
- `eslint/no-eval`, `eslint/no-var`, `eslint/prefer-const`.
|
||||||
|
- `unicorn/prefer-node-protocol`: требует `node:` для built-in модулей.
|
||||||
|
- `unicorn/no-thenable`: запрещает thenable-объекты.
|
||||||
|
- `oxc/*` correctness правила (`bad-comparison-sequence`, `missing-throw` и др.).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Good
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const id = 42;
|
||||||
|
if (id === 42) {
|
||||||
|
throw new Error('unexpected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
|
var id = 42;
|
||||||
|
if (id == '42') {
|
||||||
|
throw 'unexpected';
|
||||||
|
}
|
||||||
|
```
|
||||||
27
configs/oxlint/rules/imports.md
Normal file
27
configs/oxlint/rules/imports.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# imports preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Чистые границы модулей и предсказуемые импорты.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `import/no-duplicates`.
|
||||||
|
- `import/no-self-import`.
|
||||||
|
- `import/no-cycle` (warn).
|
||||||
|
- `import/no-mutable-exports`.
|
||||||
|
- `import/consistent-type-specifier-style`: `prefer-top-level`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Good
|
||||||
|
import type { User } from './types';
|
||||||
|
import { getUser } from './service';
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
import { getUser } from './service';
|
||||||
|
import { getUser as getUser2 } from './service';
|
||||||
|
|
||||||
|
export let state = 0;
|
||||||
|
```
|
||||||
22
configs/oxlint/rules/node.md
Normal file
22
configs/oxlint/rules/node.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# node preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Node.js-правила и окружение `env.node = true`.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `node/no-exports-assign`: запрещает перезапись `exports`.
|
||||||
|
- `node/no-new-require`: запрещает `new require(...)`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Good
|
||||||
|
module.exports = { run };
|
||||||
|
const mod = require('./mod');
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
exports = { run };
|
||||||
|
const bad = new require('./mod');
|
||||||
|
```
|
||||||
51
configs/oxlint/rules/stylistic.md
Normal file
51
configs/oxlint/rules/stylistic.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# stylistic preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Форматирование через `@stylistic/eslint-plugin` (отступы, пробелы, скобки, переносы, TS/JSX-стиль).
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- `indent: 2`
|
||||||
|
- `quotes: single`
|
||||||
|
- `semi: always`
|
||||||
|
- `braceStyle: stroustrup`
|
||||||
|
- `commaDangle: always-multiline`
|
||||||
|
- `arrowParens: as-needed`
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `@stylistic/indent`, `@stylistic/no-tabs`.
|
||||||
|
- `@stylistic/quotes`, `@stylistic/semi`.
|
||||||
|
- `@stylistic/object-curly-spacing`, `@stylistic/comma-spacing`.
|
||||||
|
- `@stylistic/arrow-spacing`, `@stylistic/space-before-function-paren`.
|
||||||
|
- `@stylistic/max-statements-per-line`.
|
||||||
|
- `@stylistic/no-mixed-operators`.
|
||||||
|
- `@stylistic/member-delimiter-style` (TS).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Good
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = condition
|
||||||
|
? 'yes'
|
||||||
|
: 'no';
|
||||||
|
|
||||||
|
const sum = (a: number, b: number) => a + b;
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
type User = {
|
||||||
|
id: string
|
||||||
|
role: 'admin' | 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = condition ? 'yes' : 'no'; const x = 1;
|
||||||
|
const sum=(a:number,b:number)=>{ return a+b };
|
||||||
|
```
|
||||||
|
|
||||||
|
Полный список правил и их настройки см. в `src/presets/stylistic.ts`.
|
||||||
33
configs/oxlint/rules/typescript.md
Normal file
33
configs/oxlint/rules/typescript.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# typescript preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TypeScript-правила для `.ts/.tsx/.mts/.cts` через `overrides`.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `typescript/consistent-type-imports`: выносит типы в `import type`.
|
||||||
|
- `typescript/no-import-type-side-effects`: запрещает сайд-эффекты в type import.
|
||||||
|
- `typescript/prefer-as-const`.
|
||||||
|
- `typescript/no-namespace`, `typescript/triple-slash-reference`.
|
||||||
|
- `typescript/no-wrapper-object-types`: запрещает `String`, `Number`, `Boolean`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Good
|
||||||
|
import type { User } from './types';
|
||||||
|
|
||||||
|
const status = 'ok' as const;
|
||||||
|
interface Payload {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
import { User } from './types';
|
||||||
|
|
||||||
|
type Boxed = String;
|
||||||
|
namespace Legacy {
|
||||||
|
export const x = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
34
configs/oxlint/rules/vitest.md
Normal file
34
configs/oxlint/rules/vitest.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# vitest preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Правила для тестов (`*.test.*`, `*.spec.*`, `test/**`, `__tests__/**`).
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `vitest/no-conditional-tests`.
|
||||||
|
- `vitest/no-import-node-test`.
|
||||||
|
- `vitest/prefer-to-be-truthy`, `vitest/prefer-to-be-falsy`.
|
||||||
|
- `vitest/prefer-to-have-length`.
|
||||||
|
- Relaxations: `eslint/no-unused-vars` и `typescript/no-explicit-any` выключены для тестов.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Good
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('has items', () => {
|
||||||
|
expect([1, 2, 3]).toHaveLength(3);
|
||||||
|
expect(true).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
if (process.env.CI) {
|
||||||
|
it('conditionally runs', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
30
configs/oxlint/rules/vue.md
Normal file
30
configs/oxlint/rules/vue.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# vue preset
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Правила для Vue 3 с упором на Composition API и `<script setup>`.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- `vue/no-export-in-script-setup`.
|
||||||
|
- `vue/no-import-compiler-macros`.
|
||||||
|
- `vue/define-props-declaration`: type-based.
|
||||||
|
- `vue/define-emits-declaration`: type-based.
|
||||||
|
- `vue/valid-define-props`, `vue/valid-define-emits`.
|
||||||
|
- `vue/no-lifecycle-after-await`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ id: string }>();
|
||||||
|
const emit = defineEmits<{ change: [value: string] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ❌ Bad -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
export const x = 1;
|
||||||
|
const props = defineProps({ id: String });
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -31,6 +31,7 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
|
|||||||
* Compose multiple oxlint configurations into a single config.
|
* Compose multiple oxlint configurations into a single config.
|
||||||
*
|
*
|
||||||
* - `plugins` — union (deduplicated)
|
* - `plugins` — union (deduplicated)
|
||||||
|
* - `jsPlugins` — union (deduplicated by specifier)
|
||||||
* - `categories` — later configs override earlier
|
* - `categories` — later configs override earlier
|
||||||
* - `rules` — later configs override earlier
|
* - `rules` — later configs override earlier
|
||||||
* - `overrides` — concatenated
|
* - `overrides` — concatenated
|
||||||
@@ -60,6 +61,22 @@ export function compose(...configs: OxlintConfig[]): OxlintConfig {
|
|||||||
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
|
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS Plugins — union with dedup by specifier
|
||||||
|
if (config.jsPlugins?.length) {
|
||||||
|
const existing = result.jsPlugins ?? [];
|
||||||
|
const seen = new Set(existing.map(e => typeof e === 'string' ? e : e.specifier));
|
||||||
|
|
||||||
|
for (const entry of config.jsPlugins) {
|
||||||
|
const specifier = typeof entry === 'string' ? entry : entry.specifier;
|
||||||
|
if (!seen.has(specifier)) {
|
||||||
|
seen.add(specifier);
|
||||||
|
existing.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.jsPlugins = existing;
|
||||||
|
}
|
||||||
|
|
||||||
// Categories — shallow merge
|
// Categories — shallow merge
|
||||||
if (config.categories) {
|
if (config.categories) {
|
||||||
result.categories = { ...result.categories, ...config.categories };
|
result.categories = { ...result.categories, ...config.categories };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export { compose } from './compose';
|
export { compose } from './compose';
|
||||||
|
|
||||||
/* Presets */
|
/* Presets */
|
||||||
export { base, typescript, vue, vitest, imports, node } from './presets';
|
export { base, typescript, vue, vitest, imports, node, stylistic } from './presets';
|
||||||
|
|
||||||
/* Types */
|
/* Types */
|
||||||
export type {
|
export type {
|
||||||
@@ -10,6 +10,7 @@ export type {
|
|||||||
OxlintOverride,
|
OxlintOverride,
|
||||||
OxlintEnv,
|
OxlintEnv,
|
||||||
OxlintGlobals,
|
OxlintGlobals,
|
||||||
|
ExternalPluginEntry,
|
||||||
AllowWarnDeny,
|
AllowWarnDeny,
|
||||||
DummyRule,
|
DummyRule,
|
||||||
DummyRuleMap,
|
DummyRuleMap,
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ export const imports: OxlintConfig = {
|
|||||||
'import/no-commonjs': 'warn',
|
'import/no-commonjs': 'warn',
|
||||||
'import/no-empty-named-blocks': 'warn',
|
'import/no-empty-named-blocks': 'warn',
|
||||||
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
|
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
|
||||||
|
|
||||||
|
'sort-imports': 'warn',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { vue } from './vue';
|
|||||||
export { vitest } from './vitest';
|
export { vitest } from './vitest';
|
||||||
export { imports } from './imports';
|
export { imports } from './imports';
|
||||||
export { node } from './node';
|
export { node } from './node';
|
||||||
|
export { stylistic } from './stylistic';
|
||||||
|
|||||||
162
configs/oxlint/src/presets/stylistic.ts
Normal file
162
configs/oxlint/src/presets/stylistic.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import type { OxlintConfig } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stylistic formatting rules via `@stylistic/eslint-plugin`.
|
||||||
|
*
|
||||||
|
* Uses the plugin's `customize()` defaults:
|
||||||
|
* - indent: 2
|
||||||
|
* - quotes: single
|
||||||
|
* - semi: true
|
||||||
|
* - braceStyle: stroustrup
|
||||||
|
* - commaDangle: always-multiline
|
||||||
|
* - arrowParens: false (as-needed)
|
||||||
|
* - blockSpacing: true
|
||||||
|
* - quoteProps: consistent-as-needed
|
||||||
|
* - jsx: true
|
||||||
|
*
|
||||||
|
* Requires `@stylistic/eslint-plugin` to be installed.
|
||||||
|
*
|
||||||
|
* @see https://eslint.style/guide/config-presets
|
||||||
|
*/
|
||||||
|
export const stylistic: OxlintConfig = {
|
||||||
|
jsPlugins: ['@stylistic/eslint-plugin'],
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
/* ── spacing & layout ─────────────────────────────────── */
|
||||||
|
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||||
|
'@stylistic/arrow-spacing': ['error', { after: true, before: true }],
|
||||||
|
'@stylistic/block-spacing': ['error', 'always'],
|
||||||
|
'@stylistic/comma-spacing': ['error', { after: true, before: false }],
|
||||||
|
'@stylistic/computed-property-spacing': ['error', 'never', { enforceForClassMembers: true }],
|
||||||
|
'@stylistic/dot-location': ['error', 'property'],
|
||||||
|
'@stylistic/key-spacing': ['error', { afterColon: true, beforeColon: false }],
|
||||||
|
'@stylistic/keyword-spacing': ['error', { after: true, before: true }],
|
||||||
|
'@stylistic/no-mixed-spaces-and-tabs': 'error',
|
||||||
|
'@stylistic/no-multi-spaces': 'error',
|
||||||
|
'@stylistic/no-trailing-spaces': 'error',
|
||||||
|
'@stylistic/no-whitespace-before-property': 'error',
|
||||||
|
'@stylistic/rest-spread-spacing': ['error', 'never'],
|
||||||
|
'@stylistic/semi-spacing': ['error', { after: true, before: false }],
|
||||||
|
'@stylistic/space-before-blocks': ['error', 'always'],
|
||||||
|
'@stylistic/space-before-function-paren': ['error', { anonymous: 'always', asyncArrow: 'always', named: 'never' }],
|
||||||
|
'@stylistic/space-in-parens': ['error', 'never'],
|
||||||
|
'@stylistic/space-infix-ops': 'error',
|
||||||
|
'@stylistic/space-unary-ops': ['error', { nonwords: false, words: true }],
|
||||||
|
'@stylistic/template-curly-spacing': 'error',
|
||||||
|
'@stylistic/template-tag-spacing': ['error', 'never'],
|
||||||
|
|
||||||
|
/* ── braces & blocks ──────────────────────────────────── */
|
||||||
|
'@stylistic/brace-style': ['error', 'stroustrup', { allowSingleLine: true }],
|
||||||
|
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
|
||||||
|
'@stylistic/no-extra-parens': ['error', 'functions'],
|
||||||
|
'@stylistic/no-floating-decimal': 'error',
|
||||||
|
'@stylistic/wrap-iife': ['error', 'any', { functionPrototypeMethods: true }],
|
||||||
|
'@stylistic/new-parens': 'error',
|
||||||
|
'@stylistic/padded-blocks': ['error', { blocks: 'never', classes: 'never', switches: 'never' }],
|
||||||
|
|
||||||
|
/* ── punctuation ──────────────────────────────────────── */
|
||||||
|
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'@stylistic/comma-style': ['error', 'last'],
|
||||||
|
'@stylistic/semi': ['error', 'always'],
|
||||||
|
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
|
||||||
|
'@stylistic/quote-props': ['error', 'as-needed'],
|
||||||
|
|
||||||
|
/* ── indentation ──────────────────────────────────────── */
|
||||||
|
'@stylistic/indent': ['error', 2, {
|
||||||
|
ArrayExpression: 1,
|
||||||
|
CallExpression: { arguments: 1 },
|
||||||
|
flatTernaryExpressions: false,
|
||||||
|
FunctionDeclaration: { body: 1, parameters: 1, returnType: 1 },
|
||||||
|
FunctionExpression: { body: 1, parameters: 1, returnType: 1 },
|
||||||
|
ignoreComments: false,
|
||||||
|
ignoredNodes: [
|
||||||
|
'TSUnionType',
|
||||||
|
'TSIntersectionType',
|
||||||
|
],
|
||||||
|
ImportDeclaration: 1,
|
||||||
|
MemberExpression: 1,
|
||||||
|
ObjectExpression: 1,
|
||||||
|
offsetTernaryExpressions: true,
|
||||||
|
outerIIFEBody: 1,
|
||||||
|
SwitchCase: 1,
|
||||||
|
tabLength: 2,
|
||||||
|
VariableDeclarator: 1,
|
||||||
|
}],
|
||||||
|
'@stylistic/indent-binary-ops': ['error', 2],
|
||||||
|
'@stylistic/no-tabs': 'error',
|
||||||
|
|
||||||
|
/* ── line breaks ──────────────────────────────────────── */
|
||||||
|
'@stylistic/eol-last': 'error',
|
||||||
|
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }],
|
||||||
|
'@stylistic/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
|
||||||
|
'@stylistic/max-statements-per-line': ['error', { max: 1 }],
|
||||||
|
'@stylistic/multiline-ternary': ['error', 'always-multiline'],
|
||||||
|
'@stylistic/operator-linebreak': ['error', 'before'],
|
||||||
|
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||||
|
|
||||||
|
/* ── generators ───────────────────────────────────────── */
|
||||||
|
'@stylistic/generator-star-spacing': ['error', { after: true, before: false }],
|
||||||
|
'@stylistic/yield-star-spacing': ['error', { after: true, before: false }],
|
||||||
|
|
||||||
|
/* ── operators & mixed ────────────────────────────────── */
|
||||||
|
'@stylistic/no-mixed-operators': ['error', {
|
||||||
|
allowSamePrecedence: true,
|
||||||
|
groups: [
|
||||||
|
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
|
||||||
|
['&&', '||'],
|
||||||
|
['in', 'instanceof'],
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
|
||||||
|
/* ── typescript styling ───────────────────────────────── */
|
||||||
|
'@stylistic/member-delimiter-style': ['error', {
|
||||||
|
multiline: { delimiter: 'semi', requireLast: true },
|
||||||
|
multilineDetection: 'brackets',
|
||||||
|
overrides: {
|
||||||
|
interface: {
|
||||||
|
multiline: { delimiter: 'semi', requireLast: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
singleline: { delimiter: 'semi' },
|
||||||
|
}],
|
||||||
|
'@stylistic/type-annotation-spacing': ['error', {}],
|
||||||
|
'@stylistic/type-generic-spacing': 'error',
|
||||||
|
'@stylistic/type-named-tuple-spacing': 'error',
|
||||||
|
|
||||||
|
/* ── comments ─────────────────────────────────────────── */
|
||||||
|
'@stylistic/spaced-comment': ['error', 'always', {
|
||||||
|
block: { balanced: true, exceptions: ['*'], markers: ['!'] },
|
||||||
|
line: { exceptions: ['/', '#'], markers: ['/'] },
|
||||||
|
}],
|
||||||
|
|
||||||
|
/* ── jsx ───────────────────────────────────────────────── */
|
||||||
|
'@stylistic/jsx-closing-bracket-location': 'error',
|
||||||
|
'@stylistic/jsx-closing-tag-location': 'error',
|
||||||
|
'@stylistic/jsx-curly-brace-presence': ['error', { propElementValues: 'always' }],
|
||||||
|
'@stylistic/jsx-curly-newline': 'error',
|
||||||
|
'@stylistic/jsx-curly-spacing': ['error', 'never'],
|
||||||
|
'@stylistic/jsx-equals-spacing': 'error',
|
||||||
|
'@stylistic/jsx-first-prop-new-line': 'error',
|
||||||
|
'@stylistic/jsx-function-call-newline': ['error', 'multiline'],
|
||||||
|
'@stylistic/jsx-indent-props': ['error', 2],
|
||||||
|
'@stylistic/jsx-max-props-per-line': ['error', { maximum: 1, when: 'multiline' }],
|
||||||
|
'@stylistic/jsx-one-expression-per-line': ['error', { allow: 'single-child' }],
|
||||||
|
'@stylistic/jsx-quotes': 'error',
|
||||||
|
'@stylistic/jsx-tag-spacing': ['error', {
|
||||||
|
afterOpening: 'never',
|
||||||
|
beforeClosing: 'never',
|
||||||
|
beforeSelfClosing: 'always',
|
||||||
|
closingSlash: 'never',
|
||||||
|
}],
|
||||||
|
'@stylistic/jsx-wrap-multilines': ['error', {
|
||||||
|
arrow: 'parens-new-line',
|
||||||
|
assignment: 'parens-new-line',
|
||||||
|
condition: 'parens-new-line',
|
||||||
|
declaration: 'parens-new-line',
|
||||||
|
logical: 'parens-new-line',
|
||||||
|
prop: 'parens-new-line',
|
||||||
|
propertyValue: 'parens-new-line',
|
||||||
|
return: 'parens-new-line',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
OxlintOverride,
|
OxlintOverride,
|
||||||
OxlintEnv,
|
OxlintEnv,
|
||||||
OxlintGlobals,
|
OxlintGlobals,
|
||||||
|
ExternalPluginEntry,
|
||||||
AllowWarnDeny,
|
AllowWarnDeny,
|
||||||
DummyRule,
|
DummyRule,
|
||||||
DummyRuleMap,
|
DummyRuleMap,
|
||||||
|
|||||||
@@ -143,4 +143,29 @@ describe('compose', () => {
|
|||||||
expect(result.env).toBeUndefined();
|
expect(result.env).toBeUndefined();
|
||||||
expect(result.settings).toBeUndefined();
|
expect(result.settings).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should concatenate jsPlugins with dedup by specifier', () => {
|
||||||
|
const a: OxlintConfig = { jsPlugins: ['eslint-plugin-foo'] };
|
||||||
|
const b: OxlintConfig = { jsPlugins: ['eslint-plugin-foo', 'eslint-plugin-bar'] };
|
||||||
|
|
||||||
|
const result = compose(a, b);
|
||||||
|
expect(result.jsPlugins).toEqual(['eslint-plugin-foo', 'eslint-plugin-bar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dedup jsPlugins with mixed string and object entries', () => {
|
||||||
|
const a: OxlintConfig = { jsPlugins: ['eslint-plugin-foo'] };
|
||||||
|
const b: OxlintConfig = { jsPlugins: [{ name: 'foo', specifier: 'eslint-plugin-foo' }] };
|
||||||
|
|
||||||
|
const result = compose(a, b);
|
||||||
|
expect(result.jsPlugins).toEqual(['eslint-plugin-foo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep jsPlugins and plugins independent', () => {
|
||||||
|
const a: OxlintConfig = { plugins: ['eslint'], jsPlugins: ['eslint-plugin-foo'] };
|
||||||
|
const b: OxlintConfig = { plugins: ['typescript'], jsPlugins: ['eslint-plugin-bar'] };
|
||||||
|
|
||||||
|
const result = compose(a, b);
|
||||||
|
expect(result.plugins).toEqual(['eslint', 'typescript']);
|
||||||
|
expect(result.jsPlugins).toEqual(['eslint-plugin-foo', 'eslint-plugin-bar']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "packages/tsconfig"
|
"directory": "packages/tsconfig"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "configs/tsdown"
|
"directory": "configs/tsdown"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Options } from 'tsdown';
|
import type { InlineConfig } from 'tsdown';
|
||||||
|
|
||||||
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
|
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
|
||||||
|
|
||||||
@@ -10,4 +10,4 @@ export const sharedConfig = {
|
|||||||
outputOptions: {
|
outputOptions: {
|
||||||
banner: BANNER,
|
banner: BANNER,
|
||||||
},
|
},
|
||||||
} satisfies Options;
|
} satisfies InlineConfig;
|
||||||
|
|||||||
6
core/encoding/jsr.json
Normal file
6
core/encoding/jsr.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||||
|
"name": "@robonen/encoding",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"exports": "./src/index.ts"
|
||||||
|
}
|
||||||
14
core/encoding/oxlint.config.ts
Normal file
14
core/encoding/oxlint.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'oxlint';
|
||||||
|
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||||
|
|
||||||
|
export default defineConfig(compose(base, typescript, imports, stylistic, {
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['src/qr/qr-code.ts'],
|
||||||
|
rules: {
|
||||||
|
'@stylistic/max-statements-per-line': 'off',
|
||||||
|
'@stylistic/no-mixed-operators': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
52
core/encoding/package.json
Normal file
52
core/encoding/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "@robonen/encoding",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"description": "Encoding utilities for TypeScript",
|
||||||
|
"keywords": [
|
||||||
|
"encoding",
|
||||||
|
"tools"
|
||||||
|
],
|
||||||
|
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
|
"directory": "core/encoding"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.13.1"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint:check": "oxlint -c oxlint.config.ts",
|
||||||
|
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "vitest dev",
|
||||||
|
"bench": "vitest bench",
|
||||||
|
"build": "tsdown"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@robonen/oxlint": "workspace:*",
|
||||||
|
"@robonen/tsconfig": "workspace:*",
|
||||||
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
|
"oxlint": "catalog:",
|
||||||
|
"tsdown": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
core/encoding/src/index.ts
Normal file
2
core/encoding/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './reed-solomon';
|
||||||
|
export * from './qr';
|
||||||
96
core/encoding/src/qr/__test__/index.bench.ts
Normal file
96
core/encoding/src/qr/__test__/index.bench.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { bench, describe } from 'vitest';
|
||||||
|
import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '..';
|
||||||
|
|
||||||
|
/* -- Test data -- */
|
||||||
|
|
||||||
|
const SHORT_TEXT = 'Hello';
|
||||||
|
const MEDIUM_TEXT = 'https://example.com/path?query=value&foo=bar';
|
||||||
|
const LONG_TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.';
|
||||||
|
const NUMERIC_TEXT = '314159265358979323846264338327950288419716939937510';
|
||||||
|
const ALPHANUMERIC_TEXT = 'HELLO WORLD 12345';
|
||||||
|
|
||||||
|
const SMALL_BINARY = Array.from({ length: 32 }, (_, i) => i);
|
||||||
|
const MEDIUM_BINARY = Array.from({ length: 256 }, (_, i) => i % 256);
|
||||||
|
|
||||||
|
/* -- Precomputed segments for isolated encodeSegments benchmark -- */
|
||||||
|
const precomputedSegs = makeSegments(MEDIUM_TEXT);
|
||||||
|
|
||||||
|
/* -- encodeText benchmarks -- */
|
||||||
|
|
||||||
|
describe('encodeText', () => {
|
||||||
|
bench('short text (5 chars)', () => {
|
||||||
|
encodeText(SHORT_TEXT, LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('medium text (URL ~44 chars)', () => {
|
||||||
|
encodeText(MEDIUM_TEXT, LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('long text (~270 chars)', () => {
|
||||||
|
encodeText(LONG_TEXT, LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('numeric text (50 digits)', () => {
|
||||||
|
encodeText(NUMERIC_TEXT, LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('alphanumeric text (17 chars)', () => {
|
||||||
|
encodeText(ALPHANUMERIC_TEXT, LOW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -- ECC level impact -- */
|
||||||
|
|
||||||
|
describe('encodeText — ECC levels', () => {
|
||||||
|
bench('LOW (L)', () => {
|
||||||
|
encodeText(MEDIUM_TEXT, EccMap.L);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('MEDIUM (M)', () => {
|
||||||
|
encodeText(MEDIUM_TEXT, EccMap.M);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('QUARTILE (Q)', () => {
|
||||||
|
encodeText(MEDIUM_TEXT, EccMap.Q);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('HIGH (H)', () => {
|
||||||
|
encodeText(MEDIUM_TEXT, EccMap.H);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -- encodeBinary benchmarks -- */
|
||||||
|
|
||||||
|
describe('encodeBinary', () => {
|
||||||
|
bench('small binary (32 bytes)', () => {
|
||||||
|
encodeBinary(SMALL_BINARY, LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('medium binary (256 bytes)', () => {
|
||||||
|
encodeBinary(MEDIUM_BINARY, LOW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -- makeSegments benchmarks -- */
|
||||||
|
|
||||||
|
describe('makeSegments', () => {
|
||||||
|
bench('numeric classification', () => {
|
||||||
|
makeSegments(NUMERIC_TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('alphanumeric classification', () => {
|
||||||
|
makeSegments(ALPHANUMERIC_TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
bench('byte mode classification', () => {
|
||||||
|
makeSegments(MEDIUM_TEXT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -- encodeSegments (pre-built segments) -- */
|
||||||
|
|
||||||
|
describe('encodeSegments', () => {
|
||||||
|
bench('from pre-built segments', () => {
|
||||||
|
encodeSegments(precomputedSegs, LOW);
|
||||||
|
});
|
||||||
|
});
|
||||||
257
core/encoding/src/qr/__test__/index.test.ts
Normal file
257
core/encoding/src/qr/__test__/index.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { encodeText, encodeBinary, encodeSegments, makeSegments, isNumeric, isAlphanumeric, QrCode, QrCodeDataType, EccMap, LOW, MEDIUM, QUARTILE, HIGH } from '..';
|
||||||
|
|
||||||
|
describe('isNumeric', () => {
|
||||||
|
it('accepts pure digit strings', () => {
|
||||||
|
expect(isNumeric('0123456789')).toBe(true);
|
||||||
|
expect(isNumeric('0')).toBe(true);
|
||||||
|
expect(isNumeric('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-digit characters', () => {
|
||||||
|
expect(isNumeric('12a3')).toBe(false);
|
||||||
|
expect(isNumeric('HELLO')).toBe(false);
|
||||||
|
expect(isNumeric('12 34')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAlphanumeric', () => {
|
||||||
|
it('accepts valid alphanumeric strings', () => {
|
||||||
|
expect(isAlphanumeric('HELLO WORLD')).toBe(true);
|
||||||
|
expect(isAlphanumeric('0123456789')).toBe(true);
|
||||||
|
expect(isAlphanumeric('ABC123')).toBe(true);
|
||||||
|
expect(isAlphanumeric('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects lowercase and special characters', () => {
|
||||||
|
expect(isAlphanumeric('hello')).toBe(false);
|
||||||
|
expect(isAlphanumeric('Hello')).toBe(false);
|
||||||
|
expect(isAlphanumeric('test@email')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeSegments', () => {
|
||||||
|
it('returns empty array for empty string', () => {
|
||||||
|
expect(makeSegments('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects numeric mode for digit strings', () => {
|
||||||
|
const segs = makeSegments('12345');
|
||||||
|
expect(segs).toHaveLength(1);
|
||||||
|
expect(segs[0]!.mode[0]).toBe(0x1); // MODE_NUMERIC
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects alphanumeric mode for uppercase strings', () => {
|
||||||
|
const segs = makeSegments('HELLO WORLD');
|
||||||
|
expect(segs).toHaveLength(1);
|
||||||
|
expect(segs[0]!.mode[0]).toBe(0x2); // MODE_ALPHANUMERIC
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects byte mode for general text', () => {
|
||||||
|
const segs = makeSegments('Hello, World!');
|
||||||
|
expect(segs).toHaveLength(1);
|
||||||
|
expect(segs[0]!.mode[0]).toBe(0x4); // MODE_BYTE
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeText', () => {
|
||||||
|
it('encodes short text at LOW ECC', () => {
|
||||||
|
const qr = encodeText('Hello', LOW);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
expect(qr.version).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(qr.size).toBe(qr.version * 4 + 17);
|
||||||
|
expect(qr.mask).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(qr.mask).toBeLessThanOrEqual(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes text at different ECC levels', () => {
|
||||||
|
const qrL = encodeText('Test', LOW);
|
||||||
|
const qrM = encodeText('Test', MEDIUM);
|
||||||
|
const qrH = encodeText('Test', HIGH);
|
||||||
|
|
||||||
|
// Higher ECC needs same or higher version
|
||||||
|
expect(qrH.version).toBeGreaterThanOrEqual(qrL.version);
|
||||||
|
// All produce valid sizes
|
||||||
|
for (const qr of [qrL, qrM, qrH]) {
|
||||||
|
expect(qr.size).toBe(qr.version * 4 + 17);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes numeric-only text', () => {
|
||||||
|
const qr = encodeText('123456789012345', LOW);
|
||||||
|
expect(qr.version).toBe(1); // Numeric mode is compact
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes a URL', () => {
|
||||||
|
const qr = encodeText('https://example.com/path?query=value', LOW);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
expect(qr.size).toBeGreaterThanOrEqual(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes long text', () => {
|
||||||
|
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.';
|
||||||
|
const qr = encodeText(longText, LOW);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for data too long', () => {
|
||||||
|
const tooLong = 'A'.repeat(10000);
|
||||||
|
expect(() => encodeText(tooLong, HIGH)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeBinary', () => {
|
||||||
|
it('encodes binary data', () => {
|
||||||
|
const data = [0x00, 0xFF, 0x48, 0x65, 0x6C, 0x6C, 0x6F];
|
||||||
|
const qr = encodeBinary(data, LOW);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QrCode', () => {
|
||||||
|
it('modules grid has correct dimensions', () => {
|
||||||
|
const qr = encodeText('Test', LOW);
|
||||||
|
// Flat Uint8Array grid, verify via getModule
|
||||||
|
expect(qr.size).toBeGreaterThanOrEqual(21);
|
||||||
|
for (let y = 0; y < qr.size; y++) {
|
||||||
|
for (let x = 0; x < qr.size; x++) {
|
||||||
|
const mod = qr.getModule(x, y);
|
||||||
|
expect(typeof mod).toBe('boolean');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('types grid has correct dimensions', () => {
|
||||||
|
const qr = encodeText('Test', LOW);
|
||||||
|
// Flat Int8Array grid, verify via getType
|
||||||
|
for (let y = 0; y < qr.size; y++) {
|
||||||
|
for (let x = 0; x < qr.size; x++) {
|
||||||
|
const t = qr.getType(x, y);
|
||||||
|
expect(typeof t).toBe('number');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getModule returns false for out of bounds', () => {
|
||||||
|
const qr = encodeText('Test', LOW);
|
||||||
|
expect(qr.getModule(-1, 0)).toBe(false);
|
||||||
|
expect(qr.getModule(0, -1)).toBe(false);
|
||||||
|
expect(qr.getModule(qr.size, 0)).toBe(false);
|
||||||
|
expect(qr.getModule(0, qr.size)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces deterministic output', () => {
|
||||||
|
const qr1 = encodeText('Hello', LOW);
|
||||||
|
const qr2 = encodeText('Hello', LOW);
|
||||||
|
expect(qr1.version).toBe(qr2.version);
|
||||||
|
expect(qr1.mask).toBe(qr2.mask);
|
||||||
|
for (let y = 0; y < qr1.size; y++) {
|
||||||
|
for (let x = 0; x < qr1.size; x++) {
|
||||||
|
expect(qr1.getModule(x, y)).toBe(qr2.getModule(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different inputs produce different outputs', () => {
|
||||||
|
const qr1 = encodeText('Hello', LOW);
|
||||||
|
const qr2 = encodeText('World', LOW);
|
||||||
|
// They might have the same version/size but different modules
|
||||||
|
let hasDiff = false;
|
||||||
|
for (let y = 0; y < qr1.size && !hasDiff; y++) {
|
||||||
|
for (let x = 0; x < qr1.size && !hasDiff; x++) {
|
||||||
|
if (qr1.getModule(x, y) !== qr2.getModule(x, y))
|
||||||
|
hasDiff = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(hasDiff).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EccMap', () => {
|
||||||
|
it('has all four levels', () => {
|
||||||
|
expect(EccMap.L).toBeDefined();
|
||||||
|
expect(EccMap.M).toBeDefined();
|
||||||
|
expect(EccMap.Q).toBeDefined();
|
||||||
|
expect(EccMap.H).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with encodeText', () => {
|
||||||
|
const qr = encodeText('Test', EccMap.L);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeSegments', () => {
|
||||||
|
it('uses explicit mask when specified', () => {
|
||||||
|
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, 3);
|
||||||
|
expect(qr.mask).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves ECC level when boostEcl is false', () => {
|
||||||
|
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, -1, false);
|
||||||
|
expect(qr.ecc).toBe(LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boosts ECC level by default when data fits', () => {
|
||||||
|
const qr = encodeSegments(makeSegments('Test'), LOW);
|
||||||
|
expect(qr.ecc).toBe(HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces a specific version when min equals max', () => {
|
||||||
|
const qr = encodeSegments(makeSegments('Test'), LOW, 5, 5);
|
||||||
|
expect(qr.version).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid version range', () => {
|
||||||
|
expect(() => encodeSegments(makeSegments('Test'), LOW, 2, 1)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid mask value', () => {
|
||||||
|
expect(() => encodeSegments(makeSegments('Test'), LOW, 1, 40, 8)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeBinary edge cases', () => {
|
||||||
|
it('encodes an empty array', () => {
|
||||||
|
const qr = encodeBinary([], LOW);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeText edge cases', () => {
|
||||||
|
it('encodes Unicode emoji text', () => {
|
||||||
|
const qr = encodeText('Hello \uD83C\uDF0D', LOW);
|
||||||
|
expect(qr).toBeInstanceOf(QrCode);
|
||||||
|
expect(qr.size).toBeGreaterThanOrEqual(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses compact encoding for alphanumeric text', () => {
|
||||||
|
const qr = encodeText('HELLO WORLD', LOW);
|
||||||
|
expect(qr.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects version >= 7 for long data (triggers drawVersion)', () => {
|
||||||
|
const qr = encodeText('a'.repeat(200), LOW);
|
||||||
|
expect(qr.version).toBeGreaterThanOrEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getType semantics', () => {
|
||||||
|
it('identifies finder pattern modules as Position', () => {
|
||||||
|
const qr = encodeText('Test', LOW);
|
||||||
|
// Top-left finder pattern
|
||||||
|
expect(qr.getType(0, 0)).toBe(QrCodeDataType.Position);
|
||||||
|
expect(qr.getType(3, 3)).toBe(QrCodeDataType.Position);
|
||||||
|
expect(qr.getType(6, 6)).toBe(QrCodeDataType.Position);
|
||||||
|
// Top-right finder pattern
|
||||||
|
expect(qr.getType(qr.size - 1, 0)).toBe(QrCodeDataType.Position);
|
||||||
|
// Bottom-left finder pattern
|
||||||
|
expect(qr.getType(0, qr.size - 1)).toBe(QrCodeDataType.Position);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies timing pattern modules as Timing', () => {
|
||||||
|
const qr = encodeText('Test', LOW);
|
||||||
|
// Horizontal timing row y=6, between finders
|
||||||
|
expect(qr.getType(8, 6)).toBe(QrCodeDataType.Timing);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
core/encoding/src/qr/__test__/segment.test.ts
Normal file
92
core/encoding/src/qr/__test__/segment.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { QrSegment, makeNumeric, makeAlphanumeric, makeBytes } from '../segment';
|
||||||
|
import { MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC } from '../constants';
|
||||||
|
|
||||||
|
describe('QrSegment', () => {
|
||||||
|
it('throws on negative numChars', () => {
|
||||||
|
expect(() => new QrSegment(MODE_BYTE, -1, [])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts zero numChars', () => {
|
||||||
|
const seg = new QrSegment(MODE_BYTE, 0, []);
|
||||||
|
expect(seg.numChars).toBe(0);
|
||||||
|
expect(seg.bitData).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeNumeric', () => {
|
||||||
|
it('encodes a 5-digit string', () => {
|
||||||
|
const seg = makeNumeric('12345');
|
||||||
|
expect(seg.mode).toBe(MODE_NUMERIC);
|
||||||
|
expect(seg.numChars).toBe(5);
|
||||||
|
// "123" → 10 bits, "45" → 7 bits
|
||||||
|
expect(seg.bitData).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes a single digit', () => {
|
||||||
|
const seg = makeNumeric('0');
|
||||||
|
expect(seg.numChars).toBe(1);
|
||||||
|
expect(seg.bitData).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes an empty string', () => {
|
||||||
|
const seg = makeNumeric('');
|
||||||
|
expect(seg.numChars).toBe(0);
|
||||||
|
expect(seg.bitData).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on non-numeric input', () => {
|
||||||
|
expect(() => makeNumeric('12a3')).toThrow(RangeError);
|
||||||
|
expect(() => makeNumeric('hello')).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeAlphanumeric', () => {
|
||||||
|
it('encodes a character pair', () => {
|
||||||
|
const seg = makeAlphanumeric('AB');
|
||||||
|
expect(seg.mode).toBe(MODE_ALPHANUMERIC);
|
||||||
|
expect(seg.numChars).toBe(2);
|
||||||
|
// 1 pair → 11 bits
|
||||||
|
expect(seg.bitData).toHaveLength(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes a pair plus remainder', () => {
|
||||||
|
const seg = makeAlphanumeric('ABC');
|
||||||
|
expect(seg.numChars).toBe(3);
|
||||||
|
// 1 pair (11 bits) + 1 remainder (6 bits)
|
||||||
|
expect(seg.bitData).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on lowercase input', () => {
|
||||||
|
expect(() => makeAlphanumeric('hello')).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid characters', () => {
|
||||||
|
expect(() => makeAlphanumeric('test@email')).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeBytes', () => {
|
||||||
|
it('encodes an empty array', () => {
|
||||||
|
const seg = makeBytes([]);
|
||||||
|
expect(seg.mode).toBe(MODE_BYTE);
|
||||||
|
expect(seg.numChars).toBe(0);
|
||||||
|
expect(seg.bitData).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes two bytes', () => {
|
||||||
|
const seg = makeBytes([0x48, 0x65]);
|
||||||
|
expect(seg.numChars).toBe(2);
|
||||||
|
expect(seg.bitData).toHaveLength(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes 0xFF correctly', () => {
|
||||||
|
const seg = makeBytes([0xFF]);
|
||||||
|
expect(seg.bitData).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes 0x00 correctly', () => {
|
||||||
|
const seg = makeBytes([0x00]);
|
||||||
|
expect(seg.bitData).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
core/encoding/src/qr/__test__/utils.test.ts
Normal file
119
core/encoding/src/qr/__test__/utils.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { appendBits, getBit, getNumDataCodewords, getNumRawDataModules, getTotalBits, numCharCountBits } from '../utils';
|
||||||
|
import { HIGH, LOW, MODE_BYTE, MODE_NUMERIC } from '../constants';
|
||||||
|
import { QrSegment } from '../segment';
|
||||||
|
|
||||||
|
describe('appendBits', () => {
|
||||||
|
it('appends nothing when len is 0', () => {
|
||||||
|
const bb: number[] = [];
|
||||||
|
appendBits(0, 0, bb);
|
||||||
|
expect(bb).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends bits in MSB-first order', () => {
|
||||||
|
const bb: number[] = [];
|
||||||
|
appendBits(0b101, 3, bb);
|
||||||
|
expect(bb).toEqual([1, 0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends to an existing array', () => {
|
||||||
|
const bb = [1, 0];
|
||||||
|
appendBits(0b11, 2, bb);
|
||||||
|
expect(bb).toEqual([1, 0, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when value exceeds bit length', () => {
|
||||||
|
expect(() => appendBits(5, 2, [])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on negative length', () => {
|
||||||
|
expect(() => appendBits(0, -1, [])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBit', () => {
|
||||||
|
it('returns correct bits for 0b10110', () => {
|
||||||
|
expect(getBit(0b10110, 0)).toBe(false);
|
||||||
|
expect(getBit(0b10110, 1)).toBe(true);
|
||||||
|
expect(getBit(0b10110, 2)).toBe(true);
|
||||||
|
expect(getBit(0b10110, 3)).toBe(false);
|
||||||
|
expect(getBit(0b10110, 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for high bits of a small number', () => {
|
||||||
|
expect(getBit(1, 7)).toBe(false);
|
||||||
|
expect(getBit(1, 31)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNumRawDataModules', () => {
|
||||||
|
it('returns 208 for version 1', () => {
|
||||||
|
expect(getNumRawDataModules(1)).toBe(208);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct value for version 2 (with alignment)', () => {
|
||||||
|
expect(getNumRawDataModules(2)).toBe(359);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct value for version 7 (with version info)', () => {
|
||||||
|
expect(getNumRawDataModules(7)).toBe(1568);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 29648 for version 40', () => {
|
||||||
|
expect(getNumRawDataModules(40)).toBe(29648);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on version 0', () => {
|
||||||
|
expect(() => getNumRawDataModules(0)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on version 41', () => {
|
||||||
|
expect(() => getNumRawDataModules(41)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNumDataCodewords', () => {
|
||||||
|
it('returns 19 for version 1 LOW', () => {
|
||||||
|
expect(getNumDataCodewords(1, LOW)).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 9 for version 1 HIGH', () => {
|
||||||
|
expect(getNumDataCodewords(1, HIGH)).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTotalBits', () => {
|
||||||
|
it('returns 0 for empty segments', () => {
|
||||||
|
expect(getTotalBits([], 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Infinity when numChars overflows char count field', () => {
|
||||||
|
// MODE_BYTE at v1 has ccbits=8, so numChars=256 overflows
|
||||||
|
const seg = new QrSegment(MODE_BYTE, 256, []);
|
||||||
|
expect(getTotalBits([seg], 1)).toBe(Number.POSITIVE_INFINITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates total bits for a single segment', () => {
|
||||||
|
// MODE_BYTE at v1: 4 (mode) + 8 (char count) + 8 (data) = 20
|
||||||
|
const seg = new QrSegment(MODE_BYTE, 1, [0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
expect(getTotalBits([seg], 1)).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numCharCountBits', () => {
|
||||||
|
it('returns correct bits for MODE_NUMERIC across version ranges', () => {
|
||||||
|
expect(numCharCountBits(MODE_NUMERIC, 1)).toBe(10);
|
||||||
|
expect(numCharCountBits(MODE_NUMERIC, 9)).toBe(10);
|
||||||
|
expect(numCharCountBits(MODE_NUMERIC, 10)).toBe(12);
|
||||||
|
expect(numCharCountBits(MODE_NUMERIC, 26)).toBe(12);
|
||||||
|
expect(numCharCountBits(MODE_NUMERIC, 27)).toBe(14);
|
||||||
|
expect(numCharCountBits(MODE_NUMERIC, 40)).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct bits for MODE_BYTE across version ranges', () => {
|
||||||
|
expect(numCharCountBits(MODE_BYTE, 1)).toBe(8);
|
||||||
|
expect(numCharCountBits(MODE_BYTE, 9)).toBe(8);
|
||||||
|
expect(numCharCountBits(MODE_BYTE, 10)).toBe(16);
|
||||||
|
expect(numCharCountBits(MODE_BYTE, 40)).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
core/encoding/src/qr/constants.ts
Normal file
67
core/encoding/src/qr/constants.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { QrCodeEcc, QrSegmentMode } from './types';
|
||||||
|
|
||||||
|
/* -- ECC Levels -- */
|
||||||
|
|
||||||
|
export const LOW: QrCodeEcc = [0, 1]; // ~7% recovery
|
||||||
|
export const MEDIUM: QrCodeEcc = [1, 0]; // ~15% recovery
|
||||||
|
export const QUARTILE: QrCodeEcc = [2, 3]; // ~25% recovery
|
||||||
|
export const HIGH: QrCodeEcc = [3, 2]; // ~30% recovery
|
||||||
|
|
||||||
|
export const EccMap = {
|
||||||
|
L: LOW,
|
||||||
|
M: MEDIUM,
|
||||||
|
Q: QUARTILE,
|
||||||
|
H: HIGH,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* -- Segment Modes -- */
|
||||||
|
|
||||||
|
export const MODE_NUMERIC: QrSegmentMode = [0x1, 10, 12, 14];
|
||||||
|
export const MODE_ALPHANUMERIC: QrSegmentMode = [0x2, 9, 11, 13];
|
||||||
|
export const MODE_BYTE: QrSegmentMode = [0x4, 8, 16, 16];
|
||||||
|
|
||||||
|
/* -- Version Limits -- */
|
||||||
|
|
||||||
|
export const MIN_VERSION = 1;
|
||||||
|
export const MAX_VERSION = 40;
|
||||||
|
|
||||||
|
/* -- Penalty Constants -- */
|
||||||
|
|
||||||
|
export const PENALTY_N1 = 3;
|
||||||
|
export const PENALTY_N2 = 3;
|
||||||
|
export const PENALTY_N3 = 40;
|
||||||
|
export const PENALTY_N4 = 10;
|
||||||
|
|
||||||
|
/* -- Character Sets & Patterns -- */
|
||||||
|
|
||||||
|
export const NUMERIC_REGEX = /^[0-9]*$/;
|
||||||
|
export const ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+./:_-]*$/;
|
||||||
|
export const ALPHANUMERIC_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' as const;
|
||||||
|
|
||||||
|
/** Pre-computed charCode → alphanumeric index lookup (0xFF = invalid). O(1) instead of O(45) indexOf. */
|
||||||
|
export const ALPHANUMERIC_MAP = /* @__PURE__ */ (() => {
|
||||||
|
const map = new Uint8Array(128).fill(0xFF);
|
||||||
|
for (let i = 0; i < ALPHANUMERIC_CHARSET.length; i++)
|
||||||
|
map[ALPHANUMERIC_CHARSET.charCodeAt(i)] = i;
|
||||||
|
return map;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* -- ECC Lookup Tables -- */
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const ECC_CODEWORDS_PER_BLOCK: number[][] = [
|
||||||
|
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
|
||||||
|
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low
|
||||||
|
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium
|
||||||
|
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile
|
||||||
|
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High
|
||||||
|
];
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const NUM_ERROR_CORRECTION_BLOCKS: number[][] = [
|
||||||
|
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
|
||||||
|
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low
|
||||||
|
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium
|
||||||
|
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile
|
||||||
|
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High
|
||||||
|
];
|
||||||
94
core/encoding/src/qr/encode.ts
Normal file
94
core/encoding/src/qr/encode.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { QrCodeEcc } from './types';
|
||||||
|
import { HIGH, MAX_VERSION, MEDIUM, MIN_VERSION, QUARTILE } from './constants';
|
||||||
|
import { QrCode } from './qr-code';
|
||||||
|
import { makeBytes, makeSegments } from './segment';
|
||||||
|
import type { QrSegment } from './segment';
|
||||||
|
import { appendBits, assert, getNumDataCodewords, getTotalBits, numCharCountBits } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a QR Code representing the given Unicode text string at the given error correction level.
|
||||||
|
* As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
|
||||||
|
* Unicode code points (not UTF-16 code units) if the low error correction level is used.
|
||||||
|
* The smallest possible QR Code version is automatically chosen for the output.
|
||||||
|
*/
|
||||||
|
export function encodeText(text: string, ecl: QrCodeEcc): QrCode {
|
||||||
|
const segs = makeSegments(text);
|
||||||
|
return encodeSegments(segs, ecl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a QR Code representing the given binary data at the given error correction level.
|
||||||
|
* This function always encodes using the binary segment mode, not any text mode.
|
||||||
|
* The maximum number of bytes allowed is 2953.
|
||||||
|
*/
|
||||||
|
export function encodeBinary(data: Readonly<number[]>, ecl: QrCodeEcc): QrCode {
|
||||||
|
const seg = makeBytes(data);
|
||||||
|
return encodeSegments([seg], ecl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a QR Code representing the given segments with the given encoding parameters.
|
||||||
|
* The smallest possible QR Code version within the given range is automatically chosen for the output.
|
||||||
|
* This is a mid-level API; the high-level API is encodeText() and encodeBinary().
|
||||||
|
*/
|
||||||
|
export function encodeSegments(
|
||||||
|
segs: Readonly<QrSegment[]>,
|
||||||
|
ecl: QrCodeEcc,
|
||||||
|
minVersion = 1,
|
||||||
|
maxVersion = 40,
|
||||||
|
mask = -1,
|
||||||
|
boostEcl = true,
|
||||||
|
): QrCode {
|
||||||
|
if (!(MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= MAX_VERSION)
|
||||||
|
|| mask < -1 || mask > 7)
|
||||||
|
throw new RangeError('Invalid value');
|
||||||
|
|
||||||
|
// Find the minimal version number to use
|
||||||
|
let version: number;
|
||||||
|
let dataUsedBits: number;
|
||||||
|
for (version = minVersion; ; version++) {
|
||||||
|
const dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
|
||||||
|
const usedBits = getTotalBits(segs, version);
|
||||||
|
if (usedBits <= dataCapacityBits) {
|
||||||
|
dataUsedBits = usedBits;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (version >= maxVersion)
|
||||||
|
throw new RangeError('Data too long');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase the error correction level while the data still fits in the current version number
|
||||||
|
for (const newEcl of [MEDIUM, QUARTILE, HIGH]) {
|
||||||
|
if (boostEcl && dataUsedBits! <= getNumDataCodewords(version, newEcl) * 8)
|
||||||
|
ecl = newEcl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate all segments to create the data bit string
|
||||||
|
const bb: number[] = [];
|
||||||
|
for (const seg of segs) {
|
||||||
|
appendBits(seg.mode[0], 4, bb);
|
||||||
|
appendBits(seg.numChars, numCharCountBits(seg.mode, version), bb);
|
||||||
|
for (const b of seg.bitData)
|
||||||
|
bb.push(b);
|
||||||
|
}
|
||||||
|
assert(bb.length === dataUsedBits!);
|
||||||
|
|
||||||
|
// Add terminator and pad up to a byte if applicable
|
||||||
|
const dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
|
||||||
|
assert(bb.length <= dataCapacityBits);
|
||||||
|
appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
|
||||||
|
appendBits(0, (8 - bb.length % 8) % 8, bb);
|
||||||
|
assert(bb.length % 8 === 0);
|
||||||
|
|
||||||
|
// Pad with alternating bytes until data capacity is reached
|
||||||
|
for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
|
||||||
|
appendBits(padByte, 8, bb);
|
||||||
|
|
||||||
|
// Pack bits into bytes in big endian
|
||||||
|
const dataCodewords = Array.from({ length: Math.ceil(bb.length / 8) }, () => 0);
|
||||||
|
for (let i = 0; i < bb.length; i++)
|
||||||
|
dataCodewords[i >>> 3]! |= bb[i]! << (7 - (i & 7));
|
||||||
|
|
||||||
|
// Create the QR Code object
|
||||||
|
return new QrCode(version, ecl, dataCodewords, mask);
|
||||||
|
}
|
||||||
8
core/encoding/src/qr/index.ts
Normal file
8
core/encoding/src/qr/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { QrCodeDataType } from './types';
|
||||||
|
export type { QrCodeEcc, QrSegmentMode } from './types';
|
||||||
|
|
||||||
|
export { EccMap, LOW, MEDIUM, QUARTILE, HIGH } from './constants';
|
||||||
|
|
||||||
|
export { QrCode } from './qr-code';
|
||||||
|
export { QrSegment, makeBytes, makeSegments, isNumeric, isAlphanumeric } from './segment';
|
||||||
|
export { encodeText, encodeBinary, encodeSegments } from './encode';
|
||||||
428
core/encoding/src/qr/qr-code.ts
Normal file
428
core/encoding/src/qr/qr-code.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
/*
|
||||||
|
* QR Code generator — core QrCode class
|
||||||
|
*
|
||||||
|
* Based on Project Nayuki's QR Code generator library (MIT License)
|
||||||
|
* https://www.nayuki.io/page/qr-code-generator-library
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { QrCodeEcc } from './types';
|
||||||
|
import { QrCodeDataType } from './types';
|
||||||
|
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS, PENALTY_N1, PENALTY_N2, PENALTY_N3, PENALTY_N4 } from './constants';
|
||||||
|
import { assert, getBit, getNumDataCodewords, getNumRawDataModules } from './utils';
|
||||||
|
import { computeDivisor, computeRemainder } from '../reed-solomon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A QR Code symbol, which is a type of two-dimension barcode.
|
||||||
|
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
|
||||||
|
* Instances of this class represent an immutable square grid of dark and light cells.
|
||||||
|
*/
|
||||||
|
export class QrCode {
|
||||||
|
/** The width and height of this QR Code, measured in modules, between 21 and 177 (inclusive). */
|
||||||
|
public readonly size: number;
|
||||||
|
|
||||||
|
/** The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). */
|
||||||
|
public readonly mask: number;
|
||||||
|
|
||||||
|
/** The modules of this QR Code (0 = light, 1 = dark). Flat row-major Uint8Array. */
|
||||||
|
private readonly modules: Uint8Array;
|
||||||
|
|
||||||
|
/** Data type of each module. Flat row-major Int8Array. */
|
||||||
|
private readonly types: Int8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new QR Code with the given version number, error correction level, data codeword bytes, and mask number.
|
||||||
|
* This is a low-level API that most users should not use directly.
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
/** The version number of this QR Code, which is between 1 and 40 (inclusive). */
|
||||||
|
public readonly version: number,
|
||||||
|
/** The error correction level used in this QR Code. */
|
||||||
|
public readonly ecc: QrCodeEcc,
|
||||||
|
dataCodewords: Readonly<number[]>,
|
||||||
|
msk: number,
|
||||||
|
) {
|
||||||
|
if (version < MIN_VERSION || version > MAX_VERSION)
|
||||||
|
throw new RangeError('Version value out of range');
|
||||||
|
if (msk < -1 || msk > 7)
|
||||||
|
throw new RangeError('Mask value out of range');
|
||||||
|
this.size = version * 4 + 17;
|
||||||
|
|
||||||
|
const totalModules = this.size * this.size;
|
||||||
|
this.modules = new Uint8Array(totalModules);
|
||||||
|
this.types = new Int8Array(totalModules); // 0 = QrCodeDataType.Data
|
||||||
|
|
||||||
|
// Compute ECC, draw modules
|
||||||
|
this.drawFunctionPatterns();
|
||||||
|
const allCodewords = this.addEccAndInterleave(dataCodewords);
|
||||||
|
this.drawCodewords(allCodewords);
|
||||||
|
|
||||||
|
// Do masking
|
||||||
|
if (msk === -1) {
|
||||||
|
let minPenalty = 1_000_000_000;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
this.applyMask(i);
|
||||||
|
this.drawFormatBits(i);
|
||||||
|
const penalty = this.getPenaltyScore();
|
||||||
|
if (penalty < minPenalty) {
|
||||||
|
msk = i;
|
||||||
|
minPenalty = penalty;
|
||||||
|
}
|
||||||
|
this.applyMask(i); // Undoes the mask due to XOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(msk >= 0 && msk <= 7);
|
||||||
|
this.mask = msk;
|
||||||
|
this.applyMask(msk);
|
||||||
|
this.drawFormatBits(msk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the color of the module (pixel) at the given coordinates.
|
||||||
|
* false for light, true for dark. Out of bounds returns false (light).
|
||||||
|
*/
|
||||||
|
public getModule(x: number, y: number): boolean {
|
||||||
|
return x >= 0 && x < this.size && y >= 0 && y < this.size
|
||||||
|
&& this.modules[y * this.size + x] === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the data type of the module at the given coordinates. */
|
||||||
|
public getType(x: number, y: number): QrCodeDataType {
|
||||||
|
return this.types[y * this.size + x] as QrCodeDataType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Private helper methods for constructor: Drawing function modules -- */
|
||||||
|
|
||||||
|
private drawFunctionPatterns(): void {
|
||||||
|
const size = this.size;
|
||||||
|
// Draw horizontal and vertical timing patterns
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
const dark = (i & 1) ^ 1;
|
||||||
|
this.setFunctionModule(6, i, dark, QrCodeDataType.Timing);
|
||||||
|
this.setFunctionModule(i, 6, dark, QrCodeDataType.Timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw 3 finder patterns (all corners except bottom right)
|
||||||
|
this.drawFinderPattern(3, 3);
|
||||||
|
this.drawFinderPattern(size - 4, 3);
|
||||||
|
this.drawFinderPattern(3, size - 4);
|
||||||
|
|
||||||
|
// Draw numerous alignment patterns
|
||||||
|
const alignPatPos = this.getAlignmentPatternPositions();
|
||||||
|
const numAlign = alignPatPos.length;
|
||||||
|
for (let i = 0; i < numAlign; i++) {
|
||||||
|
for (let j = 0; j < numAlign; j++) {
|
||||||
|
if (!(i === 0 && j === 0 || i === 0 && j === numAlign - 1 || i === numAlign - 1 && j === 0))
|
||||||
|
this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw configuration data
|
||||||
|
this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
|
||||||
|
this.drawVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFormatBits(mask: number): void {
|
||||||
|
const data = this.ecc[1] << 3 | mask;
|
||||||
|
let rem = data;
|
||||||
|
for (let i = 0; i < 10; i++)
|
||||||
|
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
||||||
|
const bits = (data << 10 | rem) ^ 0x5412;
|
||||||
|
assert(bits >>> 15 === 0);
|
||||||
|
|
||||||
|
const size = this.size;
|
||||||
|
// Draw first copy
|
||||||
|
for (let i = 0; i <= 5; i++)
|
||||||
|
this.setFunctionModule(8, i, getBit(bits, i) ? 1 : 0);
|
||||||
|
this.setFunctionModule(8, 7, getBit(bits, 6) ? 1 : 0);
|
||||||
|
this.setFunctionModule(8, 8, getBit(bits, 7) ? 1 : 0);
|
||||||
|
this.setFunctionModule(7, 8, getBit(bits, 8) ? 1 : 0);
|
||||||
|
for (let i = 9; i < 15; i++)
|
||||||
|
this.setFunctionModule(14 - i, 8, getBit(bits, i) ? 1 : 0);
|
||||||
|
|
||||||
|
// Draw second copy
|
||||||
|
for (let i = 0; i < 8; i++)
|
||||||
|
this.setFunctionModule(size - 1 - i, 8, getBit(bits, i) ? 1 : 0);
|
||||||
|
for (let i = 8; i < 15; i++)
|
||||||
|
this.setFunctionModule(8, size - 15 + i, getBit(bits, i) ? 1 : 0);
|
||||||
|
this.setFunctionModule(8, size - 8, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawVersion(): void {
|
||||||
|
if (this.version < 7)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let rem = this.version;
|
||||||
|
for (let i = 0; i < 12; i++)
|
||||||
|
rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
|
||||||
|
const bits = this.version << 12 | rem;
|
||||||
|
assert(bits >>> 18 === 0);
|
||||||
|
|
||||||
|
const size = this.size;
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const color = getBit(bits, i) ? 1 : 0;
|
||||||
|
const a = size - 11 + i % 3;
|
||||||
|
const b = (i / 3) | 0;
|
||||||
|
this.setFunctionModule(a, b, color);
|
||||||
|
this.setFunctionModule(b, a, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFinderPattern(x: number, y: number): void {
|
||||||
|
const size = this.size;
|
||||||
|
for (let dy = -4; dy <= 4; dy++) {
|
||||||
|
for (let dx = -4; dx <= 4; dx++) {
|
||||||
|
const dist = Math.max(Math.abs(dx), Math.abs(dy));
|
||||||
|
const xx = x + dx;
|
||||||
|
const yy = y + dy;
|
||||||
|
if (xx >= 0 && xx < size && yy >= 0 && yy < size)
|
||||||
|
this.setFunctionModule(xx, yy, dist !== 2 && dist !== 4 ? 1 : 0, QrCodeDataType.Position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawAlignmentPattern(x: number, y: number): void {
|
||||||
|
for (let dy = -2; dy <= 2; dy++) {
|
||||||
|
for (let dx = -2; dx <= 2; dx++) {
|
||||||
|
this.setFunctionModule(
|
||||||
|
x + dx,
|
||||||
|
y + dy,
|
||||||
|
Math.max(Math.abs(dx), Math.abs(dy)) !== 1 ? 1 : 0,
|
||||||
|
QrCodeDataType.Alignment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFunctionModule(x: number, y: number, isDark: number, type: QrCodeDataType = QrCodeDataType.Function): void {
|
||||||
|
const idx = y * this.size + x;
|
||||||
|
this.modules[idx] = isDark;
|
||||||
|
this.types[idx] = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Private helper methods for constructor: Codewords and masking -- */
|
||||||
|
|
||||||
|
private addEccAndInterleave(data: Readonly<number[]>): number[] {
|
||||||
|
const ver = this.version;
|
||||||
|
const ecl = this.ecc;
|
||||||
|
if (data.length !== getNumDataCodewords(ver, ecl))
|
||||||
|
throw new RangeError('Invalid argument');
|
||||||
|
|
||||||
|
const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[ecl[0]]![ver]!;
|
||||||
|
const blockEccLen = ECC_CODEWORDS_PER_BLOCK[ecl[0]]![ver]!;
|
||||||
|
const rawCodewords = (getNumRawDataModules(ver) / 8) | 0;
|
||||||
|
const numShortBlocks = numBlocks - rawCodewords % numBlocks;
|
||||||
|
const shortBlockLen = (rawCodewords / numBlocks) | 0;
|
||||||
|
|
||||||
|
// Split data into blocks and append ECC to each block
|
||||||
|
const blocks: number[][] = [];
|
||||||
|
const rsDiv = computeDivisor(blockEccLen);
|
||||||
|
for (let i = 0, k = 0; i < numBlocks; i++) {
|
||||||
|
const dat: number[] = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)) as number[];
|
||||||
|
k += dat.length;
|
||||||
|
const ecc = computeRemainder(dat, rsDiv);
|
||||||
|
if (i < numShortBlocks)
|
||||||
|
dat.push(0);
|
||||||
|
blocks.push([...dat, ...ecc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interleave (not concatenate) the bytes from every block into a single sequence
|
||||||
|
const result: number[] = [];
|
||||||
|
const blockLen = blocks[0]!.length;
|
||||||
|
for (let i = 0; i < blockLen; i++) {
|
||||||
|
for (let j = 0; j < blocks.length; j++) {
|
||||||
|
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks)
|
||||||
|
result.push(blocks[j]![i]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(result.length === rawCodewords);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawCodewords(data: Readonly<number[]>): void {
|
||||||
|
if (data.length !== ((getNumRawDataModules(this.version) / 8) | 0))
|
||||||
|
throw new RangeError('Invalid argument');
|
||||||
|
|
||||||
|
const size = this.size;
|
||||||
|
const modules = this.modules;
|
||||||
|
const types = this.types;
|
||||||
|
let i = 0;
|
||||||
|
for (let right = size - 1; right >= 1; right -= 2) {
|
||||||
|
if (right === 6)
|
||||||
|
right = 5;
|
||||||
|
for (let vert = 0; vert < size; vert++) {
|
||||||
|
for (let j = 0; j < 2; j++) {
|
||||||
|
const x = right - j;
|
||||||
|
const upward = ((right + 1) & 2) === 0;
|
||||||
|
const y = upward ? size - 1 - vert : vert;
|
||||||
|
const idx = y * size + x;
|
||||||
|
if (types[idx] === QrCodeDataType.Data && i < data.length * 8) {
|
||||||
|
modules[idx] = (data[i >>> 3]! >>> (7 - (i & 7))) & 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(i === data.length * 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMask(mask: number): void {
|
||||||
|
if (mask < 0 || mask > 7)
|
||||||
|
throw new RangeError('Mask value out of range');
|
||||||
|
const size = this.size;
|
||||||
|
const modules = this.modules;
|
||||||
|
const types = this.types;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const yOffset = y * size;
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const idx = yOffset + x;
|
||||||
|
if (types[idx] !== QrCodeDataType.Data)
|
||||||
|
continue;
|
||||||
|
let invert: boolean;
|
||||||
|
switch (mask) {
|
||||||
|
case 0: invert = (x + y) % 2 === 0; break;
|
||||||
|
case 1: invert = y % 2 === 0; break;
|
||||||
|
case 2: invert = x % 3 === 0; break;
|
||||||
|
case 3: invert = (x + y) % 3 === 0; break;
|
||||||
|
case 4: invert = (((x / 3) | 0) + ((y / 2) | 0)) % 2 === 0; break;
|
||||||
|
case 5: invert = x * y % 2 + x * y % 3 === 0; break;
|
||||||
|
case 6: invert = (x * y % 2 + x * y % 3) % 2 === 0; break;
|
||||||
|
case 7: invert = ((x + y) % 2 + x * y % 3) % 2 === 0; break;
|
||||||
|
default: throw new Error('Unreachable');
|
||||||
|
}
|
||||||
|
if (invert)
|
||||||
|
modules[idx]! ^= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPenaltyScore(): number {
|
||||||
|
const size = this.size;
|
||||||
|
const modules = this.modules;
|
||||||
|
let result = 0;
|
||||||
|
|
||||||
|
// Adjacent modules in row having same color, and finder-like patterns
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const yOffset = y * size;
|
||||||
|
let runColor = 0;
|
||||||
|
let runX = 0;
|
||||||
|
let h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0;
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const mod = modules[yOffset + x]!;
|
||||||
|
if (mod === runColor) {
|
||||||
|
runX++;
|
||||||
|
if (runX === 5)
|
||||||
|
result += PENALTY_N1;
|
||||||
|
else if (runX > 5)
|
||||||
|
result++;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (h0 === 0) runX += size;
|
||||||
|
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX;
|
||||||
|
if (runColor === 0) {
|
||||||
|
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
|
||||||
|
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
|
||||||
|
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
|
||||||
|
}
|
||||||
|
runColor = mod;
|
||||||
|
runX = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let currentRunLength = runX;
|
||||||
|
if (runColor === 1) {
|
||||||
|
if (h0 === 0) currentRunLength += size;
|
||||||
|
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
|
||||||
|
currentRunLength = 0;
|
||||||
|
}
|
||||||
|
currentRunLength += size;
|
||||||
|
if (h0 === 0) currentRunLength += size;
|
||||||
|
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
|
||||||
|
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
|
||||||
|
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
|
||||||
|
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacent modules in column having same color, and finder-like patterns
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
let runColor = 0;
|
||||||
|
let runY = 0;
|
||||||
|
let h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const mod = modules[y * size + x]!;
|
||||||
|
if (mod === runColor) {
|
||||||
|
runY++;
|
||||||
|
if (runY === 5)
|
||||||
|
result += PENALTY_N1;
|
||||||
|
else if (runY > 5)
|
||||||
|
result++;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (h0 === 0) runY += size;
|
||||||
|
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runY; h0 = runY;
|
||||||
|
if (runColor === 0) {
|
||||||
|
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
|
||||||
|
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
|
||||||
|
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
|
||||||
|
}
|
||||||
|
runColor = mod;
|
||||||
|
runY = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let currentRunLength = runY;
|
||||||
|
if (runColor === 1) {
|
||||||
|
if (h0 === 0) currentRunLength += size;
|
||||||
|
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
|
||||||
|
currentRunLength = 0;
|
||||||
|
}
|
||||||
|
currentRunLength += size;
|
||||||
|
if (h0 === 0) currentRunLength += size;
|
||||||
|
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
|
||||||
|
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
|
||||||
|
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
|
||||||
|
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2*2 blocks of modules having same color
|
||||||
|
for (let y = 0; y < size - 1; y++) {
|
||||||
|
const yOffset = y * size;
|
||||||
|
const nextYOffset = yOffset + size;
|
||||||
|
for (let x = 0; x < size - 1; x++) {
|
||||||
|
const color = modules[yOffset + x]!;
|
||||||
|
if (color === modules[yOffset + x + 1]
|
||||||
|
&& color === modules[nextYOffset + x]
|
||||||
|
&& color === modules[nextYOffset + x + 1])
|
||||||
|
result += PENALTY_N2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance of dark and light modules
|
||||||
|
let dark = 0;
|
||||||
|
const total = size * size;
|
||||||
|
for (let i = 0; i < total; i++)
|
||||||
|
dark += modules[i]!;
|
||||||
|
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
|
||||||
|
assert(k >= 0 && k <= 9);
|
||||||
|
result += k * PENALTY_N4;
|
||||||
|
assert(result >= 0 && result <= 2_568_888);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAlignmentPatternPositions(): number[] {
|
||||||
|
if (this.version === 1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const numAlign = ((this.version / 7) | 0) + 2;
|
||||||
|
const step = (this.version === 32)
|
||||||
|
? 26
|
||||||
|
: Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
|
||||||
|
const result = Array.from<number>({ length: numAlign });
|
||||||
|
result[0] = 6;
|
||||||
|
for (let i = numAlign - 1, pos = this.size - 7; i >= 1; i--, pos -= step)
|
||||||
|
result[i] = pos;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
core/encoding/src/qr/segment.ts
Normal file
82
core/encoding/src/qr/segment.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { QrSegmentMode } from './types';
|
||||||
|
import { ALPHANUMERIC_MAP, ALPHANUMERIC_REGEX, MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC, NUMERIC_REGEX } from './constants';
|
||||||
|
import { appendBits, toUtf8ByteArray } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A segment of character/binary/control data in a QR Code symbol.
|
||||||
|
* Instances of this class are immutable.
|
||||||
|
*/
|
||||||
|
export class QrSegment {
|
||||||
|
public constructor(
|
||||||
|
/** The mode indicator of this segment. */
|
||||||
|
public readonly mode: QrSegmentMode,
|
||||||
|
/** The length of this segment's unencoded data. */
|
||||||
|
public readonly numChars: number,
|
||||||
|
/** The data bits of this segment. */
|
||||||
|
public readonly bitData: readonly number[],
|
||||||
|
) {
|
||||||
|
if (numChars < 0)
|
||||||
|
throw new RangeError('Invalid argument');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a segment representing the given binary data encoded in byte mode. */
|
||||||
|
export function makeBytes(data: ArrayLike<number>): QrSegment {
|
||||||
|
const bb: number[] = [];
|
||||||
|
for (let i = 0, len = data.length; i < len; i++)
|
||||||
|
appendBits(data[i]!, 8, bb);
|
||||||
|
return new QrSegment(MODE_BYTE, data.length, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a segment representing the given string of decimal digits encoded in numeric mode. */
|
||||||
|
export function makeNumeric(digits: string): QrSegment {
|
||||||
|
if (!isNumeric(digits))
|
||||||
|
throw new RangeError('String contains non-numeric characters');
|
||||||
|
const bb: number[] = [];
|
||||||
|
for (let i = 0; i < digits.length;) {
|
||||||
|
const n = Math.min(digits.length - i, 3);
|
||||||
|
appendBits(Number.parseInt(digits.slice(i, i + n), 10), n * 3 + 1, bb);
|
||||||
|
i += n;
|
||||||
|
}
|
||||||
|
return new QrSegment(MODE_NUMERIC, digits.length, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a segment representing the given text string encoded in alphanumeric mode. */
|
||||||
|
export function makeAlphanumeric(text: string): QrSegment {
|
||||||
|
if (!isAlphanumeric(text))
|
||||||
|
throw new RangeError('String contains unencodable characters in alphanumeric mode');
|
||||||
|
const bb: number[] = [];
|
||||||
|
let i: number;
|
||||||
|
for (i = 0; i + 2 <= text.length; i += 2) {
|
||||||
|
let temp = ALPHANUMERIC_MAP[text.charCodeAt(i)]! * 45;
|
||||||
|
temp += ALPHANUMERIC_MAP[text.charCodeAt(i + 1)]!;
|
||||||
|
appendBits(temp, 11, bb);
|
||||||
|
}
|
||||||
|
if (i < text.length)
|
||||||
|
appendBits(ALPHANUMERIC_MAP[text.charCodeAt(i)]!, 6, bb);
|
||||||
|
return new QrSegment(MODE_ALPHANUMERIC, text.length, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new mutable list of zero or more segments to represent the given Unicode text string.
|
||||||
|
* The result may use various segment modes and switch modes to optimize the length of the bit stream.
|
||||||
|
*/
|
||||||
|
export function makeSegments(text: string): QrSegment[] {
|
||||||
|
if (text === '')
|
||||||
|
return [];
|
||||||
|
if (isNumeric(text))
|
||||||
|
return [makeNumeric(text)];
|
||||||
|
if (isAlphanumeric(text))
|
||||||
|
return [makeAlphanumeric(text)];
|
||||||
|
return [makeBytes(toUtf8ByteArray(text))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tests whether the given string can be encoded as a segment in numeric mode. */
|
||||||
|
export function isNumeric(text: string): boolean {
|
||||||
|
return NUMERIC_REGEX.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tests whether the given string can be encoded as a segment in alphanumeric mode. */
|
||||||
|
export function isAlphanumeric(text: string): boolean {
|
||||||
|
return ALPHANUMERIC_REGEX.test(text);
|
||||||
|
}
|
||||||
17
core/encoding/src/qr/types.ts
Normal file
17
core/encoding/src/qr/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type QrCodeEcc = readonly [ordinal: number, formatBits: number];
|
||||||
|
|
||||||
|
export type QrSegmentMode = [
|
||||||
|
modeBits: number,
|
||||||
|
numBitsCharCount1: number,
|
||||||
|
numBitsCharCount2: number,
|
||||||
|
numBitsCharCount3: number,
|
||||||
|
];
|
||||||
|
|
||||||
|
export enum QrCodeDataType {
|
||||||
|
Border = -1,
|
||||||
|
Data = 0,
|
||||||
|
Function = 1,
|
||||||
|
Position = 2,
|
||||||
|
Timing = 3,
|
||||||
|
Alignment = 4,
|
||||||
|
}
|
||||||
79
core/encoding/src/qr/utils.ts
Normal file
79
core/encoding/src/qr/utils.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { QrCodeEcc, QrSegmentMode } from './types';
|
||||||
|
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS } from './constants';
|
||||||
|
import type { QrSegment } from './segment';
|
||||||
|
|
||||||
|
const utf8Encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/** Appends the given number of low-order bits of the given value to the buffer. */
|
||||||
|
export function appendBits(val: number, len: number, bb: number[]): void {
|
||||||
|
if (len < 0 || len > 31 || val >>> len !== 0)
|
||||||
|
throw new RangeError('Value out of range');
|
||||||
|
for (let i = len - 1; i >= 0; i--)
|
||||||
|
bb.push((val >>> i) & 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true iff the i'th bit of x is set to 1. */
|
||||||
|
export function getBit(x: number, i: number): boolean {
|
||||||
|
return ((x >>> i) & 1) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws an exception if the given condition is false. */
|
||||||
|
export function assert(cond: boolean): asserts cond {
|
||||||
|
if (!cond)
|
||||||
|
throw new Error('Assertion error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a Uint8Array representing the given string encoded in UTF-8. */
|
||||||
|
export function toUtf8ByteArray(str: string): Uint8Array {
|
||||||
|
return utf8Encoder.encode(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the bit width of the character count field for a segment in this mode at the given version number. */
|
||||||
|
export function numCharCountBits(mode: QrSegmentMode, ver: number): number {
|
||||||
|
return mode[((ver + 7) / 17 | 0) + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of data bits that can be stored in a QR Code of the given version number,
|
||||||
|
* after all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
|
||||||
|
* The result is in the range [208, 29648].
|
||||||
|
*/
|
||||||
|
export function getNumRawDataModules(ver: number): number {
|
||||||
|
if (ver < MIN_VERSION || ver > MAX_VERSION)
|
||||||
|
throw new RangeError('Version number out of range');
|
||||||
|
|
||||||
|
let result = (16 * ver + 128) * ver + 64;
|
||||||
|
if (ver >= 2) {
|
||||||
|
const numAlign = (ver / 7 | 0) + 2;
|
||||||
|
result -= (25 * numAlign - 10) * numAlign - 55;
|
||||||
|
if (ver >= 7)
|
||||||
|
result -= 36;
|
||||||
|
}
|
||||||
|
assert(result >= 208 && result <= 29648);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
|
||||||
|
* QR Code of the given version number and error correction level, with remainder bits discarded.
|
||||||
|
*/
|
||||||
|
export function getNumDataCodewords(ver: number, ecl: QrCodeEcc): number {
|
||||||
|
return (getNumRawDataModules(ver) / 8 | 0)
|
||||||
|
- ECC_CODEWORDS_PER_BLOCK[ecl[0]]![ver]!
|
||||||
|
* NUM_ERROR_CORRECTION_BLOCKS[ecl[0]]![ver]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates and returns the number of bits needed to encode the given segments at the given version.
|
||||||
|
* The result is infinity if a segment has too many characters to fit its length field.
|
||||||
|
*/
|
||||||
|
export function getTotalBits(segs: Readonly<QrSegment[]>, version: number): number {
|
||||||
|
let result = 0;
|
||||||
|
for (const seg of segs) {
|
||||||
|
const ccbits = numCharCountBits(seg.mode, version);
|
||||||
|
if (seg.numChars >= (1 << ccbits))
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
result += 4 + ccbits + seg.bitData.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
115
core/encoding/src/reed-solomon/__test__/index.test.ts
Normal file
115
core/encoding/src/reed-solomon/__test__/index.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeDivisor, computeRemainder, multiply } from '..';
|
||||||
|
|
||||||
|
describe('multiply', () => {
|
||||||
|
it('multiplies zero by anything to get zero', () => {
|
||||||
|
expect(multiply(0, 0)).toBe(0);
|
||||||
|
expect(multiply(0, 1)).toBe(0);
|
||||||
|
expect(multiply(0, 255)).toBe(0);
|
||||||
|
expect(multiply(1, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiplies by one (identity)', () => {
|
||||||
|
expect(multiply(1, 1)).toBe(1);
|
||||||
|
expect(multiply(1, 42)).toBe(42);
|
||||||
|
expect(multiply(42, 1)).toBe(42);
|
||||||
|
expect(multiply(1, 255)).toBe(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is commutative', () => {
|
||||||
|
expect(multiply(5, 7)).toBe(multiply(7, 5));
|
||||||
|
expect(multiply(0x53, 0xCA)).toBe(multiply(0xCA, 0x53));
|
||||||
|
expect(multiply(100, 200)).toBe(multiply(200, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces known GF(2^8) products', () => {
|
||||||
|
expect(multiply(2, 2)).toBe(4);
|
||||||
|
expect(multiply(2, 0x80)).toBe(0x1D);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on out of range inputs', () => {
|
||||||
|
expect(() => multiply(256, 0)).toThrow(RangeError);
|
||||||
|
expect(() => multiply(0, 256)).toThrow(RangeError);
|
||||||
|
expect(() => multiply(1000, 1000)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeDivisor', () => {
|
||||||
|
it('computes a degree-1 divisor', () => {
|
||||||
|
expect(computeDivisor(1)).toEqual(Uint8Array.from([1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a degree-2 divisor', () => {
|
||||||
|
const result = computeDivisor(2);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result).toEqual(Uint8Array.from([3, 2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct length for arbitrary degrees', () => {
|
||||||
|
expect(computeDivisor(7)).toHaveLength(7);
|
||||||
|
expect(computeDivisor(10)).toHaveLength(10);
|
||||||
|
expect(computeDivisor(30)).toHaveLength(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Uint8Array', () => {
|
||||||
|
expect(computeDivisor(5)).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on degree out of range', () => {
|
||||||
|
expect(() => computeDivisor(0)).toThrow(RangeError);
|
||||||
|
expect(() => computeDivisor(256)).toThrow(RangeError);
|
||||||
|
expect(() => computeDivisor(-1)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeRemainder', () => {
|
||||||
|
it('returns zero remainder for empty data', () => {
|
||||||
|
const divisor = computeDivisor(4);
|
||||||
|
const result = computeRemainder([], divisor);
|
||||||
|
expect(result).toEqual(new Uint8Array(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces non-zero remainder for non-empty data', () => {
|
||||||
|
const divisor = computeDivisor(7);
|
||||||
|
const data = [0x40, 0xD2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xC6, 0xC6, 0x96, 0x70, 0xEC];
|
||||||
|
const result = computeRemainder(data, divisor);
|
||||||
|
expect(result).toHaveLength(7);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
for (const b of result) {
|
||||||
|
expect(b).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(b).toBeLessThanOrEqual(255);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts Uint8Array as data input', () => {
|
||||||
|
const divisor = computeDivisor(7);
|
||||||
|
const data = Uint8Array.from([0x40, 0xD2, 0x75, 0x47]);
|
||||||
|
const result = computeRemainder(data, divisor);
|
||||||
|
expect(result).toHaveLength(7);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remainder length matches divisor length', () => {
|
||||||
|
for (const degree of [1, 5, 10, 20]) {
|
||||||
|
const divisor = computeDivisor(degree);
|
||||||
|
const data = [1, 2, 3, 4, 5];
|
||||||
|
const result = computeRemainder(data, divisor);
|
||||||
|
expect(result).toHaveLength(degree);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces correct ECC for QR Version 1-M reference data', () => {
|
||||||
|
const data = [0x40, 0xD2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xC6, 0xC6, 0x96, 0x70, 0xEC];
|
||||||
|
const divisor = computeDivisor(10);
|
||||||
|
const result = computeRemainder(data, divisor);
|
||||||
|
expect(result).toEqual(Uint8Array.from([188, 42, 144, 19, 107, 175, 239, 253, 75, 224]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic', () => {
|
||||||
|
const data = [0x10, 0x20, 0x30, 0x40, 0x50];
|
||||||
|
const divisor = computeDivisor(7);
|
||||||
|
const a = computeRemainder(data, divisor);
|
||||||
|
const b = computeRemainder(data, divisor);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
core/encoding/src/reed-solomon/index.ts
Normal file
92
core/encoding/src/reed-solomon/index.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* Reed-Solomon error correction over GF(2^8/0x11D)
|
||||||
|
*
|
||||||
|
* Based on Project Nayuki's QR Code generator library (MIT License)
|
||||||
|
* https://www.nayuki.io/page/qr-code-generator-library
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* -- GF(2^8) exp/log lookup tables (generator α=0x02, primitive polynomial 0x11D) -- */
|
||||||
|
|
||||||
|
const GF_EXP = new Uint8Array(256);
|
||||||
|
const GF_LOG = new Uint8Array(256);
|
||||||
|
|
||||||
|
{
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) {
|
||||||
|
GF_EXP[i] = x;
|
||||||
|
GF_LOG[x] = i;
|
||||||
|
x = (x << 1) ^ ((x >>> 7) * 0x11D);
|
||||||
|
}
|
||||||
|
GF_EXP[255] = GF_EXP[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the product of the two given field elements modulo GF(2^8/0x11D).
|
||||||
|
* The arguments and result are unsigned 8-bit integers.
|
||||||
|
*/
|
||||||
|
export function multiply(x: number, y: number): number {
|
||||||
|
if (x >>> 8 !== 0 || y >>> 8 !== 0)
|
||||||
|
throw new RangeError('Byte out of range');
|
||||||
|
|
||||||
|
if (x === 0 || y === 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return GF_EXP[(GF_LOG[x]! + GF_LOG[y]!) % 255]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Reed-Solomon ECC generator polynomial for the given degree.
|
||||||
|
*
|
||||||
|
* Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
|
||||||
|
* For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
|
||||||
|
*/
|
||||||
|
export function computeDivisor(degree: number): Uint8Array {
|
||||||
|
if (degree < 1 || degree > 255)
|
||||||
|
throw new RangeError('Degree out of range');
|
||||||
|
|
||||||
|
const result = new Uint8Array(degree);
|
||||||
|
result[degree - 1] = 1;
|
||||||
|
|
||||||
|
// Compute the product polynomial (x - r^0) * (x - r^1) * ... * (x - r^{degree-1}),
|
||||||
|
// dropping the leading term which is always 1x^degree.
|
||||||
|
// r = 0x02, a generator element of GF(2^8/0x11D).
|
||||||
|
let root = 0; // GF_LOG[1] = 0, i.e. α^0 = 1
|
||||||
|
for (let i = 0; i < degree; i++) {
|
||||||
|
// Multiply the current product by (x - r^i)
|
||||||
|
for (let j = 0; j < degree; j++) {
|
||||||
|
// result[j] = multiply(result[j], α^root) — inlined for performance
|
||||||
|
if (result[j] !== 0)
|
||||||
|
result[j] = GF_EXP[(GF_LOG[result[j]!]! + root) % 255]!;
|
||||||
|
if (j + 1 < degree)
|
||||||
|
result[j]! ^= result[j + 1]!;
|
||||||
|
}
|
||||||
|
root = (root + 1) % 255; // root tracks log(α^i) = i mod 255
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
|
||||||
|
*/
|
||||||
|
export function computeRemainder(data: ArrayLike<number>, divisor: Uint8Array): Uint8Array {
|
||||||
|
const len = divisor.length;
|
||||||
|
const result = new Uint8Array(len);
|
||||||
|
|
||||||
|
for (let d = 0, dLen = data.length; d < dLen; d++) {
|
||||||
|
const factor = data[d]! ^ result[0]!;
|
||||||
|
// Shift left by 1 position (native memcpy)
|
||||||
|
result.copyWithin(0, 1);
|
||||||
|
result[len - 1] = 0;
|
||||||
|
// XOR with divisor scaled by factor — inlined GF multiply for performance
|
||||||
|
if (factor !== 0) {
|
||||||
|
const logFactor = GF_LOG[factor]!;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if (divisor[i] !== 0)
|
||||||
|
result[i]! ^= GF_EXP[(GF_LOG[divisor[i]!]! + logFactor) % 255]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
3
core/encoding/tsconfig.json
Normal file
3
core/encoding/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||||
|
}
|
||||||
7
core/encoding/tsdown.config.ts
Normal file
7
core/encoding/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
});
|
||||||
7
core/encoding/vitest.config.ts
Normal file
7
core/encoding/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { defineConfig } from 'oxlint';
|
import { defineConfig } from 'oxlint';
|
||||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
compose(base, typescript, imports, {
|
compose(base, typescript, imports, stylistic, {
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['src/multi/global/index.ts'],
|
files: ['src/multi/global/index.ts'],
|
||||||
rules: {
|
rules: {
|
||||||
'unicorn/prefer-global-this': 'off',
|
'unicorn/prefer-global-this': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "packages/platform"
|
"directory": "packages/platform"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
@@ -28,18 +28,29 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
"./browsers": {
|
"./browsers": {
|
||||||
"types": "./dist/browsers.d.ts",
|
"import": {
|
||||||
"import": "./dist/browsers.js",
|
"types": "./dist/browsers.d.mts",
|
||||||
"require": "./dist/browsers.cjs"
|
"default": "./dist/browsers.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/browsers.d.cts",
|
||||||
|
"default": "./dist/browsers.cjs"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"./multi": {
|
"./multi": {
|
||||||
"types": "./dist/multi.d.ts",
|
"import": {
|
||||||
"import": "./dist/multi.js",
|
"types": "./dist/multi.d.mts",
|
||||||
"require": "./dist/multi.cjs"
|
"default": "./dist/multi.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/multi.d.cts",
|
||||||
|
"default": "./dist/multi.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "oxlint -c oxlint.config.ts",
|
"lint:check": "oxlint -c oxlint.config.ts",
|
||||||
|
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vitest dev",
|
"dev": "vitest dev",
|
||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
@@ -48,6 +59,7 @@
|
|||||||
"@robonen/oxlint": "workspace:*",
|
"@robonen/oxlint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
139
core/platform/src/browsers/animationLifecycle/index.test.ts
Normal file
139
core/platform/src/browsers/animationLifecycle/index.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
getAnimationName,
|
||||||
|
isAnimatable,
|
||||||
|
shouldSuspendUnmount,
|
||||||
|
dispatchAnimationEvent,
|
||||||
|
onAnimationSettle,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
describe('getAnimationName', () => {
|
||||||
|
it('returns "none" for undefined element', () => {
|
||||||
|
expect(getAnimationName(undefined)).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the animation name from inline style', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.animationName = 'fadeIn';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
expect(getAnimationName(el)).toBe('fadeIn');
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAnimatable', () => {
|
||||||
|
it('returns false for undefined element', () => {
|
||||||
|
expect(isAnimatable(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for element with no animation or transition', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
expect(isAnimatable(el)).toBe(false);
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldSuspendUnmount', () => {
|
||||||
|
it('returns false for undefined element', () => {
|
||||||
|
expect(shouldSuspendUnmount(undefined, 'none')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for element with no animation/transition', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
expect(shouldSuspendUnmount(el, 'none')).toBe(false);
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispatchAnimationEvent', () => {
|
||||||
|
it('dispatches a custom event on the element', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
el.addEventListener('enter', handler);
|
||||||
|
dispatchAnimationEvent(el, 'enter');
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw for undefined element', () => {
|
||||||
|
expect(() => dispatchAnimationEvent(undefined, 'leave')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches non-bubbling event', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const parent = document.createElement('div');
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
parent.appendChild(el);
|
||||||
|
parent.addEventListener('enter', handler);
|
||||||
|
dispatchAnimationEvent(el, 'enter');
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAnimationSettle', () => {
|
||||||
|
it('returns a cleanup function', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const cleanup = onAnimationSettle(el, { onSettle: vi.fn() });
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSettle callback on transitionend', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
onAnimationSettle(el, { onSettle: callback });
|
||||||
|
el.dispatchEvent(new Event('transitionend'));
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSettle callback on transitioncancel', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
onAnimationSettle(el, { onSettle: callback });
|
||||||
|
el.dispatchEvent(new Event('transitioncancel'));
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onStart callback on animationstart', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const startCallback = vi.fn();
|
||||||
|
|
||||||
|
onAnimationSettle(el, {
|
||||||
|
onSettle: vi.fn(),
|
||||||
|
onStart: startCallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
el.dispatchEvent(new Event('animationstart'));
|
||||||
|
|
||||||
|
expect(startCallback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes all listeners on cleanup', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const cleanup = onAnimationSettle(el, { onSettle: callback });
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
el.dispatchEvent(new Event('transitionend'));
|
||||||
|
el.dispatchEvent(new Event('transitioncancel'));
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
core/platform/src/browsers/animationLifecycle/index.ts
Normal file
139
core/platform/src/browsers/animationLifecycle/index.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
export type AnimationLifecycleEvent = 'enter' | 'after-enter' | 'leave' | 'after-leave';
|
||||||
|
|
||||||
|
export interface AnimationSettleCallbacks {
|
||||||
|
onSettle: () => void;
|
||||||
|
onStart?: (animationName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getAnimationName
|
||||||
|
* @category Browsers
|
||||||
|
* @description Returns the current CSS animation name(s) of an element
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getAnimationName(el: HTMLElement | undefined): string {
|
||||||
|
return el ? getComputedStyle(el).animationName || 'none' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isAnimatable
|
||||||
|
* @category Browsers
|
||||||
|
* @description Checks whether an element has a running CSS animation or transition
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isAnimatable(el: HTMLElement | undefined): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const animationName = style.animationName || 'none';
|
||||||
|
const transitionProperty = style.transitionProperty || 'none';
|
||||||
|
|
||||||
|
const hasAnimation = animationName !== 'none' && animationName !== '';
|
||||||
|
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
|
||||||
|
|
||||||
|
return hasAnimation || hasTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name shouldSuspendUnmount
|
||||||
|
* @category Browsers
|
||||||
|
* @description Determines whether unmounting should be delayed due to a running animation/transition change
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function shouldSuspendUnmount(el: HTMLElement | undefined, prevAnimationName: string): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
|
||||||
|
if (style.display === 'none') return false;
|
||||||
|
|
||||||
|
const animationName = style.animationName || 'none';
|
||||||
|
const transitionProperty = style.transitionProperty || 'none';
|
||||||
|
|
||||||
|
const hasAnimation = animationName !== 'none' && animationName !== '';
|
||||||
|
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
|
||||||
|
|
||||||
|
if (!hasAnimation && !hasTransition) return false;
|
||||||
|
|
||||||
|
return prevAnimationName !== animationName || hasTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name dispatchAnimationEvent
|
||||||
|
* @category Browsers
|
||||||
|
* @description Dispatches a non-bubbling custom event on an element for animation lifecycle tracking
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function dispatchAnimationEvent(el: HTMLElement | undefined, name: AnimationLifecycleEvent): void {
|
||||||
|
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onAnimationSettle
|
||||||
|
* @category Browsers
|
||||||
|
* @description Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function.
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function onAnimationSettle(el: HTMLElement, callbacks: AnimationSettleCallbacks): () => void {
|
||||||
|
let fillModeTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const handleAnimationEnd = (event: AnimationEvent) => {
|
||||||
|
const currentAnimationName = getAnimationName(el);
|
||||||
|
const isCurrentAnimation = currentAnimationName.includes(CSS.escape(event.animationName));
|
||||||
|
|
||||||
|
if (event.target === el && isCurrentAnimation) {
|
||||||
|
callbacks.onSettle();
|
||||||
|
|
||||||
|
if (fillModeTimeoutId !== undefined) {
|
||||||
|
clearTimeout(fillModeTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFillMode = el.style.animationFillMode;
|
||||||
|
el.style.animationFillMode = 'forwards';
|
||||||
|
|
||||||
|
fillModeTimeoutId = setTimeout(() => {
|
||||||
|
if (el.style.animationFillMode === 'forwards') {
|
||||||
|
el.style.animationFillMode = currentFillMode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (event.target === el && currentAnimationName === 'none') {
|
||||||
|
callbacks.onSettle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimationStart = (event: AnimationEvent) => {
|
||||||
|
if (event.target === el) {
|
||||||
|
callbacks.onStart?.(getAnimationName(el));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransitionEnd = (event: TransitionEvent) => {
|
||||||
|
if (event.target === el) {
|
||||||
|
callbacks.onSettle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('animationstart', handleAnimationStart, { passive: true });
|
||||||
|
el.addEventListener('animationcancel', handleAnimationEnd, { passive: true });
|
||||||
|
el.addEventListener('animationend', handleAnimationEnd, { passive: true });
|
||||||
|
el.addEventListener('transitioncancel', handleTransitionEnd, { passive: true });
|
||||||
|
el.addEventListener('transitionend', handleTransitionEnd, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('animationstart', handleAnimationStart);
|
||||||
|
el.removeEventListener('animationcancel', handleAnimationEnd);
|
||||||
|
el.removeEventListener('animationend', handleAnimationEnd);
|
||||||
|
el.removeEventListener('transitioncancel', handleTransitionEnd);
|
||||||
|
el.removeEventListener('transitionend', handleTransitionEnd);
|
||||||
|
|
||||||
|
if (fillModeTimeoutId !== undefined) {
|
||||||
|
clearTimeout(fillModeTimeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export function focusGuard(namespace = 'focus-guard') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeGuard = () => {
|
const removeGuard = () => {
|
||||||
document.querySelectorAll(`[${guardAttr}]`).forEach((element) => element.remove());
|
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { afterEach, describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getActiveElement,
|
||||||
|
getTabbableCandidates,
|
||||||
|
getTabbableEdges,
|
||||||
|
focusFirst,
|
||||||
|
focus,
|
||||||
|
isHidden,
|
||||||
|
isSelectableInput,
|
||||||
|
AUTOFOCUS_ON_MOUNT,
|
||||||
|
AUTOFOCUS_ON_UNMOUNT,
|
||||||
|
EVENT_OPTIONS,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
function createContainer(html: string): HTMLElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
it('exports correct event names', () => {
|
||||||
|
expect(AUTOFOCUS_ON_MOUNT).toBe('focusScope.autoFocusOnMount');
|
||||||
|
expect(AUTOFOCUS_ON_UNMOUNT).toBe('focusScope.autoFocusOnUnmount');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports correct event options', () => {
|
||||||
|
expect(EVENT_OPTIONS).toEqual({ bubbles: false, cancelable: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActiveElement', () => {
|
||||||
|
it('returns document.body when nothing is focused', () => {
|
||||||
|
const active = getActiveElement();
|
||||||
|
expect(active).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the focused element', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
expect(getActiveElement()).toBe(input);
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTabbableCandidates', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focusable elements with tabindex >= 0', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<input type="text" />
|
||||||
|
<button>Click</button>
|
||||||
|
<a href="#">Link</a>
|
||||||
|
<div tabindex="0">Div</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const candidates = getTabbableCandidates(container);
|
||||||
|
expect(candidates.length).toBe(4);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips disabled elements', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<button disabled>Disabled</button>
|
||||||
|
<input type="text" />
|
||||||
|
`);
|
||||||
|
|
||||||
|
const candidates = getTabbableCandidates(container);
|
||||||
|
expect(candidates.length).toBe(1);
|
||||||
|
expect(candidates[0]!.tagName).toBe('INPUT');
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips hidden inputs', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<input type="hidden" />
|
||||||
|
<input type="text" />
|
||||||
|
`);
|
||||||
|
|
||||||
|
const candidates = getTabbableCandidates(container);
|
||||||
|
expect(candidates.length).toBe(1);
|
||||||
|
expect((candidates[0] as HTMLInputElement).type).toBe('text');
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips elements with hidden attribute', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<input type="text" hidden />
|
||||||
|
<input type="text" />
|
||||||
|
`);
|
||||||
|
|
||||||
|
const candidates = getTabbableCandidates(container);
|
||||||
|
expect(candidates.length).toBe(1);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for container with no focusable elements', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<div>Just text</div>
|
||||||
|
<span>More text</span>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const candidates = getTabbableCandidates(container);
|
||||||
|
expect(candidates.length).toBe(0);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTabbableEdges', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first and last tabbable elements', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<input type="text" data-testid="first" />
|
||||||
|
<button>Middle</button>
|
||||||
|
<input type="text" data-testid="last" />
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { first, last } = getTabbableEdges(container);
|
||||||
|
expect(first?.getAttribute('data-testid')).toBe('first');
|
||||||
|
expect(last?.getAttribute('data-testid')).toBe('last');
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for both when no tabbable elements', () => {
|
||||||
|
const container = createContainer(`<div>no focusable</div>`);
|
||||||
|
|
||||||
|
const { first, last } = getTabbableEdges(container);
|
||||||
|
expect(first).toBeUndefined();
|
||||||
|
expect(last).toBeUndefined();
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focusFirst', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the first element in the list', () => {
|
||||||
|
const container = createContainer(`
|
||||||
|
<input type="text" data-testid="a" />
|
||||||
|
<input type="text" data-testid="b" />
|
||||||
|
`);
|
||||||
|
|
||||||
|
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
|
||||||
|
focusFirst(candidates);
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(candidates[0]);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when focus changed', () => {
|
||||||
|
const container = createContainer(`<input type="text" />`);
|
||||||
|
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
|
||||||
|
|
||||||
|
const result = focusFirst(candidates);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when no candidate receives focus', () => {
|
||||||
|
const result = focusFirst([]);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focus', () => {
|
||||||
|
it('does nothing when element is null', () => {
|
||||||
|
expect(() => focus(null)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the given element', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
focus(input);
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls select on input when select=true', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = 'hello';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
focus(input, { select: true });
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSelectableInput', () => {
|
||||||
|
it('returns true for input elements', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
expect(isSelectableInput(input)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-input elements', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
expect(isSelectableInput(div)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHidden', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects elements with visibility: hidden', () => {
|
||||||
|
const container = createContainer('');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
expect(isHidden(el)).toBe(true);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects elements with display: none', () => {
|
||||||
|
const container = createContainer('');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.display = 'none';
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
expect(isHidden(el)).toBe(true);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for visible elements', () => {
|
||||||
|
const container = createContainer('');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
expect(isHidden(el, container)).toBe(false);
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
168
core/platform/src/browsers/focusScope/index.ts
Normal file
168
core/platform/src/browsers/focusScope/index.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
export type FocusableTarget = HTMLElement | { focus: () => void };
|
||||||
|
|
||||||
|
export const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
|
||||||
|
export const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
|
||||||
|
export const EVENT_OPTIONS = { bubbles: false, cancelable: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getActiveElement
|
||||||
|
* @category Browsers
|
||||||
|
* @description Returns the active element of the document (or shadow root)
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getActiveElement(doc: Document | ShadowRoot = document): HTMLElement | null {
|
||||||
|
let active = doc.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
while (active?.shadowRoot)
|
||||||
|
active = active.shadowRoot.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isSelectableInput
|
||||||
|
* @category Browsers
|
||||||
|
* @description Checks if an element is an input element with a select method
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isSelectableInput(element: unknown): element is FocusableTarget & { select: () => void } {
|
||||||
|
return element instanceof HTMLInputElement && 'select' in element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name focus
|
||||||
|
* @category Browsers
|
||||||
|
* @description Focuses an element without scrolling. Optionally calls select on input elements.
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function focus(element?: FocusableTarget | null, { select = false } = {}) {
|
||||||
|
if (element && element.focus) {
|
||||||
|
const previouslyFocused = getActiveElement();
|
||||||
|
|
||||||
|
element.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
if (element !== previouslyFocused && isSelectableInput(element) && select) {
|
||||||
|
element.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name focusFirst
|
||||||
|
* @category Browsers
|
||||||
|
* @description Attempts to focus the first element from a list of candidates. Stops when focus actually moves.
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function focusFirst(candidates: HTMLElement[], { select = false } = {}): boolean {
|
||||||
|
const previouslyFocused = getActiveElement();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
focus(candidate, { select });
|
||||||
|
|
||||||
|
if (getActiveElement() !== previouslyFocused)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getTabbableCandidates
|
||||||
|
* @category Browsers
|
||||||
|
* @description Collects all tabbable candidates via TreeWalker (faster than querySelectorAll).
|
||||||
|
* This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
|
||||||
|
const nodes: HTMLElement[] = [];
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
||||||
|
acceptNode: (node: HTMLElement) => {
|
||||||
|
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
|
||||||
|
|
||||||
|
if ((node as any).disabled || node.hidden || isHiddenInput)
|
||||||
|
return NodeFilter.FILTER_SKIP;
|
||||||
|
|
||||||
|
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
while (walker.nextNode())
|
||||||
|
nodes.push(walker.currentNode as HTMLElement);
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isHidden
|
||||||
|
* @category Browsers
|
||||||
|
* @description Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isHidden(node: HTMLElement, upTo?: HTMLElement): boolean {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
|
||||||
|
if (style.visibility === 'hidden' || style.display === 'none')
|
||||||
|
return true;
|
||||||
|
|
||||||
|
while (node.parentElement) {
|
||||||
|
node = node.parentElement;
|
||||||
|
|
||||||
|
if (upTo !== undefined && node === upTo)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (getComputedStyle(node).display === 'none')
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name findFirstVisible
|
||||||
|
* @category Browsers
|
||||||
|
* @description Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function findFirstVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
|
||||||
|
for (const element of elements) {
|
||||||
|
if (!isHidden(element, container))
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name findLastVisible
|
||||||
|
* @category Browsers
|
||||||
|
* @description Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function findLastVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
|
||||||
|
for (let i = elements.length - 1; i >= 0; i--) {
|
||||||
|
if (!isHidden(elements[i]!, container))
|
||||||
|
return elements[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getTabbableEdges
|
||||||
|
* @category Browsers
|
||||||
|
* @description Returns the first and last tabbable elements inside a container
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getTabbableEdges(container: HTMLElement): { first: HTMLElement | undefined; last: HTMLElement | undefined } {
|
||||||
|
const candidates = getTabbableCandidates(container);
|
||||||
|
const first = findFirstVisible(candidates, container);
|
||||||
|
const last = findLastVisible(candidates, container);
|
||||||
|
|
||||||
|
return { first, last };
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
export * from './animationLifecycle';
|
||||||
export * from './focusGuard';
|
export * from './focusGuard';
|
||||||
|
export * from './focusScope';
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*
|
*
|
||||||
* @since 0.0.1
|
* @since 0.0.1
|
||||||
*/
|
*/
|
||||||
export const _global =
|
export const _global
|
||||||
typeof globalThis !== 'undefined'
|
= typeof globalThis !== 'undefined'
|
||||||
? globalThis
|
? globalThis
|
||||||
: typeof window !== 'undefined'
|
: typeof window !== 'undefined'
|
||||||
? window
|
? window
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'oxlint';
|
import { defineConfig } from 'oxlint';
|
||||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||||
|
|
||||||
export default defineConfig(compose(base, typescript, imports));
|
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "packages/stdlib"
|
"directory": "packages/stdlib"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
@@ -28,13 +28,19 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": {
|
||||||
"import": "./dist/index.js",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "oxlint -c oxlint.config.ts",
|
"lint:check": "oxlint -c oxlint.config.ts",
|
||||||
|
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vitest dev",
|
"dev": "vitest dev",
|
||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
@@ -43,6 +49,7 @@
|
|||||||
"@robonen/oxlint": "workspace:*",
|
"@robonen/oxlint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
* @since 0.0.3
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
export function first<Value>(arr: Value[], defaultValue?: Value) {
|
export function first<Value>(arr: Value[], defaultValue?: Value) {
|
||||||
return arr[0] ?? defaultValue;
|
return arr[0] ?? defaultValue;
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ describe('sum', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('return the sum of all elements using a getValue function', () => {
|
it('return the sum of all elements using a getValue function', () => {
|
||||||
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], (item) => item.value);
|
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], item => item.value);
|
||||||
|
|
||||||
expect(result).toBe(6);
|
expect(result).toBe(6);
|
||||||
});
|
});
|
||||||
@@ -39,7 +39,7 @@ describe('sum', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handle arrays with a getValue function returning floating point numbers', () => {
|
it('handle arrays with a getValue function returning floating point numbers', () => {
|
||||||
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], (item) => item.value);
|
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], item => item.value);
|
||||||
|
|
||||||
expect(result).toBe(7.5);
|
expect(result).toBe(7.5);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('unique', () => {
|
|||||||
it('return an array with unique objects based on id', () => {
|
it('return an array with unique objects based on id', () => {
|
||||||
const result = unique(
|
const result = unique(
|
||||||
[{ id: 1 }, { id: 2 }, { id: 1 }],
|
[{ id: 1 }, { id: 2 }, { id: 1 }],
|
||||||
(item) => item.id,
|
item => item.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
|
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
* @since 0.0.3
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,9 @@ describe('tryIt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handle synchronous functions with errors', () => {
|
it('handle synchronous functions with errors', () => {
|
||||||
const syncFn = (): void => { throw new Error('Test error') };
|
const syncFn = (): void => {
|
||||||
|
throw new Error('Test error');
|
||||||
|
};
|
||||||
const wrappedSyncFn = tryIt(syncFn);
|
const wrappedSyncFn = tryIt(syncFn);
|
||||||
|
|
||||||
const [error, result] = wrappedSyncFn();
|
const [error, result] = wrappedSyncFn();
|
||||||
@@ -34,7 +36,9 @@ describe('tryIt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handle asynchronous functions with errors', async () => {
|
it('handle asynchronous functions with errors', async () => {
|
||||||
const asyncFn = async () => { throw new Error('Test error') };
|
const asyncFn = async () => {
|
||||||
|
throw new Error('Test error');
|
||||||
|
};
|
||||||
const wrappedAsyncFn = tryIt(asyncFn);
|
const wrappedAsyncFn = tryIt(asyncFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedAsyncFn();
|
const [error, result] = await wrappedAsyncFn();
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ export function tryIt<Args extends any[], Return>(
|
|||||||
|
|
||||||
if (isPromise(result))
|
if (isPromise(result))
|
||||||
return result
|
return result
|
||||||
.then((value) => [undefined, value])
|
.then(value => [undefined, value])
|
||||||
.catch((error) => [error, undefined]) as TryItReturn<Return>;
|
.catch(error => [error, undefined]) as TryItReturn<Return>;
|
||||||
|
|
||||||
return [undefined, result] as TryItReturn<Return>;
|
return [undefined, result] as TryItReturn<Return>;
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
return [error, undefined] as TryItReturn<Return>;
|
return [error, undefined] as TryItReturn<Return>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
* @since 0.0.2
|
* @since 0.0.2
|
||||||
*/
|
*/
|
||||||
export function flagsGenerator() {
|
export function flagsGenerator() {
|
||||||
let lastFlag = 0;
|
let lastFlag = 0;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// 31 flags is the maximum number of flags that can be created
|
// 31 flags is the maximum number of flags that can be created
|
||||||
// (without zero) because of the 32-bit integer limit in bitwise operations
|
// (without zero) because of the 32-bit integer limit in bitwise operations
|
||||||
if (lastFlag & 0x40000000)
|
if (lastFlag & 0x40000000)
|
||||||
throw new RangeError('Cannot create more than 31 flags');
|
throw new RangeError('Cannot create more than 31 flags');
|
||||||
|
|
||||||
return (lastFlag = lastFlag === 0 ? 1 : lastFlag << 1);
|
return (lastFlag = lastFlag === 0 ? 1 : lastFlag << 1);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { and, or, not, has, is, unset, toggle } from '.';
|
import { and, or, not, has, is, unset, toggle } from '.';
|
||||||
|
|
||||||
|
|
||||||
describe('flagsAnd', () => {
|
describe('flagsAnd', () => {
|
||||||
it('no effect on zero flags', () => {
|
it('no effect on zero flags', () => {
|
||||||
const result = and();
|
const result = and();
|
||||||
@@ -30,15 +29,15 @@ describe('flagsOr', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('source flag is returned if no flags are provided', () => {
|
it('source flag is returned if no flags are provided', () => {
|
||||||
const result = or(0b1010);
|
const result = or(0b1010);
|
||||||
|
|
||||||
expect(result).toBe(0b1010);
|
expect(result).toBe(0b1010);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('perform bitwise OR operation on flags', () => {
|
it('perform bitwise OR operation on flags', () => {
|
||||||
const result = or(0b1111, 0b1010, 0b1100);
|
const result = or(0b1111, 0b1010, 0b1100);
|
||||||
|
|
||||||
expect(result).toBe(0b1111);
|
expect(result).toBe(0b1111);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,9 +57,9 @@ describe('flagsHas', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('check if a flag has a specific bit unset', () => {
|
it('check if a flag has a specific bit unset', () => {
|
||||||
const result = has(0b1010, 0b0100);
|
const result = has(0b1010, 0b0100);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,9 +71,9 @@ describe('flagsIs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('check if a flag is unset', () => {
|
it('check if a flag is unset', () => {
|
||||||
const result = is(0);
|
const result = is(0);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Collection, Path } from '../../types';
|
import type { Collection, Path } from '../../types';
|
||||||
|
|
||||||
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
|
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K>
|
||||||
K extends keyof O
|
= K extends keyof O
|
||||||
? O[K]
|
? O[K]
|
||||||
: K extends keyof NonNullable<O>
|
: K extends keyof NonNullable<O>
|
||||||
? NonNullable<O>[K]
|
? NonNullable<O>[K]
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type ExtractFromArray<A extends readonly any[], K> =
|
export type ExtractFromArray<A extends readonly any[], K>
|
||||||
any[] extends A
|
= any[] extends A
|
||||||
? A extends ReadonlyArray<infer T>
|
? A extends ReadonlyArray<infer T>
|
||||||
? T | undefined
|
? T | undefined
|
||||||
: undefined
|
: undefined
|
||||||
@@ -16,16 +16,16 @@ export type ExtractFromArray<A extends readonly any[], K> =
|
|||||||
? A[K]
|
? A[K]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
export type ExtractFromCollection<O, K> =
|
export type ExtractFromCollection<O, K>
|
||||||
K extends []
|
= K extends []
|
||||||
? O
|
? O
|
||||||
: K extends [infer Key, ...infer Rest]
|
: K extends [infer Key, ...infer Rest]
|
||||||
? O extends Record<PropertyKey, unknown>
|
? O extends Record<PropertyKey, unknown>
|
||||||
? ExtractFromCollection<ExtractFromObject<O, Key>, Rest>
|
? ExtractFromCollection<ExtractFromObject<O, Key>, Rest>
|
||||||
: O extends readonly any[]
|
: O extends readonly any[]
|
||||||
? ExtractFromCollection<ExtractFromArray<O, Key>, Rest>
|
? ExtractFromCollection<ExtractFromArray<O, Key>, Rest>
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
type Get<O, K> = ExtractFromCollection<O, Path<K>>;
|
type Get<O, K> = ExtractFromCollection<O, Path<K>>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ export * from './structs';
|
|||||||
export * from './sync';
|
export * from './sync';
|
||||||
export * from './text';
|
export * from './text';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils'
|
export * from './utils';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe,it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { clamp } from '.';
|
import { clamp } from '.';
|
||||||
|
|
||||||
describe('clamp', () => {
|
describe('clamp', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, it, expect} from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {inverseLerp, lerp} from '.';
|
import { inverseLerp, lerp } from '.';
|
||||||
|
|
||||||
describe('lerp', () => {
|
describe('lerp', () => {
|
||||||
it('interpolates between two values', () => {
|
it('interpolates between two values', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {remap} from '.';
|
import { remap } from '.';
|
||||||
|
|
||||||
describe('remap', () => {
|
describe('remap', () => {
|
||||||
it('map values from one range to another', () => {
|
it('map values from one range to another', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clamp } from '../clamp';
|
import { clamp } from '../clamp';
|
||||||
import {inverseLerp, lerp} from '../lerp';
|
import { inverseLerp, lerp } from '../lerp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name remap
|
* @name remap
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, it, expect} from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {clampBigInt} from '.';
|
import { clampBigInt } from '.';
|
||||||
|
|
||||||
describe('clampBigInt', () => {
|
describe('clampBigInt', () => {
|
||||||
it('clamp a value within the given range', () => {
|
it('clamp a value within the given range', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {minBigInt} from '../minBigInt';
|
import { minBigInt } from '../minBigInt';
|
||||||
import {maxBigInt} from '../maxBigInt';
|
import { maxBigInt } from '../maxBigInt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name clampBigInt
|
* @name clampBigInt
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, it, expect} from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {inverseLerpBigInt, lerpBigInt} from '.';
|
import { inverseLerpBigInt, lerpBigInt } from '.';
|
||||||
|
|
||||||
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, it, expect} from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {minBigInt} from '.';
|
import { minBigInt } from '.';
|
||||||
|
|
||||||
describe('minBigInt', () => {
|
describe('minBigInt', () => {
|
||||||
it('returns Infinity when no values are provided', () => {
|
it('returns Infinity when no values are provided', () => {
|
||||||
@@ -32,7 +32,7 @@ describe('minBigInt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles a large number of bigints', () => {
|
it('handles a large number of bigints', () => {
|
||||||
const values = Array.from({length: 1000}, (_, i) => BigInt(i));
|
const values = Array.from({ length: 1000 }, (_, i) => BigInt(i));
|
||||||
const result = minBigInt(...values);
|
const result = minBigInt(...values);
|
||||||
expect(result).toBe(0n);
|
expect(result).toBe(0n);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {remapBigInt} from '.';
|
import { remapBigInt } from '.';
|
||||||
|
|
||||||
describe('remapBigInt', () => {
|
describe('remapBigInt', () => {
|
||||||
it('map values from one range to another', () => {
|
it('map values from one range to another', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clampBigInt } from '../clampBigInt';
|
import { clampBigInt } from '../clampBigInt';
|
||||||
import {inverseLerpBigInt, lerpBigInt} from '../lerpBigInt';
|
import { inverseLerpBigInt, lerpBigInt } from '../lerpBigInt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name remapBigInt
|
* @name remapBigInt
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Arrayable } from '../../types';
|
|||||||
*/
|
*/
|
||||||
export function omit<Target extends object, Key extends keyof Target>(
|
export function omit<Target extends object, Key extends keyof Target>(
|
||||||
target: Target,
|
target: Target,
|
||||||
keys: Arrayable<Key>
|
keys: Arrayable<Key>,
|
||||||
): Omit<Target, Key> {
|
): Omit<Target, Key> {
|
||||||
const result = { ...target };
|
const result = { ...target };
|
||||||
|
|
||||||
@@ -31,7 +31,8 @@ export function omit<Target extends object, Key extends keyof Target>(
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
delete result[key];
|
delete result[key];
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
delete result[keys];
|
delete result[keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Arrayable } from '../../types';
|
|||||||
*/
|
*/
|
||||||
export function pick<Target extends object, Key extends keyof Target>(
|
export function pick<Target extends object, Key extends keyof Target>(
|
||||||
target: Target,
|
target: Target,
|
||||||
keys: Arrayable<Key>
|
keys: Arrayable<Key>,
|
||||||
): Pick<Target, Key> {
|
): Pick<Target, Key> {
|
||||||
const result = {} as Pick<Target, Key>;
|
const result = {} as Pick<Target, Key>;
|
||||||
|
|
||||||
@@ -31,7 +31,8 @@ export function pick<Target extends object, Key extends keyof Target>(
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
result[key] = target[key];
|
result[key] = target[key];
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
result[keys] = target[keys];
|
result[keys] = target[keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,11 +205,11 @@ describe('asyncCommandHistory', () => {
|
|||||||
function addItemAsync(item: string): AsyncCommand {
|
function addItemAsync(item: string): AsyncCommand {
|
||||||
return {
|
return {
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
await new Promise(r => setTimeout(r, 5));
|
||||||
items.push(item);
|
items.push(item);
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => {
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
await new Promise(r => setTimeout(r, 5));
|
||||||
items.pop();
|
items.pop();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class CommandHistory extends BaseCommandHistory<Command> {
|
|||||||
|
|
||||||
batch(commands: Command[]): void {
|
batch(commands: Command[]): void {
|
||||||
const macro: Command = {
|
const macro: Command = {
|
||||||
execute: () => commands.forEach((c) => c.execute()),
|
execute: () => commands.forEach(c => c.execute()),
|
||||||
undo: () => {
|
undo: () => {
|
||||||
for (let i = commands.length - 1; i >= 0; i--)
|
for (let i = commands.length - 1; i >= 0; i--)
|
||||||
commands[i]!.undo();
|
commands[i]!.undo();
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
|
|||||||
*/
|
*/
|
||||||
public once<K extends keyof Events>(event: K, listener: Events[K]) {
|
public once<K extends keyof Events>(event: K, listener: Events[K]) {
|
||||||
const onceListener = (...args: Parameters<Events[K]>) => {
|
const onceListener = (...args: Parameters<Events[K]>) => {
|
||||||
this.off(event, onceListener as Events[K]);
|
this.off(event, onceListener as Events[K]);
|
||||||
listener(...args);
|
listener(...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.on(event, onceListener as Events[K]);
|
this.on(event, onceListener as Events[K]);
|
||||||
@@ -92,7 +92,7 @@ export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
|
|||||||
if (!listeners)
|
if (!listeners)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
listeners.forEach((listener) => listener(...args));
|
listeners.forEach(listener => listener(...args));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export class AsyncStateMachine<
|
|||||||
|
|
||||||
if (isString(transition)) {
|
if (isString(transition)) {
|
||||||
target = transition;
|
target = transition;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
if (transition.guard && !(await transition.guard(this.context)))
|
if (transition.guard && !(await transition.guard(this.context)))
|
||||||
return this.currentState;
|
return this.currentState;
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ describe('stateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
FAIL: {
|
FAIL: {
|
||||||
target: 'idle',
|
target: 'idle',
|
||||||
guard: (ctx) => ctx.retries < 3,
|
guard: ctx => ctx.retries < 3,
|
||||||
},
|
},
|
||||||
SUCCESS: 'done',
|
SUCCESS: 'done',
|
||||||
},
|
},
|
||||||
@@ -255,7 +255,7 @@ describe('stateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
UNLOCK: {
|
UNLOCK: {
|
||||||
target: 'unlocked',
|
target: 'unlocked',
|
||||||
guard: (ctx) => ctx.unlocked,
|
guard: ctx => ctx.unlocked,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
exit: exitHook,
|
exit: exitHook,
|
||||||
@@ -374,7 +374,7 @@ describe('stateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
NEXT: {
|
NEXT: {
|
||||||
target: 'c',
|
target: 'c',
|
||||||
guard: (ctx) => ctx.step === 1,
|
guard: ctx => ctx.step === 1,
|
||||||
action: (ctx) => { ctx.step = 2; },
|
action: (ctx) => { ctx.step = 2; },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -434,7 +434,7 @@ describe('asyncStateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
GO: {
|
GO: {
|
||||||
target: 'active',
|
target: 'active',
|
||||||
guard: async (ctx) => ctx.allowed,
|
guard: async ctx => ctx.allowed,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -456,7 +456,7 @@ describe('asyncStateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
GO: {
|
GO: {
|
||||||
target: 'active',
|
target: 'active',
|
||||||
guard: async (ctx) => ctx.allowed,
|
guard: async ctx => ctx.allowed,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -483,7 +483,7 @@ describe('asyncStateMachine', () => {
|
|||||||
FETCH: {
|
FETCH: {
|
||||||
target: 'done',
|
target: 'done',
|
||||||
action: async (ctx) => {
|
action: async (ctx) => {
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise(r => setTimeout(r, 10));
|
||||||
ctx.data = 'fetched';
|
ctx.data = 'fetched';
|
||||||
order.push('action');
|
order.push('action');
|
||||||
},
|
},
|
||||||
@@ -513,13 +513,13 @@ describe('asyncStateMachine', () => {
|
|||||||
a: {
|
a: {
|
||||||
on: { GO: 'b' },
|
on: { GO: 'b' },
|
||||||
exit: async () => {
|
exit: async () => {
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise(r => setTimeout(r, 10));
|
||||||
order.push('exit-a');
|
order.push('exit-a');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
entry: async () => {
|
entry: async () => {
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise(r => setTimeout(r, 10));
|
||||||
order.push('entry-b');
|
order.push('entry-b');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -544,7 +544,7 @@ describe('asyncStateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
UNLOCK: {
|
UNLOCK: {
|
||||||
target: 'unlocked',
|
target: 'unlocked',
|
||||||
guard: async (ctx) => ctx.unlocked,
|
guard: async ctx => ctx.unlocked,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
exit: exitHook,
|
exit: exitHook,
|
||||||
@@ -573,7 +573,7 @@ describe('asyncStateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
GO: {
|
GO: {
|
||||||
target: 'active',
|
target: 'active',
|
||||||
guard: async (ctx) => ctx.ready,
|
guard: async ctx => ctx.ready,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -667,7 +667,7 @@ describe('asyncStateMachine', () => {
|
|||||||
on: {
|
on: {
|
||||||
GO: {
|
GO: {
|
||||||
target: 'active',
|
target: 'active',
|
||||||
guard: (ctx) => ctx.count === 0,
|
guard: ctx => ctx.count === 0,
|
||||||
action: (ctx) => { ctx.count++; },
|
action: (ctx) => { ctx.count++; },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export class StateMachine<
|
|||||||
|
|
||||||
if (isString(transition)) {
|
if (isString(transition)) {
|
||||||
target = transition;
|
target = transition;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
if (transition.guard && !transition.guard(this.context))
|
if (transition.guard && !transition.guard(this.context))
|
||||||
return this.currentState;
|
return this.currentState;
|
||||||
|
|
||||||
|
|||||||
@@ -3,227 +3,227 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { BinaryHeap } from '.';
|
import { BinaryHeap } from '.';
|
||||||
|
|
||||||
describe('BinaryHeap', () => {
|
describe('BinaryHeap', () => {
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create an empty heap', () => {
|
it('should create an empty heap', () => {
|
||||||
const heap = new BinaryHeap<number>();
|
const heap = new BinaryHeap<number>();
|
||||||
|
|
||||||
expect(heap.length).toBe(0);
|
expect(heap.length).toBe(0);
|
||||||
expect(heap.isEmpty).toBe(true);
|
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 create a heap from single value', () => {
|
||||||
it('should insert elements maintaining heap property', () => {
|
const heap = new BinaryHeap(42);
|
||||||
const heap = new BinaryHeap<number>();
|
|
||||||
|
|
||||||
heap.push(5);
|
expect(heap.length).toBe(1);
|
||||||
heap.push(3);
|
expect(heap.peek()).toBe(42);
|
||||||
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 create a heap from array (heapify)', () => {
|
||||||
it('should return undefined for empty heap', () => {
|
const heap = new BinaryHeap([5, 3, 8, 1, 4]);
|
||||||
const heap = new BinaryHeap<number>();
|
|
||||||
|
|
||||||
expect(heap.pop()).toBeUndefined();
|
expect(heap.length).toBe(5);
|
||||||
});
|
expect(heap.peek()).toBe(1);
|
||||||
|
|
||||||
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 accept a custom comparator for max-heap', () => {
|
||||||
it('should return undefined for empty heap', () => {
|
const heap = new BinaryHeap([5, 3, 8, 1, 4], {
|
||||||
const heap = new BinaryHeap<number>();
|
comparator: (a, b) => b - a,
|
||||||
|
});
|
||||||
|
|
||||||
expect(heap.peek()).toBeUndefined();
|
expect(heap.peek()).toBe(8);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return root without removing it', () => {
|
describe('push', () => {
|
||||||
const heap = new BinaryHeap([5, 3, 1]);
|
it('should insert elements maintaining heap property', () => {
|
||||||
|
const heap = new BinaryHeap<number>();
|
||||||
|
|
||||||
expect(heap.peek()).toBe(1);
|
heap.push(5);
|
||||||
expect(heap.length).toBe(3);
|
heap.push(3);
|
||||||
});
|
heap.push(8);
|
||||||
|
heap.push(1);
|
||||||
|
|
||||||
|
expect(heap.peek()).toBe(1);
|
||||||
|
expect(heap.length).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clear', () => {
|
it('should handle duplicate values', () => {
|
||||||
it('should remove all elements', () => {
|
const heap = new BinaryHeap<number>();
|
||||||
const heap = new BinaryHeap([1, 2, 3]);
|
|
||||||
|
|
||||||
const result = heap.clear();
|
heap.push(3);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
expect(heap.length).toBe(0);
|
expect(heap.length).toBe(3);
|
||||||
expect(heap.isEmpty).toBe(true);
|
expect(heap.peek()).toBe(3);
|
||||||
expect(result).toBe(heap);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pop', () => {
|
||||||
|
it('should return undefined for empty heap', () => {
|
||||||
|
const heap = new BinaryHeap<number>();
|
||||||
|
|
||||||
|
expect(heap.pop()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toArray', () => {
|
it('should extract elements in min-heap order', () => {
|
||||||
it('should return empty array for empty heap', () => {
|
const heap = new BinaryHeap([5, 3, 8, 1, 4, 2, 7, 6]);
|
||||||
const heap = new BinaryHeap<number>();
|
const sorted: number[] = [];
|
||||||
|
|
||||||
expect(heap.toArray()).toEqual([]);
|
while (!heap.isEmpty) {
|
||||||
});
|
sorted.push(heap.pop()!);
|
||||||
|
}
|
||||||
|
|
||||||
it('should return a shallow copy', () => {
|
expect(sorted).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
const heap = new BinaryHeap([3, 1, 2]);
|
|
||||||
const arr = heap.toArray();
|
|
||||||
|
|
||||||
arr.push(99);
|
|
||||||
|
|
||||||
expect(heap.length).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toString', () => {
|
it('should extract elements in max-heap order with custom comparator', () => {
|
||||||
it('should return formatted string', () => {
|
const heap = new BinaryHeap([5, 3, 8, 1, 4], {
|
||||||
const heap = new BinaryHeap([1, 2, 3]);
|
comparator: (a, b) => b - a,
|
||||||
|
});
|
||||||
|
const sorted: number[] = [];
|
||||||
|
|
||||||
expect(heap.toString()).toBe('BinaryHeap(3)');
|
while (!heap.isEmpty) {
|
||||||
});
|
sorted.push(heap.pop()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(sorted).toEqual([8, 5, 4, 3, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('iterator', () => {
|
it('should handle single element', () => {
|
||||||
it('should iterate over heap elements', () => {
|
const heap = new BinaryHeap(42);
|
||||||
const heap = new BinaryHeap([5, 3, 8, 1]);
|
|
||||||
const elements = [...heap];
|
|
||||||
|
|
||||||
expect(elements.length).toBe(4);
|
expect(heap.pop()).toBe(42);
|
||||||
expect(elements[0]).toBe(1);
|
expect(heap.isEmpty).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('peek', () => {
|
||||||
|
it('should return undefined for empty heap', () => {
|
||||||
|
const heap = new BinaryHeap<number>();
|
||||||
|
|
||||||
|
expect(heap.peek()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('custom comparator', () => {
|
it('should return root without removing it', () => {
|
||||||
it('should work with string length comparator', () => {
|
const heap = new BinaryHeap([5, 3, 1]);
|
||||||
const heap = new BinaryHeap(['banana', 'apple', 'kiwi', 'fig'], {
|
|
||||||
comparator: (a, b) => a.length - b.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(heap.pop()).toBe('fig');
|
expect(heap.peek()).toBe(1);
|
||||||
expect(heap.pop()).toBe('kiwi');
|
expect(heap.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should work with object comparator', () => {
|
describe('clear', () => {
|
||||||
interface Task {
|
it('should remove all elements', () => {
|
||||||
priority: number;
|
const heap = new BinaryHeap([1, 2, 3]);
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heap = new BinaryHeap<Task>(
|
const result = heap.clear();
|
||||||
[
|
|
||||||
{ 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.length).toBe(0);
|
||||||
expect(heap.pop()?.name).toBe('medium');
|
expect(heap.isEmpty).toBe(true);
|
||||||
expect(heap.pop()?.name).toBe('low');
|
expect(result).toBe(heap);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toArray', () => {
|
||||||
|
it('should return empty array for empty heap', () => {
|
||||||
|
const heap = new BinaryHeap<number>();
|
||||||
|
|
||||||
|
expect(heap.toArray()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('heapify', () => {
|
it('should return a shallow copy', () => {
|
||||||
it('should correctly heapify large arrays', () => {
|
const heap = new BinaryHeap([3, 1, 2]);
|
||||||
const values = Array.from({ length: 1000 }, () => Math.random() * 1000 | 0);
|
const arr = heap.toArray();
|
||||||
const heap = new BinaryHeap(values);
|
|
||||||
const sorted: number[] = [];
|
|
||||||
|
|
||||||
while (!heap.isEmpty) {
|
arr.push(99);
|
||||||
sorted.push(heap.pop()!);
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = [...values].sort((a, b) => a - b);
|
expect(heap.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(sorted).toEqual(expected);
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interleaved operations', () => {
|
it('should work with object comparator', () => {
|
||||||
it('should maintain heap property with mixed push and pop', () => {
|
interface Task {
|
||||||
const heap = new BinaryHeap<number>();
|
priority: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
heap.push(10);
|
const heap = new BinaryHeap<Task>(
|
||||||
heap.push(5);
|
[
|
||||||
expect(heap.pop()).toBe(5);
|
{ priority: 3, name: 'low' },
|
||||||
|
{ priority: 1, name: 'high' },
|
||||||
|
{ priority: 2, name: 'medium' },
|
||||||
|
],
|
||||||
|
{ comparator: (a, b) => a.priority - b.priority },
|
||||||
|
);
|
||||||
|
|
||||||
heap.push(3);
|
expect(heap.pop()?.name).toBe('high');
|
||||||
heap.push(7);
|
expect(heap.pop()?.name).toBe('medium');
|
||||||
expect(heap.pop()).toBe(3);
|
expect(heap.pop()?.name).toBe('low');
|
||||||
|
|
||||||
heap.push(1);
|
|
||||||
expect(heap.pop()).toBe(1);
|
|
||||||
expect(heap.pop()).toBe(7);
|
|
||||||
expect(heap.pop()).toBe(10);
|
|
||||||
expect(heap.pop()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BinaryHeapLike, Comparator } from './types';
|
|||||||
export type { BinaryHeapLike, Comparator } from './types';
|
export type { BinaryHeapLike, Comparator } from './types';
|
||||||
|
|
||||||
export interface BinaryHeapOptions<T> {
|
export interface BinaryHeapOptions<T> {
|
||||||
comparator?: Comparator<T>;
|
comparator?: Comparator<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,194 +27,194 @@ const defaultComparator: Comparator<any> = (a: number, b: number) => a - b;
|
|||||||
* @template T The type of elements stored in the heap
|
* @template T The type of elements stored in the heap
|
||||||
*/
|
*/
|
||||||
export class BinaryHeap<T> implements BinaryHeapLike<T> {
|
export class BinaryHeap<T> implements BinaryHeapLike<T> {
|
||||||
/**
|
/**
|
||||||
* The comparator function used to order elements
|
* The comparator function used to order elements
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {Comparator<T>}
|
* @type {Comparator<T>}
|
||||||
*/
|
*/
|
||||||
private readonly comparator: Comparator<T>;
|
private readonly comparator: Comparator<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal flat array backing the heap
|
* Internal flat array backing the heap
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {T[]}
|
* @type {T[]}
|
||||||
*/
|
*/
|
||||||
private readonly heap: T[] = [];
|
private readonly heap: T[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an instance of BinaryHeap
|
* Creates an instance of BinaryHeap
|
||||||
*
|
*
|
||||||
* @param {(T[] | T)} [initialValues] The initial values to heapify
|
* @param {(T[] | T)} [initialValues] The initial values to heapify
|
||||||
* @param {BinaryHeapOptions<T>} [options] Heap configuration
|
* @param {BinaryHeapOptions<T>} [options] Heap configuration
|
||||||
*/
|
*/
|
||||||
constructor(initialValues?: T[] | T, options?: BinaryHeapOptions<T>) {
|
constructor(initialValues?: T[] | T, options?: BinaryHeapOptions<T>) {
|
||||||
this.comparator = options?.comparator ?? defaultComparator;
|
this.comparator = options?.comparator ?? defaultComparator;
|
||||||
|
|
||||||
if (initialValues !== null && initialValues !== undefined) {
|
if (initialValues !== null && initialValues !== undefined) {
|
||||||
const items = isArray(initialValues) ? initialValues : [initialValues];
|
const items = isArray(initialValues) ? initialValues : [initialValues];
|
||||||
this.heap.push(...items);
|
this.heap.push(...items);
|
||||||
this.heapify();
|
this.heapify();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of elements in the heap
|
* Gets the number of elements in the heap
|
||||||
* @returns {number} The number of elements in the heap
|
* @returns {number} The number of elements in the heap
|
||||||
*/
|
*/
|
||||||
public get length(): number {
|
public get length(): number {
|
||||||
return this.heap.length;
|
return this.heap.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the heap is empty
|
* Checks if the heap is empty
|
||||||
* @returns {boolean} `true` if the heap is empty, `false` otherwise
|
* @returns {boolean} `true` if the heap is empty, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isEmpty(): boolean {
|
public get isEmpty(): boolean {
|
||||||
return this.heap.length === 0;
|
return this.heap.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pushes an element into the heap
|
* Pushes an element into the heap
|
||||||
* @param {T} element The element to insert
|
* @param {T} element The element to insert
|
||||||
*/
|
*/
|
||||||
public push(element: T): void {
|
public push(element: T): void {
|
||||||
this.heap.push(element);
|
this.heap.push(element);
|
||||||
this.siftUp(this.heap.length - 1);
|
this.siftUp(this.heap.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes and returns the root element (min or max depending on comparator)
|
* 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
|
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
|
||||||
*/
|
*/
|
||||||
public pop(): T | undefined {
|
public pop(): T | undefined {
|
||||||
if (this.heap.length === 0) return undefined;
|
if (this.heap.length === 0) return undefined;
|
||||||
|
|
||||||
const root = first(this.heap)!;
|
const root = first(this.heap)!;
|
||||||
const last = this.heap.pop()!;
|
const last = this.heap.pop()!;
|
||||||
|
|
||||||
if (this.heap.length > 0) {
|
if (this.heap.length > 0) {
|
||||||
this.heap[0] = last;
|
this.heap[0] = last;
|
||||||
this.siftDown(0);
|
this.siftDown(0);
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Returns the root element without removing it
|
* Returns the root element without removing it
|
||||||
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
|
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
|
||||||
*/
|
*/
|
||||||
public peek(): T | undefined {
|
public peek(): T | undefined {
|
||||||
return first(this.heap);
|
return first(this.heap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes all elements from the heap
|
* Removes all elements from the heap
|
||||||
* @returns {this} The heap instance for chaining
|
* @returns {this} The heap instance for chaining
|
||||||
*/
|
*/
|
||||||
public clear(): this {
|
public clear(): this {
|
||||||
this.heap.length = 0;
|
this.heap.length = 0;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a shallow copy of the heap elements as an array (heap order, not sorted)
|
* Returns a shallow copy of the heap elements as an array (heap order, not sorted)
|
||||||
* @returns {T[]} Array of elements in heap order
|
* @returns {T[]} Array of elements in heap order
|
||||||
*/
|
*/
|
||||||
public toArray(): T[] {
|
public toArray(): T[] {
|
||||||
return this.heap.slice();
|
return this.heap.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a string representation of the heap
|
* Returns a string representation of the heap
|
||||||
* @returns {string} String representation
|
* @returns {string} String representation
|
||||||
*/
|
*/
|
||||||
public toString(): string {
|
public toString(): string {
|
||||||
return `BinaryHeap(${this.heap.length})`;
|
return `BinaryHeap(${this.heap.length})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterator over heap elements in heap order
|
* Iterator over heap elements in heap order
|
||||||
*/
|
*/
|
||||||
public *[Symbol.iterator](): Iterator<T> {
|
public* [Symbol.iterator](): Iterator<T> {
|
||||||
yield* this.heap;
|
yield* this.heap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async iterator over heap elements in heap order
|
* Async iterator over heap elements in heap order
|
||||||
*/
|
*/
|
||||||
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
public async* [Symbol.asyncIterator](): AsyncIterator<T> {
|
||||||
for (const element of this.heap)
|
for (const element of this.heap)
|
||||||
yield element;
|
yield element;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores heap property by sifting an element up
|
* Restores heap property by sifting an element up
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @param {number} index The index of the element to sift up
|
* @param {number} index The index of the element to sift up
|
||||||
*/
|
*/
|
||||||
private siftUp(index: number): void {
|
private siftUp(index: number): void {
|
||||||
const heap = this.heap;
|
const heap = this.heap;
|
||||||
const cmp = this.comparator;
|
const cmp = this.comparator;
|
||||||
|
|
||||||
while (index > 0) {
|
while (index > 0) {
|
||||||
const parent = (index - 1) >> 1;
|
const parent = (index - 1) >> 1;
|
||||||
|
|
||||||
if (cmp(heap[index]!, heap[parent]!) >= 0) break;
|
if (cmp(heap[index]!, heap[parent]!) >= 0) break;
|
||||||
|
|
||||||
const temp = heap[index]!;
|
const temp = heap[index]!;
|
||||||
heap[index] = heap[parent]!;
|
heap[index] = heap[parent]!;
|
||||||
heap[parent] = temp;
|
heap[parent] = temp;
|
||||||
|
|
||||||
index = parent;
|
index = parent;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores heap property by sifting an element down
|
* Restores heap property by sifting an element down
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @param {number} index The index of the element to sift down
|
* @param {number} index The index of the element to sift down
|
||||||
*/
|
*/
|
||||||
private siftDown(index: number): void {
|
private siftDown(index: number): void {
|
||||||
const heap = this.heap;
|
const heap = this.heap;
|
||||||
const cmp = this.comparator;
|
const cmp = this.comparator;
|
||||||
const length = heap.length;
|
const length = heap.length;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let smallest = index;
|
let smallest = index;
|
||||||
const left = 2 * index + 1;
|
const left = 2 * index + 1;
|
||||||
const right = 2 * index + 2;
|
const right = 2 * index + 2;
|
||||||
|
|
||||||
if (left < length && cmp(heap[left]!, heap[smallest]!) < 0) {
|
if (left < length && cmp(heap[left]!, heap[smallest]!) < 0) {
|
||||||
smallest = left;
|
smallest = left;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (right < length && cmp(heap[right]!, heap[smallest]!) < 0) {
|
if (right < length && cmp(heap[right]!, heap[smallest]!) < 0) {
|
||||||
smallest = right;
|
smallest = right;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (smallest === index) break;
|
if (smallest === index) break;
|
||||||
|
|
||||||
const temp = heap[index]!;
|
const temp = heap[index]!;
|
||||||
heap[index] = heap[smallest]!;
|
heap[index] = heap[smallest]!;
|
||||||
heap[smallest] = temp;
|
heap[smallest] = temp;
|
||||||
|
|
||||||
index = smallest;
|
index = smallest;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds heap from unordered array in O(n) using Floyd's algorithm
|
* Builds heap from unordered array in O(n) using Floyd's algorithm
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private heapify(): void {
|
private heapify(): void {
|
||||||
for (let i = (this.heap.length >> 1) - 1; i >= 0; i--) {
|
for (let i = (this.heap.length >> 1) - 1; i >= 0; i--) {
|
||||||
this.siftDown(i);
|
this.siftDown(i);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export class CircularBuffer<T> implements CircularBufferLike<T> {
|
|||||||
*
|
*
|
||||||
* @returns {AsyncIterableIterator<T>}
|
* @returns {AsyncIterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
async *[Symbol.asyncIterator]() {
|
async* [Symbol.asyncIterator]() {
|
||||||
for (const element of this)
|
for (const element of this)
|
||||||
yield element;
|
yield element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export class Deque<T> implements DequeLike<T> {
|
|||||||
*
|
*
|
||||||
* @returns {AsyncIterableIterator<T>}
|
* @returns {AsyncIterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
async *[Symbol.asyncIterator]() {
|
async* [Symbol.asyncIterator]() {
|
||||||
for (const element of this.buffer)
|
for (const element of this.buffer)
|
||||||
yield element;
|
yield element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,404 +3,404 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { LinkedList } from '.';
|
import { LinkedList } from '.';
|
||||||
|
|
||||||
describe('LinkedList', () => {
|
describe('LinkedList', () => {
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create an empty list', () => {
|
it('should create an empty list', () => {
|
||||||
const list = new LinkedList<number>();
|
const list = new LinkedList<number>();
|
||||||
|
|
||||||
expect(list.length).toBe(0);
|
expect(list.length).toBe(0);
|
||||||
expect(list.isEmpty).toBe(true);
|
expect(list.isEmpty).toBe(true);
|
||||||
expect(list.head).toBeUndefined();
|
expect(list.head).toBeUndefined();
|
||||||
expect(list.tail).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 create a list from single value', () => {
|
||||||
it('should append to empty list', () => {
|
const list = new LinkedList(42);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
const node = list.pushBack(1);
|
expect(list.length).toBe(1);
|
||||||
|
expect(list.peekFront()).toBe(42);
|
||||||
expect(list.length).toBe(1);
|
expect(list.peekBack()).toBe(42);
|
||||||
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 create a list from array', () => {
|
||||||
it('should prepend to empty list', () => {
|
const list = new LinkedList([1, 2, 3]);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
const node = list.pushFront(1);
|
expect(list.length).toBe(3);
|
||||||
|
expect(list.peekFront()).toBe(1);
|
||||||
|
expect(list.peekBack()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(list.length).toBe(1);
|
describe('pushBack', () => {
|
||||||
expect(list.head).toBe(node);
|
it('should append to empty list', () => {
|
||||||
expect(list.tail).toBe(node);
|
const list = new LinkedList<number>();
|
||||||
});
|
|
||||||
|
|
||||||
it('should prepend to non-empty list', () => {
|
const node = list.pushBack(1);
|
||||||
const list = new LinkedList([2, 3]);
|
|
||||||
|
|
||||||
list.pushFront(1);
|
expect(list.length).toBe(1);
|
||||||
|
expect(node.value).toBe(1);
|
||||||
expect(list.length).toBe(3);
|
expect(list.head).toBe(node);
|
||||||
expect(list.peekFront()).toBe(1);
|
expect(list.tail).toBe(node);
|
||||||
expect(list.peekBack()).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('popBack', () => {
|
it('should append to non-empty list', () => {
|
||||||
it('should return undefined for empty list', () => {
|
const list = new LinkedList([1, 2]);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
expect(list.popBack()).toBeUndefined();
|
list.pushBack(3);
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove and return last value', () => {
|
expect(list.length).toBe(3);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
expect(list.peekBack()).toBe(3);
|
||||||
|
expect(list.peekFront()).toBe(1);
|
||||||
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 the created node', () => {
|
||||||
it('should return undefined for empty list', () => {
|
const list = new LinkedList<number>();
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
expect(list.popFront()).toBeUndefined();
|
const node = list.pushBack(5);
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove and return first value', () => {
|
expect(node.value).toBe(5);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
expect(node.prev).toBeUndefined();
|
||||||
|
expect(node.next).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(list.popFront()).toBe(1);
|
describe('pushFront', () => {
|
||||||
expect(list.length).toBe(2);
|
it('should prepend to empty list', () => {
|
||||||
expect(list.peekFront()).toBe(2);
|
const list = new LinkedList<number>();
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle single element', () => {
|
const node = list.pushFront(1);
|
||||||
const list = new LinkedList(1);
|
|
||||||
|
|
||||||
expect(list.popFront()).toBe(1);
|
expect(list.length).toBe(1);
|
||||||
expect(list.isEmpty).toBe(true);
|
expect(list.head).toBe(node);
|
||||||
expect(list.head).toBeUndefined();
|
expect(list.tail).toBe(node);
|
||||||
expect(list.tail).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('peekBack', () => {
|
it('should prepend to non-empty list', () => {
|
||||||
it('should return undefined for empty list', () => {
|
const list = new LinkedList([2, 3]);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
expect(list.peekBack()).toBeUndefined();
|
list.pushFront(1);
|
||||||
});
|
|
||||||
|
|
||||||
it('should return last value without removing', () => {
|
expect(list.length).toBe(3);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
expect(list.peekFront()).toBe(1);
|
||||||
|
expect(list.peekBack()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(list.peekBack()).toBe(3);
|
describe('popBack', () => {
|
||||||
expect(list.length).toBe(3);
|
it('should return undefined for empty list', () => {
|
||||||
});
|
const list = new LinkedList<number>();
|
||||||
|
|
||||||
|
expect(list.popBack()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('peekFront', () => {
|
it('should remove and return last value', () => {
|
||||||
it('should return undefined for empty list', () => {
|
const list = new LinkedList([1, 2, 3]);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
expect(list.peekFront()).toBeUndefined();
|
expect(list.popBack()).toBe(3);
|
||||||
});
|
expect(list.length).toBe(2);
|
||||||
|
expect(list.peekBack()).toBe(2);
|
||||||
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 handle single element', () => {
|
||||||
it('should insert before head', () => {
|
const list = new LinkedList(1);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
const node = list.pushBack(2);
|
|
||||||
|
|
||||||
list.insertBefore(node, 1);
|
expect(list.popBack()).toBe(1);
|
||||||
|
expect(list.isEmpty).toBe(true);
|
||||||
|
expect(list.head).toBeUndefined();
|
||||||
|
expect(list.tail).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(list.peekFront()).toBe(1);
|
describe('popFront', () => {
|
||||||
expect(list.peekBack()).toBe(2);
|
it('should return undefined for empty list', () => {
|
||||||
expect(list.length).toBe(2);
|
const list = new LinkedList<number>();
|
||||||
});
|
|
||||||
|
|
||||||
it('should insert before middle node', () => {
|
expect(list.popFront()).toBeUndefined();
|
||||||
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 remove and return first value', () => {
|
||||||
it('should insert after tail', () => {
|
const list = new LinkedList([1, 2, 3]);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
const node = list.pushBack(1);
|
|
||||||
|
|
||||||
list.insertAfter(node, 2);
|
expect(list.popFront()).toBe(1);
|
||||||
|
expect(list.length).toBe(2);
|
||||||
expect(list.peekFront()).toBe(1);
|
expect(list.peekFront()).toBe(2);
|
||||||
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 handle single element', () => {
|
||||||
it('should remove head node', () => {
|
const list = new LinkedList(1);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
|
||||||
const head = list.head!;
|
|
||||||
|
|
||||||
const value = list.remove(head);
|
expect(list.popFront()).toBe(1);
|
||||||
|
expect(list.isEmpty).toBe(true);
|
||||||
|
expect(list.head).toBeUndefined();
|
||||||
|
expect(list.tail).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(value).toBe(1);
|
describe('peekBack', () => {
|
||||||
expect(list.length).toBe(2);
|
it('should return undefined for empty list', () => {
|
||||||
expect(list.peekFront()).toBe(2);
|
const list = new LinkedList<number>();
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove tail node', () => {
|
expect(list.peekBack()).toBeUndefined();
|
||||||
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 return last value without removing', () => {
|
||||||
it('should remove all elements', () => {
|
const list = new LinkedList([1, 2, 3]);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
|
||||||
|
|
||||||
const result = list.clear();
|
expect(list.peekBack()).toBe(3);
|
||||||
|
expect(list.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(list.length).toBe(0);
|
describe('peekFront', () => {
|
||||||
expect(list.isEmpty).toBe(true);
|
it('should return undefined for empty list', () => {
|
||||||
expect(list.head).toBeUndefined();
|
const list = new LinkedList<number>();
|
||||||
expect(list.tail).toBeUndefined();
|
|
||||||
expect(result).toBe(list);
|
expect(list.peekFront()).toBeUndefined();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toArray', () => {
|
it('should return first value without removing', () => {
|
||||||
it('should return empty array for empty list', () => {
|
const list = new LinkedList([1, 2, 3]);
|
||||||
const list = new LinkedList<number>();
|
|
||||||
|
|
||||||
expect(list.toArray()).toEqual([]);
|
expect(list.peekFront()).toBe(1);
|
||||||
});
|
expect(list.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return values from head to tail', () => {
|
describe('insertBefore', () => {
|
||||||
const list = new LinkedList([1, 2, 3]);
|
it('should insert before head', () => {
|
||||||
|
const list = new LinkedList<number>();
|
||||||
|
const node = list.pushBack(2);
|
||||||
|
|
||||||
expect(list.toArray()).toEqual([1, 2, 3]);
|
list.insertBefore(node, 1);
|
||||||
});
|
|
||||||
|
expect(list.peekFront()).toBe(1);
|
||||||
|
expect(list.peekBack()).toBe(2);
|
||||||
|
expect(list.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toString', () => {
|
it('should insert before middle node', () => {
|
||||||
it('should return comma-separated values', () => {
|
const list = new LinkedList([1, 3]);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
const tail = list.tail!;
|
||||||
|
|
||||||
expect(list.toString()).toBe('1,2,3');
|
list.insertBefore(tail, 2);
|
||||||
});
|
|
||||||
|
expect(list.toArray()).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('iterator', () => {
|
it('should return the created node', () => {
|
||||||
it('should iterate from head to tail', () => {
|
const list = new LinkedList<number>();
|
||||||
const list = new LinkedList([1, 2, 3]);
|
const existing = list.pushBack(2);
|
||||||
|
|
||||||
expect([...list]).toEqual([1, 2, 3]);
|
const newNode = list.insertBefore(existing, 1);
|
||||||
});
|
|
||||||
|
|
||||||
it('should yield nothing for empty list', () => {
|
expect(newNode.value).toBe(1);
|
||||||
const list = new LinkedList<number>();
|
expect(newNode.next).toBe(existing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect([...list]).toEqual([]);
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('async iterator', () => {
|
it('should insert after middle node', () => {
|
||||||
it('should async iterate from head to tail', async () => {
|
const list = new LinkedList([1, 3]);
|
||||||
const list = new LinkedList([1, 2, 3]);
|
const head = list.head!;
|
||||||
const result: number[] = [];
|
|
||||||
|
|
||||||
for await (const value of list)
|
list.insertAfter(head, 2);
|
||||||
result.push(value);
|
|
||||||
|
|
||||||
expect(result).toEqual([1, 2, 3]);
|
expect(list.toArray()).toEqual([1, 2, 3]);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('node linking', () => {
|
it('should return the created node', () => {
|
||||||
it('should maintain correct prev/next references', () => {
|
const list = new LinkedList<number>();
|
||||||
const list = new LinkedList<number>();
|
const existing = list.pushBack(1);
|
||||||
const a = list.pushBack(1);
|
|
||||||
const b = list.pushBack(2);
|
|
||||||
const c = list.pushBack(3);
|
|
||||||
|
|
||||||
expect(a.next).toBe(b);
|
const newNode = list.insertAfter(existing, 2);
|
||||||
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', () => {
|
expect(newNode.value).toBe(2);
|
||||||
const list = new LinkedList<number>();
|
expect(newNode.prev).toBe(existing);
|
||||||
const a = list.pushBack(1);
|
});
|
||||||
const b = list.pushBack(2);
|
});
|
||||||
const c = list.pushBack(3);
|
|
||||||
|
|
||||||
list.remove(b);
|
describe('remove', () => {
|
||||||
|
it('should remove head node', () => {
|
||||||
|
const list = new LinkedList([1, 2, 3]);
|
||||||
|
const head = list.head!;
|
||||||
|
|
||||||
expect(a.next).toBe(c);
|
const value = list.remove(head);
|
||||||
expect(c.prev).toBe(a);
|
|
||||||
});
|
expect(value).toBe(1);
|
||||||
|
expect(list.length).toBe(2);
|
||||||
|
expect(list.peekFront()).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interleaved operations', () => {
|
it('should remove tail node', () => {
|
||||||
it('should handle mixed push/pop from both ends', () => {
|
const list = new LinkedList([1, 2, 3]);
|
||||||
const list = new LinkedList<number>();
|
const tail = list.tail!;
|
||||||
|
|
||||||
list.pushBack(1);
|
const value = list.remove(tail);
|
||||||
list.pushBack(2);
|
|
||||||
list.pushFront(0);
|
|
||||||
|
|
||||||
expect(list.popFront()).toBe(0);
|
expect(value).toBe(3);
|
||||||
expect(list.popBack()).toBe(2);
|
expect(list.length).toBe(2);
|
||||||
expect(list.popFront()).toBe(1);
|
expect(list.peekBack()).toBe(2);
|
||||||
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]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type { LinkedListLike, LinkedListNode } from './types';
|
|||||||
* @returns {LinkedListNode<T>} The created node
|
* @returns {LinkedListNode<T>} The created node
|
||||||
*/
|
*/
|
||||||
function createNode<T>(value: T): LinkedListNode<T> {
|
function createNode<T>(value: T): LinkedListNode<T> {
|
||||||
return { value, prev: undefined, next: undefined };
|
return { value, prev: undefined, next: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,301 +24,307 @@ function createNode<T>(value: T): LinkedListNode<T> {
|
|||||||
* @template T The type of elements stored in the list
|
* @template T The type of elements stored in the list
|
||||||
*/
|
*/
|
||||||
export class LinkedList<T> implements LinkedListLike<T> {
|
export class LinkedList<T> implements LinkedListLike<T> {
|
||||||
/**
|
/**
|
||||||
* The number of elements in the list
|
* The number of elements in the list
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {number}
|
* @type {number}
|
||||||
*/
|
*/
|
||||||
private count = 0;
|
private count = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The first node in the list
|
* The first node in the list
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {LinkedListNode<T> | undefined}
|
* @type {LinkedListNode<T> | undefined}
|
||||||
*/
|
*/
|
||||||
private first: LinkedListNode<T> | undefined;
|
private first: LinkedListNode<T> | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The last node in the list
|
* The last node in the list
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {LinkedListNode<T> | undefined}
|
* @type {LinkedListNode<T> | undefined}
|
||||||
*/
|
*/
|
||||||
private last: LinkedListNode<T> | undefined;
|
private last: LinkedListNode<T> | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an instance of LinkedList
|
* Creates an instance of LinkedList
|
||||||
*
|
*
|
||||||
* @param {(T[] | T)} [initialValues] The initial values to add to the list
|
* @param {(T[] | T)} [initialValues] The initial values to add to the list
|
||||||
*/
|
*/
|
||||||
constructor(initialValues?: T[] | T) {
|
constructor(initialValues?: T[] | T) {
|
||||||
if (initialValues !== null && initialValues !== undefined) {
|
if (initialValues !== null && initialValues !== undefined) {
|
||||||
const items = isArray(initialValues) ? initialValues : [initialValues];
|
const items = isArray(initialValues) ? initialValues : [initialValues];
|
||||||
|
|
||||||
for (const item of items)
|
for (const item of items)
|
||||||
this.pushBack(item);
|
this.pushBack(item);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of elements in the list
|
* Gets the number of elements in the list
|
||||||
* @returns {number} The number of elements in the list
|
* @returns {number} The number of elements in the list
|
||||||
*/
|
*/
|
||||||
public get length(): number {
|
public get length(): number {
|
||||||
return this.count;
|
return this.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the list is empty
|
* Checks if the list is empty
|
||||||
* @returns {boolean} `true` if the list is empty, `false` otherwise
|
* @returns {boolean} `true` if the list is empty, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isEmpty(): boolean {
|
public get isEmpty(): boolean {
|
||||||
return this.count === 0;
|
return this.count === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the first node
|
* Gets the first node
|
||||||
* @returns {LinkedListNode<T> | undefined} The first node, or `undefined` if the list is empty
|
* @returns {LinkedListNode<T> | undefined} The first node, or `undefined` if the list is empty
|
||||||
*/
|
*/
|
||||||
public get head(): LinkedListNode<T> | undefined {
|
public get head(): LinkedListNode<T> | undefined {
|
||||||
return this.first;
|
return this.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the last node
|
* Gets the last node
|
||||||
* @returns {LinkedListNode<T> | undefined} The last node, or `undefined` if the list is empty
|
* @returns {LinkedListNode<T> | undefined} The last node, or `undefined` if the list is empty
|
||||||
*/
|
*/
|
||||||
public get tail(): LinkedListNode<T> | undefined {
|
public get tail(): LinkedListNode<T> | undefined {
|
||||||
return this.last;
|
return this.last;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends a value to the end of the list
|
* Appends a value to the end of the list
|
||||||
* @param {T} value The value to append
|
* @param {T} value The value to append
|
||||||
* @returns {LinkedListNode<T>} The created node
|
* @returns {LinkedListNode<T>} The created node
|
||||||
*/
|
*/
|
||||||
public pushBack(value: T): LinkedListNode<T> {
|
public pushBack(value: T): LinkedListNode<T> {
|
||||||
const node = createNode(value);
|
const node = createNode(value);
|
||||||
|
|
||||||
if (this.last) {
|
if (this.last) {
|
||||||
node.prev = this.last;
|
node.prev = this.last;
|
||||||
this.last.next = node;
|
this.last.next = node;
|
||||||
this.last = node;
|
this.last = node;
|
||||||
} else {
|
}
|
||||||
this.first = node;
|
else {
|
||||||
this.last = node;
|
this.first = node;
|
||||||
}
|
this.last = node;
|
||||||
|
|
||||||
this.count++;
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
this.count++;
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Prepends a value to the beginning of the list
|
* Prepends a value to the beginning of the list
|
||||||
* @param {T} value The value to prepend
|
* @param {T} value The value to prepend
|
||||||
* @returns {LinkedListNode<T>} The created node
|
* @returns {LinkedListNode<T>} The created node
|
||||||
*/
|
*/
|
||||||
public pushFront(value: T): LinkedListNode<T> {
|
public pushFront(value: T): LinkedListNode<T> {
|
||||||
const node = createNode(value);
|
const node = createNode(value);
|
||||||
|
|
||||||
if (this.first) {
|
if (this.first) {
|
||||||
node.next = this.first;
|
node.next = this.first;
|
||||||
this.first.prev = node;
|
this.first.prev = node;
|
||||||
this.first = node;
|
this.first = node;
|
||||||
} else {
|
}
|
||||||
this.first = node;
|
else {
|
||||||
this.last = node;
|
this.first = node;
|
||||||
}
|
this.last = node;
|
||||||
|
|
||||||
this.count++;
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
this.count++;
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Removes and returns the last value
|
* Removes and returns the last value
|
||||||
* @returns {T | undefined} The last value, or `undefined` if the list is empty
|
* @returns {T | undefined} The last value, or `undefined` if the list is empty
|
||||||
*/
|
*/
|
||||||
public popBack(): T | undefined {
|
public popBack(): T | undefined {
|
||||||
if (!this.last) return undefined;
|
if (!this.last) return undefined;
|
||||||
|
|
||||||
const node = this.last;
|
const node = this.last;
|
||||||
|
|
||||||
this.detach(node);
|
this.detach(node);
|
||||||
|
|
||||||
return node.value;
|
return node.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes and returns the first value
|
* Removes and returns the first value
|
||||||
* @returns {T | undefined} The first value, or `undefined` if the list is empty
|
* @returns {T | undefined} The first value, or `undefined` if the list is empty
|
||||||
*/
|
*/
|
||||||
public popFront(): T | undefined {
|
public popFront(): T | undefined {
|
||||||
if (!this.first) return undefined;
|
if (!this.first) return undefined;
|
||||||
|
|
||||||
const node = this.first;
|
const node = this.first;
|
||||||
|
|
||||||
this.detach(node);
|
this.detach(node);
|
||||||
|
|
||||||
return node.value;
|
return node.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the last value without removing it
|
* Returns the last value without removing it
|
||||||
* @returns {T | undefined} The last value, or `undefined` if the list is empty
|
* @returns {T | undefined} The last value, or `undefined` if the list is empty
|
||||||
*/
|
*/
|
||||||
public peekBack(): T | undefined {
|
public peekBack(): T | undefined {
|
||||||
return this.last?.value;
|
return this.last?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first value without removing it
|
* Returns the first value without removing it
|
||||||
* @returns {T | undefined} The first value, or `undefined` if the list is empty
|
* @returns {T | undefined} The first value, or `undefined` if the list is empty
|
||||||
*/
|
*/
|
||||||
public peekFront(): T | undefined {
|
public peekFront(): T | undefined {
|
||||||
return this.first?.value;
|
return this.first?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inserts a value before the given node
|
* Inserts a value before the given node
|
||||||
* @param {LinkedListNode<T>} node The reference node
|
* @param {LinkedListNode<T>} node The reference node
|
||||||
* @param {T} value The value to insert
|
* @param {T} value The value to insert
|
||||||
* @returns {LinkedListNode<T>} The created node
|
* @returns {LinkedListNode<T>} The created node
|
||||||
*/
|
*/
|
||||||
public insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
|
public insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
|
||||||
const newNode = createNode(value);
|
const newNode = createNode(value);
|
||||||
|
|
||||||
newNode.next = node;
|
newNode.next = node;
|
||||||
newNode.prev = node.prev;
|
newNode.prev = node.prev;
|
||||||
|
|
||||||
if (node.prev) {
|
if (node.prev) {
|
||||||
node.prev.next = newNode;
|
node.prev.next = newNode;
|
||||||
} else {
|
}
|
||||||
this.first = newNode;
|
else {
|
||||||
}
|
this.first = newNode;
|
||||||
|
|
||||||
node.prev = newNode;
|
|
||||||
this.count++;
|
|
||||||
|
|
||||||
return newNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
node.prev = newNode;
|
||||||
|
this.count++;
|
||||||
|
|
||||||
|
return newNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Inserts a value after the given node
|
* Inserts a value after the given node
|
||||||
* @param {LinkedListNode<T>} node The reference node
|
* @param {LinkedListNode<T>} node The reference node
|
||||||
* @param {T} value The value to insert
|
* @param {T} value The value to insert
|
||||||
* @returns {LinkedListNode<T>} The created node
|
* @returns {LinkedListNode<T>} The created node
|
||||||
*/
|
*/
|
||||||
public insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
|
public insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
|
||||||
const newNode = createNode(value);
|
const newNode = createNode(value);
|
||||||
|
|
||||||
newNode.prev = node;
|
newNode.prev = node;
|
||||||
newNode.next = node.next;
|
newNode.next = node.next;
|
||||||
|
|
||||||
if (node.next) {
|
if (node.next) {
|
||||||
node.next.prev = newNode;
|
node.next.prev = newNode;
|
||||||
} else {
|
}
|
||||||
this.last = newNode;
|
else {
|
||||||
}
|
this.last = newNode;
|
||||||
|
|
||||||
node.next = newNode;
|
|
||||||
this.count++;
|
|
||||||
|
|
||||||
return newNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
node.next = newNode;
|
||||||
|
this.count++;
|
||||||
|
|
||||||
|
return newNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Removes a node from the list by reference in O(1)
|
* Removes a node from the list by reference in O(1)
|
||||||
* @param {LinkedListNode<T>} node The node to remove
|
* @param {LinkedListNode<T>} node The node to remove
|
||||||
* @returns {T} The value of the removed node
|
* @returns {T} The value of the removed node
|
||||||
*/
|
*/
|
||||||
public remove(node: LinkedListNode<T>): T {
|
public remove(node: LinkedListNode<T>): T {
|
||||||
this.detach(node);
|
this.detach(node);
|
||||||
|
|
||||||
return node.value;
|
return node.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes all elements from the list
|
* Removes all elements from the list
|
||||||
* @returns {this} The list instance for chaining
|
* @returns {this} The list instance for chaining
|
||||||
*/
|
*/
|
||||||
public clear(): this {
|
public clear(): this {
|
||||||
this.first = undefined;
|
this.first = undefined;
|
||||||
this.last = undefined;
|
this.last = undefined;
|
||||||
this.count = 0;
|
this.count = 0;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a shallow copy of the list values as an array
|
* Returns a shallow copy of the list values as an array
|
||||||
* @returns {T[]} Array of values from head to tail
|
* @returns {T[]} Array of values from head to tail
|
||||||
*/
|
*/
|
||||||
public toArray(): T[] {
|
public toArray(): T[] {
|
||||||
const result = Array.from<T>({ length: this.count });
|
const result = Array.from<T>({ length: this.count });
|
||||||
let current = this.first;
|
let current = this.first;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (current) {
|
while (current) {
|
||||||
result[i++] = current.value;
|
result[i++] = current.value;
|
||||||
current = current.next;
|
current = current.next;
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Returns a string representation of the list
|
* Returns a string representation of the list
|
||||||
* @returns {string} String representation
|
* @returns {string} String representation
|
||||||
*/
|
*/
|
||||||
public toString(): string {
|
public toString(): string {
|
||||||
return this.toArray().toString();
|
return this.toArray().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterator over list values from head to tail
|
* Iterator over list values from head to tail
|
||||||
*/
|
*/
|
||||||
public *[Symbol.iterator](): Iterator<T> {
|
public* [Symbol.iterator](): Iterator<T> {
|
||||||
let current = this.first;
|
let current = this.first;
|
||||||
|
|
||||||
while (current) {
|
while (current) {
|
||||||
yield current.value;
|
yield current.value;
|
||||||
current = current.next;
|
current = current.next;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async iterator over list values from head to tail
|
* Async iterator over list values from head to tail
|
||||||
*/
|
*/
|
||||||
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
public async* [Symbol.asyncIterator](): AsyncIterator<T> {
|
||||||
for (const value of this)
|
for (const value of this)
|
||||||
yield value;
|
yield value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detaches a node from the list, updating head/tail and count
|
* Detaches a node from the list, updating head/tail and count
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @param {LinkedListNode<T>} node The node to detach
|
* @param {LinkedListNode<T>} node The node to detach
|
||||||
*/
|
*/
|
||||||
private detach(node: LinkedListNode<T>): void {
|
private detach(node: LinkedListNode<T>): void {
|
||||||
if (node.prev) {
|
if (node.prev) {
|
||||||
node.prev.next = node.next;
|
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--;
|
|
||||||
}
|
}
|
||||||
|
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 +1,28 @@
|
|||||||
export interface LinkedListNode<T> {
|
export interface LinkedListNode<T> {
|
||||||
value: T;
|
value: T;
|
||||||
prev: LinkedListNode<T> | undefined;
|
prev: LinkedListNode<T> | undefined;
|
||||||
next: LinkedListNode<T> | undefined;
|
next: LinkedListNode<T> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LinkedListLike<T> extends Iterable<T>, AsyncIterable<T> {
|
export interface LinkedListLike<T> extends Iterable<T>, AsyncIterable<T> {
|
||||||
readonly length: number;
|
readonly length: number;
|
||||||
readonly isEmpty: boolean;
|
readonly isEmpty: boolean;
|
||||||
|
|
||||||
readonly head: LinkedListNode<T> | undefined;
|
readonly head: LinkedListNode<T> | undefined;
|
||||||
readonly tail: LinkedListNode<T> | undefined;
|
readonly tail: LinkedListNode<T> | undefined;
|
||||||
|
|
||||||
pushBack(value: T): LinkedListNode<T>;
|
pushBack(value: T): LinkedListNode<T>;
|
||||||
pushFront(value: T): LinkedListNode<T>;
|
pushFront(value: T): LinkedListNode<T>;
|
||||||
popBack(): T | undefined;
|
popBack(): T | undefined;
|
||||||
popFront(): T | undefined;
|
popFront(): T | undefined;
|
||||||
peekBack(): T | undefined;
|
peekBack(): T | undefined;
|
||||||
peekFront(): T | undefined;
|
peekFront(): T | undefined;
|
||||||
|
|
||||||
insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
|
insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
|
||||||
insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
|
insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
|
||||||
remove(node: LinkedListNode<T>): T;
|
remove(node: LinkedListNode<T>): T;
|
||||||
|
|
||||||
clear(): this;
|
clear(): this;
|
||||||
toArray(): T[];
|
toArray(): T[];
|
||||||
toString(): string;
|
toString(): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,211 +3,211 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { PriorityQueue } from '.';
|
import { PriorityQueue } from '.';
|
||||||
|
|
||||||
describe('PriorityQueue', () => {
|
describe('PriorityQueue', () => {
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create an empty queue', () => {
|
it('should create an empty queue', () => {
|
||||||
const pq = new PriorityQueue<number>();
|
const pq = new PriorityQueue<number>();
|
||||||
|
|
||||||
expect(pq.length).toBe(0);
|
expect(pq.length).toBe(0);
|
||||||
expect(pq.isEmpty).toBe(true);
|
expect(pq.isEmpty).toBe(true);
|
||||||
expect(pq.isFull).toBe(false);
|
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 create a queue from single value', () => {
|
||||||
it('should enqueue elements by priority', () => {
|
const pq = new PriorityQueue(42);
|
||||||
const pq = new PriorityQueue<number>();
|
|
||||||
|
|
||||||
pq.enqueue(5);
|
expect(pq.length).toBe(1);
|
||||||
pq.enqueue(1);
|
expect(pq.peek()).toBe(42);
|
||||||
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 create a queue from array', () => {
|
||||||
it('should return undefined for empty queue', () => {
|
const pq = new PriorityQueue([5, 3, 8, 1, 4]);
|
||||||
const pq = new PriorityQueue<number>();
|
|
||||||
|
|
||||||
expect(pq.dequeue()).toBeUndefined();
|
expect(pq.length).toBe(5);
|
||||||
});
|
expect(pq.peek()).toBe(1);
|
||||||
|
|
||||||
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 throw if initial values exceed maxSize', () => {
|
||||||
it('should return undefined for empty queue', () => {
|
expect(() => new PriorityQueue([1, 2, 3], { maxSize: 2 }))
|
||||||
const pq = new PriorityQueue<number>();
|
.toThrow('Initial values exceed maxSize');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(pq.peek()).toBeUndefined();
|
describe('enqueue', () => {
|
||||||
});
|
it('should enqueue elements by priority', () => {
|
||||||
|
const pq = new PriorityQueue<number>();
|
||||||
|
|
||||||
it('should return highest-priority element without removing', () => {
|
pq.enqueue(5);
|
||||||
const pq = new PriorityQueue([5, 1, 3]);
|
pq.enqueue(1);
|
||||||
|
pq.enqueue(3);
|
||||||
|
|
||||||
expect(pq.peek()).toBe(1);
|
expect(pq.peek()).toBe(1);
|
||||||
expect(pq.length).toBe(3);
|
expect(pq.length).toBe(3);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isFull', () => {
|
it('should throw when queue is full', () => {
|
||||||
it('should be false when no maxSize', () => {
|
const pq = new PriorityQueue<number>(undefined, { maxSize: 2 });
|
||||||
const pq = new PriorityQueue([1, 2, 3]);
|
|
||||||
|
|
||||||
expect(pq.isFull).toBe(false);
|
pq.enqueue(1);
|
||||||
});
|
pq.enqueue(2);
|
||||||
|
|
||||||
it('should be true when at maxSize', () => {
|
expect(() => pq.enqueue(3)).toThrow('PriorityQueue is full');
|
||||||
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(pq.isFull).toBe(true);
|
describe('dequeue', () => {
|
||||||
});
|
it('should return undefined for empty queue', () => {
|
||||||
|
const pq = new PriorityQueue<number>();
|
||||||
|
|
||||||
it('should become false after dequeue', () => {
|
expect(pq.dequeue()).toBeUndefined();
|
||||||
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
|
|
||||||
|
|
||||||
pq.dequeue();
|
|
||||||
|
|
||||||
expect(pq.isFull).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clear', () => {
|
it('should dequeue elements in priority order (min-heap)', () => {
|
||||||
it('should remove all elements', () => {
|
const pq = new PriorityQueue([5, 3, 8, 1, 4]);
|
||||||
const pq = new PriorityQueue([1, 2, 3]);
|
const result: number[] = [];
|
||||||
|
|
||||||
const result = pq.clear();
|
while (!pq.isEmpty) {
|
||||||
|
result.push(pq.dequeue()!);
|
||||||
|
}
|
||||||
|
|
||||||
expect(pq.length).toBe(0);
|
expect(result).toEqual([1, 3, 4, 5, 8]);
|
||||||
expect(pq.isEmpty).toBe(true);
|
|
||||||
expect(result).toBe(pq);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toArray', () => {
|
it('should dequeue elements in priority order (max-heap)', () => {
|
||||||
it('should return empty array for empty queue', () => {
|
const pq = new PriorityQueue([5, 3, 8, 1, 4], {
|
||||||
const pq = new PriorityQueue<number>();
|
comparator: (a, b) => b - a,
|
||||||
|
});
|
||||||
|
const result: number[] = [];
|
||||||
|
|
||||||
expect(pq.toArray()).toEqual([]);
|
while (!pq.isEmpty) {
|
||||||
});
|
result.push(pq.dequeue()!);
|
||||||
|
}
|
||||||
|
|
||||||
it('should return a shallow copy', () => {
|
expect(result).toEqual([8, 5, 4, 3, 1]);
|
||||||
const pq = new PriorityQueue([3, 1, 2]);
|
});
|
||||||
const arr = pq.toArray();
|
});
|
||||||
|
|
||||||
arr.push(99);
|
describe('peek', () => {
|
||||||
|
it('should return undefined for empty queue', () => {
|
||||||
|
const pq = new PriorityQueue<number>();
|
||||||
|
|
||||||
expect(pq.length).toBe(3);
|
expect(pq.peek()).toBeUndefined();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toString', () => {
|
it('should return highest-priority element without removing', () => {
|
||||||
it('should return formatted string', () => {
|
const pq = new PriorityQueue([5, 1, 3]);
|
||||||
const pq = new PriorityQueue([1, 2, 3]);
|
|
||||||
|
|
||||||
expect(pq.toString()).toBe('PriorityQueue(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);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('iterator', () => {
|
it('should be true when at maxSize', () => {
|
||||||
it('should iterate over elements', () => {
|
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
|
||||||
const pq = new PriorityQueue([5, 3, 1]);
|
|
||||||
const elements = [...pq];
|
|
||||||
|
|
||||||
expect(elements.length).toBe(3);
|
expect(pq.isFull).toBe(true);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('custom comparator', () => {
|
it('should become false after dequeue', () => {
|
||||||
it('should work with object priority', () => {
|
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
|
||||||
interface Job {
|
|
||||||
priority: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pq = new PriorityQueue<Job>(
|
pq.dequeue();
|
||||||
[
|
|
||||||
{ 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.isFull).toBe(false);
|
||||||
expect(pq.dequeue()?.name).toBe('normal');
|
});
|
||||||
expect(pq.dequeue()?.name).toBe('low');
|
});
|
||||||
});
|
|
||||||
|
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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interleaved operations', () => {
|
it('should return a shallow copy', () => {
|
||||||
it('should maintain priority with mixed enqueue and dequeue', () => {
|
const pq = new PriorityQueue([3, 1, 2]);
|
||||||
const pq = new PriorityQueue<number>();
|
const arr = pq.toArray();
|
||||||
|
|
||||||
pq.enqueue(10);
|
arr.push(99);
|
||||||
pq.enqueue(5);
|
|
||||||
expect(pq.dequeue()).toBe(5);
|
|
||||||
|
|
||||||
pq.enqueue(3);
|
expect(pq.length).toBe(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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export type { PriorityQueueLike } from './types';
|
|||||||
export type { Comparator } from './types';
|
export type { Comparator } from './types';
|
||||||
|
|
||||||
export interface PriorityQueueOptions<T> {
|
export interface PriorityQueueOptions<T> {
|
||||||
comparator?: Comparator<T>;
|
comparator?: Comparator<T>;
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,126 +19,126 @@ export interface PriorityQueueOptions<T> {
|
|||||||
* @template T The type of elements stored in the queue
|
* @template T The type of elements stored in the queue
|
||||||
*/
|
*/
|
||||||
export class PriorityQueue<T> implements PriorityQueueLike<T> {
|
export class PriorityQueue<T> implements PriorityQueueLike<T> {
|
||||||
/**
|
/**
|
||||||
* The maximum number of elements the queue can hold
|
* The maximum number of elements the queue can hold
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {number}
|
* @type {number}
|
||||||
*/
|
*/
|
||||||
private readonly maxSize: number;
|
private readonly maxSize: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal binary heap backing the queue
|
* Internal binary heap backing the queue
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {BinaryHeap<T>}
|
* @type {BinaryHeap<T>}
|
||||||
*/
|
*/
|
||||||
private readonly heap: BinaryHeap<T>;
|
private readonly heap: BinaryHeap<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an instance of PriorityQueue
|
* Creates an instance of PriorityQueue
|
||||||
*
|
*
|
||||||
* @param {(T[] | T)} [initialValues] The initial values to add to the queue
|
* @param {(T[] | T)} [initialValues] The initial values to add to the queue
|
||||||
* @param {PriorityQueueOptions<T>} [options] Queue configuration
|
* @param {PriorityQueueOptions<T>} [options] Queue configuration
|
||||||
*/
|
*/
|
||||||
constructor(initialValues?: T[] | T, options?: PriorityQueueOptions<T>) {
|
constructor(initialValues?: T[] | T, options?: PriorityQueueOptions<T>) {
|
||||||
this.maxSize = options?.maxSize ?? Infinity;
|
this.maxSize = options?.maxSize ?? Infinity;
|
||||||
this.heap = new BinaryHeap(initialValues, { comparator: options?.comparator });
|
this.heap = new BinaryHeap(initialValues, { comparator: options?.comparator });
|
||||||
|
|
||||||
if (this.heap.length > this.maxSize) {
|
if (this.heap.length > this.maxSize) {
|
||||||
throw new RangeError('Initial values exceed maxSize');
|
throw new RangeError('Initial values exceed maxSize');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of elements in the queue
|
* Gets the number of elements in the queue
|
||||||
* @returns {number} The number of elements in the queue
|
* @returns {number} The number of elements in the queue
|
||||||
*/
|
*/
|
||||||
public get length(): number {
|
public get length(): number {
|
||||||
return this.heap.length;
|
return this.heap.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the queue is empty
|
* Checks if the queue is empty
|
||||||
* @returns {boolean} `true` if the queue is empty, `false` otherwise
|
* @returns {boolean} `true` if the queue is empty, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isEmpty(): boolean {
|
public get isEmpty(): boolean {
|
||||||
return this.heap.isEmpty;
|
return this.heap.isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the queue is full
|
* Checks if the queue is full
|
||||||
* @returns {boolean} `true` if the queue has reached maxSize, `false` otherwise
|
* @returns {boolean} `true` if the queue has reached maxSize, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isFull(): boolean {
|
public get isFull(): boolean {
|
||||||
return this.heap.length >= this.maxSize;
|
return this.heap.length >= this.maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues an element by priority
|
* Enqueues an element by priority
|
||||||
* @param {T} element The element to enqueue
|
* @param {T} element The element to enqueue
|
||||||
* @throws {RangeError} If the queue is full
|
* @throws {RangeError} If the queue is full
|
||||||
*/
|
*/
|
||||||
public enqueue(element: T): void {
|
public enqueue(element: T): void {
|
||||||
if (this.isFull)
|
if (this.isFull)
|
||||||
throw new RangeError('PriorityQueue is full');
|
throw new RangeError('PriorityQueue is full');
|
||||||
|
|
||||||
this.heap.push(element);
|
this.heap.push(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dequeues the highest-priority element
|
* Dequeues the highest-priority element
|
||||||
* @returns {T | undefined} The highest-priority element, or `undefined` if empty
|
* @returns {T | undefined} The highest-priority element, or `undefined` if empty
|
||||||
*/
|
*/
|
||||||
public dequeue(): T | undefined {
|
public dequeue(): T | undefined {
|
||||||
return this.heap.pop();
|
return this.heap.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the highest-priority element without removing it
|
* Returns the highest-priority element without removing it
|
||||||
* @returns {T | undefined} The highest-priority element, or `undefined` if empty
|
* @returns {T | undefined} The highest-priority element, or `undefined` if empty
|
||||||
*/
|
*/
|
||||||
public peek(): T | undefined {
|
public peek(): T | undefined {
|
||||||
return this.heap.peek();
|
return this.heap.peek();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes all elements from the queue
|
* Removes all elements from the queue
|
||||||
* @returns {this} The queue instance for chaining
|
* @returns {this} The queue instance for chaining
|
||||||
*/
|
*/
|
||||||
public clear(): this {
|
public clear(): this {
|
||||||
this.heap.clear();
|
this.heap.clear();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a shallow copy of elements in heap order
|
* Returns a shallow copy of elements in heap order
|
||||||
* @returns {T[]} Array of elements
|
* @returns {T[]} Array of elements
|
||||||
*/
|
*/
|
||||||
public toArray(): T[] {
|
public toArray(): T[] {
|
||||||
return this.heap.toArray();
|
return this.heap.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a string representation of the queue
|
* Returns a string representation of the queue
|
||||||
* @returns {string} String representation
|
* @returns {string} String representation
|
||||||
*/
|
*/
|
||||||
public toString(): string {
|
public toString(): string {
|
||||||
return `PriorityQueue(${this.heap.length})`;
|
return `PriorityQueue(${this.heap.length})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterator over queue elements in heap order
|
* Iterator over queue elements in heap order
|
||||||
*/
|
*/
|
||||||
public *[Symbol.iterator](): Iterator<T> {
|
public* [Symbol.iterator](): Iterator<T> {
|
||||||
yield* this.heap;
|
yield* this.heap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async iterator over queue elements in heap order
|
* Async iterator over queue elements in heap order
|
||||||
*/
|
*/
|
||||||
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
public async* [Symbol.asyncIterator](): AsyncIterator<T> {
|
||||||
for (const element of this.heap)
|
for (const element of this.heap)
|
||||||
yield element;
|
yield element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { Comparator } from '../BinaryHeap';
|
import type { Comparator } from '../BinaryHeap';
|
||||||
|
|
||||||
export interface PriorityQueueLike<T> extends Iterable<T>, AsyncIterable<T> {
|
export interface PriorityQueueLike<T> extends Iterable<T>, AsyncIterable<T> {
|
||||||
readonly length: number;
|
readonly length: number;
|
||||||
readonly isEmpty: boolean;
|
readonly isEmpty: boolean;
|
||||||
readonly isFull: boolean;
|
readonly isFull: boolean;
|
||||||
|
|
||||||
enqueue(element: T): void;
|
enqueue(element: T): void;
|
||||||
dequeue(): T | undefined;
|
dequeue(): T | undefined;
|
||||||
peek(): T | undefined;
|
peek(): T | undefined;
|
||||||
clear(): this;
|
clear(): this;
|
||||||
toArray(): T[];
|
toArray(): T[];
|
||||||
toString(): string;
|
toString(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Comparator };
|
export type { Comparator };
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export class Queue<T> implements QueueLike<T> {
|
|||||||
*
|
*
|
||||||
* @returns {AsyncIterableIterator<T>}
|
* @returns {AsyncIterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
async *[Symbol.asyncIterator]() {
|
async* [Symbol.asyncIterator]() {
|
||||||
for (const element of this.deque)
|
for (const element of this.deque)
|
||||||
yield element;
|
yield element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { StackLike } from './types';
|
|||||||
export type { StackLike } from './types';
|
export type { StackLike } from './types';
|
||||||
|
|
||||||
export interface StackOptions {
|
export interface StackOptions {
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,138 +18,138 @@ export interface StackOptions {
|
|||||||
* @template T The type of elements stored in the stack
|
* @template T The type of elements stored in the stack
|
||||||
*/
|
*/
|
||||||
export class Stack<T> implements StackLike<T> {
|
export class Stack<T> implements StackLike<T> {
|
||||||
/**
|
/**
|
||||||
* The maximum number of elements that the stack can hold
|
* The maximum number of elements that the stack can hold
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {number}
|
* @type {number}
|
||||||
*/
|
*/
|
||||||
private readonly maxSize: number;
|
private readonly maxSize: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The stack data structure
|
* The stack data structure
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
* @type {T[]}
|
* @type {T[]}
|
||||||
*/
|
*/
|
||||||
private readonly stack: T[];
|
private readonly stack: T[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an instance of Stack
|
* Creates an instance of Stack
|
||||||
*
|
*
|
||||||
* @param {(T[] | T)} [initialValues] The initial values to add to the stack
|
* @param {(T[] | T)} [initialValues] The initial values to add to the stack
|
||||||
* @param {StackOptions} [options] The options for the stack
|
* @param {StackOptions} [options] The options for the stack
|
||||||
* @memberof Stack
|
* @memberof Stack
|
||||||
*/
|
*/
|
||||||
constructor(initialValues?: T[] | T, options?: StackOptions) {
|
constructor(initialValues?: T[] | T, options?: StackOptions) {
|
||||||
this.maxSize = options?.maxSize ?? Infinity;
|
this.maxSize = options?.maxSize ?? Infinity;
|
||||||
this.stack = isArray(initialValues) ? initialValues : initialValues ? [initialValues] : [];
|
this.stack = isArray(initialValues) ? initialValues : initialValues ? [initialValues] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of elements in the stack
|
* Gets the number of elements in the stack
|
||||||
* @returns {number} The number of elements in the stack
|
* @returns {number} The number of elements in the stack
|
||||||
*/
|
*/
|
||||||
public get length() {
|
public get length() {
|
||||||
return this.stack.length;
|
return this.stack.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the stack is empty
|
* Checks if the stack is empty
|
||||||
* @returns {boolean} `true` if the stack is empty, `false` otherwise
|
* @returns {boolean} `true` if the stack is empty, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isEmpty() {
|
public get isEmpty() {
|
||||||
return this.stack.length === 0;
|
return this.stack.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the stack is full
|
* Checks if the stack is full
|
||||||
* @returns {boolean} `true` if the stack is full, `false` otherwise
|
* @returns {boolean} `true` if the stack is full, `false` otherwise
|
||||||
*/
|
*/
|
||||||
public get isFull() {
|
public get isFull() {
|
||||||
return this.stack.length === this.maxSize;
|
return this.stack.length === this.maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pushes an element onto the stack
|
* Pushes an element onto the stack
|
||||||
* @param {T} element The element to push onto the stack
|
* @param {T} element The element to push onto the stack
|
||||||
* @returns {this}
|
* @returns {this}
|
||||||
* @throws {RangeError} If the stack is full
|
* @throws {RangeError} If the stack is full
|
||||||
*/
|
*/
|
||||||
public push(element: T) {
|
public push(element: T) {
|
||||||
if (this.isFull)
|
if (this.isFull)
|
||||||
throw new RangeError('Stack is full');
|
throw new RangeError('Stack is full');
|
||||||
|
|
||||||
this.stack.push(element);
|
this.stack.push(element);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pops an element from the stack
|
* Pops an element from the stack
|
||||||
* @returns {T | undefined} The element popped from the stack
|
* @returns {T | undefined} The element popped from the stack
|
||||||
*/
|
*/
|
||||||
public pop() {
|
public pop() {
|
||||||
return this.stack.pop();
|
return this.stack.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Peeks at the top element of the stack
|
* Peeks at the top element of the stack
|
||||||
* @returns {T | undefined} The top element of the stack
|
* @returns {T | undefined} The top element of the stack
|
||||||
*/
|
*/
|
||||||
public peek() {
|
public peek() {
|
||||||
if (this.isEmpty)
|
if (this.isEmpty)
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
return last(this.stack);
|
return last(this.stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the stack
|
* Clears the stack
|
||||||
*
|
*
|
||||||
* @returns {this}
|
* @returns {this}
|
||||||
*/
|
*/
|
||||||
public clear() {
|
public clear() {
|
||||||
this.stack.length = 0;
|
this.stack.length = 0;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the stack to an array
|
* Converts the stack to an array
|
||||||
*
|
*
|
||||||
* @returns {T[]}
|
* @returns {T[]}
|
||||||
*/
|
*/
|
||||||
public toArray() {
|
public toArray() {
|
||||||
return this.stack.toReversed();
|
return this.stack.toReversed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a string representation of the stack
|
* Returns a string representation of the stack
|
||||||
*
|
*
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
public toString() {
|
public toString() {
|
||||||
return this.toArray().toString();
|
return this.toArray().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an iterator for the stack
|
* Returns an iterator for the stack
|
||||||
*
|
*
|
||||||
* @returns {IterableIterator<T>}
|
* @returns {IterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
public [Symbol.iterator]() {
|
public [Symbol.iterator]() {
|
||||||
return this.toArray()[Symbol.iterator]();
|
return this.toArray()[Symbol.iterator]();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an async iterator for the stack
|
* Returns an async iterator for the stack
|
||||||
*
|
*
|
||||||
* @returns {AsyncIterableIterator<T>}
|
* @returns {AsyncIterableIterator<T>}
|
||||||
*/
|
*/
|
||||||
public async *[Symbol.asyncIterator]() {
|
public async* [Symbol.asyncIterator]() {
|
||||||
for (const element of this.toArray()) {
|
for (const element of this.toArray()) {
|
||||||
yield element;
|
yield element;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {levenshteinDistance} from '.';
|
import { levenshteinDistance } from '.';
|
||||||
|
|
||||||
describe('levenshteinDistance', () => {
|
describe('levenshteinDistance', () => {
|
||||||
it('calculate edit distance between two strings', () => {
|
it('calculate edit distance between two strings', () => {
|
||||||
// just one substitution I at the beginning
|
// just one substitution I at the beginning
|
||||||
expect(levenshteinDistance('islander', 'slander')).toBe(1);
|
expect(levenshteinDistance('islander', 'slander')).toBe(1);
|
||||||
|
|
||||||
// substitution M->K, T->M and add an A to the end
|
// substitution M->K, T->M and add an A to the end
|
||||||
expect(levenshteinDistance('mart', 'karma')).toBe(3);
|
expect(levenshteinDistance('mart', 'karma')).toBe(3);
|
||||||
|
|
||||||
// substitution K->S, E->I and insert G at the end
|
// substitution K->S, E->I and insert G at the end
|
||||||
expect(levenshteinDistance('kitten', 'sitting')).toBe(3);
|
expect(levenshteinDistance('kitten', 'sitting')).toBe(3);
|
||||||
|
|
||||||
// should add 4 letters FOOT at the beginning
|
// should add 4 letters FOOT at the beginning
|
||||||
expect(levenshteinDistance('ball', 'football')).toBe(4);
|
expect(levenshteinDistance('ball', 'football')).toBe(4);
|
||||||
|
|
||||||
// should delete 4 letters FOOT at the beginning
|
// should delete 4 letters FOOT at the beginning
|
||||||
expect(levenshteinDistance('football', 'foot')).toBe(4);
|
expect(levenshteinDistance('football', 'foot')).toBe(4);
|
||||||
|
|
||||||
// needs to substitute the first 5 chars INTEN->EXECU
|
// needs to substitute the first 5 chars INTEN->EXECU
|
||||||
expect(levenshteinDistance('intention', 'execution')).toBe(5);
|
expect(levenshteinDistance('intention', 'execution')).toBe(5);
|
||||||
});
|
|
||||||
|
|
||||||
it('handle empty strings', () => {
|
|
||||||
expect(levenshteinDistance('', '')).toBe(0);
|
|
||||||
expect(levenshteinDistance('a', '')).toBe(1);
|
|
||||||
expect(levenshteinDistance('', 'a')).toBe(1);
|
|
||||||
expect(levenshteinDistance('abc', '')).toBe(3);
|
|
||||||
expect(levenshteinDistance('', 'abc')).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handle empty strings', () => {
|
||||||
|
expect(levenshteinDistance('', '')).toBe(0);
|
||||||
|
expect(levenshteinDistance('a', '')).toBe(1);
|
||||||
|
expect(levenshteinDistance('', 'a')).toBe(1);
|
||||||
|
expect(levenshteinDistance('abc', '')).toBe(3);
|
||||||
|
expect(levenshteinDistance('', 'abc')).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,37 +10,37 @@
|
|||||||
* @since 0.0.1
|
* @since 0.0.1
|
||||||
*/
|
*/
|
||||||
export function levenshteinDistance(left: string, right: string): number {
|
export function levenshteinDistance(left: string, right: string): number {
|
||||||
if (left === right) return 0;
|
if (left === right) return 0;
|
||||||
|
|
||||||
if (left.length === 0) return right.length;
|
if (left.length === 0) return right.length;
|
||||||
if (right.length === 0) return left.length;
|
if (right.length === 0) return left.length;
|
||||||
|
|
||||||
// Create empty edit distance matrix for all possible modifications of
|
// Create empty edit distance matrix for all possible modifications of
|
||||||
// substrings of left to substrings of right
|
// substrings of left to substrings of right
|
||||||
const distanceMatrix = Array(right.length + 1).fill(null).map(() => Array(left.length + 1).fill(null));
|
const distanceMatrix = Array(right.length + 1).fill(null).map(() => Array(left.length + 1).fill(null));
|
||||||
|
|
||||||
// Fill the first row of the matrix
|
// Fill the first row of the matrix
|
||||||
// If this is the first row, we're transforming from an empty string to left
|
// If this is the first row, we're transforming from an empty string to left
|
||||||
// In this case, the number of operations equals the length of left substring
|
// In this case, the number of operations equals the length of left substring
|
||||||
for (let i = 0; i <= left.length; i++)
|
for (let i = 0; i <= left.length; i++)
|
||||||
distanceMatrix[0]![i]! = i;
|
distanceMatrix[0]![i]! = i;
|
||||||
|
|
||||||
// Fill the first column of the matrix
|
// Fill the first column of the matrix
|
||||||
// If this is the first column, we're transforming empty string to right
|
// If this is the first column, we're transforming empty string to right
|
||||||
// In this case, the number of operations equals the length of right substring
|
// In this case, the number of operations equals the length of right substring
|
||||||
for (let j = 0; j <= right.length; j++)
|
for (let j = 0; j <= right.length; j++)
|
||||||
distanceMatrix[j]![0]! = j;
|
distanceMatrix[j]![0]! = j;
|
||||||
|
|
||||||
for (let j = 1; j <= right.length; j++) {
|
for (let j = 1; j <= right.length; j++) {
|
||||||
for (let i = 1; i <= left.length; i++) {
|
for (let i = 1; i <= left.length; i++) {
|
||||||
const indicator = left[i - 1] === right[j - 1] ? 0 : 1;
|
const indicator = left[i - 1] === right[j - 1] ? 0 : 1;
|
||||||
distanceMatrix[j]![i]! = Math.min(
|
distanceMatrix[j]![i]! = Math.min(
|
||||||
distanceMatrix[j]![i - 1]! + 1, // deletion
|
distanceMatrix[j]![i - 1]! + 1, // deletion
|
||||||
distanceMatrix[j - 1]![i]! + 1, // insertion
|
distanceMatrix[j - 1]![i]! + 1, // insertion
|
||||||
distanceMatrix[j - 1]![i - 1]! + indicator // substitution
|
distanceMatrix[j - 1]![i - 1]! + indicator, // substitution
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return distanceMatrix[right.length]![left.length]!;
|
return distanceMatrix[right.length]![left.length]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ describe.skip('templateObject', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('replace template placeholders with nested values from args', () => {
|
it('replace template placeholders with nested values from args', () => {
|
||||||
const result = templateObject('Hello {{user.name}, your address {user.addresses.0.street}', {
|
const result = templateObject('Hello {{user.name}, your address {user.addresses.0.street}', {
|
||||||
user: {
|
user: {
|
||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
addresses: [
|
addresses: [
|
||||||
{ street: '123 Main St', city: 'Springfield'},
|
{ street: '123 Main St', city: 'Springfield' },
|
||||||
{ street: '456 Elm St', city: 'Shelbyville'}
|
{ street: '456 Elm St', city: 'Shelbyville' },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe('Hello {John Doe, your address 123 Main St');
|
expect(result).toBe('Hello {John Doe, your address 123 Main St');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -25,8 +25,8 @@ const TEMPLATE_PLACEHOLDER = /\{\s*([^{}]+?)\s*\}/gm;
|
|||||||
* type Unbalanced = ClearPlaceholder<'{user.name'>; // 'user.name'
|
* type Unbalanced = ClearPlaceholder<'{user.name'>; // 'user.name'
|
||||||
* type Spaces = ClearPlaceholder<'{ user.name }'>; // 'user.name'
|
* type Spaces = ClearPlaceholder<'{ user.name }'>; // 'user.name'
|
||||||
*/
|
*/
|
||||||
export type ClearPlaceholder<In extends string> =
|
export type ClearPlaceholder<In extends string>
|
||||||
In extends `${string}{${infer Template}`
|
= In extends `${string}{${infer Template}`
|
||||||
? ClearPlaceholder<Template>
|
? ClearPlaceholder<Template>
|
||||||
: In extends `${infer Template}}${string}`
|
: In extends `${infer Template}}${string}`
|
||||||
? ClearPlaceholder<Template>
|
? ClearPlaceholder<Template>
|
||||||
@@ -38,8 +38,8 @@ export type ClearPlaceholder<In extends string> =
|
|||||||
* @example
|
* @example
|
||||||
* type Base = ExtractPlaceholders<'Hello {user.name}, {user.addresses.0.street}'>; // 'user.name' | 'user.addresses.0.street'
|
* type Base = ExtractPlaceholders<'Hello {user.name}, {user.addresses.0.street}'>; // 'user.name' | 'user.addresses.0.street'
|
||||||
*/
|
*/
|
||||||
export type ExtractPlaceholders<In extends string> =
|
export type ExtractPlaceholders<In extends string>
|
||||||
In extends `${infer Before}}${infer After}`
|
= In extends `${infer Before}}${infer After}`
|
||||||
? Before extends `${string}{${infer Placeholder}`
|
? Before extends `${string}{${infer Placeholder}`
|
||||||
? ClearPlaceholder<Placeholder> | ExtractPlaceholders<After>
|
? ClearPlaceholder<Placeholder> | ExtractPlaceholders<After>
|
||||||
: ExtractPlaceholders<After>
|
: ExtractPlaceholders<After>
|
||||||
@@ -56,12 +56,12 @@ export type GenerateTypes<T extends string, Target = string> = UnionToIntersecti
|
|||||||
|
|
||||||
export function templateObject<
|
export function templateObject<
|
||||||
T extends string,
|
T extends string,
|
||||||
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection
|
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection,
|
||||||
>(template: T, args: A, fallback?: TemplateFallback) {
|
>(template: T, args: A, fallback?: TemplateFallback) {
|
||||||
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
|
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
|
||||||
const value = get(args, key)?.toString();
|
const value = get(args, key)?.toString();
|
||||||
return value !== undefined ? value : (isFunction(fallback) ? fallback(key) : '');
|
return value !== undefined ? value : (isFunction(fallback) ? fallback(key) : '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
templateObject('Hello {user.name}, your address {user.addresses.0.city}', {
|
templateObject('Hello {user.name}, your address {user.addresses.0.city}', {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user