From 392b2c47a97df283b709e6fc2c77460e9ba3bbdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:16:02 +0200 Subject: [PATCH] test(mental-health): drive the check-in wizard in a real browser One question at a time in the source order, Back keeping the answer, the number keys and arrows, the review with a way back to every answer, the optional PHQ-9 follow-up, a failed submit, and the note over an unvalidated translation staying inside the viewport on every step. The switch to the English original is carried in the answers, and the full path from the overview posts the instrument, the reader's language and the item scores. Questionnaire text loads through the production chunk loader. The wizard's markup cases are removed from the surface suite; the loading-frame case stays there, since a browser loads the chunk before a test can look. The browser project inlines an empty process.env for Next's client modules. --- .../check-in-wizard.browser.test.tsx | 331 ++++++++++++++++++ .../__tests__/mental-wellbeing.test.tsx | 147 +------- vitest.config.mts | 9 + 3 files changed, 355 insertions(+), 132 deletions(-) create mode 100644 src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx diff --git a/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx b/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx new file mode 100644 index 000000000..69469cf9f --- /dev/null +++ b/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx @@ -0,0 +1,331 @@ +/** + * The questionnaire check-in, driven by clicks and keys in a real browser. + * + * The step arithmetic is pinned on its own in `check-in-nav.test.ts`; this + * suite proves the wizard a person uses: one question at a time in the source + * order, Back keeping the answer, the number keys, the review with a way back + * to every answer, the optional PHQ-9 follow-up, the note over an unvalidated + * translation staying on screen with every question, and the body the page + * posts. The questionnaire text loads through the production chunk loader, + * not a seeded cache. + */ +import { describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; + +import { WORDING } from "@/lib/mental-health/wording"; +import type { Locale } from "@/lib/i18n/config"; + +import { stubFetch } from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { CheckInWizard } from "../check-in-wizard"; +import { MentalWellbeing } from "../mental-wellbeing"; +import type { CheckInAnswers, InstrumentId } from "../types"; + +const WHO5 = WORDING.WHO5.en; +const PHQ9 = WORDING.PHQ9.en; + +async function wizard( + instrument: InstrumentId, + { + locale = "en", + isError = false, + }: { locale?: Locale; isError?: boolean } = {}, +) { + const onSubmit = vi.fn<(answers: CheckInAnswers) => void>(); + const onBack = vi.fn(); + const { screen } = await renderWithApp( + , + { locale }, + ); + const heading = screen.getByRole("heading", { level: 2 }); + /** The step on screen asks `text` (waiting out the language's chunk). */ + const expectQuestion = (text: string) => + expect.element(heading).toHaveTextContent(text); + const answer = (name: string) => + screen.getByRole("radio", { name, exact: true }); + const button = (name: string) => + screen.getByRole("button", { name, exact: true }); + return { screen, onSubmit, onBack, expectQuestion, answer, button }; +} + +/** + * Answer the current step by its position, as a keyboard user does: each new + * step focuses its question, so Tab first reaches the answers, then the + * number key chooses. + */ +async function pressAnswer(position: number) { + await userEvent.tab(); + await userEvent.keyboard(String(position)); +} + +describe(" steps", () => { + it("opens on the first question with the stem and the answers in source order", async () => { + const { screen, button } = await wizard("WHO5"); + await expect.element(screen.getByText(WHO5.items[0])).toBeVisible(); + await expect.element(screen.getByText(WHO5.stems.period)).toBeVisible(); + await expect.element(screen.getByText("Question 1 of 5")).toBeVisible(); + const labels = screen + .getByRole("radio") + .elements() + .map((el) => el.textContent); + expect(labels).toEqual([5, 4, 3, 2, 1, 0].map((v) => WHO5.anchors.all[v])); + await expect.element(button("Back")).toBeDisabled(); + await expect.element(button("Next")).toBeDisabled(); + await expect.element(button("See my result")).not.toBeInTheDocument(); + // The instrument's own explanation heads the check-in; the landing's + // self-test disclaimer does not follow the person into it. + await expect + .element( + screen.getByText( + "5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.", + ), + ) + .toBeVisible(); + await expect + .element(screen.getByText(/not a diagnosis/i)) + .not.toBeInTheDocument(); + // English is the validated wording: no note, no switch. + await expect + .element(screen.getByText(/has not been scientifically validated/)) + .not.toBeInTheDocument(); + }); + + it("gives each SCI item its own answers under its section stem", async () => { + const SCI = WORDING.SCI.en; + const { screen, expectQuestion } = await wizard("SCI"); + await expectQuestion(SCI.items[0]); + await expect.element(screen.getByText(SCI.stems.night)).toBeVisible(); + await expect.element(screen.getByText("Question 1 of 8")).toBeVisible(); + expect( + screen + .getByRole("radio") + .elements() + .map((el) => el.textContent), + ).toEqual([4, 3, 2, 1, 0].map((v) => SCI.anchors.duration[v])); + }); + + it("advances on a choice, and Back returns with the answer still chosen", async () => { + const { screen, answer, button, expectQuestion } = await wizard("WHO5"); + await answer(WHO5.anchors.all[3]).click(); + await expect.element(screen.getByText("Question 2 of 5")).toBeVisible(); + await expectQuestion(WHO5.items[1]); + await expect.element(button("Next")).toBeDisabled(); + + await button("Back").click(); + await expectQuestion(WHO5.items[0]); + await expect.element(answer(WHO5.anchors.all[3])).toBeChecked(); + await button("Next").click(); + await expectQuestion(WHO5.items[1]); + }); + + it("chooses by number key, moves with the arrows and focuses each new question", async () => { + const { screen, answer } = await wizard("WHO5"); + await answer(WHO5.anchors.all[5]).click(); + await expect + .element(screen.getByRole("heading", { name: WHO5.items[1] })) + .toHaveFocus(); + await userEvent.tab(); + await expect.element(answer(WHO5.anchors.all[5])).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}"); + await expect.element(answer(WHO5.anchors.all[3])).toHaveFocus(); + await userEvent.keyboard("{End}"); + await expect.element(answer(WHO5.anchors.all[0])).toHaveFocus(); + + // "2" is the second answer as printed, which scores 4 on the WHO-5. + await userEvent.keyboard("2"); + await expect.element(screen.getByText("Question 3 of 5")).toBeVisible(); + await screen.getByRole("button", { name: "Back", exact: true }).click(); + await expect.element(answer(WHO5.anchors.all[4])).toBeChecked(); + }); + + it("reviews every answer, changes one and returns to the review", async () => { + const { screen, onSubmit, button } = await wizard("WHO5"); + for (const position of [1, 2, 3, 4, 5]) await pressAnswer(position); + await expect + .element(screen.getByRole("heading", { name: "Check your answers" })) + .toBeVisible(); + await expect + .element(screen.getByText("All 5 questions answered")) + .toBeVisible(); + + await screen + .getByRole("button", { name: "Change the answer to question 3" }) + .click(); + await expect.element(screen.getByText("Question 3 of 5")).toBeVisible(); + await screen + .getByRole("radio", { name: WHO5.anchors.all[0], exact: true }) + .click(); + await expect + .element(screen.getByRole("heading", { name: "Check your answers" })) + .toBeVisible(); + + await button("See my result").click(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + items: [5, 4, 0, 2, 1], + functionalDifficulty: undefined, + englishOriginal: false, + }); + }); + + it("asks the PHQ-9 follow-up as an optional step that a second press clears", async () => { + const { screen, onSubmit, answer, button, expectQuestion } = + await wizard("PHQ9"); + for (let i = 0; i < 9; i++) await pressAnswer(1); + await expect + .element(screen.getByText("Follow-up question (optional)")) + .toBeVisible(); + await expectQuestion(PHQ9.functional!.title); + await expect.element(button("Review answers")).toBeEnabled(); + + await answer(PHQ9.functional!.anchors[2]).click(); + await expect.element(answer(PHQ9.functional!.anchors[2])).toBeChecked(); + await answer(PHQ9.functional!.anchors[2]).click(); + await expect.element(answer(PHQ9.functional!.anchors[2])).not.toBeChecked(); + + await button("Review answers").click(); + await expect.element(screen.getByText("Not answered")).toBeVisible(); + await button("See my result").click(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + items: [0, 0, 0, 0, 0, 0, 0, 0, 0], + functionalDifficulty: undefined, + englishOriginal: false, + }); + }); + + it("returns to the overview and shows a failed submit", async () => { + const { screen, onBack } = await wizard("GAD7", { isError: true }); + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Something went wrong. Please try again."); + await screen.getByRole("button", { name: "Overview" }).click(); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); + +describe(" in the reader's language", () => { + const SCI_DE = WORDING.SCI.de; + const NOTE = + "Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung."; + + it("keeps the note over an unvalidated translation on screen with every question", async () => { + await page.viewport(390, 640); + const { screen, expectQuestion } = await wizard("SCI", { locale: "de" }); + expect(SCI_DE.validated).toBe(false); + const note = screen.getByText(NOTE); + for (let i = 0; i < 8; i++) { + await expectQuestion(SCI_DE.items[i]); + await expect.element(note).toBeVisible(); + const rect = note.element().getBoundingClientRect(); + expect(rect.top, `step ${i + 1}`).toBeGreaterThanOrEqual(0); + expect(rect.bottom, `step ${i + 1}`).toBeLessThanOrEqual( + window.innerHeight, + ); + // The first question arrives with the language's chunk, after the + // focus move has run, so it is answered by pointer; the rest by key. + if (i === 0) await screen.getByRole("radio").first().click(); + else await pressAnswer(1); + } + await expect + .element(screen.getByRole("heading", { name: "Antworten prüfen" })) + .toBeVisible(); + await expect.element(note).toBeVisible(); + }); + + it("switches to the English original and says so in the answers", async () => { + const { screen, onSubmit, expectQuestion } = await wizard("SCI", { + locale: "de", + }); + await expectQuestion(SCI_DE.items[0]); + await screen + .getByRole("button", { name: "Englischen Originaltext zeigen" }) + .click(); + await expect + .element(screen.getByRole("heading", { name: WORDING.SCI.en.items[0] })) + .toHaveAttribute("lang", "en"); + // The note stays: the reader is still in a language with no validated form. + await expect.element(screen.getByText(NOTE)).toBeVisible(); + await screen.getByRole("radio").first().click(); + for (let i = 1; i < 8; i++) await pressAnswer(1); + await screen + .getByRole("button", { name: "Ergebnis ansehen", exact: true }) + .click(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ englishOriginal: true }), + ); + }); + + it("shows a validated translation with no note and no switch", async () => { + const { screen, expectQuestion } = await wizard("PHQ9", { locale: "nl" }); + await expectQuestion(WORDING.PHQ9.nl.items[0]); + await expect + .element(screen.getByText(WORDING.PHQ9.nl.stems.period)) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: /English/ })) + .not.toBeInTheDocument(); + }); +}); + +describe("the check-in from the overview", () => { + it("posts the answers with the instrument and the reader's language, then shows the result", async () => { + const api = stubFetch().on("GET", "/api/mental-health/assessments", { + data: { assessments: [] }, + }); + const post = api.deferred("POST", "/api/mental-health/assessments"); + const { screen } = await renderWithApp(); + + const start = + '[data-instrument="WHO5"] [data-slot="instrument-card-start"]'; + await expect.poll(() => document.querySelector(start)).toBeTruthy(); + await page.elementLocator(document.querySelector(start)!).click(); + await expect.element(screen.getByText(WHO5.items[0])).toBeVisible(); + await screen.getByRole("radio").nth(1).click(); + for (const position of [2, 3, 1, 2]) await pressAnswer(position); + const submit = screen.getByRole("button", { name: "See my result" }); + await submit.click(); + + await expect + .element(screen.getByRole("button", { name: "Saving…" })) + .toBeDisabled(); + expect(api.calls("POST", "/api/mental-health/assessments")).toHaveLength(1); + expect(api.calls("POST", "/api/mental-health/assessments")[0].body).toEqual( + { + instrument: "WHO5", + locale: "en", + items: [4, 4, 3, 5, 4], + englishOriginal: false, + }, + ); + + post.release({ + status: 201, + data: { + assessment: { + id: "a-1", + instrument: "WHO5", + locale: "en", + wording: "validated", + totalScore: 80, + severityBand: "good", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-09-15T10:00:00.000Z", + }, + actionThreshold: 50, + crisis: null, + }, + }); + await expect + .element(screen.getByText("80", { exact: true }).first()) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "See my result" })) + .not.toBeInTheDocument(); + }); +}); diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index f31aff775..dc9925453 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -6,15 +6,14 @@ import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import en from "../../../../messages/en.json"; import nl from "../../../../messages/nl.json"; -import { WORDING } from "@/lib/mental-health/wording"; import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording"; /** - * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention - * (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`) - * means clicks can't be driven here — the wizard's advance/back grammar is - * pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable - * contracts: the disclaimer renders ONLY on the landing (never while testing), + * Mental-wellbeing surface redesign (v1.25.3). The check-in wizard itself is + * driven in a real browser by `check-in-wizard.browser.test.tsx` (steps, + * keys, review, follow-up, the unvalidated-translation note, the posted + * body); its step arithmetic is pinned in `check-in-nav.test.ts`. These tests + * pin the rendered contracts of the rest of the surface: the disclaimer renders ONLY on the landing (never while testing), * a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is * intro + instrument cards only — the dated history + trend chart live in the * per-instrument detail a card click opens, pinned to one instrument. @@ -74,52 +73,6 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => { // …and the lead description still introduces the screeners. expect(html).toContain(mh.pageDescription); }); - - it("does NOT render any disclaimer inside the check-in wizard", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - expect(html).not.toContain('data-slot="mental-health-disclaimer"'); - // …but the standardized instrument explanation IS shown. - expect(html).toContain(mh.instrumentDescription.phq9); - }); -}); - -describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - - it("no longer renders the question-overview strip", () => { - expect(html).not.toContain('data-slot="wizard-stepper"'); - }); - - it("no longer renders the review recap or the functional follow-up", () => { - expect(html).not.toContain("You have answered"); - expect(html).not.toContain('data-slot="check-in-submit"'); - }); - - it("opens on the first question with the Back/Next footer", () => { - expect(html).toContain(WORDING.PHQ9.en.items[0]); - expect(html).toContain('data-slot="check-in-back"'); - expect(html).toContain('data-slot="check-in-next"'); - // Back is disabled on the first step. - expect(html).toMatch( - /data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/, - ); - }); }); describe("item-9 crisis surfacing on the result", () => { @@ -243,49 +196,6 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { expect(html).not.toContain("Rhythmus"); }); - it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - expect(html).toContain(WORDING.WHO5.en.items[0]); - expect(html).toContain('data-slot="check-in-stem"'); - expect(html).toContain(WORDING.WHO5.en.stems.period); - // Six anchors, 5 → 0 (the WHO form leads with "All of the time"). - for (const v of [5, 4, 3, 2, 1, 0]) { - expect(html).toContain(WORDING.WHO5.en.anchors.all[v]); - } - // English items are the validated wording for en — no translation note. - expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); - expect(html).not.toContain('data-slot="check-in-wording-toggle"'); - // No functional follow-up outside the PHQ-9: 5 steps total. - expect(html).toContain("Question 1 of 5"); - }); - - it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - expect(html).toContain(WORDING.SCI.en.items[0]); - expect(html).toContain(WORDING.SCI.en.stems.night); - // Item 1 carries the sleep-latency time ranges, 4 → 0. - for (const v of [4, 3, 2, 1, 0]) { - expect(html).toContain(WORDING.SCI.en.anchors.duration[v]); - } - expect(html).toContain("Question 1 of 8"); - }); - it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => { const result: CreateResponse = { assessment: { @@ -530,23 +440,20 @@ describe("questionnaires in the reader's language", () => { ); } - function wizard(instrument: "PHQ9" | "SCI") { - return ( - {}} - onBack={() => {}} - isPending={false} - isError={false} - /> - ); - } - + // Kept here rather than in the browser suite: the frame before the + // language's chunk arrives is the one a server render always paints, and a + // browser loads the chunk before a test can look. it("holds the step's place while the language's text loads", () => { const html = renderToStaticMarkup( - {wizard("SCI")} + {}} + onBack={() => {}} + isPending={false} + isError={false} + /> , ); @@ -555,30 +462,6 @@ describe("questionnaires in the reader's language", () => { expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); }); - it("the PHQ-9 in Dutch is the validated Dutch form, with no note and no switch", () => { - const html = renderIn("nl", wizard("PHQ9")); - const dutch = WORDING.PHQ9.nl; - expect(html).toContain(dutch.stems.period); - expect(html).toContain(dutch.items[0]); - for (const v of [0, 1, 2, 3]) expect(html).toContain(dutch.anchors.all[v]); - expect(html).not.toContain(WORDING.PHQ9.en.items[0]); - expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); - expect(html).not.toContain('data-slot="check-in-wording-toggle"'); - }); - - it("the SCI in Dutch shows the translation under the note, with the switch to the English original", () => { - const html = renderIn("nl", wizard("SCI")); - const dutch = WORDING.SCI.nl; - expect(dutch.validated).toBe(false); - expect(html).toContain(dutch.stems.night); - expect(html).toContain(dutch.items[0]); - expect(html).not.toContain(WORDING.SCI.en.items[0]); - expect(html).toContain('data-slot="check-in-unvalidated-note"'); - expect(html).toContain(nl.mentalHealth.wording.unvalidatedNote); - expect(html).toContain('data-slot="check-in-wording-toggle"'); - expect(html).toContain(nl.mentalHealth.wording.showOriginal); - }); - it("the result and the history mark an unvalidated translation, and only that", () => { const row = (wording: AssessmentRow["wording"]): AssessmentRow => ({ id: `s-${wording}`, diff --git a/vitest.config.mts b/vitest.config.mts index 51fa3430b..995c34e16 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -129,6 +129,15 @@ export default defineConfig({ ), }, }, + // Next's client modules (`next/link` among them) read + // `process.env.*`, which Next's own bundler inlines at build time. + // A page has no `process`; this inlines an environment with no + // public variables set, as a build without any would. + define: { + "process.env": JSON.stringify({ + NODE_ENV: process.env.NODE_ENV ?? "test", + }), + }, // Pre-bundle every dependency the browser files reach before the // first one loads. Discovered late, a dependency makes Vite re-bundle // and reload the page mid-run, which fails the file that was