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