Quality gates / quality (pull_request) Successful in 2m53s
Field finding #67: a hard/lumpy (Bristol 1-2) entry now asks a short follow-up before saving — difficult to pass, soreness, visible red blood, truly black/sticky/tarry versus dark brown, and dehydration. Every answer is user-reported and starts unanswered; nothing is inferred from the photo, and answering "dark brown" never silently escalates as black stool. A confirmed red-flag answer writes the same symptom flag manual logging already uses, so the existing deterministic escalation is the only escalation path. Mild reports get concise non-diagnostic self-care that states the photo cannot rule out bleeding, and it is withdrawn whenever a red flag is confirmed. - src/followup.js: pure follow-up contract (9 unit tests, tests/followup.test.js) - app.js + styles.css: follow-up block on the safety step for Type 1-2 entries only - tests/followup.acceptance.mjs: browser acceptance incl. that the AI suggestion surface still contains no symptom wording - CI + release builder gates: npm run test:followup, syntax checks, manifest gate - scripts/record_release_demo.mjs: records the real follow-up, mild guidance, and the black/tarry alert handoff with synthetic data
123 lines
5.4 KiB
JavaScript
123 lines
5.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { detectUrgentFlags } from '../src/domain.js';
|
|
import {
|
|
HARD_STOOL_QUESTIONS,
|
|
HARD_STOOL_TYPES,
|
|
createFollowUpAnswers,
|
|
evaluateHardStoolFollowUp,
|
|
isHardStoolForm,
|
|
needsHardStoolFollowUp,
|
|
} from '../src/followup.js';
|
|
|
|
// Field finding #67: after a hard/lumpy (Bristol 1-2) log, Timmy asks a short
|
|
// user-reported follow-up. Nothing here may be inferred from the photo, dark brown
|
|
// must never be silently escalated as black stool, and confirmed red flags must land
|
|
// on the same deterministic escalation path the rest of the app already uses.
|
|
|
|
const questionIds = () => HARD_STOOL_QUESTIONS.map((question) => question.id);
|
|
|
|
test('the hard-stool follow-up only appears for Bristol 1 and 2', () => {
|
|
assert.deepEqual([...HARD_STOOL_TYPES], [1, 2]);
|
|
assert.equal(isHardStoolForm(1), true);
|
|
assert.equal(isHardStoolForm(2), true);
|
|
for (const type of [3, 4, 5, 6, 7]) assert.equal(isHardStoolForm(type), false, `Type ${type} is not hard/lumpy`);
|
|
assert.equal(isHardStoolForm(undefined), false);
|
|
assert.equal(isHardStoolForm('2'), true, 'a form value from the DOM is still the same form');
|
|
assert.equal(needsHardStoolFollowUp({ bristolType: 2 }), true);
|
|
assert.equal(needsHardStoolFollowUp({ bristolType: 4 }), false);
|
|
assert.equal(needsHardStoolFollowUp({}), false);
|
|
});
|
|
|
|
test('every follow-up question starts unanswered and is never pre-filled', () => {
|
|
const answers = createFollowUpAnswers();
|
|
|
|
assert.deepEqual(questionIds().slice().sort(), [
|
|
'bloodVisible',
|
|
'dehydration',
|
|
'difficultToPass',
|
|
'soreness',
|
|
'tarryBlack',
|
|
]);
|
|
assert.equal(HARD_STOOL_QUESTIONS.length, 5);
|
|
for (const question of HARD_STOOL_QUESTIONS) {
|
|
assert.equal(answers[question.id], null, `${question.id} must start unanswered`);
|
|
assert.match(question.label, /was it|any |do you/i, `${question.id} must ask the user, not infer`);
|
|
}
|
|
});
|
|
|
|
test('an unanswered follow-up never escalates and never invents guidance', () => {
|
|
const verdict = evaluateHardStoolFollowUp(createFollowUpAnswers());
|
|
|
|
assert.equal(verdict.answered, false);
|
|
assert.equal(verdict.urgent, false);
|
|
assert.deepEqual(verdict.flags, []);
|
|
assert.equal(verdict.message, '');
|
|
assert.deepEqual(verdict.guidance, [], 'unanswered questions must not produce self-care copy');
|
|
});
|
|
|
|
test('a mild hard-stool report keeps non-diagnostic self-care and no escalation', () => {
|
|
const verdict = evaluateHardStoolFollowUp({
|
|
difficultToPass: true,
|
|
soreness: true,
|
|
bloodVisible: false,
|
|
tarryBlack: false,
|
|
dehydration: true,
|
|
});
|
|
|
|
assert.equal(verdict.answered, true);
|
|
assert.equal(verdict.urgent, false);
|
|
assert.deepEqual(verdict.flags, []);
|
|
assert.equal(verdict.message, '');
|
|
assert.ok(verdict.guidance.length >= 3, 'a mild report still gets proportionate self-care');
|
|
assert.equal(verdict.guidance.some((line) => /water|hydrat/i.test(line)), true);
|
|
assert.equal(verdict.guidance.some((line) => /strain/i.test(line)), true);
|
|
});
|
|
|
|
test('the mild guidance never implies the photo ruled out bleeding', () => {
|
|
const verdict = evaluateHardStoolFollowUp({ soreness: true, bloodVisible: false, tarryBlack: false });
|
|
|
|
assert.match(verdict.note, /not a diagnosis/i);
|
|
assert.match(verdict.note, /cannot rule out|did not rule out/i);
|
|
assert.match(verdict.note, /bleeding/i);
|
|
assert.doesNotMatch(verdict.note, /rules out|safe|cleared|no bleeding/i);
|
|
});
|
|
|
|
test('confirmed visible blood maps onto the existing deterministic blood red flag', () => {
|
|
const verdict = evaluateHardStoolFollowUp({ bloodVisible: true });
|
|
|
|
assert.equal(verdict.urgent, true);
|
|
assert.deepEqual(verdict.flags, ['blood']);
|
|
assert.equal(verdict.message, detectUrgentFlags({ blood: true }).message, 'urgent copy comes from the canonical path');
|
|
assert.deepEqual(verdict.guidance, [], 'escalation copy must not be softened with self-care');
|
|
});
|
|
|
|
test('confirmed black or tarry stool maps onto the existing deterministic blackOrDarkRed red flag', () => {
|
|
const verdict = evaluateHardStoolFollowUp({ tarryBlack: true });
|
|
|
|
assert.equal(verdict.urgent, true);
|
|
assert.deepEqual(verdict.flags, ['blackOrDarkRed']);
|
|
assert.equal(verdict.message, detectUrgentFlags({ blackOrDarkRed: true }).message);
|
|
});
|
|
|
|
test('dark brown is not silently escalated as black stool', () => {
|
|
const explicitDarkBrown = evaluateHardStoolFollowUp({ difficultToPass: true, tarryBlack: false });
|
|
const unansweredTarry = evaluateHardStoolFollowUp({ difficultToPass: true });
|
|
const colorAlone = evaluateHardStoolFollowUp({ color: 'black', note: 'dark brown' });
|
|
|
|
assert.equal(explicitDarkBrown.urgent, false, 'answering "dark brown" must not escalate');
|
|
assert.equal(unansweredTarry.urgent, false, 'an unanswered colour question is not a yes');
|
|
assert.equal(colorAlone.urgent, false, 'a draft colour value is never treated as a confirmed tarry finding');
|
|
});
|
|
|
|
test('an unanswered question is not silently treated as a no', () => {
|
|
const partial = evaluateHardStoolFollowUp({ soreness: true });
|
|
|
|
assert.equal(partial.answered, true);
|
|
assert.equal(partial.urgent, false);
|
|
assert.deepEqual(partial.flags, []);
|
|
assert.equal(partial.unanswered.length, 4, 'the verdict reports what the user still has not answered');
|
|
const all = evaluateHardStoolFollowUp({ bloodVisible: false, tarryBlack: false, soreness: false, difficultToPass: false, dehydration: false });
|
|
assert.deepEqual(all.unanswered, []);
|
|
});
|