From 7c4ed44fbaa650dae016c9d1afc6faa8c7936ef8 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 23:24:53 +0200 Subject: [PATCH 1/7] test(triki-controller): vitest suite + CI test job on PR Add the package's first automated tests with vitest: - pure modules: protocol (START/LED builders, scale-constant regression guards), parser (frame decode, header resync, split/garbage handling), fusion (quaternion helpers, Madgwick, AccelAHRS), vqf (frame parity, bootstrap, live retune), emitter (error isolation, unsubscribe). - TrikiController driven through a fake Web Bluetooth stack (src/testkit): frame pipeline with scale + bias, pluggable fusion + live tuning, resetHeading, battery read/notify, LED/rate, lifecycle. Wire scripts (test/test:watch/coverage), gate publish via prepublishOnly, and add .github/workflows/test.yml running typecheck + tests on pull requests that touch the package (Node 24, path-filtered). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 44 + packages/triki-controller/.gitignore | 1 + packages/triki-controller/package-lock.json | 1357 ++++++++++++++++- packages/triki-controller/package.json | 9 +- .../triki-controller/src/controller.test.ts | 309 ++++ packages/triki-controller/src/emitter.test.ts | 106 ++ packages/triki-controller/src/fusion.test.ts | 149 ++ packages/triki-controller/src/parser.test.ts | 106 ++ .../triki-controller/src/protocol.test.ts | 74 + .../triki-controller/src/testkit/fakeBle.ts | 167 ++ .../triki-controller/src/testkit/frames.ts | 41 + packages/triki-controller/src/vqf.test.ts | 55 + packages/triki-controller/vitest.config.ts | 18 + 13 files changed, 2431 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 packages/triki-controller/src/controller.test.ts create mode 100644 packages/triki-controller/src/emitter.test.ts create mode 100644 packages/triki-controller/src/fusion.test.ts create mode 100644 packages/triki-controller/src/parser.test.ts create mode 100644 packages/triki-controller/src/protocol.test.ts create mode 100644 packages/triki-controller/src/testkit/fakeBle.ts create mode 100644 packages/triki-controller/src/testkit/frames.ts create mode 100644 packages/triki-controller/src/vqf.test.ts create mode 100644 packages/triki-controller/vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1708d50 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Test triki-controller + +# Unit tests for the publishable package. Runs only when the package (or this +# workflow) changes, so docs-only PRs stay green without a redundant Node job. +on: + pull_request: + paths: + - packages/triki-controller/** + - .github/workflows/test.yml + push: + branches: [main] + paths: + - packages/triki-controller/** + - .github/workflows/test.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/triki-controller + steps: + - uses: actions/checkout@v4 + + # Node 24 mirrors the publish workflow, so tests run on the same runtime + # that ships the package. + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: packages/triki-controller/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test diff --git a/packages/triki-controller/.gitignore b/packages/triki-controller/.gitignore index 756593c..61ca4c6 100644 --- a/packages/triki-controller/.gitignore +++ b/packages/triki-controller/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ +coverage/ *.tgz *.tsbuildinfo diff --git a/packages/triki-controller/package-lock.json b/packages/triki-controller/package-lock.json index 7e19945..a62e028 100644 --- a/packages/triki-controller/package-lock.json +++ b/packages/triki-controller/package-lock.json @@ -10,13 +10,86 @@ "license": "MIT", "devDependencies": { "@types/web-bluetooth": "^0.0.20", + "@vitest/coverage-v8": "^4.1.9", "tsup": "^8.3.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.1.9" }, "engines": { "node": ">=18" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -498,6 +571,299 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -848,6 +1214,42 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -862,8 +1264,153 @@ "dev": true, "license": "MIT" }, - "node_modules/acorn": { - "version": "8.17.0", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, @@ -882,6 +1429,28 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -908,6 +1477,16 @@ "node": ">=8" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -951,6 +1530,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -969,6 +1555,23 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1012,6 +1615,26 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1057,6 +1680,62 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1067,6 +1746,274 @@ "node": ">=10" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1107,6 +2054,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1139,6 +2114,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1149,6 +2143,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1199,6 +2207,36 @@ "pathe": "^2.0.1" } }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -1266,6 +2304,40 @@ "node": ">=8" } }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1311,6 +2383,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1321,6 +2413,30 @@ "node": ">= 12" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -1344,6 +2460,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -1367,6 +2496,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -1391,6 +2527,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -1408,6 +2554,14 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -1482,6 +2636,203 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/packages/triki-controller/package.json b/packages/triki-controller/package.json index c7d366a..583102b 100644 --- a/packages/triki-controller/package.json +++ b/packages/triki-controller/package.json @@ -49,12 +49,17 @@ "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", - "prepublishOnly": "npm run typecheck && npm run build" + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "prepublishOnly": "npm run typecheck && npm run test && npm run build" }, "devDependencies": { "@types/web-bluetooth": "^0.0.20", + "@vitest/coverage-v8": "^4.1.9", "tsup": "^8.3.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.1.9" }, "publishConfig": { "access": "public" diff --git a/packages/triki-controller/src/controller.test.ts b/packages/triki-controller/src/controller.test.ts new file mode 100644 index 0000000..683bc16 --- /dev/null +++ b/packages/triki-controller/src/controller.test.ts @@ -0,0 +1,309 @@ +import { describe, test, expect, vi, afterEach } from "vitest"; +import { TrikiController } from "./controller"; +import { MadgwickAHRS, AccelAHRS } from "./fusion"; +import { VqfAHRS } from "./vqf"; +import { startCmd } from "./protocol"; +import { makeFakeBluetooth } from "./testkit/fakeBle"; +import type { FakeBleHandles, FakeBleOptions } from "./testkit/fakeBle"; +import { frame } from "./testkit/frames"; +import type { + ConnectionState, + FrameEvent, + OrientationEvent, + TrikiControllerOptions, +} from "./events"; + +const created: TrikiController[] = []; + +/** Construct a controller and track it so afterEach tears down its rate timer. */ +function make(options?: TrikiControllerOptions): TrikiController { + const c = new TrikiController(options); + created.push(c); + return c; +} + +/** Wire a fake stack and expose it on `navigator.bluetooth`. */ +function install(fakeOptions?: FakeBleOptions): FakeBleHandles { + const handles = makeFakeBluetooth(fakeOptions); + vi.stubGlobal("navigator", { bluetooth: handles.bluetooth }); + return handles; +} + +/** Let best-effort async work (e.g. the battery probe) settle. */ +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + +const bytes = (u: Uint8Array): number[] => Array.from(u); + +/** Last element (the package targets ES2020, so `Array.prototype.at` is unavailable). */ +const last = (a: T[]): T => a[a.length - 1]!; + +afterEach(() => { + for (const c of created.splice(0)) c.disconnect(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("fusion option resolution", () => { + test("maps the constructor option to an algorithm and filter", () => { + expect(make().fusionAlgorithm).toBe("madgwick"); + expect(make().fusion).toBeInstanceOf(MadgwickAHRS); + expect(make({ fusion: true }).fusion).toBeInstanceOf(MadgwickAHRS); + + const off = make({ fusion: false }); + expect(off.fusionAlgorithm).toBe("none"); + expect(off.fusion).toBeUndefined(); + + expect(make({ fusion: "vqf" }).fusion).toBeInstanceOf(VqfAHRS); + expect(make({ fusion: "accel" }).fusion).toBeInstanceOf(AccelAHRS); + expect(make({ fusion: "none" }).fusion).toBeUndefined(); + }); +}); + +describe("isSupported", () => { + test("reflects whether navigator.bluetooth exists", () => { + vi.stubGlobal("navigator", {}); + expect(TrikiController.isSupported()).toBe(false); + install(); + expect(TrikiController.isSupported()).toBe(true); + }); +}); + +describe("connect + frame pipeline", () => { + test("writes START, transitions state, and decodes frames into events", async () => { + const h = install(); + const ctrl = make({ fusion: "madgwick" }); + const states: ConnectionState[] = []; + ctrl.on("connectionchange", (s) => states.push(s)); + + await ctrl.connect(); + + expect(states).toEqual(["pairing", "streaming"]); + expect(ctrl.isConnected).toBe(true); + expect(bytes(h.rx.writes[0]!)).toEqual(bytes(startCmd(104))); + expect(h.tx.startNotificationsCount).toBe(1); + + const frames: FrameEvent[] = []; + const orientations: OrientationEvent[] = []; + ctrl.on("frame", (e) => frames.push(e)); + ctrl.on("orientation", (e) => orientations.push(e)); + + h.tx.notify(frame(14286, 0, -14286, 0, 0, 2048)); + + expect(frames).toHaveLength(1); + const f = frames[0]!; + expect(f.raw).toEqual({ gx: 14286, gy: 0, gz: -14286, ax: 0, ay: 0, az: 2048 }); + expect(f.gyro.x).toBeCloseTo(1000, 3); + expect(f.gyro.z).toBeCloseTo(-1000, 3); + expect(f.accel.z).toBeCloseTo(1, 6); + + expect(orientations).toHaveLength(1); + expect(orientations[0]!.algorithm).toBe("madgwick"); + expect(orientations[0]!.quaternion).toHaveLength(4); + expect(orientations[0]!.euler).toHaveProperty("roll"); + }); +}); + +describe("bias and scale", () => { + test("subtracts gyro/accel bias and honours gyro scale", async () => { + const h = install(); + const ctrl = make({ + fusion: "none", + gyroBias: { x: 1, y: 2, z: 3 }, + accelBias: { x: 0.1, y: 0, z: 0 }, + }); + await ctrl.connect(); + + const frames: FrameEvent[] = []; + ctrl.on("frame", (e) => frames.push(e)); + + h.tx.notify(frame(0, 0, 0, 0, 0, 2048)); + let f = last(frames); + expect(f.gyro.x).toBeCloseTo(-1, 6); + expect(f.gyro.y).toBeCloseTo(-2, 6); + expect(f.gyro.z).toBeCloseTo(-3, 6); + expect(f.accel.x).toBeCloseTo(-0.1, 6); + expect(f.accel.z).toBeCloseTo(1, 6); + + ctrl.setGyroBias({ x: 0, y: 0, z: 0 }); + ctrl.setAccelBias({ x: 0, y: 0, z: 0 }); + h.tx.notify(frame(0, 0, 0, 0, 0, 2048)); + f = last(frames); + expect(f.gyro.x).toBeCloseTo(0, 6); + expect(f.accel.x).toBeCloseTo(0, 6); + + ctrl.setGyroScale(1); + h.tx.notify(frame(100, 0, 0, 0, 0, 0)); + expect(last(frames).gyro.x).toBeCloseTo(100, 6); + }); +}); + +describe("runtime fusion controls", () => { + test("setFusion swaps the filter and gates orientation events", async () => { + const h = install(); + const ctrl = make({ fusion: "madgwick" }); + await ctrl.connect(); + + expect(ctrl.fusion).toBeInstanceOf(MadgwickAHRS); + ctrl.setFusion("vqf"); + expect(ctrl.fusion).toBeInstanceOf(VqfAHRS); + expect(ctrl.fusionAlgorithm).toBe("vqf"); + ctrl.setFusion("accel"); + expect(ctrl.fusion).toBeInstanceOf(AccelAHRS); + + ctrl.setFusion("none"); + expect(ctrl.fusion).toBeUndefined(); + const orientations: OrientationEvent[] = []; + ctrl.on("orientation", (e) => orientations.push(e)); + h.tx.notify(frame(0, 0, 0, 0, 0, 2048)); + expect(orientations).toHaveLength(0); + }); + + test("setBeta and setTauAcc tune the live filter", () => { + install(); + const ctrl = make({ fusion: "madgwick" }); + ctrl.setBeta(0.25); + expect((ctrl.fusion as MadgwickAHRS).beta).toBe(0.25); + + ctrl.setFusion("vqf"); + ctrl.setTauAcc(0.5); + expect((ctrl.fusion as VqfAHRS).tauAcc).toBe(0.5); + }); + + test("resetHeading is a no-op without fusion", () => { + install(); + const ctrl = make({ fusion: "none" }); + expect(() => ctrl.resetHeading()).not.toThrow(); + }); + + test("resetHeading re-zeroes the emitted heading", async () => { + const h = install(); + const ctrl = make({ fusion: "madgwick" }); + await ctrl.connect(); + + const orientations: OrientationEvent[] = []; + ctrl.on("orientation", (e) => orientations.push(e)); + + // First frame after connect integrates at a deterministic dt = 1/rateHz, so a + // strong z-gyro yields a known non-zero heading. + h.tx.notify(frame(0, 0, 14286, 0, 0, 2048)); + expect(Math.abs(last(orientations).euler.yaw)).toBeGreaterThan(1); + + ctrl.resetHeading(); + h.tx.notify(frame(0, 0, 0, 0, 0, 2048)); + expect(Math.abs(last(orientations).euler.yaw)).toBeLessThan(1); + }); +}); + +describe("battery", () => { + test("reads the level on connect and tracks notifications", async () => { + const h = install({ batteryLevel: 87 }); + const ctrl = make({ fusion: "none" }); + const firstBattery = new Promise((resolve) => ctrl.on("battery", resolve)); + + await ctrl.connect(); + expect(await firstBattery).toBe(87); + expect(ctrl.battery).toBe(87); + + // The notification subscription is set up just after the initial read; let it land. + await flush(); + h.battery!.notifyByte(42); + expect(ctrl.battery).toBe(42); + }); + + test("tolerates a token without the Battery service", async () => { + install({ battery: false }); + const ctrl = make({ fusion: "none" }); + const onBattery = vi.fn(); + ctrl.on("battery", onBattery); + + await ctrl.connect(); + await flush(); + + expect(ctrl.battery).toBeNull(); + expect(onBattery).not.toHaveBeenCalled(); + }); +}); + +describe("LED and rate commands", () => { + test("setLed writes the control byte", async () => { + const h = install({ led: true }); + const ctrl = make({ fusion: "none" }); + await ctrl.connect(); + + expect(ctrl.hasLed).toBe(true); + await ctrl.setLed(true); + await ctrl.setLed(false); + expect(h.ctrl!.writes.map(bytes)).toEqual([[0x01], [0x00]]); + }); + + test("setLed throws when the LED characteristic is absent", async () => { + install({ led: false }); + const ctrl = make({ fusion: "none" }); + await ctrl.connect(); + + expect(ctrl.hasLed).toBe(false); + await expect(ctrl.setLed(true)).rejects.toThrow(); + }); + + test("setRate stores before connect and re-sends START while streaming", async () => { + const h = install(); + const ctrl = make({ fusion: "none" }); + + await ctrl.setRate(52); + expect(ctrl.rateHz).toBe(52); + + await ctrl.connect(); + expect(bytes(h.rx.writes[0]!)).toEqual(bytes(startCmd(52))); + + await ctrl.setRate(208); + expect(ctrl.rateHz).toBe(208); + expect(bytes(last(h.rx.writes))).toEqual(bytes(startCmd(208))); + }); +}); + +describe("lifecycle", () => { + test("disconnect cleans up, emits disconnected, and stops handling frames", async () => { + const h = install(); + const ctrl = make({ fusion: "none" }); + const states: ConnectionState[] = []; + ctrl.on("connectionchange", (s) => states.push(s)); + + await ctrl.connect(); + ctrl.disconnect(); + + expect(ctrl.isConnected).toBe(false); + expect(ctrl.state).toBe("disconnected"); + expect(states).toEqual(["pairing", "streaming", "disconnected"]); + + const frames: FrameEvent[] = []; + ctrl.on("frame", (e) => frames.push(e)); + h.tx.notify(frame(1, 0, 0, 0, 0, 0)); + expect(frames).toHaveLength(0); + }); + + test("a remote gattserverdisconnected tears the session down", async () => { + const h = install(); + const ctrl = make({ fusion: "none" }); + await ctrl.connect(); + expect(ctrl.isConnected).toBe(true); + + h.gatt.disconnect(); // simulate the peripheral dropping the link + expect(ctrl.state).toBe("disconnected"); + }); +}); + +describe("rate measurement", () => { + test("emits frames-per-second roughly once per second", async () => { + vi.useFakeTimers(); + const h = install(); + const ctrl = make({ fusion: "none" }); + await ctrl.connect(); + + const rates: number[] = []; + ctrl.on("rate", (r) => rates.push(r)); + for (let i = 0; i < 5; i++) h.tx.notify(frame(0, 0, 0, 0, 0, 2048)); + + vi.advanceTimersByTime(1000); + expect(last(rates)).toBe(5); + }); +}); diff --git a/packages/triki-controller/src/emitter.test.ts b/packages/triki-controller/src/emitter.test.ts new file mode 100644 index 0000000..f2da8a3 --- /dev/null +++ b/packages/triki-controller/src/emitter.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, vi } from "vitest"; +import { TypedEmitter } from "./emitter"; + +interface Events { + ping: number; + boom: string; + [key: string]: unknown; +} + +/** TypedEmitter.emit is protected; a tiny subclass exposes it for testing. */ +class Bus extends TypedEmitter { + fire(type: K, payload: Events[K]): void { + this.emit(type, payload); + } +} + +describe("TypedEmitter", () => { + test("on/emit delivers the typed payload", () => { + const bus = new Bus(); + const seen: number[] = []; + bus.on("ping", (n) => seen.push(n)); + bus.fire("ping", 1); + bus.fire("ping", 2); + expect(seen).toEqual([1, 2]); + }); + + test("does not cross event types", () => { + const bus = new Bus(); + const ping = vi.fn(); + bus.on("ping", ping); + bus.fire("boom", "x"); + expect(ping).not.toHaveBeenCalled(); + }); + + test("the returned function unsubscribes", () => { + const bus = new Bus(); + const fn = vi.fn(); + const off = bus.on("ping", fn); + bus.fire("ping", 1); + off(); + bus.fire("ping", 2); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenLastCalledWith(1); + }); + + test("off() removes a listener", () => { + const bus = new Bus(); + const fn = vi.fn(); + bus.on("ping", fn); + bus.off("ping", fn); + bus.fire("ping", 1); + expect(fn).not.toHaveBeenCalled(); + }); + + test("once() fires exactly once", () => { + const bus = new Bus(); + const fn = vi.fn(); + bus.once("ping", fn); + bus.fire("ping", 1); + bus.fire("ping", 2); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(1); + }); + + test("a throwing listener is isolated and does not stop the others", () => { + const bus = new Bus(); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const after = vi.fn(); + bus.on("ping", () => { + throw new Error("boom"); + }); + bus.on("ping", after); + expect(() => bus.fire("ping", 1)).not.toThrow(); + expect(after).toHaveBeenCalledWith(1); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + test("removeAllListeners() clears every subscription", () => { + const bus = new Bus(); + const a = vi.fn(); + const b = vi.fn(); + bus.on("ping", a); + bus.on("boom", b); + bus.removeAllListeners(); + bus.fire("ping", 1); + bus.fire("boom", "x"); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + test("unsubscribing mid-dispatch is safe (snapshot iteration)", () => { + const bus = new Bus(); + const b = vi.fn(); + let offB: () => void = () => {}; + // The first listener removes the second during dispatch. + bus.on("ping", () => offB()); + offB = bus.on("ping", b); + expect(() => bus.fire("ping", 1)).not.toThrow(); + // b was already in the dispatch snapshot, so it still ran this round... + expect(b).toHaveBeenCalledTimes(1); + // ...but is removed for subsequent emits. + bus.fire("ping", 2); + expect(b).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/triki-controller/src/fusion.test.ts b/packages/triki-controller/src/fusion.test.ts new file mode 100644 index 0000000..43015d9 --- /dev/null +++ b/packages/triki-controller/src/fusion.test.ts @@ -0,0 +1,149 @@ +import { describe, test, expect } from "vitest"; +import { + MadgwickAHRS, + AccelAHRS, + quatMul, + quatAboutZ, + yawRadOf, + eulerOf, +} from "./fusion"; +import type { Quaternion } from "./fusion"; + +const norm = (q: Quaternion): number => Math.hypot(q[0], q[1], q[2], q[3]); +const DEG = Math.PI / 180; + +describe("quaternion helpers", () => { + test("quatMul: identity is neutral", () => { + const q: Quaternion = [0.5, 0.5, -0.5, 0.5]; + expect(quatMul([1, 0, 0, 0], q)).toEqual(q); + expect(quatMul(q, [1, 0, 0, 0])).toEqual(q); + }); + + test("quatMul: i ∘ j = k (Hamilton convention)", () => { + expect(quatMul([0, 1, 0, 0], [0, 0, 1, 0])).toEqual([0, 0, 0, 1]); + }); + + test("quatMul: non-commutative (j ∘ i = -k)", () => { + expect(quatMul([0, 0, 1, 0], [0, 1, 0, 0])).toEqual([0, 0, 0, -1]); + }); + + test("quatAboutZ round-trips through yawRadOf", () => { + for (const rad of [-1.2, -0.3, 0, 0.5, 1.9]) { + expect(yawRadOf(quatAboutZ(rad))).toBeCloseTo(rad, 10); + } + }); + + test("eulerOf: identity is zero", () => { + expect(eulerOf([1, 0, 0, 0])).toEqual({ roll: 0, pitch: 0, yaw: 0 }); + }); + + test("eulerOf: pure roll / pitch / yaw rotations", () => { + const roll = eulerOf([Math.cos(15 * DEG), Math.sin(15 * DEG), 0, 0]); + expect(roll.roll).toBeCloseTo(30, 6); + expect(roll.pitch).toBeCloseTo(0, 6); + expect(roll.yaw).toBeCloseTo(0, 6); + + const pitch = eulerOf([Math.cos(10 * DEG), 0, Math.sin(10 * DEG), 0]); + expect(pitch.pitch).toBeCloseTo(20, 6); + expect(pitch.roll).toBeCloseTo(0, 6); + + const yaw = eulerOf(quatAboutZ(25 * DEG)); + expect(yaw.yaw).toBeCloseTo(25, 6); + }); + + test("eulerOf: pitch is clamped at ±90° (no NaN past the gimbal)", () => { + const e = eulerOf([Math.cos(45 * DEG), 0, Math.sin(45 * DEG), 0]); + expect(e.pitch).toBeCloseTo(90, 6); + expect(Number.isNaN(e.pitch)).toBe(false); + }); +}); + +describe("MadgwickAHRS", () => { + test("starts at identity and resets to it", () => { + const m = new MadgwickAHRS(); + expect(m.quaternion()).toEqual([1, 0, 0, 0]); + for (let i = 0; i < 50; i++) m.update(30, 10, -5, 0, 0, 1, 1 / 104); + expect(m.quaternion()).not.toEqual([1, 0, 0, 0]); + m.reset(); + expect(m.quaternion()).toEqual([1, 0, 0, 0]); + }); + + test("keeps the quaternion unit-norm across many updates", () => { + const m = new MadgwickAHRS({ beta: 0.1 }); + for (let i = 0; i < 1000; i++) m.update(50, -30, 12, 0.1, -0.2, 0.97, 1 / 104); + expect(norm(m.quaternion())).toBeCloseTo(1, 10); + }); + + test("converges to the accel tilt with zero gyro", () => { + const ax = 0; + const ay = 0.5; + const az = Math.sqrt(1 - ay * ay); // unit gravity, ~30° roll about X + const target = new AccelAHRS(); + target.update(0, 0, 0, ax, ay, az, 1 / 104); + + const m = new MadgwickAHRS({ beta: 0.3 }); + for (let i = 0; i < 6000; i++) m.update(0, 0, 0, ax, ay, az, 1 / 104); + const e = m.euler(); + expect(Math.abs(e.roll - target.euler().roll)).toBeLessThan(1); + expect(Math.abs(e.pitch - target.euler().pitch)).toBeLessThan(1); + expect(e.yaw).toBeCloseTo(0, 6); + }); + + test("the zero-accel singularity guard yields no NaN (gyro-only integration)", () => { + const m = new MadgwickAHRS(); + for (let i = 0; i < 200; i++) m.update(45, 0, 0, 0, 0, 0, 1 / 104); + const q = m.quaternion(); + expect(q.every((c) => Number.isFinite(c))).toBe(true); + expect(norm(q)).toBeCloseTo(1, 10); + }); + + test("beta is mutable and euler() matches eulerOf(quaternion())", () => { + const m = new MadgwickAHRS(); + m.beta = 0.2; + expect(m.beta).toBe(0.2); + m.update(10, 20, 30, 0.1, 0.2, 0.95, 1 / 104); + expect(m.euler()).toEqual(eulerOf(m.quaternion())); + }); +}); + +describe("AccelAHRS", () => { + test("flat gravity → identity", () => { + const a = new AccelAHRS(); + a.update(0, 0, 0, 0, 0, 1, 0); + expect(a.quaternion()).toEqual([1, 0, 0, 0]); + }); + + test("gravity along +X body → pitched -90°", () => { + const a = new AccelAHRS(); + a.update(0, 0, 0, 1, 0, 0, 0); + const e = a.euler(); + expect(e.pitch).toBeCloseTo(-90, 4); + expect(e.yaw).toBe(0); + }); + + test("upside down → 180° about X", () => { + const a = new AccelAHRS(); + a.update(0, 0, 0, 0, 0, -1, 0); + expect(a.quaternion()).toEqual([0, 1, 0, 0]); + }); + + test("a zero-magnitude sample keeps the previous orientation", () => { + const a = new AccelAHRS(); + a.update(0, 0, 0, 1, 0, 0, 0); + const before = a.quaternion(); + a.update(99, 99, 99, 0, 0, 0, 1); + expect(a.quaternion()).toEqual(before); + }); + + test("introduces no heading DOF (quaternion z stays 0) and ignores gyro", () => { + // The accel-only solution never rotates about world Z, so q[3] is always 0... + const a = new AccelAHRS(); + a.update(1000, -500, 250, 0.3, -0.4, 0.866, 1); + expect(a.quaternion()[3]).toBe(0); + + // ...and the gyro inputs do not affect the result at all. + const noGyro = new AccelAHRS(); + noGyro.update(0, 0, 0, 0.3, -0.4, 0.866, 1); + expect(a.quaternion()).toEqual(noGyro.quaternion()); + }); +}); diff --git a/packages/triki-controller/src/parser.test.ts b/packages/triki-controller/src/parser.test.ts new file mode 100644 index 0000000..ddbf1d3 --- /dev/null +++ b/packages/triki-controller/src/parser.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from "vitest"; +import { FrameParser, decodeCounts } from "./parser"; +import { FRAME_LEN } from "./protocol"; +import { frame, concat } from "./testkit/frames"; + +describe("decodeCounts", () => { + test("reads int16 LE at the gyro (2/4/6) and accel (8/10/12) offsets", () => { + const f = frame(1, -2, 3, -4, 5, -6); + const rf = decodeCounts(f); + expect(rf.gxRaw).toBe(1); + expect(rf.gyRaw).toBe(-2); + expect(rf.gzRaw).toBe(3); + expect(rf.axRaw).toBe(-4); + expect(rf.ayRaw).toBe(5); + expect(rf.azRaw).toBe(-6); + expect(rf.bytes).toBe(f); + }); + + test("decodes full-range two's-complement values", () => { + const rf = decodeCounts(frame(-1, 32767, -32768, 0, -32768, 32767)); + expect(rf.gxRaw).toBe(-1); + expect(rf.gyRaw).toBe(32767); + expect(rf.gzRaw).toBe(-32768); + expect(rf.ayRaw).toBe(-32768); + expect(rf.azRaw).toBe(32767); + }); + + test("honours byteOffset/byteLength when the frame is a subarray view", () => { + const big = concat(new Uint8Array([0xaa, 0xbb, 0xcc]), frame(7, 8, 9, 10, 11, 12)); + const view = big.subarray(3, 3 + FRAME_LEN); + const rf = decodeCounts(view); + expect([rf.gxRaw, rf.gyRaw, rf.gzRaw, rf.axRaw, rf.ayRaw, rf.azRaw]).toEqual([7, 8, 9, 10, 11, 12]); + }); +}); + +describe("FrameParser.push", () => { + test("decodes a single clean frame", () => { + const p = new FrameParser(); + const out = p.push(frame(10, 20, 30, 40, 50, 60)); + expect(out).toHaveLength(1); + expect([out[0]!.gxRaw, out[0]!.azRaw]).toEqual([10, 60]); + }); + + test("decodes multiple frames in one chunk", () => { + const p = new FrameParser(); + const out = p.push(concat(frame(1, 0, 0, 0, 0, 0), frame(2, 0, 0, 0, 0, 0), frame(3, 0, 0, 0, 0, 0))); + expect(out.map((f) => f.gxRaw)).toEqual([1, 2, 3]); + }); + + test("reassembles a frame split across two chunks", () => { + const p = new FrameParser(); + const f = frame(123, 0, 0, 0, 0, 0); + expect(p.push(f.subarray(0, 8))).toHaveLength(0); + const out = p.push(f.subarray(8)); + expect(out).toHaveLength(1); + expect(out[0]!.gxRaw).toBe(123); + }); + + test("resyncs past leading garbage to find the header", () => { + const p = new FrameParser(); + const out = p.push(concat(new Uint8Array([0x00, 0x99, 0x11]), frame(55, 0, 0, 0, 0, 0))); + expect(out).toHaveLength(1); + expect(out[0]!.gxRaw).toBe(55); + }); + + test("ignores a 0x22 that is not followed by 0x00", () => { + const p = new FrameParser(); + // 0x22 0x01 is not a header; the real frame after it must still be found. + const out = p.push(concat(new Uint8Array([0x22, 0x01]), frame(77, 0, 0, 0, 0, 0))); + expect(out).toHaveLength(1); + expect(out[0]!.gxRaw).toBe(77); + }); + + test("retains a lone trailing 0x22 as a possible split-header half", () => { + const p = new FrameParser(); + const f1 = frame(1, 0, 0, 0, 0, 0); + const f2 = frame(2, 0, 0, 0, 0, 0); + // f1, then the first header byte of f2. + expect(p.push(concat(f1, f2.subarray(0, 1))).map((f) => f.gxRaw)).toEqual([1]); + // Deliver the rest of f2; the retained 0x22 completes the header. + expect(p.push(f2.subarray(1)).map((f) => f.gxRaw)).toEqual([2]); + }); + + test("trims unbounded garbage to its last byte (>64 bytes, no header)", () => { + const p = new FrameParser(); + // 64 non-header bytes + a trailing 0x22 (potential split header) = 65 bytes. + const garbage = concat(new Uint8Array(64).fill(0x11), new Uint8Array([0x22])); + expect(p.push(garbage)).toHaveLength(0); + // The retained 0x22 + the remainder of a frame forms one valid frame. + const f = frame(99, 0, 0, 0, 0, 0); + const out = p.push(f.subarray(1)); + expect(out).toHaveLength(1); + expect(out[0]!.gxRaw).toBe(99); + }); + + test("reset() drops the buffered partial frame", () => { + const p = new FrameParser(); + const f = frame(42, 0, 0, 0, 0, 0); + p.push(f.subarray(0, 8)); + p.reset(); + // The earlier half is gone; pushing the tail alone yields nothing. + expect(p.push(f.subarray(8))).toHaveLength(0); + // A subsequent whole frame parses cleanly. + expect(p.push(frame(43, 0, 0, 0, 0, 0))).toHaveLength(1); + }); +}); diff --git a/packages/triki-controller/src/protocol.test.ts b/packages/triki-controller/src/protocol.test.ts new file mode 100644 index 0000000..982a334 --- /dev/null +++ b/packages/triki-controller/src/protocol.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect } from "vitest"; +import { + startCmd, + ledCmd, + START_BASE, + HEADER0, + HEADER1, + FRAME_LEN, + DEFAULT_ACCEL_SCALE, + DEFAULT_GYRO_SCALE, + DEFAULT_RATE_HZ, + DEFAULT_BETA, + SUPPORTED_RATES_HZ, +} from "./protocol"; + +describe("startCmd", () => { + test("default 104 Hz produces the canonical START bytes", () => { + expect(Array.from(startCmd(104))).toEqual([0x20, 0x10, 0x00, 0xd0, 0x07, 0x68, 0x00, 0x03]); + }); + + test("encodes the rate as a little-endian uint16 at bytes 5-6", () => { + // low byte, high byte + expect(Array.from(startCmd(26)).slice(5, 7)).toEqual([0x1a, 0x00]); + expect(Array.from(startCmd(52)).slice(5, 7)).toEqual([0x34, 0x00]); + expect(Array.from(startCmd(208)).slice(5, 7)).toEqual([0xd0, 0x00]); + expect(Array.from(startCmd(416)).slice(5, 7)).toEqual([0xa0, 0x01]); + }); + + test("leaves the fixed bytes (0-4, 7) untouched regardless of rate", () => { + for (const hz of SUPPORTED_RATES_HZ) { + const cmd = Array.from(startCmd(hz)); + expect(cmd[0]).toBe(0x20); + expect(cmd[1]).toBe(0x10); + expect(cmd[2]).toBe(0x00); + expect(cmd[3]).toBe(0xd0); + expect(cmd[4]).toBe(0x07); + expect(cmd[7]).toBe(0x03); + expect(cmd).toHaveLength(8); + } + }); + + test("masks the rate to 16 bits", () => { + expect(Array.from(startCmd(0x1_0068)).slice(5, 7)).toEqual([0x68, 0x00]); + }); + + test("returns a fresh array each call (does not mutate START_BASE)", () => { + startCmd(416); + expect(START_BASE.slice()).toEqual([0x20, 0x10, 0x00, 0xd0, 0x07, 0x68, 0x00, 0x03]); + }); +}); + +describe("ledCmd", () => { + test("on -> 0x01, off -> 0x00", () => { + expect(Array.from(ledCmd(true))).toEqual([0x01]); + expect(Array.from(ledCmd(false))).toEqual([0x00]); + }); +}); + +describe("protocol constants (regression guards)", () => { + test("gyro scale is 14.286 LSB/(deg/s), never the MPU-6050 value 131", () => { + expect(DEFAULT_GYRO_SCALE).toBe(14.286); + expect(DEFAULT_GYRO_SCALE).not.toBe(131.0); + }); + + test("accel scale, frame layout and rate ladder", () => { + expect(DEFAULT_ACCEL_SCALE).toBe(2048.0); + expect(FRAME_LEN).toBe(14); + expect(HEADER0).toBe(0x22); + expect(HEADER1).toBe(0x00); + expect(DEFAULT_RATE_HZ).toBe(104); + expect(DEFAULT_BETA).toBe(0.08); + expect(Array.from(SUPPORTED_RATES_HZ)).toEqual([26, 52, 104, 208, 416]); + }); +}); diff --git a/packages/triki-controller/src/testkit/fakeBle.ts b/packages/triki-controller/src/testkit/fakeBle.ts new file mode 100644 index 0000000..0e385f1 --- /dev/null +++ b/packages/triki-controller/src/testkit/fakeBle.ts @@ -0,0 +1,167 @@ +/** + * Minimal in-memory fake of the Web Bluetooth surface the {@link TrikiController} + * touches: `navigator.bluetooth.requestDevice` → device → GATT → services → + * characteristics. Characteristics extend `EventTarget`, so a test can deliver a + * notification with {@link FakeCharacteristic.notify} and the controller's real + * `characteristicvaluechanged` listener fires exactly as it would on hardware. + * + * Lives under `src/testkit/` so it is excluded from coverage and never shipped. + */ +import { NUS_SERVICE, NUS_RX, NUS_TX, NUS_CTRL, BATTERY_SERVICE, BATTERY_LEVEL } from "../protocol"; + +/** A fake GATT characteristic that records writes and can push notifications. */ +export class FakeCharacteristic extends EventTarget { + /** The most recent value, as the controller reads it via `event.target.value`. */ + value: DataView | undefined; + /** Every payload written via `writeValue` / `writeValueWithoutResponse`, copied. */ + readonly writes: Uint8Array[] = []; + startNotificationsCount = 0; + #readValue: DataView; + + constructor(readValue?: DataView) { + super(); + this.#readValue = readValue ?? new DataView(new ArrayBuffer(1)); + } + + async writeValue(data: Uint8Array): Promise { + this.writes.push(data.slice()); + } + + async writeValueWithoutResponse(data: Uint8Array): Promise { + this.writes.push(data.slice()); + } + + async startNotifications(): Promise { + this.startNotificationsCount++; + return this; + } + + async stopNotifications(): Promise { + return this; + } + + async readValue(): Promise { + this.value = this.#readValue; + return this.#readValue; + } + + /** Test helper: deliver a `characteristicvaluechanged` carrying `bytes`. */ + notify(bytes: Uint8Array): void { + this.value = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + this.dispatchEvent(new Event("characteristicvaluechanged")); + } + + /** Test helper: deliver a single-byte (e.g. battery percent) notification. */ + notifyByte(n: number): void { + this.value = new DataView(Uint8Array.of(n).buffer); + this.dispatchEvent(new Event("characteristicvaluechanged")); + } +} + +/** A fake GATT primary service holding a fixed characteristic map. */ +export class FakeService { + #chars: Map; + constructor(chars: Map) { + this.#chars = chars; + } + async getCharacteristic(uuid: string): Promise { + const c = this.#chars.get(uuid); + if (!c) throw new Error(`fake: no characteristic ${uuid}`); + return c; + } +} + +/** A fake GATT server. `disconnect()` fires `gattserverdisconnected` on the device. */ +export class FakeGatt { + connected = false; + device!: FakeDevice; + #services: Map; + constructor(services: Map) { + this.#services = services; + } + async connect(): Promise { + this.connected = true; + return this; + } + async getPrimaryService(uuid: string): Promise { + const s = this.#services.get(uuid); + if (!s) throw new Error(`fake: no service ${uuid}`); + return s; + } + disconnect(): void { + this.connected = false; + this.device.dispatchEvent(new Event("gattserverdisconnected")); + } +} + +/** A fake BLE device. */ +export class FakeDevice extends EventTarget { + gatt: FakeGatt; + constructor(gatt: FakeGatt) { + super(); + this.gatt = gatt; + } +} + +/** A fake `navigator.bluetooth`. */ +export class FakeBluetooth { + requestDeviceCount = 0; + #device: FakeDevice; + constructor(device: FakeDevice) { + this.#device = device; + } + async requestDevice(_options?: unknown): Promise { + this.requestDeviceCount++; + return this.#device; + } +} + +/** Handles a test can drive after wiring a fake stack with {@link makeFakeBluetooth}. */ +export interface FakeBleHandles { + bluetooth: FakeBluetooth; + device: FakeDevice; + gatt: FakeGatt; + rx: FakeCharacteristic; + tx: FakeCharacteristic; + /** LED control characteristic, or `null` when `led: false`. */ + ctrl: FakeCharacteristic | null; + /** Battery Level characteristic, or `null` when `battery: false`. */ + battery: FakeCharacteristic | null; +} + +export interface FakeBleOptions { + /** Expose the NUS control (LED) characteristic. Default true. */ + led?: boolean; + /** Expose the standard Battery service. Default true. */ + battery?: boolean; + /** Initial battery percent returned by `readValue`. Default 87. */ + batteryLevel?: number; +} + +/** Wire a complete fake Web Bluetooth stack and return handles to drive it. */ +export function makeFakeBluetooth(options: FakeBleOptions = {}): FakeBleHandles { + const { led = true, battery = true, batteryLevel = 87 } = options; + + const rx = new FakeCharacteristic(); + const tx = new FakeCharacteristic(); + const ctrl = led ? new FakeCharacteristic() : null; + const batteryChar = battery ? new FakeCharacteristic(new DataView(Uint8Array.of(batteryLevel).buffer)) : null; + + const nusChars = new Map([ + [NUS_RX, rx], + [NUS_TX, tx], + ]); + if (ctrl) nusChars.set(NUS_CTRL, ctrl); + + const services = new Map([[NUS_SERVICE, new FakeService(nusChars)]]); + if (batteryChar) { + services.set(BATTERY_SERVICE, new FakeService(new Map([[BATTERY_LEVEL, batteryChar]]))); + } + + const gatt = new FakeGatt(services); + const device = new FakeDevice(gatt); + gatt.device = device; + const bluetooth = new FakeBluetooth(device); + + return { bluetooth, device, gatt, rx, tx, ctrl, battery: batteryChar }; +} diff --git a/packages/triki-controller/src/testkit/frames.ts b/packages/triki-controller/src/testkit/frames.ts new file mode 100644 index 0000000..fb8c286 --- /dev/null +++ b/packages/triki-controller/src/testkit/frames.ts @@ -0,0 +1,41 @@ +/** Test helpers for building motion frames. Lives under `src/testkit/` so it is + * excluded from coverage and never bundled into `dist` (tsup follows `index.ts`). */ +import { HEADER0, HEADER1 } from "../protocol"; + +/** + * Build a single 14-byte motion frame: the `0x22 0x00` header followed by six int16 + * little-endian counts — gyro X/Y/Z at offsets 2/4/6 and accel X/Y/Z at 8/10/12. + */ +export function frame( + gx: number, + gy: number, + gz: number, + ax: number, + ay: number, + az: number, +): Uint8Array { + const buf = new Uint8Array(14); + const dv = new DataView(buf.buffer); + dv.setUint8(0, HEADER0); + dv.setUint8(1, HEADER1); + dv.setInt16(2, gx, true); + dv.setInt16(4, gy, true); + dv.setInt16(6, gz, true); + dv.setInt16(8, ax, true); + dv.setInt16(10, ay, true); + dv.setInt16(12, az, true); + return buf; +} + +/** Concatenate byte chunks into one `Uint8Array`. */ +export function concat(...parts: Uint8Array[]): Uint8Array { + let len = 0; + for (const p of parts) len += p.length; + const out = new Uint8Array(len); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} diff --git a/packages/triki-controller/src/vqf.test.ts b/packages/triki-controller/src/vqf.test.ts new file mode 100644 index 0000000..70393a3 --- /dev/null +++ b/packages/triki-controller/src/vqf.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "vitest"; +import { VqfAHRS, DEFAULT_TAU_ACC } from "./vqf"; +import { AccelAHRS, eulerOf } from "./fusion"; +import type { Quaternion } from "./fusion"; + +const norm = (q: Quaternion): number => Math.hypot(q[0], q[1], q[2], q[3]); + +describe("VqfAHRS", () => { + test("DEFAULT_TAU_ACC is 2.0 s and is the default", () => { + expect(DEFAULT_TAU_ACC).toBe(2.0); + expect(new VqfAHRS().tauAcc).toBe(2.0); + }); + + test("reports identity before any update", () => { + expect(new VqfAHRS().quaternion()).toEqual([1, 0, 0, 0]); + }); + + test("keeps the quaternion unit-norm and NaN-free across many updates", () => { + const v = new VqfAHRS({ tauAcc: 0.5 }); + for (let i = 0; i < 1000; i++) v.update(40, -20, 15, 0.1, -0.2, 0.97, 1 / 104); + const q = v.quaternion(); + expect(q.every((c) => Number.isFinite(c))).toBe(true); + expect(norm(q)).toBeCloseTo(1, 8); + }); + + test("frame parity: settles to the same tilt as AccelAHRS on steady gravity", () => { + const ax = 0; + const ay = 0.5; + const az = Math.sqrt(1 - ay * ay); // ~30° roll about X + const target = new AccelAHRS(); + target.update(0, 0, 0, ax, ay, az, 1 / 104); + + const v = new VqfAHRS({ tauAcc: 0.1 }); + for (let i = 0; i < 4000; i++) v.update(0, 0, 0, ax, ay, az, 1 / 104); + const e = eulerOf(v.quaternion()); + expect(Math.abs(e.roll - target.euler().roll)).toBeLessThan(2); + expect(Math.abs(e.pitch - target.euler().pitch)).toBeLessThan(2); + }); + + test("setTauAcc retunes live without throwing and updates the getter", () => { + const v = new VqfAHRS({ tauAcc: 1 }); + v.update(0, 0, 0, 0, 0, 1, 1 / 104); // fixes the sample period + v.setTauAcc(0.5); + expect(v.tauAcc).toBe(0.5); + expect(() => v.update(0, 0, 0, 0, 0, 1, 1 / 104)).not.toThrow(); + }); + + test("reset() re-bootstraps to identity", () => { + const v = new VqfAHRS({ tauAcc: 0.1 }); + for (let i = 0; i < 500; i++) v.update(0, 0, 0, 0, 0.5, 0.866, 1 / 104); + expect(v.quaternion()).not.toEqual([1, 0, 0, 0]); + v.reset(); + expect(v.quaternion()).toEqual([1, 0, 0, 0]); + }); +}); diff --git a/packages/triki-controller/vitest.config.ts b/packages/triki-controller/vitest.config.ts new file mode 100644 index 0000000..9f4855d --- /dev/null +++ b/packages/triki-controller/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +// Node environment: the source only needs EventTarget, performance.now(), DataView +// and Uint8Array, all of which Node provides — no jsdom. Tests import { test, expect, +// vi, ... } from "vitest" explicitly (globals stay off) so tsconfig's restrictive +// `types: ["web-bluetooth"]` does not need touching. +export default defineConfig({ + test: { + environment: "node", + globals: false, + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/testkit/**", "src/index.ts"], + }, + }, +}); From 428ba3da48df4e42d520393809efc3eabe9490e6 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 23:37:33 +0200 Subject: [PATCH 2/7] fix(triki-controller): complete lockfile for cross-platform npm ci The macOS-generated lockfile dropped the platform-agnostic optional deps @emnapi/core and @emnapi/runtime (pulled via @vitest/coverage-v8), so `npm ci` failed on the Linux CI runner ("Missing ... from lock file"). Regenerated the lockfile so it carries every optional variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/triki-controller/package-lock.json | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/triki-controller/package-lock.json b/packages/triki-controller/package-lock.json index a62e028..da7862d 100644 --- a/packages/triki-controller/package-lock.json +++ b/packages/triki-controller/package-lock.json @@ -79,6 +79,31 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", From a81af76b5866cabcc5f540e99a7ba6492ccadab2 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 23:37:33 +0200 Subject: [PATCH 3/7] test(triki-controller): cover the input-hardening guards from main PR #2 was merged with review hardening on top of the branch base. Cover it: VqfAHRS sanitizes a non-finite/negative tauAcc and ignores a bad sample period; TrikiController rejects a non-finite/non-positive gyro scale, stores a defensive copy of the bias vectors (coercing non-finite axes to 0), and realigns the VQF sample period on setRate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../triki-controller/src/controller.test.ts | 61 +++++++++++++++++++ packages/triki-controller/src/vqf.test.ts | 31 ++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/triki-controller/src/controller.test.ts b/packages/triki-controller/src/controller.test.ts index 683bc16..74b65d7 100644 --- a/packages/triki-controller/src/controller.test.ts +++ b/packages/triki-controller/src/controller.test.ts @@ -307,3 +307,64 @@ describe("rate measurement", () => { expect(last(rates)).toBe(5); }); }); + +describe("input hardening", () => { + test("ignores a non-finite or non-positive gyro scale", async () => { + const h = install(); + // 0 in the constructor falls back to the default scale (no div-by-zero). + const ctrl = make({ fusion: "none", gyroScale: 0 }); + await ctrl.connect(); + + const frames: FrameEvent[] = []; + ctrl.on("frame", (e) => frames.push(e)); + + h.tx.notify(frame(14286, 0, 0, 0, 0, 0)); + expect(last(frames).gyro.x).toBeCloseTo(1000, 3); // 14286 / 14.286, not Infinity + + ctrl.setGyroScale(1); + h.tx.notify(frame(100, 0, 0, 0, 0, 0)); + expect(last(frames).gyro.x).toBeCloseTo(100, 3); + + // Bad values are rejected and the previous scale stands. + ctrl.setGyroScale(0); + ctrl.setGyroScale(NaN); + h.tx.notify(frame(100, 0, 0, 0, 0, 0)); + expect(last(frames).gyro.x).toBeCloseTo(100, 3); + }); + + test("stores a defensive copy of bias and coerces non-finite axes to 0", async () => { + const h = install(); + const bias = { x: 1, y: 2, z: 3 }; + const ctrl = make({ fusion: "none", gyroBias: bias }); + await ctrl.connect(); + + const frames: FrameEvent[] = []; + ctrl.on("frame", (e) => frames.push(e)); + + bias.x = 999; // mutating the caller's object must not affect the controller + h.tx.notify(frame(0, 0, 0, 0, 0, 0)); + expect(last(frames).gyro.x).toBeCloseTo(-1, 6); // used the stored copy (1) + + ctrl.setGyroBias({ x: NaN, y: 5, z: Infinity }); + h.tx.notify(frame(0, 0, 0, 0, 0, 0)); + const f = last(frames); + expect(f.gyro.x).toBeCloseTo(0, 6); // NaN -> 0 + expect(f.gyro.y).toBeCloseTo(-5, 6); + expect(f.gyro.z).toBeCloseTo(0, 6); // Infinity -> 0 + }); + + test("setRate while VQF is active realigns the sample period and re-sends START", async () => { + const h = install(); + const ctrl = make({ fusion: "vqf" }); + await ctrl.connect(); + + await ctrl.setRate(208); + expect(ctrl.rateHz).toBe(208); + expect(bytes(last(h.rx.writes))).toEqual(bytes(startCmd(208))); + + const orientations: OrientationEvent[] = []; + ctrl.on("orientation", (e) => orientations.push(e)); + h.tx.notify(frame(0, 0, 0, 0, 0, 2048)); + expect(last(orientations).quaternion.every((c) => Number.isFinite(c))).toBe(true); + }); +}); diff --git a/packages/triki-controller/src/vqf.test.ts b/packages/triki-controller/src/vqf.test.ts index 70393a3..ebb551a 100644 --- a/packages/triki-controller/src/vqf.test.ts +++ b/packages/triki-controller/src/vqf.test.ts @@ -53,3 +53,34 @@ describe("VqfAHRS", () => { expect(v.quaternion()).toEqual([1, 0, 0, 0]); }); }); + +describe("VqfAHRS input hardening", () => { + test("sanitizes a non-finite or negative tauAcc to the default", () => { + expect(new VqfAHRS({ tauAcc: NaN }).tauAcc).toBe(DEFAULT_TAU_ACC); + expect(new VqfAHRS({ tauAcc: Infinity }).tauAcc).toBe(DEFAULT_TAU_ACC); + expect(new VqfAHRS({ tauAcc: -1 }).tauAcc).toBe(DEFAULT_TAU_ACC); + // 0 is valid — the low-pass degrades to a pass-through. + expect(new VqfAHRS({ tauAcc: 0 }).tauAcc).toBe(0); + }); + + test("setTauAcc rejects bad values back to the default", () => { + const v = new VqfAHRS({ tauAcc: 1 }); + v.setTauAcc(-5); + expect(v.tauAcc).toBe(DEFAULT_TAU_ACC); + v.setTauAcc(NaN); + expect(v.tauAcc).toBe(DEFAULT_TAU_ACC); + }); + + test("setSamplePeriod ignores bad input and keeps the output finite", () => { + const v = new VqfAHRS({ tauAcc: 0.5 }); + v.update(0, 0, 0, 0, 0, 1, 1 / 104); + v.setSamplePeriod(0); + v.setSamplePeriod(-1); + v.setSamplePeriod(NaN); + // A valid period recomputes the coefficients without disturbing the state. + v.setSamplePeriod(1 / 208); + for (let i = 0; i < 200; i++) v.update(10, -5, 2, 0, 0.5, 0.866, 1 / 208); + expect(v.quaternion().every((c) => Number.isFinite(c))).toBe(true); + expect(norm(v.quaternion())).toBeCloseTo(1, 8); + }); +}); From 4a8e0bcc9b98d77514d5b18f77b91cc32eea9800 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Mon, 29 Jun 2026 00:18:51 +0200 Subject: [PATCH 4/7] ci(test): disable persisted checkout credentials Per CodeRabbit PR review on #3: the test job runs PR-controlled install/test steps and needs no token, so set persist-credentials: false on actions/checkout to avoid writing a token into the local git config (matches publish.yml). Co-Authored-By: Claude Code --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1708d50..a2123e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,10 @@ jobs: working-directory: packages/triki-controller steps: - uses: actions/checkout@v4 + with: + # This job runs PR-controlled install/test steps and needs no token; + # don't persist checkout credentials into the local git config. + persist-credentials: false # Node 24 mirrors the publish workflow, so tests run on the same runtime # that ships the package. From 392231cd97f23aee0f6faa2ba3d42b81a14049e0 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Mon, 29 Jun 2026 00:19:21 +0200 Subject: [PATCH 5/7] test(triki-controller): restore the console.error spy in finally Per CodeRabbit PR review on #3: in the emitter error-isolation test the console.error spy was restored only after the assertions, so a failing assertion would leak the spy into later tests. Wrap the assertions in try/finally and restore the spy in the finally block. Co-Authored-By: Claude Code --- packages/triki-controller/src/emitter.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/triki-controller/src/emitter.test.ts b/packages/triki-controller/src/emitter.test.ts index f2da8a3..925a8a4 100644 --- a/packages/triki-controller/src/emitter.test.ts +++ b/packages/triki-controller/src/emitter.test.ts @@ -65,15 +65,18 @@ describe("TypedEmitter", () => { test("a throwing listener is isolated and does not stop the others", () => { const bus = new Bus(); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const after = vi.fn(); - bus.on("ping", () => { - throw new Error("boom"); - }); - bus.on("ping", after); - expect(() => bus.fire("ping", 1)).not.toThrow(); - expect(after).toHaveBeenCalledWith(1); - expect(errSpy).toHaveBeenCalled(); - errSpy.mockRestore(); + try { + const after = vi.fn(); + bus.on("ping", () => { + throw new Error("boom"); + }); + bus.on("ping", after); + expect(() => bus.fire("ping", 1)).not.toThrow(); + expect(after).toHaveBeenCalledWith(1); + expect(errSpy).toHaveBeenCalled(); + } finally { + errSpy.mockRestore(); + } }); test("removeAllListeners() clears every subscription", () => { From 99111ce08769a27562350703e9af6a68c9181e70 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Mon, 29 Jun 2026 00:20:10 +0200 Subject: [PATCH 6/7] test(triki-controller): cover the Madgwick zero-gradient guard Per CodeRabbit PR review on #3: the existing gyro-only test passes a zero accel vector, which returns before the snorm>0 branch, so the zero-gradient singularity guard was never exercised. Add a test that feeds gravity exactly aligned with +Z at identity (zero gradient) so the snorm guard is hit and the quaternion stays finite and unit-norm. Co-Authored-By: Claude Code --- packages/triki-controller/src/fusion.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/triki-controller/src/fusion.test.ts b/packages/triki-controller/src/fusion.test.ts index 43015d9..bbd9386 100644 --- a/packages/triki-controller/src/fusion.test.ts +++ b/packages/triki-controller/src/fusion.test.ts @@ -89,7 +89,7 @@ describe("MadgwickAHRS", () => { expect(e.yaw).toBeCloseTo(0, 6); }); - test("the zero-accel singularity guard yields no NaN (gyro-only integration)", () => { + test("the zero-accel guard yields no NaN (gyro-only integration)", () => { const m = new MadgwickAHRS(); for (let i = 0; i < 200; i++) m.update(45, 0, 0, 0, 0, 0, 1 / 104); const q = m.quaternion(); @@ -97,6 +97,17 @@ describe("MadgwickAHRS", () => { expect(norm(q)).toBeCloseTo(1, 10); }); + test("the zero-gradient (snorm) guard yields no NaN when already aligned", () => { + // Identity orientation + gravity exactly on +Z makes the accel gradient zero, + // so the snorm>0 branch is hit: normalizing a zero vector would poison q with NaN. + const m = new MadgwickAHRS(); + for (let i = 0; i < 200; i++) m.update(0, 0, 0, 0, 0, 1, 1 / 104); + const q = m.quaternion(); + expect(q.every((c) => Number.isFinite(c))).toBe(true); + expect(norm(q)).toBeCloseTo(1, 10); + expect(m.euler()).toEqual({ roll: 0, pitch: 0, yaw: 0 }); + }); + test("beta is mutable and euler() matches eulerOf(quaternion())", () => { const m = new MadgwickAHRS(); m.beta = 0.2; From 8e0fe08be5d1b9cbeacacafcbcdb5a7f5ada98be Mon Sep 17 00:00:00 2001 From: FlopBut Date: Mon, 29 Jun 2026 00:21:01 +0200 Subject: [PATCH 7/7] test(triki-controller): assert setRate retimes the live VQF filter Per CodeRabbit PR review on #3: the setRate-while-VQF test only checked that START was re-sent and the quaternion stayed finite, which stale coefficients would also satisfy. Spy on VqfAHRS.setSamplePeriod to assert setRate(208) forwards 1/208 to the active filter, and restore mocks in afterEach so the prototype spy can't leak. Co-Authored-By: Claude Code --- packages/triki-controller/src/controller.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/triki-controller/src/controller.test.ts b/packages/triki-controller/src/controller.test.ts index 74b65d7..c268e73 100644 --- a/packages/triki-controller/src/controller.test.ts +++ b/packages/triki-controller/src/controller.test.ts @@ -41,6 +41,7 @@ afterEach(() => { for (const c of created.splice(0)) c.disconnect(); vi.unstubAllGlobals(); vi.useRealTimers(); + vi.restoreAllMocks(); }); describe("fusion option resolution", () => { @@ -358,8 +359,12 @@ describe("input hardening", () => { const ctrl = make({ fusion: "vqf" }); await ctrl.connect(); + // Prove the new rate is actually forwarded to the live filter, not just that + // START was re-sent (stale coefficients would also keep the quaternion finite). + const retime = vi.spyOn(VqfAHRS.prototype, "setSamplePeriod"); await ctrl.setRate(208); expect(ctrl.rateHz).toBe(208); + expect(retime).toHaveBeenCalledWith(1 / 208); expect(bytes(last(h.rx.writes))).toEqual(bytes(startCmd(208))); const orientations: OrientationEvent[] = [];