281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { AnalysisPage } from "./AnalysisPage";
|
||
import { analysisFixture } from "../api/analysis.test";
|
||
import type { AnalysisSnapshot } from "../api/analysis";
|
||
|
||
describe("AnalysisPage", () => {
|
||
beforeEach(() => {
|
||
document.documentElement.lang = "fr";
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
"/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope",
|
||
);
|
||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||
new Response(JSON.stringify(analysisFixture), { status: 200 }),
|
||
);
|
||
});
|
||
afterEach(() => vi.restoreAllMocks());
|
||
|
||
it("falls back safely and changes sections, periods and metrics", async () => {
|
||
render(<AnalysisPage />);
|
||
expect(
|
||
await screen.findByRole("heading", { name: "Analyse" }),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByRole("tab", { name: "Vue d’ensemble" })).toHaveAttribute(
|
||
"aria-selected",
|
||
"true",
|
||
);
|
||
expect(screen.getByLabelText("Période")).toHaveValue("30d");
|
||
fireEvent.change(screen.getByLabelText("Période"), {
|
||
target: { value: "7d" },
|
||
});
|
||
await waitFor(() =>
|
||
expect(globalThis.fetch).toHaveBeenLastCalledWith(
|
||
expect.stringContaining("period=7d"),
|
||
expect.anything(),
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole("tab", { name: "Mensurations" }));
|
||
fireEvent.change(screen.getByLabelText("Mesure"), {
|
||
target: { value: "waist" },
|
||
});
|
||
expect(window.location.search).toContain("section=measurements");
|
||
});
|
||
|
||
it.each(["7d", "30d"])(
|
||
"does not apply legacy global period %s to the Sleep workspace",
|
||
async (legacyPeriod) => {
|
||
window.history.replaceState(null, "", `/analyse?section=sleep&period=${legacyPeriod}`);
|
||
vi.mocked(globalThis.fetch).mockImplementation(async (input) => {
|
||
const url = String(input);
|
||
if (url.startsWith("/api/v1/sleep-diary?"))
|
||
return new Response(JSON.stringify({ api_version: 1, entries: [], summary: {} }));
|
||
if (url === "/api/v1/sleep-medications")
|
||
return new Response(JSON.stringify({ api_version: 1, medications: [] }));
|
||
if (url.startsWith("/api/v1/sessions/sport?"))
|
||
return new Response(JSON.stringify({
|
||
api_version: 1, offset: 0, more: false, next_offset: 1, items: [],
|
||
}));
|
||
return new Response(JSON.stringify(analysisFixture));
|
||
});
|
||
render(<AnalysisPage />);
|
||
await screen.findByTestId("sleep-workspace");
|
||
expect(screen.queryByLabelText("Période")).toBeNull();
|
||
expect(screen.getByLabelText("Afficher")).toHaveValue("7");
|
||
expect(screen.getByLabelText("PDF du")).toBeInTheDocument();
|
||
expect(screen.getByLabelText("au")).toBeInTheDocument();
|
||
await waitFor(() => {
|
||
const sleepRead = vi.mocked(globalThis.fetch).mock.calls.find(([input]) =>
|
||
String(input).startsWith("/api/v1/sleep-diary?"));
|
||
expect(sleepRead).toBeDefined();
|
||
expect(String(sleepRead?.[0])).not.toContain("start_date");
|
||
expect(String(sleepRead?.[0])).not.toContain("end_date");
|
||
});
|
||
fireEvent.click(screen.getByRole("tab", { name: "Vue d’ensemble" }));
|
||
expect(screen.getByLabelText("Période")).toHaveValue(legacyPeriod);
|
||
fireEvent.change(screen.getByLabelText("Période"), { target: { value: "90d" } });
|
||
await waitFor(() => expect(window.location.search).toContain("period=90d"));
|
||
for (const section of ["Exercices", "Répartition", "Mensurations"]) {
|
||
fireEvent.click(screen.getByRole("tab", { name: section }));
|
||
expect(screen.getByLabelText("Période")).toHaveValue("90d");
|
||
}
|
||
fireEvent.click(screen.getByRole("tab", { name: "Sommeil" }));
|
||
expect(screen.queryByLabelText("Période")).toBeNull();
|
||
expect(window.location.search).toContain("period=90d");
|
||
},
|
||
);
|
||
|
||
it("supports exercise and BODY ZONE deep-link sections without duplicating the global language control", async () => {
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
`/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`,
|
||
);
|
||
render(<AnalysisPage />);
|
||
expect(
|
||
await screen.findByRole("heading", { name: "Presse" }),
|
||
).toBeInTheDocument();
|
||
fireEvent.click(screen.getByRole("tab", { name: "Répartition" }));
|
||
expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument();
|
||
expect(screen.queryByLabelText("Langue")).not.toBeInTheDocument();
|
||
expect(document.querySelector(".analysis-zone-track span")).toHaveStyle({
|
||
width: "100%",
|
||
});
|
||
expect(document.body).not.toHaveTextContent("%");
|
||
});
|
||
|
||
it("humanizes durations and hides set counts for a continuous exercise", async () => {
|
||
const continuous: AnalysisSnapshot = {
|
||
...analysisFixture,
|
||
exercises: [
|
||
{
|
||
...analysisFixture.exercises[0],
|
||
name: "Marche",
|
||
tracking_mode: "duration",
|
||
recording_mode: "continuous",
|
||
},
|
||
],
|
||
exercise: {
|
||
...analysisFixture.exercise!,
|
||
name: "Marche",
|
||
tracking_mode: "duration",
|
||
recording_mode: "continuous",
|
||
points: [
|
||
{
|
||
...analysisFixture.exercise!.points[0],
|
||
sets: 0,
|
||
reps: null,
|
||
external_load_kg: null,
|
||
external_volume_kg: null,
|
||
explicit_max_kg: null,
|
||
continuous_duration_seconds: 900,
|
||
distance_km: 1.43,
|
||
speed_kmh: 6.4,
|
||
},
|
||
],
|
||
},
|
||
};
|
||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||
new Response(JSON.stringify(continuous), { status: 200 }),
|
||
);
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
`/analyse?section=exercise&exercise_id=${continuous.exercises[0].exercise_id}&period=30d`,
|
||
);
|
||
render(<AnalysisPage />);
|
||
expect(
|
||
await screen.findByRole("heading", { name: "Marche" }),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByText("15 min")).toBeInTheDocument();
|
||
expect(screen.getByText("1,43 km")).toBeInTheDocument();
|
||
expect(screen.getByText("6,4 km/h")).toBeInTheDocument();
|
||
expect(screen.queryByText("Séries")).not.toBeInTheDocument();
|
||
expect(screen.getByRole("tab", { name: "Durée" })).toBeInTheDocument();
|
||
expect(screen.getByRole("tab", { name: "Distance" })).toBeInTheDocument();
|
||
expect(screen.getByRole("tab", { name: "Vitesse" })).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole("tab", { name: "Charge" }),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.getByText(/Durée · min · 1 points réels/),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("offers only available loaded-repetition metrics and labels chart axes", async () => {
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
`/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`,
|
||
);
|
||
render(<AnalysisPage />);
|
||
expect(await screen.findByRole("tab", { name: "Charge" })).toHaveAttribute(
|
||
"aria-selected",
|
||
"true",
|
||
);
|
||
expect(
|
||
screen.getByRole("tab", { name: "Répétitions" }),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByRole("tab", { name: "Volume externe" }),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByRole("tab", { name: "MAX" })).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole("tab", { name: "Durée" }),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.getByText(/Charge · kg · 1 points réels/),
|
||
).toBeInTheDocument();
|
||
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(1);
|
||
expect(document.querySelectorAll(".chart-y-label")).toHaveLength(2);
|
||
});
|
||
|
||
it("renders a neutral factual measurement delta with first and last dates", async () => {
|
||
const summaries = analysisFixture.measurements.summaries.map((summary) =>
|
||
summary.metric === "weight"
|
||
? { ...summary, count: 2, first: 83.7, last: 85.1, delta: 1.4 }
|
||
: summary,
|
||
);
|
||
const measurements: AnalysisSnapshot = {
|
||
...analysisFixture,
|
||
measurements: {
|
||
selected_metric: "weight",
|
||
unit: "kg",
|
||
summaries,
|
||
series: [{
|
||
metric: "weight",
|
||
unit: "kg",
|
||
points: [
|
||
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
|
||
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
|
||
],
|
||
}],
|
||
points: [
|
||
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
|
||
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
|
||
],
|
||
},
|
||
};
|
||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||
new Response(JSON.stringify(measurements), { status: 200 }),
|
||
);
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
"/analyse?section=measurements&metric=weight&period=30d",
|
||
);
|
||
render(<AnalysisPage />);
|
||
const delta = await screen.findByText("+1,4 kg");
|
||
expect(delta.parentElement).toHaveClass("analysis-delta");
|
||
expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
|
||
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
|
||
expect(document.querySelector(".analysis-chart svg"))
|
||
.toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
|
||
});
|
||
|
||
it("uses a factual exercises-by-date histogram in the overview", async () => {
|
||
render(<AnalysisPage />);
|
||
expect(await screen.findByText("Exercices réalisés par date")).toBeInTheDocument();
|
||
expect(
|
||
screen.getByRole("img", { name: "Histogramme des exercices réalisés par date" }),
|
||
).toBeInTheDocument();
|
||
expect(document.querySelector(".activity-bar")).toHaveAttribute(
|
||
"title",
|
||
expect.stringContaining("1 exercice(s)"),
|
||
);
|
||
});
|
||
|
||
it("reports missing and partial exercise timing without inventing duration", async () => {
|
||
const missing: AnalysisSnapshot = {
|
||
...analysisFixture,
|
||
exercise_groups: [{
|
||
zone_id: "legs", label: "Jambes", exercises: [{
|
||
exercise_id: "ex_timing", name: "Presse", occurrences: 2, sessions: 2,
|
||
sets: 5, measured_seconds: 0, measured_occurrences: 0,
|
||
}],
|
||
}],
|
||
};
|
||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||
new Response(JSON.stringify(missing), { status: 200 }),
|
||
);
|
||
const { rerender } = render(<AnalysisPage />);
|
||
expect(await screen.findByTestId("duration-coverage")).toHaveTextContent(
|
||
"Temps non mesuré · durée indisponible",
|
||
);
|
||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||
new Response(JSON.stringify({
|
||
...missing,
|
||
exercise_groups: [{
|
||
...missing.exercise_groups[0],
|
||
exercises: [{ ...missing.exercise_groups[0].exercises[0], measured_seconds: 600, measured_occurrences: 1 }],
|
||
}],
|
||
}), { status: 200 }),
|
||
);
|
||
rerender(<AnalysisPage key="partial" />);
|
||
expect(await screen.findByTestId("duration-coverage")).toHaveTextContent(
|
||
"Couverture temporelle partielle",
|
||
);
|
||
});
|
||
});
|