Tests du parcours guidé : angles morts des harnais React (chargeur, dépendances des useMemo, effets) #409

Closed
opened 2026-10-09 01:12:03 +00:00 by maximus · 1 comment
Owner

Planification du 2026-10-10 (/plan-run, milestone planned-2026-10-10-fiabilite-tests) : les sections ci-dessous, à partir de « Depends on », priment sur le texte d'analyse en cas d'écart.

Analyse /analyze du 2026-10-10, lue sur main 87920ec. Corps d'origine archivé en commentaire.

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 : useState rappelle l'initialiseur à chaque appel (:26), useMemo: fn => fn() (:28), useCallback: fn => fn (:27), useReducer renvoie un dispatch espion qui n'applique pas le reducer (:25).
  • RM3 (useOnboardingChecklist.ts:318-320, chargeur hors useState) : aucun test n'appelle le hook deux fois. Confirmé.
  • RM4 (state.skipped retiré des deps de checklist, :375) : confirmé. Le reducer SKIP (:120-128) garde la même référence counters, ce qui isole skipped.
  • RM12 (mode retiré des deps de la valeur partagée, :408-422) : confirmé, mais un useMemo persistant ne suffit pas : refresh et hide/show/skip/resume sont recréés à chaque appel par les faux useState/useCallback et figurent dans les mêmes deps, donc la mémo serait recalculée de toute façon. Le harnais doit rendre persistants useState, useCallback et useMemo. (mode n'est pas dans l'état : il est dérivé par deriveOnboardingView, :265.)
  • UsersSettingsPage.test.tsx:148-152 (« never reads the preference itself ») : le fichier a un useState persistant (:11-33) mais pas de useEffect simulé. Un effet réintroduit appelle le vrai useEffect hors rendu et lève « Invalid hook call » : le test rougit par accident. Confirmé.
  • Harnais existants : le plus complet est src/components/budget/BudgetTable.wiring.test.ts:17-50,110-121 (useState/useRef en slots, useEffect capturé, commit() qui relance un effet si ses deps changent, Object.is). Copies partielles du slot useState/useRef dans BudgetPage.wiring.test.ts:12-37, UsersSettingsPage.test.tsx:11-33, AppShell.slot.test.tsx:22, OnboardingStepCard.test.tsx. Fakes non persistants dans useImportWizard.mount.test.ts et useImportWizard.executeImport.test.ts. Aucun src/test/, pas de setupFiles dans vite.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é par commit()), pas gardé par une lecture de la source : l'assertion getPreference non appelé devient un vrai test.

Travail à faire

  • src/test/hookHarness.ts : createHookHarness() (slots à curseur, reset() entre deux rendus, effets capturés, commit()) et fakeReact(harness, actual) qui fournit useState (init une fois, setter stable), useRef, useMemo et useCallback (recalcul seulement si une dep change, Object.is), useEffect (capturé, relancé par commit() si ses deps changent, nettoyage appelé), useReducer (applique le reducer). Contrainte : la fabrique de vi.mock est hoistée ; utiliser vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(...)) ou vi.hoisted. Écrire en tête du module comment l'employer.
  • Tests du harnais lui-même (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é de refresh entre deux rendus ; « Passer » fait avancer la carte au rendu suivant ; changement de mode seul change la valeur partagée).
  • UsersSettingsPage.test.tsx : harnais avec useEffect simulé ; le test « never reads the preference itself » rougit sur l'assertion quand un effet lit la préférence.
  • Migrer les copies : 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.
  • Pas d'entrée CHANGELOG (tests seulement).

Fichiers concernés

  • src/test/hookHarness.ts, src/test/hookHarness.test.ts (nouveaux)
  • src/hooks/useOnboardingChecklist.wiring.test.ts, src/pages/settings/UsersSettingsPage.test.tsx
  • src/components/budget/BudgetTable.wiring.test.ts, src/pages/BudgetPage.wiring.test.ts, src/components/layout/AppShell.slot.test.tsx, src/components/onboarding/OnboardingStepCard.test.tsx
  • docs/architecture.md

