Add implementation plan for JS unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b927fc984
commit
526d851eef
1 changed files with 426 additions and 0 deletions
426
docs/superpowers/plans/2026-05-08-js-unit-tests.md
Normal file
426
docs/superpowers/plans/2026-05-08-js-unit-tests.md
Normal file
|
|
@ -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)"
|
||||
```
|
||||
Loading…
Reference in a new issue