feat(historique): refonte pixel-perfect avec stats + filtres + tendance 30j (Sprint 4.7)

Inclut le retrait du padding de AppLayout et le wrapper standardisé
(mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9) ajouté sur
11 pages (Dashboard, Progression, 9 pages Simulation EE/EO/T1) pour
laisser chaque page gérer son max-width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 00:04:12 +03:00
parent d8bae9520c
commit 3ce91aaa7b
20 changed files with 1417 additions and 874 deletions

View file

@ -0,0 +1,129 @@
/**
* Filtres de la page /historique refonte Sprint 4.7 + correction theming.
*
* Dropdowns custom (div + état ouvert/fermé) zéro lib externe pour
* garantir la lisibilité en dark/light. Tokens DA Charcoal (Règle L).
*
* Accessibilité minimale : button aria-haspopup, fermeture sur clic
* extérieur ou Escape, options atteignables au clic.
*/
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { PeriodFilter, TaskFilter } from '../lib/historique'
interface Props {
task: TaskFilter
period: PeriodFilter
onTaskChange: (task: TaskFilter) => void
onPeriodChange: (period: PeriodFilter) => void
}
const TASK_OPTIONS: { value: TaskFilter; label: string }[] = [
{ value: 'all', label: 'Toutes les tâches' },
{ value: 'EE_T1', label: 'EE T1' },
{ value: 'EE_T2', label: 'EE T2' },
{ value: 'EE_T3', label: 'EE T3' },
{ value: 'EO_T1', label: 'EO T1' },
{ value: 'EO_T3', label: 'EO T3' },
]
const PERIOD_OPTIONS: { value: PeriodFilter; label: string }[] = [
{ value: 'this-month', label: 'Ce mois' },
{ value: '3-months', label: '3 mois' },
{ value: 'all', label: 'Tout' },
]
interface DropdownProps<T extends string> {
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
ariaLabel: string
}
function Dropdown<T extends string>({ value, options, onChange, ariaLabel }: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
}
}, [open])
const selected = options.find((o) => o.value === value) ?? options[0]
return (
<div ref={rootRef} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-[14px] py-[7px] text-[12.5px] font-semibold text-ink-primary hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
<span>{selected.label}</span>
<ChevronDown className="size-3.5 text-ink-tertiary" aria-hidden="true" />
</button>
{open && (
<ul
role="listbox"
className="absolute right-0 z-10 mt-1 min-w-full overflow-hidden rounded-lg border border-border bg-surface-solid shadow-card"
>
{options.map((opt) => {
const isActive = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={isActive}
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`block w-full whitespace-nowrap px-[14px] py-2 text-left text-[12.5px] font-medium hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none ${
isActive ? 'text-brand-text' : 'text-ink-primary'
}`}
>
{opt.label}
</button>
</li>
)
})}
</ul>
)}
</div>
)
}
export function HistoriqueFilters({ task, period, onTaskChange, onPeriodChange }: Props) {
return (
<div className="flex flex-wrap items-center gap-2">
<Dropdown
value={task}
options={TASK_OPTIONS}
onChange={onTaskChange}
ariaLabel="Filtrer par tâche"
/>
<Dropdown
value={period}
options={PERIOD_OPTIONS}
onChange={onPeriodChange}
ariaLabel="Filtrer par période"
/>
</div>
)
}

View file