Surface de test

  • La preuve est la passe de mutations sur le code de production, chacune rouge (code de sortie 1) : RM3, RM4, RM12, et « useEffect qui lit getPreference dans UsersSettingsPage ». Ajouter une mutation par fichier migré (une dep d'effet retirée, ou un useState remplacé par une valeur) pour prouver que la migration n'a rien perdu.
  • Aucun code de production ne change.

Critères d'acceptation

  • RM3, RM4, RM12 et la mutation de la page Utilisateurs font chacune rougir au moins un test, sur une assertion (pas sur « Invalid hook call »).
  • Plus aucune copie locale du slot useState/useRef dans les fichiers migrés.
  • npm test vert, 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 voisin src/test/i18nMock.ts) ; ADR 0022.


Depends on

Critères d'acceptation (planification)

  • Motif unique, écrit en tête du module et utilisé par tous les fichiers migrés : vi.mock("react", async (orig) => (await import("<chemin>/test/hookHarness")).fakeReact(await orig())), l'état du harnais lu par un accesseur exporté. hookHarness.ts n'importe React qu'en import type (sinon la fabrique chargerait le mock qu'elle construit) ; hookHarness.test.ts le vérifie (lecture de la source).
  • useReducer du harnais applique le reducer et expose un espion dispatch inspectable : les assertions existantes sur harness.dispatch.mock.calls (useOnboardingChecklist.wiring.test.ts:82,113,175) restent valides.
  • Sémantique écrite en tête du module : pas de re-rendu automatique sur setState, effets joués seulement par commit() (nettoyage avant relance), et tout hook non couvert (useContext, useLayoutEffect, useSyncExternalStore…) lève au lieu de passer au vrai React.
  • hookHarness.test.ts prouve 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

  • 🟡 (résolu) Motif d'import et singleton du module fixés ; useReducer avec espion.
  • 🟢 Le harnais simule React : chaque sémantique doit être prouvée par son propre test, sinon les tests qui s'appuient dessus peuvent mentir.

Décisions prises en planification

  • Module partagé + migration (Max, 2026-10-10, /analyze) ; effet simulé pour la page Utilisateurs, pas de garde sur la source.
  • useImportWizard.mount/executeImport hors périmètre (Max, 2026-10-10).
  • Sections de docs/architecture.md fixé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.
  • Dépend de #408 seulement pour l'ordre de pile (merge en avance rapide), pas pour le code.

Spec source

spec-plan-fiabilite-tests.md (local, ignoré par git)

