From aaecc3f80481863d0bdaa55c27afcbc48b2b6864 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Tue, 21 Apr 2026 04:02:15 +0300 Subject: [PATCH] feat(simulations): resume session depuis localStorage (FTD-21) --- .../hooks/__tests__/useSimulation.test.tsx | 68 ++++++++++++++++++- .../state/SimulationFlowProvider.tsx | 61 ++++++++++++++++- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx index 14826dc..05f6de0 100644 --- a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx +++ b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx @@ -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) diff --git a/src/features/simulations/state/SimulationFlowProvider.tsx b/src/features/simulations/state/SimulationFlowProvider.tsx index c091eab..edd3762 100644 --- a/src/features/simulations/state/SimulationFlowProvider.tsx +++ b/src/features/simulations/state/SimulationFlowProvider.tsx @@ -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(null) export function SimulationFlowProvider({ children }: { children: ReactNode }) { const [step, setStep] = useState('idle') const [production, setProduction] = useState(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() }