@ -0,0 +1,118 @@
/**
* 3 cartes métriques en haut de /historique Sprint 4.7.
*
* Total simulations / Score moyen / Meilleur score. Recalculées à chaque
* changement de filtres (les filtres sont appliqués en amont par la page).
*
* Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel.
*/
import {
formatShortDate,
formatTaskLabel,
type HistoriqueStats,
type Trend,
} from '../lib/historique'
interface Props {
stats: HistoriqueStats
trend: Trend | null
}
const NUMBER_FR = new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11.5px] font-medium uppercase tracking-[0.04em] text-ink-secondary">
{children}
</p>
)
}
function Value({ children }: { children: React.ReactNode }) {
return (
<span className="text-[34px] font-bold leading-none tracking-[-0.03em] tabular-nums text-ink-primary">
{children}
</span>
)
}
function Unit({ children }: { children: React.ReactNode }) {
return <span className="ml-[3px] text-[15px] font-medium text-ink-tertiary">{children}</span>
}
function Footer({ children }: { children: React.ReactNode }) {
return <p className="mt-2 text-[11.5px] text-ink-tertiary">{children}</p>
}
function TrendChip({ trend }: { trend: Trend }) {
const isUp = trend.direction === 'up'
const sign = isUp ? '+' : '-'
const label = `${sign}${NUMBER_FR.format(trend.delta)} en 30j`
const colorClasses = isUp
? 'bg-success-soft text-success border-success/30'
: 'bg-danger-soft text-danger border-danger/30'
return (
<span
className={`mt-2 inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11.5px] font-semibold ${colorClasses}`}
>
<span aria-hidden="true">{isUp ? '↑' : '↓'}</span>
{label}
</span>
)
}
export function HistoriqueStatsCards({ stats, trend }: Props) {
return (
<div className="grid w-full grid-cols-3 gap-3">
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Total simulations</Label>
<div className="mt-2 flex items-baseline">
<Value>{stats.total}</Value>
</div>
<Footer>dont {stats.thisMonth} ce mois</Footer>
</div>
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Score moyen</Label>
<div className="mt-2 flex items-baseline">
{stats.average !== null ? (
<>
<Value>{NUMBER_FR.format(stats.average)}</Value>
<Unit>/20</Unit>
</>
) : (
<Value></Value>
)}
</div>
{trend ? <TrendChip trend={trend} /> : <Footer></Footer>}
</div>
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Meilleur score</Label>
<div className="mt-2 flex items-baseline">
{stats.best !== null ? (
<>
<Value>{stats.best.score}</Value>
<Unit>/20</Unit>
</>
) : (
<Value></Value>
)}
</div>
{stats.best !== null ? (
<Footer>
{formatTaskLabel({ tache: stats.best.tache, mode: 'entrainement' })} ·{' '}
{formatShortDate(stats.best.created_at)}
</Footer>
) : (
<Footer></Footer>
)}
</div>
</div>
)
}

View file

