fix(lint): 4 erreurs ESLint corrigées — split SimulationFlowProvider, hook conditionnel, ref render, setState effect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de69b3ff16
commit
79bbbdc4e8
10 changed files with 82 additions and 50 deletions
|
|
@ -30,8 +30,11 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||||
const { data } = usePlan()
|
const { data } = usePlan()
|
||||||
const plan: Plan = data?.plan ?? 'free'
|
const plan: Plan = data?.plan ?? 'free'
|
||||||
|
|
||||||
// Ferme le drawer à chaque changement de route
|
// Ferme le drawer à chaque changement de route.
|
||||||
|
// Synchronisation UI → router state : pattern légitime (source externe = React
|
||||||
|
// Router). Bail-out React si déjà fermé = zéro cascading render en pratique.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsMobileMenuOpen(false)
|
setIsMobileMenuOpen(false)
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,14 @@ function gaugeColor(score: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonProfilPreparation({ plan }: Props) {
|
export function MonProfilPreparation({ plan }: Props) {
|
||||||
// Garde explicite (cohérent avec la logique du hook qui a déjà `enabled`).
|
// Hook appelé inconditionnellement (règle React). Il court-circuite la
|
||||||
if (!hasAccess(plan, 'pattern_analysis')) return null
|
// requête backend via `enabled: hasAccess(plan, 'pattern_analysis')`,
|
||||||
|
// donc aucun appel parasite pour Free/Standard.
|
||||||
const { data, isLoading, isError } = usePatterns(plan)
|
const { data, isLoading, isError } = usePatterns(plan)
|
||||||
|
|
||||||
|
// Garde explicite après le hook pour éviter un flash de contenu.
|
||||||
|
if (!hasAccess(plan, 'pattern_analysis')) return null
|
||||||
|
|
||||||
if (isLoading || isError || !data) {
|
if (isLoading || isError || !data) {
|
||||||
return (
|
return (
|
||||||
<Card variant="default" className="p-4">
|
<Card variant="default" className="p-4">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Le hook `usePatterns` est mocké pour isoler la présentation.
|
* Le hook `usePatterns` est mocké pour isoler la présentation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||||
import { render, screen, cleanup } from '@testing-library/react'
|
import { render, screen, cleanup } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
|
@ -16,6 +16,16 @@ vi.mock('@/features/progression/hooks/usePatterns', () => ({
|
||||||
import { usePatterns } from '@/features/progression/hooks/usePatterns'
|
import { usePatterns } from '@/features/progression/hooks/usePatterns'
|
||||||
import { MonProfilPreparation } from '../MonProfilPreparation'
|
import { MonProfilPreparation } from '../MonProfilPreparation'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock par défaut — usePatterns est appelé inconditionnellement depuis le
|
||||||
|
// composant (Règle des hooks). Les tests Premium surchargent ce mock.
|
||||||
|
vi.mocked(usePatterns).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as unknown as ReturnType<typeof usePatterns>)
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
function renderWithRouter(ui: React.ReactNode) {
|
function renderWithRouter(ui: React.ReactNode) {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
||||||
import { useTimer } from '../hooks/useTimer'
|
import { useTimer } from '../hooks/useTimer'
|
||||||
import { useIdees } from '../hooks/useIdees'
|
import { useIdees } from '../hooks/useIdees'
|
||||||
import { useAutosave } from '../hooks/useAutosave'
|
import { useAutosave } from '../hooks/useAutosave'
|
||||||
import type { SimulationStep } from '../state/SimulationFlowProvider'
|
import type { SimulationStep } from '../state/simulationFlow'
|
||||||
import { SujetDisplay } from './SujetDisplay'
|
import { SujetDisplay } from './SujetDisplay'
|
||||||
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
||||||
import { TimerDisplay } from './TimerDisplay'
|
import { TimerDisplay } from './TimerDisplay'
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import { MemoryRouter } from 'react-router-dom'
|
||||||
import React from 'react'
|
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 } from '../../state/SimulationFlowProvider'
|
||||||
|
import { useSimulationFlow } from '../../state/simulationFlow'
|
||||||
import {
|
import {
|
||||||
createSimulation,
|
createSimulation,
|
||||||
getSimulationState,
|
getSimulationState,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useSimulationFlow } from '../state/SimulationFlowProvider'
|
import { useSimulationFlow } from '../state/simulationFlow'
|
||||||
|
|
||||||
export function useSimulation() {
|
export function useSimulation() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ export function useTimer(dureeMinutes: number, active: boolean): TimerState {
|
||||||
const dureeSecondes = Math.max(0, Math.floor(dureeMinutes * 60))
|
const dureeSecondes = Math.max(0, Math.floor(dureeMinutes * 60))
|
||||||
const [secondesRestantes, setSecondesRestantes] = useState(dureeSecondes)
|
const [secondesRestantes, setSecondesRestantes] = useState(dureeSecondes)
|
||||||
const dureeRef = useRef(dureeSecondes)
|
const dureeRef = useRef(dureeSecondes)
|
||||||
dureeRef.current = dureeSecondes
|
useEffect(() => {
|
||||||
|
dureeRef.current = dureeSecondes
|
||||||
|
}, [dureeSecondes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { Shuffle } from 'lucide-react'
|
||||||
import { Button } from '@/shared/ui/Button'
|
import { Button } from '@/shared/ui/Button'
|
||||||
import { formatTache } from '@/entities/production/lib'
|
import { formatTache } from '@/entities/production/lib'
|
||||||
import type { SujetData } from '@/entities/production/types'
|
import type { SujetData } from '@/entities/production/types'
|
||||||
import { useSimulationFlow } from '../state/SimulationFlowProvider'
|
import { useSimulationFlow } from '../state/simulationFlow'
|
||||||
import { useSujets } from '../hooks/useSujets'
|
import { useSujets } from '../hooks/useSujets'
|
||||||
import { SujetCard } from '../components/SujetCard'
|
import { SujetCard } from '../components/SujetCard'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* 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, useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,43 +17,15 @@ import {
|
||||||
updateSujet as updateSujetApi,
|
updateSujet as updateSujetApi,
|
||||||
} from '@/entities/production/api'
|
} from '@/entities/production/api'
|
||||||
import { correctEe } from '@/entities/report/api'
|
import { correctEe } from '@/entities/report/api'
|
||||||
import type {
|
import type { CreateSimulationPayload, Production, Tache } from '@/entities/production/types'
|
||||||
CreateSimulationPayload,
|
|
||||||
Production,
|
|
||||||
SujetData,
|
|
||||||
Tache,
|
|
||||||
} from '@/entities/production/types'
|
|
||||||
import type { Report } from '@/entities/report/types'
|
import type { Report } from '@/entities/report/types'
|
||||||
import type { ApiError } from '@/shared/types/api'
|
import type { ApiError } from '@/shared/types/api'
|
||||||
|
import type { SujetData } from '@/entities/production/types'
|
||||||
export type SimulationStep =
|
import { SimulationFlowContext, type FlowValue, type SimulationStep } from './simulationFlow'
|
||||||
| 'idle'
|
|
||||||
| 'choosing-subject'
|
|
||||||
| 'task-selected'
|
|
||||||
| 'correcting'
|
|
||||||
| 'done'
|
|
||||||
|
|
||||||
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
||||||
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
||||||
|
|
||||||
interface FlowValue {
|
|
||||||
step: SimulationStep
|
|
||||||
production: Production | null
|
|
||||||
sujet: SujetData | null
|
|
||||||
report: Report | null
|
|
||||||
isCreating: boolean
|
|
||||||
isCorrecting: boolean
|
|
||||||
createError: ApiError | null
|
|
||||||
correctError: ApiError | null
|
|
||||||
selectTask: (payload: CreateSimulationPayload) => void
|
|
||||||
submitText: (texte: string, nclcCible?: 9 | 10) => void
|
|
||||||
changeSubject: (sujet: SujetData) => void
|
|
||||||
setStep: (step: SimulationStep) => void
|
|
||||||
reset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -178,11 +150,3 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
<SimulationFlowContext.Provider value={value}>{children}</SimulationFlowContext.Provider>
|
<SimulationFlowContext.Provider value={value}>{children}</SimulationFlowContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSimulationFlow(): FlowValue {
|
|
||||||
const ctx = useContext(SimulationFlowContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useSimulationFlow doit être utilisé dans un <SimulationFlowProvider>.')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
|
||||||
49
src/features/simulations/state/simulationFlow.ts
Normal file
49
src/features/simulations/state/simulationFlow.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Types, contexte et hook du flux simulation.
|
||||||
|
*
|
||||||
|
* Extrait de SimulationFlowProvider.tsx pour respecter la règle
|
||||||
|
* `react-refresh/only-export-components` : un fichier de composant ne peut
|
||||||
|
* pas ré-exporter types / hooks / contextes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import type {
|
||||||
|
CreateSimulationPayload,
|
||||||
|
Production,
|
||||||
|
SujetData,
|
||||||
|
} from '@/entities/production/types'
|
||||||
|
import type { Report } from '@/entities/report/types'
|
||||||
|
import type { ApiError } from '@/shared/types/api'
|
||||||
|
|
||||||
|
export type SimulationStep =
|
||||||
|
| 'idle'
|
||||||
|
| 'choosing-subject'
|
||||||
|
| 'task-selected'
|
||||||
|
| 'correcting'
|
||||||
|
| 'done'
|
||||||
|
|
||||||
|
export interface FlowValue {
|
||||||
|
step: SimulationStep
|
||||||
|
production: Production | null
|
||||||
|
sujet: SujetData | null
|
||||||
|
report: Report | null
|
||||||
|
isCreating: boolean
|
||||||
|
isCorrecting: boolean
|
||||||
|
createError: ApiError | null
|
||||||
|
correctError: ApiError | null
|
||||||
|
selectTask: (payload: CreateSimulationPayload) => void
|
||||||
|
submitText: (texte: string, nclcCible?: 9 | 10) => void
|
||||||
|
changeSubject: (sujet: SujetData) => void
|
||||||
|
setStep: (step: SimulationStep) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimulationFlowContext = createContext<FlowValue | null>(null)
|
||||||
|
|
||||||
|
export function useSimulationFlow(): FlowValue {
|
||||||
|
const ctx = useContext(SimulationFlowContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSimulationFlow doit être utilisé dans un <SimulationFlowProvider>.')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue