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, []); });