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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { useSimulation } from '../useSimulation'
|
import { useSimulation } from '../useSimulation'
|
||||||
import { SimulationFlowProvider, useSimulationFlow } from '../../state/SimulationFlowProvider'
|
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 { correctEe } from '@/entities/report/api'
|
||||||
import type { Production } from '@/entities/production/types'
|
import type { Production } from '@/entities/production/types'
|
||||||
import type { Report } from '@/entities/report/types'
|
import type { Report } from '@/entities/report/types'
|
||||||
|
|
@ -28,6 +32,8 @@ vi.mock('@/entities/report/api')
|
||||||
|
|
||||||
const mockCreateSimulation = vi.mocked(createSimulation)
|
const mockCreateSimulation = vi.mocked(createSimulation)
|
||||||
const mockCorrectEe = vi.mocked(correctEe)
|
const mockCorrectEe = vi.mocked(correctEe)
|
||||||
|
const mockGetSimulationState = vi.mocked(getSimulationState)
|
||||||
|
const mockUpdateSujet = vi.mocked(updateSujet)
|
||||||
|
|
||||||
const mockProduction: Production = {
|
const mockProduction: Production = {
|
||||||
id: 'sim-1',
|
id: 'sim-1',
|
||||||
|
|
@ -79,6 +85,9 @@ function createWrapper() {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
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', () => {
|
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', () => {
|
describe('useSimulation — reset', () => {
|
||||||
it('reset depuis task-selected remet step à idle et production à null', async () => {
|
it('reset depuis task-selected remet step à idle et production à null', async () => {
|
||||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@
|
||||||
* Règle H : aucune logique métier — les mutations s'appuient sur entities/.
|
* 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 { 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 { correctEe } from '@/entities/report/api'
|
||||||
import type {
|
import type {
|
||||||
CreateSimulationPayload,
|
CreateSimulationPayload,
|
||||||
|
|
@ -29,6 +34,7 @@ export type SimulationStep =
|
||||||
| 'done'
|
| 'done'
|
||||||
|
|
||||||
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
||||||
|
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
||||||
|
|
||||||
interface FlowValue {
|
interface FlowValue {
|
||||||
step: SimulationStep
|
step: SimulationStep
|
||||||
|
|
@ -51,6 +57,45 @@ const SimulationFlowContext = createContext<FlowValue | null>(null)
|
||||||
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
const [step, setStep] = useState<SimulationStep>('idle')
|
const [step, setStep] = useState<SimulationStep>('idle')
|
||||||
const [production, setProduction] = useState<Production | null>(null)
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: createSimulation,
|
mutationFn: createSimulation,
|
||||||
|
|
@ -63,7 +108,10 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
const correctMutation = useMutation({
|
const correctMutation = useMutation({
|
||||||
mutationFn: correctEe,
|
mutationFn: correctEe,
|
||||||
onMutate: () => setStep('correcting'),
|
onMutate: () => setStep('correcting'),
|
||||||
onSuccess: () => setStep('done'),
|
onSuccess: () => {
|
||||||
|
setStep('done')
|
||||||
|
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
||||||
|
},
|
||||||
onError: () => setStep('task-selected'),
|
onError: () => setStep('task-selected'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -77,12 +125,19 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSubject(sujet: SujetData): void {
|
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))
|
setProduction((p) => (p ? { ...p, sujet } : p))
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(): void {
|
function reset(): void {
|
||||||
setStep('idle')
|
setStep('idle')
|
||||||
setProduction(null)
|
setProduction(null)
|
||||||
|
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
||||||
createMutation.reset()
|
createMutation.reset()
|
||||||
correctMutation.reset()
|
correctMutation.reset()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue