import test from 'node:test'; import assert from 'node:assert/strict'; import { buildPatternSummary } from '../src/summary.js'; const DAY = 86400000; const NOW = Date.parse('2026-09-20T12:00:00.000Z'); const entry = (id, daysAgo, bristolType) => ({ id, occurredAt: new Date(NOW - daysAgo * DAY).toISOString(), bristolType, color: 'brown', urgency: 0, discomfort: 0, note: '', symptoms: {}, }); test('every summary statement cites the confirmed records it was built from', () => { const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 6)], { now: NOW }); assert.equal(summary.insufficient, false); assert.equal(summary.window.label, 'last 28 days'); const frequency = summary.statements.find(statement => statement.id === 'frequency'); assert.ok(frequency, 'the window needs a frequency line'); assert.match(frequency.text, /3 confirmed logs/); assert.match(frequency.text, /last 28 days/); assert.deepEqual(frequency.entryIds, ['c', 'b', 'a']); // No statement may cite an id the summary cannot point at a real record for. const known = new Set(summary.records.map(record => record.id)); for (const statement of summary.statements) { assert.ok(statement.entryIds.length > 0, `${statement.id} must cite its contributing records`); for (const id of statement.entryIds) assert.ok(known.has(id), `${statement.id} cited an unknown record ${id}`); } }); test('each form line cites only the records in that form bucket', () => { const summary = buildPatternSummary([entry('a', 1, 2), entry('b', 3, 4), entry('c', 5, 4), entry('d', 6, 4)], { now: NOW }); const firm = summary.statements.find(statement => statement.id === 'firm'); assert.ok(firm, 'a firm-side line is needed when firm logs exist'); assert.match(firm.text, /Type 1–2/); assert.match(firm.text, /1 of 4/); assert.deepEqual(firm.entryIds, ['a']); const typical = summary.statements.find(statement => statement.id === 'typical'); assert.match(typical.text, /3 of 4/); assert.deepEqual(typical.entryIds, ['d', 'c', 'b']); assert.equal(summary.statements.some(statement => statement.id === 'loose'), false); }); test('a contradictory window is reported as a mixed pattern instead of one trend', () => { const summary = buildPatternSummary([entry('a', 1, 1), entry('b', 2, 1), entry('c', 3, 6), entry('d', 4, 7)], { now: NOW }); const mixed = summary.statements.find(statement => statement.id === 'mixed'); assert.ok(mixed, 'a window with both firm and loose logs needs an explicit mixed line'); assert.match(mixed.text, /firm and loose logs both appear/i); assert.match(mixed.text, /not one trend/i); assert.deepEqual(mixed.entryIds, ['d', 'c', 'b', 'a']); }); test('logs outside the window are neither counted nor cited', () => { const summary = buildPatternSummary([entry('old', 40, 1), entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 4)], { now: NOW }); assert.deepEqual(summary.records.map(record => record.id), ['c', 'b', 'a']); assert.equal(summary.statements.some(statement => statement.entryIds.includes('old')), false); assert.match(summary.statements.find(statement => statement.id === 'frequency').text, /3 confirmed logs/); const weekly = buildPatternSummary([entry('old', 40, 1), entry('a', 1, 4), entry('b', 2, 4), entry('c', 3, 4)], { now: NOW, windowDays: 7 }); assert.equal(weekly.window.label, 'last 7 days'); assert.equal(weekly.window.days, 7); assert.deepEqual(weekly.records.map(record => record.id), ['c', 'b', 'a']); }); test('the summary states its boundary and never explains a cause or clears a food', () => { const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 6)], { now: NOW }); assert.match(summary.boundary, /your own confirmed records/i); const forbidden = /because|due to|caused|triggered|safe to eat|avoid eating|should eat|clears|diagnos|treat/i; for (const text of [summary.boundary, summary.copy, ...summary.statements.map(statement => statement.text)]) { assert.doesNotMatch(text, forbidden, text); } const sparse = buildPatternSummary([entry('a', 1, 4)], { now: NOW }); for (const text of [sparse.boundary, sparse.copy]) { assert.doesNotMatch(text, forbidden, text); } }); test('refuses to describe a pattern from too few confirmed logs', () => { const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3)], { now: NOW }); assert.equal(summary.insufficient, true); assert.deepEqual(summary.statements, []); assert.match(summary.copy, /not enough confirmed logs/i); assert.match(summary.copy, /2 of 3/); // The summary still lists exactly what it did read, in the order it happened. assert.deepEqual(summary.records.map(record => record.id), ['b', 'a']); });