@ -1,59 +1,65 @@
/**
* SimulationListItem Sprint 3.7.
* Item d'une ligne de la liste /historique réécrit Sprint 4.7 selon maquette.
*
* Carte item de la page /historique. Clic /rapport/:id (RapportPage gère le
* cas `rapport === null` en redirigeant vers /simulation/ee FTD-21).
* Layout flex : Date · Libellé · Badge NCLC · Score · Chevron.
* Couleur du badge NCLC selon seuil (cf. `nclcChipVariant`).
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel aucune logique plan ici.
* Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel.
*/
import { ChevronRight } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Badge } from '@/shared/ui/Badge'
import { formatTache } from '@/entities/production/lib'
import { formatRelativeDate } from '@/shared/lib/date'
import type { SimulationListItem as Item } from '@/entities/production/types'
import { formatShortDate, formatTaskLabel, nclcChipVariant } from '../lib/historique'
interface Props {
item: Item
isLast: boolean
}
export function SimulationListItem({ item }: Props) {
const CHIP_BASE =
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide'
const CHIP_OK = 'bg-success-soft text-success border-success/30'
const CHIP_WARN = 'bg-warning-soft text-warning border-warning/30'
const CHIP_ERR = 'bg-danger-soft text-danger border-danger/30'
const CHIP_NEUTRAL = 'bg-surface text-ink-secondary border-border'
function NclcBadge({ nclc }: { nclc: number }) {
const variant = nclcChipVariant(nclc)
const cls = variant === 'ok' ? CHIP_OK : variant === 'warn' ? CHIP_WARN : CHIP_ERR
return <span className={`${CHIP_BASE} ${cls}`}>NCLC {nclc}</span>
}
export function SimulationListItem({ item, isLast }: Props) {
const hasScore = item.score !== null && item.nclc !== null
const isExam = item.mode === 'examen'
const borderClass = isLast ? '' : 'border-b border-border'
return (
<Link
to={`/rapport/${item.id}`}
className="block rounded-lg border border-border bg-surface p-4 shadow-card transition-colors duration-150 hover:border-brand hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
className={`flex items-center gap-[14px] px-4 py-[14px] transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus ${borderClass}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-ink-primary">
{formatTache(item.tache)}
</span>
{isExam && <Badge variant="nclc">Examen</Badge>}
{!hasScore && <Badge variant="neutral">En cours</Badge>}
</div>
<p className="text-xs text-ink-secondary">{formatRelativeDate(item.created_at)}</p>
</div>
<span className="w-[68px] shrink-0 text-[11.5px] tabular-nums text-ink-tertiary">
{formatShortDate(item.created_at)}
</span>
{hasScore ? (
<div className="shrink-0 text-right">
<p className="tabular-nums text-ink-primary">
<span className="text-xl font-bold">{item.score}</span>
<span className="text-sm font-medium text-ink-secondary">/20</span>
</p>
<p className="text-xs text-ink-secondary tabular-nums">
NCLC {item.nclc}
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''}
</p>
</div>
) : (
<div className="shrink-0 text-right text-xs text-ink-secondary">Score à venir</div>
)}
</div>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink-primary">
{formatTaskLabel(item)}
</span>
{hasScore && item.nclc !== null ? (
<NclcBadge nclc={item.nclc} />
) : (
<span className={`${CHIP_BASE} ${CHIP_NEUTRAL}`}>En cours</span>
)}
<span className="min-w-[56px] text-right text-[16px] font-semibold tracking-[-0.02em] tabular-nums text-ink-primary">
{hasScore ? `${item.score}/20` : '—/20'}
</span>
<ChevronRight className="size-[14px] shrink-0 text-ink-tertiary" aria-hidden="true" />
</Link>
)
}

View file

@ -1,13 +1,14 @@
/**
* SimulationsList Sprint 3.7.
* SimulationsList refonte Sprint 4.7.
*
* Orchestre :
* - Loading / error / empty state
* - Gating plan : Free aperçu flouté + upgrade ; Standard+ liste
* - Pagination Précédent/Suivant (MVP)
* - Reçoit directement `items: SimulationListItem[]` (filtrés en amont par la
* page) au lieu d'une réponse paginée. La pagination Précédent/Suivant a
* é supprimée au profit du filtrage local (cf. HistoriquePage).
* - Conserve le gating Free (aperçu flouté + CTA upgrade Règle D).
* - Distingue état vide global (« aucune simulation ») vs filtré
* (« aucun résultat pour ces filtres »).
*
* Règle D : passe par `hasAccess(plan, 'dashboard')` jamais de `plan === 'free'`.
* Règle L : tokens Direction H exclusivement.
* Règle L : tokens DA Charcoal exclusivement.
*/
import { Lock } from 'lucide-react'
@ -15,27 +16,24 @@ import { Link } from 'react-router-dom'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess, type Plan } from '@/entities/user/lib'
import type { SimulationsListResponse } from '@/entities/production/types'
import type { SimulationListItem as Item } from '@/entities/production/types'
import { SimulationListItem } from './SimulationListItem'
interface Props {
plan: Plan
data: SimulationsListResponse | undefined
items: Item[]
isLoading: boolean
isError: boolean
page: number
limit: number
onPrev: () => void
onNext: () => void
/** True si au moins un filtre non-`all` est actif — distingue empty filtré vs global. */
isFiltered: boolean
onUpgrade: () => void
}
// Floutage local (dupliqué du pattern RapportPage — à extraire en shared/ si répété ailleurs).
const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
return (
<div className="relative min-h-[240px] overflow-hidden rounded-lg border border-border bg-surface">
<div className="relative min-h-[240px] overflow-hidden rounded-[12px] border border-border bg-surface">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-16 rounded bg-surface-hover ${w}`} />
@ -56,7 +54,7 @@ function ListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-lg bg-surface" />
<div key={i} className="h-14 animate-pulse rounded-lg bg-surface" />
))}
</div>
)
@ -80,6 +78,17 @@ function EmptyState() {
)
}
function EmptyFilteredState() {
return (
<Card variant="default" className="p-6 text-center">
<p className="text-sm text-ink-primary">Aucune simulation ne correspond à ces filtres.</p>
<p className="mt-1 text-xs text-ink-secondary">
Essayez d'élargir la période ou de changer la tâche sélectionnée.
</p>
</Card>
)
}
function ErrorState() {
return (
<Card variant="default" className="border-l-4 border-l-danger p-4">
@ -90,58 +99,27 @@ function ErrorState() {
)
}
export function SimulationsList({
plan,
data,
isLoading,
isError,
page,
limit,
onPrev,
onNext,
onUpgrade,
}: Props) {
// Gating plan — Free voit uniquement l'aperçu flouté (Règle D).
export function SimulationsList({ plan, items, isLoading, isError, isFiltered, onUpgrade }: Props) {
if (!hasAccess(plan, 'dashboard')) {
return <BlurredPreview onUpgrade={onUpgrade} />
}
if (isError) return <ErrorState />
if (isLoading && !data) return <ListSkeleton />
if (!data) return null
if (isLoading) return <ListSkeleton />
if (data.data.length === 0) return <EmptyState />
const totalPages = Math.max(1, Math.ceil(data.pagination.total / limit))
const isFirst = page <= 1
const isLast = page >= totalPages
if (items.length === 0) {
return isFiltered ? <EmptyFilteredState /> : <EmptyState />
}
return (
<div className="space-y-3">
<ul className="space-y-3">
{data.data.map((item) => (
<li key={item.id}>
<SimulationListItem item={item} />
<div className="overflow-hidden rounded-[12px] border border-border bg-surface shadow-card">
<ul>
{items.map((it, i) => (
<li key={it.id}>
<SimulationListItem item={it} isLast={i === items.length - 1} />
</li>
))}
</ul>
{totalPages > 1 && (
<nav
aria-label="Pagination de l'historique"
className="flex items-center justify-between gap-3 pt-2"
>
<Button variant="secondary" size="sm" onClick={onPrev} disabled={isFirst}>
Précédent
</Button>
<p className="text-xs text-ink-secondary tabular-nums" aria-live="polite">
Page {page} sur {totalPages} {data.pagination.total} simulations
</p>
<Button variant="secondary" size="sm" onClick={onNext} disabled={isLast}>
Suivant
</Button>
</nav>
)}
</div>
)
}

View file

@ -1,12 +1,8 @@
/**
* Tests SimulationsList (Sprint 3.7).
* Tests SimulationsList refonte Sprint 4.7.
*
* Couvre :
* - État vide (Standard+)
* - Items rendus (Standard+)
* - Gating Free : aperçu flouté + bouton upgrade
* - Pagination : Précédent désactivé sur page 1, Suivant désactivé sur dernière page
* - Clic upgrade onUpgrade appelé
* Couvre : gating Free, état vide global, état vide filtré, items rendus,
* isError, isLoading. La pagination a é retirée au Sprint 4.7.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
@ -14,7 +10,7 @@ import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { SimulationsList } from '../SimulationsList'
import type { SimulationsListResponse } from '@/entities/production/types'
import type { SimulationListItem } from '@/entities/production/types'
afterEach(cleanup)
@ -22,66 +18,52 @@ function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
const EMPTY: SimulationsListResponse = {
data: [],
pagination: { page: 1, limit: 20, total: 0 },
}
const THREE_ITEMS: SimulationsListResponse = {
data: [
{
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T10:00:00Z',
},
{
id: 'p2',
tache: 'EE_T2',
mode: 'examen',
score: 16,
nclc: 10,
nclc_cible: 10,
created_at: '2026-04-22T09:00:00Z',
},
{
id: 'p3',
tache: 'EE_T3',
mode: 'entrainement',
score: null,
nclc: null,
nclc_cible: null,
created_at: '2026-04-22T08:00:00Z',
},
],
pagination: { page: 1, limit: 20, total: 3 },
}
const ITEMS: SimulationListItem[] = [
{
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T10:00:00Z',
},
{
id: 'p2',
tache: 'EE_T2',
mode: 'examen',
score: 16,
nclc: 10,
nclc_cible: 10,
created_at: '2026-04-22T09:00:00Z',
},
{
id: 'p3',
tache: 'EE_T3',
mode: 'entrainement',
score: null,
nclc: null,
nclc_cible: null,
created_at: '2026-04-22T08:00:00Z',
},
]
const NOOP = () => {}
describe('SimulationsList — plan Free (gating)', () => {
it('affiche l\'aperçu flouté et le bouton "Passer en Standard"', () => {
describe('SimulationsList — gating Free', () => {
it("affiche l'aperçu flouté pour le plan Free", () => {
renderWithRouter(
<SimulationsList
plan="free"
data={THREE_ITEMS}
items={ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /passer en standard/i })).toBeInTheDocument()
// Les items NE doivent PAS être accessibles à Free
expect(screen.queryByText('Expression Écrite — Tâche 1')).not.toBeInTheDocument()
expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
})
it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
@ -90,221 +72,115 @@ describe('SimulationsList — plan Free (gating)', () => {
renderWithRouter(
<SimulationsList
plan="free"
data={THREE_ITEMS}
items={ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={onUpgrade}
/>,
)
await user.click(screen.getByRole('button', { name: /passer en standard/i }))
expect(onUpgrade).toHaveBeenCalledTimes(1)
})
})
describe('SimulationsList — plan Standard', () => {
it('affiche l\'état vide avec le CTA "Démarrer une simulation"', () => {
it('affiche l\'état vide global avec CTA "Démarrer une simulation"', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={EMPTY}
items={[]}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/aucune simulation/i)).toBeInTheDocument()
expect(screen.getByText(/aucune simulation pour le moment/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
'href',
'/simulation/ee',
)
})
it('rend les items avec score, NCLC et badge "Examen" quand mode=examen', () => {
it('affiche un état vide spécifique quand des filtres sont actifs', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
items={[]}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={true}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/aucune simulation ne correspond à ces filtres/i)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /démarrer une simulation/i })).not.toBeInTheDocument()
})
// 3 items rendus
it('rend les items avec libellé, score et badges', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={ITEMS}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(3)
// Item p1 : score 14, NCLC 9 / cible 9
expect(screen.getByText('14')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9 \/ cible 9/)).toBeInTheDocument()
// Item p2 (examen) : badge Examen
expect(screen.getByText('Examen')).toBeInTheDocument()
// Item p3 (score null) : badge "En cours"
expect(screen.getByText('En cours')).toBeInTheDocument()
expect(screen.getByText('EE · Tâche 1')).toBeInTheDocument()
expect(screen.getByText('Examen blanc EE')).toBeInTheDocument()
expect(screen.getByText('14/20')).toBeInTheDocument()
expect(screen.getByText('NCLC 9')).toBeInTheDocument()
expect(screen.getByText(/En cours/i)).toBeInTheDocument()
expect(screen.getByText('—/20')).toBeInTheDocument()
})
it('chaque item pointe vers /rapport/:id', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
items={ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('link', { name: /expression écrite.*tâche 1/i })).toHaveAttribute(
'href',
'/rapport/p1',
)
})
})
describe('SimulationsList — pagination', () => {
it('un seul page (total ≤ limit) : pas de nav de pagination', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
expect(screen.queryByRole('navigation', { name: /pagination/i })).not.toBeInTheDocument()
})
it('page 1 sur plusieurs : Précédent désactivé, Suivant actif', async () => {
const user = userEvent.setup()
const onNext = vi.fn()
const multi: SimulationsListResponse = {
data: THREE_ITEMS.data,
pagination: { page: 1, limit: 20, total: 50 },
}
renderWithRouter(
<SimulationsList
plan="standard"
data={multi}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={onNext}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('button', { name: /précédent/i })).toBeDisabled()
const suivant = screen.getByRole('button', { name: /suivant/i })
expect(suivant).toBeEnabled()
await user.click(suivant)
expect(onNext).toHaveBeenCalledTimes(1)
})
it('dernière page : Suivant désactivé', () => {
const last: SimulationsListResponse = {
data: THREE_ITEMS.data,
pagination: { page: 3, limit: 20, total: 50 }, // 50 / 20 = 3 pages
}
renderWithRouter(
<SimulationsList
plan="standard"
data={last}
isLoading={false}
isError={false}
page={3}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('button', { name: /suivant/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /précédent/i })).toBeEnabled()
})
it('affiche le compteur "Page X sur Y — Z simulations"', () => {
const multi: SimulationsListResponse = {
data: THREE_ITEMS.data,
pagination: { page: 2, limit: 20, total: 50 },
}
renderWithRouter(
<SimulationsList
plan="standard"
data={multi}
isLoading={false}
isError={false}
page={2}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/Page 2 sur 3 — 50 simulations/i)).toBeInTheDocument()
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', '/rapport/p1')
})
})
describe('SimulationsList — états transverses', () => {
it("isError → affiche le callout d'erreur", () => {
it("isError → callout d'erreur", () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={undefined}
items={[]}
isLoading={false}
isError={true}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
})
it('isLoading + pas de data → squelettes', () => {
it('isLoading → squelettes', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={undefined}
items={[]}
isLoading={true}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
})
})