// 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/);
});