426 lines
13 KiB
Markdown
426 lines
13 KiB
Markdown
# 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)"
|
|
```
|