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