dudi/docs/superpowers/plans/2026-05-08-js-unit-tests.md
Simon Kühn 526d851eef Add implementation plan for JS unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:27:54 +02:00

13 KiB

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
{ "type": "module" }
  • Step 2: Commit
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

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
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:

// Replace static import with:
globalThis.localStorage = lsMock;
const { tr, ldoc, setLocale, applyLocale } = await import('../../public/js/i18n.js');
  • Step 3: Commit
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

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
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) === 10230/23 = 10.0 exactly. ✓ (300 - 70 = 230, dl = 22+1 = 23)

  • Step 3: Run all tests together
node --test tests/js/

Expected: all tests from both files pass.

  • Step 4: Commit
git add tests/js/test-goals.js
git commit -m "test: add unit tests for goals.js (calc, heuteColor, dTot, dcls, dlbl, tOff, o2d)"