> **Planification du 2026-10-10** (`/plan-run`, milestone `planned-2026-10-10-fiabilite-tests`) : les sections ci-dessous, à partir de « Depends on », **priment** sur le texte d'analyse en cas d'écart. > Analyse `/analyze` du 2026-10-10, lue sur `main` `87920ec`. Corps d'origine archivé en commentaire. ## 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` : `useState` rappelle l'initialiseur à chaque appel (`:26`), `useMemo: fn => fn()` (`:28`), `useCallback: fn => fn` (`:27`), `useReducer` renvoie un `dispatch` espion qui n'applique pas le reducer (`:25`). - **RM3** (`useOnboardingChecklist.ts:318-320`, chargeur hors `useState`) : aucun test n'appelle le hook deux fois. Confirmé. - **RM4** (`state.skipped` retiré des deps de `checklist`, `:375`) : confirmé. Le reducer SKIP (`:120-128`) garde la même référence `counters`, ce qui isole `skipped`. - **RM12** (`mode` retiré des deps de la valeur partagée, `:408-422`) : confirmé, **mais un `useMemo` persistant ne suffit pas** : `refresh` et `hide/show/skip/resume` sont recréés à chaque appel par les faux `useState`/`useCallback` et figurent dans les mêmes deps, donc la mémo serait recalculée de toute façon. Le harnais doit rendre persistants `useState`, `useCallback` **et** `useMemo`. (`mode` n'est pas dans l'état : il est dérivé par `deriveOnboardingView`, `:265`.) - **`UsersSettingsPage.test.tsx:148-152`** (« never reads the preference itself ») : le fichier a un `useState` persistant (`:11-33`) mais pas de `useEffect` simulé. Un effet réintroduit appelle le vrai `useEffect` hors rendu et lève « Invalid hook call » : le test rougit par accident. Confirmé. - **Harnais existants** : le plus complet est `src/components/budget/BudgetTable.wiring.test.ts:17-50,110-121` (`useState`/`useRef` en slots, `useEffect` capturé, `commit()` qui relance un effet si ses deps changent, `Object.is`). Copies partielles du slot `useState`/`useRef` dans `BudgetPage.wiring.test.ts:12-37`, `UsersSettingsPage.test.tsx:11-33`, `AppShell.slot.test.tsx:22`, `OnboardingStepCard.test.tsx`. Fakes non persistants dans `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts`. Aucun `src/test/`, pas de `setupFiles` dans `vite.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é par `commit()`), pas gardé par une lecture de la source : l'assertion `getPreference` non appelé devient un vrai test. ## Travail à faire - [ ] `src/test/hookHarness.ts` : `createHookHarness()` (slots à curseur, `reset()` entre deux rendus, effets capturés, `commit()`) et `fakeReact(harness, actual)` qui fournit `useState` (init une fois, setter stable), `useRef`, `useMemo` et `useCallback` (recalcul seulement si une dep change, `Object.is`), `useEffect` (capturé, relancé par `commit()` si ses deps changent, nettoyage appelé), `useReducer` (applique le reducer). Contrainte : la fabrique de `vi.mock` est hoistée ; utiliser `vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(...))` ou `vi.hoisted`. Écrire en tête du module comment l'employer. - [ ] Tests du harnais lui-même (`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é de `refresh` entre deux rendus ; « Passer » fait avancer la carte au rendu suivant ; changement de `mode` seul change la valeur partagée). - [ ] `UsersSettingsPage.test.tsx` : harnais avec `useEffect` simulé ; le test « never reads the preference itself » rougit sur l'assertion quand un effet lit la préférence. - [ ] Migrer les copies : `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. - Pas d'entrée CHANGELOG (tests seulement). ## Fichiers concernés - `src/test/hookHarness.ts`, `src/test/hookHarness.test.ts` (nouveaux) - `src/hooks/useOnboardingChecklist.wiring.test.ts`, `src/pages/settings/UsersSettingsPage.test.tsx` - `src/components/budget/BudgetTable.wiring.test.ts`, `src/pages/BudgetPage.wiring.test.ts`, `src/components/layout/AppShell.slot.test.tsx`, `src/components/onboarding/OnboardingStepCard.test.tsx` - `docs/architecture.md` ## Surface de test - La preuve est la passe de mutations sur le code de production, chacune rouge (code de sortie 1) : RM3, RM4, RM12, et « `useEffect` qui lit `getPreference` dans `UsersSettingsPage` ». Ajouter une mutation par fichier migré (une dep d'effet retirée, ou un `useState` remplacé par une valeur) pour prouver que la migration n'a rien perdu. - Aucun code de production ne change. ## Critères d'acceptation - [ ] RM3, RM4, RM12 et la mutation de la page Utilisateurs font chacune rougir au moins un test, sur une assertion (pas sur « Invalid hook call »). - [ ] Plus aucune copie locale du slot `useState`/`useRef` dans les fichiers migrés. - [ ] `npm test` vert, 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 voisin `src/test/i18nMock.ts`) ; ADR 0022. --- ## Depends on - #408 ## Critères d'acceptation (planification) - [ ] **Motif unique**, écrit en tête du module et utilisé par tous les fichiers migrés : `vi.mock("react", async (orig) => (await import("<chemin>/test/hookHarness")).fakeReact(await orig()))`, l'état du harnais lu par un accesseur exporté. `hookHarness.ts` n'importe React qu'en `import type` (sinon la fabrique chargerait le mock qu'elle construit) ; `hookHarness.test.ts` le vérifie (lecture de la source). - [ ] `useReducer` du harnais applique le reducer **et** expose un espion `dispatch` inspectable : les assertions existantes sur `harness.dispatch.mock.calls` (`useOnboardingChecklist.wiring.test.ts:82,113,175`) restent valides. - [ ] Sémantique écrite en tête du module : pas de re-rendu automatique sur `setState`, effets joués seulement par `commit()` (nettoyage avant relance), et tout hook non couvert (`useContext`, `useLayoutEffect`, `useSyncExternalStore`…) **lève** au lieu de passer au vrai React. - [ ] `hookHarness.test.ts` prouve 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 - 🟡 (résolu) Motif d'import et singleton du module fixés ; `useReducer` avec espion. - 🟢 Le harnais simule React : chaque sémantique doit être prouvée par son propre test, sinon les tests qui s'appuient dessus peuvent mentir. ## Décisions prises en planification - Module partagé + migration (Max, 2026-10-10, `/analyze`) ; effet simulé pour la page Utilisateurs, pas de garde sur la source. - `useImportWizard.mount/executeImport` hors périmètre (Max, 2026-10-10). - Sections de `docs/architecture.md` fixé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. - Dépend de #408 seulement pour l'ordre de pile (merge en avance rapide), pas pour le code. ## Spec source `spec-plan-fiabilite-tests.md` (local, ignoré par git)
maximus added the
status:triage
type:refactor
source:human
labels 2026-10-09 01:12:03 +00:00
Author
Owner

