diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index dd04bf6..9b7b8c2 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c3785..4d4a6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8fff92f..c0a870f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/REVIEW.md b/REVIEW.md index 88f0f13..8d03e77 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -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 `). — `check-frontend.yml` (`paths-ignore`), #384 diff --git a/docs/architecture.md b/docs/architecture.md index 413fdd9..ffe059d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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.`. 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.`. 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=""` 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=""` 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 `
`, 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 `
`, 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) diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index b90df0c..1cb5c34 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -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 --- diff --git a/src-tauri/src/commands/fs_commands.rs b/src-tauri/src/commands/fs_commands.rs index 0d90f42..d3ddd43 100644 --- a/src-tauri/src/commands/fs_commands.rs +++ b/src-tauri/src/commands/fs_commands.rs @@ -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"); diff --git a/src/components/categories/AddKeywordDialog.tsx b/src/components/categories/AddKeywordDialog.tsx index 69c12ee..a67ef6b 100644 --- a/src/components/categories/AddKeywordDialog.tsx +++ b/src/components/categories/AddKeywordDialog.tsx @@ -146,9 +146,10 @@ export default function AddKeywordDialog({

{t("reports.keyword.dialogTitle")}

- {/* 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. */}
"); diff --git a/src/services/categoryService.test.ts b/src/services/categoryService.test.ts index f74c42e..b240cb0 100644 --- a/src/services/categoryService.test.ts +++ b/src/services/categoryService.test.ts @@ -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 = {}): 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 = {}) { +function contentDb(transactions: Record, 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 = {}, 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]> = [ + ["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, 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 } } }).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); diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index a73bfaa..e16e27e 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -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 { - return db.select(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 { + return db.select(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 { ); } +/** + * 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 { + 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>(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 { @@ -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>( - `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 { 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 { - 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, diff --git a/src/utils/categoryHierarchy.test.ts b/src/utils/categoryHierarchy.test.ts index de853a6..11b537a 100644 --- a/src/utils/categoryHierarchy.test.ts +++ b/src/utils/categoryHierarchy.test.ts @@ -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)]; diff --git a/src/utils/reorderRows.test.ts b/src/utils/reorderRows.test.ts index 473e97d..528fdd5 100644 --- a/src/utils/reorderRows.test.ts +++ b/src/utils/reorderRows.test.ts @@ -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...