// Tests for the rules behind REVIEW.html. // // node --test test_review_page.mjs // // The page's drawing code needs a browser and is not tested here; these cover every decision // the page makes about a group, and saving and loading a review file. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const L = require('./review_page_logic.cjs'); const RULES = { minMembers: 5, minStories: 4, maxDominant: 0.4, maxFieldChars: 80 }; function group(id, kind, stories) { return { kind, stats: { stories: new Set(stories).size }, gate_failures: [], role_hints: [], members: stories.map((story, n) => ({ member: `${id}.${String(n + 1).padStart(2, '0')}`, chunk: `${id}-${n}`, story, title: `Story ${story}`, summary: `${id} scene ${n + 1}`, })), }; } function doc() { return { run: { id: 'k60-s99', fingerprint: 'abc123', scenes: 21, stories: 13 }, rules: RULES, candidates: { C01: group('C01', 'accepted', [1, 2, 3, 4, 5, 6, 7, 8]), C02: group('C02', 'accepted', [9, 10, 11, 12, 13, 14]), B01: group('B01', 'borderline', [1, 3, 5, 7, 9, 11, 13]), }, }; } const find = (entries, id) => entries.find((e) => e.id === id); test('groups are named the way a reviewer reads them', () => { const d = doc(); assert.equal(L.displayName(d, 'C02'), 'Group 2'); assert.equal(L.displayName(d, 'B01'), 'Extra group 1'); assert.equal(L.shortName(d, 'B01'), '+1'); }); test('a group goes from not decided, to kept but unnamed, to done', () => { const d = doc(); const entries = L.initialEntries(d); assert.equal(L.status(d, entries, 'C01').state, 'todo'); assert.equal(L.status(d, entries, 'B01').state, 'skipped', 'extras start left out'); find(entries, 'C01').decision = 'keep'; assert.equal(L.status(d, entries, 'C01').state, 'problem'); assert.match(L.status(d, entries, 'C01').message, /name is not finished/); Object.assign(find(entries, 'C01'), { actor: 'lodger', wants: 'more time', counterpart: 'a landlady' }); assert.equal(L.status(d, entries, 'C01').state, 'done'); assert.equal(L.sentence(find(entries, 'C01')), 'A lodger wants more time from a landlady.'); }); test('unticking too much is flagged as it happens, with what a situation needs', () => { const d = doc(); const entries = L.initialEntries(d); Object.assign(find(entries, 'C02'), { decision: 'keep', actor: 'clerk', wants: 'a raise', counterpart: 'an employer', excluded: ['C02.01'], }); assert.equal(L.status(d, entries, 'C02').state, 'done', 'five scenes from five stories is enough'); find(entries, 'C02').excluded.push('C02.02', 'C02.03'); const s = L.status(d, entries, 'C02'); assert.equal(s.state, 'problem'); assert.match(s.message, /3 scenes from 3 stories\. A situation needs at least 5 scenes/); }); test('same-as offers only kept groups, merges scenes, and breaks if the target is dropped', () => { const d = doc(); const entries = L.initialEntries(d); assert.deepEqual(L.keptTargets(d, entries, 'C02'), [], 'nothing to merge into yet'); Object.assign(find(entries, 'C01'), { decision: 'keep', actor: 'lodger', wants: 'time', counterpart: 'a landlady' }); assert.deepEqual(L.keptTargets(d, entries, 'C02'), ['C01']); assert.deepEqual(L.keptTargets(d, entries, 'C01'), [], 'never itself'); Object.assign(find(entries, 'C02'), { decision: 'same', same_as: 'C01' }); assert.equal(L.groupMembers(d, entries, 'C01').length, 14); assert.equal(L.status(d, entries, 'C02').state, 'done'); find(entries, 'C01').decision = 'drop'; assert.equal(L.status(d, entries, 'C02').state, 'problem'); }); test('progress counts required groups only', () => { const d = doc(); const entries = L.initialEntries(d); find(entries, 'C01').decision = 'drop'; assert.deepEqual(L.progress(d, entries), { total: 2, decided: 1, problems: 0 }); }); test('a saved review loads back exactly', () => { const d = doc(); const entries = L.initialEntries(d); Object.assign(find(entries, 'C01'), { decision: 'keep', actor: 'lodger', wants: 'time', counterpart: 'a landlady', excluded: ['C01.03'] }); const file = JSON.parse(JSON.stringify(L.toReviewFile(d, entries, '2026-09-15T12:00:00Z'))); assert.equal(file.schema_version, 2); assert.equal(file.fingerprint, 'abc123'); assert.deepEqual(L.fromReviewFile(d, file).entries, entries); }); test('loading repairs what does not fit, and refuses the wrong run', () => { const d = doc(); const repaired = L.fromReviewFile(d, { schema_version: 2, run: 'k60-s99', fingerprint: 'abc123', entries: [ { id: 'C01', decision: 'maybe', excluded: ['C01.02', 'C99.01', 'C01.02'], actor: 'x'.repeat(200) }, { id: 'B01', decision: 'drop' }, { id: 'ZZZ', decision: 'keep' }, ], }); const c01 = find(repaired.entries, 'C01'); assert.equal(c01.decision, null, 'an unknown decision becomes undecided'); assert.deepEqual(c01.excluded, ['C01.02'], 'unknown and repeated scenes are dropped'); assert.equal(c01.actor.length, 80); assert.equal(find(repaired.entries, 'B01').decision, 'skip', 'an extra cannot be dropped'); assert.equal(repaired.entries.length, 3); assert.match(L.fromReviewFile(d, { schema_version: 2, run: 'k60-s24', entries: [] }).error, /for run k60-s24/); assert.match(L.fromReviewFile(d, { schema_version: 1, run: 'k60-s99' }).error, /different version/); assert.match(L.fromReviewFile(d, { schema_version: 2, run: 'k60-s99', fingerprint: 'nope', entries: [] }).error, /different set of groups/); });