From 24968f542de41a9d7f578c5ec754a7e1cf97b40c Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Tue, 21 Apr 2026 00:16:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(simulations):=20config=20par=20t=C3=A2che?= =?UTF-8?q?=20+=20hook=20useTimer=20+=207=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/__tests__/useTimer.test.ts | 100 ++++++++++++++++++ src/features/simulations/hooks/useTimer.ts | 47 ++++++++ .../simulations/lib/simulationConfig.ts | 52 +++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/features/simulations/hooks/__tests__/useTimer.test.ts create mode 100644 src/features/simulations/hooks/useTimer.ts create mode 100644 src/features/simulations/lib/simulationConfig.ts diff --git a/src/features/simulations/hooks/__tests__/useTimer.test.ts b/src/features/simulations/hooks/__tests__/useTimer.test.ts new file mode 100644 index 0000000..6bf97eb --- /dev/null +++ b/src/features/simulations/hooks/__tests__/useTimer.test.ts @@ -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) + }) +}) diff --git a/src/features/simulations/hooks/useTimer.ts b/src/features/simulations/hooks/useTimer.ts new file mode 100644 index 0000000..49a6db5 --- /dev/null +++ b/src/features/simulations/hooks/useTimer.ts @@ -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, + } +} diff --git a/src/features/simulations/lib/simulationConfig.ts b/src/features/simulations/lib/simulationConfig.ts new file mode 100644 index 0000000..9f5bdf1 --- /dev/null +++ b/src/features/simulations/lib/simulationConfig.ts @@ -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 = { + 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}` +}