import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SleepEntry, SleepEntryInput, SleepMedication, SleepSnapshot, } from "../api/sleepDiary"; import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace"; const api = vi.hoisted(() => ({ deleteSleepEntry: vi.fn(), fetchSleepDiary: vi.fn(), fetchSleepMedications: vi.fn(), saveSleepEntry: vi.fn(), saveSleepMedication: vi.fn(), validateSleepEntry: vi.fn(), nextId: 0, })); vi.mock("../api/sleepDiary", async (importOriginal) => { const original = await importOriginal(); return { ...original, deleteSleepEntry: api.deleteSleepEntry, fetchSleepDiary: api.fetchSleepDiary, fetchSleepMedications: api.fetchSleepMedications, saveSleepEntry: api.saveSleepEntry, saveSleepMedication: api.saveSleepMedication, validateSleepEntry: api.validateSleepEntry, newSleepId: (prefix: "sle" | "mdi") => `${prefix}_00000000-0000-4000-8000-${String(++api.nextId).padStart(12, "0")}`, }; }); const emptySnapshot: SleepSnapshot = { api_version: 1, entries: [], summary: { nights: 0, long_awake_count: 0, nap_count: 0, sleepiness_count: 0, intake_count: 0, sleep_duration_seconds: 0, long_awake_duration_seconds: 0, nap_duration_seconds: 0, average_bed_minute: null, average_get_up_minute: null, }, }; const medication: SleepMedication = { medication_id: "med_00000000-0000-4000-8000-000000000001", revision_id: "mr_00000000-0000-4000-8000-000000000001", name: "TestMed", default_dose_value: 5, default_dose_unit: "mg", form: "", note: "", active: true, }; const snapshotWith = (entries: SleepEntry[]): SleepSnapshot => { const duration = (start: string, end: string | null) => end === null ? 0 : (Date.parse(end) - Date.parse(start)) / 1000; const events = entries.flatMap((entry) => entry.events); const selected = (type: string) => events.filter((event) => event.type === type); return { api_version: 1, entries, summary: { nights: entries.length, long_awake_count: selected("long_awake").length, nap_count: selected("nap").length, sleepiness_count: selected("daytime_sleepiness").length, intake_count: entries.reduce( (total, entry) => total + entry.intakes.length, 0, ), sleep_duration_seconds: selected("sleep").reduce( (total, event) => total + duration(event.start_at, event.end_at), 0, ), long_awake_duration_seconds: selected("long_awake").reduce( (total, event) => total + duration(event.start_at, event.end_at), 0, ), nap_duration_seconds: selected("nap").reduce( (total, event) => total + duration(event.start_at, event.end_at), 0, ), average_bed_minute: null, average_get_up_minute: null, }, }; }; const persistedEntry = ( input: SleepEntryInput, entryId: string, revisionId: string, ): SleepEntry => ({ ...input, entry_id: entryId, revision_id: revisionId, publication_status: "draft", events: input.events.map((event) => ({ ...event })), intakes: input.intakes.map((intake) => ({ ...intake })), }); describe("SleepDiaryWorkspace", () => { beforeEach(() => { vi.clearAllMocks(); api.nextId = 0; api.fetchSleepDiary.mockResolvedValue(emptySnapshot); api.fetchSleepMedications.mockResolvedValue([]); let revision = 0; api.saveSleepEntry.mockImplementation(async (input) => ({ entry_id: input.entry_id || "sd_00000000-0000-4000-8000-000000000001", revision_id: `sdr_00000000-0000-4000-8000-${String(++revision).padStart(12, "0")}`, })); }); it.each([ ["bed_time", "Mise au lit", "22:30", null], ["sleep", "Sommeil", "23:00", "06:30"], ["long_awake", "Long réveil", "03:00", "03:30"], ["final_get_up", "Lever", "07:00", null], ["nap", "Sieste", "14:00", "14:30"], ["daytime_sleepiness", "Somnolence", "15:00", null], ])( "keeps the workspace mounted after adding %s and advances the same entry revision", async (type, label, start, end) => { render(); await screen.findByTestId("sleep-workspace"); fireEvent.change(screen.getByTestId("sleep-event-type"), { target: { value: type }, }); fireEvent.change(screen.getByTestId("sleep-event-start"), { target: { value: start }, }); if (end !== null) fireEvent.change(screen.getByTestId("sleep-event-end"), { target: { value: end }, }); fireEvent.click(screen.getByTestId("sleep-add-event")); expect( await screen.findByTestId(`sleep-event-${type}`), ).toHaveTextContent(label); expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument(); expect(screen.getByTestId("sleep-add-event")).toBeEnabled(); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(1)); expect(api.saveSleepEntry.mock.calls[0][0]).toMatchObject({ entry_id: "", expected_revision: null, }); fireEvent.click(screen.getByTestId("sleep-add-event")); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2)); expect(api.saveSleepEntry.mock.calls[1][0]).toMatchObject({ entry_id: "sd_00000000-0000-4000-8000-000000000001", expected_revision: "sdr_00000000-0000-4000-8000-000000000001", }); expect( screen.queryByText("Impossible d’afficher cette section."), ).not.toBeInTheDocument(); }, ); it("adds a medication, exposes it immediately for an intake, and supports edit and delete mutations", async () => { api.fetchSleepMedications .mockResolvedValueOnce([]) .mockResolvedValue([medication]); render(); await screen.findByText("Aucun médicament enregistré."); fireEvent.click( screen.getByRole("button", { name: "+ Ajouter un médicament" }), ); fireEvent.change(screen.getByTestId("sleep-medication-name"), { target: { value: "TestMed" }, }); fireEvent.change(screen.getByTestId("sleep-medication-dose"), { target: { value: "5" }, }); fireEvent.click(screen.getByTestId("sleep-add-medication")); const medicationList = await screen.findByTestId("sleep-medication-list"); expect(within(medicationList).getByText("TestMed")).toBeInTheDocument(); expect(screen.getByTestId("sleep-intake-medication")).toHaveTextContent( "TestMed", ); fireEvent.change(screen.getByTestId("sleep-intake-medication"), { target: { value: medication.medication_id }, }); fireEvent.click(screen.getByTestId("sleep-add-intake")); const intake = await screen.findByTestId("sleep-intake-row"); expect(intake).toHaveTextContent("TestMed"); expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument(); fireEvent.change(within(intake).getByLabelText("Heure"), { target: { value: "22:15" }, }); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2)); fireEvent.click( within(intake).getByRole("button", { name: "Supprimer TestMed" }), ); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(3)); expect(screen.queryByTestId("sleep-intake-row")).not.toBeInTheDocument(); }); it("modifies and removes an event without losing the workspace", async () => { render(); await screen.findByTestId("sleep-workspace"); fireEvent.change(screen.getByTestId("sleep-event-type"), { target: { value: "sleep" }, }); fireEvent.click(screen.getByTestId("sleep-add-event")); const row = await screen.findByTestId("sleep-event-sleep"); fireEvent.change(within(row).getByLabelText("Début"), { target: { value: "23:00" }, }); fireEvent.click(within(row).getByRole("button", { name: "Modifier" })); fireEvent.click( within(row).getByRole("button", { name: "Supprimer Sommeil" }), ); await waitFor(() => expect(screen.queryByTestId("sleep-event-sleep")).not.toBeInTheDocument(), ); expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument(); expect(screen.getByTestId("sleep-add-event")).toBeEnabled(); }); it("projects every persisted revision live and keeps distinct medication doses", async () => { let persisted: SleepEntry[] = []; let catalog: SleepMedication[] = []; let revision = 0; api.fetchSleepDiary.mockImplementation(async () => snapshotWith(persisted)); api.fetchSleepMedications.mockImplementation(async () => catalog); api.saveSleepEntry.mockImplementation(async (input: SleepEntryInput) => { const entryId = input.entry_id || "sd_00000000-0000-4000-8000-000000000001"; const revisionId = `sdr_00000000-0000-4000-8000-${String(++revision).padStart(12, "0")}`; persisted = [persistedEntry(input, entryId, revisionId)]; return { entry_id: entryId, revision_id: revisionId }; }); api.saveSleepMedication.mockImplementation(async (input) => { const index = catalog.length + 1; const saved: SleepMedication = { ...input, medication_id: `med_00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, revision_id: `mr_00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, }; catalog = [...catalog, saved]; return { medication_id: saved.medication_id, revision_id: saved.revision_id, }; }); render(); await screen.findByText("Aucun médicament enregistré."); const addMedication = async (dose: string) => { fireEvent.click( screen.getByRole("button", { name: "+ Ajouter un médicament" }), ); fireEvent.change(screen.getByTestId("sleep-medication-name"), { target: { value: "venlafaxine" }, }); fireEvent.change(screen.getByTestId("sleep-medication-dose"), { target: { value: dose }, }); fireEvent.click(screen.getByTestId("sleep-add-medication")); await waitFor(() => expect(screen.queryByTestId("sleep-medication-name")).toBeNull(), ); }; await addMedication("75"); await addMedication("37.5"); const medicationSelect = screen.getByTestId("sleep-intake-medication"); expect(medicationSelect).toHaveTextContent("venlafaxine — 75 mg"); expect(medicationSelect).toHaveTextContent("venlafaxine — 37,5 mg"); expect(screen.getAllByText("venlafaxine")).toHaveLength(2); const addIntake = async (medicationId: string, expectedDose: string) => { const calls = api.saveSleepEntry.mock.calls.length; fireEvent.change(medicationSelect, { target: { value: medicationId } }); expect(screen.getByTestId("sleep-intake-dose")).toHaveValue( Number(expectedDose), ); fireEvent.click(screen.getByTestId("sleep-add-intake")); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(calls + 1), ); await waitFor(() => expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( "Enregistré localement", ), ); }; await addIntake(catalog[0].medication_id, "75"); await addIntake(catalog[1].medication_id, "37.5"); expect(screen.getByTestId("sleep-agenda-medication")).toHaveTextContent( "M ×2", ); expect(screen.getByTestId("sleep-agenda-medication")).toHaveAttribute( "title", expect.stringContaining("venlafaxine — 37,5 mg"), ); const addEvent = async (type: string, start: string, end?: string) => { const calls = api.saveSleepEntry.mock.calls.length; fireEvent.change(screen.getByTestId("sleep-event-type"), { target: { value: type }, }); fireEvent.change(screen.getByTestId("sleep-event-start"), { target: { value: start }, }); if (end !== undefined) fireEvent.change(screen.getByTestId("sleep-event-end"), { target: { value: end }, }); fireEvent.click(screen.getByTestId("sleep-add-event")); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(calls + 1), ); await waitFor(() => expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( "Enregistré localement", ), ); }; await addEvent("bed_time", "22:45"); expect(screen.getByTestId("sleep-agenda-bed-time")).toHaveTextContent( "22:45", ); await addEvent("sleep", "23:15", "03:00"); expect(screen.getByTestId("sleep-agenda-sleep-duration")).toHaveTextContent( "3 h 45 min", ); await addEvent("long_awake", "03:00", "03:30"); expect(screen.getByTestId("sleep-agenda-long-awake")).toHaveTextContent( "1 · 30 min", ); await addEvent("sleep", "03:30", "06:45"); expect(screen.getByTestId("sleep-agenda-sleep-duration")).toHaveTextContent( "7 h 00 min", ); await addEvent("final_get_up", "07:10"); expect(screen.getByTestId("sleep-agenda-final-get-up")).toHaveTextContent( "07:10", ); expect(screen.getByTestId("sleep-agenda-time-in-bed")).toHaveTextContent( "8 h 25 min", ); const getUpRow = screen.getByTestId("sleep-event-final_get_up"); fireEvent.change(within(getUpRow).getByLabelText("Heure"), { target: { value: "07:30" }, }); await waitFor(() => expect(screen.getByTestId("sleep-agenda-time-in-bed")).toHaveTextContent( "8 h 45 min", ), ); const sleepRows = screen.getAllByTestId("sleep-event-sleep"); fireEvent.change(within(sleepRows[1]).getByLabelText("Fin"), { target: { value: "07:00" }, }); await waitFor(() => expect( screen.getByTestId("sleep-agenda-sleep-duration"), ).toHaveTextContent("7 h 15 min"), ); fireEvent.click( within(screen.getByTestId("sleep-event-long_awake")).getByRole("button", { name: "Supprimer Long réveil", }), ); await waitFor(() => expect(screen.queryByTestId("sleep-agenda-long-awake")).toBeNull(), ); expect(screen.queryByTestId("sleep-event-long_awake")).toBeNull(); await addEvent("nap", "14:00", "14:35"); expect(screen.getByTestId("sleep-agenda-naps")).toHaveTextContent( "1 · 35 min", ); await addEvent("daytime_sleepiness", "15:00"); expect(screen.getByTestId("sleep-agenda-sleepiness")).toHaveTextContent( "1 occurrence", ); expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument(); }); it("ignores a stale diary read that finishes after a newer persisted revision", async () => { let resolveInitial: ((value: SleepSnapshot) => void) | undefined; const initial = new Promise((resolve) => { resolveInitial = resolve; }); let persisted: SleepEntry[] = []; api.fetchSleepDiary .mockImplementationOnce(async () => initial) .mockImplementation(async () => snapshotWith(persisted)); api.saveSleepEntry.mockImplementation(async (input: SleepEntryInput) => { const result = { entry_id: "sd_00000000-0000-4000-8000-000000000001", revision_id: "sdr_00000000-0000-4000-8000-000000000099", }; persisted = [persistedEntry(input, result.entry_id, result.revision_id)]; return result; }); render(); fireEvent.change(screen.getByTestId("sleep-event-type"), { target: { value: "bed_time" }, }); fireEvent.change(screen.getByTestId("sleep-event-start"), { target: { value: "22:45" }, }); fireEvent.click(screen.getByTestId("sleep-add-event")); expect( await screen.findByTestId("sleep-agenda-event-bed_time"), ).toBeInTheDocument(); resolveInitial?.(emptySnapshot); await Promise.resolve(); expect(screen.getByTestId("sleep-agenda-bed-time")).toHaveTextContent( "22:45", ); }); it("serializes autosaves so the next mutation uses the returned revision", async () => { let resolveFirst: ((value: { entry_id: string; revision_id: string }) => void) | undefined; let persisted: SleepEntry[] = []; const first = new Promise<{ entry_id: string; revision_id: string }>( (resolve) => { resolveFirst = resolve; }, ); api.fetchSleepDiary.mockImplementation(async () => snapshotWith(persisted)); api.saveSleepEntry .mockImplementationOnce(async () => first) .mockImplementationOnce(async (input: SleepEntryInput) => { const result = { entry_id: input.entry_id, revision_id: "sdr_00000000-0000-4000-8000-000000000002", }; persisted = [ persistedEntry(input, result.entry_id, result.revision_id), ]; return result; }); render(); await screen.findByTestId("sleep-workspace"); fireEvent.change(screen.getByTestId("sleep-event-type"), { target: { value: "bed_time" }, }); fireEvent.click(screen.getByTestId("sleep-add-event")); fireEvent.change(screen.getByTestId("sleep-event-type"), { target: { value: "sleep" }, }); fireEvent.click(screen.getByTestId("sleep-add-event")); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(1)); expect(api.saveSleepEntry).toHaveBeenCalledTimes(1); const firstInput = api.saveSleepEntry.mock.calls[0][0] as SleepEntryInput; const firstResult = { entry_id: "sd_00000000-0000-4000-8000-000000000001", revision_id: "sdr_00000000-0000-4000-8000-000000000001", }; persisted = [ persistedEntry(firstInput, firstResult.entry_id, firstResult.revision_id), ]; resolveFirst?.(firstResult); await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2)); expect(api.saveSleepEntry.mock.calls[1][0]).toMatchObject({ entry_id: firstResult.entry_id, expected_revision: firstResult.revision_id, }); await waitFor(() => expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( "Enregistré localement", ), ); expect( screen.getByTestId("sleep-agenda-event-bed_time"), ).toBeInTheDocument(); expect(screen.getByTestId("sleep-agenda-event-sleep")).toBeInTheDocument(); }); });