feat(mental-health): one check-in pattern for every questionnaire, worded as published

Every instrument now runs the same wizard: the form's own question stem
above each item (the PHQ-9 and GAD-7 stems were missing), the item's
exact answer options as one radio group in source order, progress, Back,
number-key answers, and a review step that lists every answer, lets any
of them be changed and is the only step that submits. The result leads
with the crisis card when it applies, then shows the score on the
instrument's scale, the band, what the band means, the change since the
last check-in against the meaningful-change threshold, and the next
step. Scoring is untouched.

The wording was read against the published versions. German follows the
PHQ-D and Löwe's GAD-7 verbatim, including the follow-up question in its
published formal address. Spanish, Italian and Polish carry the official
PHQ-9 and GAD-7 translations, French the official GAD-7, and the GAD-7
gets its own answer set because the Italian forms word it differently.
Where a version could not be matched to a published form (the French
PHQ-9, every Korean form) the language now shows the English original.
Each locale list names its sources, and spot checks pin the corrected
wording.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 19:28:29 +02:00
1 parent 49a999ab94
commit 038f8b10cd
24 files changed
+1398 -435

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* One full check-in per instrument through the shared wizard: the stem is
* on every step, the answers form one radio group in the source order, the
* number keys answer, the review lists every answer and changes one, and
* the result carries score, band, meaning and the next step. The PHQ-9 run
* answers item 9 above zero, so its result must lead with the crisis card.
*
* The total on the result is the server's; this journey adds the chosen
* option values itself and compares, which is the scoring contract seen
* from the outside (`instruments.test.ts` pins it from the inside). Each
* screen is scanned with axe on the way.
*
* Anchored on data attributes only, so the journey reads the same at every
* width and in every language.
*/
type Case = {
id: "PHQ9" | "GAD7" | "WHO5" | "SCI";
items: number;
options: number;
multiplier: number;
followUp: boolean;
};
const CASES: readonly Case[] = [
{ id: "PHQ9", items: 9, options: 4, multiplier: 1, followUp: true },
{ id: "GAD7", items: 7, options: 4, multiplier: 1, followUp: false },
{ id: "WHO5", items: 5, options: 6, multiplier: 4, followUp: false },
{ id: "SCI", items: 8, options: 5, multiplier: 1, followUp: false },
];
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest.
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
)
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations
.filter((v) => v.impact === "serious" || v.impact === "critical")
.map(
(v) =>
`${v.id}: ${v.nodes.map((n) => `${n.html} ${n.failureSummary ?? ""}`).join(" | ")}`,
);
expect(blocking, `axe on ${label}`).toEqual([]);
}
test.describe("mental-health check-in, every instrument", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const c of CASES) {
test(`${c.id}: steps, review, result`, async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/mental-wellbeing");
const card = page.locator(
`[data-slot="instrument-card"][data-instrument="${c.id}"]`,
);
await card.locator('[data-slot="instrument-card-start"]').click();
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
await expect(wizard).toBeVisible();
const values: number[] = [];
for (let step = 1; step <= c.items; step++) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(current).toBeVisible();
await expect(
current.locator('[data-slot="check-in-stem"]'),
).toHaveCount(1);
const group = current.locator('[role="radiogroup"]');
await expect(group.locator('[role="radio"]')).toHaveCount(c.options);
if (step === 1) await expectNoSeriousViolations(page, `${c.id} step 1`);
// PHQ-9 item 9 answered above zero: the option in the last position
// is "nearly every day" (value 3).
const position =
c.id === "PHQ9" && step === 9 ? c.options : (step % c.options) + 1;
const answered = group.locator('[role="radio"]').nth(position - 1);
values.push(Number(await answered.getAttribute("data-value")));
if (step % 2 === 0) {
// Keyboard: the number key picks the answer in that position.
await group.locator('[role="radio"]').first().focus();
await page.keyboard.press(String(position));
} else {
await group
.locator('[role="radio"]')
.nth(position - 1)
.click();
}
}
if (c.followUp) {
const followUp = wizard.locator(
'[data-slot="check-in-step"][data-functional]',
);
await expect(followUp).toBeVisible();
await followUp.locator('[role="radio"]').nth(1).click();
await wizard.locator('[data-slot="check-in-next"]').click();
}
const review = wizard.locator('[data-slot="check-in-review"]');
await expect(review).toBeVisible();
await expect(
review.locator('[data-slot="check-in-review-row"]'),
).toHaveCount(c.items + (c.followUp ? 1 : 0));
await expectNoSeriousViolations(page, `${c.id} review`);
// Change the second answer from the review; the wizard returns there.
await review.locator('[data-review-edit="2"]').click();
const second = wizard.locator(
'[data-slot="check-in-step"][data-step="2"]',
);
await expect(second).toBeVisible();
const other =
values[1] ===
Number(
await second
.locator('[role="radio"]')
.first()
.getAttribute("data-value"),
)
? 1
: 0;
await second.locator('[role="radio"]').nth(other).click();
await expect(review).toBeVisible();
values[1] = Number(
await review
.locator('[data-slot="check-in-review-row"][data-step="2"]')
.getAttribute("data-value"),
);
await wizard.locator('[data-slot="check-in-submit"]').click();
const result = page.locator(
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
);
await expect(result).toBeVisible();
const total = values.reduce((sum, v) => sum + v, 0) * c.multiplier;
await expect(result.locator('[data-slot="result-score"]')).toContainText(
String(total),
);
await expect(result.locator('[data-slot="result-band"]')).toBeVisible();
await expect(
result.locator('[data-slot="result-meaning"]'),
).toBeVisible();
await expect(
result.locator(
'[data-slot="result-follow-up-hint"], [data-slot="result-next-step"]',
),
).toHaveCount(1);
if (c.id === "PHQ9") {
await expect(
result.locator('[data-slot="mental-health-crisis-card"]'),
).toBeVisible();
}
await expectNoSeriousViolations(page, `${c.id} result`);
});
}
});