feat(simulations): resume session depuis localStorage (FTD-21)
This commit is contained in:
parent
549e5f698f
commit
aaecc3f804
2 changed files with 125 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue