Quality gates / quality (pull_request) Successful in 2m59s
The home insight card now reports the confirmed-log count and form groupings for a window, every line names the exact confirmed records it was built from, and following a citation opens those records. A window below the minimum states that it is too sparse instead of describing a trend, and a window with both firm and loose logs says it is mixed rather than picking a winner. No line explains a cause, rules on food, or offers a health verdict. - src/summary.js: window filtering, insufficient-data guard, per-form lines, mixed-pattern line, boundary copy, record citations - app.js: explainable home card plus a pattern-source sheet listing the cited records - tests/summary.test.js, tests/pattern-summary.acceptance.mjs: unit and real-browser coverage, gated in CI and the release builder - scripts/record_release_demo.mjs: fictional multi-day sample logs, citation follow-through, and the sparse-window refusal on camera
101 lines
4.7 KiB
JavaScript
101 lines
4.7 KiB
JavaScript
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']);
|
||
});
|