From 526d851eef7d66cc4d40db390f0d591a5c770ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20K=C3=BChn?= Date: Fri, 8 May 2026 19:27:54 +0200 Subject: [PATCH] Add implementation plan for JS unit tests Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-08-js-unit-tests.md | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-js-unit-tests.md diff --git a/docs/superpowers/plans/2026-05-08-js-unit-tests.md b/docs/superpowers/plans/2026-05-08-js-unit-tests.md new file mode 100644 index 0000000..d735606 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-js-unit-tests.md @@ -0,0 +1,426 @@ +# JS Unit Tests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Node.js unit tests for `public/js/goals.js` and `public/js/i18n.js` using the built-in `node:test` runner — no npm, no build step. + +**Architecture:** Two test files under `tests/js/`, run with `node --test tests/js/`. A root `package.json` with `{"type":"module"}` enables Node to parse all `.js` files as ESM. `test-i18n.js` uses static imports; `test-goals.js` uses top-level `await import()` so `globalThis.localStorage` is set before `state.js` evaluates. `state` is mutated per-test to control `TODAY` and other date-dependent logic. + +**Tech Stack:** Node 25, `node:test`, `node:assert/strict`, ES modules. + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `package.json` | Create | `{"type":"module"}` — lets Node parse `.js` as ESM | +| `tests/js/test-i18n.js` | Create | Tests for `tr()`, `ldoc()`, `setLocale()`, `applyLocale()` | +| `tests/js/test-goals.js` | Create | Tests for `heuteColor()`, `dTot()`, `o2d()`, `tOff()`, `calc()`, `dcls()`, `dlbl()` | + +--- + +### Task 1: Add `package.json` and verify test runner + +**Files:** +- Create: `package.json` + +Without `{"type":"module"}` at the project root, Node parses `.js` as CommonJS and chokes on `import` statements. The deploy script uses only git and composer — this file is ignored by both. + +- [ ] **Step 1: Create `package.json`** + +```json +{ "type": "module" } +``` + +- [ ] **Step 2: Commit** + +```bash +git add package.json +git commit -m "chore: add package.json to enable ES module parsing for Node test runner" +``` + +--- + +### Task 2: i18n.js tests + +**Files:** +- Create: `tests/js/test-i18n.js` + +`i18n.js` has no imports. It accesses `localStorage` only when functions are called (not at module evaluation time), so static imports work fine. A `before()` hook sets up the stubs; `beforeEach()` resets locale and clears the localStorage mock between tests. + +Key behaviors under test: +- `tr(key)` — string lookup with locale fallback chain: current → de → key +- `ldoc()` — maps `LOCALE` to BCP-47 string +- `setLocale(lang, save)` — updates `LOCALE`, optionally persists to localStorage +- `applyLocale(userLocale)` — priority: explicit arg → localStorage → navigator.language + +- [ ] **Step 1: Create `tests/js/test-i18n.js`** + +```js +import { test, before, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tr, ldoc, setLocale, applyLocale } from '../../public/js/i18n.js'; + +const lsMock = { + _data: {}, + getItem(k) { return this._data[k] ?? null; }, + setItem(k, v) { this._data[k] = v; }, +}; + +before(() => { + globalThis.localStorage = lsMock; + globalThis.navigator = { language: 'de' }; +}); + +beforeEach(() => { + setLocale('de', false); + lsMock._data = {}; +}); + +// tr() — string lookup + +test('tr() returns German string', () => { + assert.equal(tr('noch'), 'Noch'); +}); + +test('tr() returns English string', () => { + setLocale('en', false); + assert.equal(tr('noch'), 'Left'); +}); + +test('tr() returns Polish string', () => { + setLocale('pl', false); + assert.equal(tr('noch'), 'Jeszcze'); +}); + +test('tr() falls back to German for unknown locale', () => { + setLocale('xx', false); + assert.equal(tr('noch'), 'Noch'); +}); + +test('tr() returns key for completely unknown key', () => { + assert.equal(tr('__nonexistent__'), '__nonexistent__'); +}); + +// ldoc() — BCP-47 locale string + +test('ldoc() returns de-DE for German', () => { + assert.equal(ldoc(), 'de-DE'); +}); + +test('ldoc() returns en-GB for English', () => { + setLocale('en', false); + assert.equal(ldoc(), 'en-GB'); +}); + +test('ldoc() returns pl-PL for Polish', () => { + setLocale('pl', false); + assert.equal(ldoc(), 'pl-PL'); +}); + +// setLocale() + +test('setLocale() with save=false does not write to localStorage', () => { + setLocale('en', false); + assert.equal(lsMock._data.zt_locale, undefined); + assert.equal(ldoc(), 'en-GB'); +}); + +test('setLocale() with save=true writes to localStorage', () => { + setLocale('pl', true); + assert.equal(lsMock._data.zt_locale, 'pl'); +}); + +// applyLocale() + +test('applyLocale() with explicit valid lang sets locale', () => { + applyLocale('pl'); + assert.equal(ldoc(), 'pl-PL'); +}); + +test('applyLocale() with unknown lang leaves locale unchanged', () => { + applyLocale('xx'); + assert.equal(ldoc(), 'de-DE'); +}); + +test('applyLocale() without arg reads localStorage', () => { + lsMock._data.zt_locale = 'en'; + applyLocale(null); + assert.equal(ldoc(), 'en-GB'); +}); + +test('applyLocale() without arg falls back to navigator.language', () => { + globalThis.navigator = { language: 'pl-PL' }; + applyLocale(null); // localStorage empty, navigator → 'pl' + globalThis.navigator = { language: 'de' }; // restore + assert.equal(ldoc(), 'pl-PL'); +}); +``` + +- [ ] **Step 2: Run the tests** + +```bash +node --test tests/js/test-i18n.js +``` + +Expected: all 14 tests pass (`✓` lines, `0 failing`). + +If you see `SyntaxError: Cannot use import statement in a module` — verify `package.json` exists at the project root with `{"type":"module"}`. + +If you see `ReferenceError: localStorage is not defined` — the `before()` hook hasn't run yet when the import resolves. This shouldn't happen with i18n.js (no localStorage at evaluation time), but if it does, move the `globalThis.localStorage = lsMock` assignment to before the import using a dynamic import: + +```js +// Replace static import with: +globalThis.localStorage = lsMock; +const { tr, ldoc, setLocale, applyLocale } = await import('../../public/js/i18n.js'); +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/js/test-i18n.js +git commit -m "test: add unit tests for i18n.js (tr, ldoc, setLocale, applyLocale)" +``` + +--- + +### Task 3: goals.js tests + +**Files:** +- Create: `tests/js/test-goals.js` + +`goals.js` imports `state` from `./state.js`. `state.js` calls `loadPrefs()` at evaluation time — that function accesses `localStorage` inside a try/catch, so it survives without stubs, but setting up the stub first is cleaner. Use top-level `await import()` so `globalThis.localStorage` is set before `state.js` evaluates. + +After import, `state.TODAY` is set in `beforeEach` to `new Date(2026, 4, 8, 0, 0, 0, 0)` (May 8, 2026, local midnight). The test goal uses `start: '2026-05-01 00:00:00'` (May 1) → `tOff = 7`. + +`g.sets` format: keys are day offsets as strings (`'0'`, `'1'`…), values are arrays of `{amount: number}` objects. + +Key `calc()` fields to understand: +- `t` = `tOff(g)` — days elapsed since start +- `past` = sum of all sets before today +- `tdone` = today's total +- `pd` = per-day target needed to finish on time +- `buf` = `(past - expectedPast) + max(0, tdone - daily)` — can be negative (deficit) +- `ok` = `tdone >= pd` + +- [ ] **Step 1: Create `tests/js/test-goals.js`** + +```js +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +globalThis.localStorage = { + _data: {}, + getItem(k) { return this._data[k] ?? null; }, + setItem(k, v) { this._data[k] = v; }, +}; + +const { state } = await import('../../public/js/state.js'); +const { tOff, o2d, dTot, heuteColor, calc, dcls, dlbl } = await import('../../public/js/goals.js'); + +// May 8, 2026 local midnight — used as state.TODAY in all tests +const FIXED_TODAY = new Date(2026, 4, 8, 0, 0, 0, 0); + +// goal starting May 1, 2026 → tOff = 7 when TODAY = May 8 +function makeGoal(overrides = {}) { + return { id: 1, start: '2026-05-01 00:00:00', daily: 10, days: 30, sets: {}, ...overrides }; +} + +beforeEach(() => { + state.TODAY = new Date(FIXED_TODAY); + state.goals = []; + state.collapsed = {}; + state.selDay = {}; +}); + +// heuteColor() + +test('heuteColor() 0 done → red', () => { + assert.equal(heuteColor(0, 10), 'var(--red)'); +}); + +test('heuteColor() partial → amber', () => { + assert.equal(heuteColor(5, 10), 'var(--amber)'); +}); + +test('heuteColor() exact daily → green', () => { + assert.equal(heuteColor(10, 10), 'var(--green)'); +}); + +test('heuteColor() over 110% → blue', () => { + assert.equal(heuteColor(11, 10), 'var(--blue)'); // 11 >= 10 * 1.1 +}); + +// dTot() + +test('dTot() sums amount fields', () => { + const g = { sets: { '0': [{ amount: 10 }, { amount: 5 }] } }; + assert.equal(dTot(g, 0), 15); +}); + +test('dTot() empty array returns 0', () => { + const g = { sets: { '0': [] } }; + assert.equal(dTot(g, 0), 0); +}); + +test('dTot() missing key returns 0', () => { + const g = { sets: {} }; + assert.equal(dTot(g, 99), 0); +}); + +// o2d() + +test('o2d() day 0 = start date at midnight', () => { + const g = makeGoal(); + const d = o2d(g, 0); + assert.equal(d.getFullYear(), 2026); + assert.equal(d.getMonth(), 4); // 0-indexed: 4 = May + assert.equal(d.getDate(), 1); + assert.equal(d.getHours(), 0); + assert.equal(d.getMinutes(), 0); +}); + +test('o2d() day 1 = start + 1 day at midnight', () => { + const g = makeGoal(); + const d = o2d(g, 1); + assert.equal(d.getDate(), 2); // May 2 + assert.equal(d.getHours(), 0); +}); + +// tOff() + +test('tOff() 7 days after start = 7', () => { + const g = makeGoal(); // start May 1, TODAY = May 8 + assert.equal(tOff(g), 7); +}); + +// calc() + +test('calc() day 0 nothing done: pd = daily, pct = 0, ok = false', () => { + // start = today → tOff = 0 + const g = makeGoal({ start: '2026-05-08 00:00:00' }); + const r = calc(g); + assert.equal(r.tot, 300); // 10 * 30 + assert.equal(r.tOff, 0); + assert.equal(r.pd, 10); // ceil(300/30) = 10 = daily + assert.equal(r.done, 0); + assert.equal(r.pct, 0); + assert.equal(r.ok, false); + assert.equal(r.deficit, 0); + assert.equal(r.surplus, 0); + assert.equal(r.dailyDelta, 0); +}); + +test('calc() day 7 on track: no deficit, pd = daily', () => { + // days 0-6 done exactly 10 each, today nothing + const sets = {}; + for (let i = 0; i < 7; i++) sets[String(i)] = [{ amount: 10 }]; + const g = makeGoal({ sets }); + const r = calc(g); + assert.equal(r.pd, 10); // ceil(230/23) = ceil(10) = 10 + assert.equal(r.deficit, 0); + assert.equal(r.surplus, 0); + assert.equal(r.done, 70); // 7 * 10 +}); + +test('calc() day 7 one missed day: pd > daily, deficit < 0', () => { + // days 0-5 done (10 each), day 6 missed, today nothing + const sets = {}; + for (let i = 0; i < 6; i++) sets[String(i)] = [{ amount: 10 }]; + const g = makeGoal({ sets }); + const r = calc(g); + // past = 60, expectedPast = 70, buf = -10 + assert.equal(r.deficit, -10); + assert.ok(r.pd > g.daily); // pd = ceil(240/23) = 11 > 10 + assert.equal(r.surplus, 0); +}); + +test('calc() pct capped at 100', () => { + // start 30 days ago, all 30 days done → tOff = 30 = days + const sets = {}; + for (let i = 0; i < 30; i++) sets[String(i)] = [{ amount: 10 }]; + // April 8 + 30 days = May 8 → tOff = 30 + const g = makeGoal({ start: '2026-04-08 00:00:00', sets }); + const r = calc(g); + assert.equal(r.pct, 100); +}); + +// dcls() + +test('dcls() future day → dot df', () => { + const g = makeGoal(); // tOff = 7 + assert.equal(dcls(g, 8), 'dot df'); // i=8 > t=7 +}); + +test('dcls() missed past day → dot dm dl', () => { + const g = makeGoal(); // no sets, day 0 < tOff 7, not editable + assert.equal(dcls(g, 0), 'dot dm dl'); +}); + +test('dcls() today with partial done → dot dp de', () => { + const g = makeGoal({ sets: { '7': [{ amount: 5 }] } }); // 5 of 10 today + assert.equal(dcls(g, 7), 'dot dp de'); // partial + editable (i === t) +}); + +test('dcls() yesterday over 110% → dot db de', () => { + const g = makeGoal({ sets: { '6': [{ amount: 12 }] } }); // 12 >= 10*1.1=11 + assert.equal(dcls(g, 6), 'dot db de'); // over + editable (i === t-1) +}); + +// dlbl() + +test('dlbl() future day → day number string', () => { + const g = makeGoal(); // tOff = 7 + assert.equal(dlbl(g, 8), '9'); // String(8+1) +}); + +test('dlbl() missed day → ✕', () => { + const g = makeGoal(); // no sets + assert.equal(dlbl(g, 0), '✕'); +}); + +test('dlbl() exact → ✓', () => { + const g = makeGoal({ sets: { '0': [{ amount: 10 }] } }); + assert.equal(dlbl(g, 0), '✓'); +}); + +test('dlbl() partial → percentage', () => { + const g = makeGoal({ sets: { '0': [{ amount: 5 }] } }); // 5/10 = 50% + assert.equal(dlbl(g, 0), '50%'); +}); + +test('dlbl() over 110% → +', () => { + const g = makeGoal({ sets: { '0': [{ amount: 12 }] } }); // 12 >= 11 + assert.equal(dlbl(g, 0), '+'); +}); +``` + +- [ ] **Step 2: Run the tests** + +```bash +node --test tests/js/test-goals.js +``` + +Expected: all 26 tests pass (`0 failing`). + +Common failures and fixes: + +**`tOff` returns wrong value**: `new Date('2026-05-01 00:00:00')` is parsed as local time in V8. If the test machine has a UTC offset that shifts midnight across a day boundary, the offset may be off by 1. Fix: change `start` in `makeGoal` to use `new Date(2026, 4, 1).toISOString().replace('T',' ').slice(0,19)` to generate a local-time string. But on standard setups in a UTC±12 range this is not an issue. + +**`calc() day 7 on track pd = 10`**: Verify with: `Math.ceil(230/23) === 10` → `230/23 = 10.0` exactly. ✓ (300 - 70 = 230, dl = 22+1 = 23) + +- [ ] **Step 3: Run all tests together** + +```bash +node --test tests/js/ +``` + +Expected: all tests from both files pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/js/test-goals.js +git commit -m "test: add unit tests for goals.js (calc, heuteColor, dTot, dcls, dlbl, tOff, o2d)" +```