Quality gates / quality (pull_request) Successful in 1m45s
Suggested, confirmed, and corrected AI states are now distinct in the log sheet, and correcting a suggested value clears the AI-confirmed state while keeping every user-entered field. Screen-reader copy states the AI confidence and who is in control. - src/suggestion.js: provenance state machine (pending/confirmed/corrected/manual) - app.js: renders provenance on the scan result and log steps, tracks corrections - tests/suggestion.test.js: unit coverage for the state machine and a11y copy - tests/provisional-suggestion.acceptance.mjs: browser acceptance + screenshots - record_release_demo.mjs: demo now reviews, confirms, and corrects the suggestion - CI, release builder, and release notes gate the new contract
96 lines
5.4 KiB
JavaScript
96 lines
5.4 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: 4, color: 'brown', confidence: 0.83,
|
|
imageQuality: 'good', observations: 'Smooth, formed appearance.',
|
|
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. The AI suggestion is visibly provisional and its screen-reader copy states confidence and user control.
|
|
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();
|
|
await page.getByText(/83% confidence/i).waitFor();
|
|
|
|
const suggested = page.locator('[data-provenance="pending"]');
|
|
assert.equal(await suggested.count(), 1, 'the AI result is a distinct provisional state');
|
|
assert.match(await suggested.innerText(), /AI SUGGESTION/);
|
|
const suggestedAria = await suggested.getAttribute('aria-label');
|
|
assert.match(suggestedAria, /83 percent confidence/i, 'screen-reader copy states the AI confidence');
|
|
assert.match(suggestedAria, /not saved until you confirm/i, 'screen-reader copy states that the user is in control');
|
|
await page.screenshot({ path: 'artifacts/provisional-suggestion-pending-mobile.png' });
|
|
|
|
// 2. Confirming the suggestion produces a different, clearly labelled state.
|
|
await page.locator('#use-suggestion').click();
|
|
const confirmed = page.locator('[data-provenance="confirmed"]');
|
|
assert.equal(await confirmed.count(), 1, 'confirmation is a distinct state from the raw suggestion');
|
|
assert.equal(await page.locator('[data-provenance="pending"]').count(), 0);
|
|
assert.match(await confirmed.innerText(), /AI PREFILLED/);
|
|
assert.match(await confirmed.locator('[role="status"]').innerText(), /you confirmed/i);
|
|
await page.screenshot({ path: 'artifacts/provisional-suggestion-confirmed-mobile.png' });
|
|
|
|
// 3. User context entered after the suggestion survives a correction.
|
|
await page.locator('#next').click();
|
|
await page.locator('#urgency').fill('3');
|
|
await page.locator('#note').fill('After lunch');
|
|
await page.locator('#back').click();
|
|
await page.locator('[data-type="2"]').click();
|
|
|
|
const corrected = page.locator('[data-provenance="corrected"]');
|
|
assert.equal(await corrected.count(), 1, 'changing the suggestion clears AI-confirmed state');
|
|
assert.equal(await confirmed.count(), 0);
|
|
assert.equal(await page.locator('[data-provenance="confirmed"]').count(), 0);
|
|
assert.match(await corrected.innerText(), /CORRECTED BY YOU/);
|
|
assert.match(await corrected.innerText(), /AI suggested Type 4/);
|
|
const correctedAria = await corrected.getAttribute('aria-label');
|
|
assert.match(correctedAria, /You changed/i);
|
|
assert.match(correctedAria, /Type 4/);
|
|
assert.match(correctedAria, /Type 2/);
|
|
assert.equal(await page.locator('[data-type="2"]').getAttribute('class').then(v => v.includes('selected')), true);
|
|
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), false);
|
|
await page.screenshot({ path: 'artifacts/provisional-suggestion-corrected-mobile.png' });
|
|
|
|
// 4. The correction keeps every user-entered field and reports it honestly on later steps.
|
|
await page.locator('#next').click();
|
|
assert.equal(await page.locator('#color').inputValue(), 'brown', 'the confirmed color is kept, not silently re-suggested');
|
|
assert.equal(await page.locator('#urgency').inputValue(), '3', 'user context is not lost when a suggestion is corrected');
|
|
assert.equal(await page.locator('#note').inputValue(), 'After lunch');
|
|
assert.match(await page.locator('.provenance-note').last().innerText(), /You changed it to Type 2/);
|
|
assert.match(await page.locator('.provenance-note').last().innerText(), /must come from you/i);
|
|
|
|
// 5. Correcting the second suggested field keeps both corrections in the provenance record.
|
|
await page.locator('#color').selectOption('black');
|
|
await page.locator('#back').click();
|
|
const recolored = page.locator('[data-provenance="corrected"]');
|
|
assert.equal(await recolored.count(), 1);
|
|
assert.match(await recolored.getAttribute('aria-label'), /Type 2, black/);
|
|
|
|
assert.deepEqual(errors, []);
|
|
await context.close();
|
|
await browser.close();
|
|
console.log('PASS AI suggestions stay provisional, confirmation is explicit, and correcting a suggestion clears AI-confirmed state without losing user context');
|