feat(simulations): config par tâche + hook useTimer + 7 tests
This commit is contained in:
parent
869668a1ba
commit
24968f542d
3 changed files with 199 additions and 0 deletions
100
src/features/simulations/hooks/__tests__/useTimer.test.ts
Normal file
100
src/features/simulations/hooks/__tests__/useTimer.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* Tests du hook useTimer — logique de décompte critique (auto-submit à l'expiration).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useTimer } from '../useTimer'
|
||||||
|
|
||||||
|
describe('useTimer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initialise secondesRestantes à dureeMinutes × 60', () => {
|
||||||
|
const { result } = renderHook(() => useTimer(10, true))
|
||||||
|
expect(result.current.secondesRestantes).toBe(600)
|
||||||
|
expect(result.current.isExpired).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('décrémente de 1 par seconde quand active=true', () => {
|
||||||
|
const { result } = renderHook(() => useTimer(1, true))
|
||||||
|
expect(result.current.secondesRestantes).toBe(60)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(59)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(56)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne décrémente pas quand active=false', () => {
|
||||||
|
const { result } = renderHook(() => useTimer(1, false))
|
||||||
|
expect(result.current.secondesRestantes).toBe(60)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(60)
|
||||||
|
expect(result.current.isExpired).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe isExpired=true quand secondesRestantes atteint 0', () => {
|
||||||
|
const { result } = renderHook(() => useTimer(1 / 60, true)) // 1 seconde
|
||||||
|
expect(result.current.secondesRestantes).toBe(1)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(0)
|
||||||
|
expect(result.current.isExpired).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("n'évolue pas sous zéro", () => {
|
||||||
|
const { result } = renderHook(() => useTimer(1 / 60, true)) // 1 seconde
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(0)
|
||||||
|
expect(result.current.isExpired).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reset() restaure la valeur initiale', () => {
|
||||||
|
const { result } = renderHook(() => useTimer(1, true))
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(50)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.reset()
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(60)
|
||||||
|
expect(result.current.isExpired).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reprend le décompte quand active passe de false à true sans reset', () => {
|
||||||
|
const { result, rerender } = renderHook(({ active }) => useTimer(1, active), {
|
||||||
|
initialProps: { active: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(60)
|
||||||
|
|
||||||
|
rerender({ active: true })
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000)
|
||||||
|
})
|
||||||
|
expect(result.current.secondesRestantes).toBe(58)
|
||||||
|
})
|
||||||
|
})
|
||||||
47
src/features/simulations/hooks/useTimer.ts
Normal file
47
src/features/simulations/hooks/useTimer.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Hook de décompte en secondes piloté par un flag `active`.
|
||||||
|
*
|
||||||
|
* Règle H : logique isolée du composant, testable unitairement.
|
||||||
|
*
|
||||||
|
* Comportement :
|
||||||
|
* - Initialise `secondesRestantes` à `dureeMinutes * 60`.
|
||||||
|
* - Décrémente de 1 chaque seconde tant que `active === true` et non expiré.
|
||||||
|
* - `isExpired` passe à true lorsque `secondesRestantes` atteint 0.
|
||||||
|
* - `reset()` restaure la valeur initiale et relance le décompte si `active`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface TimerState {
|
||||||
|
secondesRestantes: number
|
||||||
|
isExpired: boolean
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimer(dureeMinutes: number, active: boolean): TimerState {
|
||||||
|
const dureeSecondes = Math.max(0, Math.floor(dureeMinutes * 60))
|
||||||
|
const [secondesRestantes, setSecondesRestantes] = useState(dureeSecondes)
|
||||||
|
const dureeRef = useRef(dureeSecondes)
|
||||||
|
dureeRef.current = dureeSecondes
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
if (secondesRestantes <= 0) return
|
||||||
|
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setSecondesRestantes((prev) => (prev <= 0 ? 0 : prev - 1))
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [active, secondesRestantes])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setSecondesRestantes(dureeRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
secondesRestantes,
|
||||||
|
isExpired: secondesRestantes <= 0,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/features/simulations/lib/simulationConfig.ts
Normal file
52
src/features/simulations/lib/simulationConfig.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Configuration par tâche de simulation : durée et cibles de mots.
|
||||||
|
*
|
||||||
|
* Règle H : logique métier regroupée ici, jamais dans les composants UI.
|
||||||
|
*
|
||||||
|
* Les valeurs EE viennent des consignes TCF Canada officielles.
|
||||||
|
* Les valeurs EO sont symboliques (placeholders) — seront ajustées quand
|
||||||
|
* les flux EO seront implémentés côté frontend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tache } from '@/entities/production/types'
|
||||||
|
|
||||||
|
export interface SimulationConfig {
|
||||||
|
/** Durée du minuteur en minutes. */
|
||||||
|
dureeMinutes: number
|
||||||
|
/** Seuil minimum de mots pour autoriser la soumission. */
|
||||||
|
motsMin: number
|
||||||
|
/** Borne basse de la cible TCF. */
|
||||||
|
motsCibleMin: number
|
||||||
|
/** Borne haute de la cible TCF. */
|
||||||
|
motsCibleMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIMULATION_CONFIG: Record<Tache, SimulationConfig> = {
|
||||||
|
EE_T1: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 120 },
|
||||||
|
EE_T2: { dureeMinutes: 20, motsMin: 30, motsCibleMin: 120, motsCibleMax: 150 },
|
||||||
|
EE_T3: { dureeMinutes: 30, motsMin: 30, motsCibleMin: 120, motsCibleMax: 180 },
|
||||||
|
EO_T1: { dureeMinutes: 5, motsMin: 30, motsCibleMin: 30, motsCibleMax: 80 },
|
||||||
|
EO_T3: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 150 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSimulationConfig(tache: Tache): SimulationConfig {
|
||||||
|
return SIMULATION_CONFIG[tache]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les mots d'un texte en découpant sur les espaces / caractères blancs.
|
||||||
|
* Un "mot" = toute séquence non-blanche. Retourne 0 si le texte est vide.
|
||||||
|
*/
|
||||||
|
export function countWords(texte: string): number {
|
||||||
|
const trimmed = texte.trim()
|
||||||
|
if (trimmed.length === 0) return 0
|
||||||
|
return trimmed.split(/\s+/).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formate un nombre de secondes en MM:SS (ex. 125 → "02:05"). */
|
||||||
|
export function formatTimer(secondes: number): string {
|
||||||
|
const safe = Math.max(0, Math.floor(secondes))
|
||||||
|
const mm = Math.floor(safe / 60).toString().padStart(2, '0')
|
||||||
|
const ss = (safe % 60).toString().padStart(2, '0')
|
||||||
|
return `${mm}:${ss}`
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue