feat(historique): page /historique — liste paginée des productions + gating plan (Sprint 3.7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 21:29:32 +03:00
parent da4e465125
commit a752029c19
12 changed files with 762 additions and 1 deletions

View file

@ -0,0 +1,59 @@
/**
* SimulationListItem Sprint 3.7.
*
* Carte item de la page /historique. Clic /rapport/:id (RapportPage gère le
* cas `rapport === null` en redirigeant vers /simulation/ee FTD-21).
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel aucune logique plan ici.
*/
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'
interface Props {
item: Item
}
export function SimulationListItem({ item }: Props) {
const hasScore = item.score !== null && item.nclc !== null
const isExam = item.mode === 'examen'
return (
<Link
to={`/rapport/${item.id}`}
className="block rounded-lg border border-line bg-surface p-4 shadow-sm transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
<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-1">{formatTache(item.tache)}</span>
{isExam && <Badge variant="nclc">Examen</Badge>}
{!hasScore && <Badge variant="neutral">En cours</Badge>}
</div>
<p className="text-xs text-ink-4">{formatRelativeDate(item.created_at)}</p>
</div>
{hasScore ? (
<div className="shrink-0 text-right">
<p className="tabular-nums text-ink-1">
<span className="text-xl font-bold">{item.score}</span>
<span className="text-sm font-medium text-ink-4">/20</span>
</p>
<p className="text-xs text-ink-4 tabular-nums">
NCLC {item.nclc}
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''}
</p>
</div>
) : (
<div className="shrink-0 text-right text-xs text-ink-4">
Score à venir
</div>
)}
</div>
</Link>
)
}

View file

@ -0,0 +1,147 @@
/**
* SimulationsList Sprint 3.7.
*
* Orchestre :
* - Loading / error / empty state
* - Gating plan : Free aperçu flouté + upgrade ; Standard+ liste
* - Pagination Précédent/Suivant (MVP)
*
* Règle D : passe par `hasAccess(plan, 'dashboard')` jamais de `plan === 'free'`.
* Règle L : tokens Direction H exclusivement.
*/
import { Lock } from 'lucide-react'
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 { SimulationListItem } from './SimulationListItem'
interface Props {
plan: Plan
data: SimulationsListResponse | undefined
isLoading: boolean
isError: boolean
page: number
limit: number
onPrev: () => void
onNext: () => void
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-line bg-canvas-2">
<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-ink-4 ${w}`} />
))}
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
<Lock className="size-5 text-ink-4" aria-hidden="true" />
<p className="text-sm font-medium text-ink-2">Historique disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Standard
</Button>
</div>
</div>
)
}
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-canvas-2" />
))}
</div>
)
}
function EmptyState() {
return (
<Card variant="default" className="space-y-3 p-6 text-center">
<p className="text-sm text-ink-2">Aucune simulation pour le moment.</p>
<p className="text-xs text-ink-4">
Lancez votre première simulation pour commencer à construire votre historique.
</p>
<div className="flex justify-center">
<Button variant="primary" size="sm">
<Link to="/simulation/ee" className="-m-1 p-1">
Démarrer une simulation
</Link>
</Button>
</div>
</Card>
)
}
function ErrorState() {
return (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert">
Impossible de charger l'historique. Réessayez dans quelques instants.
</p>
</Card>
)
}
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).
if (!hasAccess(plan, 'dashboard')) {
return <BlurredPreview onUpgrade={onUpgrade} />
}
if (isError) return <ErrorState />
if (isLoading && !data) return <ListSkeleton />
if (!data) return null
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
return (
<div className="space-y-3">
<ul className="space-y-3">
{data.data.map((item) => (
<li key={item.id}>
<SimulationListItem item={item} />
</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-4 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

@ -0,0 +1,310 @@
/**
* Tests SimulationsList (Sprint 3.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é
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
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'
afterEach(cleanup)
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 NOOP = () => {}
describe('SimulationsList — plan Free (gating)', () => {
it('affiche l\'aperçu flouté et le bouton "Passer en Standard"', () => {
renderWithRouter(
<SimulationsList
plan="free"
data={THREE_ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
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()
})
it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
const user = userEvent.setup()
const onUpgrade = vi.fn()
renderWithRouter(
<SimulationsList
plan="free"
data={THREE_ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
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"', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={EMPTY}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/aucune simulation/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', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
// 3 items rendus
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()
})
it('chaque item pointe vers /rapport/:id', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
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()
})
})
describe('SimulationsList — états transverses', () => {
it('isError → affiche le callout d\'erreur', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={undefined}
isLoading={false}
isError={true}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
})
it('isLoading + pas de data → squelettes', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={undefined}
isLoading={true}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP}
/>,
)
expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
})
})