Tests du parcours guidé : angles morts des harnais React (chargeur, dépendances des useMemo, effets) #409
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#409
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Contexte
Les revues de la pile #402 → #407 ont relevé des mutations d'une ligne que la suite laisse vertes. Le code est juste ; ce sont des épingles manquantes, dues à des harnais qui simulent React sans sa sémantique. Le dépôt n'a pas de jsdom : les tests appellent hooks et composants comme des fonctions sous
vi.mock("react").Vérifié en analyse
src/hooks/useOnboardingChecklist.wiring.test.ts:useStaterappelle l'initialiseur à chaque appel (:26),useMemo: fn => fn()(:28),useCallback: fn => fn(:27),useReducerrenvoie undispatchespion qui n'applique pas le reducer (:25).useOnboardingChecklist.ts:318-320, chargeur horsuseState) : aucun test n'appelle le hook deux fois. Confirmé.state.skippedretiré des deps dechecklist,:375) : confirmé. Le reducer SKIP (:120-128) garde la même référencecounters, ce qui isoleskipped.moderetiré des deps de la valeur partagée,:408-422) : confirmé, mais unuseMemopersistant ne suffit pas :refreshethide/show/skip/resumesont recréés à chaque appel par les fauxuseState/useCallbacket figurent dans les mêmes deps, donc la mémo serait recalculée de toute façon. Le harnais doit rendre persistantsuseState,useCallbacketuseMemo. (moden'est pas dans l'état : il est dérivé parderiveOnboardingView,:265.)UsersSettingsPage.test.tsx:148-152(« never reads the preference itself ») : le fichier a unuseStatepersistant (:11-33) mais pas deuseEffectsimulé. Un effet réintroduit appelle le vraiuseEffecthors rendu et lève « Invalid hook call » : le test rougit par accident. Confirmé.src/components/budget/BudgetTable.wiring.test.ts:17-50,110-121(useState/useRefen slots,useEffectcapturé,commit()qui relance un effet si ses deps changent,Object.is). Copies partielles du slotuseState/useRefdansBudgetPage.wiring.test.ts:12-37,UsersSettingsPage.test.tsx:11-33,AppShell.slot.test.tsx:22,OnboardingStepCard.test.tsx. Fakes non persistants dansuseImportWizard.mount.test.tsetuseImportWizard.executeImport.test.ts. Aucunsrc/test/, pas desetupFilesdansvite.config.ts.Décision (Max, 2026-10-10)
Module partagé + migration. Un harnais unique dans
src/test/hookHarness.ts, adopté par les tests d'onboarding et par les copies existantes. Pour la page Utilisateurs, l'effet est simulé par le harnais (capturé puis joué parcommit()), pas gardé par une lecture de la source : l'assertiongetPreferencenon appelé devient un vrai test.Travail à faire
src/test/hookHarness.ts:createHookHarness()(slots à curseur,reset()entre deux rendus, effets capturés,commit()) etfakeReact(harness, actual)qui fournituseState(init une fois, setter stable),useRef,useMemoetuseCallback(recalcul seulement si une dep change,Object.is),useEffect(capturé, relancé parcommit()si ses deps changent, nettoyage appelé),useReducer(applique le reducer). Contrainte : la fabrique devi.mockest hoistée ; utiliservi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(...))ouvi.hoisted. Écrire en tête du module comment l'employer.hookHarness.test.ts) : init une fois, mémo stable à deps égales, recalcul à dep changée, effet relancé seulement sur changement.useOnboardingChecklist.wiring.test.ts: passer au harnais ; ajouter un test par mutation (identité derefreshentre deux rendus ; « Passer » fait avancer la carte au rendu suivant ; changement demodeseul change la valeur partagée).UsersSettingsPage.test.tsx: harnais avecuseEffectsimulé ; le test « never reads the preference itself » rougit sur l'assertion quand un effet lit la préférence.BudgetTable.wiring.test.ts,BudgetPage.wiring.test.ts,AppShell.slot.test.tsx,OnboardingStepCard.test.tsx.useImportWizard.mount/executeImport: hors périmètre (décision du 2026-10-10).docs/architecture.md, section## Tests (vitest sans jsdom)(à créer, juste avant CI/CD) : le harnais, son motif d'import, ce qu'il ne simule pas.Fichiers concernés
src/test/hookHarness.ts,src/test/hookHarness.test.ts(nouveaux)src/hooks/useOnboardingChecklist.wiring.test.ts,src/pages/settings/UsersSettingsPage.test.tsxsrc/components/budget/BudgetTable.wiring.test.ts,src/pages/BudgetPage.wiring.test.ts,src/components/layout/AppShell.slot.test.tsx,src/components/onboarding/OnboardingStepCard.test.tsxdocs/architecture.mdSurface de test
useEffectqui litgetPreferencedansUsersSettingsPage». Ajouter une mutation par fichier migré (une dep d'effet retirée, ou unuseStateremplacé par une valeur) pour prouver que la migration n'a rien perdu.Critères d'acceptation
useState/useRefdans les fichiers migrés.npm testvert, nombre de tests égal ou supérieur.Complexité estimée
Medium.
Liens
Revues de #402 (commentaire 20659, point 2) et #403 (commentaire 20676, suggestion 3) ; #389 (faux
t, module voisinsrc/test/i18nMock.ts) ; ADR 0022.Depends on
Critères d'acceptation (planification)
vi.mock("react", async (orig) => (await import("<chemin>/test/hookHarness")).fakeReact(await orig())), l'état du harnais lu par un accesseur exporté.hookHarness.tsn'importe React qu'enimport type(sinon la fabrique chargerait le mock qu'elle construit) ;hookHarness.test.tsle vérifie (lecture de la source).useReducerdu harnais applique le reducer et expose un espiondispatchinspectable : les assertions existantes surharness.dispatch.mock.calls(useOnboardingChecklist.wiring.test.ts:82,113,175) restent valides.setState, effets joués seulement parcommit()(nettoyage avant relance), et tout hook non couvert (useContext,useLayoutEffect,useSyncExternalStore…) lève au lieu de passer au vrai React.hookHarness.test.tsprouve chaque sémantique couverte (init une fois, setter stable, mémo et callback stables à deps égales, recalcul à dep changée, effet relancé seulement sur changement, nettoyage, reducer appliqué, hook non couvert qui lève).Review caveats
useReduceravec espion.Décisions prises en planification
/analyze) ; effet simulé pour la page Utilisateurs, pas de garde sur la source.useImportWizard.mount/executeImporthors périmètre (Max, 2026-10-10).docs/architecture.mdfixées pour toute la milestone :### Accès DB et transactions(sous la section Base de données) et## Tests (vitest sans jsdom)(juste avant CI/CD). La première issue de la pile qui en a besoin crée la section ; les suivantes y ajoutent.Spec source
spec-plan-fiabilite-tests.md(local, ignoré par git)Archive avant réécriture par
/analyze(2026-10-10, lu surmain87920ec). Le corps est réécrit avec les décisions de Max du jour ; le texte d'origine est conservé ci-dessous.Corps d'origine
Constat
Deux revues de la pile #402 → #407 ont relevé des mutations d'une ligne que la suite laisse vertes. Toutes viennent de harnais qui simulent React sans sa sémantique. Le code est juste aujourd'hui : ce sont des épingles manquantes.
vi.mock("react")des tests deuseOnboardingChecklist(revue de #402, commentaire 20659, point 2). LeuseStatefactice rappelle l'initialiseur à chaque appel, et leuseMemofactice recalcule toujours. Trois mutations restent vertes (0 rouge sur 54 à 59 tests) :useState). En vrai,refreshchangerait à chaque rendu, l'effet[refresh, pathname]relirait à chaque rendu, chaque relecture redéclencherait un rendu (boucle de lectures), et D9 repartirait à chaque chargement ;state.skippedretiré des dépendances duuseMemodechecklist. Après « Passer », l'étape suivante ne bougerait qu'à la relecture suivante ; orOnboardingStepCardappelleskip, et c'est cette dépendance qui fait avancer la carte ;moderetiré des dépendances de la valeur partagée. Un changement de mode seul (de/vide vers/reports) laisserait l'ancien mode aux consommateurs jusqu'au changement d'une autre dépendance.Piste : un harnais à emplacements persistants entre deux appels du hook (
useStateinitialisé une fois, mémo recalculé seulement quand une dépendance change).BudgetPage.wiring.test.tsen a déjà un pouruseState.UsersSettingsPage.test.tsx, « never reads the preference itself » (revue de #403, commentaire 20676, suggestion 3). La page est appelée comme une fonction, donc aucun effet ne tourne : une lecture de la préférence remise dans unuseEffectfait rougir les 6 tests par « Invalid hook call », pas par cette assertion. La règle de #397 (la page lit l'état partagé, jamais la préférence elle-même) n'est gardée que par accident. Piste : une vérification sur la source (aucun import deuserPreferenceServiceni deONBOARDING_CHECKLIST_HIDDEN_KEYdans la page), ou un nom de test qui dit ce qu'il prouve.maximus referenced this issue2026-10-14 00:29:49 +00:00