diff --git a/src/components/onboarding/__tests__/units-screen.test.tsx b/src/components/onboarding/__tests__/units-screen.test.tsx new file mode 100644 index 000000000..9dfeb5de5 --- /dev/null +++ b/src/components/onboarding/__tests__/units-screen.test.tsx @@ -0,0 +1,82 @@ +/** + * v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k). + * + * "Weiter" used to be live on an untouched units screen: it wrote + * `{ units: {} }`, which marked the step DONE without recording a preference, + * so the account carried "asked and answered" for a question nobody answered. + * Skip is the way past a question with no opinion, and Skip says so on the + * ledger. + * + * Rendered server-side, this repo's component-test convention. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +import { UnitsScreen } from "../units-screen"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null }), +})); + +function state( + needs: Partial, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs }, + completedAt: null, + firstResult: null, + }; +} + +function render(needs: Partial): string { + return renderToStaticMarkup( + + + + + , + ); +} + +/** + * Is the primary button's own tag disabled? Read off the tag rather than the + * whole page, and off the attribute rather than the string "disabled", which + * also occurs in the button's `disabled:` utility classes. + */ +function nextIsDisabled(html: string): boolean { + const at = html.indexOf('data-slot="onboarding-next"'); + expect(at).toBeGreaterThan(-1); + const open = html.lastIndexOf("", at) + 1).includes('disabled=""'); +} + +describe("", () => { + it("does not let the person continue without choosing a unit", () => { + const html = render({ areas: ["glucose"] }); + expect(nextIsDisabled(html)).toBe(true); + // Skip is still there: it is the honest way past a question with no + // opinion, and it says "skipped" on the ledger. + expect(html).toContain('data-slot="onboarding-skip"'); + }); + + it("lets the person continue once a unit is chosen", () => { + const html = render({ + areas: ["glucose"], + units: { glucoseUnit: "mg/dL", unitPreference: null }, + }); + expect(nextIsDisabled(html)).toBe(false); + }); +}); diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index b2fec48bd..e4be7672d 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -54,9 +54,15 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { const back = previousScreen(state, "units"); + const answerBody = unitsAnswerBody({ asked, glucoseUnit, unitPreference }); + async function persist( input: - ReturnType | { step: "units"; status: "skipped" }, + | NonNullable> + | { + step: "units"; + status: "skipped"; + }, ) { try { const written = await answer.mutateAsync(input); @@ -121,9 +127,10 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { void persist({ step: "units", status: "skipped" })} - onNext={() => - void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference })) - } + onNext={() => { + if (answerBody) void persist(answerBody); + }} + nextDisabled={answerBody === null} pending={answer.isPending} /> diff --git a/src/lib/onboarding/__tests__/question-config.test.ts b/src/lib/onboarding/__tests__/question-config.test.ts index 0151f4204..98c14d46a 100644 --- a/src/lib/onboarding/__tests__/question-config.test.ts +++ b/src/lib/onboarding/__tests__/question-config.test.ts @@ -80,11 +80,15 @@ describe("questionAnswerBody", () => { expect(questionAnswerBody("visit", [])).toBeNull(); }); - it("sends an empty list for a many-answer question with nothing chosen", () => { - expect(questionAnswerBody("areas", [])).toEqual({ - step: "areas", - areas: [], - }); + it("is null for a many-answer question with nothing chosen (I4)", () => { + // An empty tick list is not an opinion. It used to be sent as an + // ANSWER, and an answered Q2 with no areas is what switched every + // optional module off for somebody who meant "no preference" — Skip is + // the way past, and Skip is the conservative derivation. + expect(questionAnswerBody("areas", [])).toBeNull(); + expect(questionAnswerBody("sources", [])).toBeNull(); + // A list of values none of which the step offers is just as empty. + expect(questionAnswerBody("areas", ["no-such-area"])).toBeNull(); }); it("drops a value the step's vocabulary does not contain", () => { @@ -125,13 +129,22 @@ describe("units", () => { expect(onboardingAnswerSchema.safeParse(body).success).toBe(true); }); - it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => { + it("is null when nothing was chosen, so Skip is the way past (M-k)", () => { + // An empty units answer marked Q6 done without recording a preference. expect( unitsAnswerBody({ asked: { glucose: true, weight: true }, glucoseUnit: null, unitPreference: null, }), - ).toEqual({ step: "units", units: {} }); + ).toBeNull(); + // One of two asked questions answered is still an answer. + expect( + unitsAnswerBody({ + asked: { glucose: true, weight: true }, + glucoseUnit: "mg/dL", + unitPreference: null, + }), + ).toEqual({ step: "units", units: { glucoseUnit: "mg/dL" } }); }); }); diff --git a/src/lib/onboarding/question-config.ts b/src/lib/onboarding/question-config.ts index 547b28cd0..e3ea93734 100644 --- a/src/lib/onboarding/question-config.ts +++ b/src/lib/onboarding/question-config.ts @@ -110,9 +110,15 @@ function only( /** * The body for one answered step, or null when the selection cannot be an - * answer (Q1 with nothing chosen). Every arm names its field; a value that is - * not in the step's vocabulary is dropped rather than sent, so the strict - * server schema never sees a chip this file did not offer. + * answer. Every arm names its field; a value that is not in the step's + * vocabulary is dropped rather than sent, so the strict server schema never + * sees a chip this file did not offer. + * + * v1.39 (Wave C, C3) — an empty tick list is not an answer either (research + * I4). The many-answer arms used to send `[]` and mark the step DONE, and an + * answered Q2 with no areas is what switched every optional module off for + * somebody whose click meant "no preference". Skip is the way past a question + * nobody wants to answer, and Skip is the conservative derivation. */ export function questionAnswerBody( step: QuestionStepId, @@ -123,14 +129,18 @@ export function questionAnswerBody( const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected); return recordTarget ? { step, recordTarget } : null; } - case "areas": - return { step, areas: only(ONBOARDING_AREA_KEYS, selected) }; + case "areas": { + const areas = only(ONBOARDING_AREA_KEYS, selected); + return areas.length > 0 ? { step, areas } : null; + } case "medication": { const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected); return medication ? { step, medication } : null; } - case "sources": - return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) }; + case "sources": { + const sources = only(ONBOARDING_SOURCE_KEYS, selected); + return sources.length > 0 ? { step, sources } : null; + } case "visit": { const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected); return visit ? { step, visit } : null; @@ -171,7 +181,7 @@ export function unitsAnswerBody(input: { asked: { glucose: boolean; weight: boolean }; glucoseUnit: "mg/dL" | "mmol/L" | null; unitPreference: "metric" | "imperial" | null; -}): OnboardingAnswerInput { +}): OnboardingAnswerInput | null { const units: { glucoseUnit?: "mg/dL" | "mmol/L"; unitPreference?: "metric" | "imperial"; @@ -182,5 +192,7 @@ export function unitsAnswerBody(input: { if (input.asked.weight && input.unitPreference) { units.unitPreference = input.unitPreference; } - return { step: "units", units }; + // v1.39 (Wave C, C3) — nothing chosen is not an answer (M-k): an empty + // units body marked Q6 done while recording no preference at all. + return Object.keys(units).length > 0 ? { step: "units", units } : null; }