Files
timmy-talking-turd/tests/followup.acceptance.mjs
timmy 4cc5f4f7b1
Quality gates / quality (pull_request) Successful in 2m53s
feat: user-reported hard-stool follow-up with deterministic red-flag handoff (#67)
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
2026-10-09 08:10:43 +00:00

105 lines
6.2 KiB
JavaScript

import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/vision-status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }),
}));
await page.route('**/api/analyze', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'suggestion', isStool: true, bristolType: 2, color: 'brown', confidence: 0.71,
imageQuality: 'good', observations: 'Small, separate, hard-looking lumps.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
}),
}));
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// 1. A hard/lumpy photo suggestion still contains only visible form, colour, and quality.
await page.locator('[data-scan]').click();
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.locator('#ai-consent').check();
await page.locator('#analyze-photo').click();
const suggestion = page.locator('[data-provenance="pending"]');
await suggestion.waitFor();
const suggestionText = await suggestion.innerText();
assert.match(suggestionText, /Type 2/);
assert.doesNotMatch(suggestionText, /sore|dehydrat|tarry|difficult to pass|blood/i, 'the photo suggestion may never mention user-reported symptoms');
// 2. Confirming the suggestion and moving to the safety step offers the follow-up for a hard form.
await page.locator('#use-suggestion').click();
await page.locator('#next').click();
await page.locator('#next').click();
const block = page.locator('[data-followup-block]');
await block.waitFor();
assert.equal(await block.count(), 1, 'a Bristol 2 entry asks the hard-stool follow-up');
assert.equal(await page.locator('[data-followup]').count(), 10, 'five questions, each with an explicit yes and no');
const askedIds = await page.locator('.followup-row').evaluateAll(rows => rows.map(row => row.dataset.question));
assert.deepEqual(askedIds.sort(), ['bloodVisible', 'dehydration', 'difficultToPass', 'soreness', 'tarryBlack']);
assert.match(await block.innerText(), /never (guesses|infers)/i, 'the copy states these are user-reported, never inferred');
assert.equal(await page.locator('[data-followup].selected').count(), 0, 'no question may be pre-answered');
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'an unanswered follow-up never escalates');
assert.match(await page.locator('#followup-output').innerText(), /answer/i, 'the user is asked to answer rather than given invented findings');
// 3. A mild hard-stool report gets proportionate non-diagnostic self-care and no escalation.
for (const id of ['difficultToPass', 'soreness', 'dehydration']) await page.locator(`[data-followup="${id}"][data-answer="yes"]`).click();
await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click();
await page.locator('[data-followup="bloodVisible"][data-answer="no"]').click();
const guidance = page.locator('#followup-guidance');
await guidance.waitFor();
assert.match(await guidance.innerText(), /not a diagnosis/i);
assert.match(await guidance.innerText(), /cannot rule out/i);
assert.match(await guidance.innerText(), /bleeding/i);
assert.match(await guidance.innerText(), /water|hydrat/i);
assert.match(await guidance.innerText(), /strain/i);
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'dark brown plus soreness must not escalate');
await page.screenshot({ path: 'artifacts/followup-hard-stool-mobile.png' });
// 4. Confirming truly black/tarry stool lands on the existing deterministic path.
await page.locator('[data-followup="tarryBlack"][data-answer="yes"]').click();
const alert = page.locator('#urgent-box .alert');
await alert.waitFor();
assert.match(await alert.innerText(), /Pause and get medical help\./);
assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), true, 'the confirmed answer sets the app\'s own red-flag control');
// 5. Clearing that answer clears the escalation instead of sticking.
await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click();
assert.equal(await page.locator('#urgent-box .alert').count(), 0);
assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), false);
// 6. Visible blood is the other existing deterministic flag.
await page.locator('[data-followup="bloodVisible"][data-answer="yes"]').click();
await page.locator('#urgent-box .alert').waitFor();
assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true);
// 7. Non-hard forms never see the follow-up.
await page.locator('#back').click();
await page.locator('#back').click();
await page.locator('[data-type="6"]').click();
await page.locator('#next').click();
await page.locator('#next').click();
assert.equal(await page.locator('[data-followup-block]').count(), 0, 'loose forms are not asked hard-stool questions');
assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true, 'an answer the user already confirmed is not silently dropped');
assert.equal(await page.locator('#urgent-box .alert').count(), 1, 'the deterministic path still reflects that confirmed answer');
await page.locator('[data-symptom="blood"]').uncheck();
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'clearing the confirmed flag clears the escalation');
assert.deepEqual(errors, []);
await context.close();
await browser.close();
console.log('PASS hard-stool follow-up is user-reported, non-diagnostic for mild reports, and lands on the deterministic escalation path');