diff --git a/docs/adr/0021-migrations-profils-secondaires.md b/docs/adr/0021-migrations-profils-secondaires.md index 691af12..66c9338 100644 --- a/docs/adr/0021-migrations-profils-secondaires.md +++ b/docs/adr/0021-migrations-profils-secondaires.md @@ -58,7 +58,7 @@ Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étr ### 7. L'échec reste ouvert -Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`. +Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Deux limites (précisées par #437). Un chemin relatif sans début reconnu garde l'ancien repli, d'un blanc à l'autre : un morceau qui porte deux séparateurs ou plus est réduit à son dernier segment (`dossier/sous/x.db` donne `x.db`), un morceau qui n'en porte qu'un voit son séparateur remplacé par `-`, si bien que les mots d'un dossier qui contient un blanc restent (`dossier/sous dossier/x.db` donne `dossier-sous dossier-x.db`) ; un tel chemin ne part pas du dossier personnel, donc n'en contient pas le nom. Et un nom de dossier qui contient l'un des arrêts (une apostrophe inverse, `;`, `|`, une parenthèse fermante suivie d'autre chose qu'un séparateur…) arrête le chemin là : ce nom, ou son début, reste dans la ligne, et ce peut être un nom d'utilisateur (`C:\Users\Jean (Work) Smith\AppData\x.db` donne `Jean (Work) x.db`). Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`. ### 8. Un seul dossier des bases diff --git a/docs/architecture.md b/docs/architecture.md index 3909403..6dfb738 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -659,6 +659,12 @@ Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x) `CategoryCombobox` rend sa liste ouverte dans un portail sur `document.body`, en position fixe au-dessus de toutes les fenêtres (`z-[300]`) : aucun conteneur défilant ne la coupe. Fermé, il ne rend que `
`, sans portail, et son rendu statique n'en demande rien. La règle de placement (sous le champ, au-dessus quand la place manque en dessous et suffit au-dessus) et les écouteurs qui gardent la liste sur son champ pendant qu'elle est ouverte (`scroll` en capture, `resize`, retirés avec elle) sont des fonctions pures de `src/components/shared/placeListbox.ts`, testées sans DOM. Le composant ouvert, lui, ne se rend pas sans DOM : `renderToStaticMarkup` le rend fermé et le harnais refuse ses `useId` et `useLayoutEffect` ; le reste (portail, clic dans la liste, clavier) se vérifie dans l'application. La liste n'étant plus dans le DOM de son conteneur, un écouteur natif de clic extérieur posé par un conteneur la verrait dehors : celui du combobox la compte comme intérieure. +### Limites connues (#437) + +- **Emplacements préréglés du harnais.** Le harnais donne un emplacement à chaque appel de hook, tous types confondus (`useState`, `useReducer`, `useRef`, `useMemo`, `useCallback`, `useEffect`), dans l'ordre des appels : c'est le helper `slot` de `fakeReact`. Un test qui prérègle un emplacement par sa position (`harness.slots[0] = …`) dépend donc de l'ordre des hooks du composant : un hook ajouté avant, de n'importe quel type, décale les emplacements sans rien dire, et la valeur préréglée tombe sur un autre hook. Préréglages actuels, chacun commenté dans son fichier : `PriceFetchConsentToggle.test.tsx` (emplacements 0 à 2), `PriceFetchControl.test.tsx` (la fenêtre de consentement ouverte), `useOnboardingChecklist.wiring.test.ts` (`mountWith` et deux écritures directes) et `SystemsSettingsPage.test.tsx` ; `OnboardingStepCard.test.tsx` lit le `useRef` de l'emplacement 0. Qui ajoute un hook avant ces emplacements met le test à jour. Un test qui ouvre un état par un clic dans l'arbre rendu (`TransactionTable.wiring.test.ts`) n'a pas cette dépendance. +- **`withoutFolders`.** Ses arrêts et ses deux limites (chemin relatif sans début reconnu ; nom de dossier qui contient l'un des arrêts) sont dans l'[ADR 0021](adr/0021-migrations-profils-secondaires.md), § 7. +- **Mesure du rattrapage.** Le test `#[ignore]` `a_large_profile_catch_up_duration` (`profile_db.rs`) ne tourne pas en CI et n'asserte rien : il imprime une durée. Sa mesure, prise sur un SSD NVMe, et le risque qu'elle ne couvre pas (disque lent, antivirus qui inspecte la copie) sont dans l'[ADR 0021](adr/0021-migrations-profils-secondaires.md), « Négatives et points de vigilance », point « Le délai de démarrage couvre le rattrapage du profil actif ». + ## CI/CD Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle. diff --git a/src/components/balance/PriceFetchControl.test.tsx b/src/components/balance/PriceFetchControl.test.tsx index d70ca60..5bc5970 100644 --- a/src/components/balance/PriceFetchControl.test.tsx +++ b/src/components/balance/PriceFetchControl.test.tsx @@ -1,9 +1,10 @@ // PriceFetchControl — unit tests (issue #158) // // NOTE: This project does not have @testing-library/react or jsdom configured -// (logged as MEDIUM in decisions-log.md). Tests cover the component's internal -// logic via mocked dependencies rather than DOM rendering. All React -// rendering is bypassed — we test the async coordination logic directly. +// (logged as MEDIUM in decisions-log.md). Most tests cover the component's +// internal logic via mocked dependencies rather than DOM rendering; the +// "displayed texts" tests render it with renderToStaticMarkup, its hooks +// coming from the shared harness (src/test/hookHarness.ts). import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -11,6 +12,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mocks — declared before imports to satisfy vi.mock hoisting // --------------------------------------------------------------------------- +// React's hooks from the shared harness (#409): the consent dialog opens on a +// click (internal state), so the render test below presets its slot (#437). +vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); + vi.mock("../../hooks/useIsPremium", () => ({ useIsPremium: vi.fn(), })); @@ -40,6 +45,7 @@ vi.mock("lucide-react", () => ({ // Imports (after mock declarations) // --------------------------------------------------------------------------- +import type { ReactNode } from "react"; import { useIsPremium } from "../../hooks/useIsPremium"; import { prices } from "../../services/balance.service"; import type { PriceResult } from "../../services/balance.service"; @@ -48,6 +54,7 @@ import { setPreference, } from "../../services/userPreferenceService"; import { renderToStaticMarkup } from "react-dom/server"; +import { hookHarness } from "../../test/hookHarness"; import PriceFetchControl, { __resetBestEffortDismissForTests, } from "./PriceFetchControl"; @@ -56,6 +63,7 @@ import PriceFetchControl, { // Helpers // --------------------------------------------------------------------------- +const harness = hookHarness(); const mockUseIsPremium = vi.mocked(useIsPremium); const mockFetchPrice = vi.mocked(prices.fetchPrice); const mockGetPreference = vi.mocked(getPreference); @@ -363,18 +371,35 @@ describe("PriceFetchControl — displayed texts", () => { __resetBestEffortDismissForTests(); vi.resetAllMocks(); setPremium(true); + harness.clear(); }); - it("names the fetch button and the stock best-effort notice through t", () => { - const html = renderToStaticMarkup( - , + /** + * One render of a stock's control, its hooks from the harness. `consentOpen` + * presets slot 0, the control's first hook (`showConsentModal`). The slots + * follow the order of the control's hook calls: a hook of any kind added + * before it shifts them without a word, and the preset lands on another hook + * (one slot per hook call: the `slot` helper of `fakeReact`, + * src/test/hookHarness.ts). + */ + function markup(consentOpen = false): string { + if (consentOpen) harness.slots[0] = true; + return renderToStaticMarkup( + harness.render( + () => + PriceFetchControl({ + symbol: "AAPL", + date: "2026-04-25", + categoryKind: "priced", + assetType: "stock", + onPriceFetched: vi.fn(), + }) as ReactNode, + ), ); + } + + it("names the fetch button and the stock best-effort notice through t", () => { + const html = markup(); expect(html).toContain('aria-label="t(balance.priceFetching.button)"'); expect(html).toContain(">t(balance.priceFetching.button)<"); expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<"); @@ -382,4 +407,15 @@ describe("PriceFetchControl — displayed texts", () => { expect(html).not.toContain(">best-effort<"); expect(html).toContain('aria-label="t(common.close)"'); }); + + it("names the consent dialog through t once it is open (#437)", () => { + expect(markup()).not.toContain('role="dialog"'); + harness.clear(); + const html = markup(true); + expect(html).toContain('role="dialog"'); + expect(html).toContain(">t(balance.priceFetching.consent.title)<"); + expect(html).toContain(">t(balance.priceFetching.consent.body)<"); + expect(html).toContain(">t(balance.priceFetching.consent.decline)<"); + expect(html).toContain(">t(balance.priceFetching.consent.accept)<"); + }); }); diff --git a/src/components/settings/LicenseKeyForm.render.test.tsx b/src/components/settings/LicenseKeyForm.render.test.tsx index c28ba59..6ecacc1 100644 --- a/src/components/settings/LicenseKeyForm.render.test.tsx +++ b/src/components/settings/LicenseKeyForm.render.test.tsx @@ -1,5 +1,6 @@ // Render tests for the key entry form of the License card (#347). No jsdom needed: -// react-dom/server renders in vitest's node environment, and `t` echoes its key. +// react-dom/server renders in vitest's node environment, and the marked `t` of +// src/test/i18nMock.ts renders `t(key)`. import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -44,4 +45,10 @@ describe("LicenseKeyForm", () => { expect(render(null, true)).toMatch(/