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:
parent
d8bae9520c
commit
3ce91aaa7b
20 changed files with 1417 additions and 874 deletions
129
src/features/historique/components/HistoriqueFilters.tsx
Normal file
129
src/features/historique/components/HistoriqueFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/features/historique/components/HistoriqueStats.tsx
Normal file
118
src/features/historique/components/HistoriqueStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* été 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 été 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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue