fix: follow-ups of the lot-v018 stack reviews #482

Merged
maximus merged 25 commits from review-followups-lot-v018 into main 2026-10-21 00:31:46 +00:00
32 changed files with 997 additions and 113 deletions

View file

@ -4,21 +4,22 @@
### Ajouté
- Catégories : un bouton « Vérifier la hiérarchie », entre « Tous les mots-clés » et « Réinitialiser », liste les catégories orphelines : celles prises dans une boucle de parenté, qui les cache avec leurs sous-catégories de l'arbre, des menus de catégories, du budget et des rapports, et celles dont le parent a été supprimé ou est introuvable. Chacune se rattache à la racine, choisie par défaut, ou à un autre parent ; elle revient alors à sa place avec ses transactions, ses mots-clés et ses montants de budget. Le Journal note chaque rattachement, sans nom de catégorie (#393).
- Catégories : un bouton « Vérifier la hiérarchie », entre « Tous les mots-clés » et « Réinitialiser », liste les catégories orphelines : celles prises dans une boucle de parenté, qui les cache avec leurs sous-catégories de l'arbre, des menus de catégories, du budget et des rapports, et celles dont le parent a été supprimé ou est introuvable. Chacune se rattache à la racine, choisie par défaut, ou à un autre parent, vérifié comme dans le formulaire de catégorie ; elle revient alors à sa place avec ses transactions, ses mots-clés et ses montants de budget. Le Journal note chaque rattachement, sans nom de catégorie (#393).
### Modifié
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus les journaux en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
- Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand elle ne tient pas en dessous mais tient au-dessus (#429).
- Transactions : sur un profil sans aucune transaction, la carte « Aucune transaction pour l'instant » s'affiche dès que l'application sait que le profil est vide, au lieu des filtres, des totaux et de « Chargement... » jusqu'au retour de la première page de la liste (#384).
- Textes d'aide : l'aide de la page Rapports nomme désormais les rapports par leurs cartes, « Tendances » et « Analyse par catégorie », au lieu de « Tendances mensuelles » et « Catégories dans le temps », des onglets qui n'existent plus. La section Tableau de bord du guide d'utilisation dit désormais comment se cochent les huit étapes de démarrage : selon vos données, ou par « J'ai compris » pour les visites (#446).
- Textes d'aide : l'aide de la page Rapports nomme désormais les rapports par leurs cartes, « Tendances » et « Analyse par catégorie », au lieu de « Tendances mensuelles » et « Catégories dans le temps », des onglets qui n'existent plus. La section Tableau de bord du guide d'utilisation nomme désormais les huit étapes de démarrage et dit comment elles se cochent : selon vos données, ou par « J'ai compris » (ou, pour les rapports, un clic sur la carte Tendances entourée) pour les visites (#446).
### Corrigé
- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), un parent qui créerait un quatrième niveau et un parent qui a des transactions à son nom (devenu parent, il ne serait plus saisissable) sont refusés avec un message, et rien n'est enregistré. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393).
- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche) et un parent qui créerait un quatrième niveau sont refusés avec un message, et rien n'est enregistré. Une catégorie encore saisissable ne peut pas devenir parent tant qu'elle a des transactions, un montant de budget non nul (d'une année quelconque, ou dans un modèle de budget) ou des mots-clés à son nom, puisqu'elle ne serait plus saisissable : la vérification se fait à l'enregistrement d'une catégorie comme à la création d'une sous-catégorie, et le message dit quoi déplacer d'abord. Un parent existant, qui n'est plus saisissable, accepte de nouvelles sous-catégories comme avant. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393).
- Budget : une catégorie dont le parent a été supprimé ou est introuvable ne disparaît plus de la grille Budget ni du rapport Budget vs réel : elle s'affiche au premier niveau, comme dans l'arbre des catégories (#393).
- Import : les messages de « Créer un dossier d'exemple » suivent désormais la langue de l'application : un dossier d'exemple déjà présent dans le dossier d'import (nommé, avec la marche à suivre : actualiser la liste, ou le renommer s'il n'y apparaît pas), un dossier d'import introuvable, une date du jour illisible. Toute autre erreur reste affichée telle que le système l'a écrite. Une fois les dossiers d'exemple écrits, le message de réussite ne s'affiche que si le dossier d'import a pu être relu ; sinon le message d'erreur s'affiche seul, au lieu des deux à la fois (#384).
- Import : après le choix d'un autre dossier d'import, l'avertissement des fichiers posés directement dans le dossier d'import ne nomme plus les fichiers de l'ancien dossier sous le nom du nouveau, pendant la lecture du nouveau dossier ou quand il ne peut pas être lu (#446).
- Import : un message d'erreur ne reste plus affiché une fois le dossier d'import relu, après « Actualiser » ou après le choix d'un autre dossier par « Parcourir » ; le message d'un dossier d'exemple déjà présent dans le dossier d'import demandait justement d'actualiser. Annuler le choix du dossier garde le message (#384).
## [0.17.2] - 2026-10-18

View file

@ -4,21 +4,22 @@
### Added
- Categories: a “Check hierarchy” button, between “All keywords” and “Re-initialize”, lists the orphan categories: those caught in a parent loop, which hides them and their subcategories from the tree, the category menus, the budget and the reports, and those whose parent was deleted or cannot be found. Each one can be reattached to the root, chosen by default, or to another parent; it then comes back in its place with its transactions, keywords and budget amounts. The Journal notes each reattachment, without any category name (#393).
- Categories: a “Check hierarchy” button, between “All keywords” and “Re-initialize”, lists the orphan categories: those caught in a parent loop, which hides them and their subcategories from the tree, the category menus, the budget and the reports, and those whose parent was deleted or cannot be found. Each one can be reattached to the root, chosen by default, or to another parent, checked as in the category form; it then comes back in its place with its transactions, keywords and budget amounts. The Journal notes each reattachment, without any category name (#393).
### Changed
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the logs to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
- Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when it does not fit below but fits above (#429).
- Transactions: on a profile without any transaction, the “No transactions yet” card now shows as soon as the app knows the profile is empty, instead of the filters, the totals and “Loading...” until the first page of the list comes back (#384).
- Help texts: the help of the Reports page now names the reports by their cards, “Trends” and “Category Analysis”, instead of “Monthly Trends” and “Category Over Time”, tabs that no longer exist. The Dashboard section of the user guide now says how the eight getting-started steps get checked: from your data, or with “Got it” for the visit steps (#446).
- Help texts: the help of the Reports page now names the reports by their cards, “Trends” and “Category Analysis”, instead of “Monthly Trends” and “Category Over Time”, tabs that no longer exist. The Dashboard section of the user guide now names the eight getting-started steps and says how they get checked: from your data, or with “Got it” (or, for the reports, a click on the outlined Trends card) for the visit steps (#446).
### Fixed
- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), a parent that would make a fourth level, and a parent with transactions of its own (once a parent, it would no longer take input) are refused with a message, and nothing is saved. A category saved with its parent unchanged is never refused by these checks (#393).
- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch) and a parent that would make a fourth level are refused with a message, and nothing is saved. A category that still takes input cannot become a parent while it holds transactions, a budget amount other than zero (of any year, or in a budget template) or keywords of its own, since it would no longer take input: this is checked when a category is saved and when a subcategory is created, and the message says what to move first. An existing parent, which no longer takes input, accepts new subcategories as before. A category saved with its parent unchanged is never refused by these checks (#393).
- Budget: a category whose parent was deleted or cannot be found no longer disappears from the Budget grid and the Budget vs Actual report: it now shows at the top level, as in the category tree (#393).
- Import: the messages of “Create a sample folder” now follow the app's language: a sample folder already in the import folder (named, with what to do: refresh the list, or rename it if it does not show up), an import folder that cannot be found, today's date unreadable. Any other error still shows as the system wrote it. Once the sample folders are written, the success message only shows when the import folder could be read again; otherwise the error message shows alone, instead of both at once (#384).
- Import: after you choose another import folder, the warning about the files placed directly in the import folder no longer names the previous folder's files under the new folder's name, while the new folder is being read or when it cannot be read (#446).
- Import: an error message no longer stays on screen once the import folder is read again, after “Refresh” or after choosing another folder with “Browse”; the message of a sample folder already in the import folder asked for that very refresh. Cancelling the folder choice keeps the message (#384).
## [0.17.2] - 2026-10-18

View file

@ -40,11 +40,14 @@
src/
├── components/ # 138 composants React organisés par domaine
│ ├── adjustments/ # Ajustements
│ ├── balance/ # Bilan (12 composants)
│ ├── budget/ # Budget
│ ├── categories/ # Catégories hiérarchiques
│ ├── categories-migration/ # Migration des catégories (5 composants)
│ ├── dashboard/ # Tableau de bord
│ ├── import/ # Wizard d'import (18 composants)
│ ├── layout/ # AppShell, Sidebar
│ ├── onboarding/ # Parcours de démarrage (3 composants)
│ ├── profile/ # Profils (PIN, formulaire, switcher)
│ ├── reports/ # Graphiques et rapports
│ ├── settings/ # Paramètres
@ -62,10 +65,18 @@ src/
src-tauri/
├── src/
│ ├── commands/ # 3 modules, 17 commandes Tauri
│ │ ├── fs_commands.rs # Système de fichiers (6 commandes)
│ ├── commands/ # 12 modules, dont 10 avec des commandes : 45 commandes Tauri (enregistrées dans generate_handler!)
│ │ ├── fs_commands.rs # Système de fichiers (7 commandes)
│ │ ├── export_import_commands.rs # Export/import chiffré (5 commandes)
│ │ └── profile_commands.rs # Gestion des profils (8 commandes)
│ │ ├── profile_commands.rs # Gestion des profils (8 commandes)
│ │ ├── license_commands.rs # Licence et activation machine (11 commandes)
│ │ ├── auth_commands.rs # Compte Maximus, OAuth2 PKCE (5 commandes)
│ │ ├── token_store.rs # Stockage des jetons OAuth (1 commande)
│ │ ├── entitlements.rs # Entitlements (1 commande)
│ │ ├── feedback_commands.rs # Feedback Hub (2 commandes)
│ │ ├── backup_commands.rs # Sauvegarde avant la migration des catégories (3 commandes)
│ │ ├── balance_commands.rs # Bilan (2 commandes)
│ │ └── account_cache.rs, return_calculator.rs # Sans commande
│ ├── database/ # Schémas SQL et migrations
│ │ ├── schema.sql # Schéma initial (v1)
│ │ ├── seed_categories.sql # Seed catégories (v2)

View file

@ -127,6 +127,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm
- `latest.json` porte une clé par paquet (`linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), contrôlée par `scripts/check-latest-json.jq` avant le `DELETE` du registre ; `scripts/build-latest-json.test.mjs` tourne dans `npm test`. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `release.yml` signe les binaires et publie le JSON d'updater que lisent toutes les installations : une clé publique de `plugins.updater` changée sans bascule prévue coupe leurs mises à jour, comme un endpoint déplacé. — `CLAUDE.md` « CI/CD » et « Fonctionnalités principales »
- Une release qui touche l'updater, ses dépendances, `release.yml` ou la config `updater` exige un cycle réel de mise à jour (étape 10 de `/release`, `docs/qa-update-cycle.md`) : la PR le signale pour la release suivante. `verify-release.sh` contrôle la release publiée. — `CLAUDE.md` « CI/CD », `.claude/skills/release/SKILL.md`
- Le `-- --locked` des deux `npx tauri build` n'arrive à `cargo build` que par la CLI de Tauri (`@tauri-apps/cli`, 2.10.0 dans le lock npm), qui le passe à son runner, et par cargo-xwin (0.23.1, épinglé), qui le passe à `cargo build` ; le journal du tag ne montre jamais la ligne de commande de cargo. Une PR qui monte l'une de ces deux versions rejoue la preuve de #449 sur les deux runners : un lock désaccordé fait échouer l'étape, `cargo` remplacé par un script qui journalise chaque appel et son appelant. — `docs/architecture.md` « `release.yml` — Build et publication », #449
### CI et gardes
@ -186,3 +187,4 @@ Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dép
- **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions »
- **Dépôt public.** Aucune coordonnée d'un tiers (courriel, adresse, téléphone), aucune clé de licence ni aucun jeton dans un fichier commité, `STATE.md`, `docs/` et `.claude/skills/` compris ; un testeur se désigne par son identifiant Logto. — `.claude/skills/issue-license/SKILL.md`
- **Tests vitest qui lisent `src-tauri/`.** Le couplage inverse de l'exception « CI d'une pile » : `src/hooks/useImportWizard.test.ts` lit les codes `SAMPLE_ERROR_*` de `fs_commands.rs` (contrat avec leur traduction), `src/services/categoryService.test.ts` et `src/__integration__/onboarding-samples.test.ts` lisent `consolidated_schema.sql`, `src/utils/changelog.test.ts` la version de `tauri.conf.json`. Une PR qui ne touche que `src-tauri/**` saute `check-frontend` : la dérive ne rougit que la PR suivante qui touche `src/`, sans rapport avec elle. La revue d'une telle PR lance ces tests (`npx vitest run <fichier>`). — `check-frontend.yml` (`paths-ignore`), #384

View file

@ -201,7 +201,7 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof
|---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
| `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques. `updateCategory` vérifie un nouveau parent et `reattachCategory` rattache une catégorie orpheline, en une transaction par son handle ; `CategoryHierarchyError` typée ; `findOrphanCategories` (diagnostic, requête dédiée sur toutes les catégories) ; `getCategoryDepth` s'arrête sur un cycle — voir « Hiérarchie des catégories » |
| `categoryService.ts` | CRUD catégories hiérarchiques. `updateCategory` vérifie un nouveau parent, `reattachCategory` rattache une catégorie orpheline et `createCategory` vérifie ce que porte le parent d'une sous-catégorie quand il deviendrait parent, chacun en une transaction par son handle ; `CategoryHierarchyError` typée ; `findOrphanCategories` (diagnostic, requête dédiée sur toutes les catégories) ; `getCategoryDepth` s'arrête sur un cycle — voir « Hiérarchie des catégories » |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
| `importedFileService.ts` | Suivi des fichiers importés |
@ -238,7 +238,7 @@ Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v
| Hook | Domaine |
|------|---------|
| `useCategories` | Catégories avec hiérarchie. Erreurs typées `CategoriesError` (`{ key }` d'un refus de la hiérarchie, `{ message }` sinon, `categoriesErrorOf`). Fenêtre « Vérifier la hiérarchie » : `hierarchyCheck`, `checkHierarchy`, `reattachOrphan` (une ligne de Journal, puis rechargement de la liste et de l'arbre), `closeHierarchyCheck` (#393) |
| `useCategories` | Catégories avec hiérarchie. Erreurs typées `CategoriesError` (`{ key }` d'un refus de la hiérarchie, `{ message }` sinon, `categoriesErrorOf`). Fenêtre « Vérifier la hiérarchie » : `hierarchyCheck`, `checkHierarchy`, `reattachOrphan` (une ligne de Journal, puis relecture de la liste et des catégories), `closeHierarchyCheck` ; relecture des catégories sans état de chargement (`readCategoriesAgain`) (#393) |
| `useCategoryMigration` | Migration vers la taxonomie standard (`/settings/categories/migrate`, `CategoriesMigrationPage`) : machine d'états des étapes découvrir, simuler et consentir, puis exécution et résultat. Reducer sans appel de service : la page calcule le plan, écrit la sauvegarde préalable (SREF) et applique la migration, puis dispatche |
| `useCategoryTaxonomy` | Taxonomie standard v1 en lecture (`getTaxonomyV1`, `findById`, `findByPath`, `getLeaves`, `getParentById` de `categoryTaxonomyService`), mémorisée une fois : guide des catégories standard (`CategoriesStandardGuidePage`) et étapes de la migration |
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
@ -280,12 +280,12 @@ Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v
Un `parent_id` en boucle (A sous B, B sous A, ou une catégorie sous elle-même) cache la boucle et tout ce qui pend dessous partout où l'on part des racines : arbre, sélecteurs de catégorie, budget, rapports. Les données restent intactes ; seul `parent_id` est en cause.
- **Module pur unique** — `src/utils/categoryHierarchy.ts` : `depthsOf` (une catégorie prise dans une boucle, ou dessous, n'a **pas** de profondeur), `subtreeHeight` et `descendantsOf` (une seule descente, `levelsBelow`), `fitsUnder` (trois niveaux au plus, `MAX_CATEGORY_DEPTH = 2`, sous-arbre compris), `parentChangeRefusal`, `findOrphans`, `displaysAsRoot`. Chaque remontée et chaque descente garde l'ensemble des catégories vues : une boucle termine le parcours. Les parcours ont trois consommateurs, sans autre implémentation : `buildParentOptions` (liste des parents du formulaire, #423, et de la fenêtre), le diagnostic et la validation du service ; le budget lit `displaysAsRoot`. `computeDepths` de `CategoryCombobox` ne sert qu'à l'indentation et reste à part.
- **Diagnostic** — `findOrphanCategories` lit **toutes** les catégories, actives ou non (`SELECT id, parent_id, is_active FROM categories`) : les autres requêtes ne gardent que les actives, ce qui ferait d'un parent désactivé un parent absent. Motifs : `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est listée, sa descendance la suit.
- **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis `parent_has_transactions` pour un parent qui porte des transactions directes (devenu parent, il ne serait plus saisissable). La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.<code>`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface.
- **Diagnostic** — `findOrphanCategories` lit **toutes** les catégories, actives ou non (`SELECT id, parent_id, is_active, is_inputable FROM categories`, la lecture de la validation) : les autres requêtes ne gardent que les actives, ce qui ferait d'un parent désactivé un parent absent. Motifs : `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est listée, sa descendance la suit.
- **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis ce que porte le parent (`checkParentContent`, décision de Max du 2026-10-19), dans cet ordre, le premier trouvé faisant le refus : `parent_has_transactions` (des transactions directes), `parent_has_budget` (un montant non nul, négatif compris, d'une année quelconque dans `budget_entries` ou dans `budget_template_entries`), `parent_has_keywords` (un mot-clé actif ; un mot-clé supprimé, `is_active = 0`, ne classe rien et ne compte pas). Devenu parent, il ne serait plus saisissable : ses transactions resteraient dessus, ses montants sortiraient de la grille du budget, et ses mots-clés continueraient d'y classer des transactions. Le contenu ne se vérifie que si le parent est encore saisissable (`is_inputable = 1`, la condition de `markAsParent`, lue dans les lignes de la hiérarchie) : c'est alors qu'il deviendrait parent et cesserait d'être saisissable. Un parent existant, déjà non saisissable, accepte un enfant quoi qu'il porte, comme dans toutes les versions publiées : le refus n'y protégerait rien (décision de Max du 2026-10-20, revue 25921). La règle vaut pour les trois codes, `parent_has_transactions` compris, et pour les trois points d'entrée. `createCategory` vérifie de même le parent d'une sous-catégorie, sur les lignes de la hiérarchie lues par le handle, dans un `withTransaction` qui porte aussi l'insertion et le `is_inputable = 0` du parent ; sa vérification de profondeur (`getCategoryDepth`, erreur simple) reste avant la transaction. La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.<code>`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface.
- **`getCategoryDepth`** garde les `id` vus et rend `Infinity` sur une boucle : `createCategory` refuse alors ce parent.
- **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et l'arbre se rechargent.
- **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). À l'ouverture, `checkHierarchy` relit les catégories (`readCategoriesAgain`) avant le diagnostic, une fois finies les écritures d'un glisser-déposer en cours (un `UPDATE` par catégorie, qu'une lecture à mi-chemin montrerait à moitié appliqué), sans l'état de chargement de la page (un formulaire ouvert derrière la fenêtre reste tel quel) : un glisser-déposer laisse dans `state.categories` ce que montre l'arbre (`SET_TREE`), sans les catégories qu'aucune racine n'atteint, que la fenêtre doit nommer et dont elle calcule les parents proposés. Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et les catégories sont relues de la même façon : un formulaire ouvert derrière la fenêtre garde ses changements non enregistrés (jusqu'à la revue 25921, `loadCategories` les effaçait), et un échec de cette relecture s'affiche dans la fenêtre. Limite connue : un formulaire ouvert sur l'orpheline elle-même garde son ancien parent, qu'un enregistrement remettrait (sous les mêmes vérifications).
- **Budget** — `buildBudgetYearRows` (`useBudget.ts`) et `getBudgetVsActualData` (`budgetService.ts`) placent au premier niveau une catégorie dont le parent n'est pas dans la liste active (`displaysAsRoot`), comme l'arbre et les sélecteurs ; une boucle y reste cachée, la fenêtre la répare.
- **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent avec transactions et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup.
- **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent qui porte des transactions, des montants de budget ou des mots-clés, et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup.
## Import CSV — format persisté et détection
@ -353,7 +353,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0`, `totalInDb` via `getTransactionCount`, lu au montage : la carte vient dès que le compte est lu, sans attendre la première page (#384, D1) | `EmptyState` dans la page | « Importer des transactions » → `/import` |
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
Sur ces deux pages, le compte se lit au montage à côté des autres lectures de la page (catégories et sources de Transactions, comptes du filtre de Budget), chaque lecture indépendante des autres (`Promise.allSettled`, #384) : un compte en échec laisse `totalInDb` à `null`, donc la mise en page normale, jamais l'état vide, sans priver la page de ses filtres ; le premier échec devient l'erreur de la page. Le compte n'est relu qu'au montage, aucun abonnement au canal d'événements de données : un import se fait sur `/import`, et revenir sur la page la remonte (un changement de profil remonte toutes les routes, `BrowserRouter key={refreshKey}`).
Sur ces deux pages, le compte se lit au montage à côté des autres lectures de la page (catégories et sources de Transactions, comptes du filtre de Budget), chaque lecture indépendante des autres (`Promise.allSettled`, #384) : un compte en échec laisse `totalInDb` à `null`, donc la mise en page normale, jamais l'état vide, sans priver la page de ses filtres ; le premier échec devient l'erreur de la page. Sur Transactions, elle n'y reste qu'environ 300 ms : la lecture de la première page que relance le délai des filtres (300 ms) l'efface (`fetchData` envoie `SET_ERROR(null)`), limite antérieure à #384, non corrigée. Le compte n'est relu qu'au montage, aucun abonnement au canal d'événements de données : un import se fait sur `/import`, et revenir sur la page la remonte (un changement de profil remonte toutes les routes, `BrowserRouter key={refreshKey}`).
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) :
@ -370,7 +370,7 @@ Sur ces deux pages, le compte se lit au montage à côté des autres lectures de
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. Il ne vient qu'après un rescan réussi : `scanFolderInternal` rend `false` sur un échec, dont le bandeau d'erreur reste seul affiché (#384). La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; ses refus sont des codes stables que `sampleFolderError` (pur) traduit en clés `import.empty.sampleErrors.*`, le nom du dossier d'exemple passé par `errorValues` de l'état, que la page interpole au rendu ; tout autre message reste affiché brut. Le message brut part aussi au Journal en `logWarn` : un code, ou une erreur d'écriture qui nomme un dossier d'exemple, son fichier et l'erreur du système, jamais un chemin ; l'erreur du rescan, qui peut porter le chemin, n'y va pas. Les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. Il ne vient qu'après un rescan réussi : `scanFolderInternal` rend `false` sur un échec, dont le bandeau d'erreur reste seul affiché (#384). `refreshFolder` (« Actualiser », et le rescan après un changement de l'historique des imports) et `browseFolder`, une fois un dossier choisi, effacent le bandeau d'erreur avant de relire le dossier, comme `selectSource` : le refus d'un dossier d'exemple déjà présent demande justement d'actualiser, et un rescan en échec affiche sa propre erreur ; un choix de dossier annulé ne change rien. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; ses refus sont des codes stables que `sampleFolderError` (pur) traduit en clés `import.empty.sampleErrors.*`, le nom du dossier d'exemple passé par `errorValues` de l'état, que la page interpole au rendu ; tout autre message reste affiché brut. Le message brut part aussi au Journal en `logWarn` : un code, ou une erreur d'écriture qui nomme un dossier d'exemple, son fichier et l'erreur du système, jamais un chemin ; l'erreur du rescan, qui peut porter le chemin, n'y va pas. Les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
### Parcours guidé
@ -411,7 +411,7 @@ Sur ces deux pages, le compte se lit au montage à côté des autres lectures de
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. `HubReportNavCard` le passe au `onClick` du `Link`, que React tire aussi sur un Ctrl+clic (pas sur le clic du milieu, qui passe par `onAuxClick`) : un Ctrl+clic sur la carte Tendances coche donc l'étape « Parcourir les rapports », alors que le routeur ne navigue pas sur un clic modifié. Le webview n'ayant pas d'onglets, le cas est jugé théorique : documenté, pas corrigé (#446). Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. `HubReportNavCard` le passe au `onClick` du `Link`, que React tire aussi sur un Ctrl+clic (pas sur le clic du milieu, qui passe par `onAuxClick`) : un Ctrl+clic sur la carte Tendances coche donc l'étape « Parcourir les rapports », alors que le routeur ne navigue pas sur un clic modifié. L'app ne pose aucun gestionnaire `on_new_window` (rien dans `src-tauri/src`) ; avec les versions du lock (tauri 2.10.2, wry 0.54.1), WebView2 marque alors une demande de nouvelle fenêtre traitée sans en ouvrir (`SetHandled(true)`), et wry ne branche le signal `create` de WebKitGTK que si un gestionnaire existe : le seul effet d'un Ctrl+clic est l'étape cochée. Lu dans le code des crates, pas observé dans l'application : le cas est jugé théorique, documenté, pas corrigé (#446). Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
**Paramètres → Utilisateurs** lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
@ -661,7 +661,7 @@ Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants ; `useImportWizard.samples.test.ts` (« Créer un dossier d'exemple », #384) passe par le harnais partagé.
Utilisateurs : 21 fichiers de test au 2026-10-20, que `grep -rl "test/hookHarness" src --include='*.test.*'` liste (sans le filtre, la commande nomme aussi le harnais) ; d'abord ceux de #389 (`useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx`), puis les tests de câblage et de hooks venus après (`PriceFetchControl.test.tsx` en #437, `TransactionTable.wiring.test.ts`, `useCategories.test.ts`…). `CategoryCombobox.placement.test.ts` y fait passer `useLayoutEffect` pour `useEffect` et `useId` pour une constante, dans ce seul fichier (#429). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants ; `useImportWizard.samples.test.ts` (« Créer un dossier d'exemple », #384) passe par le harnais partagé.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
@ -681,13 +681,17 @@ Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)
`renderToStaticMarkup` ne rend pas un portail (« Portals are not currently supported by the server renderer »), et l'environnement node de vitest n'a pas de `document` (sans élément DOM, `createPortal` lève « Target container is not a DOM element »). Une fenêtre rendue par `createPortal(…, document.body)` (`LinkTransfersModal`, et de même `StarterAccountsModal`, `DetailAccountWizard`…) ne se teste donc qu'en remplaçant `createPortal` par l'identité (`vi.mock("react-dom", …)`, le reste du module gardé) et en posant un `document` factice (`vi.stubGlobal`), puisque `document.body` est lu avant l'appel : modèle dans `LinkTransfersModal.render.test.tsx` et, sous le harnais, `LinkTransfersModal.wiring.test.ts`.
`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 `<div class="relative"><input …/></div>`, 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.
`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 `<div class="relative"><input …/></div>`, 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. Un rendu du parent peut aussi déplacer le champ sans défilement ni redimensionnement (une fenêtre centrée qui grandit quand son contenu arrive) : tant que la liste est ouverte, sa position est relue après chaque rendu (`useLayoutEffect` sans dépendances), et une position inchangée garde le même objet d'état (`keepPlacement`), sur lequel React ne rend rien de plus. `renderToStaticMarkup` rend le composant fermé, et le harnais refuse ses `useId` et `useLayoutEffect` : seul `CategoryCombobox.placement.test.ts` le rend ouvert, en faisant passer `useLayoutEffect` pour le `useEffect` du harnais et `useId` pour une constante, avec un champ factice dont il déplace le rectangle. Le reste (portail réel, peinture, saut de rendu de React, 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.
### SQL réel — `node:sqlite` (#393)
`categoryService.test.ts` exécute les requêtes qui vérifient ce que porte un parent (transactions, montants de budget, mots-clés) telles qu'elles sont écrites, sur une base SQLite en mémoire de `node:sqlite`, module intégré à Node (aucun paquet ; expérimental sous Node 22, qui l'annonce par un avertissement à l'exécution). Ses tables y sont créées par leur `CREATE TABLE` de `consolidated_schema.sql`, clés étrangères coupées : la table `categories` reste dans le faux du test. Une condition retirée d'une requête (`amount <> 0`, `is_active = 1`) rougit donc un test, ce qu'un faux qui réimplémente la règle ne ferait pas. Les autres tests de services gardent leurs faux.
### 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 ».
- **Mesure du rattrapage.** Le test `#[ignore]` `a_large_profile_catch_up_duration` (`profile_db.rs`) ne tourne pas en CI et n'asserte rien sur la durée : il vérifie que le rattrapage aboutit (aucune erreur, plancher 6), puis imprime la 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
@ -740,7 +744,7 @@ Fonctionnalités :
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).
**Lock commité, cargo-xwin épinglé** ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)). Les deux `npx tauri build` passent `-- --locked` : la CLI de Tauri transmet ce qui suit `--` à son runner, cargo pour Linux, cargo-xwin pour Windows, qui le passe à `cargo build`. Les binaires livrés sont donc bâtis sur le `Cargo.lock` commité, ou le build échoue au lieu de le réécrire ; l'étape 2 de `/release`, qui édite le lock à la main sur `main` sans PR, vérifie ce lock par `cargo metadata --locked` avant le tag. Le journal du run n'imprime jamais la ligne de commande de cargo : la preuve que le drapeau arrive est un lock désaccordé qui fait échouer le build, faite en local dans la PR de #449 pour les deux runners (cargo-xwin 0.23.1 lance `cargo build … --locked …`, et les autres appels à cargo de la CLI et de cargo-xwin sont des `cargo metadata --no-deps`, qui ne touchent pas au lock). cargo-xwin, qui tourne avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement, est installé en version fixe (`cargo install --locked cargo-xwin --version 0.23.1`, celle qu'avait installée la release v0.17.2), et l'étape affiche `cargo-xwin --version` ; une nouvelle version se décide, après lecture de ce qu'elle change.
**Lock commité, cargo-xwin épinglé** ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)). Les deux `npx tauri build` passent `-- --locked` : la CLI de Tauri transmet ce qui suit `--` à son runner, cargo pour Linux, cargo-xwin pour Windows, qui le passe à `cargo build`. Les binaires livrés sont donc bâtis sur le `Cargo.lock` commité, ou le build échoue au lieu de le réécrire ; l'étape 2 de `/release`, qui édite le lock à la main sur `main` sans PR, vérifie ce lock par `cargo metadata --locked` avant le tag. Le journal du run n'imprime jamais la ligne de commande de cargo : la preuve que le drapeau arrive est un lock désaccordé qui fait échouer le build, faite en local dans la PR de #449 pour les deux runners. D'après l'appelant que journalisait l'espion de la revue (commentaire 25416 de la PR #469), les six `cargo metadata --no-deps` viennent tous de la CLI de Tauri, sous les deux runners, et ne touchent pas au lock ; le `cargo build … --locked …` vient de la CLI pour Linux, et de cargo-xwin 0.23.1 pour Windows, qui ne lance rien d'autre. Ce relais dépend donc des versions de `@tauri-apps/cli` (2.10.0 dans le lock npm) et de cargo-xwin : une montée de l'une ou de l'autre rejoue cette preuve (lock désaccordé, `cargo` remplacé par un script qui journalise chaque appel et son appelant), puisque le journal du tag ne la montrerait pas. cargo-xwin, qui tourne avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement, est installé en version fixe (`cargo install --locked cargo-xwin --version 0.23.1`, celle qu'avait installée la release v0.17.2), et l'étape affiche `cargo-xwin --version` ; une nouvelle version se décide, après lecture de ce qu'elle change.
## Architecture Decision Records (ADRs)

View file

@ -100,7 +100,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
### Comment faire
1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total
1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage, en huit étapes : « Créer un profil » (déjà cochée), « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget » et « Parcourir les rapports ». Les étapes se cochent selon vos données, sauf les deux étapes de visite (catégories et rapports), cochées par « J'ai compris » sur leur carte ou, pour les rapports, par un clic sur la carte Tendances entourée. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total
2. Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)
3. « Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche
4. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
@ -295,7 +295,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
- Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation
- Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions
- Une catégorie disparue de l'arbre, des menus et du budget est souvent prise dans une boucle de parenté : Vérifier la hiérarchie la retrouve, et ses transactions, ses mots-clés et ses montants de budget reviennent avec elle
- Une catégorie qui a des transactions à son nom ne peut pas devenir le parent d'une catégorie existante, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, ou choisissez un autre parent
- Une catégorie encore saisissable qui a des transactions, des montants de budget (d'une année quelconque, ou dans un modèle) ou des mots-clés à son nom ne peut pas devenir parent, ni d'une catégorie existante ni d'une nouvelle sous-catégorie, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, remettez ses montants à zéro dans la grille du Budget pour chaque année concernée (pour une année passée, son prévu quitte aussi le rapport Budget vs Réel) et supprimez les modèles qui en contiennent, un modèle ne se modifiant pas, supprimez ses mots-clés, ou choisissez un autre parent. Un parent existant, qui n'est plus saisissable, accepte de nouvelles sous-catégories sans cette vérification
---

View file

@ -456,6 +456,23 @@ mod sample_folder_tests {
assert_eq!(entries, 1, "nothing but the existing folder");
}
/// A FILE named like a sample folder blocks it as well (#384 review): the
/// check reads `exists()`, not `is_dir()`, so nothing is half-created and
/// the refusal keeps its code instead of a raw "File exists" from
/// `create_dir`.
#[test]
fn refuses_a_file_named_like_a_sample_folder_and_writes_nothing() {
let dir = sandbox("file-tangerine");
fs::write(dir.join(SAMPLE_TANGERINE_FOLDER), "").unwrap();
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
let desjardins_created = dir.join(SAMPLE_DESJARDINS_FOLDER).exists();
let entries = fs::read_dir(&dir).unwrap().count();
cleanup(&dir);
assert_eq!(result.unwrap_err(), "sample_folder_exists:Exemple Tangerine");
assert!(!desjardins_created, "Exemple Desjardins must not be created");
assert_eq!(entries, 1, "nothing but the existing file");
}
#[test]
fn writes_both_folders_for_the_two_previous_months() {
let dir = sandbox("success");

View file

@ -146,9 +146,10 @@ export default function AddKeywordDialog({
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
{/* The two fields sit outside the scrolling block, so the open category
list is drawn over the preview below instead of being cut by its
scrollport. */}
{/* The two fields sit outside the scrolling block, so they stay in view
while the preview scrolls. The open category list does not depend
on it any more: since #429 it lives in a portal on document.body,
where no scrollport cuts it. */}
<div className="px-5 pt-4 flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">

View file

@ -26,8 +26,9 @@ const ROOT = "";
* row per group to reattach, each with its reason and a new parent. The root
* is preselected; the other choices are those of the category form
* (`buildParentOptions`, the row's category treated as the one being edited).
* The service applies the rules again and may refuse (a parent that holds
* transactions, for one): the translated refusal shows above the list.
* The service applies the rules again and may refuse (a parent that still
* takes input and holds transactions, for one): the translated refusal shows
* above the list.
*
* The comboboxes open their list in a portal (#429), so the scrolling list of
* rows never cuts it.

View file

@ -335,4 +335,29 @@ describe("ChangelogContent — language switch", () => {
expect(html).toContain("note-0171-en");
expect(html).not.toMatch(/note-0171(?!-en)/);
});
it("never lets a late failure of the previous language empty the current history", async () => {
const pending: Array<{ url: string; resolve: (text: string) => void; reject: (error: Error) => void }> = [];
fetchMock.mockImplementation(
(url: string) =>
new Promise<FetchedFile>((resolveFetch, rejectFetch) => {
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }), reject: rejectFetch });
}),
);
render("0.17.1");
harness.commit(); // asks for the French file
ctx.language = "en";
render("0.17.1");
harness.commit(); // cancels the French one, asks for the English file
expect(pending.map((p) => p.url)).toEqual(["/CHANGELOG.fr.md", "/CHANGELOG.md"]);
pending[1].resolve(EN);
await settle();
pending[0].reject(new Error("the French file could not be read"));
await settle();
const html = renderToStaticMarkup(render("0.17.1"));
expect(html).toContain("note-0171-en");
expect(html).not.toContain("t(changelog.empty)");
});
});

View file

@ -15,6 +15,7 @@ import en from "../../i18n/locales/en.json";
import { SECTIONS } from "./DocsContent";
import { readSubsections } from "./DocsSubsections";
import { GETTING_STARTED_GUIDE } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
type Docs = Record<string, unknown>;
@ -137,6 +138,27 @@ describe("docs.* keys of the user guide", () => {
}
});
it("name the eight getting-started steps in the Dashboard section, by their labels, in the list's order", () => {
// docs.dashboard.steps[0] lists the checklist (#446 review): each row by
// the label it shows, in the order computeChecklist gives them, and the
// button that checks the visit steps by its own label.
const order = computeChecklist(
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
{ importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
).items.map((item) => item.key);
expect(order).toHaveLength(8);
for (const [lng, locale] of [["fr", fr], ["en", en]] as const) {
const onboarding = (locale as {
onboarding: { checklist: { items: Record<string, string> }; card: { acknowledge: string } };
}).onboarding;
const step = (section(LOCALES[lng], "dashboard").steps as string[])[0];
const at = order.map((key) => step.indexOf(onboarding.checklist.items[key]));
for (const [i, key] of order.entries()) expect(at[i], `${lng} ${key}`).toBeGreaterThan(-1);
expect(at, `${lng}: the list's order`).toEqual([...at].sort((a, b) => a - b));
expect(step, lng).toContain(onboarding.card.acknowledge);
}
});
it("describe the getting-started path in the section that \"Open the guide\" opens", () => {
// The end of the path links to the guide by its hash (#397); the section it
// opens carries the path's own sub-section (#401).

View file

@ -51,4 +51,8 @@ describe("LicenseKeyForm", () => {
expect(html).toContain('placeholder="t(license.keyPlaceholder)"');
expect(html).toMatch(/<button type="submit"[^>]*>t\(license\.activate\)<\/button>/);
});
it("names the cancel button through t (#437)", () => {
expect(render(null)).toMatch(/<button type="button"[^>]*>t\(common\.cancel\)<\/button>/);
});
});

View file

@ -0,0 +1,105 @@
// CategoryCombobox — the open list follows its input (#429, review 25431).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409),
// which refuses `useLayoutEffect` and `useId`: this file alone maps
// `useLayoutEffect` onto the harness's `useEffect` (same dependency rule, run
// by `commit()` after the render, where React runs a layout effect before the
// paint) and `useId` onto a constant. No DOM: the input is a fake whose
// `getBoundingClientRect` the test moves, `window` and `document` are stubs,
// and `createPortal` is the identity. What stays out of reach: the paint, and
// React skipping the render on an unchanged state (here, the same object in
// the state slot stands for it).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => {
const fake = (await import("../../test/hookHarness")).fakeReact(await orig());
return { ...fake, useLayoutEffect: fake.useEffect, useId: () => "cb" };
});
vi.mock("react-dom", async (orig) => ({
...(await orig<typeof import("react-dom")>()),
createPortal: (children: ReactNode) => children,
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import { hookHarness } from "../../test/hookHarness";
import CategoryCombobox from "./CategoryCombobox";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(el.type === type ? [el] : []), ...find(el.props.children, type)];
}
const CATEGORIES = [{ id: 7, name: "Épicerie", parent_id: null, sort_order: 1 }];
/** The input's place on screen, which a render of the parent can change. */
const rect = { top: 100, bottom: 130, left: 50, width: 200 };
const fakeInput = { getBoundingClientRect: () => ({ ...rect }), blur: vi.fn() };
/** One render of the mounted combobox. */
const render = () =>
harness.render(() => CategoryCombobox({ categories: CATEGORIES, value: null, onChange: vi.fn() }) as ReactNode);
/** The open list's style, or null when no list is rendered. */
const listStyle = (tree: ReactNode) => {
const [list] = find(tree, "ul");
return list ? (list.props.style as { top?: number; bottom?: number }) : null;
};
/** Mounts the combobox, hands its input ref the fake input, then focuses it: the list opens. */
function open(): ReactNode {
const closed = render();
const [input] = find(closed, "input");
(input.props.ref as { current: unknown }).current = fakeInput;
harness.commit();
(input.props.onFocus as () => void)();
render();
harness.commit(); // places the list
return render();
}
beforeEach(() => {
harness.clear();
Object.assign(rect, { top: 100, bottom: 130 });
vi.stubGlobal("window", { innerHeight: 800, addEventListener: vi.fn(), removeEventListener: vi.fn() });
vi.stubGlobal("document", { body: {}, addEventListener: vi.fn(), removeEventListener: vi.fn() });
});
afterEach(() => {
harness.unmount();
vi.unstubAllGlobals();
});
describe("CategoryCombobox — the open list follows its input (#429)", () => {
it("places the list under its input once open", () => {
expect(listStyle(render())).toBeNull();
harness.clear();
expect(listStyle(open())).toMatchObject({ top: 134 });
});
it("follows the input moved by a render of the parent, with no scroll nor resize", () => {
open();
// A window centered on screen grows when its content arrives: the input moves down.
Object.assign(rect, { top: 300, bottom: 330 });
render();
harness.commit();
expect(listStyle(render())).toMatchObject({ top: 334 });
});
it("keeps the same placement object when the input did not move, so React renders nothing more", () => {
open();
const placement = listStyle(render());
const before = harness.slots.find((slot) => (slot as { top?: number } | null)?.top === 134);
expect(before).toBeDefined();
render();
harness.commit();
expect(listStyle(render())).toEqual(placement);
expect(harness.slots).toContain(before);
});
});

View file

@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
import {
LISTBOX_MAX_HEIGHT,
followAnchor,
keepPlacement,
placeListbox,
samePlacement,
type ListboxPlacement,
} from "./placeListbox";
/**
@ -207,7 +207,7 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
anchor: input.getBoundingClientRect(),
viewportHeight: window.innerHeight,
});
setPlacement((prev) => (samePlacement(prev, next) ? prev : next));
setPlacement((prev) => keepPlacement(prev, next));
}, []);
useLayoutEffect(() => {
@ -216,6 +216,15 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
return followAnchor(window, updatePlacement, () => listRef.current);
}, [open, updatePlacement]);
// A render can move the input with no scroll nor resize: a window centered
// on screen grows when its content arrives, and its fields move with it.
// While the list is open, its position is read again after every render;
// an unchanged one keeps the same state object, so React renders nothing
// more (`keepPlacement`).
useLayoutEffect(() => {
if (open) updatePlacement();
});
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {

View file

@ -1,11 +1,17 @@
// placeListbox — where the open list of a category combobox goes (#429).
// The portal itself needs a DOM (manual check); its placement rule and the
// listeners that keep it on its input do not, and are pinned here.
// The portal itself needs a DOM (manual check); its placement rule, the
// listeners that keep it on its input and the state update that keeps an
// unchanged position do not, and are pinned here. The combobox reading its
// position again after a render that moved its input is pinned under the
// harness by CategoryCombobox.placement.test.ts. What still needs a DOM: the
// paint, and React skipping the render when `keepPlacement` hands back the
// same object, which is what ends that reading.
import { describe, it, expect, vi } from "vitest";
import {
LISTBOX_GAP,
LISTBOX_MAX_HEIGHT,
followAnchor,
keepPlacement,
placeListbox,
samePlacement,
type ListenerTarget,
@ -31,10 +37,11 @@ describe("placeListbox", () => {
});
it("stays below when the room below is exactly the list's maximum height", () => {
// 130 (input's bottom) + 4 + 240 = 374.
const placement = placeListbox({ anchor: anchorAt(100), viewportHeight: 374 });
// Room below: 574 - 330 - 4 = 240, the list's height; room above: 300 - 4 =
// 296, enough too. Below wins, its usual place.
const placement = placeListbox({ anchor: anchorAt(300), viewportHeight: 574 });
expect(placement.openUpward).toBe(false);
expect(placement.top).toBe(134);
expect(placement.top).toBe(334);
});
it("opens upward when the room below is short and the room above suffices", () => {
@ -88,6 +95,22 @@ describe("samePlacement", () => {
});
});
describe("keepPlacement", () => {
const below: ListboxPlacement = { left: 50, width: 200, openUpward: false, top: 134 };
it("hands back the previous object itself when the position did not change", () => {
// React skips a render on the same object: the reading after each render ends.
const previous = { ...below };
expect(keepPlacement(previous, { ...below })).toBe(previous);
});
it("takes the new position when the input moved, or on the first reading", () => {
const moved = { ...below, top: 334 };
expect(keepPlacement(below, moved)).toBe(moved);
expect(keepPlacement(null, below)).toBe(below);
});
});
/**
* A fake `window` with the DOM's rule: a listener is removed only by the same
* type, the same function and the same capture flag.

View file

@ -72,6 +72,19 @@ export function samePlacement(a: ListboxPlacement | null, b: ListboxPlacement):
);
}
/**
* The state update of a new reading: the previous object itself when the
* position did not change. React then skips the render, which is what lets
* `CategoryCombobox` read the position again after every render of an open
* list without looping.
*/
export function keepPlacement(
previous: ListboxPlacement | null,
next: ListboxPlacement,
): ListboxPlacement {
return samePlacement(previous, next) ? (previous as ListboxPlacement) : next;
}
/** The part of `window` the listeners use, so a test can pass a fake. */
export interface ListenerTarget {
addEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void;

View file

@ -147,6 +147,10 @@ describe("TransactionTable — texts that go through t (#437)", () => {
const icon = 'aria-label="t(transactions.transferIcon.ariaLabel)"';
expect(count(html, icon)).toBe(1);
expect(bodyRows(html)[1]).toContain(icon);
// Its tooltip, the account and the direction of the link included.
expect(bodyRows(html)[1]).toContain(
'title="t(transactions.transferIcon.tooltip): CELI (t(balance.transfers.direction.in))"',
);
});
it("names every row's notes button through t", () => {

View file

@ -9,7 +9,7 @@
// SplitButton (or to a row's notes button), then renders again to read what
// opened.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Fragment, isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
@ -156,4 +156,17 @@ describe("TransactionTable — the notes row (#437)", () => {
expect(areas).toHaveLength(1);
expect(areas[0].props.placeholder).toBe("t(transactions.notes.placeholder)");
});
it("opens the notes of the row whose button was clicked, under that row", () => {
(notesButtons(render("open"))[1].props.onClick as () => void)();
// Each row and the rows it opens sit in a Fragment keyed by the row's id
// (the unkeyed ones are inside the cells).
const byRow = find(render("open"), Fragment)
.filter((row) => row.key !== null)
.map((row) => [row.key, find(row.props.children, "textarea").length]);
expect(byRow).toEqual([
["1", 0],
["2", 1],
]);
});
});

View file

@ -359,6 +359,11 @@ describe("buildBudgetYearRows — a category whose parent is deleted or gone (#3
);
expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false);
});
it("hides a category that is its own parent too, a loop of one member", () => {
const rows = buildBudgetYearRows([...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 7 })], [entry(7, 40)], []);
expect(rows.some((r) => r.category_id === 7)).toBe(false);
});
});
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {

View file

@ -42,10 +42,12 @@ import {
} from "./useCategories";
import {
CategoryHierarchyError,
createCategory,
findOrphanCategories,
getAllCategoriesWithCounts,
reattachCategory,
updateCategory,
updateCategorySortOrders,
} from "../services/categoryService";
import { logInfo } from "../services/logService";
import { markedT } from "../test/i18nMock";
@ -80,8 +82,11 @@ beforeEach(() => {
vi.mocked(getAllCategoriesWithCounts).mockReset();
vi.mocked(getAllCategoriesWithCounts).mockResolvedValue(ROWS as never);
vi.mocked(updateCategory).mockReset();
vi.mocked(createCategory).mockReset();
vi.mocked(findOrphanCategories).mockReset();
vi.mocked(reattachCategory).mockReset();
vi.mocked(updateCategorySortOrders).mockReset();
vi.mocked(updateCategorySortOrders).mockResolvedValue(undefined);
vi.mocked(logInfo).mockReset();
});
@ -92,6 +97,15 @@ describe("categoriesErrorOf / categoriesErrorText (#393)", () => {
expect(categoriesErrorText(error, markedT)).toBe("t(categories.hierarchy.errors.cycle)");
});
it("names the refusals of a parent's budget amounts and keywords by their keys (decision of 2026-10-19)", () => {
expect(categoriesErrorOf(new CategoryHierarchyError("parent_has_budget"))).toEqual({
key: "categories.hierarchy.errors.parent_has_budget",
});
expect(categoriesErrorOf(new CategoryHierarchyError("parent_has_keywords"))).toEqual({
key: "categories.hierarchy.errors.parent_has_keywords",
});
});
it("keeps the message of any other failure", () => {
const error = categoriesErrorOf(new Error("database is locked"));
expect(error).toEqual({ message: "database is locked" });
@ -116,6 +130,21 @@ describe("useCategories — saving the form (#393)", () => {
expect(state.editingCategory).not.toBeNull();
});
it("shows a refused sub-category by its translated code, and keeps the creation form open", async () => {
vi.mocked(createCategory).mockRejectedValue(new CategoryHierarchyError("parent_has_keywords"));
let api = await mount();
api.startCreating();
api = harness.render(useCategories);
const child = { ...FORM, name: "Électricité", parent_id: 10 };
await api.saveCategory(child);
const { state } = harness.render(useCategories);
expect(createCategory).toHaveBeenCalledWith(expect.objectContaining({ name: "Électricité", parent_id: 10 }));
expect(state.error).toEqual({ key: "categories.hierarchy.errors.parent_has_keywords" });
expect(state.isSaving).toBe(false);
expect(state.isCreating).toBe(true);
expect(state.editingCategory).not.toBeNull();
});
it("shows any other failure by its message", async () => {
vi.mocked(updateCategory).mockRejectedValue(new Error("database is locked"));
let api = await mount();
@ -155,7 +184,8 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1);
// The mount's read, then the check's.
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
await api.reattachOrphan(CYCLE, 1);
@ -166,7 +196,7 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
expect(line).toContain("3 categories");
for (const row of ROWS) expect(line).not.toContain(row.name);
expect(findOrphanCategories).toHaveBeenCalledTimes(2);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck).toEqual({ orphans: [], error: null });
expect(state.isSaving).toBe(false);
@ -183,7 +213,8 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
await api.reattachOrphan(CYCLE, null);
expect(reattachCategory).toHaveBeenCalledWith(3, null);
expect(logInfo).toHaveBeenCalledTimes(1);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
// The mount's read, the check's, and the reload after the committed reattachment.
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck?.error).toEqual({ message: "database is locked" });
expect(state.isSaving).toBe(false);
@ -206,6 +237,126 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
expect(logInfo).not.toHaveBeenCalled();
});
it("a refusal, then a reattachment that goes through: the window's message goes", async () => {
vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]);
vi.mocked(reattachCategory)
.mockRejectedValueOnce(new CategoryHierarchyError("parent_has_transactions"))
.mockResolvedValueOnce(3);
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
await api.reattachOrphan(CYCLE, 1);
api = harness.render(useCategories);
expect(api.state.hierarchyCheck?.error).toEqual({ key: "categories.hierarchy.errors.parent_has_transactions" });
await api.reattachOrphan(CYCLE, null);
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [], error: null });
});
it("after a drag-and-drop, reads the categories again: the window gets the members of a loop back", async () => {
// 1 with 10 under it, 2, and 3 <-> 5: no root reaches 3 nor 5.
const row = (id: number, parent_id: number | null) => ({ ...ROWS[1], id, name: `c${id}`, parent_id, sort_order: id });
vi.mocked(getAllCategoriesWithCounts).mockResolvedValue([
row(1, null),
row(10, 1),
row(2, null),
row(3, 5),
row(5, 3),
] as never);
vi.mocked(findOrphanCategories).mockResolvedValue([CYCLE]);
let api = await mount();
const ids = () => harness.render(useCategories).state.categories.map((c) => c.id).sort((a, b) => a - b);
expect(ids()).toEqual([1, 2, 3, 5, 10]);
await api.moveCategory(2, null, 0);
// The drag-and-drop keeps what the tree shows: the loop is gone from the list.
expect(ids()).toEqual([1, 2, 10]);
api = harness.render(useCategories);
harness.dispatch.mockClear();
await api.checkHierarchy();
expect(ids()).toEqual([1, 2, 3, 5, 10]);
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [CYCLE], error: null });
// Read without the page's loading state: the tree and an open form stay on screen.
const types = harness.dispatch.mock.calls.map(([action]) => (action as { type: string }).type);
expect(types).not.toContain("SET_LOADING");
});
it("lets a load started during the check's read win over it", async () => {
let answer: (rows: unknown) => void = () => undefined;
const NEWER = [...ROWS, { ...ROWS[0], id: 2, name: "Épargne", sort_order: 2 }];
vi.mocked(findOrphanCategories).mockResolvedValue([]);
let api = await mount();
vi.mocked(getAllCategoriesWithCounts)
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never)
.mockResolvedValueOnce(NEWER as never);
const checked = api.checkHierarchy();
await flush(); // the check's read is out, still unanswered
api = harness.render(useCategories);
await api.reinitializeCategories(); // a newer load: NEWER lands
answer(ROWS); // the check's older answer comes last
await checked;
const ids = harness.render(useCategories).state.categories.map((c) => c.id).sort((a, b) => a - b);
expect(ids).toEqual([1, 2, 10]);
});
it("a reattachment reads the categories again without the loading state: a form behind the window stays", async () => {
vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]);
vi.mocked(reattachCategory).mockResolvedValue(3);
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
harness.dispatch.mockClear();
await api.reattachOrphan(CYCLE, null);
const types = harness.dispatch.mock.calls.map(([action]) => (action as { type: string }).type);
expect(types).toContain("SET_CATEGORIES");
expect(types).not.toContain("SET_LOADING");
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3);
});
it("shows a failed read of the categories in the window, never in the page's banner behind it", async () => {
vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]);
vi.mocked(reattachCategory).mockResolvedValue(3);
let api = await mount();
// At the opening: the diagnostic does not run on the old categories.
vi.mocked(getAllCategoriesWithCounts).mockRejectedValueOnce(new Error("database is locked"));
await api.checkHierarchy();
let state = harness.render(useCategories).state;
expect(state.hierarchyCheck).toEqual({ orphans: null, error: { message: "database is locked" } });
expect(state.error).toBeNull();
expect(findOrphanCategories).not.toHaveBeenCalled();
// After a committed reattachment.
api = harness.render(useCategories);
await api.checkHierarchy();
api = harness.render(useCategories);
vi.mocked(getAllCategoriesWithCounts).mockRejectedValueOnce(new Error("disk I/O error"));
await api.reattachOrphan(CYCLE, null);
state = harness.render(useCategories).state;
expect(reattachCategory).toHaveBeenCalledWith(3, null);
expect(state.hierarchyCheck).toEqual({ orphans: [], error: { message: "disk I/O error" } });
expect(state.error).toBeNull();
expect(state.isSaving).toBe(false);
});
it("reads the categories once the drag-and-drop's writes are over, never halfway", async () => {
let written: () => void = () => undefined;
vi.mocked(updateCategorySortOrders).mockReturnValue(new Promise<void>((r) => (written = r)));
vi.mocked(findOrphanCategories).mockResolvedValue([]);
let api = await mount();
const moved = api.moveCategory(10, null, 0);
api = harness.render(useCategories);
const checked = api.checkHierarchy();
await flush();
// The mount's read only: the check waits for the writes.
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1);
expect(findOrphanCategories).not.toHaveBeenCalled();
written();
await Promise.all([moved, checked]);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [], error: null });
});
it("closes the window; a check that ends after it closed changes nothing", async () => {
let resolve: (orphans: OrphanCategory[]) => void = () => undefined;
vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r)));

View file

@ -204,6 +204,8 @@ export function useCategories() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const duplicateCheckDone = useRef(false);
/** The writes of the last drag-and-drop, settled or not: the hierarchy check reads after them. */
const pendingMoveRef = useRef<Promise<unknown>>(Promise.resolve());
const loadCategories = useCallback(async () => {
const fetchId = ++fetchIdRef.current;
@ -393,8 +395,10 @@ export function useCategories() {
}
collectUpdates(newTree, null);
const written = updateCategorySortOrders(updates);
pendingMoveRef.current = written.catch(() => undefined);
try {
await updateCategorySortOrders(updates);
await written;
} catch {
// Revert on error
await loadCategories();
@ -403,22 +407,49 @@ export function useCategories() {
[state.tree, loadCategories]
);
/** Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). */
/**
* Reads the categories again for the « Vérifier la hiérarchie » window
* (#393), without the page's loading state: `loadCategories` would show
* « Chargement » in place of the tree and the detail panel, and a form open
* behind the window would come back from its stored values, its unsaved
* changes lost. Waits for a drag-and-drop's writes first (one `UPDATE` per
* category: read halfway, the tree would show half the move); a load
* started meanwhile wins. Throws when the read fails.
*/
const readCategoriesAgain = useCallback(async () => {
await pendingMoveRef.current;
const fetchId = fetchIdRef.current;
const rows = await getAllCategoriesWithCounts();
if (fetchId !== fetchIdRef.current) return;
const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] }));
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree: buildTree(flat) } });
}, []);
/**
* Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393).
* The window names the orphans and offers their parents from
* `state.categories`, which a drag-and-drop leaves with what the tree shows
* only (`SET_TREE`), without the categories no root reaches: the categories
* are read again first (`readCategoriesAgain`).
*/
const checkHierarchy = useCallback(async () => {
dispatch({ type: "OPEN_HIERARCHY_CHECK" });
try {
await readCategoriesAgain();
const orphans = await findOrphanCategories();
dispatch({ type: "SET_ORPHANS", payload: orphans });
} catch (e) {
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
}
}, []);
}, [readCategoriesAgain]);
/**
* Reattaches an orphan under `parentId`, or to the root with `null` (#393).
* On success: one Journal line (counts, no name), then the orphan list and
* the tree reload; the tree reloads even if the new check fails, since the
* write is committed. A refusal shows in the window, by its translated code.
* the categories are read again; the categories are read even if the new
* check fails, since the write is committed. Read like at the opening
* (`readCategoriesAgain`), so a form open behind the window keeps its
* unsaved changes. A refusal, or a failed read, shows in the window.
*/
const reattachOrphan = useCallback(
async (orphan: OrphanCategory, parentId: number | null) => {
@ -430,14 +461,14 @@ export function useCategories() {
const orphans = await findOrphanCategories();
dispatch({ type: "SET_ORPHANS", payload: orphans });
} finally {
await loadCategories();
await readCategoriesAgain();
}
dispatch({ type: "SET_SAVING", payload: false });
} catch (e) {
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
}
},
[loadCategories]
[readCategoriesAgain]
);
const closeHierarchyCheck = useCallback(() => {

View file

@ -10,14 +10,21 @@
// (a failed rescan leaves its error alone on screen), a refusal of the Rust
// command shown by its translation key and values, any other message raw, and
// the Journal line of a refusal, which never carries the import folder's path
// or name (the Journal travels with a feedback report).
// or name (the Journal travels with a feedback report). Then the banner of a
// refusal once the folder is read again, by « Actualiser » (`refreshFolder`)
// or by another folder (`browseFolder`, which sets the folder before its
// scan, so the root files shown are the new folder's).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ipc = vi.hoisted(() => ({
/** The stored import folder: its name must never reach the Journal. */
importFolder: "/home/jean/Relevés bancaires",
/** What each command answers, in call order: a value, or an Error or a string to reject with. */
/**
* What each command answers, in call order: a value, or an Error or a string
* to reject with; `{ resolves: "…" }` for a command that resolves a string
* (`pick_folder`).
*/
answers: {} as Record<string, unknown[]>,
}));
vi.mock("@tauri-apps/api/core", () => ({
@ -26,6 +33,7 @@ vi.mock("@tauri-apps/api/core", () => ({
if (queue.length === 0) throw new Error(`unexpected command ${command}`);
const answer = queue.shift();
if (answer instanceof Error || typeof answer === "string") throw answer;
if (typeof answer === "object" && answer !== null && "resolves" in answer) return answer.resolves;
return answer;
}),
}));
@ -174,3 +182,62 @@ describe("createSampleFolders — a refusal (#384, D2)", () => {
expect(journal().join("\n")).not.toContain("/home/jean");
});
});
// The banner of a refusal asks to refresh the list: « Actualiser », or a new
// folder, reads the folder again and takes the banner away, unless that
// reading fails and puts up its own (#384 review). `browseFolder` sets the
// folder before its scan, so the scan's root files are the new folder's
// (#446 review).
describe("the error banner when the folder is read again (#384)", () => {
const OTHER_FOLDER = "/home/jean/Autres relevés";
/** A refused creation, then the next render of the wizard. */
async function refused() {
ipc.answers.create_sample_import_folders = ["sample_folder_exists:Exemple Desjardins"];
const state = await createSamples();
expect(state.error).toBe("import.empty.sampleErrors.folderExists");
return harness.render(useImportWizard);
}
it("« Actualiser » that goes through takes the banner away", async () => {
const wizard = await refused();
ipc.answers.scan_import_folder.push(SAMPLE_SCAN);
await wizard.refreshFolder();
const state = harness.render(useImportWizard).state;
expect(state.error).toBeNull();
expect(state.errorValues).toBeNull();
expect(state.scannedSources).toBe(SAMPLE_SCAN.sources);
});
it("« Actualiser » that fails puts up its own error instead", async () => {
const wizard = await refused();
ipc.answers.scan_import_folder.push(new Error(`Folder does not exist: ${IMPORT_FOLDER}`));
await wizard.refreshFolder();
const state = harness.render(useImportWizard).state;
expect(state.error).toBe(`Folder does not exist: ${IMPORT_FOLDER}`);
expect(state.errorValues).toBeNull();
});
it("another folder takes the banner away, and shows its own root files", async () => {
const wizard = await refused();
ipc.answers.pick_folder = [{ resolves: OTHER_FOLDER }];
ipc.answers.scan_import_folder.push({ sources: [], root_files: ["releve-4510.csv"] });
await wizard.browseFolder();
const state = harness.render(useImportWizard).state;
expect(state.importFolder).toBe(OTHER_FOLDER);
expect(state.error).toBeNull();
expect(state.errorValues).toBeNull();
expect(state.rootFiles).toEqual(["releve-4510.csv"]);
expect(vi.mocked(invoke)).toHaveBeenCalledWith("scan_import_folder", { folderPath: OTHER_FOLDER });
});
it("a cancelled picker keeps the folder and its banner", async () => {
const wizard = await refused();
ipc.answers.pick_folder = [null];
await wizard.browseFolder();
const state = harness.render(useImportWizard).state;
expect(state.importFolder).toBe(IMPORT_FOLDER);
expect(state.error).toBe("import.empty.sampleErrors.folderExists");
expect(state.errorValues).toEqual({ name: "Exemple Desjardins" });
});
});

