import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SleepEntry, SleepEntryInput, SleepMedication, SleepSnapshot, } from "../api/sleepDiary"; import * as pdfReport from "../report/sleepDiaryPdf"; 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, })); const heartRateApi = vi.hoisted(() => ({ fetchHeartRateTimeline: vi.fn(), })); const sportApi = vi.hoisted(() => ({ fetchSportSessions: vi.fn() })); vi.mock("../api/sportSessions", () => ({ fetchSportSessions: sportApi.fetchSportSessions, })); 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")}`, }; }); vi.mock("../api/heartRate", async (importOriginal) => { const original = await importOriginal(); return { ...original, fetchHeartRateTimeline: heartRateApi.fetchHeartRateTimeline }; }); 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 })), }); const dayAEntry = (): SleepEntry => ({ entry_id: "sd_00000000-0000-4000-8000-000000000020", revision_id: "sdr_00000000-0000-4000-8000-000000000020", night_start_date: "2026-09-20", night_end_date: "2026-09-21", created_at: "2026-09-20T18:00:00+02:00", updated_at: "2026-09-21T16:00:00+02:00", sleep_quality: "B", wake_quality: "TB", day_form: "Moy", treatment_and_notes: "Observation du jour A", publication_status: "ready", events: [ { event_id: "sle_bed", type: "bed_time", start_at: "2026-09-20T22:30:00+02:00", end_at: null, }, { event_id: "sle_sleep", type: "sleep", start_at: "2026-09-20T22:30:00+02:00", end_at: "2026-09-21T03:30:00+02:00", }, { event_id: "sle_up", type: "final_get_up", start_at: "2026-09-21T04:45:00+02:00", end_at: null, }, { event_id: "sle_sleepiness", type: "daytime_sleepiness", start_at: "2026-09-21T15:30:00+02:00", end_at: null, }, ], intakes: [ { intake_id: "mdi_day_a", medication_id: medication.medication_id, medication_name: medication.name, taken_at: "2026-09-20T22:30:00+02:00", dose_value: 5, dose_unit: "mg", quantity: 1, note: "", created_at: "2026-09-20T22:30:00+02:00", }, ], }); describe("SleepDiaryWorkspace", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00")); vi.clearAllMocks(); api.nextId = 0; api.fetchSleepDiary.mockResolvedValue(emptySnapshot); api.fetchSleepMedications.mockResolvedValue([]); sportApi.fetchSportSessions.mockResolvedValue([]); heartRateApi.fetchHeartRateTimeline.mockImplementation(async (contextId) => ({ api_version: 1, context_id: contextId, available: false, capture: null, events: [], guidance: null, calibration: null, })); 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")}`, })); }); afterEach(() => { vi.useRealTimers(); }); it("renders every deduplicated period night and changes presentation selection", async () => { const first = dayAEntry(); const second = { ...dayAEntry(), entry_id: "sl_00000000-0000-4000-8000-000000000021", night_start_date: "2026-09-18", night_end_date: "2026-09-19", }; const third = { ...dayAEntry(), entry_id: "sl_00000000-0000-4000-8000-000000000022", night_start_date: "2026-09-19", night_end_date: "2026-09-20", }; api.fetchSleepDiary.mockResolvedValue(snapshotWith([first, third, second, third])); render(); await screen.findByTestId(`sleep-agenda-row-${first.entry_id}`); expect(screen.getAllByTestId(/^sleep-agenda-row-/)).toHaveLength(3); const secondRow = screen.getByTestId(`sleep-agenda-row-${second.entry_id}`); fireEvent.click(secondRow); expect(secondRow).toHaveClass("sleep-row-selected"); expect(screen.getByTestId("sleep-night-start")).toHaveValue("2026-09-18"); expect(screen.getByTestId("sleep-night-end")).toHaveValue("2026-09-19"); await waitFor(() => expect(heartRateApi.fetchHeartRateTimeline).toHaveBeenLastCalledWith( second.entry_id, expect.any(AbortSignal), ), ); expect(api.saveSleepEntry).not.toHaveBeenCalled(); expect(screen.getByLabelText("Traitement et remarques particulières")).toHaveValue( "Observation du jour A", ); }); it("uses the same inclusive date-range subset for PDF preview and export", async () => { const first = dayAEntry(); const second = { ...dayAEntry(), entry_id: "sl_selected", revision_id: "slr_selected", night_start_date: "2026-09-18", night_end_date: "2026-09-19", treatment_and_notes: "Selected observation", }; api.fetchSleepDiary.mockResolvedValue(snapshotWith([first, second])); const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( new Blob(["selected"], { type: "application/pdf" }), ); const present = vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); render(); fireEvent.click(await screen.findByTestId(`sleep-agenda-row-${second.entry_id}`)); fireEvent.change(screen.getByLabelText("PDF du"), { target: { value: "2026-09-18" }, }); fireEvent.change(screen.getByLabelText("au"), { target: { value: "2026-09-18" }, }); await waitFor(() => expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" })); expect(build).toHaveBeenCalledTimes(2); for (const [selected] of build.mock.calls) { expect(selected.entries.map((entry) => entry.entry_id)).toEqual([second.entry_id]); expect(selected.summary.nights).toBe(1); expect(selected.summary.sleep_duration_seconds).toBe(5 * 3600); } expect(present.mock.calls.map((call) => call[1])).toEqual([false, true]); expect(api.saveSleepEntry).not.toHaveBeenCalled(); }); it("blocks a complete PDF when completed-session history is unavailable", async () => { api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); sportApi.fetchSportSessions.mockRejectedValue(new Error("history unavailable")); render(); await screen.findByText("Séances indisponibles : export complet impossible."); expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Exporter PDF" })).toBeDisabled(); expect(screen.queryByText(/Aucune dans la période/)).toBeNull(); }); it("discards a late sport response after the period changes", async () => { api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); let finishOld!: (items: unknown[]) => void; sportApi.fetchSportSessions .mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; })) .mockResolvedValueOnce([{ identity: "se_current", label: "Current session", session_type: "training", started_at: "2026-09-21T05:00:00+02:00", ended_at: "2026-09-21T06:00:00+02:00", }]); const view = render(); await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1)); view.rerender(); await screen.findByText(/Current session/); finishOld([{ identity: "se_stale", label: "Stale session", session_type: "training", started_at: "2026-09-21T07:00:00+02:00", ended_at: "2026-09-21T08:00:00+02:00", }]); await waitFor(() => expect(screen.queryByText(/Stale session/)).toBeNull()); }); it("can export sport in a period with no recorded Sleep night", async () => { sportApi.fetchSportSessions.mockResolvedValue([{ identity: "se_only", label: "Musculation", session_type: "training", started_at: "2026-09-21T05:00:00+02:00", ended_at: "2026-09-21T06:00:00+02:00", }]); const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( new Blob(["sport"], { type: "application/pdf" }), ); vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); render(); await waitFor(() => expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); expect(build.mock.calls[0][0].summary.nights).toBe(0); expect(build.mock.calls[0][2]).toHaveLength(1); expect(api.saveSleepEntry).not.toHaveBeenCalled(); }); it("exports every night whose start date is inside the chosen range", async () => { const first = dayAEntry(); const second = { ...dayAEntry(), entry_id: "sl_range_second", revision_id: "slr_range_second", night_start_date: "2026-09-19", night_end_date: "2026-09-20", }; api.fetchSleepDiary.mockResolvedValue(snapshotWith([first, second])); const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( new Blob(["range"], { type: "application/pdf" }), ); vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); render(); await screen.findByTestId(`sleep-agenda-row-${second.entry_id}`); await waitFor(() => { expect(screen.getByLabelText("PDF du")).toHaveValue("2026-09-19"); expect(screen.getByLabelText("au")).toHaveValue(first.night_start_date); }); await screen.findByText(/Bleu : séance de sport effectuée/); fireEvent.change(screen.getByLabelText("PDF du"), { target: { value: "2026-09-21" }, }); expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeDisabled(); expect(screen.getByText("Aucune nuit dans cette plage.")).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("PDF du"), { target: { value: "2026-09-19" }, }); fireEvent.change(screen.getByLabelText("au"), { target: { value: first.night_start_date }, }); fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); const [selection] = build.mock.calls[0]; expect(selection.entries.map((entry) => entry.entry_id).sort()).toEqual( [first.entry_id, second.entry_id].sort(), ); expect(selection.summary.nights).toBe(2); }); 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(); }); it("aligns ticks, events, intervals, and medication markers on one 18:00 axis", async () => { const mixedOffsetEntry = { ...dayAEntry(), // Regression fixture: metadata is UTC while factual night events carry // the user's +02:00 local offset. created_at: "2026-09-20T16:00:00Z", }; api.fetchSleepDiary.mockResolvedValue(snapshotWith([mixedOffsetEntry])); api.fetchSleepMedications.mockResolvedValue([medication]); render(); const bedtime = await screen.findByTestId("sleep-agenda-event-bed_time"); expect(screen.getByText("Nuit du 20/09/2026")).toBeInTheDocument(); const sleep = screen.getByTestId("sleep-agenda-event-sleep"); const getUp = screen.getByTestId("sleep-agenda-event-final_get_up"); const sleepiness = screen.getByTestId( "sleep-agenda-event-daytime_sleepiness", ); const intake = screen.getByTestId("sleep-agenda-medication"); expect(bedtime).toHaveStyle({ left: "18.75%" }); expect(sleep).toHaveStyle({ left: "18.75%" }); expect(Number.parseFloat(sleep.style.width)).toBeCloseTo((5 / 24) * 100, 9); expect(Number.parseFloat(getUp.style.left)).toBeCloseTo( (10.75 / 24) * 100, 9, ); expect(Number.parseFloat(sleepiness.style.left)).toBeCloseTo( (21.5 / 24) * 100, 9, ); expect(intake).toHaveStyle({ left: "18.75%" }); const ticks = Array.from(document.querySelectorAll(".sleep-hour-axis > span")); expect(ticks).toHaveLength(24); expect( ticks .filter((_, index) => index % 2 === 0) .map((tick) => tick.textContent), ).toEqual([ "18", "20", "22", "00", "02", "04", "06", "08", "10", "12", "14", "16", ]); ticks.forEach((tick, index) => { expect(Number.parseFloat(tick.style.left)).toBeCloseTo( ((index + 0.5) / 24) * 100, 9, ); }); expect(ticks[1]).toHaveClass("sleep-hour-tick-odd"); expect(ticks[2]).toHaveClass("sleep-hour-tick-even"); expect(ticks[6]).toHaveClass("sleep-hour-tick-major"); }); it("shows the shared estimated sleep range only when factual sleep is absent", async () => { const estimated = { ...dayAEntry(), entry_id: "sl_estimated", revision_id: "slr_estimated", night_start_date: "2026-09-23", night_end_date: "2026-09-24", events: [ { event_id: "bed", type: "bed_time" as const, start_at: "2026-09-23T22:05:00+02:00", end_at: null, }, { event_id: "up", type: "final_get_up" as const, start_at: "2026-09-24T04:14:00+02:00", end_at: null, }, ], intakes: [], }; api.fetchSleepDiary.mockResolvedValue(snapshotWith([estimated])); api.fetchSleepMedications.mockResolvedValue([medication]); render(); const range = await screen.findByTestId("sleep-agenda-sleep-estimated"); expect(range).toHaveAttribute( "aria-label", "Sommeil estimé 22:50 → 04:04", ); expect(Number.parseFloat(range.style.left)).toBeCloseTo((4.833333333 / 24) * 100, 7); expect(Number.parseFloat(range.style.width)).toBeCloseTo((5.233333333 / 24) * 100, 7); expect(screen.queryByTestId("sleep-agenda-event-sleep")).not.toBeInTheDocument(); }); it("isolates an empty editor night while retaining the period agenda", async () => { api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); api.fetchSleepMedications.mockResolvedValue([medication]); render(); expect( await screen.findByTestId("sleep-agenda-event-bed_time"), ).toBeInTheDocument(); expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent( "Journée validée", ); expect(screen.queryByText("Prête à synchroniser")).toBeNull(); expect( screen.getByLabelText("Traitement et remarques particulières"), ).toHaveValue("Observation du jour A"); fireEvent.change(screen.getByTestId("sleep-night-start"), { target: { value: "2026-09-21" }, }); expect(screen.getByTestId("sleep-night-end")).toHaveValue("2026-09-22"); expect(screen.queryByTestId("sleep-event-bed_time")).toBeNull(); expect(screen.queryByTestId("sleep-intake-row")).toBeNull(); expect(screen.getByTestId("sleep-timeline")).toHaveTextContent( "Aucun événement enregistré pour cette nuit.", ); expect(screen.getByTestId("sleep-agenda-event-bed_time")).toBeInTheDocument(); expect( screen.getByLabelText("Traitement et remarques particulières"), ).toHaveValue(""); expect( within( screen.getByRole("group", { name: "Qualité du sommeil" }), ).queryByRole("button", { pressed: true }), ).toBeNull(); const summary = screen.getByRole("heading", { name: "Synthèse factuelle" }) .parentElement as HTMLElement; expect(summary).toHaveTextContent("Sommeil déclaré—"); expect(summary).toHaveTextContent("Somnolence—"); expect(summary).toHaveTextContent("Prises—"); await waitFor(() => expect(api.fetchSleepDiary).toHaveBeenCalledTimes(2)); fireEvent.change(screen.getByTestId("sleep-night-start"), { target: { value: "2026-09-20" }, }); expect( await screen.findByTestId("sleep-event-bed_time"), ).toBeInTheDocument(); expect(screen.getByTestId("sleep-intake-row")).toHaveTextContent("TestMed"); expect( screen.getByLabelText("Traitement et remarques particulières"), ).toHaveValue("Observation du jour A"); expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent( "Journée validée", ); }); it("does not let a slow previous-night fetch contaminate the active empty night", async () => { let resolveNightA: ((value: SleepSnapshot) => void) | undefined; const slowNightA = new Promise((resolve) => { resolveNightA = resolve; }); api.fetchSleepDiary .mockImplementationOnce(async () => slowNightA) .mockResolvedValue(emptySnapshot); render(); fireEvent.change(screen.getByTestId("sleep-night-start"), { target: { value: "2026-09-21" }, }); await waitFor(() => expect(api.fetchSleepDiary).toHaveBeenCalledTimes(2)); expect(screen.getByTestId("sleep-night-start")).toHaveValue("2026-09-21"); expect( screen.getByText("Aucune donnée pour cette nuit."), ).toBeInTheDocument(); resolveNightA?.(snapshotWith([dayAEntry()])); await Promise.resolve(); expect(screen.getByTestId("sleep-night-start")).toHaveValue("2026-09-21"); expect(screen.queryByTestId("sleep-event-bed_time")).toBeNull(); expect(screen.queryByTestId("sleep-intake-row")).toBeNull(); expect( screen.getByText("Aucune donnée pour cette nuit."), ).toBeInTheDocument(); }); it("presents validation as a local completion state without transport wording", async () => { let current: SleepEntry = { ...dayAEntry(), publication_status: "draft", }; api.fetchSleepDiary.mockImplementation(async () => snapshotWith([current])); api.saveSleepEntry.mockImplementation(async (input: SleepEntryInput) => { current = { ...persistedEntry(input, current.entry_id, current.revision_id), publication_status: "draft", }; return { entry_id: current.entry_id, revision_id: current.revision_id, }; }); api.validateSleepEntry.mockImplementation(async () => { current = { ...current, publication_status: "ready" }; }); render(); expect( await screen.findByTestId("sleep-publication-status"), ).toHaveTextContent("Brouillon"); fireEvent.click(screen.getByTestId("sleep-validate-day")); await waitFor(() => expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent( "Journée validée", ), ); expect(screen.queryByText("Prête à synchroniser")).toBeNull(); expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( "Enregistré localement", ); }); });