966 lines
36 KiB
TypeScript
966 lines
36 KiB
TypeScript
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<typeof import("../api/sleepDiary")>();
|
||
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<typeof import("../api/heartRate")>();
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1));
|
||
view.rerender(<SleepDiaryWorkspace period="7d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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<SleepSnapshot>((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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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<HTMLElement>(".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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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<SleepSnapshot>((resolve) => {
|
||
resolveNightA = resolve;
|
||
});
|
||
api.fetchSleepDiary
|
||
.mockImplementationOnce(async () => slowNightA)
|
||
.mockResolvedValue(emptySnapshot);
|
||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
|
||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||
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",
|
||
);
|
||
});
|
||
});
|