diff --git a/src/app/__tests__/sheet-dirty-check.test.ts b/src/app/__tests__/sheet-dirty-check.test.ts new file mode 100644 index 000000000..188dc4fbb --- /dev/null +++ b/src/app/__tests__/sheet-dirty-check.test.ts @@ -0,0 +1,140 @@ +/** + * v1.11.5 — `sheetBodyHasUnsavedInput` dirty-check coverage. + * + * The quick-entry sheets confirm-on-dismiss only when the body holds unsaved + * input. The original walk read `input` / `textarea` only, so a user who + * changed just the mood `role="radio"` selector or opened a Radix Select + * (type / medication) could lose the change on a backdrop dismiss with no + * confirm. These cases lock the extended detection in — and pin the + * over-trigger guard so a pristine sheet (no radio chosen, every Select on its + * default and closed) still reads clean. + * + * The repo runs vitest in the Node environment (no jsdom / happy-dom). The + * function only ever touches `document.querySelector` once (the sheet body) and + * then `body.querySelectorAll` / `body.querySelector` against three fixed + * selectors, so we drive it with a hand-rolled fake `document` that answers + * exactly those selectors — no DOM engine, matching the repo's node-only test + * convention (cf. the SSR-string tests elsewhere). + */ +import { afterEach, describe, expect, it } from "vitest"; +import { sheetBodyHasUnsavedInput } from "../page"; + +const BODY_SELECTOR = '[data-slot="responsive-sheet-body"]'; +const MOOD_RADIO_SELECTOR = '[role="radio"][aria-checked="true"]'; +const OPEN_SELECT_SELECTOR = '[role="combobox"][data-state="open"]'; + +interface FakeField { + type: string; + value?: string; + disabled?: boolean; + checked?: boolean; + defaultChecked?: boolean; +} + +interface SheetFixture { + fields?: FakeField[]; + moodRadioSelected?: boolean; + openSelect?: boolean; +} + +/** Install a fake `document` whose queries answer the given fixture. */ +function installFakeDocument(fixture: SheetFixture | null): void { + const body = fixture + ? { + querySelectorAll: (sel: string) => + sel === "input, textarea" ? (fixture.fields ?? []) : [], + querySelector: (sel: string) => { + if (sel === MOOD_RADIO_SELECTOR) + return fixture.moodRadioSelected ? {} : null; + if (sel === OPEN_SELECT_SELECTOR) + return fixture.openSelect ? {} : null; + return null; + }, + } + : null; + + (globalThis as { document?: unknown }).document = { + querySelector: (sel: string) => (sel === BODY_SELECTOR ? body : null), + }; +} + +afterEach(() => { + delete (globalThis as { document?: unknown }).document; +}); + +describe("sheetBodyHasUnsavedInput — mood radio", () => { + it("is clean when no mood radio is selected (pristine)", () => { + installFakeDocument({ moodRadioSelected: false }); + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); + + it("is dirty when a mood radio is selected", () => { + installFakeDocument({ moodRadioSelected: true }); + expect(sheetBodyHasUnsavedInput()).toBe(true); + }); +}); + +describe("sheetBodyHasUnsavedInput — Radix Select", () => { + it("is clean for a closed Select on its default value (no over-trigger)", () => { + // A measurement-type / medication Select mounts with a non-placeholder + // DEFAULT and is closed — a pristine sheet must NOT prompt the confirm. + installFakeDocument({ openSelect: false }); + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); + + it("is dirty while a Select dropdown is open (mid-interaction)", () => { + installFakeDocument({ openSelect: true }); + expect(sheetBodyHasUnsavedInput()).toBe(true); + }); +}); + +describe("sheetBodyHasUnsavedInput — text fields still honoured", () => { + it("is clean for an empty pristine sheet", () => { + installFakeDocument({ + fields: [ + { type: "text", value: "" }, + { type: "textarea", value: "" }, + { type: "datetime-local", value: "2026-06-04T08:00" }, + ], + }); + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); + + it("is dirty when a text input carries a value", () => { + installFakeDocument({ fields: [{ type: "text", value: "75.5" }] }); + expect(sheetBodyHasUnsavedInput()).toBe(true); + }); + + it("ignores a prefilled date/time picker (v1.11.4 H1)", () => { + installFakeDocument({ + fields: [{ type: "datetime-local", value: "2026-06-04T08:00" }], + }); + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); + + it("ignores a disabled field carrying a value", () => { + installFakeDocument({ + fields: [{ type: "text", value: "ignored", disabled: true }], + }); + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); + + it("is dirty for a native checkbox toggled off its default", () => { + installFakeDocument({ + fields: [{ type: "checkbox", checked: true, defaultChecked: false }], + }); + expect(sheetBodyHasUnsavedInput()).toBe(true); + }); +}); + +describe("sheetBodyHasUnsavedInput — no sheet body", () => { + it("is clean when no sheet body is mounted", () => { + installFakeDocument(null); + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); + + it("is clean when document is undefined (SSR)", () => { + delete (globalThis as { document?: unknown }).document; + expect(sheetBodyHasUnsavedInput()).toBe(false); + }); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index b98f7b105..6aec28a80 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -88,7 +88,7 @@ const PREFILLED_PICKER_TYPES = new Set([ "month", "week", ]); -function sheetBodyHasUnsavedInput(): boolean { +export function sheetBodyHasUnsavedInput(): boolean { if (typeof document === "undefined") return false; const body = document.querySelector( '[data-slot="responsive-sheet-body"]', @@ -109,6 +109,30 @@ function sheetBodyHasUnsavedInput(): boolean { } if (field.value.trim() !== "") return true; } + // v1.11.5 — the input/textarea walk misses two non-text controls a user can + // change before dismissing: the mood `role="radio"` selector and a Radix + // Select (measurement type / medication). Losing either silently on a + // backdrop-dismiss is the same data-loss class the H1 confirm-on-dirty guard + // was meant to close. + // + // Mood radios start with nothing selected (`mood = ""`), so ANY + // `aria-checked="true"` is a deliberate user choice — flag it. + if ( + body.querySelector('[role="radio"][aria-checked="true"]') !== null + ) { + return true; + } + // Radix Select trigger renders `role="combobox"`. A trigger with + // `data-state="open"` is mid-interaction (the dropdown is up) — the user is + // actively choosing, so a dismiss should confirm rather than drop the pick. + // We deliberately do NOT treat a merely non-placeholder value as dirty: the + // type / medication selects mount with a non-placeholder DEFAULT, and + // flagging that would over-trigger the confirm on a pristine sheet. + if ( + body.querySelector('[role="combobox"][data-state="open"]') !== null + ) { + return true; + } return false; }