Archive avant réécriture par /analyze (2026-10-10, lu sur main 87920ec). 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.

  1. vi.mock("react") des tests de useOnboardingChecklist (revue de #402, commentaire 20659, point 2). Le useState factice rappelle l'initialiseur à chaque appel, et le useMemo factice recalcule toujours. Trois mutations restent vertes (0 rouge sur 54 à 59 tests) :

    • RM3 : le chargeur recréé à chaque rendu (plus de useState). En vrai, refresh changerait à chaque rendu, l'effet [refresh, pathname] relirait à chaque rendu, chaque relecture redéclencherait un rendu (boucle de lectures), et D9 repartirait à chaque chargement ;
    • RM4 : state.skipped retiré des dépendances du useMemo de checklist. Après « Passer », l'étape suivante ne bougerait qu'à la relecture suivante ; or OnboardingStepCard appelle skip, et c'est cette dépendance qui fait avancer la carte ;
    • RM12 : mode retiré 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 (useState initialisé une fois, mémo recalculé seulement quand une dépendance change). BudgetPage.wiring.test.ts en a déjà un pour useState.

  2. 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 un useEffect fait 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 de userPreferenceService ni de ONBOARDING_CHECKLIST_HIDDEN_KEY dans la page), ou un nom de test qui dit ce qu'il prouve.

Archive avant réécriture par `/analyze` (2026-10-10, lu sur `main` `87920ec`). Le corps est réécrit avec les décisions de Max du jour ; le texte d'origine est conservé ci-dessous. <details><summary>Corps d'origine</summary> ## 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. 1. **`vi.mock("react")` des tests de `useOnboardingChecklist`** (revue de #402, commentaire 20659, point 2). Le `useState` factice rappelle l'initialiseur à chaque appel, et le `useMemo` factice recalcule toujours. Trois mutations restent vertes (0 rouge sur 54 à 59 tests) : - RM3 : le chargeur recréé à chaque rendu (plus de `useState`). En vrai, `refresh` changerait à chaque rendu, l'effet `[refresh, pathname]` relirait à chaque rendu, chaque relecture redéclencherait un rendu (boucle de lectures), et D9 repartirait à chaque chargement ; - RM4 : `state.skipped` retiré des dépendances du `useMemo` de `checklist`. Après « Passer », l'étape suivante ne bougerait qu'à la relecture suivante ; or `OnboardingStepCard` appelle `skip`, et c'est cette dépendance qui fait avancer la carte ; - RM12 : `mode` retiré 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 (`useState` initialisé une fois, mémo recalculé seulement quand une dépendance change). `BudgetPage.wiring.test.ts` en a déjà un pour `useState`. 2. **`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 un `useEffect` fait 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 de `userPreferenceService` ni de `ONBOARDING_CHECKLIST_HIDDEN_KEY` dans la page), ou un nom de test qui dit ce qu'il prouve. </details>
maximus added
status:ready
and removed
status:triage
labels 2026-10-10 23:11:38 +00:00
maximus added this to the planned-2026-10-10-fiabilite-tests milestone 2026-10-10 23:37:36 +00:00
maximus added
status:in-progress
and removed
status:ready
labels 2026-10-12 23:35:39 +00:00
maximus added
status:review
and removed
status:in-progress
labels 2026-10-12 23:45:29 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-10-13 00:48:44 +00:00
maximus removed the
status:approved
label 2026-10-14 00:29:22 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#409
No description provided.