dudi/docs/superpowers/plans/2026-05-08-js-unit-tests.md

427 lines
13 KiB
Markdown
Raw Normal View History

# 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)"
```