diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index d6a1616..b15c9fb 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -1,4 +1,4 @@ -name: backend-tests +name: tests on: push: @@ -15,4 +15,17 @@ jobs: - name: Install backend with dev extras run: pip install -e 'backend[dev]' - name: Run tests - run: python -m pytest -q \ No newline at end of file + run: python -m pytest -q + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - working-directory: frontend + run: npm ci + - working-directory: frontend + run: npm test \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6896fbf..247e448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ compose.yaml docker compose: backend:8000, frontend:8080 ## CODING STANDARDS * **Language**: Python 3.11+ with `from __future__ import annotations`; raw `sqlite3` (no ORM) with `Row` factory; Pydantic models for request bodies; Svelte 4 syntax (`on:click`, `new App({ target })`), TypeScript interfaces. -* **Style**: Routers grouped under `app/routers/` with `prefix="/api"`; services hold reusable computation (`services/streams.py`, `services/power_bests.py`); `@lru_cache` on parquet loads (must `cache_clear` on deletion — already done in the DELETE route); no frontend test framework (deliberate YAGNI); no linter/formatter configs committed. +* **Style**: Routers grouped under `app/routers/` with `prefix="/api"`; services hold reusable computation (`services/streams.py`, `services/power_bests.py`); `@lru_cache` on parquet loads (must `cache_clear` on deletion — already done in the DELETE route); frontend has vitest unit tests for pure logic (colocated `*.test.ts` in `src/lib/`), no component tests (deliberate YAGNI); no linter/formatter configs committed. * **Data contract**: Parsers all emit the same normalized DataFrame: `t` (datetime, UTC), `lat`, `lon`, `power`, `heart_rate`, `speed`, `altitude`, `cadence`, `distance`, `temperature` (NaN for missing). `STREAM_FIELDS` in `services/streams.py` is the API-allowed field list. * **Tests**: `tests/backend/conftest.py` `data_dir` fixture monkeypatches `settings.data_dir` to a temp dir and clears the `load_stream` lru_cache. **Tests must never touch the real `data/` directory.** Router tests use `api_client` (TestClient with startup events). @@ -78,7 +78,7 @@ compose.yaml docker compose: backend:8000, frontend:8080 * **Tests**: `tests/backend/` (root `pytest.ini`: `testpaths=tests`, `pythonpath=. backend`) * **Frontend source**: `frontend/src/` * **Docs/plans**: `docs/`, `README.md` -* **CI**: `.github/workflows/backend-tests.yml` (pytest on push/PR, Python 3.13) +* **CI**: `.github/workflows/backend-tests.yml` (name "tests": pytest on push/PR, Python 3.13 + `npm test`/vitest job, Node 22) ## NOTES * **Responsive design**: one codebase; mobile ≤768px (bottom tab bar, settings FAB, bottom sheets, ride cards) vs desktop ≥769px (table + aside filters). Shared tokens live in `app.css` (`--bg/--panel/--panel-2/--border/--text/--muted/--accent`, `--touch` ≥44px, `--elev-*`); component styles are scoped. **No hardcoded colors — use the tokens.** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 582425b..ef75ddf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,8 @@ "svelte-check": "^3.8.6", "tslib": "^2.7.0", "typescript": "^5.5.4", - "vite": "^5.4.10" + "vite": "^5.4.10", + "vitest": "^2.1.9" } }, "node_modules/@ampproject/remapping": { @@ -901,7 +902,6 @@ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -1004,6 +1004,119 @@ "@types/geojson": "*" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1039,6 +1152,16 @@ "node": ">= 0.4" } }, + "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/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1102,6 +1225,43 @@ "node": ">=8.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1178,6 +1338,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1204,6 +1374,13 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -1259,6 +1436,16 @@ "@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/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1523,6 +1710,13 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lucide-svelte": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", @@ -1705,6 +1899,23 @@ "node": ">=0.10.0" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -1769,7 +1980,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1910,6 +2120,13 @@ "rimraf": "^2.5.2" } }, + "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/sorcery": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", @@ -1935,6 +2152,20 @@ "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -1962,7 +2193,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -2086,12 +2316,56 @@ "integrity": "sha512-/NTxqTwLc7Dq306hARJrH2HLXOBtKd7hu8nxgoFDlK0AC4SOKnzisiX/9m8Uksei1QAWtlAEdF91YphNM8iDMg==", "license": "MIT" }, + "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", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2118,7 +2392,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2139,7 +2412,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -2194,6 +2466,29 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", @@ -2209,6 +2504,72 @@ } } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", @@ -2235,6 +2596,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "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" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c4c0ed..00dc706 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview --host --port 8080", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" }, "dependencies": { "lucide-svelte": "^1.0.1", @@ -22,6 +23,7 @@ "svelte-check": "^3.8.6", "tslib": "^2.7.0", "typescript": "^5.5.4", - "vite": "^5.4.10" + "vite": "^5.4.10", + "vitest": "^2.1.9" } } diff --git a/frontend/src/lib/colormap.test.ts b/frontend/src/lib/colormap.test.ts new file mode 100644 index 0000000..f972bc7 --- /dev/null +++ b/frontend/src/lib/colormap.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { rampColor, robustRange } from "./colormap"; + +describe("rampColor", () => { + it("clamps below zero to the first stop", () => expect(rampColor(-1)).toBe("rgb(48, 18, 59)")); + it("clamps above one to the last stop", () => expect(rampColor(2)).toBe("rgb(122, 4, 3)")); + it("hits exact stops", () => expect(rampColor(0.5)).toBe("rgb(97, 220, 102)")); +}); + +describe("robustRange", () => { + it("empty input yields [0, 1]", () => expect(robustRange([])).toEqual([0, 1])); + it("constant input yields [x, x+1]", () => expect(robustRange([5, 5, 5])).toEqual([5, 6])); + it("trims outliers via percentiles", () => { + const values = Array.from({ length: 100 }, (_, i) => i); + values.push(1000); + expect(robustRange(values)).toEqual([2, 98]); + }); +}); diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts new file mode 100644 index 0000000..681c0e1 --- /dev/null +++ b/frontend/src/lib/format.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { fmtDuration, fmtKm, fmtKmh, fmtNum } from "./format"; + +describe("fmtDuration", () => { + it("returns en-dash for null", () => expect(fmtDuration(null)).toBe("–")); + it("formats under an hour as m:ss", () => expect(fmtDuration(125)).toBe("2:05")); + it("formats over an hour as h:mm:ss", () => expect(fmtDuration(3661)).toBe("1:01:01")); +}); + +describe("fmtKm / fmtKmh / fmtNum", () => { + it("converts meters to km", () => expect(fmtKm(12300)).toBe("12.30 km")); + it("returns en-dash for null", () => { + expect(fmtKm(null)).toBe("–"); + expect(fmtKmh(null)).toBe("–"); + expect(fmtNum(null)).toBe("–"); + }); + it("converts m/s to km/h", () => expect(fmtKmh(10)).toBe("36.0 km/h")); + it("applies digits and suffix", () => expect(fmtNum(1.234, 1, " W")).toBe("1.2 W")); +}); diff --git a/frontend/src/lib/i18n.test.ts b/frontend/src/lib/i18n.test.ts new file mode 100644 index 0000000..ee62284 --- /dev/null +++ b/frontend/src/lib/i18n.test.ts @@ -0,0 +1,14 @@ +import { get } from "svelte/store"; +import { describe, expect, it } from "vitest"; +import { settings } from "./settings"; +import { t } from "./i18n"; + +describe("t", () => { + it("defaults to German", () => expect(get(t)("settings.title")).toBe("Einstellungen")); + it("switches with the language setting", () => { + settings.set({ ...get(settings), language: "en" }); + expect(get(t)("settings.title")).toBe("Settings"); + settings.set({ ...get(settings), language: "de" }); + expect(get(t)("settings.title")).toBe("Einstellungen"); + }); +}); diff --git a/frontend/src/lib/power-format.test.ts b/frontend/src/lib/power-format.test.ts new file mode 100644 index 0000000..61b624a --- /dev/null +++ b/frontend/src/lib/power-format.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { fmtPower, fmtWindow } from "./power-format"; + +describe("fmtWindow", () => { + it("seconds, minutes, hours", () => { + expect(fmtWindow(5)).toBe("5 s"); + expect(fmtWindow(120)).toBe("2 min"); + expect(fmtWindow(3600)).toBe("1 h"); + }); +}); + +describe("fmtPower", () => { + it("watts, rounded", () => expect(fmtPower(250, 75, "W")).toBe("250 W")); + it("watts per kg, two decimals", () => expect(fmtPower(250, 75, "W/kg")).toBe("3.33 W/kg")); + it("W/kg without weight falls back to en-dash", () => expect(fmtPower(250, 0, "W/kg")).toBe("–")); + it("null falls back to en-dash", () => expect(fmtPower(null, 75, "W")).toBe("–")); +}); diff --git a/frontend/src/lib/settings.test.ts b/frontend/src/lib/settings.test.ts new file mode 100644 index 0000000..b07d6e9 --- /dev/null +++ b/frontend/src/lib/settings.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + applyStreamFilter, + buildPauseMask, + kcalFromPower, + mean, + maxOf, + minOf, + normalizedPower, +} from "./settings"; + +describe("mean / maxOf / minOf", () => { + it("skips nulls", () => { + expect(mean([1, 2, null, 3])).toBe(2); + expect(mean([null])).toBeNull(); + expect(maxOf([1, 9, 3])).toBe(9); + expect(minOf([1, 9, 3])).toBe(1); + }); +}); + +describe("applyStreamFilter", () => { + it("nulls out zero power when asked", () => + expect(applyStreamFilter([0, 100, null, 0], [], { excludeZero: true })).toEqual([null, 100, null, null])); + it("nulls out paused samples via mask", () => + expect(applyStreamFilter([1, 2, 3], [0, 1, 2], { pauseMask: [false, true, false] })).toEqual([1, null, 3])); +}); + +describe("buildPauseMask", () => { + it("marks stops longer than the threshold", () => { + const t = Array.from({ length: 10 }, (_, i) => i); // 1s cadence, 9s stop + expect(buildPauseMask([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], t, 5, 0.5).every((m) => m)).toBe(true); + }); + it("ignores stops shorter than the threshold", () => { + const t = [0, 1, 2]; // 2s stop + expect(buildPauseMask([0, 0, 0], t, 5, 0.5).every((m) => !m)).toBe(true); + }); +}); + +describe("normalizedPower", () => { + it("constant power returns that power", () => { + const t = Array.from({ length: 60 }, (_, i) => i); + const power = Array.from({ length: 60 }, () => 200); + expect(normalizedPower(power, t)).toBeCloseTo(200, 0); + }); + it("empty input is null", () => expect(normalizedPower([], [])).toBeNull()); +}); + +describe("kcalFromPower", () => { + it("200 W for one hour ≈ 717 kcal at η=0.24", () => { + const t = Array.from({ length: 3600 }, (_, i) => i); + const power = Array.from({ length: 3600 }, () => 200); + expect(kcalFromPower(power, t)).toBeCloseTo(717, 1); + }); + it("length mismatch is null", () => expect(kcalFromPower([200], [0, 1])).toBeNull()); +});