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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(await screen.findByTestId("duration-coverage")).toHaveTextContent( "Couverture temporelle partielle", ); }); });