feat(simulations): resume session depuis localStorage (FTD-21)

This commit is contained in:
Hermann_Kitio 2026-04-21 04:02:15 +03:00
parent 549e5f698f
commit aaecc3f804
2 changed files with 125 additions and 4 deletions

View file

@ -18,7 +18,11 @@ import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSimulation } from '../useSimulation'
import { SimulationFlowProvider, useSimulationFlow } from '../../state/SimulationFlowProvider'
import { createSimulation } from '@/entities/production/api'
import {
createSimulation,
getSimulationState,
updateSujet,
} from '@/entities/production/api'
import { correctEe } from '@/entities/report/api'
import type { Production } from '@/entities/production/types'
import type { Report } from '@/entities/report/types'
@ -28,6 +32,8 @@ vi.mock('@/entities/report/api')
const mockCreateSimulation = vi.mocked(createSimulation)
const mockCorrectEe = vi.mocked(correctEe)
const mockGetSimulationState = vi.mocked(getSimulationState)
const mockUpdateSujet = vi.mocked(updateSujet)
const mockProduction: Production = {
id: 'sim-1',
@ -79,6 +85,9 @@ function createWrapper() {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
// FTD-21 — par défaut, pas de resume : la plupart des tests partent de idle.
mockUpdateSujet.mockResolvedValue(undefined)
})
describe('useSimulation — état initial', () => {
@ -204,6 +213,63 @@ describe('useSimulation — submitText', () => {
})
})
describe('useSimulation — FTD-21 resume depuis localStorage', () => {
it('restaure step=task-selected et production hydratée si rapport=null', async () => {
localStorage.setItem('expria_simulation_id', 'sim-42')
mockGetSimulationState.mockResolvedValue({
simulation_id: 'sim-42',
tache: 'EE_T1',
mode: 'entrainement',
created_at: '2026-04-21T00:00:00Z',
contenu: 'Mon brouillon.',
sujet: mockSujet,
rapport: null,
})
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.step).toBe('task-selected'))
expect(mockGetSimulationState).toHaveBeenCalledWith('sim-42')
expect(result.current.production?.id).toBe('sim-42')
expect(result.current.production?.contenu).toBe('Mon brouillon.')
expect(result.current.sujet).toEqual(mockSujet)
expect(localStorage.getItem('expria_simulation_id')).toBe('sim-42')
})
it('nettoie localStorage si rapport présent (simulation déjà corrigée)', async () => {
localStorage.setItem('expria_simulation_id', 'sim-42')
mockGetSimulationState.mockResolvedValue({
simulation_id: 'sim-42',
tache: 'EE_T1',
mode: 'entrainement',
created_at: '2026-04-21T00:00:00Z',
contenu: 'texte',
sujet: null,
rapport: {
score: 14, nclc: 8, feedback_court: 'OK',
criteres: [], erreurs: [], modele: '', idees: [], exercices: [],
},
})
renderHook(() => useSimulation(), { wrapper: createWrapper() })
await waitFor(() => {
expect(localStorage.getItem('expria_simulation_id')).toBeNull()
})
})
it('nettoie localStorage si getSimulationState échoue', async () => {
localStorage.setItem('expria_simulation_id', 'sim-missing')
mockGetSimulationState.mockRejectedValue({ code: 'SIMULATION_NOT_FOUND' })
renderHook(() => useSimulation(), { wrapper: createWrapper() })
await waitFor(() => {
expect(localStorage.getItem('expria_simulation_id')).toBeNull()
})
})
})
describe('useSimulation — reset', () => {
it('reset depuis task-selected remet step à idle et production à null', async () => {
mockCreateSimulation.mockResolvedValue(mockProduction)

View file

@ -8,9 +8,14 @@
* Règle H : aucune logique métier les mutations s'appuient sur entities/.
*/
import { createContext, useContext, useState, type ReactNode } from 'react'
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { createSimulation } from '@/entities/production/api'
import {
createSimulation,
getSimulationState,
updateSujet as updateSujetApi,
} from '@/entities/production/api'
import { correctEe } from '@/entities/report/api'
import type {
CreateSimulationPayload,
@ -29,6 +34,7 @@ export type SimulationStep =
| 'done'
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
interface FlowValue {
step: SimulationStep
@ -51,6 +57,45 @@ const SimulationFlowContext = createContext<FlowValue | null>(null)
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
const [step, setStep] = useState<SimulationStep>('idle')
const [production, setProduction] = useState<Production | null>(null)
const navigate = useNavigate()
const location = useLocation()
const hydratedRef = useRef(false)
// FTD-21 — restauration de session depuis localStorage au montage.
// Si `rapport === null` → simulation en cours, on restaure le state et redirige
// vers /simulation/ee. Sinon (rapport présent ou erreur/404) → on nettoie.
useEffect(() => {
if (hydratedRef.current) return
hydratedRef.current = true
const id = localStorage.getItem(LS_SIMULATION_ID_KEY)
if (!id) return
getSimulationState(id)
.then((state) => {
if (state.rapport !== null) {
localStorage.removeItem(LS_SIMULATION_ID_KEY)
return
}
setProduction({
id: state.simulation_id,
tache: state.tache,
mode: state.mode,
created_at: state.created_at,
sujet: state.sujet,
contenu: state.contenu ?? undefined,
sujet_id: state.sujet?.id,
})
setStep('task-selected')
if (!location.pathname.startsWith('/simulation/ee')) {
navigate('/simulation/ee')
}
})
.catch(() => {
localStorage.removeItem(LS_SIMULATION_ID_KEY)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const createMutation = useMutation({
mutationFn: createSimulation,
@ -63,7 +108,10 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
const correctMutation = useMutation({
mutationFn: correctEe,
onMutate: () => setStep('correcting'),
onSuccess: () => setStep('done'),
onSuccess: () => {
setStep('done')
localStorage.removeItem(LS_SIMULATION_ID_KEY)
},
onError: () => setStep('task-selected'),
})
@ -77,12 +125,19 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
}
function changeSubject(sujet: SujetData): void {
// FTD-21 — persiste le changement côté backend (best-effort : l'UI ne bloque pas).
if (production) {
void updateSujetApi(production.id, sujet.id).catch(() => {
// silencieux : le sujet reste localement, le resume ramènera l'ancien si échec
})
}
setProduction((p) => (p ? { ...p, sujet } : p))
}
function reset(): void {
setStep('idle')
setProduction(null)
localStorage.removeItem(LS_SIMULATION_ID_KEY)
createMutation.reset()
correctMutation.reset()
}