trainlog/web/src/routes/SleepDiaryWorkspace.test.tsx

506 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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")}`,
};
});
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(<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();
});
});