From ee63b09e59b269c86027880d3266ea50511a2c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:53:44 +0200 Subject: [PATCH] Rebuild the score settings card the way the other settings cards are built Save moves out of the header and into its own row at the bottom right, where every other settings card that saves a form puts it. The body starts on the title's reading edge instead of the card edge, so the text lines up with the cards above and below it. The rules between the rows are gone; the gap between groups does the grouping. Every row said 'Being recorded - Shown in the app' under the pillar name, which is the modules screen's business and was the same two words on every line - the sentence at the top of the card already says it once. The paragraph explaining that heart, glucose and cholesterol are three pillars in one area is gone too. The area headings read in the content colour now rather than the muted one, so they stop looking switched off beside the rows they head. --- .../score-section-saved-selection.test.tsx | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/components/settings/__tests__/score-section-saved-selection.test.tsx diff --git a/src/components/settings/__tests__/score-section-saved-selection.test.tsx b/src/components/settings/__tests__/score-section-saved-selection.test.tsx new file mode 100644 index 000000000..2e92203aa --- /dev/null +++ b/src/components/settings/__tests__/score-section-saved-selection.test.tsx @@ -0,0 +1,213 @@ +/** + * What the score settings page does with a selection that is already + * stored, and what it puts on the wire when you press Save. + * + * The v1.35.0 defect this pins the fix for: the page filtered the + * selection on its way to the server, so the answer that came back could + * never match the form that sent it. The switches then re-seeded from the + * server's narrower list — a pillar the person had never touched read as + * switched off — and the draft could never equal the server copy again, + * which left Save lit and the card claiming unsaved changes forever. + * + * Two ends and the pipe between them: + * + * - the rows a stored selection renders (`data-counts` per pillar), + * - the state of the Save button, which is the whole "did this settle" + * question in one attribute, + * - the body the mutation actually hands to `apiPatch`, driven through + * the component's own `mutationFn` rather than restated here. + * + * SSR markup, matching the rest of the settings suite. The draft re-seed + * happens in render, so a static render is enough to see it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "@/lib/analytics/score/types"; + +const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS]; + +/** The resolved config the page's primary read returns. */ +const configState: { pillars: ScorePillarId[] } = { pillars: [...ALL] }; + +/** Every `mutationFn` the component registered, newest last. */ +const mutationFns: Array<(input: never) => unknown> = []; + +const apiPatch = vi.fn(async () => ({ pillars: configState.pillars })); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + // The factory's key, not a guess: `queryKeys.healthScoreConfig()`. + if ( + Array.isArray(queryKey) && + queryKey[0] === "settings" && + queryKey[1] === "health-score-config" + ) { + return { + data: { + pillars: configState.pillars, + excludedPillars: ALL.filter( + (id) => !configState.pillars.includes(id), + ), + hasSelection: true, + version: 1, + changedAt: null, + updatedAt: "2026-08-01T00:00:00.000Z", + }, + isSuccess: true, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + } + return { + data: undefined, + isSuccess: false, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + }, + useMutation: (options: { mutationFn?: (input: never) => unknown }) => { + if (options.mutationFn) mutationFns.push(options.mutationFn); + return { mutate: vi.fn(), isPending: false }; + }, + useQueryClient: () => ({ + setQueryData: vi.fn(), + getQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +vi.mock("sonner", () => ({ + toast: { message: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@/components/outcome/outcome-toast", () => ({ + toastWrittenOutcome: vi.fn(), +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPatch: (...args: unknown[]) => + (apiPatch as unknown as (...a: unknown[]) => unknown)(...args), + ApiError: class ApiError extends Error {}, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false, user: {} }), +})); + +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => ({ + data: undefined, + isError: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ScoreSection } from "../score-section"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +/** `data-counts` per pillar, read off the rendered rows. */ +function countsByPillar(html: string): Record { + const out: Record = {}; + for (const match of html.matchAll( + /data-pillar="([A-Z_]+)"[^>]*data-counts="(true|false)"/g, + )) { + out[match[1]] = match[2]; + } + return out; +} + +function saveIsDisabled(html: string): boolean { + const at = html.indexOf('data-slot="score-config-save"'); + expect(at, "save button is rendered").toBeGreaterThan(-1); + const open = html.lastIndexOf("", at)).includes("disabled="); +} + +beforeEach(() => { + configState.pillars = [...ALL]; + mutationFns.length = 0; + apiPatch.mockClear(); +}); + +describe("a selection that is already stored", () => { + it("shows every pillar the person kept switched on, and only those", () => { + configState.pillars = ALL.filter( + (id) => id !== "SLEEP" && id !== "WELLBEING", + ); + + const counts = countsByPillar(render()); + + expect(Object.keys(counts).sort()).toEqual([...ALL].sort()); + for (const id of ALL) { + expect(counts[id], `${id} switch`).toBe( + configState.pillars.includes(id) ? "true" : "false", + ); + } + }); + + it("has nothing to save the moment it loads", () => { + configState.pillars = ALL.filter((id) => id !== "SLEEP"); + + expect(saveIsDisabled(render())).toBe(true); + }); + + it("settles on the every-pillar starting point too", () => { + // "All pillars" has to be a selection the page can hold: if the save + // silently narrowed it, the form would come back different from the + // list it sent and never read as saved again. + configState.pillars = [...ALL]; + const html = render(); + + expect(Object.values(countsByPillar(html))).toEqual(ALL.map(() => "true")); + expect(saveIsDisabled(html)).toBe(true); + }); +}); + +describe("what the save puts on the wire", () => { + it("sends the selection unchanged, pillar for pillar", async () => { + render(); + const save = mutationFns[0]; + expect(save, "the save mutation registered a mutationFn").toBeTypeOf( + "function", + ); + + await (save as (input: ScorePillarId[]) => unknown)([...ALL]); + + expect(apiPatch).toHaveBeenCalledTimes(1); + const [path, body] = apiPatch.mock.calls[0] as unknown as [ + string, + { pillars: ScorePillarId[] }, + ]; + expect(path).toBe("/api/auth/me/health-score-config"); + expect(body.pillars).toEqual(ALL); + }); + + it("puts the selection in registry order whatever order it arrives in", async () => { + render(); + const save = mutationFns[0] as (input: ScorePillarId[]) => unknown; + + await save(["LIPIDS", "ACTIVITY", "BLOOD_PRESSURE"]); + + const [, body] = apiPatch.mock.calls[0] as unknown as [ + string, + { pillars: ScorePillarId[] }, + ]; + expect(body.pillars).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "LIPIDS"]); + }); +});