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
105 lines
6.2 KiB
JavaScript
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');
|