import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { App } from './App' import { parseDashboard, type DashboardSnapshot } from '../api/dashboard' const programsApi = vi.hoisted(() => ({ fetchAllPrograms: vi.fn(), fetchProgram: vi.fn(), createPreparationFromProgram: vi.fn(), })) vi.mock('../api/programs', async (importOriginal) => ({ ...await importOriginal(), ...programsApi, })) const health = { api_version: 1, status: 'ok', product: 'trainlog', version: '0.1.2', } const dashboard: DashboardSnapshot = { api_version: 1, data: { footer: { user: '—', last_session_date: null, last_zones: [] }, next_session: { available: false, reason: 'no_persisted_executable_plan' }, activity: { available: true, window_days: 90, days: Array.from({ length: 90 }, (_, day) => ({ date: `day-${day}`, active: false, session_count: 0, set_count: 0 })) }, progression: { available: false, reason: 'no_comparable_performance' }, last_session: { available: false, reason: 'no_observable_session' }, max_records: { available: false, records: [] }, muscle_distribution: { available: false, window_days: 30, primary_zones: [] }, cardio: { available: false, reason: 'no_cardio_data_source' }, }, meta: { partial: false, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' }, } const dashboardLayout = { format: 'trainlog-dashboard-layout', version: 1, revision: 0, columns: 12, source: 'default', tiles: [ { id: 'next-session', x: 0, y: 0, width: 5, height: 4 }, { id: 'activity', x: 5, y: 0, width: 7, height: 4 }, { id: 'progression', x: 0, y: 4, width: 8, height: 5 }, { id: 'last-session', x: 8, y: 4, width: 4, height: 3 }, { id: 'max-records', x: 8, y: 7, width: 4, height: 3 }, { id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 }, { id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 }, ], } const layoutHeaders = new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }) function responseValue(input: RequestInfo | URL, dashboardValue: unknown, healthValue: unknown) { const url = String(input) if (url.includes('/dashboard-layout')) return { ok: true, status: 200, headers: layoutHeaders, json: () => Promise.resolve(dashboardLayout) } return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(url.includes('/dashboard') ? dashboardValue : healthValue) } } function mockHealth(value: unknown = health) { vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, dashboard, value)))) } describe('shell Trainlog', () => { beforeEach(() => { programsApi.fetchAllPrograms.mockReset() programsApi.fetchProgram.mockReset() programsApi.createPreparationFromProgram.mockReset() programsApi.fetchAllPrograms.mockResolvedValue([]) }) it('rend le shell, les sept états vides et le health sans données fictives', async () => { mockHealth() render() expect(screen.getByRole('banner')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Aller au contenu' })).toHaveAttribute('href', '#main-content') expect(screen.getByRole('navigation', { name: 'Navigation principale' })).toBeInTheDocument() const footer = screen.getByRole('contentinfo') expect(footer).toHaveClass('status-footer') expect(footer.parentElement).toHaveClass('app-shell') expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG') expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument() await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(6)) expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible') expect(document.body).not.toHaveTextContent('fy59') expect(document.body).not.toHaveTextContent('DOS · BICEPS') await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté · v0.1.2')) }) it.each([ ['Analyse', '/analyse'], ['Programmes', '/programmes'], ['Séances', '/seances'], ['Exercices', '/exercices'], ['Dashboard', '/'], ])('navigue vers %s sans remonter le shell', async (label, path) => { mockHealth() render() const header = screen.getByRole('banner') const footer = screen.getByRole('contentinfo') fireEvent.click(screen.getByRole('link', { name: label })) expect(window.location.pathname).toBe(path) expect(await screen.findByRole('heading', { level: 1, name: label })).toBeInTheDocument() expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page') expect(screen.getByRole('banner')).toBe(header) expect(screen.getByRole('contentinfo')).toBe(footer) }) it('suit la navigation arrière du navigateur', async () => { mockHealth() render() fireEvent.click(screen.getByRole('link', { name: 'Analyse' })) window.history.pushState(null, '', '/programmes') fireEvent.popState(window) expect(await screen.findByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument() }) it('route /programmes vers la page calendrier réelle', async () => { window.history.replaceState(null, '', '/programmes') mockHealth() render() expect(await screen.findByText('Aucun programme actif.')).toBeInTheDocument() expect(programsApi.fetchAllPrograms).toHaveBeenCalledWith('', 'active', expect.any(AbortSignal)) expect(screen.queryByText(/command services Core explicites/)).not.toBeInTheDocument() }) it('présente un échec health discret et accessible', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network'))) render() await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible')) }) it('rejette un JSON health invalide', async () => { mockHealth({ status: 'ok' }) render() await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible')) }) it('alimente le Footer uniquement avec les faits Dashboard reçus', async () => { const actual = structuredClone(dashboard) actual.data.footer = { user: 'fy59', last_session_date: '2026-09-15T10:00:00+02:00', last_zones: ['DOS', 'BRAS'] } vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, actual, health)))) render() await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59')) expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('15/09/2026') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('DOS · BRAS') }) it('rejette les snapshots Dashboard invalides sans inventer de Footer', async () => { expect(() => parseDashboard({ api_version: 1, data: {}, meta: {} })).toThrow() vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, { status: 'ok' }, health)))) render() await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté')) expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible') }) it('expose les cinq destinations dans une navigation clavier sémantique', () => { mockHealth() render() expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(5) expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page') }) })