View file

@ -24,6 +24,9 @@ vi.mock("../services/db", () => {
import { readFileSync } from "fs";
import { resolve } from "path";
import i18next from "i18next";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
import {
wizardReducer,
initialWizardState,
@ -271,6 +274,35 @@ describe("sampleFolderError — the refusals of create_sample_import_folders (#3
expect(sampleFolderError("invalid_date")).toEqual({ key: "import.empty.sampleErrors.invalidDate" });
});
it("the real messages name the folder, in French and in English, as the banner renders them", async () => {
// The tests of the page take the marked `t`, and the key guard (#453)
// compares no value: only the real locale files, through a real i18next
// with the app's options, prove the `{{name}}` of the message.
const i18n = i18next.createInstance();
await i18n.init({
resources: { fr: { translation: fr }, en: { translation: en } },
lng: "fr",
fallbackLng: "en",
interpolation: { escapeValue: false },
});
for (const lng of ["fr", "en"]) {
const t = i18n.getFixedT(lng);
for (const name of ["Exemple Desjardins", "Exemple Tangerine"]) {
const error = sampleFolderError(`sample_folder_exists:${name}`)!;
// ImportPage: t(state.error, { ...errorValues, defaultValue: state.error }).
const text = t(error.key, { ...error.values, defaultValue: error.key });
expect(text, `${lng} ${name}`).toContain(name);
expect(text, `${lng} ${name}`).not.toContain("{{");
}
for (const code of ["import_folder_missing", "invalid_date"]) {
const error = sampleFolderError(code)!;
const text = t(error.key, { defaultValue: error.key });
expect(text, `${lng} ${code}`).not.toBe(error.key);
expect(text, `${lng} ${code}`).not.toContain("{{");
}
}
});
it("any other message is unknown, so the banner shows it raw", () => {
for (const raw of [
"Cannot write Exemple Desjardins/releve-2026-09.csv: Permission denied (os error 13)",

View file

@ -460,7 +460,13 @@ export function useImportWizard() {
try {
const folder = await invoke<string | null>("pick_folder");
if (folder) {
// Another folder: the error banner of the previous one goes, as in
// `selectSource` (#384 review); a failed scan puts up its own. A
// cancelled picker changes nothing, so its banner stays.
dispatch({ type: "SET_ERROR", payload: null });
await setImportFolder(folder);
// Before the scan (#446): `SET_IMPORT_FOLDER` empties the root files,
// which the scan of the new folder then fills.
dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
await scanFolderInternal(folder);
}
@ -474,6 +480,10 @@ export function useImportWizard() {
const refreshFolder = useCallback(async () => {
if (state.importFolder) {
// A new reading of the folder: the error banner of an earlier action
// goes (a refused sample folder asks for this very refresh, #384
// review), and a failed scan puts up its own.
dispatch({ type: "SET_ERROR", payload: null });
await scanFolderInternal(state.importFolder);
}
}, [state.importFolder, scanFolderInternal]);

View file

@ -68,7 +68,9 @@ const EN = nodesOf(en);
describe("locale keys: fr.json and en.json (#453)", () => {
it("reads both files whole, array items included", () => {
// The canary: an empty or unread file would make the comparison pass on nothing.
// The canary: an empty or unread file would make the comparison pass on
// nothing, and so would one file imported twice.
expect(en, "en.json and fr.json are two modules").not.toBe(fr);
for (const [name, nodes] of [["fr.json", FR], ["en.json", EN]] as const) {
expect(nodes.get("common.cancel"), name).toBe("leaf");
expect(nodes.get("dashboard.help.tips"), name).toBe("array");
@ -95,6 +97,8 @@ describe("locale keys: the comparison (#453)", () => {
const b = nodesOf({ list: ["one"], only: { b: "y" } });
expect(missingKeys(a, b)).toEqual(["list.1", "only.a"]);
expect(missingKeys(b, a)).toEqual(["only.b"]);
// Leaves only: the container `zz` of a missing branch is not listed.
expect(missingKeys(nodesOf({ zz: { only: "x" } }), nodesOf({}))).toEqual(["zz.only"]);
});
it("holds an array against an object with the same indices as a gap", () => {

View file

@ -466,7 +466,9 @@
"self_parent": "A category cannot be its own parent.",
"cycle": "This parent would create a parent loop: it sits under this category, or inside a loop. Choose another parent.",
"too_deep": "With this parent, the category or its subcategories would go beyond three levels. Choose a parent higher in the hierarchy.",
"parent_has_transactions": "This parent has transactions of its own: once a parent, it would no longer take input. Choose another parent, or recategorize its transactions first."
"parent_has_transactions": "This parent has transactions of its own: once a parent, it would no longer take input. Choose another parent, or recategorize its transactions first.",
"parent_has_budget": "This parent has budget amounts of its own, for any year in the Budget grid or in a budget template: once a parent, it would no longer take input and those amounts would drop out of the grid. Choose another parent, or first set its amounts to zero in the grid, for every year that has some, and delete, on the Budget page, the templates that hold any: a template cannot be edited.",
"parent_has_keywords": "This parent has keywords of its own: once a parent, it would no longer take input, yet its keywords would keep sorting transactions into it. Choose another parent, or first delete its keywords (add them to another category if needed)."
}
},
"help": {
@ -1013,7 +1015,7 @@
"Welcome card for a profile without transactions, with the getting-started checklist: eight steps, checked from your data or with \"Got it\" for the visit steps, and a \"Hide\" button"
],
"steps": [
"In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist: create a profile (already ticked), choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total",
"In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist, in eight steps: \"Create a profile\" (already ticked), \"Choose an import folder\", \"Set up an import source\", \"Import a first file\", \"Browse categories and keywords\", \"Categorize a transaction by hand\", \"Enter a first budget\" and \"Browse the reports\". The steps are ticked from your data, except the two visit steps (categories and reports), ticked with \"Got it\" on their card or, for the reports, by a click on the outlined Trends card. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total",
"Once the profile has transactions, the checklist leaves the dashboard: the getting-started banner, at the top of every page, takes over until the end of the path (see Getting Started → The getting-started path)",
"\"Hide\" removes the checklist from the welcome card, and the banner from every page, for this profile only; Settings → Users shows them again",
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
@ -1201,7 +1203,7 @@
"Use the All Keywords view to get a global overview of your categorization rules",
"Use Re-initialize to reset categories to defaults — this will unlink all transaction categories",
"A category missing from the tree, the menus and the budget is often caught in a parent loop: Check hierarchy finds it, and its transactions, keywords and budget amounts come back with it",
"A category with transactions of its own cannot become the parent of an existing category, since it would no longer take input: recategorize its transactions first, or choose another parent"
"A category that still takes input and has transactions, budget amounts (of any year, or in a template) or keywords of its own cannot become a parent, of an existing category or of a new subcategory, since it would no longer take input: first recategorize its transactions, set its amounts to zero in the Budget grid for every year concerned (for a past year, its planned amounts also leave the Budget vs Actual report) and delete the templates that hold any, since a template cannot be edited, delete its keywords, or choose another parent. An existing parent, which no longer takes input, accepts new subcategories without this check"
]
},
"adjustments": {

View file

@ -466,7 +466,9 @@
"self_parent": "Une catégorie ne peut pas être son propre parent.",
"cycle": "Ce parent créerait une boucle de parenté : il se trouve sous cette catégorie, ou dans une boucle. Choisissez un autre parent.",
"too_deep": "Avec ce parent, la catégorie ou ses sous-catégories dépasseraient trois niveaux. Choisissez un parent plus haut dans la hiérarchie.",
"parent_has_transactions": "Ce parent a des transactions à son nom : devenu parent, il ne serait plus saisissable. Choisissez un autre parent, ou reclassez d'abord ses transactions."
"parent_has_transactions": "Ce parent a des transactions à son nom : devenu parent, il ne serait plus saisissable. Choisissez un autre parent, ou reclassez d'abord ses transactions.",
"parent_has_budget": "Ce parent a des montants de budget à son nom, d'une année quelconque dans la grille du Budget ou dans un modèle de budget : devenu parent, il ne serait plus saisissable et ces montants sortiraient de la grille. Choisissez un autre parent, ou remettez d'abord ses montants à zéro dans la grille, pour chaque année qui en a, et supprimez, dans la page Budget, les modèles qui en contiennent : un modèle ne se modifie pas.",
"parent_has_keywords": "Ce parent a des mots-clés à son nom : devenu parent, il ne serait plus saisissable, mais ses mots-clés continueraient d'y classer des transactions. Choisissez un autre parent, ou supprimez d'abord ses mots-clés (ajoutez-les au besoin à une autre catégorie)."
}
},
"help": {
@ -1013,7 +1015,7 @@
"Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes, cochées selon vos données ou par « J'ai compris » pour les visites, et un bouton « Masquer »"
],
"steps": [
"Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total",
"Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage, en huit étapes : « Créer un profil » (déjà cochée), « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget » et « Parcourir les rapports ». Les étapes se cochent selon vos données, sauf les deux étapes de visite (catégories et rapports), cochées par « J'ai compris » sur leur carte ou, pour les rapports, par un clic sur la carte Tendances entourée. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total",
"Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)",
"« Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche",
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
@ -1201,7 +1203,7 @@
"Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation",
"Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions",
"Une catégorie disparue de l'arbre, des menus et du budget est souvent prise dans une boucle de parenté : Vérifier la hiérarchie la retrouve, et ses transactions, ses mots-clés et ses montants de budget reviennent avec elle",
"Une catégorie qui a des transactions à son nom ne peut pas devenir le parent d'une catégorie existante, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, ou choisissez un autre parent"
"Une catégorie encore saisissable qui a des transactions, des montants de budget (d'une année quelconque, ou dans un modèle) ou des mots-clés à son nom ne peut pas devenir parent, ni d'une catégorie existante ni d'une nouvelle sous-catégorie, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, remettez ses montants à zéro dans la grille du Budget pour chaque année concernée (pour une année passée, son prévu quitte aussi le rapport Budget vs Réel) et supprimez les modèles qui en contiennent, un modèle ne se modifiant pas, supprimez ses mots-clés, ou choisissez un autre parent. Un parent existant, qui n'est plus saisissable, accepte de nouvelles sous-catégories sans cette vérification"
]
},
"adjustments": {

View file

@ -202,6 +202,35 @@ describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
});
it("hands the window every category, not the tree: an orphan in a loop is named", () => {
const node = (id: number, name: string, parent_id: number | null) => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
// 3 <-> 5: no root reaches them, so the tree holds root 1 (and 10 under it) only.
const categories = [node(1, "Logement", null), node(10, "Loyer", 1), node(3, "Cadeaux", 5), node(5, "Fêtes", 3)];
harness.state = {
isLoading: false,
categories,
tree: [{ ...categories[0], children: [categories[1]] }],
hierarchyCheck: { orphans: [{ id: 3, reason: "cycle" }], error: null },
};
const html = render();
expect(html).toContain(">Cadeaux</p>");
expect(html).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
expect(html).not.toContain(">—</p>");
});
it("translates a refused save of the form by its key, and shows any other failure as is", () => {
harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)</div>");

View file

@ -1,14 +1,24 @@
// categoryService — the hierarchy rules at the write (#393): a new parent is
// checked by `updateCategory` and `reattachCategory`, the reads and the writes
// run in one transaction through its handle, the orphan diagnostic reads every
// category, and `getCategoryDepth` ends on a cycle.
// checked by `updateCategory` and `reattachCategory`, a parent's content by
// `createCategory` too, the reads and the writes run in one transaction
// through its handle, the orphan diagnostic reads every category, and
// `getCategoryDepth` ends on a cycle.
//
// The base is a fake: a `categories` table in memory, the direct transaction
// count of each category, BEGIN/COMMIT/ROLLBACK. `withTransaction` hands the
// callback its own handle; the global connection of `getDb()` throws while a
// transaction runs, as the real lock would deadlock (docs/architecture.md,
// « Accès DB et transactions »).
// The base is a fake: a `categories` table in memory and BEGIN/COMMIT/ROLLBACK.
// What a parent holds (its transactions, budget amounts and keywords) lives in
// a real SQLite database (`node:sqlite`, built into Node, no package), whose
// tables are created by their CREATE TABLE of the schema of a new profile
// (`consolidated_schema.sql`): the checks' SQL runs as written, so a condition
// dropped from a query (`amount <> 0`, `is_active = 1`) turns a test red.
// `withTransaction` hands the callback its own handle; the global connection
// of `getDb()` throws while a transaction runs, as the real lock would
// deadlock (docs/architecture.md, « Accès DB et transactions »).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { DatabaseSync } from "node:sqlite";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
const fake = vi.hoisted(() => ({
inTransaction: false,
@ -30,6 +40,7 @@ vi.mock("./db", () => ({
import { withTransaction } from "./db";
import {
CATEGORY_HIERARCHY_ERROR_CODES,
CategoryHierarchyError,
categoryHierarchyErrorKey,
createCategory,
@ -58,23 +69,72 @@ const row = (id: number, parent_id: number | null, extra: Partial<Row> = {}): Ro
...extra,
});
const HIERARCHY_SQL = "SELECT id, parent_id, is_active FROM categories";
const HIERARCHY_SQL = "SELECT id, parent_id, is_active, is_inputable FROM categories";
/** What categories hold in their own name, for the checks of a parent's content. */
interface Content {
/** Budget amounts of a year, as [category, amount]. */
budget?: Array<[number, number]>;
/** Amounts of budget templates, as [category, amount]. */
templates?: Array<[number, number]>;
/** Keywords, as [category, is_active]. */
keywords?: Array<[number, 0 | 1]>;
}
/** The tables the content checks read. */
const CONTENT_TABLES = ["transactions", "budget_entries", "budget_template_entries", "keywords"];
const CONTENT_SQL = /\bFROM (transactions|budget_entries|budget_template_entries|keywords)\b/;
const SCHEMA = readFileSync(
resolve(import.meta.dirname, "../../src-tauri/src/database/consolidated_schema.sql"),
"utf-8",
);
/** The CREATE TABLE of `table` in the schema of a new profile. */
function createTableSql(table: string): string {
const found = SCHEMA.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table} \\([\\s\\S]*?\\n\\);`));
if (!found) throw new Error(`no CREATE TABLE for ${table} in consolidated_schema.sql`);
return found[0];
}
/**
* The categories table and the direct transaction counts. Every statement is
* logged with the connection it went through ("handle" or "global").
* A SQLite database with the real content tables (no categories table: the
* foreign keys are off) holding `transactions` direct transactions per
* category and `content`.
*/
function useFakeDb(initial: Row[], transactions: Record<number, number> = {}) {
function contentDb(transactions: Record<number, number>, content: Content): DatabaseSync {
const db = new DatabaseSync(":memory:", { enableForeignKeyConstraints: false });
for (const table of CONTENT_TABLES) db.exec(createTableSql(table));
const tx = db.prepare("INSERT INTO transactions (date, description, amount, category_id) VALUES ('2026-09-01', 'tx', -10, ?)");
for (const [category, count] of Object.entries(transactions)) {
for (let i = 0; i < count; i++) tx.run(Number(category));
}
const budget = db.prepare("INSERT INTO budget_entries (category_id, year, month, amount) VALUES (?, 2026, ?, ?)");
(content.budget ?? []).forEach(([category, amount], i) => budget.run(category, (i % 12) + 1, amount));
const template = db.prepare("INSERT INTO budget_template_entries (template_id, category_id, amount) VALUES (?, ?, ?)");
(content.templates ?? []).forEach(([category, amount], i) => template.run(i + 1, category, amount));
const keyword = db.prepare("INSERT INTO keywords (keyword, category_id, priority, is_active) VALUES (?, ?, 0, ?)");
(content.keywords ?? []).forEach(([category, active], i) => keyword.run(`KW${i}`, category, active));
return db;
}
/**
* The categories table, and what categories hold. Every statement is logged
* with the connection it went through ("handle" or "global").
*/
function useFakeDb(initial: Row[], transactions: Record<number, number> = {}, content: Content = {}) {
let rows = initial.map((r) => ({ ...r }));
let beforeTransaction: Row[] | null = null;
const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = [];
const held = contentDb(transactions, content);
const select = async (sql: string, params: unknown[] = []) => {
if (sql === HIERARCHY_SQL) {
return rows.map(({ id, parent_id, is_active }) => ({ id, parent_id, is_active }));
return rows.map(({ id, parent_id, is_active, is_inputable }) => ({ id, parent_id, is_active, is_inputable }));
}
if (sql.includes("COUNT(*) AS cnt FROM transactions WHERE category_id = $1")) {
return [{ cnt: transactions[params[0] as number] ?? 0 }];
if (CONTENT_SQL.test(sql)) {
// The query as written, its `$1` bound by name.
return held.prepare(sql).all(Object.fromEntries(params.map((p, i) => [`$${i + 1}`, p as number])));
}
if (sql.includes("SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1")) {
const found = rows.find((r) => r.id === params[0] && r.is_active === 1);
@ -261,16 +321,31 @@ describe("updateCategory — regression of #393: A -> B -> A", () => {
});
it("does not check an unchanged parent: a rename under a parent with transactions or at a fourth level is saved", async () => {
// Stored before the rules: 2 holds transactions and has a child; 1000 sits at level 3.
const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 });
// Stored before the rules: 2 holds transactions, a budget amount and a
// keyword, and has a child; 1000 sits at level 3.
const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 }, { budget: [[2, 40]], keywords: [[2, 1]] });
await updateCategory(20, form(2, { name: "Rent" }));
await updateCategory(1000, form(100, { name: "Deep" }));
expect(db.rows().find((r) => r.id === 20)?.name).toBe("Rent");
expect(db.rows().find((r) => r.id === 1000)?.name).toBe("Deep");
expect(db.log.some((l) => l.sql.includes("FROM transactions"))).toBe(false);
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
expect(db.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]);
});
it("counts the depth on the active categories only: a category under a deleted parent is a root", async () => {
// 3 hangs under 2, deactivated: the tree shows 3 at the root, and so do the
// pickers. Root 1 carries 10: under 3 (level 0), 10 lands at level 2.
const db = useFakeDb([row(1, null), row(10, 1), row(2, null, { is_active: 0 }), row(3, 2)]);
await updateCategory(1, form(3));
expect(db.rows().find((r) => r.id === 1)?.parent_id).toBe(3);
expect(db.writes()).toEqual([
"BEGIN",
"UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
"UPDATE categories SET is_inputable = 0",
"COMMIT",
]);
});
it("refuses a deactivated parent with a plain error, out of reach of the pickers", async () => {
const db = useFakeDb([...TREE, row(3, null, { is_active: 0 })]);
const error = await updateCategory(20, form(3)).then(
@ -283,7 +358,142 @@ describe("updateCategory — regression of #393: A -> B -> A", () => {
});
});
describe("a parent's content: transactions, budget amounts, keywords (#393, decision of 2026-10-19)", () => {
/** A sub-category of `parent` created from the form. */
const newChild = (parent: number) => ({
name: "New",
type: "expense",
color: "#000",
parent_id: parent,
is_inputable: true,
sort_order: 1,
});
// The three ways a category becomes a parent. In TREE, 11 is a leaf under
// root 1 and 20 a leaf under root 2: each puts 20, or a new category, under 11.
const ENTRY_POINTS: Array<[string, () => Promise<unknown>]> = [
["updateCategory (parent changed)", () => updateCategory(20, form(11))],
["reattachCategory", () => reattachCategory(20, 11)],
["createCategory (sub-category)", () => createCategory(newChild(11))],
];
// What 11, a leaf that takes input, holds; the code that refuses it.
const HOLDINGS: Array<[string, string, Record<number, number>, Content]> = [
["transactions", "parent_has_transactions", { 11: 2 }, {}],
["a budget amount", "parent_has_budget", {}, { budget: [[11, 25]] }],
// The grid stores what is typed: a negative amount counts too.
["a negative budget amount", "parent_has_budget", {}, { budget: [[11, -25]] }],
["a negative template amount", "parent_has_budget", {}, { templates: [[11, -40]] }],
["an active keyword", "parent_has_keywords", {}, { keywords: [[11, 1]] }],
];
// An existing parent no longer takes input: 11, with 110 under it, holding
// transactions, amounts and a keyword stored before these rules.
const EXISTING_PARENT = [...TREE.map((r) => (r.id === 11 ? { ...r, is_inputable: 0 } : r)), row(110, 11)];
describe.each(ENTRY_POINTS)("%s", (_name, act) => {
it.each(HOLDINGS)("refuses a leaf that takes input and holds %s (%s), writing nothing", async (_holding, code, transactions, content) => {
const db = useFakeDb(TREE, transactions, content);
expect((await refusal(act())).code).toBe(code);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
expect(db.rows()).toHaveLength(TREE.length);
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2);
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1);
// Every check went through the transaction's handle.
expect(db.log.filter((l) => CONTENT_SQL.test(l.sql)).every((l) => l.via === "handle")).toBe(true);
});
it("accepts a parent whose amounts are all zero and whose keywords are all deleted", async () => {
const db = useFakeDb(TREE, {}, {
budget: [[11, 0], [11, -0]],
templates: [[11, 0]],
keywords: [[11, 0], [11, 0]],
});
await act();
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
const writes = db.writes();
expect([writes[0], writes[writes.length - 1]]).toEqual(["BEGIN", "COMMIT"]);
});
it("accepts an existing parent, which no longer takes input, whatever it holds (decision of 2026-10-20)", async () => {
const db = useFakeDb(EXISTING_PARENT, { 11: 2 }, {
budget: [[11, 25]],
templates: [[11, 10]],
keywords: [[11, 1]],
});
await act();
// Nothing to protect: markAsParent changes nothing for it, and no content is read.
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
const writes = db.writes();
expect([writes[0], writes[writes.length - 1]]).toEqual(["BEGIN", "COMMIT"]);
});
});
it("refuses a budget amount of a template, even with no amount in the budget itself", async () => {
useFakeDb(TREE, {}, { templates: [[11, 120]] });
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_budget");
});
it("reads the budget and the keywords of the parent only, never of the moved category", async () => {
// 20 itself holds an amount, a keyword and transactions: moving it changes nothing for them.
const db = useFakeDb(TREE, { 20: 4 }, { budget: [[20, 50]], templates: [[20, 50]], keywords: [[20, 1]] });
await updateCategory(20, form(11));
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(11);
});
it("checks transactions, then budget amounts, then keywords: the first found is the refusal", async () => {
useFakeDb(TREE, { 11: 1 }, { budget: [[11, 10]], keywords: [[11, 1]] });
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions");
useFakeDb(TREE, {}, { budget: [[11, 10]], keywords: [[11, 1]] });
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_budget");
});
it("checks the content after the hierarchy rules: a parent that loops is refused as a loop", async () => {
const db = useFakeDb(TREE, { 10: 3 }, { keywords: [[10, 1]] });
expect((await refusal(updateCategory(1, form(10)))).code).toBe("cycle");
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
});
it("creates a sub-category in one transaction through its handle: the parent's content, the row, the parent's flag", async () => {
const db = useFakeDb(TREE);
const id = await createCategory(newChild(11));
const created = db.rows().find((r) => r.id === id);
expect(created).toMatchObject({ name: "New", parent_id: 11, is_inputable: 1 });
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
expect(db.writes()).toEqual(["BEGIN", "INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "UPDATE categories SET is_inputable = 0", "COMMIT"]);
// The depth is read before the transaction; everything after BEGIN goes through the handle.
const begin = db.log.findIndex((l) => l.sql === "BEGIN");
expect(db.log.slice(0, begin).every((l) => l.via === "global" && l.sql.startsWith("SELECT parent_id FROM categories"))).toBe(true);
expect(db.log.slice(begin).every((l) => l.via === "handle")).toBe(true);
expect(db.log.slice(begin).filter((l) => CONTENT_SQL.test(l.sql))).toHaveLength(3);
});
it("creates a root category without any content check", async () => {
const db = useFakeDb(TREE, { 1: 3 }, { keywords: [[1, 1]] });
await createCategory({ ...newChild(11), parent_id: null });
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
expect(db.writes()).toEqual(["BEGIN", "INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "COMMIT"]);
});
it("checks a category that still takes input even when it has children: it would stop taking input", async () => {
// 1 has children (10, 11) yet takes input (the fake's default), and holds a keyword.
useFakeDb(TREE, {}, { keywords: [[1, 1]] });
expect((await refusal(createCategory(newChild(1)))).code).toBe("parent_has_keywords");
});
});
describe("CategoryHierarchyError", () => {
it("has a message for each of its codes, in French and in English", () => {
const errorsOf = (locale: unknown) =>
(locale as { categories: { hierarchy: { errors: Record<string, string> } } }).categories.hierarchy.errors;
for (const code of CATEGORY_HIERARCHY_ERROR_CODES) {
expect(errorsOf(fr)[code], `fr ${code}`).toMatch(/\S/);
expect(errorsOf(en)[code], `en ${code}`).toMatch(/\S/);
}
expect(Object.keys(errorsOf(fr)).sort()).toEqual([...CATEGORY_HIERARCHY_ERROR_CODES].sort());
});
it("carries its code and names the i18n key of its message", () => {
const error = new CategoryHierarchyError("too_deep");
expect(error).toBeInstanceOf(Error);

View file

@ -12,15 +12,20 @@ import {
} from "../utils/categoryHierarchy";
/** Why a new parent is refused (#393); each code has its message under `categories.hierarchy.errors`. */
export type CategoryHierarchyErrorCode =
| "self_parent"
| "cycle"
| "too_deep"
| "parent_has_transactions";
export const CATEGORY_HIERARCHY_ERROR_CODES = [
"self_parent",
"cycle",
"too_deep",
"parent_has_transactions",
"parent_has_budget",
"parent_has_keywords",
] as const;
export type CategoryHierarchyErrorCode = (typeof CATEGORY_HIERARCHY_ERROR_CODES)[number];
/**
* Thrown when `updateCategory` or `reattachCategory` refuses a new parent
* (#393), before any write. The UI shows the translation of its code
* Thrown when `createCategory`, `updateCategory` or `reattachCategory` refuses
* a parent (#393), before any write. The UI shows the translation of its code
* (`categoryHierarchyErrorKey`), never `message`, which is for the tests and
* the developer console.
*/
@ -39,14 +44,21 @@ export function categoryHierarchyErrorKey(code: CategoryHierarchyErrorCode): str
}
/**
* Every category, active or not, with what the hierarchy rules read (#393).
* The other category queries keep active rows only, which would turn a
* deactivated parent into a missing one.
* Every category, active or not, with what the hierarchy rules read (#393),
* and whether it takes input: a category that does would become a parent
* (`checkParentContent`). The other category queries keep active rows only,
* which would turn a deactivated parent into a missing one.
*/
const HIERARCHY_ROWS_SQL = "SELECT id, parent_id, is_active FROM categories";
const HIERARCHY_ROWS_SQL = "SELECT id, parent_id, is_active, is_inputable FROM categories";
async function readHierarchyRows(db: Database): Promise<HierarchyRow[]> {
return db.select<HierarchyRow[]>(HIERARCHY_ROWS_SQL);
/** A row of `HIERARCHY_ROWS_SQL`. */
interface CategoryCheckRow extends HierarchyRow {
/** `1` while the category takes input (SQLite's INTEGER flag). */
is_inputable: number | boolean;
}
async function readHierarchyRows(db: Database): Promise<CategoryCheckRow[]> {
return db.select<CategoryCheckRow[]>(HIERARCHY_ROWS_SQL);
}
/** The orphan categories of the profile, one per group to reattach (`findOrphans`, #393). */
@ -63,16 +75,71 @@ async function markAsParent(db: Database, parentId: number): Promise<void> {
);
}
/**
* What a category may not hold to become a parent (#393, decision of
* 2026-10-19): once a parent, it stops taking input, yet its direct
* transactions would stay on it, a non-zero budget amount (of any year, or in
* a template; negative ones included, the grid stores what is typed) would
* drop out of the budget grid, and its active keywords would keep sorting
* transactions into it. A deleted keyword (`is_active = 0`) sorts nothing and
* no screen shows it, so it does not count. Checked in this order, and the
* first one found is the refusal: transactions first, as before these two.
*/
const PARENT_CONTENT_CHECKS: ReadonlyArray<{ code: CategoryHierarchyErrorCode; sql: string }> = [
{
code: "parent_has_transactions",
sql: `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
},
{
code: "parent_has_budget",
sql: `SELECT (SELECT COUNT(*) FROM budget_entries WHERE category_id = $1 AND amount <> 0)
+ (SELECT COUNT(*) FROM budget_template_entries WHERE category_id = $1 AND amount <> 0) AS cnt`,
},
{
code: "parent_has_keywords",
sql: `SELECT COUNT(*) AS cnt FROM keywords WHERE category_id = $1 AND is_active = 1`,
},
];
/**
* Whether `parent` would become a parent now: it still takes input, which
* `markAsParent` would take away (`is_inputable = 1`, read the same way).
*/
function becomesParent(parent: CategoryCheckRow): boolean {
return parent.is_inputable === 1 || parent.is_inputable === true;
}
/**
* Refuses `parentId` as a parent when it would become one now (it still takes
* input) and holds what `PARENT_CONTENT_CHECKS` lists, through the handle it
* is given; `rows` are read through the same handle. A category that no
* longer takes input, an existing parent for one, accepts a child whatever it
* holds, as before these checks: `markAsParent` changes nothing for it
* (decision of Max of 2026-10-20, review 25921). Throws before any write.
*/
async function checkParentContent(
db: Database,
rows: CategoryCheckRow[],
parentId: number
): Promise<void> {
const parent = rows.find((r) => r.id === parentId);
if (parent === undefined || !becomesParent(parent)) return;
for (const { code, sql } of PARENT_CONTENT_CHECKS) {
const found = await db.select<Array<{ cnt: number }>>(sql, [parentId]);
if ((found[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError(code);
}
}
/**
* Checks moving category `id` under `parentId` (#393), on rows read in the
* caller's transaction and through its handle: the hierarchy rules of
* `parentChangeRefusal`, then a parent that holds transactions of its own is
* refused (once a parent, it would stop taking input). The root is always
* accepted. Throws before any write.
* `parentChangeRefusal`, then what the parent holds when it would become one
* (`checkParentContent`). The root is always accepted. Throws before any
* write.
*/
async function checkNewParent(
db: Database,
rows: HierarchyRow[],
rows: CategoryCheckRow[],
id: number,
parentId: number | null
): Promise<void> {
@ -84,11 +151,7 @@ async function checkNewParent(
}
const refusal = parentChangeRefusal(active, id, parentId);
if (refusal !== null) throw new CategoryHierarchyError(refusal);
const used = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
[parentId]
);
if ((used[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError("parent_has_transactions");
await checkParentContent(db, rows, parentId);
}
/** Runs `body` between BEGIN and COMMIT on the transaction handle, ROLLBACK on failure. */
@ -159,6 +222,13 @@ export async function getCategoryDepth(categoryId: number): Promise<number> {
return depth;
}
/**
* Creates a category. Under a parent: the depth is checked first, outside any
* transaction (`getCategoryDepth`); then, in one transaction through its
* handle, what the parent holds when it would become one (#393,
* `checkParentContent`: `CategoryHierarchyError` on a refusal, nothing
* written), the new row, and the parent's `is_inputable`.
*/
export async function createCategory(data: {
name: string;
type: string;
@ -167,27 +237,28 @@ export async function createCategory(data: {
is_inputable: boolean;
sort_order: number;
}): Promise<number> {
const db = await getDb();
const parentId = data.parent_id ?? null;
// Validate max depth: parent at depth 2 would create a 4th level
if (data.parent_id !== null) {
const parentDepth = await getCategoryDepth(data.parent_id);
if (parentId !== null) {
const parentDepth = await getCategoryDepth(parentId);
if (parentDepth >= 2) {
throw new Error("Cannot create category: maximum depth of 3 levels reached");
}
}
const result = await db.execute(
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`,
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order]
return withTransaction((db) =>
inTransaction(db, async () => {
if (parentId !== null) await checkParentContent(db, await readHierarchyRows(db), parentId);
const result = await db.execute(
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`,
[data.name, data.type, data.color, parentId, data.is_inputable ? 1 : 0, data.sort_order]
);
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
if (parentId !== null) await markAsParent(db, parentId);
return result.lastInsertId as number;
})
);
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
if (data.parent_id !== null) {
await markAsParent(db, data.parent_id);
}
return result.lastInsertId as number;
}
/**
@ -197,8 +268,8 @@ export async function createCategory(data: {
* `is_inputable`) run in one transaction, through its handle.
*
* An unchanged parent is not checked: a name or a colour saved on a category
* stored before these rules (a fourth level, a parent with transactions) must
* not be refused.
* stored before these rules (a fourth level, a parent that holds transactions,
* budget amounts or keywords) must not be refused.
*/
export async function updateCategory(
id: number,

View file

@ -113,6 +113,10 @@ describe("displaysAsRoot", () => {
expect(displaysAsRoot({ id: 10, parent_id: 1 }, ids)).toBe(false);
expect(displaysAsRoot({ id: 1, parent_id: 2 }, ids)).toBe(false);
});
it("is not a root as its own parent: that one-member loop stays hidden, like the tree hides it", () => {
expect(displaysAsRoot({ id: 10, parent_id: 10 }, ids)).toBe(false);
});
});
describe("isActiveRow", () => {
@ -139,6 +143,11 @@ describe("findOrphans (#393)", () => {
expect(findOrphans(rows)).toEqual([{ id: 3, reason: "cycle" }]);
});
it("names the loop by its own smallest member, never by a smaller id that hangs under it", () => {
// 5 <-> 8, 2 under 5: reattaching 2 would leave 5 and 8 hidden.
expect(findOrphans([r(2, 5), r(5, 8), r(8, 5), r(1, null)])).toEqual([{ id: 5, reason: "cycle" }]);
});
it("lists a three-member cycle once, by its smallest id, whatever the order of the rows", () => {
// 6 -> 4 -> 9 -> 6, 12 under 9.
const rows = [r(6, 4), r(4, 9), r(9, 6), r(12, 9), r(1, null)];

View file

@ -86,6 +86,11 @@ describe("reorderRows — subtotals at the bottom (issue #424)", () => {
expect(names(reorderRows(rows, false))).toEqual(["Vide", "z", "A"]);
});
it("a collapsed sub-group last in its group keeps its subtotal, before the group's own", () => {
const rows: Row[] = [sub("A", 0), leaf("z", 1), sub("Vide", 1)];
expect(names(reorderRows(rows, false))).toEqual(["z", "Vide", "A"]);
});
it("the same block, followed by a sibling root: the root closes after its own rows", () => {
const block: Row[] = [sub("A", 0), sub("Vide", 1), leaf("z", 1)];
// A root group...