Compare commits

..

No commits in common. "issue-446-review-followups" and "main" have entirely different histories.

75 changed files with 403 additions and 5263 deletions

View file

@ -2,7 +2,7 @@
name: release name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push) description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true user-invocable: true
updated: 2026-10-18 updated: 2026-10-11
--- ---
# /release — Release Simpl-Resultat # /release — Release Simpl-Resultat
@ -15,7 +15,7 @@ updated: 2026-10-18
## Workflow ## Workflow
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check --locked && cargo test --locked` (`--locked` comme la CI et le build du tag depuis #449 : sans lui, un lock que cargo réécrirait ici partirait sans relecture dans le commit de release). Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert. 0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag. **Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
**Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires. **Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires.
1. Determiner la nouvelle version (argument utilisateur ou demander) 1. Determiner la nouvelle version (argument utilisateur ou demander)
@ -26,11 +26,6 @@ updated: 2026-10-18
- `package.json` (champ `"version"`) - `package.json` (champ `"version"`)
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9) - `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective. - Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
- Puis vérifier que cargo accepte le lock édité tel quel, sans le réécrire : les deux builds de `release.yml` passent `-- --locked` (#449), donc un lock qui ne correspond plus à `Cargo.toml` arrêterait le build du tag, et le commit de release va sur `main` sans PR ni `check-rust`. Depuis la racine du dépôt :
```bash
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked > /dev/null
```
Attendu : code de sortie 0 (avec `--locked`, cargo n'écrit jamais le lock). Un refus (code 101, `error: cannot update the lock file … because --locked was passed to prevent this`) veut dire que l'édition à la main ne correspond pas à `Cargo.toml` (version de `simpl-result` différente des deux côtés, strophe abîmée) : corriger le lock à la main, ne jamais le régénérer avec cargo.
- Puis rejouer le garde `windows-sys` de `check-rust.yml` sur le lock édité : le commit de release touche `Cargo.lock` directement sur `main`, sans PR, et `check-rust` ne le verrait qu'au lundi suivant, après le build du tag ([ADR 0023](../../../docs/adr/0023-windows-sys-pinning.md)). Depuis la racine du dépôt, le texte exact de l'étape avec les options du runner : - Puis rejouer le garde `windows-sys` de `check-rust.yml` sur le lock édité : le commit de release touche `Cargo.lock` directement sur `main`, sans PR, et `check-rust` ne le verrait qu'au lundi suivant, après le build du tag ([ADR 0023](../../../docs/adr/0023-windows-sys-pinning.md)). Depuis la racine du dépôt, le texte exact de l'étape avec les options du runner :
```bash ```bash
python3 -c 'import sys, yaml; steps = yaml.safe_load(open(".forgejo/workflows/check-rust.yml"))["jobs"]["rust"]["steps"]; sys.stdout.write(next(s["run"] for s in steps if s.get("name") == "Verify windows-sys pinning"))' \ python3 -c 'import sys, yaml; steps = yaml.safe_load(open(".forgejo/workflows/check-rust.yml"))["jobs"]["rust"]["steps"]; sys.stdout.write(next(s["run"] for s in steps if s.get("name") == "Verify windows-sys pinning"))' \
@ -99,4 +94,3 @@ updated: 2026-10-18
- 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415. - 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415.
- 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180. - 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180.
- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418). - 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418).
- 2026-10-18 — Étape 2 : `cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked` juste après l'édition du lock, avant le garde `windows-sys`, puisque les deux builds de `release.yml` passent désormais `-- --locked` et qu'un lock désaccordé arrêterait le tag. Étape 0 : `cargo check --locked` et `cargo test --locked`, comme la CI. Source : #449.

View file

@ -126,15 +126,8 @@ jobs:
with: with:
workspaces: "src-tauri -> target" workspaces: "src-tauri -> target"
# --locked (#449): a Cargo.lock that no longer matches Cargo.toml (hand
# edit, conflict resolved badly, dependency added without its lockfile)
# fails the job here instead of being rewritten in the job's checkout.
# The guards and the audit below then read the committed lockfile, the
# one release.yml builds the shipped binaries from, also with --locked.
# A deliberate `cargo update` in a PR is not affected: it commits the
# lockfile it writes.
- name: cargo check - name: cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets --locked run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
# Anti-rot guard for the suppressions in .cargo/audit.toml (#310). Each # Anti-rot guard for the suppressions in .cargo/audit.toml (#310). Each
# entry there is justified by its crate being absent from every shipped # entry there is justified by its crate being absent from every shipped
@ -225,10 +218,9 @@ jobs:
# and a canary must be read and checked, or the loop's silence proves # and a canary must be read and checked, or the loop's silence proves
# nothing. # nothing.
# #
# No cargo call, so neither the warm index nor --locked matters here. The # No cargo call, so neither the warm index nor --locked matters here: the
# lockfile it reads is the committed one: cargo check runs with --locked # step reads the lockfile as cargo check left it, which is the committed
# and has already failed the job on a lockfile cargo would have had to # one unless cargo had to update it.
# rewrite (#449).
- name: Verify windows-sys pinning - name: Verify windows-sys pinning
run: | run: |
set -u set -u
@ -338,9 +330,8 @@ jobs:
echo "windows-sys pinning: $checks checks, exit $rc" echo "windows-sys pinning: $checks checks, exit $rc"
exit $rc exit $rc
# --locked, as cargo check above (#449).
- name: cargo test - name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets --locked run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
# Prebuilt binary. `cargo install --locked cargo-audit` recompiled the # Prebuilt binary. `cargo install --locked cargo-audit` recompiled the
# tool from source on every single run (~4m40s). No continue-on-error: # tool from source on every single run (~4m40s). No continue-on-error:

View file

@ -43,15 +43,7 @@ jobs:
run: | run: |
apt-get install -y lld llvm clang nsis apt-get install -y lld llvm clang nsis
rustup target add x86_64-pc-windows-msvc rustup target add x86_64-pc-windows-msvc
# Pinned (#449): cargo-xwin is the runner of the Windows build below cargo install --locked cargo-xwin
# and runs with TAURI_SIGNING_PRIVATE_KEY in its environment, so a
# new release of it must be a decision, not whatever crates.io serves
# on the day of the tag. 0.23.1 is the version the v0.17.2 release
# installed unpinned (run 570). A version without an operator makes
# cargo install exactly that one. Bump it on purpose, after reading
# what the new version changes.
cargo install --locked cargo-xwin --version 0.23.1
cargo-xwin --version
- name: Install frontend dependencies - name: Install frontend dependencies
run: npm ci run: npm ci
@ -61,21 +53,14 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: | run: |
# Arguments after -- go to the runner (#449): cargo here, cargo-xwin npx tauri build --bundles deb,rpm
# in the Windows step, which hands them to cargo build. --locked
# builds the shipped binaries from the committed Cargo.lock, or fails
# rather than rewrite it. The log never prints the cargo command
# line: only a build that fails on a lockfile out of step with
# Cargo.toml proves the flag gets there.
npx tauri build --bundles deb,rpm -- --locked
- name: Build Tauri Windows - name: Build Tauri Windows
env: env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: | run: |
# -- --locked: see the Linux build above (#449). npx tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
npx tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc -- --locked
- name: Collect release files - name: Collect release files
run: | run: |

View file

@ -2,24 +2,6 @@
## [Non publié] ## [Non publié]
### 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).
### 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).
- 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).
### 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).
- 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).
## [0.17.2] - 2026-10-18 ## [0.17.2] - 2026-10-18
### Ajouté ### Ajouté

View file

@ -2,24 +2,6 @@
## [Unreleased] ## [Unreleased]
### 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).
### 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).
- 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).
### 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).
- 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).
## [0.17.2] - 2026-10-18 ## [0.17.2] - 2026-10-18
### Added ### Added

View file

@ -38,12 +38,12 @@
``` ```
src/ src/
├── components/ # 138 composants React organisés par domaine ├── components/ # 53 composants React organisés par domaine
│ ├── adjustments/ # Ajustements │ ├── adjustments/ # Ajustements
│ ├── budget/ # Budget │ ├── budget/ # Budget
│ ├── categories/ # Catégories hiérarchiques │ ├── categories/ # Catégories hiérarchiques
│ ├── dashboard/ # Tableau de bord │ ├── dashboard/ # Tableau de bord
│ ├── import/ # Wizard d'import (18 composants) │ ├── import/ # Wizard d'import (14 composants)
│ ├── layout/ # AppShell, Sidebar │ ├── layout/ # AppShell, Sidebar
│ ├── profile/ # Profils (PIN, formulaire, switcher) │ ├── profile/ # Profils (PIN, formulaire, switcher)
│ ├── reports/ # Graphiques et rapports │ ├── reports/ # Graphiques et rapports
@ -51,7 +51,7 @@ src/
│ ├── shared/ # Composants réutilisables │ ├── shared/ # Composants réutilisables
│ └── transactions/ # Transactions │ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil) ├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 29 hooks custom (useReducer) ├── hooks/ # 13 hooks custom (useReducer)
├── pages/ # 11 pages ├── pages/ # 11 pages
├── services/ # 14 services métier ├── services/ # 14 services métier
├── shared/ # Types et constantes partagés ├── shared/ # Types et constantes partagés
@ -95,7 +95,7 @@ src-tauri/
- **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide - **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF) - **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
- **Mises à jour** : auto-updater intégré (tauri-plugin-updater) - **Mises à jour** : auto-updater intégré (tauri-plugin-updater)
- **Changelog bilingue** : historique complet des versions dans Paramètres → Système, sous les Journaux (`/changelog` y redirige), notes de la version installée dépliées et autres versions repliées ; notes de version dynamiques FR/EN depuis `CHANGELOG.md` / `CHANGELOG.fr.md` (bundlés dans `public/`) - **Changelog bilingue** : page `/changelog` avec historique complet, notes de version dynamiques FR/EN depuis `CHANGELOG.md` / `CHANGELOG.fr.md` (bundlés dans `public/`)
--- ---
@ -164,12 +164,12 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle. Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle.
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check --locked`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test --locked` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Avec `--locked` ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)), un `Cargo.lock` qui ne correspond plus à `Cargo.toml` fait échouer le job au lieu d'être réécrit dans le checkout : les gardes et l'audit lisent le lock commité. Un `cargo update` voulu dans une PR reste possible, il commite le lock qu'il écrit. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer. - **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient). - **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient).
- **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier. - **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier.
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques. Les deux `npx tauri build` passent `-- --locked` à cargo (par cargo-xwin pour Windows) : les binaires livrés sont bâtis sur le lock commité, jamais réécrit, et l'étape 2 de `/release` vérifie le lock édité à la main par `cargo metadata --locked` (#449). cargo-xwin, qui tourne avec la clé de signature dans son environnement, est installé en version épinglée (`--version 0.23.1`) ; la toolchain Rust (`rustup`, `stable`) ne l'est pas. - **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.
Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Rust passe par `Swatinem/rust-cache` ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : le runner garde ce qu'écrit un run de PR pour cette seule PR, donc c'est le run du lundi (ou un `workflow_dispatch`) qui alimente le cache partagé que lisent les autres PR. Un run à froid reste normal après une nouvelle version stable de Rust ou une modification de l'`env:` du job, quand une entrée expire (7 jours sans usage, 30 jours d'âge), ou quand une PR sauvegarde sous la clé de l'entrée partagée et l'évince, le runner ne gardant qu'une entrée par clé. La liste complète est en tête de `check-rust.yml` ; seul un délai dépassé signale un retour de #234. Pas de cache npm : `check-frontend` ne tourne que sur des PR, et `npm ci` y prend 9 s. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub) et volontairement pas aligné : rien n'y tourne, il garde l'ancienne mise en page combinée, et ni `--locked` ni l'épinglage de cargo-xwin n'y sont reportés (décision de Max sur #449). Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Rust passe par `Swatinem/rust-cache` ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : le runner garde ce qu'écrit un run de PR pour cette seule PR, donc c'est le run du lundi (ou un `workflow_dispatch`) qui alimente le cache partagé que lisent les autres PR. Un run à froid reste normal après une nouvelle version stable de Rust ou une modification de l'`env:` du job, quand une entrée expire (7 jours sans usage, 30 jours d'âge), ou quand une PR sauvegarde sous la clé de l'entrée partagée et l'évince, le runner ne gardant qu'une entrée par clé. La liste complète est en tête de `check-rust.yml` ; seul un délai dépassé signale un retour de #234. Pas de cache npm : `check-frontend` ne tourne que sur des PR, et `npm ci` y prend 9 s. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub).
--- ---

View file

@ -123,7 +123,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm
### Release et mise à jour ### Release et mise à jour
- `release.yml` met la clé de signature de l'updater (`TAURI_SIGNING_PRIVATE_KEY`) dans l'environnement des deux étapes `npx tauri build`. S'exécutent avec elle : `beforeBuildCommand` (`npm run build`, donc `tsc`, `vite.config.ts` et les `scripts` de `package.json`), `@tauri-apps/cli` et son binaire natif, qui lit la clé, `cargo-xwin` (`--runner cargo-xwin` pour Windows), installé à chaque tag en version épinglée depuis #449, la toolchain Rust, installée à chaque tag sans version épinglée, `src-tauri/build.rs`, les scripts de build et les macros des crates, et tout module JS qu'une directive CSS `@plugin` ou `@config` fait charger. `npm ci` tourne avant, sans elle, mais ce qu'il installe s'exécute ensuite avec elle. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication » - `release.yml` met la clé de signature de l'updater (`TAURI_SIGNING_PRIVATE_KEY`) dans l'environnement des deux étapes `npx tauri build`. S'exécutent avec elle : `beforeBuildCommand` (`npm run build`, donc `tsc`, `vite.config.ts` et les `scripts` de `package.json`), `@tauri-apps/cli` et son binaire natif, qui lit la clé, `cargo-xwin` (`--runner cargo-xwin` pour Windows) et la toolchain Rust, installés à chaque tag sans version épinglée, `src-tauri/build.rs`, les scripts de build et les macros des crates, et tout module JS qu'une directive CSS `@plugin` ou `@config` fait charger. `npm ci` tourne avant, sans elle, mais ce qu'il installe s'exécute ensuite avec elle. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `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 » - `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 » - `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` - 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`
@ -138,7 +138,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm
### i18n et documentation ### i18n et documentation
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois (mêmes clés, vérifiées par `src/i18n/locales.keys.test.ts`) ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`, #453 - Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`
- Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG) - Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG)
- Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique » - Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique »
@ -153,16 +153,18 @@ Les workflows font foi ; ce tableau les résume au 2026-10-18. Le runner a une c
| Workflow | Déclencheur | Commandes | | Workflow | Déclencheur | Commandes |
|---|---|---| |---|---|---|
| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) | | `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) |
| `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets --locked`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets --locked`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) | | `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) |
| `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) | | `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) |
| `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin 0.23.1, épinglé), les deux par `npx tauri build … -- --locked`, signature, `latest.json` | | `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` |
`cargo check` et `cargo test` tournent sans `--locked` dans la CI ; #449 l'ajoute, et sa PR met ce tableau à jour.
En local, les mêmes commandes, depuis la racine : En local, les mêmes commandes, depuis la racine :
```bash ```bash
npm ci && npm run build && npm test npm ci && npm run build && npm test
cargo check --manifest-path src-tauri/Cargo.toml --all-targets --locked cargo check --manifest-path src-tauri/Cargo.toml --all-targets
cargo test --manifest-path src-tauri/Cargo.toml --all-targets --locked cargo test --manifest-path src-tauri/Cargo.toml --all-targets
cargo audit --file src-tauri/Cargo.lock # depuis la racine : .cargo/audit.toml s'applique cargo audit --file src-tauri/Cargo.lock # depuis la racine : .cargo/audit.toml s'applique
``` ```
@ -181,7 +183,7 @@ Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dép
- **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447 - **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447
- **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455 - **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455
- **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) » - **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) »
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Une clé présente dans une seule langue fait échouer la garde `src/i18n/locales.keys.test.ts`, qui ne compare que les clés des deux fichiers : un texte resté dans l'autre langue (du français dans `en.json`) la passe, et une clé que le code appelle sans qu'aucun des deux fichiers la déclare lui échappe aussi. — `docs/architecture.md` « Faux `t` marqué », #389, #453 - **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389
- **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452 - **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452
- **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) - **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 » - **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 »

View file

@ -58,7 +58,7 @@ Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étr
### 7. L'échec reste ouvert ### 7. L'échec reste ouvert
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Deux limites (précisées par #437). Un chemin relatif sans début reconnu garde l'ancien repli, d'un blanc à l'autre : un morceau qui porte deux séparateurs ou plus est réduit à son dernier segment (`dossier/sous/x.db` donne `x.db`), un morceau qui n'en porte qu'un voit son séparateur remplacé par `-`, si bien que les mots d'un dossier qui contient un blanc restent (`dossier/sous dossier/x.db` donne `dossier-sous dossier-x.db`) ; un tel chemin ne part pas du dossier personnel, donc n'en contient pas le nom. Et un nom de dossier qui contient l'un des arrêts (une apostrophe inverse, `;`, `|`, une parenthèse fermante suivie d'autre chose qu'un séparateur…) arrête le chemin là : ce nom, ou son début, reste dans la ligne, et ce peut être un nom d'utilisateur (`C:\Users\Jean (Work) Smith\AppData\x.db` donne `Jean (Work) x.db`). Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`. Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`.
### 8. Un seul dossier des bases ### 8. Un seul dossier des bases

View file

@ -73,7 +73,7 @@ L'étape `Verify windows-sys pinning` de `check-rust.yml`, placée après `cargo
3. **Ensemble des versions.** Les versions de `windows-sys` du lock, toutes cibles confondues et doublons compris, doivent égaler exactement `{0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2}` (état du 2026-10-10). Une version qui apparaît ou disparaît fait échouer l'étape avec un renvoi à cet ADR : c'est soit le coût réel (une version de plus), soit le début de la sortie, et dans les deux cas une décision. 3. **Ensemble des versions.** Les versions de `windows-sys` du lock, toutes cibles confondues et doublons compris, doivent égaler exactement `{0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2}` (état du 2026-10-10). Une version qui apparaît ou disparaît fait échouer l'étape avec un renvoi à cet ADR : c'est soit le coût réel (une version de plus), soit le début de la sortie, et dans les deux cas une décision.
4. **Canari.** La boucle doit avoir lu et vérifié une arête de `tempfile`, crate connu pour en porter une, faute de quoi son silence ne prouve rien et l'étape échoue. 4. **Canari.** La boucle doit avoir lu et vérifié une arête de `tempfile`, crate connu pour en porter une, faute de quoi son silence ne prouve rien et l'étape échoue.
Comme dans l'ADR 0018, chaque vérification est loggée, succès compris (`ok: rustix 1.1.3 -> windows-sys 0.61.2`), puis un bilan (`windows-sys pinning: 9 checks, exit 0`), et le code de sortie d'`awk` est testé à part de sa sortie. Une sortie vide échoue déjà (ensemble des versions, blocs introuvables, canari) ; ce test attrape seul un `awk` qui échoue après avoir tout imprimé, et sans lui le garde sortirait en 0 (éprouvé à la revue de la PR #415). Le garde n'appelle pas cargo, donc ni l'index chaud ni `--locked` ne le concernent. Il lit le lock commité : `cargo check` tourne avec `--locked` depuis #449 et a déjà fait échouer le job sur un lock que cargo aurait dû réécrire. Comme dans l'ADR 0018, chaque vérification est loggée, succès compris (`ok: rustix 1.1.3 -> windows-sys 0.61.2`), puis un bilan (`windows-sys pinning: 9 checks, exit 0`), et le code de sortie d'`awk` est testé à part de sa sortie. Une sortie vide échoue déjà (ensemble des versions, blocs introuvables, canari) ; ce test attrape seul un `awk` qui échoue après avoir tout imprimé, et sans lui le garde sortirait en 0 (éprouvé à la revue de la PR #415). Le garde n'appelle pas cargo, donc ni l'index chaud ni `--locked` ne le concernent ; il lit le lock tel que `cargo check` l'a laissé.
La liste gardée, la version épinglée et l'ensemble attendu sont en tête du script, sous un commentaire qui renvoie ici. On ne les modifie que sur la preuve que demandent les sections suivantes, jamais pour faire passer l'étape au vert. La liste gardée, la version épinglée et l'ensemble attendu sont en tête du script, sous un commentaire qui renvoie ici. On ne les modifie que sur la preuve que demandent les sections suivantes, jamais pour faire passer l'étape au vert.

View file

@ -26,18 +26,17 @@
``` ```
simpl-resultat/ simpl-resultat/
├── src/ # Frontend React/TypeScript ├── src/ # Frontend React/TypeScript
│ ├── components/ # 138 composants organisés par domaine (fichiers .tsx hors tests, #446) │ ├── components/ # 61 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants │ │ ├── adjustments/ # 3 composants
│ │ ├── balance/ # 12 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOverviewCard, DetailAccountWizard, HoldingsCsvImportModal, LinkTransfersModal, PriceFetchControl, SecurityPicker, SnapshotEditor, SnapshotLineRow, StarterAccountsModal) │ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
│ │ ├── budget/ # 5 composants │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 8 composants (dont HierarchyCheckModal, #393) │ │ ├── categories/ # 5 composants
│ │ ├── categories-migration/ # 5 composants (flux de migration des catégories v1→v2 de CategoriesMigrationPage : StepDiscover, StepSimulate, StepConsent, MappingRow, TransactionPreviewPanel)
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist) │ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
│ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay) │ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay)
│ │ ├── layout/ # AppShell, Sidebar │ │ ├── layout/ # AppShell, Sidebar
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398) │ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 35 composants, dont 7 sous cards/ (hub, faits saillants, tendances, comparables, analyse par catégorie, cartes) │ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections) │ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState) │ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
│ │ └── transactions/ # 6 composants (dont SplitButton, #369) │ │ └── transactions/ # 6 composants (dont SplitButton, #369)
@ -46,24 +45,20 @@ simpl-resultat/
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript ») │ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 20 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage, historique des versions, hiérarchie des catégories) │ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
│ ├── i18n/ # Config i18next + locales FR/EN │ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust │ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
│ ├── App.tsx # Router principal │ ├── App.tsx # Router principal
│ └── main.tsx # Point d'entrée │ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust ├── src-tauri/ # Backend Rust
│ ├── src/ │ ├── src/
│ │ ├── commands/ # 10 modules de commandes Tauri (45 commandes), plus account_cache.rs et return_calculator.rs, sans commande │ │ ├── commands/ # 6 modules de commandes Tauri
│ │ │ ├── fs_commands.rs │ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs │ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs │ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs │ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs │ │ │ ├── auth_commands.rs
│ │ │ ├── token_store.rs │ │ │ └── entitlements.rs
│ │ │ ├── entitlements.rs
│ │ │ ├── feedback_commands.rs
│ │ │ ├── backup_commands.rs
│ │ │ └── balance_commands.rs
│ │ ├── database/ # Schémas SQL et migrations │ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql │ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql │ │ │ ├── seed_categories.sql
@ -201,7 +196,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 | | `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 | | `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 |
| `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) | | `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)) | | `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 | | `importedFileService.ts` | Suivi des fichiers importés |
@ -228,22 +223,20 @@ Un seul service par convention projet (1 service par domaine, splitter seulement
1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.). 1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.).
2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`. 2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`.
3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`). 3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
4. **Prices** — `prices.fetchPrice(symbol, date)` invoque `fetch_price` (Tauri, voir « Commandes Tauri »), avec rate-limit client (1/2s), dedup in-flight, backoff exponentiel sur les erreurs de classe 5xx et plafond de 100 cours réussis par session ; un refus `license_revoked` est écrit au Journal (`ACTIVATION_TOKEN_LOG`). Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy. 4. **Prices** — *(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback, aux cours (`fetch_price`, appel réseau qui porte le jeton d'activation) et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`). Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
## Hooks (29) ## Hooks (25+)
Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v '\.test\.ts$' | wc -l`, #384) : les 28 du tableau et `useCollapsibleGroups`, décrit plus bas. Chaque hook de domaine encapsule la logique d'état via `useReducer` : Chaque hook encapsule la logique d'état via `useReducer` :
| Hook | Domaine | | 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 |
| `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) | | `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) |
| `useDataImport` | Import de données | | `useDataImport` | Import de données |
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, vidé par `SET_IMPORT_FOLDER` jusqu'au scan du nouveau dossier, pour que l'avertissement ne nomme pas les fichiers de l'ancien dossier sous le nom du nouveau (les sources, `scannedSources`, restent celles du dernier scan jusque-là : comportement antérieur, #446), nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) | | `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
| `useImportHistory` | Historique des imports | | `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
@ -265,7 +258,6 @@ Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) | | `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) | | `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
| `useFeedback` | Envoi d'une rétroaction au Feedback Hub (`FeedbackDialog`) : `idle`, `sending`, puis `success` ou `error` avec le code stable rendu par `send_feedback` (`invalid`, `rate_limit`, `server_error`, `network_error`) ; toute autre erreur vaut `network_error` |
### Hook transverse — `useCollapsibleGroups` ### Hook transverse — `useCollapsibleGroups`
@ -273,19 +265,6 @@ Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible. - `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler). - `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
- L'historique des versions de Paramètres → Système (`ChangelogContent`, #372) n'utilise pas ce hook : c'est une liste plate dont une seule version s'ouvre au départ, choisie par une règle, alors que le hook n'a qu'un `defaultExpanded` global, sans amorce par clé. Son état est un `Set` local, en mémoire, amorcé à chaque lecture du fichier par `initiallyExpandedVersions` (`src/utils/changelog.ts`, pur, avec `parseChangelog`) : la version installée, ou la première version publiée quand la version installée est inconnue ou absente du fichier.
## Hiérarchie des catégories (#393)
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.
- **`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.
- **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.
## Import CSV — format persisté et détection ## Import CSV — format persisté et détection
@ -350,11 +329,9 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|---|---|---|---| |---|---|---|---|
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` | | Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas | | Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
| 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` | | Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `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` | | 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}`).
**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) : **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) :
| Case | Source de vérité | | Case | Source de vérité |
@ -370,7 +347,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. `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. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
### Parcours guidé ### Parcours guidé
@ -411,7 +388,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. **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. 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). **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).
@ -436,9 +413,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide. Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (45) ## Commandes Tauri (39)
Les 45 commandes de `generate_handler!` (`src-tauri/src/lib.rs`), réparties par module ; chaque compteur de section est le nombre de ses entrées (recompté pour #384).
### `fs_commands.rs` — Système de fichiers (7) ### `fs_commands.rs` — Système de fichiers (7)
@ -448,7 +423,7 @@ Les 45 commandes de `generate_handler!` (`src-tauri/src/lib.rs`), réparties par
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15) - `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
- `get_file_preview` — Aperçu des N premières lignes - `get_file_preview` — Aperçu des N premières lignes
- `pick_folder` — Dialogue de sélection de dossier - `pick_folder` — Dialogue de sélection de dossier
- `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Ses refus sont des codes stables (constantes `SAMPLE_ERROR_*`, #384), traduits par le front : `invalid_date`, `import_folder_missing` (sans le chemin) et `sample_folder_exists:<nom du dossier d'exemple>` ; une erreur d'écriture garde son texte anglais (dossier d'exemple, fichier, erreur du système). Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/` - `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/`
### `export_import_commands.rs` — Export/Import de données (5) ### `export_import_commands.rs` — Export/Import de données (5)
@ -526,26 +501,14 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), tout échec de validation retourne `("free", [])` - Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), tout échec de validation retourne `("free", [])`
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`) - Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
### `feedback_commands.rs` — Feedback Hub (2) ### `balance_commands.rs` — Bilan (1)
- `send_feedback(content, user_id, context)` — Envoie une rétroaction au Feedback Hub (`POST /api/feedback` sur `feedback.lacompagniemaximus.com`, `FEEDBACK_HUB_URL` en développement), délai de 15 s : `app_id` `simpl-resultat`, le texte (celui saisi, suivi au choix des dernières erreurs du Journal, ajoutées par `FeedbackDialog`), refusé s'il est vide, un `user_id` facultatif et le contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage ; un champ absent n'est pas envoyé). Rend `{ id, created_at }`, ou un code stable que `useFeedback` traduit : `invalid`, `rate_limit`, `server_error`, `network_error`
- `get_feedback_user_agent` — Rend `Simpl'Résultat/<version> (<système>)` pour le contexte
### `backup_commands.rs` — Sauvegarde avant la migration des catégories (3)
Appelées par `categoryBackupService` (sauvegarde SREF vérifiée avant la migration vers la taxonomie standard) et `categoryRestoreService` (restauration) :
- `ensure_backup_dir` — Crée au besoin `Simpl-Resultat/backups/` dans le dossier Documents du système et rend son chemin ; un échec rend `permission_denied: <chemin>` ou `create_dir_failed: <chemin>: <erreur>`, que le service traduit en code
- `get_file_size(file_path)` — Taille en octets d'un fichier : celle de la sauvegarde qui vient d'être écrite
- `file_exists(file_path)` — Vrai si le chemin désigne un fichier existant, `false` sans erreur s'il n'existe pas. Lue avant la fenêtre de restauration : une sauvegarde déplacée ou supprimée renvoie au choix manuel d'un fichier
### `balance_commands.rs` — Bilan (2)
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md). - `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
- `fetch_price(symbol, date)` — Cours d'un titre à une date (`YYYY-MM-DD`) par `maximus-api` (`GET /v1/prices`, [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md)) : lit le jeton d'activation du dossier de données et l'envoie en `Authorization`, avec les seuls en-têtes `Accept` et `User-Agent` (posé sur le client). Rend `PriceResponse`, ou une `FetchPriceError` sérialisée en JSON (`{"code":"rate_limit","retry_after_s":42}`…) : `auth` (sans jeton, ou sur un 401), `premium_required`, `license_revoked` sur un 403 qui le dit (`error.code`), `symbol_not_found`, `rate_limit`, `provider_unavailable`, `network`, `internal`. Sa logique vit dans `fetch_price_in`, sans `AppHandle`, testée dans un dossier temporaire contre un serveur mockito : sur `license_revoked`, le jeton refusé est supprimé s'il n'a pas changé pendant la requête, sans écrire l'état de la clé (#367, voir « Révocation » dans la section licence). Le consentement aux cours n'est vérifié que côté front (`PriceFetchControl`, `PriceFetchConsentToggle`)
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation). Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
**À venir Phase 5** (Issue #143, BLOCKED par maximus-api Phase 2) : commande `fetch_price(symbol, date)` pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (`maximus-api`) expose l'endpoint `GET /v1/prices`.
## Plugins Tauri ## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) : Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
@ -601,10 +564,10 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) | | `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
| `/import` | `ImportPage` | Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → **aperçu obligatoire** (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation | | `/import` | `ImportPage` | Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → **aperçu obligatoire** (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation |
| `/transactions` | `TransactionsPage` | Liste avec filtres | | `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique ; le bouton « Vérifier la hiérarchie » ouvre `HierarchyCheckModal` (#393) | | `/categories` | `CategoriesPage` | Gestion hiérarchique |
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels | | `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
| `/budget` | `BudgetPage` | Planification budgétaire | | `/budget` | `BudgetPage` | Planification budgétaire |
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 5 cartes de navigation (Faits saillants, Tendances, Comparables, Analyse par catégorie, Cartes) | | `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation |
| `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) | | `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) |
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) | | `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) | | `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
@ -616,7 +579,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
| `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales | | `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales |
| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) | | `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
| `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix | | `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix |
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), journaux + commentaires (`LogViewerCard`), puis historique des versions (`ChangelogContent`), dans cet ordre : le Journal ne descend plus sous l'historique, qui s'allonge à chaque release (#372). `ChangelogContent` n'est monté qu'une fois `getVersion()` résolu (ou en échec : version inconnue, écrite au Journal) ; il ouvre les notes de la version installée et replie les autres, chaque titre de version étant un bouton (`aria-expanded`, chevron), avec un bouton « Tout déplier / Tout replier » ; l'en-tête non publié (« Unreleased », « Non publié ») est masqué quand il est vide. La carte Système de `SettingsHomePage` liste ces sections dans le même ordre | | `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) |
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) | | `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) | | `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) | | `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
@ -661,7 +624,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é. 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 : `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.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389) ### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
@ -677,25 +640,13 @@ Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche). Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
### Portails (#429)
`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.
### Limites connues (#437)
- **Emplacements préréglés du harnais.** Le harnais donne un emplacement à chaque appel de hook, tous types confondus (`useState`, `useReducer`, `useRef`, `useMemo`, `useCallback`, `useEffect`), dans l'ordre des appels : c'est le helper `slot` de `fakeReact`. Un test qui prérègle un emplacement par sa position (`harness.slots[0] = …`) dépend donc de l'ordre des hooks du composant : un hook ajouté avant, de n'importe quel type, décale les emplacements sans rien dire, et la valeur préréglée tombe sur un autre hook. Préréglages actuels, chacun commenté dans son fichier : `PriceFetchConsentToggle.test.tsx` (emplacements 0 à 2), `PriceFetchControl.test.tsx` (la fenêtre de consentement ouverte), `useOnboardingChecklist.wiring.test.ts` (`mountWith` et deux écritures directes) et `SystemsSettingsPage.test.tsx` ; `OnboardingStepCard.test.tsx` lit le `useRef` de l'emplacement 0. Qui ajoute un hook avant ces emplacements met le test à jour. Un test qui ouvre un état par un clic dans l'arbre rendu (`TransactionTable.wiring.test.ts`) n'a pas cette dépendance.
- **`withoutFolders`.** Ses arrêts et ses deux limites (chemin relatif sans début reconnu ; nom de dossier qui contient l'un des arrêts) sont dans l'[ADR 0021](adr/0021-migrations-profils-secondaires.md), § 7.
- **Mesure du rattrapage.** Le test `#[ignore]` `a_large_profile_catch_up_duration` (`profile_db.rs`) ne tourne pas en CI et n'asserte rien : il imprime une durée. Sa mesure, prise sur un SSD NVMe, et le risque qu'elle ne couvre pas (disque lent, antivirus qui inspecte la copie) sont dans l'[ADR 0021](adr/0021-migrations-profils-secondaires.md), « Négatives et points de vigilance », point « Le délai de démarrage couvre le rattrapage du profil actif ».
## CI/CD ## CI/CD
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle. Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
### `check-rust.yml` — Vérifications Rust sur PR et chaque semaine ### `check-rust.yml` — Vérifications Rust sur PR et chaque semaine
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check --locked` puis `cargo test --locked`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`). Avec `--locked` ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)), un `Cargo.lock` qui ne correspond plus à `Cargo.toml` (édition à la main, conflit mal résolu, dépendance ajoutée sans son lock) fait échouer le job à `cargo check` au lieu d'être réécrit dans le checkout : les deux gardes et l'audit qui suivent lisent le lock commité. Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
@ -740,8 +691,6 @@ 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`). **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.
## Architecture Decision Records (ADRs) ## Architecture Decision Records (ADRs)
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`. Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.

View file

@ -27,7 +27,7 @@ Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'ac
3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier 3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier
4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé 4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé
5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer 5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer
6. Classer une transaction à la main — les mots-clés fournis avec l'application font le gros du travail, une correction manuelle affine le reste 6. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports 7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports 8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports
@ -96,7 +96,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
- Histogramme empilé des dépenses par catégorie et par mois - Histogramme empilé des dépenses par catégorie et par mois
- Sélecteur de période ajustable (par défaut : année à ce jour) - Sélecteur de période ajustable (par défaut : année à ce jour)
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions - Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- 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 » - Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
### Comment faire ### Comment faire
@ -275,7 +275,6 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
- Glisser-déposer pour réordonner les catégories ou changer leur parent - Glisser-déposer pour réordonner les catégories ou changer leur parent
- Basculer les catégories en saisissable ou non-saisissable - Basculer les catégories en saisissable ou non-saisissable
- Vue « Tous les mots-clés » pour voir l'ensemble des règles - Vue « Tous les mots-clés » pour voir l'ensemble des règles
- « Vérifier la hiérarchie » pour retrouver les catégories orphelines (prises dans une boucle de parenté, ou dont le parent a été supprimé ou est introuvable) et les rattacher
- Réinitialiser les catégories aux valeurs par défaut - Réinitialiser les catégories aux valeurs par défaut
### Comment faire ### Comment faire
@ -286,7 +285,6 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation 4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
5. Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent 5. Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent
6. Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques 6. Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques
7. Cliquez sur Vérifier la hiérarchie pour lister les catégories orphelines : pour chacune, choisissez un nouveau parent (la racine par défaut), puis cliquez sur Rattacher
### Astuces ### Astuces
@ -294,8 +292,6 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
- Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction - Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction
- Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation - 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 - 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
--- ---
@ -313,7 +309,7 @@ Ajoutez des entrées manuelles non issues de vos relevés bancaires, et consulte
### Comment faire ### Comment faire
1. Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement 1. Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement
2. Ajoutez des entrées avec une description, un montant, une date et une catégorie (tapez quelques lettres pour filtrer la liste des catégories) 2. Ajoutez des entrées avec une description, un montant, une date et une catégorie
3. Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période 3. Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période
4. Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions 4. Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions
@ -587,7 +583,6 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres - Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic - Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement - Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Historique des versions : les notes de la version installée s'affichent d'emblée, les autres versions sont repliées et se déplient d'un clic sur leur numéro, ou toutes ensemble avec « Tout déplier »
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi) - Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV - Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
- Import des données depuis un fichier exporté précédemment - Import des données depuis un fichier exporté précédemment
@ -600,11 +595,10 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible 2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez 3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut 4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut
5. Dans Paramètres → Système, les sections se suivent dans cet ordre : Version, Mise à jour, Journaux et commentaires, puis Historique des versions. L'historique s'ouvre sur les notes de la version installée ; cliquez sur un autre numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (« Tout replier » le referme) 5. Utilisez la section Gestion des données pour exporter ou importer vos données
6. Utilisez la section Gestion des données pour exporter ou importer vos données 6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
7. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement 7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
8. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe 8. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
9. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
### Astuces ### Astuces

View file

@ -220,15 +220,6 @@ const SAMPLE_DESJARDINS_TEMPLATE: &str =
const SAMPLE_TANGERINE_TEMPLATE: &str = const SAMPLE_TANGERINE_TEMPLATE: &str =
include_str!("../../../src/__fixtures__/samples/exemple-tangerine.csv"); include_str!("../../../src/__fixtures__/samples/exemple-tangerine.csv");
/// Stable refusal codes of `create_sample_import_folders` (#384), translated by
/// the front (`sampleFolderError` in `src/hooks/useImportWizard.ts`, whose
/// tests read these three constants). `SAMPLE_ERROR_FOLDER_EXISTS` is followed
/// by `:` and the name of the sample folder found. Any other error keeps its
/// English text, shown raw.
const SAMPLE_ERROR_FOLDER_EXISTS: &str = "sample_folder_exists";
const SAMPLE_ERROR_IMPORT_FOLDER_MISSING: &str = "import_folder_missing";
const SAMPLE_ERROR_INVALID_DATE: &str = "invalid_date";
/// Write the two onboarding sample statements (a Desjardins chequing account and /// Write the two onboarding sample statements (a Desjardins chequing account and
/// a Tangerine credit card, both synthetic) into `import_folder`, one sub-folder /// a Tangerine credit card, both synthetic) into `import_folder`, one sub-folder
/// each, so that the very next scan of the import folder shows two sources a /// each, so that the very next scan of the import folder shows two sources a
@ -240,22 +231,18 @@ const SAMPLE_ERROR_INVALID_DATE: &str = "invalid_date";
/// `MM/YYYY` of the two complete months before `today` (`{{M1}}` the most /// `MM/YYYY` of the two complete months before `today` (`{{M1}}` the most
/// recent), and each file is named `releve-YYYY-MM.csv` after `{{M1}}`. /// recent), and each file is named `releve-YYYY-MM.csv` after `{{M1}}`.
/// ///
/// Refuses to run when `today` is not a date (`invalid_date`), when /// Refuses to run when `import_folder` is not a directory, and when either
/// `import_folder` is not a directory (`import_folder_missing`, without the /// sample folder already exists — checked BEFORE anything is written, so an
/// path), and when either sample folder already exists /// existing folder is never touched. The command only ever receives the import
/// (`sample_folder_exists:<name>`) — checked BEFORE anything is written, so an /// folder and a date: the names of what it writes are fixed here. Returns the
/// existing folder is never touched. A write error keeps its English text: the /// two folder NAMES (not paths).
/// sample folder's name (with the file's for a write) and the OS error, never
/// the import folder's path. The command only ever receives the import folder
/// and a date: the names of what it writes are fixed here. Returns the two
/// folder NAMES (not paths).
#[tauri::command] #[tauri::command]
pub fn create_sample_import_folders( pub fn create_sample_import_folders(
import_folder: String, import_folder: String,
today: String, today: String,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
let today = NaiveDate::parse_from_str(&today, "%Y-%m-%d") let today = NaiveDate::parse_from_str(&today, "%Y-%m-%d")
.map_err(|_| SAMPLE_ERROR_INVALID_DATE.to_string())?; .map_err(|e| format!("Invalid date '{}': {}", today, e))?;
let first_of_month = today let first_of_month = today
.with_day(1) .with_day(1)
.ok_or_else(|| "Cannot compute the first day of the month".to_string())?; .ok_or_else(|| "Cannot compute the first day of the month".to_string())?;
@ -268,7 +255,7 @@ pub fn create_sample_import_folders(
let root = Path::new(&import_folder); let root = Path::new(&import_folder);
if !root.is_dir() { if !root.is_dir() {
return Err(SAMPLE_ERROR_IMPORT_FOLDER_MISSING.to_string()); return Err(format!("Folder does not exist: {}", import_folder));
} }
let samples: [(&str, &str); 2] = [ let samples: [(&str, &str); 2] = [
@ -280,7 +267,7 @@ pub fn create_sample_import_folders(
// one of the folders keeps it untouched and gets neither. // one of the folders keeps it untouched and gets neither.
for (folder_name, _) in &samples { for (folder_name, _) in &samples {
if root.join(folder_name).exists() { if root.join(folder_name).exists() {
return Err(format!("{}:{}", SAMPLE_ERROR_FOLDER_EXISTS, folder_name)); return Err(format!("Folder already exists: {}", folder_name));
} }
} }
@ -384,39 +371,14 @@ mod sample_folder_tests {
} }
} }
/// `{{M1}}` is the most recent month (#384): line by line, a template line
/// that carries `{{M1}}` comes out in `m1`, one that carries `{{M2}}` in
/// `m2`. `assert_dates_in` accepts either month on any line, so the two
/// tokens swapped would pass it.
fn assert_months_follow_tokens(content: &str, template: &str, m1: &str, m2: &str) {
let rendered: Vec<&str> = content.lines().collect();
let source: Vec<&str> = template.lines().collect();
assert_eq!(rendered.len(), source.len());
let (mut m1_lines, mut m2_lines) = (0, 0);
for (out, tpl) in rendered.iter().zip(&source) {
let expected = if tpl.contains("{{M1}}") {
m1_lines += 1;
m1
} else if tpl.contains("{{M2}}") {
m2_lines += 1;
m2
} else {
continue;
};
let date = out.split([';', ',']).next().unwrap();
assert_eq!(&date[3..], expected, "template line {:?} came out as {:?}", tpl, out);
}
assert!(m1_lines > 0 && m2_lines > 0, "each template carries both tokens");
}
#[test] #[test]
fn refuses_a_missing_import_folder() { fn refuses_a_missing_import_folder() {
let dir = sandbox("missing"); let dir = sandbox("missing");
let missing = dir.join("nope"); let missing = dir.join("nope");
let result = create_sample_import_folders(as_arg(&missing), "2026-09-25".to_string()); let result = create_sample_import_folders(as_arg(&missing), "2026-09-25".to_string());
cleanup(&dir); cleanup(&dir);
// The code alone, translated by the front: never the folder's path. let err = result.unwrap_err();
assert_eq!(result.unwrap_err(), "import_folder_missing"); assert!(err.contains("does not exist"), "{}", err);
} }
#[test] #[test]
@ -430,32 +392,12 @@ mod sample_folder_tests {
.next() .next()
.is_none(); .is_none();
cleanup(&dir); cleanup(&dir);
assert_eq!(result.unwrap_err(), "sample_folder_exists:Exemple Desjardins"); let err = result.unwrap_err();
assert!(err.contains(SAMPLE_DESJARDINS_FOLDER), "{}", err);
assert!(!tangerine_created, "Exemple Tangerine must not be created"); assert!(!tangerine_created, "Exemple Tangerine must not be created");
assert!(desjardins_empty, "the existing folder must stay untouched"); assert!(desjardins_empty, "the existing folder must stay untouched");
} }
/// The second folder of the loop (#384): without the checks that run
/// before the first write, Exemple Desjardins would be written before the
/// loop reached Exemple Tangerine.
#[test]
fn refuses_when_tangerine_folder_exists_and_writes_nothing() {
let dir = sandbox("existing-tangerine");
fs::create_dir(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 tangerine_empty = fs::read_dir(dir.join(SAMPLE_TANGERINE_FOLDER))
.unwrap()
.next()
.is_none();
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!(tangerine_empty, "the existing folder must stay untouched");
assert_eq!(entries, 1, "nothing but the existing folder");
}
#[test] #[test]
fn writes_both_folders_for_the_two_previous_months() { fn writes_both_folders_for_the_two_previous_months() {
let dir = sandbox("success"); let dir = sandbox("success");
@ -486,9 +428,6 @@ mod sample_folder_tests {
assert_dates_in(&tangerine, &["08/2026", "07/2026"]); assert_dates_in(&tangerine, &["08/2026", "07/2026"]);
assert!(desjardins.contains("08/2026") && desjardins.contains("07/2026")); assert!(desjardins.contains("08/2026") && desjardins.contains("07/2026"));
assert!(tangerine.contains("08/2026") && tangerine.contains("07/2026")); assert!(tangerine.contains("08/2026") && tangerine.contains("07/2026"));
// August, the month before the click, is `{{M1}}`; July is `{{M2}}`.
assert_months_follow_tokens(&desjardins, SAMPLE_DESJARDINS_TEMPLATE, "08/2026", "07/2026");
assert_months_follow_tokens(&tangerine, SAMPLE_TANGERINE_TEMPLATE, "08/2026", "07/2026");
} }
#[test] #[test]
@ -497,7 +436,8 @@ mod sample_folder_tests {
let result = create_sample_import_folders(as_arg(&dir), "pas-une-date".to_string()); let result = create_sample_import_folders(as_arg(&dir), "pas-une-date".to_string());
let anything_written = fs::read_dir(&dir).unwrap().next().is_some(); let anything_written = fs::read_dir(&dir).unwrap().next().is_some();
cleanup(&dir); cleanup(&dir);
assert_eq!(result.unwrap_err(), "invalid_date"); let err = result.unwrap_err();
assert!(err.contains("Invalid date"), "{}", err);
assert!(!anything_written); assert!(!anything_written);
} }
@ -518,8 +458,6 @@ mod sample_folder_tests {
assert_dates_in(&tangerine, &["12/2026", "11/2026"]); assert_dates_in(&tangerine, &["12/2026", "11/2026"]);
assert!(desjardins.contains("12/2026") && desjardins.contains("11/2026")); assert!(desjardins.contains("12/2026") && desjardins.contains("11/2026"));
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026")); assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
assert_months_follow_tokens(&desjardins, SAMPLE_DESJARDINS_TEMPLATE, "12/2026", "11/2026");
assert_months_follow_tokens(&tangerine, SAMPLE_TANGERINE_TEMPLATE, "12/2026", "11/2026");
} }
} }

View file

@ -225,13 +225,8 @@ describe("onboarding samples — seed categorisation (#354)", () => {
"%s: the seed categorises the frozen share and leaves the hand-classified lines alone", "%s: the seed categorises the frozen share and leaves the hand-classified lines alone",
async (name) => { async (name) => {
const keywords = seedKeywords(); const keywords = seedKeywords();
// `categorizeBatch` asks for the active keywords by descending priority // `categorizeBatch` asks for active keywords by descending priority; the
// and keeps the first one that matches. The seed is not all priority 0: // seed is all priority 0, so the SQL order is the file order.
// COSTCO and WALMART are at 50, COUCHE-TARD ESSENCE at 10, the rest at 0.
// The mock sorts the same way; its sort is stable, so equal priorities
// keep the file order, which SQLite does not promise. Neither order moves
// the counts below: on 2026-10-18 no line of either sample matched more
// than one seed keyword (COSTCO WHOLESALE matches COSTCO alone).
select.mockResolvedValue( select.mockResolvedValue(
[...keywords].sort((a, b) => b.priority - a.priority) [...keywords].sort((a, b) => b.priority - a.priority)
); );

View file

@ -1,87 +0,0 @@
// AdjustmentForm — each adjustment line picks its category in a combobox
// (#429). No jsdom: react-dom/server renders the initial markup in vitest's
// node environment, the combobox closed, so without its list in a portal.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import AdjustmentForm from "./AdjustmentForm";
import type { Category } from "../../shared/types";
import type { EntryFormData } from "../../hooks/useAdjustments";
const category = (id: number, name: string, extra: Partial<Category> = {}): Category => ({
id,
name,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
created_at: "",
...extra,
});
/** A seed category, translated through its key, and one the user created. */
const GROCERIES = category(10, "Épicerie régulière", {
i18n_key: "categoriesSeed.alimentation.epicerie.reguliere",
});
const CABIN = category(20, "Chalet");
const line = (categoryId: number): EntryFormData => ({
category_id: categoryId,
amount: 12.5,
description: "",
});
const markup = (entries: EntryFormData[]) =>
renderToStaticMarkup(
<AdjustmentForm
initialData={{ name: "Chalet", description: "", date: "2026-10-01", is_recurring: false }}
initialEntries={entries}
categories={[GROCERIES, CABIN]}
isCreating={false}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);
/** The comboboxes' inputs, in the order of the lines. */
const comboboxes = (html: string) => html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
describe("AdjustmentForm — category of a line (#429)", () => {
it("gives every line a combobox, and no select", () => {
const html = markup([line(10), line(20)]);
expect(comboboxes(html)).toHaveLength(2);
expect(html).not.toContain("<select");
});
it("shows each line's category, a seed one through its key, a user one by its name", () => {
const [seed, user] = comboboxes(markup([line(10), line(20)]));
expect(seed).toContain(
'value="t(categoriesSeed.alimentation.epicerie.reguliere[defaultValue=Épicerie régulière])"',
);
expect(user).toContain('value="Chalet"');
});
it("names every combobox and renders it compact, as in the split window", () => {
const inputs = comboboxes(markup([line(10), line(20)]));
expect(inputs).toHaveLength(2);
for (const input of inputs) {
expect(input).toContain('aria-label="t(adjustments.category)"');
expect(input).toMatch(/class="w-full px-2 py-1 /);
}
});
it("leaves the field empty for a line on a category the list does not offer (D2)", () => {
// A category hidden from input is not in the list: nothing to show.
const [input] = comboboxes(markup([line(99)]));
expect(input).toContain('value=""');
});
it("keeps the empty state of an adjustment without a line", () => {
const html = markup([]);
expect(comboboxes(html)).toHaveLength(0);
expect(html).toContain(">t(adjustments.noEntries)<");
});
});

View file

@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import type { Category } from "../../shared/types"; import type { Category } from "../../shared/types";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
import CategoryCombobox from "../shared/CategoryCombobox";
interface Props { interface Props {
initialData: AdjustmentFormData; initialData: AdjustmentFormData;
@ -121,15 +120,17 @@ export default function AdjustmentForm({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{entries.map((entry, index) => ( {entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2">
<div className="flex-1 min-w-0"> <select
<CategoryCombobox value={entry.category_id}
categories={categories} onChange={(e) => updateEntryField(index, "category_id", Number(e.target.value))}
value={entry.category_id} className="flex-1 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
onChange={(id) => updateEntryField(index, "category_id", id)} >
compact {categories.map((c) => (
ariaLabel={t("adjustments.category")} <option key={c.id} value={c.id}>
/> {c.name}
</div> </option>
))}
</select>
<input <input
type="number" type="number"
step="0.01" step="0.01"

View file

@ -1,116 +0,0 @@
// AdjustmentForm — what a line's combobox changes (#429).
//
// A static render clicks nothing. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the form runs as a plain function, its
// state survives from one call to the next as between two renders of a
// mounted form, and the test calls the props the form hands to each
// CategoryCombobox and to its buttons, then renders again to read the lines.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { 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());
import { hookHarness } from "../../test/hookHarness";
import AdjustmentForm from "./AdjustmentForm";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, match: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, match));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(match(el) ? [el] : []), ...find(el.props.children, match)];
}
/** Text of an element's children, elements left out. */
const text = (node: ReactNode): string =>
Array.isArray(node) ? node.map(text).join("") : typeof node === "string" ? node : "";
const category = (id: number, name: string): Category => ({
id,
name,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
created_at: "",
});
const RENT = category(30, "Loyer");
const CABIN = category(20, "Chalet");
/** In the list's order: a new line takes the first one. */
const CATEGORIES = [RENT, CABIN];
const DATA: AdjustmentFormData = { name: "Chalet", description: "", date: "2026-10-01", is_recurring: false };
let onSave = vi.fn();
/** One render of the mounted form; the props keep their identity from one render to the next. */
const mount = (entries: EntryFormData[]) => {
const props = {
initialData: DATA,
initialEntries: entries,
categories: CATEGORIES,
isCreating: false,
isSaving: false,
onSave,
onCancel: vi.fn(),
};
return () => harness.render(() => AdjustmentForm(props));
};
const comboboxes = (tree: ReactNode) => find(tree, (el) => el.type === CategoryCombobox);
const addButton = (tree: ReactNode) =>
find(tree, (el) => el.type === "button" && text(el.props.children) === "t(adjustments.addEntry)")[0];
const form = (tree: ReactNode) => find(tree, (el) => el.type === "form")[0];
beforeEach(() => {
harness.clear();
onSave = vi.fn();
});
describe("AdjustmentForm — a line's category (#429)", () => {
it("gives a new line the first category of the list", () => {
const render = mount([]);
let tree = render();
expect(comboboxes(tree)).toHaveLength(0);
(addButton(tree).props.onClick as () => void)();
tree = render();
const [combobox] = comboboxes(tree);
expect(combobox.props.value).toBe(RENT.id);
expect(combobox.props.categories).toBe(CATEGORIES);
expect(combobox.props.compact).toBe(true);
expect(combobox.props.ariaLabel).toBe("t(adjustments.category)");
});
it("changes the category of the line picked, and of that line only", () => {
const render = mount([
{ id: 1, category_id: RENT.id, amount: 1200, description: "" },
{ id: 2, category_id: RENT.id, amount: 80, description: "Électricité" },
]);
let tree = render();
(comboboxes(tree)[1].props.onChange as (id: number) => void)(CABIN.id);
tree = render();
expect(comboboxes(tree).map((c) => c.props.value)).toEqual([RENT.id, CABIN.id]);
});
it("saves every line with its category, its amount and its description, as before", () => {
const render = mount([{ id: 1, category_id: RENT.id, amount: 1200, description: "" }]);
let tree = render();
(addButton(tree).props.onClick as () => void)();
tree = render();
(comboboxes(tree)[1].props.onChange as (id: number) => void)(CABIN.id);
tree = render();
(form(tree).props.onSubmit as (e: { preventDefault: () => void }) => void)({ preventDefault: vi.fn() });
expect(onSave).toHaveBeenCalledWith(DATA, [
{ id: 1, category_id: RENT.id, amount: 1200, description: "" },
{ category_id: CABIN.id, amount: 0, description: "" },
]);
});
});

View file

@ -1,69 +0,0 @@
// LinkTransfersModal — the category filter is a combobox (#429). No jsdom:
// react-dom/server renders the initial markup in vitest's node environment.
// It cannot render a portal, and the window is one: `createPortal` becomes
// the identity, and `document` a stub for the `document.body` it is given.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("react-dom", async (orig) => ({
...(await orig<typeof import("react-dom")>()),
createPortal: (children: ReactNode) => children,
}));
vi.mock("../../services/transactionService", () => ({
getTransactionPage: vi.fn(() => new Promise(() => undefined)),
}));
vi.mock("../../services/balance.service", () => ({
linkTransfer: vi.fn(),
suggestTransferDirection: vi.fn(() => "in"),
BalanceServiceError: class extends Error {},
}));
import LinkTransfersModal from "./LinkTransfersModal";
import type { Category } from "../../shared/types";
const TRANSFERS: Category = {
id: 7,
name: "Virements",
type: "transfer",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
beforeAll(() => {
vi.stubGlobal("document", { body: {} });
});
afterAll(() => {
vi.unstubAllGlobals();
});
const markup = () =>
renderToStaticMarkup(
<LinkTransfersModal
accountId={1}
accountName="CELI"
categories={[TRANSFERS]}
initialFrom="2026-07-01"
initialTo="2026-09-30"
onClose={vi.fn()}
/>,
);
describe("LinkTransfersModal — category filter (#429)", () => {
it("renders the filter as a combobox, and no select", () => {
const html = markup();
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
expect(html).not.toContain("<select");
});
it("opens on 'any category', named, compact like the other filters", () => {
const [input] = markup().match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(input).toContain('value="t(balance.transfers.modal.anyCategory)"');
expect(input).toContain('aria-label="t(balance.transfers.modal.category)"');
expect(input).toMatch(/class="w-full px-2 py-1 /);
});
});

View file

@ -3,7 +3,7 @@
// //
// Filters available: // Filters available:
// - Period (from / to ISO dates) — default: last 90 days. // - Period (from / to ISO dates) — default: last 90 days.
// - Category combobox, "any category" first (#429). // - Category dropdown.
// - Free-text search on description. // - Free-text search on description.
// //
// Each row shows: date, description, amount, suggested direction // Each row shows: date, description, amount, suggested direction
@ -29,7 +29,6 @@ import type {
TransactionRow, TransactionRow,
BalanceTransferDirection, BalanceTransferDirection,
} from "../../shared/types"; } from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
const DEFAULT_PAGE_SIZE = 100; const DEFAULT_PAGE_SIZE = 100;
@ -50,7 +49,7 @@ export interface LinkTransfersModalProps {
/** Account that the selected transfers will be attached to. */ /** Account that the selected transfers will be attached to. */
accountId: number; accountId: number;
accountName: string; accountName: string;
/** Categories offered by the filter combobox. */ /** Full category list for the filter dropdown. */
categories: Category[]; categories: Category[];
/** Optional pre-fill date bounds (defaults to last 90 days). */ /** Optional pre-fill date bounds (defaults to last 90 days). */
initialFrom?: string; initialFrom?: string;
@ -97,13 +96,6 @@ export default function LinkTransfersModal({
[i18n.language] [i18n.language]
); );
// "Any category" first: the filter's empty value (null), as on the
// Transactions page's filter bar.
const categoryExtras = useMemo(
() => [{ value: "", label: t("balance.transfers.modal.anyCategory") }],
[t]
);
// Re-fetch whenever the filters change. Debounced via React's render cycle // Re-fetch whenever the filters change. Debounced via React's render cycle
// — typing in the search box re-runs the SQL but at < 500 rows that's fine. // — typing in the search box re-runs the SQL but at < 500 rows that's fine.
useEffect(() => { useEffect(() => {
@ -264,16 +256,20 @@ export default function LinkTransfersModal({
<span className="block text-[var(--muted-foreground)] mb-1"> <span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.category")} {t("balance.transfers.modal.category")}
</span> </span>
<CategoryCombobox <select
categories={categories} value={categoryId === null ? "" : String(categoryId)}
value={categoryId} onChange={(e) =>
onChange={(id) => setCategoryId(id)} setCategoryId(e.target.value === "" ? null : Number(e.target.value))
compact }
ariaLabel={t("balance.transfers.modal.category")} className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
extraOptions={categoryExtras} >
activeExtra={categoryId === null ? "" : null} <option value="">{t("balance.transfers.modal.anyCategory")}</option>
onExtraSelect={() => setCategoryId(null)} {categories.map((c) => (
/> <option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label> </label>
<label className="text-xs"> <label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1"> <span className="block text-[var(--muted-foreground)] mb-1">

View file

@ -1,129 +0,0 @@
// LinkTransfersModal — what the category filter changes (#429).
//
// A static render runs no effect and clicks nothing. React's hooks come from
// the shared harness (src/test/hookHarness.ts, #409): the window runs as a
// plain function, its state survives from one call to the next, the test
// calls the props it hands to its CategoryCombobox, renders again to read the
// filter, and `commit()` runs the effect that reloads the transactions.
// `createPortal` is the identity and `document` a stub: the window is a portal.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
import { 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());
vi.mock("react-dom", async (orig) => ({
...(await orig<typeof import("react-dom")>()),
createPortal: (children: ReactNode) => children,
}));
const { getTransactionPage } = vi.hoisted(() => ({ getTransactionPage: vi.fn() }));
vi.mock("../../services/transactionService", () => ({ getTransactionPage }));
vi.mock("../../services/balance.service", () => ({
linkTransfer: vi.fn(),
suggestTransferDirection: vi.fn(() => "in"),
BalanceServiceError: class extends Error {},
}));
import { hookHarness } from "../../test/hookHarness";
import LinkTransfersModal from "./LinkTransfersModal";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
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 TRANSFERS: Category = {
id: 7,
name: "Virements",
type: "transfer",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
const CATEGORIES = [TRANSFERS];
const PROPS = {
accountId: 1,
accountName: "CELI",
categories: CATEGORIES,
initialFrom: "2026-07-01",
initialTo: "2026-09-30",
onClose: vi.fn(),
};
/** One render of the mounted window; its category filter. */
const filter = () => {
const [combobox] = find(harness.render(() => LinkTransfersModal(PROPS)), CategoryCombobox);
return combobox.props;
};
/** The category of the last transaction query. */
const queriedCategory = () => {
const calls = getTransactionPage.mock.calls;
return (calls[calls.length - 1][0] as { categoryId: number | null }).categoryId;
};
/** Lets the query's promise settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
beforeAll(() => {
vi.stubGlobal("document", { body: {} });
});
afterAll(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
harness.clear();
getTransactionPage.mockReset();
getTransactionPage.mockResolvedValue({ rows: [], total: 0 });
});
describe("LinkTransfersModal — category filter (#429)", () => {
it("opens on 'any category', first of the list, with the categories it is given", () => {
const props = filter();
expect(props.extraOptions).toEqual([{ value: "", label: "t(balance.transfers.modal.anyCategory)" }]);
expect(props.activeExtra).toBe("");
expect(props.value).toBeNull();
expect(props.categories).toBe(CATEGORIES);
expect(props.compact).toBe(true);
expect(props.ariaLabel).toBe("t(balance.transfers.modal.category)");
});
it("filters on the category picked, then 'any category' empties the filter again", async () => {
filter();
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(1);
expect(queriedCategory()).toBeNull();
(filter().onChange as (id: number) => void)(TRANSFERS.id);
let props = filter();
expect(props.value).toBe(TRANSFERS.id);
expect(props.activeExtra).toBeNull();
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(2);
expect(queriedCategory()).toBe(TRANSFERS.id);
(props.onExtraSelect as (value: string) => void)("");
props = filter();
expect(props.value).toBeNull();
expect(props.activeExtra).toBe("");
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(3);
expect(queriedCategory()).toBeNull();
});
});

View file

@ -1,10 +1,9 @@
// PriceFetchControl — unit tests (issue #158) // PriceFetchControl — unit tests (issue #158)
// //
// NOTE: This project does not have @testing-library/react or jsdom configured // NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in decisions-log.md). Most tests cover the component's // (logged as MEDIUM in decisions-log.md). Tests cover the component's internal
// internal logic via mocked dependencies rather than DOM rendering; the // logic via mocked dependencies rather than DOM rendering. All React
// "displayed texts" tests render it with renderToStaticMarkup, its hooks // rendering is bypassed — we test the async coordination logic directly.
// coming from the shared harness (src/test/hookHarness.ts).
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@ -12,10 +11,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mocks — declared before imports to satisfy vi.mock hoisting // Mocks — declared before imports to satisfy vi.mock hoisting
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// React's hooks from the shared harness (#409): the consent dialog opens on a
// click (internal state), so the render test below presets its slot (#437).
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("../../hooks/useIsPremium", () => ({ vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(), useIsPremium: vi.fn(),
})); }));
@ -45,7 +40,6 @@ vi.mock("lucide-react", () => ({
// Imports (after mock declarations) // Imports (after mock declarations)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import type { ReactNode } from "react";
import { useIsPremium } from "../../hooks/useIsPremium"; import { useIsPremium } from "../../hooks/useIsPremium";
import { prices } from "../../services/balance.service"; import { prices } from "../../services/balance.service";
import type { PriceResult } from "../../services/balance.service"; import type { PriceResult } from "../../services/balance.service";
@ -54,7 +48,6 @@ import {
setPreference, setPreference,
} from "../../services/userPreferenceService"; } from "../../services/userPreferenceService";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { hookHarness } from "../../test/hookHarness";
import PriceFetchControl, { import PriceFetchControl, {
__resetBestEffortDismissForTests, __resetBestEffortDismissForTests,
} from "./PriceFetchControl"; } from "./PriceFetchControl";
@ -63,7 +56,6 @@ import PriceFetchControl, {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const harness = hookHarness();
const mockUseIsPremium = vi.mocked(useIsPremium); const mockUseIsPremium = vi.mocked(useIsPremium);
const mockFetchPrice = vi.mocked(prices.fetchPrice); const mockFetchPrice = vi.mocked(prices.fetchPrice);
const mockGetPreference = vi.mocked(getPreference); const mockGetPreference = vi.mocked(getPreference);
@ -371,35 +363,18 @@ describe("PriceFetchControl — displayed texts", () => {
__resetBestEffortDismissForTests(); __resetBestEffortDismissForTests();
vi.resetAllMocks(); vi.resetAllMocks();
setPremium(true); setPremium(true);
harness.clear();
}); });
/**
* One render of a stock's control, its hooks from the harness. `consentOpen`
* presets slot 0, the control's first hook (`showConsentModal`). The slots
* follow the order of the control's hook calls: a hook of any kind added
* before it shifts them without a word, and the preset lands on another hook
* (one slot per hook call: the `slot` helper of `fakeReact`,
* src/test/hookHarness.ts).
*/
function markup(consentOpen = false): string {
if (consentOpen) harness.slots[0] = true;
return renderToStaticMarkup(
harness.render(
() =>
PriceFetchControl({
symbol: "AAPL",
date: "2026-04-25",
categoryKind: "priced",
assetType: "stock",
onPriceFetched: vi.fn(),
}) as ReactNode,
),
);
}
it("names the fetch button and the stock best-effort notice through t", () => { it("names the fetch button and the stock best-effort notice through t", () => {
const html = markup(); const html = renderToStaticMarkup(
<PriceFetchControl
symbol="AAPL"
date="2026-04-25"
categoryKind="priced"
assetType="stock"
onPriceFetched={vi.fn()}
/>,
);
expect(html).toContain('aria-label="t(balance.priceFetching.button)"'); expect(html).toContain('aria-label="t(balance.priceFetching.button)"');
expect(html).toContain(">t(balance.priceFetching.button)<"); expect(html).toContain(">t(balance.priceFetching.button)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<"); expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<");
@ -407,15 +382,4 @@ describe("PriceFetchControl — displayed texts", () => {
expect(html).not.toContain(">best-effort<"); expect(html).not.toContain(">best-effort<");
expect(html).toContain('aria-label="t(common.close)"'); expect(html).toContain('aria-label="t(common.close)"');
}); });
it("names the consent dialog through t once it is open (#437)", () => {
expect(markup()).not.toContain('role="dialog"');
harness.clear();
const html = markup(true);
expect(html).toContain('role="dialog"');
expect(html).toContain(">t(balance.priceFetching.consent.title)<");
expect(html).toContain(">t(balance.priceFetching.consent.body)<");
expect(html).toContain(">t(balance.priceFetching.consent.decline)<");
expect(html).toContain(">t(balance.priceFetching.consent.accept)<");
});
}); });

View file

@ -1,123 +0,0 @@
// HierarchyCheckModal — what the « Vérifier la hiérarchie » window shows (#393).
// No jsdom: react-dom/server renders in vitest's node environment. A closed
// CategoryCombobox renders its input only, without its portal (#429).
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import HierarchyCheckModal from "./HierarchyCheckModal";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
const node = (id: number, parent_id: number | null, name: string, i18n_key: string | null = null): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key,
children: [],
});
// 1 is a root; 3 <-> 5 is a cycle; 20's parent is gone.
const CATEGORIES = [
node(1, null, "Logement", "categoriesSeed.housing"),
node(3, 5, "Épicerie"),
node(5, 3, "Alimentation"),
node(20, 99, "Cadeaux"),
];
const ORPHANS: OrphanCategory[] = [
{ id: 3, reason: "cycle" },
{ id: 20, reason: "missing_parent" },
];
const render = (props: { orphans: OrphanCategory[] | null; error?: string | null; isSaving?: boolean }) =>
renderToStaticMarkup(
<HierarchyCheckModal
orphans={props.orphans}
error={props.error ?? null}
categories={CATEGORIES}
isSaving={props.isSaving ?? false}
onReattach={vi.fn()}
onClose={vi.fn()}
/>,
);
const comboboxes = (html: string) => html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
describe("HierarchyCheckModal (#393)", () => {
it("is a modal dialog named after the button, with its explanation and a close button", () => {
const html = render({ orphans: [] });
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-label="t(categories.hierarchy.check)"');
expect(html).toContain("t(categories.hierarchy.intro)");
expect(html).toContain("t(common.close)</button>");
});
it("says the check runs while the orphans are not known", () => {
const html = render({ orphans: null });
expect(html).toContain("t(categories.hierarchy.checking)");
expect(html).not.toContain("t(categories.hierarchy.none)");
expect(comboboxes(html)).toHaveLength(0);
});
it("says « aucune » for a sound hierarchy, with no row", () => {
const html = render({ orphans: [] });
expect(html).toContain("t(categories.hierarchy.none)");
expect(html).not.toContain("categories.hierarchy.count");
expect(html).not.toContain("t(categories.hierarchy.reattach)");
});
it("counts the orphans, and gives each row its name, its reason, a new parent and « Rattacher »", () => {
const html = render({ orphans: ORPHANS });
expect(html).toContain("t(categories.hierarchy.count[count=2])");
expect(html).toContain(">Épicerie</p>");
expect(html).toContain(">Cadeaux</p>");
expect(html).toContain("t(categories.hierarchy.reasons.cycle)");
expect(html).toContain("t(categories.hierarchy.reasons.missing_parent)");
expect(html.split("t(categories.hierarchy.reattach)</button>")).toHaveLength(3);
const inputs = comboboxes(html);
expect(inputs).toHaveLength(2);
expect(inputs[0]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Épicerie])"');
expect(inputs[1]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
});
it("preselects the root on every row", () => {
for (const input of comboboxes(render({ orphans: ORPHANS }))) {
expect(input).toContain('value="t(categories.hierarchy.root)"');
}
});
it("names a seed category in the app's language", () => {
const html = render({ orphans: [{ id: 1, reason: "inactive_parent" }] });
expect(html).toContain(">t(categoriesSeed.housing[defaultValue=Logement])</p>");
expect(html).toContain("t(categories.hierarchy.reasons.inactive_parent)");
});
it("shows the translated error above the list, as an alert", () => {
const html = render({ orphans: ORPHANS, error: "Ce parent a des transactions" });
expect(html).toContain('role="alert"');
expect(html.indexOf("Ce parent a des transactions")).toBeLessThan(html.indexOf("categories.hierarchy.count"));
});
it("shows a failed check alone, without the « checking » line", () => {
const html = render({ orphans: null, error: "database is locked" });
expect(html).toContain("database is locked");
expect(html).not.toContain("t(categories.hierarchy.checking)");
});
it("disables « Rattacher » while a reattachment runs", () => {
const html = render({ orphans: ORPHANS, isSaving: true });
const buttons = html.match(/<button[^>]*>t\(categories\.hierarchy\.reattach\)<\/button>/g) ?? [];
expect(buttons).toHaveLength(2);
for (const button of buttons) expect(button).toContain('disabled=""');
expect(render({ orphans: ORPHANS })).not.toContain('disabled=""');
});
});

View file

@ -1,161 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ListTree } from "lucide-react";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
interface Props {
/** The orphan categories, one per group to reattach; `null` while the check runs. */
orphans: OrphanCategory[] | null;
/** The translated text of a refused reattachment, or of a check that failed. */
error: string | null;
/** The active categories, as the tree has them: names and parent choices. */
categories: CategoryTreeNode[];
isSaving: boolean;
onReattach: (orphan: OrphanCategory, parentId: number | null) => void;
onClose: () => void;
}
/** The value of the « Racine » option of each row's combobox. */
const ROOT = "";
/**
* « Vérifier la hiérarchie » (#393): the orphan categories of the profile, one
* 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 comboboxes open their list in a portal (#429), so the scrolling list of
* rows never cuts it.
*/
export default function HierarchyCheckModal({
orphans,
error,
categories,
isSaving,
onReattach,
onClose,
}: Props) {
const { t } = useTranslation();
/** The parent picked on each row, by orphan id; no entry means the root. */
const [choices, setChoices] = useState<Record<number, number>>({});
const optionsByOrphan = useMemo(() => {
const byOrphan = new Map<number, CategoryTreeNode[]>();
for (const orphan of orphans ?? []) {
byOrphan.set(orphan.id, buildParentOptions(categories, { editingId: orphan.id }));
}
return byOrphan;
}, [orphans, categories]);
const rootOption = useMemo(() => [{ value: ROOT, label: t("categories.hierarchy.root") }], [t]);
/** The row's parent: the one picked if it is still offered, else the root. */
const choiceOf = (orphanId: number): number | null => {
const picked = choices[orphanId];
if (picked === undefined) return null;
return optionsByOrphan.get(orphanId)?.some((c) => c.id === picked) ? picked : null;
};
const choose = (orphanId: number, parentId: number | null) => {
setChoices((previous) => {
const next = { ...previous };
if (parentId === null) delete next[orphanId];
else next[orphanId] = parentId;
return next;
});
};
const nameOf = (id: number): string => {
const category = categories.find((c) => c.id === id);
if (!category) return "—";
return category.i18n_key ? t(category.i18n_key, { defaultValue: category.name }) : category.name;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-label={t("categories.hierarchy.check")}
className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col"
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-[var(--primary)]/10">
<ListTree size={20} className="text-[var(--primary)]" />
</div>
<h2 className="text-lg font-semibold">{t("categories.hierarchy.check")}</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-4">{t("categories.hierarchy.intro")}</p>
{error && (
<div role="alert" className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{error}
</div>
)}
{orphans === null ? (
error ? null : (
<p className="text-sm text-[var(--muted-foreground)]">{t("categories.hierarchy.checking")}</p>
)
) : orphans.length === 0 ? (
<p className="text-sm font-medium">{t("categories.hierarchy.none")}</p>
) : (
<>
<p className="text-sm font-medium mb-2">{t("categories.hierarchy.count", { count: orphans.length })}</p>
<ul className="flex-1 min-h-0 overflow-y-auto divide-y divide-[var(--border)]">
{orphans.map((orphan) => {
const name = nameOf(orphan.id);
const choice = choiceOf(orphan.id);
return (
<li key={orphan.id} className="py-3 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[10rem]">
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{t(`categories.hierarchy.reasons.${orphan.reason}`)}
</p>
</div>
<div className="w-56">
<CategoryCombobox
categories={optionsByOrphan.get(orphan.id) ?? []}
value={choice}
onChange={(id) => choose(orphan.id, id)}
extraOptions={rootOption}
activeExtra={choice === null ? ROOT : null}
onExtraSelect={() => choose(orphan.id, null)}
ariaLabel={t("categories.hierarchy.newParentFor", { name })}
compact
/>
</div>
<button
type="button"
onClick={() => onReattach(orphan, choice)}
disabled={isSaving}
className="px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("categories.hierarchy.reattach")}
</button>
</li>
);
})}
</ul>
</>
)}
<div className="flex justify-end mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.close")}
</button>
</div>
</div>
</div>
);
}

View file

@ -1,137 +0,0 @@
// HierarchyCheckModal — what a row hands to « Rattacher » (#393).
//
// A static render clicks nothing. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the window runs as a plain function, its
// state survives from one call to the next, the test calls the props it hands
// to each CategoryCombobox and to its buttons, then renders again.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { 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());
// Pass-through spy on the pure module, to pin what each row offers.
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import { hookHarness } from "../../test/hookHarness";
import HierarchyCheckModal from "./HierarchyCheckModal";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, match: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, match));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(match(el) ? [el] : []), ...find(el.props.children, match)];
}
const node = (id: number, parent_id: number | null): CategoryTreeNode => ({
id,
name: `c${id}`,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
// Roots 1 and 2 (2 has a child, 21); 3 <-> 5 is a cycle; 20's parent is gone.
const CATEGORIES = [node(1, null), node(2, null), node(21, 2), node(3, 5), node(5, 3), node(20, 99)];
const CYCLE: OrphanCategory = { id: 3, reason: "cycle" };
const GONE: OrphanCategory = { id: 20, reason: "missing_parent" };
const onReattach = vi.fn();
const onClose = vi.fn();
const view = (orphans: OrphanCategory[] | null = [CYCLE, GONE], categories = CATEGORIES) => {
const tree = harness.render(() =>
HierarchyCheckModal({ orphans, error: null, categories, isSaving: false, onReattach, onClose }),
);
return {
comboboxes: find(tree, (el) => el.type === CategoryCombobox).map((el) => el.props),
reattach: find(tree, (el) => el.type === "button" && el.props.children === "t(categories.hierarchy.reattach)"),
close: find(tree, (el) => el.type === "button" && el.props.children === "t(common.close)"),
};
};
const ids = (list: unknown) => (list as CategoryTreeNode[]).map((c) => c.id).sort((a, b) => a - b);
/** Calls the `onClick` the window hands to a button. */
const click = (button: ReactElement<Props>) => (button.props.onClick as () => void)();
beforeEach(() => {
harness.clear();
onReattach.mockReset();
onClose.mockReset();
vi.mocked(buildParentOptions).mockClear();
});
describe("HierarchyCheckModal — a row's new parent (#393)", () => {
it("offers the category form's parents, the row's category treated as the one edited, the root first", () => {
const { comboboxes } = view();
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 3 });
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 20 });
// The cycle member carries the other member (5): roots only, its own cycle left out.
// 20, whose parent is gone, shows as a root in the tree, and is offered as one.
expect(ids(comboboxes[0].categories)).toEqual([1, 2, 20]);
// A leaf: roots and level-1 categories, never a cycle member nor itself.
expect(ids(comboboxes[1].categories)).toEqual([1, 2, 21]);
for (const props of comboboxes) {
expect(props.extraOptions).toEqual([{ value: "", label: "t(categories.hierarchy.root)" }]);
expect(props.value).toBeNull();
expect(props.activeExtra).toBe("");
}
});
it("« Rattacher » hands the root by default", () => {
click(view().reattach[1]);
expect(onReattach).toHaveBeenCalledWith(GONE, null);
});
it("hands the parent picked on the row, and the root again once it is picked back", () => {
(view().comboboxes[1].onChange as (id: number) => void)(21);
let current = view();
expect(current.comboboxes[1].value).toBe(21);
expect(current.comboboxes[1].activeExtra).toBeNull();
// The other row keeps the root.
expect(current.comboboxes[0].value).toBeNull();
click(current.reattach[1]);
expect(onReattach).toHaveBeenLastCalledWith(GONE, 21);
(current.comboboxes[1].onExtraSelect as (value: string) => void)("");
current = view();
expect(current.comboboxes[1].value).toBeNull();
expect(current.comboboxes[1].activeExtra).toBe("");
click(current.reattach[1]);
expect(onReattach).toHaveBeenLastCalledWith(GONE, null);
});
it("falls back to the root when the parent picked is no longer offered", () => {
(view().comboboxes[1].onChange as (id: number) => void)(21);
// After a reload, 21 is gone from the categories.
const current = view([CYCLE, GONE], CATEGORIES.filter((c) => c.id !== 21));
expect(current.comboboxes[1].value).toBeNull();
click(current.reattach[1]);
expect(onReattach).toHaveBeenCalledWith(GONE, null);
});
it("« Fermer » closes the window", () => {
const { close } = view();
expect(close).toHaveLength(1);
click(close[0]);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -97,13 +97,6 @@ describe("buildParentOptions — editing a category", () => {
expect(offered.every((id) => id === 1 || id === 2)).toBe(true); expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
}); });
it("never offers a member of a cycle, nor what hangs under it: a child there would be hidden (#393)", () => {
// 1 <-> 2 with 4 under 2; 3 and 5 are sound roots. Creating, then editing 5.
const cyclic = [node(1, 2), node(2, 1), node(4, 2), node(3, null), node(5, null)];
expect(ids(buildParentOptions(cyclic))).toEqual([3, 5]);
expect(ids(buildParentOptions(cyclic, { editingId: 5 }))).toEqual([3]);
});
it("terminates when the edited category is itself a member of a cycle", () => { it("terminates when the edited category is itself a member of a cycle", () => {
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not // 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
// unbounded). Only roots remain, so the root outside the cycle is offered. // unbounded). Only roots remain, so the root outside the cycle is offered.

View file

@ -1,5 +1,7 @@
import type { CategoryTreeNode } from "../../shared/types"; import type { CategoryTreeNode } from "../../shared/types";
import { depthsOf, fitsUnder, subtreeHeight } from "../../utils/categoryHierarchy";
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
const MAX_PARENT_DEPTH = 1;
export interface ParentOptionsContext { export interface ParentOptionsContext {
/** Id of the category being edited; absent or null when creating. */ /** Id of the category being edited; absent or null when creating. */
@ -12,9 +14,60 @@ export interface ParentOptionsContext {
currentParentId?: number | null; currentParentId?: number | null;
} }
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
const byId = new Map<number, CategoryTreeNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
const depthOf = (id: number, seen: Set<number>): number => {
const known = depths.get(id);
if (known !== undefined) return known;
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
seen.add(id);
const cat = byId.get(id);
const parentId = cat?.parent_id ?? null;
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
depths.set(id, d);
return d;
};
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
/** /**
* Categories that may be chosen as the parent in the category form, and in the * Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
* « Vérifier la hiérarchie » window (#393). * grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
* walk can never take more steps than there are categories.
*/
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
const childrenOf = new Map<number, number[]>();
for (const c of categories) {
if (c.parent_id === null) continue;
const bucket = childrenOf.get(c.parent_id) ?? [];
bucket.push(c.id);
childrenOf.set(c.parent_id, bucket);
}
let height = 0;
const seen = new Set<number>([rootId]);
let frontier = [rootId];
while (frontier.length > 0 && height < categories.length) {
const next: number[] = [];
for (const id of frontier) {
for (const child of childrenOf.get(id) ?? []) {
if (seen.has(child)) continue;
seen.add(child);
next.push(child);
}
}
if (next.length === 0) break;
height += 1;
frontier = next;
}
return height;
}
/**
* Categories that may be chosen as the parent in the category form.
* *
* - Roots and level-1 categories only: a level-2 parent would create a fourth * - Roots and level-1 categories only: a level-2 parent would create a fourth
* level, which nothing else in the app knows how to display. * level, which nothing else in the app knows how to display.
@ -25,15 +78,11 @@ export interface ParentOptionsContext {
* whole descent, which would create a cycle that hides the branch everywhere * whole descent, which would create a cycle that hides the branch everywhere
* (#393): a descendant sits at least one level below the edited category, * (#393): a descendant sits at least one level below the edited category,
* deeper than any depth still allowed. * deeper than any depth still allowed.
* - A category caught in a `parent_id` cycle, or under one, has no depth and is
* never offered: a child placed under it would be hidden with it.
* - The parent currently set on the form is always kept when it exists. * - The parent currently set on the form is always kept when it exists.
* *
* The depth and height rules are those of `src/utils/categoryHierarchy.ts`, * This list is the only guard: `updateCategory` writes any `parent_id` it is
* which `updateCategory` and `reattachCategory` apply again before writing * given, without a depth or cycle check (#393 will add one). The form therefore
* (#393): the list only spares a refusal at save time. It does not leave out * never offers a new parent that the rules above reject.
* a parent that holds transactions; the service refuses it with a translated
* message.
* *
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically). * Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
*/ */
@ -46,13 +95,15 @@ export function buildParentOptions(
const depths = depthsOf(categories); const depths = depthsOf(categories);
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0; const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
// Negative when the edited category has grandchildren: only the current parent survives.
const maxDepth = MAX_PARENT_DEPTH - height;
return categories.filter((c) => { return categories.filter((c) => {
if (c.id === editingId) return false; if (c.id === editingId) return false;
// Kept even when the depth rule would drop it: the field must show what is // Kept even when the depth rule would drop it: the field must show what is
// stored. Keeping it changes nothing on save (it is already the parent). // stored. Keeping it changes nothing on save (it is already the parent);
// repairing such data is the job of #393.
if (c.id === currentParentId) return true; if (c.id === currentParentId) return true;
const depth = depths.get(c.id); return (depths.get(c.id) ?? 0) <= maxDepth;
return depth !== undefined && fitsUnder(depth, height);
}); });
} }

View file

@ -61,6 +61,7 @@ type LinkEl = ReactElement<{ to: string; children?: ReactNode }>;
function render(overrides: Partial<ImportEmptyStateProps> = {}) { function render(overrides: Partial<ImportEmptyStateProps> = {}) {
const props: ImportEmptyStateProps = { const props: ImportEmptyStateProps = {
hasFolder: false,
folderName: null, folderName: null,
onBrowse: vi.fn(), onBrowse: vi.fn(),
onCreateSample: vi.fn(), onCreateSample: vi.fn(),
@ -76,7 +77,7 @@ function render(overrides: Partial<ImportEmptyStateProps> = {}) {
describe("ImportEmptyState — no folder (#356)", () => { describe("ImportEmptyState — no folder (#356)", () => {
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => { it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
const { props, tree, buttons, links } = render({ folderName: null }); const { props, tree, buttons, links } = render({ hasFolder: false });
expect(texts(tree)).toContain("t(import.empty.noFolder.title)"); expect(texts(tree)).toContain("t(import.empty.noFolder.title)");
expect(texts(tree)).toContain("t(import.empty.noFolder.description)"); expect(texts(tree)).toContain("t(import.empty.noFolder.description)");
@ -95,7 +96,7 @@ describe("ImportEmptyState — no folder (#356)", () => {
}); });
it("never shows the sample or refresh actions without a folder", () => { it("never shows the sample or refresh actions without a folder", () => {
const { tree } = render({ folderName: null }); const { tree } = render({ hasFolder: false });
expect(texts(tree)).not.toContain("t(import.empty.createSample)"); expect(texts(tree)).not.toContain("t(import.empty.createSample)");
expect(texts(tree)).not.toContain("t(import.empty.refresh)"); expect(texts(tree)).not.toContain("t(import.empty.refresh)");
}); });
@ -103,7 +104,7 @@ describe("ImportEmptyState — no folder (#356)", () => {
describe("ImportEmptyState — folder without sources (#356)", () => { describe("ImportEmptyState — folder without sources (#356)", () => {
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => { it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
const { props, tree, buttons, links } = render({ folderName: "Relevés" }); const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
expect(texts(tree)).toContain("t(import.empty.noSources.title)"); expect(texts(tree)).toContain("t(import.empty.noSources.title)");
@ -124,7 +125,7 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
}); });
it("disables the create button while the sample folders are being written", () => { it("disables the create button while the sample folders are being written", () => {
const { buttons } = render({ folderName: "Relevés", isCreatingSample: true }); const { buttons } = render({ hasFolder: true, folderName: "Relevés", isCreatingSample: true });
expect(buttons[0].props.disabled).toBe(true); expect(buttons[0].props.disabled).toBe(true);
// Refresh stays available: it is not the action in flight. // Refresh stays available: it is not the action in flight.
expect(buttons[1].props.disabled).toBe(false); expect(buttons[1].props.disabled).toBe(false);
@ -132,22 +133,12 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
it("names the folder in the description, never the full path", () => { it("names the folder in the description, never the full path", () => {
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés"); const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
const { tree } = render({ folderName }); const { tree } = render({ hasFolder: true, folderName });
const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description")); const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description"));
expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])"); expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])");
expect(description).not.toContain("Users"); expect(description).not.toContain("Users");
}); });
// `folderName` alone tells the variants apart (#384): null is "no folder",
// any string a folder, an empty one included (what the page hands over for
// an import folder stored as "", as `hasFolder` did before it).
it("an empty folder name is still a folder: the 'no source' variant", () => {
const { tree, buttons } = render({ folderName: "" });
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
expect(texts(tree)).toContain("t(import.empty.noSources.description[folderName=])");
expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)");
});
}); });
describe("ImportEmptyState — expected structure of the folder (#422)", () => { describe("ImportEmptyState — expected structure of the folder (#422)", () => {
@ -162,8 +153,8 @@ describe("ImportEmptyState — expected structure of the folder (#422)", () => {
].map((name) => `t(import.empty.structure.${name})`); ].map((name) => `t(import.empty.structure.${name})`);
it.each([ it.each([
["no folder", { folderName: null }], ["no folder", { hasFolder: false }],
["a folder without sources", { folderName: "Relevés" }], ["a folder without sources", { hasFolder: true, folderName: "Relevés" }],
] as const)("shows the expected structure with %s, above the guide link", (_, overrides) => { ] as const)("shows the expected structure with %s, above the guide link", (_, overrides) => {
const { tree } = render(overrides); const { tree } = render(overrides);
const all = texts(tree); const all = texts(tree);
@ -179,7 +170,7 @@ describe("ImportEmptyState — getting-started target (#398)", () => {
find(tree, (e) => e.props["data-onboarding-highlight"] === "true"); find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
it("highlights 'Choose a folder' without a folder, and only it", () => { it("highlights 'Choose a folder' without a folder, and only it", () => {
const { tree } = render({ folderName: null, onboardingTarget: true }); const { tree } = render({ hasFolder: false, onboardingTarget: true });
const targets = highlighted(tree); const targets = highlighted(tree);
expect(targets).toHaveLength(1); expect(targets).toHaveLength(1);
expect(targets[0].type).toBe("button"); expect(targets[0].type).toBe("button");
@ -187,16 +178,16 @@ describe("ImportEmptyState — getting-started target (#398)", () => {
}); });
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => { it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
const { tree } = render({ folderName: "Relevés", onboardingTarget: true }); const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
const targets = highlighted(tree); const targets = highlighted(tree);
expect(targets).toHaveLength(1); expect(targets).toHaveLength(1);
expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)"); expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)");
}); });
it("highlights nothing without the property", () => { it("highlights nothing without the property", () => {
expect(highlighted(render({ folderName: null }).tree)).toHaveLength(0); expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0);
expect(highlighted(render({ folderName: "Relevés" }).tree)).toHaveLength(0); expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
expect(highlighted(render({ folderName: "Relevés", onboardingTarget: false }).tree)).toHaveLength(0); expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
}); });
}); });

View file

@ -8,11 +8,9 @@ import ImportFolderStructure from "./ImportFolderStructure";
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export"; export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
export interface ImportEmptyStateProps { export interface ImportEmptyStateProps {
/** /** An import folder is configured; the empty state then speaks of its content. */
* Last segment of the import folder path, never the full path; null when no hasFolder: boolean;
* import folder is configured. With a folder, the empty state speaks of its /** Last segment of the import folder path, never the full path. */
* content (#384: one prop, no separate `hasFolder` to disagree with it).
*/
folderName: string | null; folderName: string | null;
onBrowse: () => void; onBrowse: () => void;
onCreateSample: () => void; onCreateSample: () => void;
@ -45,6 +43,7 @@ export function folderNameFromPath(path: string): string {
* so it is tested by direct call. * so it is tested by direct call.
*/ */
export default function ImportEmptyState({ export default function ImportEmptyState({
hasFolder,
folderName, folderName,
onBrowse, onBrowse,
onCreateSample, onCreateSample,
@ -68,7 +67,7 @@ export default function ImportEmptyState({
</> </>
); );
if (folderName === null) { if (!hasFolder) {
return ( return (
<EmptyState <EmptyState
icon={<FolderOpen size={40} />} icon={<FolderOpen size={40} />}
@ -85,7 +84,7 @@ export default function ImportEmptyState({
<EmptyState <EmptyState
icon={<Inbox size={40} />} icon={<Inbox size={40} />}
title={t("import.empty.noSources.title")} title={t("import.empty.noSources.title")}
description={t("import.empty.noSources.description", { folderName })} description={t("import.empty.noSources.description", { folderName: folderName ?? "" })}
primaryAction={{ primaryAction={{
label: t("import.empty.createSample"), label: t("import.empty.createSample"),
onClick: onCreateSample, onClick: onCreateSample,

View file

@ -96,13 +96,6 @@ const buttonNamed = (node: ReactNode, key: string) => {
return button; return button;
}; };
/** The dot between the counter and the next step: a decorative `h-1 w-1` span. */
const isDot = (e: AnyElement) => {
const classes = String(e.props.className).split(/\s+/);
return e.type === "span" && classes.includes("h-1") && classes.includes("w-1");
};
const dots = (props: OnboardingBannerProps) => find(tree(props), isDot);
describe("OnboardingBanner — banner mode (#397)", () => { describe("OnboardingBanner — banner mode (#397)", () => {
it("is a region named for the path, with the counter, the next step and its link", () => { it("is a region named for the path, with the counter, the next step and its link", () => {
const html = markup(bannerProps()); const html = markup(bannerProps());
@ -126,35 +119,15 @@ describe("OnboardingBanner — banner mode (#397)", () => {
}); });
it("the counter waits for the Budget entitlement, the next step does not", () => { it("the counter waits for the Budget entitlement, the next step does not", () => {
const props = bannerProps({ budgetReady: false }); const html = markup(bannerProps({ budgetReady: false }));
const html = markup(props);
expect(html).not.toContain("onboarding.banner.progress"); expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/); expect(html).toMatch(/href="\/import"/);
// The dot waits with the counter it follows (#446).
expect(dots(props)).toHaveLength(0);
}); });
it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => { it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => {
const props = bannerProps({ visitStepsInitialized: false }); const html = markup(bannerProps({ visitStepsInitialized: false }));
const html = markup(props);
expect(html).not.toContain("onboarding.banner.progress"); expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/); expect(html).toMatch(/href="\/import"/);
// No dot either: it would stand alone before the next step (#446).
expect(dots(props)).toHaveLength(0);
});
it("once the total is known, a decorative dot separates the counter from the next step (#446)", () => {
const props = bannerProps();
const found = dots(props);
expect(found).toHaveLength(1);
expect(found[0].props["aria-hidden"]).toBe("true");
const html = markup(props);
const counter = html.indexOf("t(onboarding.banner.progress");
const dotAt = html.search(/<span aria-hidden="true" class="h-1 w-1 /);
const next = html.indexOf("t(onboarding.banner.next)");
expect(counter).toBeGreaterThan(-1);
expect(dotAt).toBeGreaterThan(counter);
expect(next).toBeGreaterThan(dotAt);
}); });
it("folded: 'Show the list' with aria-expanded false, and no list", () => { it("folded: 'Show the list' with aria-expanded false, and no list", () => {

View file

@ -1,338 +0,0 @@
// ChangelogContent — the version history folds by version (#372).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// component runs as a plain function, its state survives from one call to the
// next, `commit()` runs its effect (the fetch of the changelog), and the
// handlers of the rendered tree are called as clicks would. The markup is
// read through renderToStaticMarkup.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ language: "fr" }));
vi.mock("react-i18next", async () =>
(await import("../../test/i18nMock")).i18nMock({ language: () => ctx.language }),
);
import { renderToStaticMarkup } from "react-dom/server";
import { hookHarness } from "../../test/hookHarness";
import ChangelogContent, { CHANGELOG_NOTES_ID_PREFIX } from "./ChangelogContent";
const harness = hookHarness();
const FR = [
"# Journal des modifications",
"",
"## [Non publié]",
"",
"## [0.17.2] - 2026-10-18",
"",
"### Ajouté",
"",
"- **Import** : note-0172-a",
"",
"### Corrigé",
"",
"- note-0172-b",
"",
"## [0.17.1] - 2026-10-08",
"",
"### Modifié",
"",
"- note-0171",
"",
"## 0.3.2",
"",
"### Ajouté",
"",
"- note-032",
"",
].join("\n");
const EN = FR.replace("Journal des modifications", "Changelog")
.replace("[Non publié]", "[Unreleased]")
.replace("note-0171", "note-0171-en");
/** FR with content under its unreleased heading, as in a development build. */
const FR_DEV = FR.replace("## [Non publié]\n", "## [Non publié]\n\n### Modifié\n\n- note-dev\n");
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
interface FetchedFile {
text: () => Promise<string>;
}
let files: Record<string, string>;
/** The bundled files, `files` by URL; any other URL fails, as an unreadable file. */
const readFiles = async (url: string): Promise<FetchedFile> => {
if (!(url in files)) throw new Error(`unexpected fetch ${url}`);
return { text: async () => files[url] };
};
const fetchMock = vi.fn(readFiles);
/** Lets the fetch, `text()` and the state updates that follow them settle. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** One render of the mounted component. */
function render(installedVersion: string | null = "0.17.1"): ReactNode {
return harness.render(() => ChangelogContent({ installedVersion }) as ReactNode);
}
/** Mount, run the effect, let the file load, then render with the loaded state. */
async function mount(installedVersion: string | null = "0.17.1"): Promise<ReactNode> {
render(installedVersion);
harness.commit();
await settle();
return render(installedVersion);
}
/** The title button of `version`. */
function titleOf(tree: ReactNode, version: string): AnyElement {
const [button] = find(
tree,
(e) => e.type === "button" && e.props["aria-controls"] !== undefined && texts(e.props.children).includes(version),
);
expect(button, `title of ${version}`).toBeDefined();
return button;
}
/** The version titles, in order, with their state. */
function titles(tree: ReactNode): Array<[string, boolean]> {
return find(tree, (e) => e.type === "button" && e.props["aria-controls"] !== undefined).map((e) => [
texts(e.props.children).join(""),
e.props["aria-expanded"] as boolean,
]);
}
/** The expand-all button. */
function allButton(tree: ReactNode): AnyElement {
const [button] = find(tree, (e) => e.type === "button" && e.props["aria-controls"] === undefined);
expect(button, "expand-all button").toBeDefined();
return button;
}
const click = (button: AnyElement) => (button.props.onClick as () => void)();
beforeEach(() => {
harness.clear();
ctx.language = "fr";
files = { "/CHANGELOG.fr.md": FR, "/CHANGELOG.md": EN };
fetchMock.mockReset();
fetchMock.mockImplementation(readFiles);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ChangelogContent — loading", () => {
it("reads the changelog of the app language", async () => {
await mount();
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.fr.md");
harness.clear();
ctx.language = "en";
fetchMock.mockClear();
await mount();
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.md");
});
it("shows the empty message until the file is read, and when it cannot be read", async () => {
expect(renderToStaticMarkup(render())).toContain("t(changelog.empty)");
harness.clear();
files = {};
const html = renderToStaticMarkup(await mount());
expect(html).toContain("t(changelog.empty)");
expect(html).not.toContain("aria-expanded");
});
});
describe("ChangelogContent — which versions open (#372)", () => {
it("opens on the installed version, every other version folded", async () => {
const tree = await mount("0.17.1");
expect(titles(tree)).toEqual([
["0.17.2", false],
["0.17.1", true],
["0.3.2", false],
]);
const html = renderToStaticMarkup(tree);
expect(html).toContain("note-0171");
expect(html).not.toContain("note-0172-a");
expect(html).not.toContain("note-032");
});
it("opens on the first published version when the installed version is unknown", async () => {
const tree = await mount(null);
expect(titles(tree)).toEqual([
["0.17.2", true],
["0.17.1", false],
["0.3.2", false],
]);
});
it("opens on the first published version when the installed one is not in the file", async () => {
const tree = await mount("0.18.0");
expect(titles(tree).filter(([, open]) => open)).toEqual([["0.17.2", true]]);
});
it("hides the empty unreleased heading", async () => {
const html = renderToStaticMarkup(await mount());
expect(html).not.toContain("Non publié");
});
it("keeps an unreleased heading with content, folded, before the versions", async () => {
files["/CHANGELOG.fr.md"] = FR_DEV;
const tree = await mount("0.17.2");
expect(titles(tree)).toEqual([
["Non publié", false],
["0.17.2", true],
["0.17.1", false],
["0.3.2", false],
]);
expect(renderToStaticMarkup(tree)).not.toContain("note-dev");
});
it("shows the notes of an open version, sections and items, without bold marks", async () => {
const html = renderToStaticMarkup(await mount("0.17.2"));
expect(html).toContain(">Ajouté</h4>");
expect(html).toContain(">Corrigé</h4>");
expect(html).toContain("Import : note-0172-a");
expect(html).not.toContain("**Import**");
expect(html).toContain("note-0172-b");
});
});
describe("ChangelogContent — folding by hand", () => {
it("unfolds a folded version and folds it again on its title", async () => {
let tree: ReactNode = await mount("0.17.1");
click(titleOf(tree, "0.3.2"));
tree = render();
expect(titles(tree)).toEqual([
["0.17.2", false],
["0.17.1", true],
["0.3.2", true],
]);
expect(renderToStaticMarkup(tree)).toContain("note-032");
click(titleOf(tree, "0.3.2"));
tree = render();
expect(titleOf(tree, "0.3.2").props["aria-expanded"]).toBe(false);
expect(renderToStaticMarkup(tree)).not.toContain("note-032");
});
it("folds the installed version on its title", async () => {
let tree: ReactNode = await mount("0.17.1");
click(titleOf(tree, "0.17.1"));
tree = render();
expect(titles(tree).every(([, open]) => !open)).toBe(true);
});
it("unfolds everything with « Expand all », then folds everything with « Collapse all »", async () => {
let tree: ReactNode = await mount("0.17.1");
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
click(allButton(tree));
tree = render();
expect(titles(tree).every(([, open]) => open)).toBe(true);
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
const html = renderToStaticMarkup(tree);
for (const note of ["note-0172-a", "note-0171", "note-032"]) expect(html).toContain(note);
click(allButton(tree));
tree = render();
expect(titles(tree).every(([, open]) => !open)).toBe(true);
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
});
it("offers « Collapse all » once every version is unfolded by hand", async () => {
let tree: ReactNode = await mount("0.17.1");
click(titleOf(tree, "0.17.2"));
tree = render();
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
click(titleOf(tree, "0.3.2"));
tree = render();
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
});
});
describe("ChangelogContent — accessibility", () => {
it("makes each title a button inside its heading, with its state and the notes it controls", async () => {
const html = renderToStaticMarkup(await mount("0.17.1"));
// Heading > button with aria-expanded and aria-controls, chevron, version.
expect(html).toMatch(
/<h3[^>]*><button type="button" aria-expanded="true" aria-controls="changelog-notes-1" title="t\(changelog\.hideNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.1<\/button><\/h3>/,
);
expect(html).toMatch(
/<h3[^>]*><button type="button" aria-expanded="false" aria-controls="changelog-notes-0" title="t\(changelog\.showNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.2<\/button><\/h3>/,
);
// Every id an aria-controls names is in the page, folded or not.
const controlled = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]);
expect(controlled).toEqual([0, 1, 2].map((i) => `${CHANGELOG_NOTES_ID_PREFIX}${i}`));
for (const id of controlled) expect(html).toContain(`id="${id}"`);
});
it("names the expand-all button through t, never in hard text", async () => {
const html = renderToStaticMarkup(await mount("0.17.1"));
expect(html).toMatch(/<button type="button"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>t\(changelog\.expandAll\)<\/button>/);
});
});
describe("ChangelogContent — language switch", () => {
it("reloads the file of the new language and opens the installed version again", async () => {
let tree: ReactNode = await mount("0.17.1");
click(allButton(tree));
ctx.language = "en";
render();
harness.commit();
await settle();
tree = render();
expect(fetchMock).toHaveBeenLastCalledWith("/CHANGELOG.md");
expect(titles(tree)).toEqual([
["0.17.2", false],
["0.17.1", true],
["0.3.2", false],
]);
expect(renderToStaticMarkup(tree)).toContain("note-0171-en");
});
it("never lets a late answer of the previous language replace the current one", async () => {
const pending: Array<{ url: string; resolve: (text: string) => void }> = [];
fetchMock.mockImplementation(
(url: string) =>
new Promise<FetchedFile>((resolveFetch) => {
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }) });
}),
);
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].resolve(FR);
await settle();
const html = renderToStaticMarkup(render("0.17.1"));
expect(html).toContain("note-0171-en");
expect(html).not.toMatch(/note-0171(?!-en)/);
});
});

View file

@ -1,52 +1,57 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import {
allVersionsExpanded,
initiallyExpandedVersions,
parseChangelog,
toggleAllVersions,
toggleVersion,
visibleChangelogEntries,
type ChangelogEntry,
} from "../../utils/changelog";
/** Prefix of the id of each version's notes, which its title names in `aria-controls`. */ interface ChangelogEntry {
export const CHANGELOG_NOTES_ID_PREFIX = "changelog-notes-"; version: string;
sections: { heading: string; items: string[] }[];
interface ChangelogContentProps {
/** The running app's version (`getVersion()`), `null` when it could not be read. */
installedVersion: string | null;
} }
export default function ChangelogContent({ installedVersion }: ChangelogContentProps) { function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogContent() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]); const [entries, setEntries] = useState<ChangelogEntry[]>([]);
// Versions whose notes show. Seeded by the expansion rule at each load of
// the file, then changed by the titles and the expand-all button. In
// memory only: the history opens on the installed version every time (#372).
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
useEffect(() => { useEffect(() => {
// A file of the previous language that answers late must not replace
// the one of the current language.
let cancelled = false;
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md"; const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file) fetch(file)
.then((r) => r.text()) .then((r) => r.text())
.then((text) => { .then((text) => setEntries(parseChangelog(text)))
if (cancelled) return; .catch(() => setEntries([]));
const visible = visibleChangelogEntries(parseChangelog(text)); }, [i18n.language]);
setEntries(visible);
setExpanded(initiallyExpandedVersions(visible, installedVersion));
})
.catch(() => {
if (!cancelled) setEntries([]);
});
return () => {
cancelled = true;
};
}, [i18n.language, installedVersion]);
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
@ -54,77 +59,34 @@ export default function ChangelogContent({ installedVersion }: ChangelogContentP
); );
} }
const allExpanded = allVersionsExpanded(entries, expanded);
return ( return (
<div className="space-y-3"> <div className="space-y-6">
<div className="flex justify-end"> {entries.map((entry) => (
<button <div
type="button" key={entry.version}
onClick={() => setExpanded((current) => toggleAllVersions(entries, current))} className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
> >
{allExpanded ? ( <h3 className="text-lg font-semibold">{entry.version}</h3>
<ChevronsDownUp size={13} aria-hidden="true" /> {entry.sections.map((section, si) => (
) : ( <div key={si} className="space-y-1.5">
<ChevronsUpDown size={13} aria-hidden="true" /> <h4 className="text-sm font-semibold text-[var(--primary)]">
)} {section.heading}
{allExpanded ? t("changelog.collapseAll") : t("changelog.expandAll")} </h4>
</button> <ul className="space-y-1">
</div> {section.items.map((item, ii) => (
{entries.map((entry, index) => { <li
const open = expanded.has(entry.version); key={ii}
const notesId = `${CHANGELOG_NOTES_ID_PREFIX}${index}`; className="text-sm text-[var(--muted-foreground)] pl-3"
return ( >
<div {"• "}
key={entry.version} {item.replace(/\*\*(.+?)\*\*/g, "$1")}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl" </li>
> ))}
<h3 className="text-lg font-semibold"> </ul>
<button
type="button"
aria-expanded={open}
aria-controls={notesId}
title={open ? t("changelog.hideNotes") : t("changelog.showNotes")}
onClick={() => setExpanded((current) => toggleVersion(current, entry.version))}
className="flex items-center gap-2 w-full px-6 py-3 text-left hover:opacity-80 transition-opacity"
>
{open ? (
<ChevronDown size={18} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronRight size={18} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
)}
{entry.version}
</button>
</h3>
{/* Always in the page, so `aria-controls` names an element even while folded. */}
<div id={notesId}>
{open && (
<div className="px-6 pb-6 space-y-3">
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h4 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h4>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"• "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
)}
</div> </div>
</div> ))}
); </div>
})} ))}
</div> </div>
); );
} }

View file

@ -1,6 +1,5 @@
// Render tests for the key entry form of the License card (#347). No jsdom needed: // Render tests for the key entry form of the License card (#347). No jsdom needed:
// react-dom/server renders in vitest's node environment, and the marked `t` of // react-dom/server renders in vitest's node environment, and `t` echoes its key.
// src/test/i18nMock.ts renders `t(key)`.
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
@ -45,10 +44,4 @@ describe("LicenseKeyForm", () => {
expect(render(null, true)).toMatch(/<button type="submit" disabled=""/); expect(render(null, true)).toMatch(/<button type="submit" disabled=""/);
expect(render(null, false)).not.toMatch(/<button type="submit" disabled=""/); expect(render(null, false)).not.toMatch(/<button type="submit" disabled=""/);
}); });
it("names the key field and the submit button through t (#437)", () => {
const html = render(null);
expect(html).toContain('placeholder="t(license.keyPlaceholder)"');
expect(html).toMatch(/<button type="submit"[^>]*>t\(license\.activate\)<\/button>/);
});
}); });

View file

@ -226,13 +226,7 @@ describe("PriceFetchConsentToggle — displayed texts", () => {
harness.clear(); harness.clear();
}); });
/** /** One render once loaded: slots by call order, hasConsent, showConfirm, loading. */
* One render once loaded: slots by call order, hasConsent, showConfirm, loading.
* The presets depend on the order of the toggle's hook calls: slots 0 to 2 are
* its first three hooks, and a hook of any kind added before them shifts the
* slots without a word, each preset then landing on another hook (one slot per
* hook call, every kind: the `slot` helper of `fakeReact`, src/test/hookHarness.ts).
*/
function markup(premium: boolean, consent: boolean, confirming: boolean): string { function markup(premium: boolean, consent: boolean, confirming: boolean): string {
setPremium(premium); setPremium(premium);
harness.slots[0] = consent; harness.slots[0] = consent;

View file

@ -99,19 +99,6 @@ describe("sortHierarchical", () => {
expect(ordered).toEqual([1, 10, 500]); expect(ordered).toEqual([1, 10, 500]);
}); });
it("leaves out the members of a parent_id cycle and what hangs under it, and ends (#393)", () => {
// 3 <-> 5, 8 under 5: no root leads to them, and their parents are in the
// list, so they are no orphans of it either. This is why a cycle hides a
// category from every picker; « Vérifier la hiérarchie » repairs it.
const input: Category[] = [
cat(1, "Revenus", null, 1),
cat(3, "Épicerie", 5, 1),
cat(5, "Alimentation", 3, 1),
cat(8, "Boulangerie", 5, 2),
];
expect(sortHierarchical(input, displayName).map((c) => c.id)).toEqual([1]);
});
it("is stable (does not duplicate) when called with already-ordered input", () => { it("is stable (does not duplicate) when called with already-ordered input", () => {
const input: Category[] = [ const input: Category[] = [
cat(1, "Revenus", null, 1), cat(1, "Revenus", null, 1),

View file

@ -1,13 +1,5 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useId, useMemo } from "react"; import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
LISTBOX_MAX_HEIGHT,
followAnchor,
placeListbox,
samePlacement,
type ListboxPlacement,
} from "./placeListbox";
/** /**
* Minimal shape the combobox needs. `Category` (transactions, reports) and * Minimal shape the combobox needs. `Category` (transactions, reports) and
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their * `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
@ -143,7 +135,6 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0); const [highlightIndex, setHighlightIndex] = useState(0);
const [placement, setPlacement] = useState<ListboxPlacement | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null); const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -196,36 +187,10 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
} }
}, [highlightIndex, open]); }, [highlightIndex, open]);
// The open list lives in a portal on document.body (#429): no scrolling
// container clips it. It is placed on its input before the first paint,
// then follows the input on every scroll and resize, even out of the
// visible part of its container; the listeners go with the list.
const updatePlacement = useCallback(() => {
const input = inputRef.current;
if (!input) return;
const next = placeListbox({
anchor: input.getBoundingClientRect(),
viewportHeight: window.innerHeight,
});
setPlacement((prev) => (samePlacement(prev, next) ? prev : next));
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePlacement();
return followAnchor(window, updatePlacement, () => listRef.current);
}, [open, updatePlacement]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
// The list is outside the container in the DOM: a press on an option if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
// counts as inside, or the list would close before the click selects.
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) {
setOpen(false); setOpen(false);
setQuery(""); setQuery("");
} }
@ -312,20 +277,12 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`} className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`}
/> />
{open && totalItems > 0 && placement && createPortal( {open && totalItems > 0 && (
<ul <ul
ref={listRef} ref={listRef}
id={listboxId} id={listboxId}
role="listbox" role="listbox"
// z-[300]: above every window of the app, the highest at z-[210]. className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
className="fixed z-[300] overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
style={{
top: placement.top,
bottom: placement.bottom,
left: placement.left,
width: placement.width,
maxHeight: LISTBOX_MAX_HEIGHT,
}}
> >
{filteredExtras.map((opt, i) => ( {filteredExtras.map((opt, i) => (
<li <li
@ -370,8 +327,7 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
</li> </li>
); );
})} })}
</ul>, </ul>
document.body,
)} )}
</div> </div>
); );

View file

@ -1,175 +0,0 @@
// 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.
import { describe, it, expect, vi } from "vitest";
import {
LISTBOX_GAP,
LISTBOX_MAX_HEIGHT,
followAnchor,
placeListbox,
samePlacement,
type ListenerTarget,
type ListboxPlacement,
} from "./placeListbox";
/** An input 30 px high and 200 px wide, its top at `top`. */
const anchorAt = (top: number) => ({ top, bottom: top + 30, left: 50, width: 200 });
describe("placeListbox", () => {
it("keeps the list's size of max-h-60 and the gap of mt-1", () => {
expect(LISTBOX_MAX_HEIGHT).toBe(240);
expect(LISTBOX_GAP).toBe(4);
});
it("places the list under the input, as wide as it, at the input's left edge", () => {
expect(placeListbox({ anchor: anchorAt(100), viewportHeight: 800 })).toEqual({
left: 50,
width: 200,
openUpward: false,
top: 134,
});
});
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 });
expect(placement.openUpward).toBe(false);
expect(placement.top).toBe(134);
});
it("opens upward when the room below is short and the room above suffices", () => {
const placement = placeListbox({ anchor: anchorAt(700), viewportHeight: 800 });
expect(placement).toEqual({ left: 50, width: 200, openUpward: true, bottom: 104 });
// Its bottom edge sits the gap above the input, whatever its height.
expect(800 - placement.bottom!).toBe(700 - LISTBOX_GAP);
});
it("opens upward when the room above is exactly the list's maximum height", () => {
// Room above: 244 - 4 = 240; room below: 300 - 274 - 4 = 22.
expect(placeListbox({ anchor: anchorAt(244), viewportHeight: 300 }).openUpward).toBe(true);
});
it("stays below when room is short on both sides, even with more room above", () => {
// Room above: 200 - 4 = 196; room below: 400 - 230 - 4 = 166.
expect(placeListbox({ anchor: anchorAt(200), viewportHeight: 400 })).toEqual({
left: 50,
width: 200,
openUpward: false,
top: 234,
});
// One pixel short above: still below.
expect(placeListbox({ anchor: anchorAt(243), viewportHeight: 300 }).openUpward).toBe(false);
});
it("reads the maximum height and the gap it is given", () => {
// Room below: 800 - 730 - 10 = 60, under 100; room above: 700 - 10 = 690.
expect(
placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 100, gap: 10 }),
).toEqual({ left: 50, width: 200, openUpward: true, bottom: 110 });
// Room below: 60, enough for a list of 50 px.
expect(
placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 50, gap: 10 }),
).toEqual({ left: 50, width: 200, openUpward: false, top: 740 });
});
});
describe("samePlacement", () => {
const below: ListboxPlacement = { left: 50, width: 200, openUpward: false, top: 134 };
it("tells an unchanged position apart from a moved one", () => {
expect(samePlacement({ ...below }, below)).toBe(true);
expect(samePlacement(null, below)).toBe(false);
expect(samePlacement({ ...below, top: 135 }, below)).toBe(false);
expect(samePlacement({ ...below, left: 51 }, below)).toBe(false);
expect(samePlacement({ ...below, width: 201 }, below)).toBe(false);
expect(
samePlacement({ left: 50, width: 200, openUpward: true, bottom: 134 }, below),
).toBe(false);
});
});
/**
* 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.
*/
function fakeTarget() {
const listeners: Array<{ type: string; fn: (event: Event) => void; capture: boolean }> = [];
const target: ListenerTarget = {
addEventListener: (type, fn, capture = false) => {
listeners.push({ type, fn, capture });
},
removeEventListener: (type, fn, capture = false) => {
const at = listeners.findIndex((l) => l.type === type && l.fn === fn && l.capture === capture);
if (at >= 0) listeners.splice(at, 1);
},
};
const fire = (type: string, eventTarget: unknown = null) => {
for (const l of [...listeners]) if (l.type === type) l.fn({ target: eventTarget } as unknown as Event);
};
return { target, listeners, fire };
}
/** A node of the list: it contains itself and its options. */
const option = { id: "option" };
const listNode = { contains: (node: unknown) => node === listNode || node === option } as unknown as Node;
const outside = { id: "a scrolling container" };
describe("followAnchor", () => {
it("listens to scroll in the capture phase, since scroll does not bubble, and to resize", () => {
const { target, listeners } = fakeTarget();
followAnchor(target, vi.fn(), () => listNode);
expect(listeners.map(({ type, capture }) => ({ type, capture }))).toEqual([
{ type: "scroll", capture: true },
{ type: "resize", capture: false },
]);
});
it("moves the list on a scroll of the page or of a container, and on a resize", () => {
const { target, fire } = fakeTarget();
const onMove = vi.fn();
followAnchor(target, onMove, () => listNode);
fire("scroll", outside);
expect(onMove).toHaveBeenCalledTimes(1);
fire("resize");
expect(onMove).toHaveBeenCalledTimes(2);
});
it("does not move the list on a scroll of the list itself", () => {
const { target, fire } = fakeTarget();
const onMove = vi.fn();
followAnchor(target, onMove, () => listNode);
fire("scroll", listNode);
fire("scroll", option);
expect(onMove).not.toHaveBeenCalled();
// Before the list is in the page, every scroll counts.
const early = fakeTarget();
const onEarlyMove = vi.fn();
followAnchor(early.target, onEarlyMove, () => null);
early.fire("scroll", outside);
expect(onEarlyMove).toHaveBeenCalledTimes(1);
});
it("removes exactly its own listeners, capture flag included", () => {
const { target, listeners, fire } = fakeTarget();
const onMove = vi.fn();
const stop = followAnchor(target, onMove, () => listNode);
stop();
expect(listeners).toEqual([]);
fire("scroll", outside);
fire("resize");
expect(onMove).not.toHaveBeenCalled();
});
it("leaves another open list's listeners in place", () => {
const { target, listeners, fire } = fakeTarget();
const first = vi.fn();
const second = vi.fn();
const stopFirst = followAnchor(target, first, () => listNode);
followAnchor(target, second, () => listNode);
stopFirst();
expect(listeners).toHaveLength(2);
fire("scroll", outside);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,105 +0,0 @@
// placeListbox — where the open list of a combobox goes (#429).
//
// `CategoryCombobox` renders its open list in a portal on `document.body`,
// with `position: fixed`, so no scrolling container clips it: the body of the
// split window, the wrapper of the Transactions table, a modal. What does not
// need a DOM lives here, so vitest's node environment can pin it: the
// placement rule, and the listeners that keep the list on its input.
/** Maximum height of the list, in pixels (Tailwind's `max-h-60`). */
export const LISTBOX_MAX_HEIGHT = 240;
/** Gap between the input and the list, in pixels (the former `mt-1`). */
export const LISTBOX_GAP = 4;
/** What the placement reads of the input: a `DOMRect` fits. */
export interface ListboxAnchor {
top: number;
bottom: number;
left: number;
width: number;
}
/**
* Fixed position of the list, in viewport pixels, as wide as the input.
* Below the input, `top` is the list's top edge. Above it, `bottom` is the
* distance from the viewport's bottom edge to the list's bottom edge, so a
* list shorter than its maximum height still ends right above the input.
*/
export interface ListboxPlacement {
left: number;
width: number;
openUpward: boolean;
top?: number;
bottom?: number;
}
export interface PlaceListboxInput {
anchor: ListboxAnchor;
viewportHeight: number;
maxHeight?: number;
gap?: number;
}
/**
* Under the input, unless the list does not fit below and does fit above:
* then above. With room on neither side, it stays below, its usual place.
*/
export function placeListbox({
anchor,
viewportHeight,
maxHeight = LISTBOX_MAX_HEIGHT,
gap = LISTBOX_GAP,
}: PlaceListboxInput): ListboxPlacement {
const spaceBelow = viewportHeight - anchor.bottom - gap;
const spaceAbove = anchor.top - gap;
const base = { left: anchor.left, width: anchor.width };
if (spaceBelow < maxHeight && spaceAbove >= maxHeight) {
return { ...base, openUpward: true, bottom: viewportHeight - anchor.top + gap };
}
return { ...base, openUpward: false, top: anchor.bottom + gap };
}
/** Same position: a scroll that does not move the input renders nothing again. */
export function samePlacement(a: ListboxPlacement | null, b: ListboxPlacement): boolean {
return (
a !== null &&
a.left === b.left &&
a.width === b.width &&
a.openUpward === b.openUpward &&
a.top === b.top &&
a.bottom === b.bottom
);
}
/** 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;
removeEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void;
}
/**
* Keeps the list on its input while it is open: `onMove` runs on every resize
* and on every scroll, of the page or of any scrolling container (a modal's
* body, the page's main area), hence the capture phase, since `scroll` does
* not bubble. A scroll of the list itself is skipped: its options moving do
* not move the input. Returns the cleanup, which removes exactly these
* listeners, capture flag included.
*/
export function followAnchor(
target: ListenerTarget,
onMove: () => void,
list: () => Pick<Node, "contains"> | null,
): () => void {
const onScroll = (event: Event) => {
if (list()?.contains(event.target as Node | null)) return;
onMove();
};
const onResize = () => onMove();
target.addEventListener("scroll", onScroll, true);
target.addEventListener("resize", onResize);
return () => {
target.removeEventListener("scroll", onScroll, true);
target.removeEventListener("resize", onResize);
};
}

View file

@ -10,7 +10,6 @@ vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nM
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable"; import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
import type { Category, TransactionRow } from "../../shared/types"; import type { Category, TransactionRow } from "../../shared/types";
import type { SplitAccess } from "../../shared/splitGate"; import type { SplitAccess } from "../../shared/splitGate";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
const HIGHLIGHT = 'data-onboarding-highlight="true"'; const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1; const count = (html: string, needle: string) => html.split(needle).length - 1;
@ -47,12 +46,7 @@ const row = (
/** The guided entry: uncategorized rows only. */ /** The guided entry: uncategorized rows only. */
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")]; const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
const markup = ( const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess: SplitAccess = "open") =>
rows: TransactionRow[],
onboardingTarget?: boolean,
splitAccess: SplitAccess = "open",
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>,
) =>
renderToStaticMarkup( renderToStaticMarkup(
<TransactionTable <TransactionTable
rows={rows} rows={rows}
@ -66,7 +60,6 @@ const markup = (
onSaveSplit={vi.fn(async () => undefined)} onSaveSplit={vi.fn(async () => undefined)}
onDeleteSplit={vi.fn(async () => undefined)} onDeleteSplit={vi.fn(async () => undefined)}
splitAccess={splitAccess} splitAccess={splitAccess}
linkedTransfersByTxId={linkedTransfersByTxId}
onboardingTarget={onboardingTarget} onboardingTarget={onboardingTarget}
/>, />,
); );
@ -137,22 +130,3 @@ describe("TransactionTable — the Répartition button (#369)", () => {
expect(buttons[0]).toContain("text-orange-500"); expect(buttons[0]).toContain("text-orange-500");
}); });
}); });
describe("TransactionTable — texts that go through t (#437)", () => {
it("names the transfer icon through t, on the linked row only", () => {
const links = new Map<number, LinkedTransferTooltipRow[]>([
[2, [{ transaction_id: 2, account_id: 5, account_name: "CELI", direction: "in" }]],
]);
const html = markup(UNCATEGORIZED, undefined, "open", links);
const icon = 'aria-label="t(transactions.transferIcon.ariaLabel)"';
expect(count(html, icon)).toBe(1);
expect(bodyRows(html)[1]).toContain(icon);
});
it("names every row's notes button through t", () => {
// The textarea that shows the same key renders for an expanded row only
// (internal state): TransactionTable.wiring.test.ts opens one.
const html = markup(UNCATEGORIZED);
expect(count(html, 'title="t(transactions.notes.placeholder)"')).toBe(UNCATEGORIZED.length);
});
});

View file

@ -1,13 +1,11 @@
// TransactionTable — what the Répartition button opens (#369), and the notes // TransactionTable — what the Répartition button opens (#369).
// row its notes button opens (#437).
// //
// A static render never shows a window: they open on a click, through the // A static render never shows a window: they open on a click, through the
// table's own state. React's hooks come from the shared harness // table's own state. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the table runs as a plain function, its // (src/test/hookHarness.ts, #409): the table runs as a plain function, its
// state survives from one call to the next as between two renders of a mounted // state survives from one call to the next as between two renders of a mounted
// component, and the test clicks through the props the table hands to each // component, and the test clicks through the props the table hands to each
// SplitButton (or to a row's notes button), then renders again to read what // SplitButton, then renders again to read what opened.
// opened.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
@ -142,18 +140,3 @@ describe("TransactionTable — the Répartition button's wiring (#369)", () => {
expect(splitWindows(upgraded)).toHaveLength(0); expect(splitWindows(upgraded)).toHaveLength(0);
}); });
}); });
describe("TransactionTable — the notes row (#437)", () => {
/** The rows' notes buttons, named by their title (pinned in TransactionTable.render.test.tsx). */
const notesButtons = (tree: ReactNode) =>
find(tree, "button").filter((b) => b.props.title === "t(transactions.notes.placeholder)");
it("a row's notes button opens one textarea, named through t", () => {
expect(find(render("open"), "textarea")).toHaveLength(0);
(notesButtons(render("open"))[1].props.onClick as () => void)();
const opened = render("open");
const areas = find(opened, "textarea");
expect(areas).toHaveLength(1);
expect(areas[0].props.placeholder).toBe("t(transactions.notes.placeholder)");
});
});

View file

@ -1,85 +0,0 @@
// useAdjustments — the categories an adjustment line can pick (#429, D2).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its mount effect. The services are mocked; the base answers a raw
// query with every active category, so a hook that read the base itself,
// instead of the Transactions list, would show it.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const { getAllCategories, getAllAdjustments, select } = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllAdjustments: vi.fn(),
select: vi.fn(),
}));
vi.mock("../services/transactionService", () => ({ getAllCategories }));
vi.mock("../services/adjustmentService", () => ({
getAllAdjustments,
getEntriesByAdjustmentId: vi.fn(),
createAdjustment: vi.fn(),
updateAdjustment: vi.fn(),
deleteAdjustment: vi.fn(),
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
}));
vi.mock("../services/db", () => ({ getDb: vi.fn(async () => ({ select })) }));
import { hookHarness } from "../test/hookHarness";
import { useAdjustments } from "./useAdjustments";
import type { Category } from "../shared/types";
const harness = hookHarness();
const category = (id: number, name: string, isInputable: boolean): Category => ({
id,
name,
type: "expense",
is_active: true,
is_inputable: isInputable,
sort_order: id,
created_at: "",
});
/** The Transactions list: active and open to input. */
const INPUTABLE = [category(1, "Épicerie", true), category(2, "Loyer", true)];
/** Every active category, a parent hidden from input included. */
const ACTIVE = [category(3, "Alimentation", false), ...INPUTABLE];
/** Lets the loaders' promises settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
beforeEach(() => {
harness.clear();
getAllCategories.mockReset();
getAllCategories.mockResolvedValue(INPUTABLE);
getAllAdjustments.mockReset();
getAllAdjustments.mockResolvedValue([]);
select.mockReset();
select.mockResolvedValue(ACTIVE);
});
describe("useAdjustments — categories of a line (#429, D2)", () => {
it("offers the Transactions list, active and open to input, not every active category", async () => {
harness.render(useAdjustments);
harness.commit();
await flush();
const { state } = harness.render(useAdjustments);
expect(state.categories).toBe(INPUTABLE);
expect(getAllCategories).toHaveBeenCalledTimes(1);
expect(select).not.toHaveBeenCalled();
});
it("reports a failed read as the page's error, with no category", async () => {
getAllCategories.mockRejectedValue(new Error("database is locked"));
harness.render(useAdjustments);
harness.commit();
await flush();
const { state } = harness.render(useAdjustments);
expect(state.categories).toEqual([]);
expect(state.error).toBe("database is locked");
});
});

View file

@ -11,7 +11,7 @@ import {
updateEntry, updateEntry,
deleteEntry, deleteEntry,
} from "../services/adjustmentService"; } from "../services/adjustmentService";
import { getAllCategories } from "../services/transactionService"; import { getDb } from "../services/db";
export interface AdjustmentFormData { export interface AdjustmentFormData {
name: string; name: string;
@ -123,14 +123,12 @@ export function useAdjustments() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
// The categories a line can pick, the list of the Transactions page: active
// and open to input (#429, D2). The page passes it to the adjustment form and
// to the split window, both pickers; the detail view names a line's
// category from the entries' own join, so a line saved on a category hidden
// from input keeps its name there, and its value until changed by hand.
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
try { try {
const rows = await getAllCategories(); const db = await getDb();
const rows = await db.select<Category[]>(
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
);
dispatch({ type: "SET_CATEGORIES", payload: rows }); dispatch({ type: "SET_CATEGORIES", payload: rows });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });

View file

@ -322,45 +322,6 @@ describe("buildBudgetYearRows — depth-first emission (issue #424)", () => {
}); });
}); });
describe("buildBudgetYearRows — a category whose parent is deleted or gone (#393)", () => {
// The budget reads active categories only: a deleted (inactive) parent is
// simply absent from the list, like a parent that no longer exists.
it("shows a leaf whose parent is absent as a root, with its budget amounts", () => {
const rows = buildBudgetYearRows(
[...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 99, sort_order: 5 })],
[entry(7, 40, 1), entry(7, 60, 2)],
[{ category_id: 7, actual: -80 }],
);
const gifts = rows.find((r) => r.category_id === 7);
expect(gifts).toMatchObject({ parent_id: null, depth: 0, is_parent: false, annual: 100, previousYearTotal: -80 });
expectDepthFirst(rows);
});
it("shows a parent whose own parent is absent as a root group, its children under it", () => {
const rows = buildBudgetYearRows(
[
...CATEGORIES,
cat(7, "Gifts", "expense", { parent_id: 99, is_inputable: false, sort_order: 5 }),
cat(8, "Flowers", "expense", { parent_id: 7, sort_order: 1 }),
],
[entry(8, 25)],
[],
);
expect(rows.find((r) => r.category_id === 7)).toMatchObject({ parent_id: null, depth: 0, is_parent: true, annual: 25 });
expect(rows.find((r) => r.category_id === 8)).toMatchObject({ parent_id: 7, depth: 1, annual: 25 });
expectDepthFirst(rows);
});
it("still hides the members of a parent_id cycle, which « Vérifier la hiérarchie » repairs", () => {
const rows = buildBudgetYearRows(
[...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 8 }), cat(8, "Flowers", "expense", { parent_id: 7 })],
[],
[],
);
expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false);
});
});
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => { describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []); const rows = buildBudgetYearRows(CATEGORIES, [], []);

View file

@ -12,7 +12,6 @@ import {
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources, getTransactionCount } from "../services/transactionService"; import { getAllImportSources, getTransactionCount } from "../services/transactionService";
import { displaysAsRoot } from "../utils/categoryHierarchy";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export interface BudgetState { export interface BudgetState {
@ -242,11 +241,8 @@ export function buildBudgetYearRows(
return [subtotal, ...gcRows]; return [subtotal, ...gcRows];
} }
// Identify top-level parents and standalone leaves. A category whose parent // Identify top-level parents and standalone leaves
// is deactivated or gone (not in the active list) stands at the top level, const topLevel = allCategories.filter((c) => !c.parent_id);
// as in the tree and the pickers, instead of vanishing from the grid (#393).
const activeIds = new Set(allCategories.map((c) => c.id));
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
for (const cat of topLevel) { for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || []; const children = childrenByParent.get(cat.id) || [];
@ -428,23 +424,17 @@ export function useBudget() {
// Load once on mount: the import-source list for the FilterPanel's account // Load once on mount: the import-source list for the FilterPanel's account
// checkboxes (same query as the Trends/Compare source filters — see // checkboxes (same query as the Trends/Compare source filters — see
// useTrends.ts) and the profile-wide transaction count (#353). Each read // useTrends.ts) and the profile-wide transaction count (#353).
// stands alone (#384): a failed count no longer leaves the account filter
// empty, and leaves `totalInDb` null, so the grid, never the empty state.
// The first failure is the page's error.
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const [accounts, total] = await Promise.allSettled([ try {
getAllImportSources(), const [accounts, total] = await Promise.all([
getTransactionCount(), getAllImportSources(),
]); getTransactionCount(),
if (accounts.status === "fulfilled") dispatch({ type: "SET_ACCOUNTS", payload: accounts.value }); ]);
if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value }); dispatch({ type: "SET_ACCOUNTS", payload: accounts });
const failed = [accounts, total].find( dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
(r): r is PromiseRejectedResult => r.status === "rejected" } catch (e) {
);
if (failed) {
const e: unknown = failed.reason;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
})(); })();

View file

@ -1,97 +0,0 @@
// useBudget — the reads of the mount stand alone (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its effects (the year's budget, then the accounts and the count). The
// services and the reports period are mocked. What is pinned: a failed count
// no longer drops the accounts of the filter, leaves `totalInDb` unknown (the
// grid, never the "import first" card), and the first failure, in the order
// accounts, count, is the page's error.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const services = vi.hoisted(() => ({
getAllImportSources: vi.fn(),
getTransactionCount: vi.fn(),
}));
vi.mock("../services/transactionService", () => services);
vi.mock("../services/budgetService", () => ({
getAllActiveCategories: vi.fn(async () => []),
getBudgetEntriesForYear: vi.fn(async () => []),
getActualTotalsForYear: vi.fn(async () => []),
getAllTemplates: vi.fn(async () => []),
upsertBudgetEntry: vi.fn(),
upsertBudgetEntriesForYear: vi.fn(),
saveAsTemplate: vi.fn(),
applyTemplate: vi.fn(),
deleteTemplate: vi.fn(),
}));
// One array for every render: a new one would read as a changed filter.
const { NO_ACCOUNT } = vi.hoisted(() => ({ NO_ACCOUNT: [] as number[] }));
vi.mock("./useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: NO_ACCOUNT, setAccountIds: () => undefined }),
}));
import { hookHarness } from "../test/hookHarness";
import { useBudget, showsEmptyState } from "./useBudget";
import type { ImportSource } from "../shared/types";
const harness = hookHarness();
const ACCOUNTS = [{ id: 7, name: "Desjardins" }] as ImportSource[];
/** Lets the mount reads settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
async function mount() {
harness.render(useBudget);
harness.commit();
await flush();
return harness.render(useBudget).state;
}
beforeEach(() => {
harness.clear();
services.getAllImportSources.mockReset().mockResolvedValue(ACCOUNTS);
services.getTransactionCount.mockReset().mockResolvedValue(0);
});
describe("useBudget — the reads of the mount (#384)", () => {
it("both succeed: the accounts and the count, no error", async () => {
const state = await mount();
expect(state.accounts).toBe(ACCOUNTS);
expect(state.totalInDb).toBe(0);
expect(state.error).toBeNull();
expect(showsEmptyState(state)).toBe(true);
});
it("a failed count still loads the accounts, and leaves the count unknown", async () => {
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.accounts).toBe(ACCOUNTS);
expect(state.totalInDb).toBeNull();
expect(state.error).toBe("count failed");
// Unknown count: the grid, never the "import first" card.
expect(showsEmptyState(state)).toBe(false);
});
it("failed accounts still load the count", async () => {
services.getAllImportSources.mockRejectedValue(new Error("accounts failed"));
services.getTransactionCount.mockResolvedValue(41);
const state = await mount();
expect(state.accounts).toEqual([]);
expect(state.totalInDb).toBe(41);
expect(state.error).toBe("accounts failed");
});
it("both fail: the accounts' failure, the first one, is the error", async () => {
services.getAllImportSources.mockRejectedValue(new Error("accounts failed"));
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.totalInDb).toBeNull();
expect(state.error).toBe("accounts failed");
});
});

View file

@ -1,231 +0,0 @@
// useCategories — the hierarchy check and its refusals (#393).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, `commit()` runs
// its mount effect. The service is mocked, except the refusal class and its
// i18n key, which the hook must recognize by `instanceof`.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
vi.mock("../services/categoryService", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/categoryService")>();
return {
CategoryHierarchyError: actual.CategoryHierarchyError,
categoryHierarchyErrorKey: actual.categoryHierarchyErrorKey,
getAllCategoriesWithCounts: vi.fn(),
createCategory: vi.fn(),
updateCategory: vi.fn(),
deactivateCategory: vi.fn(),
getCategoryUsageCount: vi.fn(),
getChildrenUsageCount: vi.fn(),
getKeywordsByCategoryId: vi.fn(async () => []),
createKeyword: vi.fn(),
updateKeyword: vi.fn(),
deactivateKeyword: vi.fn(),
reinitializeCategories: vi.fn(),
hasDuplicateSortOrders: vi.fn(async () => false),
fixDuplicateSortOrders: vi.fn(),
getNextSortOrder: vi.fn(),
updateCategorySortOrders: vi.fn(),
findOrphanCategories: vi.fn(),
reattachCategory: vi.fn(),
};
});
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
import { hookHarness } from "../test/hookHarness";
import {
useCategories,
categoriesErrorOf,
categoriesErrorText,
reattachLogLine,
} from "./useCategories";
import {
CategoryHierarchyError,
findOrphanCategories,
getAllCategoriesWithCounts,
reattachCategory,
updateCategory,
} from "../services/categoryService";
import { logInfo } from "../services/logService";
import { markedT } from "../test/i18nMock";
import type { OrphanCategory } from "../utils/categoryHierarchy";
const harness = hookHarness();
const ROWS = [
{ id: 1, name: "Logement", parent_id: null, color: null, icon: null, type: "expense", is_active: true, is_inputable: false, sort_order: 1, keyword_count: 0, i18n_key: null },
{ id: 10, name: "Loyer", parent_id: 1, color: null, icon: null, type: "expense", is_active: true, is_inputable: true, sort_order: 1, keyword_count: 0, i18n_key: null },
];
const CYCLE: OrphanCategory = { id: 3, reason: "cycle" };
/** Lets the hook's promises settle. */
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
/** Mounts the hook and lets its first load settle. */
async function mount() {
harness.render(useCategories);
harness.commit();
await flush();
return harness.render(useCategories);
}
const FORM = { name: "Logement", type: "expense" as const, color: "#4A90A4", parent_id: 10, is_inputable: false, sort_order: 1 };
beforeEach(() => {
harness.clear();
vi.mocked(getAllCategoriesWithCounts).mockReset();
vi.mocked(getAllCategoriesWithCounts).mockResolvedValue(ROWS as never);
vi.mocked(updateCategory).mockReset();
vi.mocked(findOrphanCategories).mockReset();
vi.mocked(reattachCategory).mockReset();
vi.mocked(logInfo).mockReset();
});
describe("categoriesErrorOf / categoriesErrorText (#393)", () => {
it("names a hierarchy refusal by the i18n key of its code, never by its message", () => {
const error = categoriesErrorOf(new CategoryHierarchyError("cycle", "raw text"));
expect(error).toEqual({ key: "categories.hierarchy.errors.cycle" });
expect(categoriesErrorText(error, markedT)).toBe("t(categories.hierarchy.errors.cycle)");
});
it("keeps the message of any other failure", () => {
const error = categoriesErrorOf(new Error("database is locked"));
expect(error).toEqual({ message: "database is locked" });
expect(categoriesErrorText(error, markedT)).toBe("database is locked");
expect(categoriesErrorOf("boom")).toEqual({ message: "boom" });
});
});
describe("useCategories — saving the form (#393)", () => {
it("shows a refused parent by its translated code, and keeps the form open", async () => {
vi.mocked(updateCategory).mockRejectedValue(new CategoryHierarchyError("cycle"));
let api = await mount();
await api.selectCategory(1);
api = harness.render(useCategories);
api.startEditing();
api = harness.render(useCategories);
await api.saveCategory(FORM);
const { state } = harness.render(useCategories);
expect(updateCategory).toHaveBeenCalledWith(1, FORM);
expect(state.error).toEqual({ key: "categories.hierarchy.errors.cycle" });
expect(state.isSaving).toBe(false);
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();
await api.selectCategory(1);
api = harness.render(useCategories);
await api.saveCategory(FORM);
expect(harness.render(useCategories).state.error).toEqual({ message: "database is locked" });
});
});
describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
it("opens the window while the check runs, then lists the orphans", async () => {
let resolve: (orphans: OrphanCategory[]) => void = () => undefined;
vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r)));
const api = await mount();
expect(api.state.hierarchyCheck).toBeNull();
const done = api.checkHierarchy();
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: null, error: null });
resolve([CYCLE]);
await done;
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [CYCLE], error: null });
});
it("shows a check that failed in the window", async () => {
vi.mocked(findOrphanCategories).mockRejectedValue(new Error("database is locked"));
const api = await mount();
await api.checkHierarchy();
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({
orphans: null,
error: { message: "database is locked" },
});
});
it("reattaches, writes one Journal line without any name, then reloads the list and the tree", async () => {
vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]);
vi.mocked(reattachCategory).mockResolvedValue(3);
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1);
await api.reattachOrphan(CYCLE, 1);
expect(reattachCategory).toHaveBeenCalledWith(3, 1);
expect(logInfo).toHaveBeenCalledTimes(1);
const line = vi.mocked(logInfo).mock.calls[0][0] as string;
expect(line).toBe(reattachLogLine("cycle", 3));
expect(line).toContain("3 categories");
for (const row of ROWS) expect(line).not.toContain(row.name);
expect(findOrphanCategories).toHaveBeenCalledTimes(2);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck).toEqual({ orphans: [], error: null });
expect(state.isSaving).toBe(false);
});
it("reloads the tree even when the check after a committed reattachment fails", async () => {
vi.mocked(findOrphanCategories)
.mockResolvedValueOnce([CYCLE])
.mockRejectedValueOnce(new Error("database is locked"));
vi.mocked(reattachCategory).mockResolvedValue(2);
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
await api.reattachOrphan(CYCLE, null);
expect(reattachCategory).toHaveBeenCalledWith(3, null);
expect(logInfo).toHaveBeenCalledTimes(1);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck?.error).toEqual({ message: "database is locked" });
expect(state.isSaving).toBe(false);
});
it("shows a refused reattachment in the window by its translated code, with no Journal line", async () => {
vi.mocked(findOrphanCategories).mockResolvedValue([CYCLE]);
vi.mocked(reattachCategory).mockRejectedValue(new CategoryHierarchyError("parent_has_transactions"));
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
await api.reattachOrphan(CYCLE, 1);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck).toEqual({
orphans: [CYCLE],
error: { key: "categories.hierarchy.errors.parent_has_transactions" },
});
expect(state.error).toBeNull();
expect(state.isSaving).toBe(false);
expect(logInfo).not.toHaveBeenCalled();
});
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)));
let api = await mount();
const done = api.checkHierarchy();
api = harness.render(useCategories);
api.closeHierarchyCheck();
resolve([CYCLE]);
await done;
expect(harness.render(useCategories).state.hierarchyCheck).toBeNull();
});
});
describe("reattachLogLine (#393)", () => {
it("gives the reason code and the count, singular or plural", () => {
expect(reattachLogLine("inactive_parent", 1)).toBe(
"Categories: orphan category reattached (inactive_parent), 1 category back in the hierarchy",
);
expect(reattachLogLine("cycle", 4)).toBe(
"Categories: orphan category reattached (cycle), 4 categories back in the hierarchy",
);
});
});

View file

@ -20,37 +20,7 @@ import {
fixDuplicateSortOrders, fixDuplicateSortOrders,
getNextSortOrder, getNextSortOrder,
updateCategorySortOrders, updateCategorySortOrders,
findOrphanCategories,
reattachCategory,
CategoryHierarchyError,
categoryHierarchyErrorKey,
} from "../services/categoryService"; } from "../services/categoryService";
import { logInfo } from "../services/logService";
import type { OrphanCategory } from "../utils/categoryHierarchy";
/**
* What a failed action shows: a refusal of the hierarchy rules by the i18n key
* of its code (#393), any other failure by its message.
*/
export type CategoriesError = { key: string } | { message: string };
export function categoriesErrorOf(e: unknown): CategoriesError {
if (e instanceof CategoryHierarchyError) return { key: categoryHierarchyErrorKey(e.code) };
return { message: e instanceof Error ? e.message : String(e) };
}
/** The text of a categories error: the translation of its key, or its message. */
export function categoriesErrorText(error: CategoriesError, t: (key: string) => string): string {
return "key" in error ? t(error.key) : error.message;
}
/** The « Vérifier la hiérarchie » window (#393), while it is open. */
export interface HierarchyCheckState {
/** The orphan categories, one per group to reattach; `null` while the check runs. */
orphans: OrphanCategory[] | null;
/** A refused reattachment, or a check that failed. */
error: CategoriesError | null;
}
interface CategoriesState { interface CategoriesState {
categories: CategoryTreeNode[]; categories: CategoryTreeNode[];
@ -59,29 +29,22 @@ interface CategoriesState {
keywords: Keyword[]; keywords: Keyword[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: CategoriesError | null; error: string | null;
editingCategory: CategoryFormData | null; editingCategory: CategoryFormData | null;
isCreating: boolean; isCreating: boolean;
/** `null` while the window is closed. */
hierarchyCheck: HierarchyCheckState | null;
} }
type CategoriesAction = type CategoriesAction =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: CategoriesError | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } } | { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
| { type: "SET_TREE"; payload: CategoryTreeNode[] } | { type: "SET_TREE"; payload: CategoryTreeNode[] }
| { type: "SELECT_CATEGORY"; payload: number | null } | { type: "SELECT_CATEGORY"; payload: number | null }
| { type: "SET_KEYWORDS"; payload: Keyword[] } | { type: "SET_KEYWORDS"; payload: Keyword[] }
| { type: "START_CREATING" } | { type: "START_CREATING" }
| { type: "START_EDITING"; payload: CategoryFormData } | { type: "START_EDITING"; payload: CategoryFormData }
| { type: "CANCEL_EDITING" } | { type: "CANCEL_EDITING" };
| { type: "OPEN_HIERARCHY_CHECK" }
| { type: "SET_ORPHANS"; payload: OrphanCategory[] }
| { type: "START_REATTACH" }
| { type: "SET_HIERARCHY_ERROR"; payload: CategoriesError }
| { type: "CLOSE_HIERARCHY_CHECK" };
const initialState: CategoriesState = { const initialState: CategoriesState = {
categories: [], categories: [],
@ -93,16 +56,9 @@ const initialState: CategoriesState = {
error: null, error: null,
editingCategory: null, editingCategory: null,
isCreating: false, isCreating: false,
hierarchyCheck: null,
}; };
/** function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
* The tree of the Categories page: each category under its parent, a category
* whose parent is not in the list at the root. A category caught in a
* `parent_id` cycle is under a parent and never at the root, so no root leads
* to it: « Vérifier la hiérarchie » finds it (#393).
*/
export function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
const map = new Map<number, CategoryTreeNode>(); const map = new Map<number, CategoryTreeNode>();
const roots: CategoryTreeNode[] = []; const roots: CategoryTreeNode[] = [];
@ -163,43 +119,11 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
return { ...state, isCreating: false, editingCategory: action.payload }; return { ...state, isCreating: false, editingCategory: action.payload };
case "CANCEL_EDITING": case "CANCEL_EDITING":
return { ...state, editingCategory: null, isCreating: false }; return { ...state, editingCategory: null, isCreating: false };
case "OPEN_HIERARCHY_CHECK":
return { ...state, hierarchyCheck: { orphans: null, error: null } };
case "SET_ORPHANS":
// A check that ends after the window closed changes nothing.
return state.hierarchyCheck
? { ...state, hierarchyCheck: { ...state.hierarchyCheck, orphans: action.payload } }
: state;
case "START_REATTACH":
return {
...state,
isSaving: true,
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: null } : null,
};
case "SET_HIERARCHY_ERROR":
return {
...state,
isSaving: false,
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: action.payload } : null,
};
case "CLOSE_HIERARCHY_CHECK":
return { ...state, hierarchyCheck: null };
default: default:
return state; return state;
} }
} }
/**
* The Journal line of a reattachment (#393): the reason code and the number of
* categories put back in place, never a category name.
*/
export function reattachLogLine(reason: OrphanCategory["reason"], restored: number): string {
return (
`Categories: orphan category reattached (${reason}), ` +
`${restored} ${restored === 1 ? "category" : "categories"} back in the hierarchy`
);
}
export function useCategories() { export function useCategories() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
@ -226,7 +150,7 @@ export function useCategories() {
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } }); dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } });
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, []); }, []);
@ -241,7 +165,7 @@ export function useCategories() {
const kws = await getKeywordsByCategoryId(id); const kws = await getKeywordsByCategoryId(id);
dispatch({ type: "SET_KEYWORDS", payload: kws }); dispatch({ type: "SET_KEYWORDS", payload: kws });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
} }
}, []); }, []);
@ -288,7 +212,7 @@ export function useCategories() {
} }
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, },
[state.isCreating, state.selectedCategoryId, loadCategories, selectCategory] [state.isCreating, state.selectedCategoryId, loadCategories, selectCategory]
@ -313,7 +237,7 @@ export function useCategories() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
return { blocked: false, count: 0 }; return { blocked: false, count: 0 };
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
return { blocked: false, count: 0 }; return { blocked: false, count: 0 };
} }
}, },
@ -329,7 +253,7 @@ export function useCategories() {
await loadCategories(); await loadCategories();
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, [loadCategories]); }, [loadCategories]);
@ -403,53 +327,12 @@ export function useCategories() {
[state.tree, loadCategories] [state.tree, loadCategories]
); );
/** Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). */
const checkHierarchy = useCallback(async () => {
dispatch({ type: "OPEN_HIERARCHY_CHECK" });
try {
const orphans = await findOrphanCategories();
dispatch({ type: "SET_ORPHANS", payload: orphans });
} catch (e) {
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
}
}, []);
/**
* 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.
*/
const reattachOrphan = useCallback(
async (orphan: OrphanCategory, parentId: number | null) => {
dispatch({ type: "START_REATTACH" });
try {
const restored = await reattachCategory(orphan.id, parentId);
logInfo(reattachLogLine(orphan.reason, restored));
try {
const orphans = await findOrphanCategories();
dispatch({ type: "SET_ORPHANS", payload: orphans });
} finally {
await loadCategories();
}
dispatch({ type: "SET_SAVING", payload: false });
} catch (e) {
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
}
},
[loadCategories]
);
const closeHierarchyCheck = useCallback(() => {
dispatch({ type: "CLOSE_HIERARCHY_CHECK" });
}, []);
const loadKeywords = useCallback(async (categoryId: number) => { const loadKeywords = useCallback(async (categoryId: number) => {
try { try {
const kws = await getKeywordsByCategoryId(categoryId); const kws = await getKeywordsByCategoryId(categoryId);
dispatch({ type: "SET_KEYWORDS", payload: kws }); dispatch({ type: "SET_KEYWORDS", payload: kws });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, []); }, []);
@ -461,7 +344,7 @@ export function useCategories() {
await loadKeywords(state.selectedCategoryId); await loadKeywords(state.selectedCategoryId);
await loadCategories(); await loadCategories();
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, },
[state.selectedCategoryId, loadKeywords, loadCategories] [state.selectedCategoryId, loadKeywords, loadCategories]
@ -475,7 +358,7 @@ export function useCategories() {
await loadKeywords(state.selectedCategoryId); await loadKeywords(state.selectedCategoryId);
} }
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, },
[state.selectedCategoryId, loadKeywords] [state.selectedCategoryId, loadKeywords]
@ -490,7 +373,7 @@ export function useCategories() {
await loadCategories(); await loadCategories();
} }
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, },
[state.selectedCategoryId, loadKeywords, loadCategories] [state.selectedCategoryId, loadKeywords, loadCategories]
@ -509,8 +392,5 @@ export function useCategories() {
removeKeyword, removeKeyword,
reinitializeCategories, reinitializeCategories,
moveCategory, moveCategory,
checkHierarchy,
reattachOrphan,
closeHierarchyCheck,
}; };
} }

View file

@ -1,176 +0,0 @@
// useImportWizard — "Create a sample folder", its banners and its Journal line (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, `commit()` runs
// the mount read of the import folder (and its first scan), and the test then
// calls `createSampleFolders` from the next render, as the page's button does.
// `invoke` answers per command; the Journal is the real log service.
//
// What is pinned: the success banner only after a rescan that went through
// (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).
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. */
answers: {} as Record<string, unknown[]>,
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string) => {
const queue = ipc.answers[command] ?? [];
if (queue.length === 0) throw new Error(`unexpected command ${command}`);
const answer = queue.shift();
if (answer instanceof Error || typeof answer === "string") throw answer;
return answer;
}),
}));
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../services/userPreferenceService", () => ({
getImportFolder: vi.fn(async () => ipc.importFolder),
setImportFolder: vi.fn(),
}));
vi.mock("../services/importSourceService", () => ({
getAllSources: vi.fn(async () => []),
getSourceByName: vi.fn(async () => null),
createSource: vi.fn(),
updateSource: vi.fn(),
}));
vi.mock("../services/importedFileService", () => ({
existsByHash: vi.fn(),
createImportedFile: vi.fn(),
updateFileStatus: vi.fn(),
getFilesBySourceId: vi.fn(async () => []),
}));
vi.mock("../services/importConfigTemplateService", () => ({
getAllTemplates: vi.fn(async () => []),
createTemplate: vi.fn(),
updateTemplate: vi.fn(),
deleteTemplate: vi.fn(),
}));
import { invoke } from "@tauri-apps/api/core";
import { hookHarness } from "../test/hookHarness";
import { useImportWizard } from "./useImportWizard";
import { clearLogs, getLogs, initLogCapture } from "../services/logService";
import type { ImportFolderScan } from "../shared/types";
const harness = hookHarness();
const IMPORT_FOLDER = ipc.importFolder;
const NAMES = ["Exemple Desjardins", "Exemple Tangerine"];
const EMPTY_SCAN: ImportFolderScan = { sources: [], root_files: [] };
const SAMPLE_SCAN: ImportFolderScan = {
sources: NAMES.map((name) => ({ folder_name: name, folder_path: `${IMPORT_FOLDER}/${name}`, files: [] })),
root_files: [],
};
/** Lets the hook's promises settle. */
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
/** Mounts the wizard on IMPORT_FOLDER (read, then scanned empty) and returns its next render. */
async function mount() {
harness.render(useImportWizard);
harness.commit();
await flush();
return harness.render(useImportWizard);
}
/** Clicks "Create a sample folder" and returns the state after it. */
async function createSamples() {
const wizard = await mount();
expect(wizard.state.importFolder).toBe(IMPORT_FOLDER);
await wizard.createSampleFolders();
return harness.render(useImportWizard).state;
}
const journal = () => getLogs().map((l) => `${l.level}: ${l.message}`);
const created = () =>
harness.dispatch.mock.calls.filter(([a]) => (a as { type: string }).type === "SET_SAMPLE_CREATED");
beforeEach(() => {
harness.clear();
vi.mocked(invoke).mockClear();
initLogCapture();
clearLogs();
ipc.answers = { scan_import_folder: [EMPTY_SCAN] };
});
afterEach(() => {
harness.unmount();
});
describe("createSampleFolders — the banners (#384)", () => {
it("rescan went through: the success banner names the folders, no error", async () => {
ipc.answers.create_sample_import_folders = [NAMES];
ipc.answers.scan_import_folder.push(SAMPLE_SCAN);
const state = await createSamples();
expect(state.sampleCreated).toEqual(NAMES);
expect(state.scannedSources).toBe(SAMPLE_SCAN.sources);
expect(state.error).toBeNull();
expect(state.isLoading).toBe(false);
});
it("rescan failed: the error banner alone, never the success one", async () => {
ipc.answers.create_sample_import_folders = [NAMES];
ipc.answers.scan_import_folder.push(new Error(`Folder does not exist: ${IMPORT_FOLDER}`));
const state = await createSamples();
expect(state.sampleCreated).toBeNull();
expect(created()).toEqual([]);
expect(state.error).toBe(`Folder does not exist: ${IMPORT_FOLDER}`);
// The folders were written all the same: the Journal says so, by name.
expect(journal()).toContain("info: [import] Sample folders created: Exemple Desjardins, Exemple Tangerine");
// The rescan's error carries the path: it stays on screen, out of the Journal.
expect(journal().join("\n")).not.toContain("Relevés");
});
});
describe("createSampleFolders — a refusal (#384, D2)", () => {
it("a sample folder already there: its key and name, no scan, no success banner", async () => {
ipc.answers.create_sample_import_folders = ["sample_folder_exists:Exemple Desjardins"];
const state = await createSamples();
expect(state.error).toBe("import.empty.sampleErrors.folderExists");
expect(state.errorValues).toEqual({ name: "Exemple Desjardins" });
expect(state.sampleCreated).toBeNull();
expect(state.isLoading).toBe(false);
// The mount's scan only: a refused creation is not rescanned.
expect(vi.mocked(invoke).mock.calls.filter(([c]) => c === "scan_import_folder")).toHaveLength(1);
});
it.each([
["import_folder_missing", "import.empty.sampleErrors.importFolderMissing"],
["invalid_date", "import.empty.sampleErrors.invalidDate"],
])("%s: its key, without values", async (code, key) => {
ipc.answers.create_sample_import_folders = [code];
const state = await createSamples();
expect(state.error).toBe(key);
expect(state.errorValues).toBeNull();
});
it("any other message is shown raw", async () => {
const raw = "Cannot write Exemple Desjardins/releve-2026-09.csv: Permission denied (os error 13)";
ipc.answers.create_sample_import_folders = [raw];
const state = await createSamples();
expect(state.error).toBe(raw);
expect(state.errorValues).toBeNull();
});
it.each([
"sample_folder_exists:Exemple Tangerine",
"import_folder_missing",
"invalid_date",
"Cannot create folder Exemple Desjardins: Permission denied (os error 13)",
])("the Journal gets the raw refusal as a warning, never the import folder (%s)", async (raw) => {
ipc.answers.create_sample_import_folders = [raw];
await createSamples();
expect(journal()).toEqual([`warn: [import] Sample folders not created: ${raw}`]);
expect(journal().join("\n")).not.toContain("Relevés");
expect(journal().join("\n")).not.toContain("/home/jean");
});
});

View file

@ -22,13 +22,10 @@ vi.mock("../services/db", () => {
}; };
}); });
import { readFileSync } from "fs";
import { resolve } from "path";
import { import {
wizardReducer, wizardReducer,
initialWizardState, initialWizardState,
formatLocalDate, formatLocalDate,
sampleFolderError,
type WizardAction, type WizardAction,
type WizardState, type WizardState,
} from "./useImportWizard"; } from "./useImportWizard";
@ -222,122 +219,4 @@ describe("wizardReducer — rootFiles (#427)", () => {
const after = wizardReducer(before, { type: "SET_ERROR", payload: "folder not found" }); const after = wizardReducer(before, { type: "SET_ERROR", payload: "folder not found" });
expect(after.rootFiles).toBe(before.rootFiles); expect(after.rootFiles).toBe(before.rootFiles);
}); });
it("choosing another folder empties the root files until its own scan lands (#446)", () => {
const before = reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) },
]);
const changed = wizardReducer(before, { type: "SET_IMPORT_FOLDER", payload: "/autre" });
// The warning would name "a.csv" of /releves under the name of /autre.
expect(changed.importFolder).toBe("/autre");
expect(changed.rootFiles).toEqual([]);
// Prior behaviour, kept: the sources of the last scan stay until the new one.
expect(changed.scannedSources).toBe(before.scannedSources);
const scanned = wizardReducer(changed, { type: "SET_SCANNED_SOURCES", payload: scan(["b.csv"], []) });
expect(scanned.rootFiles).toEqual(["b.csv"]);
expect(scanned.scannedSources).toEqual([]);
});
it("a scan error after a folder change names no root file of the previous folder (#446)", () => {
const after = reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) },
{ type: "SET_IMPORT_FOLDER", payload: "/autre" },
{ type: "SET_LOADING", payload: true },
{ type: "SET_ERROR", payload: "folder not found" },
]);
expect(after.rootFiles).toEqual([]);
});
});
describe("sampleFolderError — the refusals of create_sample_import_folders (#384, D2)", () => {
it("a sample folder already there: its key, with the folder's name", () => {
expect(sampleFolderError("sample_folder_exists:Exemple Desjardins")).toEqual({
key: "import.empty.sampleErrors.folderExists",
values: { name: "Exemple Desjardins" },
});
expect(sampleFolderError("sample_folder_exists:Exemple Tangerine")).toEqual({
key: "import.empty.sampleErrors.folderExists",
values: { name: "Exemple Tangerine" },
});
});
it("the import folder missing, the date unreadable: their keys, without values", () => {
expect(sampleFolderError("import_folder_missing")).toEqual({
key: "import.empty.sampleErrors.importFolderMissing",
});
expect(sampleFolderError("invalid_date")).toEqual({ key: "import.empty.sampleErrors.invalidDate" });
});
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)",
"Cannot create folder Exemple Tangerine: File exists (os error 17)",
"Cannot compute the previous month",
// The messages of the versions before #384, and codes bent out of shape.
"Folder already exists: Exemple Desjardins",
"Folder does not exist: /home/max/Relevés",
"sample_folder_exists:",
"sample_folder_exists",
"import_folder_missing: /home/max/Relevés",
"Invalid date 'x': input contains invalid characters",
"",
]) {
expect(sampleFolderError(raw), raw).toBeNull();
}
});
// The codes are Rust constants (`SAMPLE_ERROR_*` of fs_commands.rs): a code
// renamed or added there, with no mapping here, would reach the user raw.
// Read from the source like onboarding-samples.test.ts reads the seed. A PR
// that touches only `src-tauri/**` skips `check-frontend`: the drift then
// shows at the next frontend run.
it("maps every refusal code the Rust command declares", () => {
const rust = readFileSync(
resolve(import.meta.dirname, "../../src-tauri/src/commands/fs_commands.rs"),
"utf-8",
);
const codes = [...rust.matchAll(/^const SAMPLE_ERROR_[A-Z_]+: &str = "([a-z_]+)";$/gm)].map((m) => m[1]);
expect(codes.sort()).toEqual(["import_folder_missing", "invalid_date", "sample_folder_exists"]);
for (const code of codes) {
const raw = code === "sample_folder_exists" ? `${code}:Exemple Desjardins` : code;
expect(sampleFolderError(raw), code).not.toBeNull();
}
});
});
describe("wizardReducer — errorValues (#384)", () => {
it("starts with none", () => {
expect(initialWizardState.errorValues).toBeNull();
});
it("SET_ERROR sets the values with the key, and any later SET_ERROR drops them", () => {
const refused = wizardReducer(initialWizardState, {
type: "SET_ERROR",
payload: "import.empty.sampleErrors.folderExists",
values: { name: "Exemple Desjardins" },
});
expect(refused.error).toBe("import.empty.sampleErrors.folderExists");
expect(refused.errorValues).toEqual({ name: "Exemple Desjardins" });
const raw = wizardReducer(refused, { type: "SET_ERROR", payload: "folder not found" });
expect(raw.error).toBe("folder not found");
expect(raw.errorValues).toBeNull();
const cleared = wizardReducer(refused, { type: "SET_ERROR", payload: null });
expect(cleared.error).toBeNull();
expect(cleared.errorValues).toBeNull();
});
it("RESET drops them with the error", () => {
const refused = wizardReducer(initialWizardState, {
type: "SET_ERROR",
payload: "import.empty.sampleErrors.folderExists",
values: { name: "Exemple Desjardins" },
});
const after = wizardReducer(refused, { type: "RESET" });
expect(after.error).toBeNull();
expect(after.errorValues).toBeNull();
});
}); });

View file

@ -61,7 +61,7 @@ import {
mapRow, mapRow,
ROW_ERROR_KEYS, ROW_ERROR_KEYS,
} from "../utils/importFormat"; } from "../utils/importFormat";
import { logInfo, logWarn } from "../services/logService"; import { logInfo } from "../services/logService";
/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */ /** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
function errorMessage(e: unknown): string { function errorMessage(e: unknown): string {
@ -69,32 +69,6 @@ function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e); return e instanceof Error ? e.message : String(e);
} }
/** A refusal of `create_sample_import_folders` as the banner says it: an i18n key and its values. */
export interface SampleFolderError {
key: string;
values?: Record<string, string>;
}
const SAMPLE_FOLDER_EXISTS = "sample_folder_exists:";
/**
* The banner text of a refusal of `create_sample_import_folders` (#384). The
* command refuses with a stable code (its constants `SAMPLE_ERROR_*` in
* `fs_commands.rs`): `sample_folder_exists:<name>`, `import_folder_missing`,
* `invalid_date`, each turned here into its `import.empty.sampleErrors.*` key,
* with the name of the sample folder for the first. Any other message (a
* write error of the system) gives null: the banner shows it raw.
*/
export function sampleFolderError(raw: string): SampleFolderError | null {
if (raw.startsWith(SAMPLE_FOLDER_EXISTS)) {
const name = raw.slice(SAMPLE_FOLDER_EXISTS.length);
return name ? { key: "import.empty.sampleErrors.folderExists", values: { name } } : null;
}
if (raw === "import_folder_missing") return { key: "import.empty.sampleErrors.importFolderMissing" };
if (raw === "invalid_date") return { key: "import.empty.sampleErrors.invalidDate" };
return null;
}
export interface WizardState { export interface WizardState {
step: ImportWizardStep; step: ImportWizardStep;
importFolder: string | null; importFolder: string | null;
@ -110,11 +84,9 @@ export interface WizardState {
/** /**
* Names of the CSV/TXT files placed directly at the root of the import * Names of the CSV/TXT files placed directly at the root of the import
* folder, which no source reads (#427). Set with `scannedSources` by the * folder, which no source reads (#427). Set with `scannedSources` by the
* same action, so the warning and the source list come from the same scan; * same action, so the warning and the source list always come from the same
* kept by `RESET` like them. Emptied by `SET_IMPORT_FOLDER`, which leaves * scan; kept by `RESET` like them. Shown on screen only, never logged: a
* `scannedSources` alone (#446): another folder shows no warning until its * statement name often carries an account number.
* own scan. Shown on screen only, never logged: a statement name often
* carries an account number.
*/ */
rootFiles: string[]; rootFiles: string[];
selectedSource: ScannedSource | null; selectedSource: ScannedSource | null;
@ -129,12 +101,6 @@ export interface WizardState {
importProgress: { current: number; total: number; file: string }; importProgress: { current: number; total: number; file: string };
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/**
* Values `error` interpolates when it is an i18n key that needs some (the
* name of a sample folder that already exists, #384); null otherwise. Set
* and cleared with `error` by `SET_ERROR`, dropped with it by `RESET`.
*/
errorValues: Record<string, string> | null;
configuredSourceNames: Set<string>; configuredSourceNames: Set<string>;
importedFilesBySource: Map<string, Set<string>>; importedFilesBySource: Map<string, Set<string>>;
configTemplates: ImportConfigTemplate[]; configTemplates: ImportConfigTemplate[];
@ -173,7 +139,7 @@ export interface WizardState {
export type WizardAction = export type WizardAction =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null; values?: Record<string, string> } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_STEP"; payload: ImportWizardStep } | { type: "SET_STEP"; payload: ImportWizardStep }
| { type: "SET_IMPORT_FOLDER"; payload: string | null } | { type: "SET_IMPORT_FOLDER"; payload: string | null }
| { type: "SET_IMPORT_FOLDER_LOADED" } | { type: "SET_IMPORT_FOLDER_LOADED" }
@ -230,7 +196,6 @@ const initialState: WizardState = {
importProgress: { current: 0, total: 0, file: "" }, importProgress: { current: 0, total: 0, file: "" },
isLoading: false, isLoading: false,
error: null, error: null,
errorValues: null,
configuredSourceNames: new Set(), configuredSourceNames: new Set(),
importedFilesBySource: new Map(), importedFilesBySource: new Map(),
configTemplates: [], configTemplates: [],
@ -250,16 +215,12 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
case "SET_LOADING": case "SET_LOADING":
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, errorValues: action.values ?? null, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
case "SET_STEP": case "SET_STEP":
return { ...state, step: action.payload }; return { ...state, step: action.payload };
case "SET_IMPORT_FOLDER": case "SET_IMPORT_FOLDER":
// The sample-folder banner describes the previous folder: drop it. So do // The sample-folder banner describes the previous folder: drop it.
// the root files (#446): until the new folder's scan lands, or for good return { ...state, importFolder: action.payload, sampleCreated: null };
// if it fails, the warning would name the previous folder's files under
// the new folder's name. The scanned sources stay until that scan, as
// they always did.
return { ...state, importFolder: action.payload, rootFiles: [], sampleCreated: null };
case "SET_IMPORT_FOLDER_LOADED": case "SET_IMPORT_FOLDER_LOADED":
return { ...state, importFolderLoaded: true }; return { ...state, importFolderLoaded: true };
case "SET_SCANNED_SOURCES": case "SET_SCANNED_SOURCES":
@ -429,13 +390,8 @@ export function useImportWizard() {
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates }); dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, []); }, []);
/**
* Scan `folder` and read the configured sources. Never throws: a failure
* lands in the error banner, and the result says whether the scan went
* through, for a caller that has more to show after it (#384).
*/
const scanFolderInternal = useCallback( const scanFolderInternal = useCallback(
async (folder: string): Promise<boolean> => { async (folder: string) => {
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
// The root file names stay out of the logs (#427): on screen only. // The root file names stay out of the logs (#427): on screen only.
@ -444,13 +400,11 @@ export function useImportWizard() {
}); });
dispatch({ type: "SET_SCANNED_SOURCES", payload: scan }); dispatch({ type: "SET_SCANNED_SOURCES", payload: scan });
await loadConfiguredSources(); await loadConfiguredSources();
return true;
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),
}); });
return false;
} }
}, },
[loadConfiguredSources] [loadConfiguredSources]
@ -481,17 +435,10 @@ export function useImportWizard() {
/** /**
* Write the two sample bank folders into the import folder (#356), rescan * Write the two sample bank folders into the import folder (#356), rescan
* it so their cards show up without another click, then raise the success * it so their cards show up without another click, then raise the success
* banner — only once the rescan went through: a failed rescan puts up its * banner. The Rust command refuses when either folder already exists and
* own error, and the two banners would contradict each other (#384). * writes nothing; its message lands in the regular error banner. Only the
* * folder NAMES reach the logs: the logs travel with a feedback report, the
* The Rust command refuses with a stable code (a folder already there, the * absolute path of the import folder must not.
* import folder missing, an unreadable date) and writes nothing;
* `sampleFolderError` turns the code into the error banner's key, any other
* message stays raw. Only NAMES reach the logs, never a path: the logs
* travel with a feedback report, the absolute path of the import folder
* must not. Hence the raw refusal is logged as is: a code, or a write error
* that names a sample folder, its file and the OS error. The rescan's own
* error, which can carry the path, is not logged.
*/ */
const createSampleFolders = useCallback(async () => { const createSampleFolders = useCallback(async () => {
const folder = state.importFolder; const folder = state.importFolder;
@ -504,14 +451,13 @@ export function useImportWizard() {
today: formatLocalDate(new Date()), today: formatLocalDate(new Date()),
}); });
logInfo(`[import] Sample folders created: ${names.join(", ")}`); logInfo(`[import] Sample folders created: ${names.join(", ")}`);
if (await scanFolderInternal(folder)) { await scanFolderInternal(folder);
dispatch({ type: "SET_SAMPLE_CREATED", payload: names }); dispatch({ type: "SET_SAMPLE_CREATED", payload: names });
}
} catch (e) { } catch (e) {
const raw = e instanceof Error ? e.message : String(e); dispatch({
logWarn(`[import] Sample folders not created: ${raw}`); type: "SET_ERROR",
const known = sampleFolderError(raw); payload: e instanceof Error ? e.message : String(e),
dispatch({ type: "SET_ERROR", payload: known ? known.key : raw, values: known?.values }); });
} }
}, [state.importFolder, scanFolderInternal]); }, [state.importFolder, scanFolderInternal]);

View file

@ -70,15 +70,7 @@ const ready: OnboardingChecklistState = [
/** One render of the mounted provider. */ /** One render of the mounted provider. */
const render = () => harness.render(useOnboardingChecklist); const render = () => harness.render(useOnboardingChecklist);
/** /** The state the provider mounts with: its `useReducer` is its first React hook. */
* The state the provider mounts with: its `useReducer` is its first React hook
* (`useEntitlement` and `useLocation` are mocked, so they take no slot). This
* preset, like the writes to `harness.slots[0]` further down, depends on the
* order of the hook's calls to React: a hook of any kind added before the
* reducer shifts the slots without a word, and the state lands on another hook
* (one slot per hook call, every kind: the `slot` helper of `fakeReact`,
* src/test/hookHarness.ts).
*/
const mountWith = (state: OnboardingChecklistState) => { const mountWith = (state: OnboardingChecklistState) => {
harness.slots[0] = state; harness.slots[0] = state;
}; };

View file

@ -67,19 +67,14 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
expect(showsEmptyState(filled)).toBe(false); expect(showsEmptyState(filled)).toBe(false);
}); });
it("shows the empty state as soon as the count is 0, even while the first page loads (#384, D1)", () => { it("keeps the regular layout while a page is loading, even at 0", () => {
// Same rule as Budget: the count alone decides. On an empty profile the // The loading placeholder stays in place of the table so the card does not
// card comes up as soon as the count is known, instead of the filter bar, // flash during the first fetch of a profile whose count is already known.
// the totals and the loading line until the first page is back.
const loadingEmpty = transactionsReducer( const loadingEmpty = transactionsReducer(
transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }), transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }),
{ type: "SET_LOADING", payload: true }, { type: "SET_LOADING", payload: true },
); );
expect(loadingEmpty.isLoading).toBe(true); expect(showsEmptyState(loadingEmpty)).toBe(false);
expect(showsEmptyState(loadingEmpty)).toBe(true);
// And while the count is unknown, a loading page keeps the regular layout.
const loadingUnknown = transactionsReducer(initialTransactionsState, { type: "SET_LOADING", payload: true });
expect(showsEmptyState(loadingUnknown)).toBe(false);
}); });
it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => { it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => {

View file

@ -207,13 +207,11 @@ export function transactionsReducer(
/** /**
* Whether the Transactions page shows the "import first" empty state (#353) * Whether the Transactions page shows the "import first" empty state (#353)
* instead of the filter bar, table and pagination. Pure, so the 0 → N * instead of the filter bar, table and pagination. Pure, so the 0 → N
* transition is unit-testable without a renderer. `null` (count not loaded, * transition is unit-testable without a renderer. `null` (count not loaded)
* or its read failed) keeps the regular layout. A 0 shows the card at once, * and a loading page keep the regular layout.
* even while the first page is still loading: the same rule as Budget, so an
* empty profile no longer gets the table's layout first (#384, D1).
*/ */
export function showsEmptyState(state: Pick<TransactionsState, "totalInDb">): boolean { export function showsEmptyState(state: Pick<TransactionsState, "totalInDb" | "isLoading">): boolean {
return state.totalInDb === 0; return state.totalInDb === 0 && !state.isLoading;
} }
/** /**
@ -227,26 +225,19 @@ export function useTransactions(initialFilters?: Partial<TransactionFilters>) {
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFiltersRef = useRef(state.filters); const debouncedFiltersRef = useRef(state.filters);
// Load categories, sources and the profile-wide transaction count once on // Load categories, sources and the profile-wide transaction count once on mount
// mount. Each read stands alone (#384): a failed count no longer leaves the
// category and source filters empty, and leaves `totalInDb` null, so the
// regular layout, never the empty state. The first failure is the page's
// error.
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const [cats, srcs, total] = await Promise.allSettled([ try {
getAllCategories(), const [cats, srcs, total] = await Promise.all([
getAllImportSources(), getAllCategories(),
getTransactionCount(), getAllImportSources(),
]); getTransactionCount(),
if (cats.status === "fulfilled") dispatch({ type: "SET_CATEGORIES", payload: cats.value }); ]);
if (srcs.status === "fulfilled") dispatch({ type: "SET_SOURCES", payload: srcs.value }); dispatch({ type: "SET_CATEGORIES", payload: cats });
if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value }); dispatch({ type: "SET_SOURCES", payload: srcs });
const failed = [cats, srcs, total].find( dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
(r): r is PromiseRejectedResult => r.status === "rejected" } catch (e) {
);
if (failed) {
const e: unknown = failed.reason;
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),

View file

@ -1,121 +0,0 @@
// useTransactions — the reads of the mount stand alone (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its effects (the mount reads, the first page, the search debounce,
// whose timer `unmount()` clears). The services are mocked. What is pinned:
// one failed read no longer drops the others, a failed count leaves
// `totalInDb` unknown (the regular layout, never the "import first" card),
// and the first failure, in the order categories, sources, count, is the
// page's error.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const services = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllImportSources: vi.fn(),
getTransactionCount: vi.fn(),
getTransactionPage: vi.fn(),
}));
vi.mock("../services/transactionService", () => ({
...services,
updateTransactionCategory: vi.fn(),
updateTransactionNotes: vi.fn(),
autoCategorizeTransactions: vi.fn(),
getSplitChildren: vi.fn(),
saveSplitAdjustment: vi.fn(),
deleteSplitAdjustment: vi.fn(),
}));
vi.mock("../services/categoryService", () => ({ createKeyword: vi.fn() }));
import { hookHarness } from "../test/hookHarness";
import { useTransactions, showsEmptyState } from "./useTransactions";
import type { Category, ImportSource, TransactionPageResult } from "../shared/types";
const harness = hookHarness();
const CATEGORIES = [{ id: 1, name: "Épicerie" }] as Category[];
const SOURCES = [{ id: 7, name: "Desjardins" }] as ImportSource[];
const PAGE: TransactionPageResult = {
rows: [],
totalCount: 0,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
};
/** Lets the mount reads and the first page settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
async function mount() {
harness.render(() => useTransactions());
harness.commit();
await flush();
return harness.render(() => useTransactions()).state;
}
/** The actions of one type the hook dispatched, in order. */
const sent = (type: string) =>
harness.dispatch.mock.calls
.map(([action]) => action as { type: string; payload?: unknown })
.filter((a) => a.type === type);
beforeEach(() => {
harness.clear();
services.getAllCategories.mockReset().mockResolvedValue(CATEGORIES);
services.getAllImportSources.mockReset().mockResolvedValue(SOURCES);
services.getTransactionCount.mockReset().mockResolvedValue(0);
services.getTransactionPage.mockReset().mockResolvedValue(PAGE);
});
afterEach(() => {
harness.unmount();
});
describe("useTransactions — the reads of the mount (#384)", () => {
it("all three succeed: categories, sources and the count, no error", async () => {
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBe(0);
expect(state.error).toBeNull();
expect(showsEmptyState(state)).toBe(true);
});
it("a failed count still loads the categories and the sources, and leaves the count unknown", async () => {
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBeNull();
expect(sent("SET_TOTAL_IN_DB")).toEqual([]);
expect(state.error).toBe("count failed");
// Unknown count: the regular layout, never the "import first" card.
expect(showsEmptyState(state)).toBe(false);
});
it("failed categories still load the sources and the count", async () => {
services.getAllCategories.mockRejectedValue(new Error("categories failed"));
services.getTransactionCount.mockResolvedValue(12);
const state = await mount();
expect(state.categories).toEqual([]);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBe(12);
expect(state.error).toBe("categories failed");
});
it("several failures: the first one, in the order categories, sources, count, is the error", async () => {
services.getAllImportSources.mockRejectedValue("sources failed");
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toEqual([]);
expect(state.totalInDb).toBeNull();
// A rejection that is not an Error is shown as its text.
expect(state.error).toBe("sources failed");
expect(sent("SET_ERROR").filter((a) => a.payload !== null)).toHaveLength(1);
});
});

View file

@ -1,125 +0,0 @@
// Guard: fr.json and en.json declare the same keys (#453).
//
// i18next serves fr.json, the default language, and en.json, the fallback
// (`src/i18n/config.ts`). A key written in fr.json only shows raw to an
// English user; one written in en.json only shows in English to a French
// user. Neither breaks the build. These tests hold both files to one set of
// keys.
//
// A key is the dotted path of a leaf, array indices included
// (`docs.dashboard.features.6`) and plural suffixes too
// (`import.sources.fileCount_one`). Values are never compared: a text left
// untranslated in the other file passes. A path that is an array in one file
// and an object in the other, or a leaf in one and an object or an array in
// the other, is a gap as well: `t(key, { returnObjects: true })` would hand
// the component another shape.
import { describe, it, expect } from "vitest";
import fr from "./locales/fr.json";
import en from "./locales/en.json";
type NodeKind = "object" | "array" | "leaf";
/** Every node under the root of a locale tree, by dotted path (array indices included), with its kind. */
function nodesOf(tree: unknown): Map<string, NodeKind> {
const nodes = new Map<string, NodeKind>();
const visit = (value: unknown, path: string[]): void => {
const kind: NodeKind = Array.isArray(value)
? "array"
: typeof value === "object" && value !== null
? "object"
: "leaf";
if (path.length > 0) nodes.set(path.join("."), kind);
if (kind === "leaf") return;
// An array's entries are its indices, as strings: `features.6`.
for (const [key, item] of Object.entries(value as object)) visit(item, [...path, key]);
};
visit(tree, []);
return nodes;
}
/**
* The keys of `from` that `into` lacks, sorted: the leaves of `from` with
* nothing at the same path in `into`. A path `into` holds with another kind is
* left to `kindGaps`.
*/
function missingKeys(from: Map<string, NodeKind>, into: Map<string, NodeKind>): string[] {
return [...from]
.filter(([path, kind]) => kind === "leaf" && !into.has(path))
.map(([path]) => path)
.sort();
}
/** The paths both trees hold with different kinds, sorted, as `path: <kind> in <a>, <kind> in <b>`. */
function kindGaps(
a: Map<string, NodeKind>,
b: Map<string, NodeKind>,
nameA: string,
nameB: string,
): string[] {
return [...a]
.filter(([path, kind]) => b.has(path) && b.get(path) !== kind)
.map(([path, kind]) => `${path}: ${kind} in ${nameA}, ${b.get(path)} in ${nameB}`)
.sort();
}
const FR = nodesOf(fr);
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.
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");
expect(nodes.get("dashboard.help.tips.0"), name).toBe("leaf");
}
});
it("en.json declares every key of fr.json", () => {
expect(missingKeys(FR, EN), "keys of fr.json missing from en.json").toEqual([]);
});
it("fr.json declares every key of en.json", () => {
expect(missingKeys(EN, FR), "keys of en.json missing from fr.json").toEqual([]);
});
it("gives every path both files hold the same kind: object, array or leaf", () => {
expect(kindGaps(FR, EN, "fr.json", "en.json"), "paths of another kind").toEqual([]);
});
});
describe("locale keys: the comparison (#453)", () => {
it("names, on each side apart, the keys the other side lacks, array indices included", () => {
const a = nodesOf({ list: ["one", "two"], only: { a: "x" } });
const b = nodesOf({ list: ["one"], only: { b: "y" } });
expect(missingKeys(a, b)).toEqual(["list.1", "only.a"]);
expect(missingKeys(b, a)).toEqual(["only.b"]);
});
it("holds an array against an object with the same indices as a gap", () => {
const a = nodesOf({ list: ["one"] });
const b = nodesOf({ list: { 0: "one" } });
expect(missingKeys(a, b)).toEqual([]);
expect(missingKeys(b, a)).toEqual([]);
expect(kindGaps(a, b, "a", "b")).toEqual(["list: array in a, object in b"]);
});
it("holds a leaf against an object or an array as a gap", () => {
const leaf = nodesOf({ x: "text" });
expect(kindGaps(leaf, nodesOf({ x: { y: "text" } }), "a", "b")).toEqual([
"x: leaf in a, object in b",
]);
expect(kindGaps(leaf, nodesOf({ x: ["text"] }), "a", "b")).toEqual(["x: leaf in a, array in b"]);
// The leaf is not reported missing as well: `x` exists on the other side, with another kind.
expect(missingKeys(leaf, nodesOf({ x: ["text"] }))).toEqual([]);
});
it("never compares values", () => {
const a = nodesOf({ x: "un", list: ["a", "b"] });
const b = nodesOf({ x: "one", list: ["c", "d"] });
expect(missingKeys(a, b)).toEqual([]);
expect(missingKeys(b, a)).toEqual([]);
expect(kindGaps(a, b, "a", "b")).toEqual([]);
});
});

View file

@ -5,11 +5,7 @@
"changelog": { "changelog": {
"title": "Version History", "title": "Version History",
"description": "View what's new and fixed in each version", "description": "View what's new and fixed in each version",
"empty": "No entries available", "empty": "No entries available"
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"showNotes": "Show this version's notes",
"hideNotes": "Hide this version's notes"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@ -334,12 +330,7 @@
"account2": "Chequing account: another source", "account2": "Chequing account: another source",
"rootNote": "Files placed directly in the parent folder are ignored." "rootNote": "Files placed directly in the parent folder are ignored."
}, },
"sampleCreated": "Two sample folders were created: {{names}}. Click one of them to start the import. Delete them once your real statements are in place.", "sampleCreated": "Two sample folders were created: {{names}}. Click one of them to start the import. Delete them once your real statements are in place."
"sampleErrors": {
"folderExists": "\"{{name}}\" already exists in the import folder: no sample folder was created. Refresh the list; if it does not show up, rename it before trying again.",
"importFolderMissing": "The import folder cannot be found: no sample folder was created. Choose the folder again, then retry.",
"invalidDate": "Today's date could not be read: no sample folder was created."
}
}, },
"rootFiles": { "rootFiles": {
"message_one": "{{count}} file sits directly in “{{folder}}” and is ignored: {{names}}. Move it into a subfolder per account.", "message_one": "{{count}} file sits directly in “{{folder}}” and is ignored: {{names}}. Move it into a subfolder per account.",
@ -447,28 +438,6 @@
"customColor": "Custom color", "customColor": "Custom color",
"allKeywords": "All Keywords", "allKeywords": "All Keywords",
"allKeywordsEmpty": "No keywords yet", "allKeywordsEmpty": "No keywords yet",
"hierarchy": {
"check": "Check hierarchy",
"intro": "An orphan category is no longer in its place in the hierarchy: it is caught in a parent loop (a category placed under its own subcategory), which hides it along with its subcategories, or its parent was deleted or cannot be found. Reattach it to the root or to another parent: it comes back in the tree, the category menus, the budget and the reports, with its transactions, keywords and budget amounts.",
"checking": "Checking…",
"none": "No orphan categories: every category is in its place in the hierarchy.",
"count_one": "{{count}} orphan category",
"count_other": "{{count}} orphan categories",
"reasons": {
"cycle": "Caught in a parent loop",
"inactive_parent": "Its parent was deleted",
"missing_parent": "Its parent cannot be found"
},
"newParentFor": "New parent for {{name}}",
"root": "Root (top level)",
"reattach": "Reattach",
"errors": {
"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."
}
},
"help": { "help": {
"title": "How to manage Categories", "title": "How to manage Categories",
"tips": [ "tips": [
@ -707,8 +676,8 @@
"tips": [ "tips": [
"The cards at the bottom of the page open the five reports: Highlights, Trends, Compare, Category Analysis and Cards", "The cards at the bottom of the page open the five reports: Highlights, Trends, Compare, Category Analysis and Cards",
"Use the period selector to adjust the time range for all charts", "Use the period selector to adjust the time range for all charts",
"Trends shows your income and expenses month by month over 12 months", "Monthly Trends shows your income and expenses over time",
"Category Analysis tracks how a single category evolves over time" "Category Over Time tracks how spending in each category evolves"
] ]
} }
}, },
@ -946,7 +915,7 @@
"Set up an import source — opening a subfolder for the first time detects the file format", "Set up an import source — opening a subfolder for the first time detects the file format",
"Import a first file — after reading the preview and its signed recap", "Import a first file — after reading the preview and its signed recap",
"Browse categories and keywords — what keywords do, before categorizing", "Browse categories and keywords — what keywords do, before categorizing",
"Categorize a transaction by hand — the keywords that come with the app do most of the work, a manual correction refines the rest", "Categorize a transaction by hand — the keyword seed does most of the work, a manual correction refines the rest",
"Enter a first budget — Base edition and up; then track your progress through Reports", "Enter a first budget — Base edition and up; then track your progress through Reports",
"Browse the reports — the period, the highlights and the report cards" "Browse the reports — the period, the highlights and the report cards"
], ],
@ -1010,7 +979,7 @@
"Stacked bar chart of expenses by category and month", "Stacked bar chart of expenses by category and month",
"Adjustable time period selector (default: year to date)", "Adjustable time period selector (default: year to date)",
"Context menu (right-click) to hide a category or view its transactions", "Context menu (right-click) to hide a category or view its transactions",
"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" "Welcome card for a profile without transactions, with the getting-started checklist: eight steps that tick themselves from your data, and a \"Hide\" button"
], ],
"steps": [ "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: 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",
@ -1183,7 +1152,6 @@
"Drag-and-drop to reorder categories or change their parent", "Drag-and-drop to reorder categories or change their parent",
"Toggle categories as inputable or non-inputable", "Toggle categories as inputable or non-inputable",
"All Keywords view to see all rules at a glance", "All Keywords view to see all rules at a glance",
"“Check hierarchy” to find the orphan categories (caught in a parent loop, or whose parent was deleted or cannot be found) and reattach them",
"Re-initialize categories to defaults" "Re-initialize categories to defaults"
], ],
"steps": [ "steps": [
@ -1192,16 +1160,13 @@
"Drag and drop a category in the tree to reorder it or move it under a different parent", "Drag and drop a category in the tree to reorder it or move it under a different parent",
"Add keywords that match transaction descriptions for auto-categorization", "Add keywords that match transaction descriptions for auto-categorization",
"Set keyword priority to resolve conflicts when multiple categories match", "Set keyword priority to resolve conflicts when multiple categories match",
"Use the color picker to assign a custom color for charts", "Use the color picker to assign a custom color for charts"
"Click Check hierarchy to list the orphan categories: for each one, choose a new parent (the root by default), then click Reattach"
], ],
"tips": [ "tips": [
"Non-inputable categories are hidden from budget and transaction dropdowns but still visible in reports", "Non-inputable categories are hidden from budget and transaction dropdowns but still visible in reports",
"Higher priority keywords win when multiple categories match the same transaction", "Higher priority keywords win when multiple categories match the same transaction",
"Use the All Keywords view to get a global overview of your categorization rules", "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", "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"
] ]
}, },
"adjustments": { "adjustments": {
@ -1215,7 +1180,7 @@
], ],
"steps": [ "steps": [
"Click New Adjustment to create an adjustment group", "Click New Adjustment to create an adjustment group",
"Add entries with a description, amount, date, and category (type a few letters to filter the category list)", "Add entries with a description, amount, date, and category",
"Toggle the recurring flag if the adjustment should repeat each period", "Toggle the recurring flag if the adjustment should repeat each period",
"View the Transaction Splits section to see splits created from the Transactions page" "View the Transaction Splits section to see splits created from the Transactions page"
], ],
@ -1337,7 +1302,6 @@
"Complete user guide accessible directly from settings", "Complete user guide accessible directly from settings",
"Automatic update checker with one-click install", "Automatic update checker with one-click install",
"Application logs viewable with level filters, copy, and clear", "Application logs viewable with level filters, copy, and clear",
"Version history: the installed version's notes show right away, other versions are folded and unfold with a click on their number, or all together with \"Expand all\"",
"Data export (transactions, categories, or both) in JSON or CSV format", "Data export (transactions, categories, or both) in JSON or CSV format",
"Data import from a previously exported file", "Data import from a previously exported file",
"Optional AES-256-GCM encryption for exported files", "Optional AES-256-GCM encryption for exported files",
@ -1348,7 +1312,6 @@
"Click User Guide to access the full documentation", "Click User Guide to access the full documentation",
"Click Check for Updates to see if a new version is available", "Click Check for Updates to see if a new version is available",
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear", "View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
"In Settings → System, the sections come in this order: Version, Update, Logs and feedback, then Version history. The history opens on the installed version's notes; click another version number to read its notes, or \"Expand all\" to open the whole history (\"Collapse all\" folds it again)",
"Use the Data Management section to export or import your data", "Use the Data Management section to export or import your data",
"When exporting, choose what to include and optionally set a password for encryption", "When exporting, choose what to include and optionally set a password for encryption",
"When importing, select a previously exported file — encrypted files will prompt for the password", "When importing, select a previously exported file — encrypted files will prompt for the password",

View file

@ -5,11 +5,7 @@
"changelog": { "changelog": {
"title": "Historique des versions", "title": "Historique des versions",
"description": "Consultez les nouveautés et corrections de chaque version", "description": "Consultez les nouveautés et corrections de chaque version",
"empty": "Aucune entrée disponible", "empty": "Aucune entrée disponible"
"expandAll": "Tout déplier",
"collapseAll": "Tout replier",
"showNotes": "Afficher les notes de cette version",
"hideNotes": "Masquer les notes de cette version"
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
@ -334,12 +330,7 @@
"account2": "Compte chèques : une autre source", "account2": "Compte chèques : une autre source",
"rootNote": "Les fichiers posés directement dans le dossier parent sont ignorés." "rootNote": "Les fichiers posés directement dans le dossier parent sont ignorés."
}, },
"sampleCreated": "Deux dossiers d'exemple ont été créés : {{names}}. Cliquez sur l'un d'eux pour lancer l'import. Supprimez-les quand vos vrais relevés sont en place.", "sampleCreated": "Deux dossiers d'exemple ont été créés : {{names}}. Cliquez sur l'un d'eux pour lancer l'import. Supprimez-les quand vos vrais relevés sont en place."
"sampleErrors": {
"folderExists": "« {{name}} » existe déjà dans le dossier d'import : aucun dossier d'exemple n'a été créé. Actualisez la liste ; s'il n'y apparaît pas, renommez-le avant de réessayer.",
"importFolderMissing": "Le dossier d'import est introuvable : aucun dossier d'exemple n'a été créé. Choisissez-le de nouveau, puis réessayez.",
"invalidDate": "La date du jour n'a pas pu être lue : aucun dossier d'exemple n'a été créé."
}
}, },
"rootFiles": { "rootFiles": {
"message_one": "{{count}} fichier est posé directement dans « {{folder}} » et est ignoré : {{names}}. Déplacez-le dans un sous-dossier par compte.", "message_one": "{{count}} fichier est posé directement dans « {{folder}} » et est ignoré : {{names}}. Déplacez-le dans un sous-dossier par compte.",
@ -447,28 +438,6 @@
"customColor": "Couleur personnalisée", "customColor": "Couleur personnalisée",
"allKeywords": "Tous les mots-clés", "allKeywords": "Tous les mots-clés",
"allKeywordsEmpty": "Aucun mot-clé", "allKeywordsEmpty": "Aucun mot-clé",
"hierarchy": {
"check": "Vérifier la hiérarchie",
"intro": "Une catégorie orpheline n'est plus à sa place dans la hiérarchie : elle est prise dans une boucle de parenté (une catégorie placée sous sa propre sous-catégorie), qui la cache avec ses sous-catégories, ou son parent a été supprimé ou est introuvable. Rattachez-la à la racine ou à un autre parent : elle revient dans l'arbre, les menus de catégories, le budget et les rapports, avec ses transactions, ses mots-clés et ses montants de budget.",
"checking": "Vérification en cours…",
"none": "Aucune catégorie orpheline : chaque catégorie est à sa place dans la hiérarchie.",
"count_one": "{{count}} catégorie orpheline",
"count_other": "{{count}} catégories orphelines",
"reasons": {
"cycle": "Prise dans une boucle de parenté",
"inactive_parent": "Son parent a été supprimé",
"missing_parent": "Son parent est introuvable"
},
"newParentFor": "Nouveau parent de {{name}}",
"root": "Racine (niveau supérieur)",
"reattach": "Rattacher",
"errors": {
"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."
}
},
"help": { "help": {
"title": "Comment gérer les Catégories", "title": "Comment gérer les Catégories",
"tips": [ "tips": [
@ -707,8 +676,8 @@
"tips": [ "tips": [
"Les cartes du bas de la page ouvrent les cinq rapports : Faits saillants, Tendances, Comparables, Analyse par catégorie et Cartes", "Les cartes du bas de la page ouvrent les cinq rapports : Faits saillants, Tendances, Comparables, Analyse par catégorie et Cartes",
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques", "Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
"Tendances montre vos revenus et dépenses mois par mois sur 12 mois", "Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
"Analyse par catégorie suit l'évolution d'une catégorie dans le temps" "Catégories dans le temps suit l'évolution des dépenses par catégorie"
] ]
} }
}, },
@ -946,7 +915,7 @@
"Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier", "Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier",
"Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé", "Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé",
"Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer", "Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer",
"Classer une transaction à la main — les mots-clés fournis avec l'application font le gros du travail, une correction manuelle affine le reste", "Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste",
"Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports", "Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports",
"Parcourir les rapports — la période, les faits saillants et les cartes de rapports" "Parcourir les rapports — la période, les faits saillants et les cartes de rapports"
], ],
@ -1010,7 +979,7 @@
"Histogramme empilé des dépenses par catégorie et par mois", "Histogramme empilé des dépenses par catégorie et par mois",
"Sélecteur de période ajustable (par défaut : année à ce jour)", "Sélecteur de période ajustable (par défaut : année à ce jour)",
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions", "Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
"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 »" "Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »"
], ],
"steps": [ "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 : 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",
@ -1183,7 +1152,6 @@
"Glisser-déposer pour réordonner les catégories ou changer leur parent", "Glisser-déposer pour réordonner les catégories ou changer leur parent",
"Basculer les catégories en saisissable ou non-saisissable", "Basculer les catégories en saisissable ou non-saisissable",
"Vue « Tous les mots-clés » pour voir l'ensemble des règles", "Vue « Tous les mots-clés » pour voir l'ensemble des règles",
"« Vérifier la hiérarchie » pour retrouver les catégories orphelines (prises dans une boucle de parenté, ou dont le parent a été supprimé ou est introuvable) et les rattacher",
"Réinitialiser les catégories aux valeurs par défaut" "Réinitialiser les catégories aux valeurs par défaut"
], ],
"steps": [ "steps": [
@ -1192,16 +1160,13 @@
"Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent", "Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent",
"Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation", "Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation",
"Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent", "Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent",
"Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques", "Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques"
"Cliquez sur Vérifier la hiérarchie pour lister les catégories orphelines : pour chacune, choisissez un nouveau parent (la racine par défaut), puis cliquez sur Rattacher"
], ],
"tips": [ "tips": [
"Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports", "Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports",
"Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction", "Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction",
"Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation", "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", "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"
] ]
}, },
"adjustments": { "adjustments": {
@ -1215,7 +1180,7 @@
], ],
"steps": [ "steps": [
"Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement", "Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement",
"Ajoutez des entrées avec une description, un montant, une date et une catégorie (tapez quelques lettres pour filtrer la liste des catégories)", "Ajoutez des entrées avec une description, un montant, une date et une catégorie",
"Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période", "Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période",
"Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions" "Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions"
], ],
@ -1337,7 +1302,6 @@
"Guide d'utilisation complet accessible directement depuis les paramètres", "Guide d'utilisation complet accessible directement depuis les paramètres",
"Vérification automatique des mises à jour avec installation en un clic", "Vérification automatique des mises à jour avec installation en un clic",
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement", "Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
"Historique des versions : les notes de la version installée s'affichent d'emblée, les autres versions sont repliées et se déplient d'un clic sur leur numéro, ou toutes ensemble avec « Tout déplier »",
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV", "Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
"Import des données depuis un fichier exporté précédemment", "Import des données depuis un fichier exporté précédemment",
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés", "Chiffrement AES-256-GCM optionnel pour les fichiers exportés",
@ -1348,7 +1312,6 @@
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète", "Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
"Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible", "Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible",
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez", "Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
"Dans Paramètres → Système, les sections se suivent dans cet ordre : Version, Mise à jour, Journaux et commentaires, puis Historique des versions. L'historique s'ouvre sur les notes de la version installée ; cliquez sur un autre numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (« Tout replier » le referme)",
"Utilisez la section Gestion des données pour exporter ou importer vos données", "Utilisez la section Gestion des données pour exporter ou importer vos données",
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement", "Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe", "Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe",

View file

@ -12,16 +12,10 @@ vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock()); vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({ const harness = vi.hoisted(() => ({ onboarding: null as unknown }));
onboarding: null as unknown,
/** Overrides of the categories state, for the tests of #393. */
state: {} as Record<string, unknown>,
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useCategories", async (importOriginal) => ({ vi.mock("../hooks/useCategories", () => ({
// The real error text helper: the page must translate a refusal's key.
categoriesErrorText: (await importOriginal<typeof import("../hooks/useCategories")>()).categoriesErrorText,
useCategories: () => ({ useCategories: () => ({
state: { state: {
categories: [], categories: [],
@ -33,8 +27,6 @@ vi.mock("../hooks/useCategories", async (importOriginal) => ({
isLoading: true, isLoading: true,
isSaving: false, isSaving: false,
error: null, error: null,
hierarchyCheck: null,
...harness.state,
}, },
}), }),
})); }));
@ -96,7 +88,6 @@ const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1]
beforeEach(() => { beforeEach(() => {
harness.onboarding = path([]); harness.onboarding = path([]);
harness.state = {};
}); });
describe("CategoriesPage — the step card (#432)", () => { describe("CategoriesPage — the step card (#432)", () => {
@ -159,53 +150,3 @@ describe("CategoriesPage — the step card (#432)", () => {
expect(value.skip).not.toHaveBeenCalled(); expect(value.skip).not.toHaveBeenCalled();
}); });
}); });
describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
/** The opening tag of the header button whose label closes right after it. */
const buttonTag = (html: string, label: string) => {
const end = html.indexOf(`${label}</button>`);
expect(end).toBeGreaterThan(-1);
const start = html.lastIndexOf("<button", end);
return html.slice(start, html.indexOf(">", start) + 1);
};
it("sits between « All keywords » and « Reset », styled as « Reset »", () => {
const html = render();
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
const check = html.indexOf("t(categories.hierarchy.check)</button>");
const reset = html.indexOf("t(categories.reinitialize)</button>");
expect(allKeywords).toBeLessThan(check);
expect(check).toBeLessThan(reset);
const classOf = (tag: string) => tag.match(/class="([^"]*)"/)?.[1];
expect(classOf(buttonTag(html, "t(categories.hierarchy.check)"))).toBe(
classOf(buttonTag(html, "t(categories.reinitialize)")),
);
});
it("is disabled while a save runs, as « Reset »", () => {
harness.state = { isSaving: true };
expect(buttonTag(render(), "t(categories.hierarchy.check)")).toContain('disabled=""');
});
it("opens the window only while the check is open", () => {
expect(render()).not.toContain('role="dialog"');
harness.state = { hierarchyCheck: { orphans: [], error: null } };
const html = render();
expect(html).toContain('role="dialog"');
expect(html).toContain("t(categories.hierarchy.none)");
});
it("translates a refusal in the window by its key", () => {
harness.state = {
hierarchyCheck: { orphans: [], error: { key: "categories.hierarchy.errors.too_deep" } },
};
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
});
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>");
harness.state = { error: { message: "database is locked" } };
expect(render()).toContain(">database is locked</div>");
});
});

View file

@ -1,13 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Plus, RotateCcw, List, AlertTriangle, ListTree } from "lucide-react"; import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import { useCategories, categoriesErrorText } from "../hooks/useCategories"; import { useCategories } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree"; import CategoryTree from "../components/categories/CategoryTree";
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel"; import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel"; import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
import HierarchyCheckModal from "../components/categories/HierarchyCheckModal";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext"; import { useOnboarding } from "../contexts/OnboardingContext";
import { import {
@ -36,9 +35,6 @@ export default function CategoriesPage() {
removeKeyword, removeKeyword,
reinitializeCategories, reinitializeCategories,
moveCategory, moveCategory,
checkHierarchy,
reattachOrphan,
closeHierarchyCheck,
} = useCategories(); } = useCategories();
// The getting-started card (#432) points at "All keywords" while it is on // The getting-started card (#432) points at "All keywords" while it is on
@ -83,14 +79,6 @@ export default function CategoriesPage() {
<List size={16} /> <List size={16} />
{t("categories.allKeywords")} {t("categories.allKeywords")}
</button> </button>
<button
onClick={checkHierarchy}
disabled={state.isSaving}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
<ListTree size={16} />
{t("categories.hierarchy.check")}
</button>
<button <button
onClick={() => setShowReinitConfirm(true)} onClick={() => setShowReinitConfirm(true)}
disabled={state.isSaving} disabled={state.isSaving}
@ -111,7 +99,7 @@ export default function CategoriesPage() {
{state.error && ( {state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm"> <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{categoriesErrorText(state.error, t)} {state.error}
</div> </div>
)} )}
@ -153,17 +141,6 @@ export default function CategoriesPage() {
</div> </div>
)} )}
{state.hierarchyCheck && (
<HierarchyCheckModal
orphans={state.hierarchyCheck.orphans}
error={state.hierarchyCheck.error ? categoriesErrorText(state.hierarchyCheck.error, t) : null}
categories={state.categories}
isSaving={state.isSaving}
onReattach={reattachOrphan}
onClose={closeHierarchyCheck}
/>
)}
{/* Reinitialize confirmation modal */} {/* Reinitialize confirmation modal */}
{showReinitConfirm && ( {showReinitConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">

View file

@ -163,9 +163,3 @@ describe("DashboardPage on the shared path (#352, #396, #397)", () => {
expect(html).toContain('data-marker="filters"'); expect(html).toContain('data-marker="filters"');
}); });
}); });
describe("DashboardPage — texts that go through t (#437)", () => {
it("names the KPI section through t", () => {
expect(render()).toContain(">t(dashboard.kpiSectionTitle)<");
});
});

View file

@ -303,23 +303,6 @@ describe("ImportPage — before the folder is read (#384)", () => {
}); });
}); });
describe("ImportPage — the empty state's one prop (#384)", () => {
it("an import folder stored as \"\": the 'no source' variant, as the card says", () => {
// `folderName` alone tells the variants apart: the page hands "" for "",
// as `hasFolder` (`importFolder !== null`) did, so the empty state and
// the card (`hasFolder` of `importCardModel`) keep speaking of a folder.
harness.wizard = wizard({ importFolder: "" });
harness.onboarding = path(ZERO, true);
const html = render();
expect(html).toContain("t(import.empty.noSources.title)");
expect(html).not.toContain("import.empty.noFolder.title");
expect(sentence(html)).toBe(
"t(onboarding.card.import.noSources[folder=,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
);
expect(highlighted(html).inner).toBe("t(import.empty.createSample)");
});
});
describe("ImportPage — files placed directly in the import folder (#427)", () => { describe("ImportPage — files placed directly in the import folder (#427)", () => {
const WARNING = "t(import.rootFiles.message["; const WARNING = "t(import.rootFiles.message[";
@ -384,22 +367,3 @@ describe("ImportPage — files placed directly in the import folder (#427)", ()
expect(render()).not.toContain("import.rootFiles"); expect(render()).not.toContain("import.rootFiles");
}); });
}); });
describe("ImportPage — the error banner of the sample folders (#384)", () => {
it("a refusal of the command: its key, with the folder's name", () => {
harness.wizard = wizard({
importFolder: "/home/max/Relevés",
error: "import.empty.sampleErrors.folderExists",
errorValues: { name: "Exemple Desjardins" },
});
expect(render()).toContain(
"t(import.empty.sampleErrors.folderExists[name=Exemple Desjardins,defaultValue=import.empty.sampleErrors.folderExists])",
);
});
it("any other message: raw, through `defaultValue`", () => {
const raw = "Cannot write Exemple Desjardins/releve-2026-09.csv: Permission denied (os error 13)";
harness.wizard = wizard({ importFolder: "/home/max/Relevés", error: raw });
expect(render()).toContain(`t(${raw}[defaultValue=${raw}])`);
});
});

View file

@ -102,12 +102,11 @@ export default function ImportPage() {
<AlertCircle size={16} className="text-[var(--negative)] shrink-0" /> <AlertCircle size={16} className="text-[var(--negative)] shrink-0" />
{/* {/*
The wizard reports a translation key when it has one (a stored The wizard reports a translation key when it has one (a stored
format it cannot decode, a refusal of the sample folders with its format it cannot decode) and a raw message otherwise; `defaultValue`
values, #384) and a raw message otherwise; `defaultValue` renders renders the latter unchanged.
the latter unchanged.
*/} */}
<p className="text-sm text-[var(--foreground)]"> <p className="text-sm text-[var(--foreground)]">
{t(state.error, { ...(state.errorValues ?? {}), defaultValue: state.error })} {t(state.error, { defaultValue: state.error })}
</p> </p>
</div> </div>
)} )}
@ -152,7 +151,8 @@ export default function ImportPage() {
(showEmptyState ? ( (showEmptyState ? (
<> <>
<ImportEmptyState <ImportEmptyState
folderName={state.importFolder !== null ? folderNameFromPath(state.importFolder) : null} hasFolder={state.importFolder !== null}
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
onBrowse={browseFolder} onBrowse={browseFolder}
onCreateSample={createSampleFolders} onCreateSample={createSampleFolders}
onRefresh={refreshFolder} onRefresh={refreshFolder}

View file

@ -20,8 +20,6 @@ const harness = vi.hoisted(() => ({
rows: [] as TransactionRow[], rows: [] as TransactionRow[],
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */ /** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
filters: null as Partial<TransactionFilters> | null, filters: null as Partial<TransactionFilters> | null,
/** Other fields a read would have set: the profile-wide count, a page loading (#384). */
state: {} as { totalInDb?: number | null; isLoading?: boolean },
/** The license gate the page reads (#369), and the features it asked for. */ /** The license gate the page reads (#369), and the features it asked for. */
entitlement: { allowed: true, ready: true }, entitlement: { allowed: true, ready: true },
entitlementCalls: [] as string[], entitlementCalls: [] as string[],
@ -41,7 +39,7 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => { useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args); const hook = actual.useTransactions(...args);
const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters; const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters;
return { ...hook, state: { ...hook.state, ...harness.state, rows: harness.rows, filters } }; return { ...hook, state: { ...hook.state, rows: harness.rows, filters } };
}, },
}; };
}); });
@ -142,7 +140,6 @@ beforeEach(() => {
harness.onboarding = path(IMPORTED); harness.onboarding = path(IMPORTED);
harness.rows = []; harness.rows = [];
harness.filters = null; harness.filters = null;
harness.state = {};
harness.entitlement = { allowed: true, ready: true }; harness.entitlement = { allowed: true, ready: true };
harness.entitlementCalls = []; harness.entitlementCalls = [];
}); });
@ -308,64 +305,3 @@ describe("TransactionsPage — the Répartition button follows the edition (#369
} }
}); });
}); });
describe("TransactionsPage — a profile without transactions (#353, #384)", () => {
/** An empty profile: the path's next step is elsewhere, so no step card here. */
const EMPTY: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
const CTA = /<a [^>]*href="\/import"[^>]*>t\(transactions\.empty\.cta\)<\/a>/;
/** What only the regular layout renders: filter bar, totals, table, pagination, loading line. */
const REGULAR = [
"t(transactions.filters.searchPlaceholder)",
"t(transactions.summary.count)",
"t(transactions.noTransactions)",
"t(transactions.pagination.showing)",
"t(common.loading)",
];
beforeEach(() => {
harness.onboarding = path(EMPTY, { mode: "banner", cardPage: null });
});
it("the 'import first' card in place of the filter bar, the table and the pagination", () => {
harness.state = { totalInDb: 0 };
const html = render();
expect(html).toContain("t(transactions.empty.title)");
expect(html).toContain("t(transactions.empty.description)");
expect(html).toMatch(CTA);
for (const marker of REGULAR) expect(html, marker).not.toContain(marker);
expect(html).not.toContain("<table");
// The page's heading stays above the card.
expect(html.indexOf("t(transactions.title)")).toBeLessThan(html.indexOf("t(transactions.empty.title)"));
});
it("as soon as the count is known, while the first page still loads (D1)", () => {
harness.state = { totalInDb: 0, isLoading: true };
const html = render();
expect(html).toContain("t(transactions.empty.title)");
expect(html).not.toContain("t(common.loading)");
expect(html).not.toContain("t(transactions.filters.searchPlaceholder)");
});
it("count not loaded yet, or its read failed: the regular layout, never the card", () => {
for (const state of [{ totalInDb: null }, { totalInDb: null, isLoading: true }]) {
harness.state = state;
const html = render();
expect(html, JSON.stringify(state)).not.toContain("transactions.empty");
expect(html, JSON.stringify(state)).toContain("t(transactions.filters.searchPlaceholder)");
}
});
it("a populated profile with an empty page keeps the table's layout (default filter: since January 1st)", () => {
harness.state = { totalInDb: 7 };
const html = render();
expect(html).not.toContain("transactions.empty");
expect(html).toContain("t(transactions.noTransactions)");
});
});

View file

@ -11,8 +11,7 @@ interface SectionCard {
itemKeys: string[]; itemKeys: string[];
} }
/** The three cards of the hub; each lists its page's sections, in the page's order. */ const SECTIONS: SectionCard[] = [
export const SECTIONS: SectionCard[] = [
{ {
to: "/settings/users", to: "/settings/users",
titleKey: "settings.users.title", titleKey: "settings.users.title",
@ -43,8 +42,8 @@ export const SECTIONS: SectionCard[] = [
itemKeys: [ itemKeys: [
"settings.systems.sections.version", "settings.systems.sections.version",
"settings.systems.sections.update", "settings.systems.sections.update",
"settings.systems.sections.logs",
"settings.systems.sections.changelog", "settings.systems.sections.changelog",
"settings.systems.sections.logs",
], ],
}, },
]; ];

View file

@ -1,134 +0,0 @@
// SystemsSettingsPage — the Journal before the version history, and the
// history mounted once the installed version is known (#372).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// page runs as a plain function, `commit()` runs its effect (getVersion), and
// the next render reads what the effect stored. The cards are stubs: only
// where the page puts them, and what it hands the history, are under test.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { 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());
vi.mock("react-router-dom", async () => {
const { createElement } = await vi.importActual<typeof import("react")>("react");
return {
Link: ({ to, className, children }: { to: string; className?: string; children?: ReactNode }) =>
createElement("a", { href: to, className }, children),
};
});
vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn() }));
vi.mock("../../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../../components/settings/UpdateCard", () => ({ default: () => "[UpdateCard]" }));
vi.mock("../../components/settings/LogViewerCard", () => ({ default: () => "[LogViewerCard]" }));
vi.mock("../../components/settings/ChangelogContent", () => ({ default: () => "[ChangelogContent]" }));
import { renderToStaticMarkup } from "react-dom/server";
import { getVersion } from "@tauri-apps/api/app";
import { hookHarness } from "../../test/hookHarness";
import { logWarn } from "../../services/logService";
import ChangelogContent from "../../components/settings/ChangelogContent";
import SystemsSettingsPage from "./SystemsSettingsPage";
import { SECTIONS } from "./SettingsHomePage";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
/** One render of the mounted page. */
const render = (): ReactNode => harness.render(() => SystemsSettingsPage() as ReactNode);
/** Lets getVersion() and the state update that follows it settle. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const history = (page: ReactNode) => find(page, (e) => e.type === ChangelogContent);
beforeEach(() => {
harness.clear();
vi.mocked(getVersion).mockReset();
vi.mocked(logWarn).mockReset();
});
describe("SystemsSettingsPage — order of the sections (#372)", () => {
it("renders Version, Update, the Journal, then the version history and the data notice", () => {
vi.mocked(getVersion).mockResolvedValue("0.17.2");
// Slot 0 is the page's first hook, its `version` state. This preset, and the
// next test's, depend on the order of the page's hook calls: a hook of any
// kind added before it shifts the slots without a word (one slot per hook
// call, every kind: the `slot` helper of `fakeReact`, src/test/hookHarness.ts).
harness.slots[0] = "0.17.2"; // the version, already read
const html = renderToStaticMarkup(render());
const at = (marker: string) => {
const index = html.indexOf(marker);
expect(index, marker).toBeGreaterThanOrEqual(0);
return index;
};
const order = [
at("t(settings.systems.sections.version)"),
at("t(settings.systems.sections.update)"),
at("[UpdateCard]"),
at("t(settings.systems.sections.logs)"),
at("[LogViewerCard]"),
at("t(settings.systems.sections.changelog)"),
at("[ChangelogContent]"),
at("t(settings.dataSafeNotice)"),
];
expect(order).toEqual([...order].sort((a, b) => a - b));
});
it("lists, on the System card of the Settings home, the page's sections in the page's order", () => {
harness.slots[0] = "0.17.2";
const html = renderToStaticMarkup(render());
const headings = [...html.matchAll(/<h2[^>]*>t\((settings\.systems\.sections\.[a-zA-Z]+)\)<\/h2>/g)].map(
(match) => match[1],
);
expect(headings).toHaveLength(4);
const card = SECTIONS.find((section) => section.to === "/settings/systems");
expect(card?.itemKeys).toEqual(headings);
});
});
describe("SystemsSettingsPage — the version history waits for the version (#372)", () => {
it("keeps the history heading but mounts no history while getVersion() runs", () => {
vi.mocked(getVersion).mockReturnValue(new Promise(() => {}));
let page = render();
harness.commit();
page = render();
expect(history(page)).toHaveLength(0);
expect(renderToStaticMarkup(page)).toContain("t(settings.systems.sections.changelog)");
});
it("mounts the history with the installed version once it is read", async () => {
vi.mocked(getVersion).mockResolvedValue("0.17.2");
expect(history(render())).toHaveLength(0);
harness.commit();
await settle();
const page = render();
const [mounted] = history(page);
expect(mounted.props.installedVersion).toBe("0.17.2");
expect(renderToStaticMarkup(page)).toContain("t(settings.version[version=0.17.2])");
expect(logWarn).not.toHaveBeenCalled();
});
it("mounts the history with an unknown version when getVersion() fails, and says so in the Journal", async () => {
vi.mocked(getVersion).mockRejectedValue(new Error("ipc down"));
render();
harness.commit();
await settle();
const page = render();
const [mounted] = history(page);
expect(mounted.props.installedVersion).toBeNull();
expect(renderToStaticMarkup(page)).toContain("t(settings.version[version=])");
expect(logWarn).toHaveBeenCalledTimes(1);
expect(vi.mocked(logWarn).mock.calls[0][0]).toBe(
"App version unavailable; the version history opens on the latest release",
);
});
});

View file

@ -4,23 +4,16 @@ import { Link } from "react-router-dom";
import { ArrowLeft, ShieldCheck } from "lucide-react"; import { ArrowLeft, ShieldCheck } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { APP_NAME } from "../../shared/constants"; import { APP_NAME } from "../../shared/constants";
import { logWarn } from "../../services/logService";
import UpdateCard from "../../components/settings/UpdateCard"; import UpdateCard from "../../components/settings/UpdateCard";
import ChangelogContent from "../../components/settings/ChangelogContent"; import ChangelogContent from "../../components/settings/ChangelogContent";
import LogViewerCard from "../../components/settings/LogViewerCard"; import LogViewerCard from "../../components/settings/LogViewerCard";
export default function SystemsSettingsPage() { export default function SystemsSettingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
// `undefined` while getVersion() runs, `null` when it failed. const [version, setVersion] = useState("");
const [version, setVersion] = useState<string | null | undefined>(undefined);
useEffect(() => { useEffect(() => {
getVersion() getVersion().then(setVersion);
.then(setVersion)
.catch((error) => {
logWarn("App version unavailable; the version history opens on the latest release", String(error));
setVersion(null);
});
}, []); }, []);
return ( return (
@ -47,7 +40,7 @@ export default function SystemsSettingsPage() {
<div> <div>
<h3 className="text-lg font-semibold">{APP_NAME}</h3> <h3 className="text-lg font-semibold">{APP_NAME}</h3>
<p className="text-sm text-[var(--muted-foreground)]"> <p className="text-sm text-[var(--muted-foreground)]">
{t("settings.version", { version: version ?? "" })} {t("settings.version", { version })}
</p> </p>
</div> </div>
</div> </div>
@ -61,7 +54,13 @@ export default function SystemsSettingsPage() {
<UpdateCard /> <UpdateCard />
</section> </section>
{/* The Journal comes before the version history, which grows at each release (#372). */} <section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.changelog")}
</h2>
<ChangelogContent />
</section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]"> <h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.logs")} {t("settings.systems.sections.logs")}
@ -69,14 +68,6 @@ export default function SystemsSettingsPage() {
<LogViewerCard /> <LogViewerCard />
</section> </section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.changelog")}
</h2>
{/* Mounted once the version is settled: the history opens on it (#372). */}
{version !== undefined && <ChangelogContent installedVersion={version} />}
</section>
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"> <div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" /> <ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p> <p>{t("settings.dataSafeNotice")}</p>

View file

@ -94,50 +94,6 @@ describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
}); });
}); });
// The categories query keeps active rows only: a deleted (inactive) parent is
// absent from the list, like a parent that no longer exists (#393).
describe("getBudgetVsActualData — a category whose parent is deleted or gone (#393)", () => {
const answer = (categories: unknown[]) =>
mockSelect.mockImplementation((sql: string) => {
if (sql.includes("FROM categories")) return Promise.resolve(categories);
if (sql.includes("FROM budget_entries")) {
return Promise.resolve([{ id: 1, category_id: 7, year: 2026, month: 3, amount: 50 }]);
}
if (sql.includes("FROM transactions")) return Promise.resolve([{ category_id: 7, actual: -30 }]);
return Promise.resolve([]);
});
const category = (id: number, name: string, parent_id: number | null, is_inputable = 1) => ({
id,
name,
type: "expense",
parent_id,
is_active: 1,
is_inputable,
sort_order: id,
});
it("shows it as a root row, with its budget and its actuals", async () => {
answer([category(1, "Logement", null, 0), category(2, "Loyer", 1), category(7, "Cadeaux", 99)]);
const rows = await getBudgetVsActualData(2026, 3);
expect(rows.find((r) => r.category_id === 7)).toMatchObject({
parent_id: null,
depth: 0,
is_parent: false,
monthBudget: -50,
monthActual: -30,
});
});
it("shows a parent whose own parent is absent as a root group", async () => {
answer([category(6, "Fêtes", 99, 0), category(7, "Cadeaux", 6)]);
const rows = await getBudgetVsActualData(2026, 3);
expect(rows.map((r) => [r.category_id, r.parent_id, r.depth, r.is_parent])).toEqual([
[6, null, 0, true],
[7, 6, 1, false],
]);
});
});
// getActualTotalsForYear is the previous-year actuals reference column // getActualTotalsForYear is the previous-year actuals reference column
// consumed by useBudget (Issue #276) — a thin wrapper over the same // consumed by useBudget (Issue #276) — a thin wrapper over the same
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through. // getActualsByCategoryRange helper, so it gets the same accountIds pass-through.

View file

@ -2,7 +2,6 @@ import type Database from "@tauri-apps/plugin-sql";
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents"; import { notifyDataChanged } from "./dataEvents";
import { inPlaceholders } from "../utils/sqlFilters"; import { inPlaceholders } from "../utils/sqlFilters";
import { displaysAsRoot } from "../utils/categoryHierarchy";
import type { import type {
Category, Category,
BudgetEntry, BudgetEntry,
@ -410,10 +409,7 @@ export async function getBudgetVsActualData(
} }
const rows: BudgetVsActualRow[] = []; const rows: BudgetVsActualRow[] = [];
// A category whose parent is deactivated or gone (not in the active list) const topLevel = allCategories.filter((c) => !c.parent_id);
// stands at the top level, as in the tree and the pickers (#393).
const activeIds = new Set(allCategories.map((c) => c.id));
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
for (const cat of topLevel) { for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || []; const children = childrenByParent.get(cat.id) || [];

View file

@ -1,397 +0,0 @@
// 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.
//
// 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 »).
import { describe, it, expect, vi, beforeEach } from "vitest";
const fake = vi.hoisted(() => ({
inTransaction: false,
global: null as unknown,
handle: null as unknown,
}));
vi.mock("./db", () => ({
getDb: vi.fn(async () => fake.global),
withTransaction: vi.fn(async (fn: (db: unknown) => Promise<unknown>) => {
fake.inTransaction = true;
try {
return await fn(fake.handle);
} finally {
fake.inTransaction = false;
}
}),
}));
import { withTransaction } from "./db";
import {
CategoryHierarchyError,
categoryHierarchyErrorKey,
createCategory,
findOrphanCategories,
getCategoryDepth,
reattachCategory,
updateCategory,
} from "./categoryService";
import { buildTree } from "../hooks/useCategories";
import type { CategoryTreeNode } from "../shared/types";
interface Row {
id: number;
name: string;
parent_id: number | null;
is_active: number;
is_inputable: number;
}
const row = (id: number, parent_id: number | null, extra: Partial<Row> = {}): Row => ({
id,
name: `c${id}`,
parent_id,
is_active: 1,
is_inputable: 1,
...extra,
});
const HIERARCHY_SQL = "SELECT id, parent_id, is_active FROM categories";
/**
* The categories table and the direct transaction counts. Every statement is
* logged with the connection it went through ("handle" or "global").
*/
function useFakeDb(initial: Row[], transactions: Record<number, number> = {}) {
let rows = initial.map((r) => ({ ...r }));
let beforeTransaction: Row[] | null = null;
const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = [];
const select = async (sql: string, params: unknown[] = []) => {
if (sql === HIERARCHY_SQL) {
return rows.map(({ id, parent_id, is_active }) => ({ id, parent_id, is_active }));
}
if (sql.includes("COUNT(*) AS cnt FROM transactions WHERE category_id = $1")) {
return [{ cnt: transactions[params[0] as number] ?? 0 }];
}
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);
return found ? [{ parent_id: found.parent_id }] : [];
}
throw new Error(`unexpected SELECT: ${sql}`);
};
const execute = async (sql: string, params: unknown[] = []) => {
if (sql === "BEGIN") {
beforeTransaction = rows.map((r) => ({ ...r }));
} else if (sql === "COMMIT") {
beforeTransaction = null;
} else if (sql === "ROLLBACK") {
if (beforeTransaction) rows = beforeTransaction;
beforeTransaction = null;
} else if (sql.startsWith("UPDATE categories SET name = $1")) {
const [name, , , parent_id, is_inputable, , id] = params as [string, string, string, number | null, number, number, number];
rows = rows.map((r) => (r.id === id ? { ...r, name, parent_id, is_inputable } : r));
} else if (sql.startsWith("UPDATE categories SET parent_id = $1 WHERE id = $2")) {
const [parent_id, id] = params as [number | null, number];
rows = rows.map((r) => (r.id === id ? { ...r, parent_id } : r));
} else if (sql.startsWith("UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1")) {
rows = rows.map((r) => (r.id === params[0] && r.is_inputable === 1 ? { ...r, is_inputable: 0 } : r));
} else if (sql.startsWith("INSERT INTO categories")) {
const [name, , , parent_id, is_inputable] = params as [string, string, string, number | null, number];
const id = Math.max(0, ...rows.map((r) => r.id)) + 1;
rows = [...rows, { id, name, parent_id, is_active: 1, is_inputable }];
return { rowsAffected: 1, lastInsertId: id };
} else {
throw new Error(`unexpected execute: ${sql}`);
}
return { rowsAffected: 1 };
};
const connection = (via: "handle" | "global") => ({
select: vi.fn(async (sql: string, params?: unknown[]) => {
if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
log.push({ via, sql, params: params ?? [] });
return select(sql, params);
}),
execute: vi.fn(async (sql: string, params?: unknown[]) => {
if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
log.push({ via, sql, params: params ?? [] });
return execute(sql, params);
}),
});
fake.global = connection("global");
fake.handle = connection("handle");
return {
rows: () => rows,
log,
/** The statements that wrote, in order (BEGIN/COMMIT/ROLLBACK included). */
writes: () => log.filter((l) => !l.sql.startsWith("SELECT")).map((l) => l.sql.split(" WHERE")[0]),
};
}
const form = (parent_id: number | null, extra: { name?: string; is_inputable?: boolean } = {}) => ({
name: extra.name ?? "Renamed",
type: "expense",
color: "#4A90A4",
parent_id,
is_inputable: extra.is_inputable ?? true,
sort_order: 1,
});
/** What `updateCategory` or `reattachCategory` rejected with. */
async function refusal(promise: Promise<unknown>): Promise<CategoryHierarchyError> {
const error = await promise.then(
() => null,
(e: unknown) => e,
);
expect(error).toBeInstanceOf(CategoryHierarchyError);
return error as CategoryHierarchyError;
}
/** The tree of the Categories page, built from the active rows as the page loads them. */
const visibleIds = (rows: Row[]): number[] => {
const flat: CategoryTreeNode[] = rows
.filter((r) => r.is_active === 1)
.map((r) => ({
id: r.id,
name: r.name,
parent_id: r.parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: r.is_inputable === 1,
sort_order: r.id,
keyword_count: 0,
i18n_key: null,
children: [],
}));
const ids: number[] = [];
const walk = (nodes: CategoryTreeNode[]) => {
for (const node of nodes) {
ids.push(node.id);
walk(node.children);
}
};
walk(buildTree(flat));
return ids.sort((a, b) => a - b);
};
// 1 -> 10 -> 100, 1 -> 11; 2 -> 20.
const TREE = [row(1, null), row(10, 1), row(100, 10), row(11, 1), row(2, null), row(20, 2)];
beforeEach(() => {
fake.inTransaction = false;
vi.mocked(withTransaction).mockClear();
});
describe("updateCategory — regression of #393: A -> B -> A", () => {
it("refuses to put a category under its own child, writes nothing, rolls back", async () => {
// A (1) is a root, B (2) its child. Editing A with parent B closed the
// loop in one save and hid both everywhere.
const db = useFakeDb([row(1, null), row(2, 1)]);
const error = await refusal(updateCategory(1, form(2)));
expect(error.code).toBe("cycle");
expect(db.rows().find((r) => r.id === 1)?.parent_id).toBeNull();
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
expect(visibleIds(db.rows())).toEqual([1, 2]);
});
it("refuses the category itself as its parent", async () => {
useFakeDb(TREE);
expect((await refusal(updateCategory(11, form(11)))).code).toBe("self_parent");
});
it("refuses a grandchild as the parent", async () => {
useFakeDb(TREE);
expect((await refusal(updateCategory(1, form(100)))).code).toBe("cycle");
});
it("refuses a fourth level, counting the sub-tree of the category", async () => {
const db = useFakeDb(TREE);
// Root 2 carries 20: under 10 (level 1), 20 would land at level 3.
expect((await refusal(updateCategory(2, form(10)))).code).toBe("too_deep");
// A leaf under 100 (level 2) would be level 3.
expect((await refusal(updateCategory(20, form(100)))).code).toBe("too_deep");
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
});
it("refuses a new parent that holds transactions of its own", async () => {
const db = useFakeDb(TREE, { 11: 3 });
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions");
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2);
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
});
it("moves a category under a valid parent, which stops taking input, in one transaction through its handle", async () => {
const db = useFakeDb(TREE);
await updateCategory(20, form(11, { is_inputable: false }));
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",
]);
// Every statement, reads included, went through the transaction's handle.
expect(db.log.every((l) => l.via === "handle")).toBe(true);
expect(withTransaction).toHaveBeenCalledTimes(1);
const moved = db.rows().find((r) => r.id === 20);
expect(moved?.parent_id).toBe(11);
expect(moved?.name).toBe("Renamed");
// The flag is bound as the integer 0, never the boolean false.
const update = db.log.find((l) => l.sql.startsWith("UPDATE categories SET name"));
expect(update?.params[4]).toBe(0);
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
});
it("accepts the root, without touching any other category", async () => {
const db = useFakeDb(TREE);
await updateCategory(10, form(null));
expect(db.rows().find((r) => r.id === 10)?.parent_id).toBeNull();
expect(db.writes()).toEqual([
"BEGIN",
"UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
"COMMIT",
]);
});
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 });
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.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]);
});
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(
() => null,
(e: unknown) => e,
);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(CategoryHierarchyError);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
});
});
describe("CategoryHierarchyError", () => {
it("carries its code and names the i18n key of its message", () => {
const error = new CategoryHierarchyError("too_deep");
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe("CategoryHierarchyError");
expect(error.code).toBe("too_deep");
expect(categoryHierarchyErrorKey("parent_has_transactions")).toBe(
"categories.hierarchy.errors.parent_has_transactions",
);
});
});
describe("findOrphanCategories (#393)", () => {
it("reads every category with the dedicated query, inactive ones included", async () => {
const db = useFakeDb([row(1, null), row(2, null, { is_active: 0 }), row(20, 2), row(30, 99)]);
expect(await findOrphanCategories()).toEqual([
{ id: 20, reason: "inactive_parent" },
{ id: 30, reason: "missing_parent" },
]);
expect(db.log.map((l) => l.sql)).toEqual([HIERARCHY_SQL]);
});
});
describe("reattachCategory on rows already in a cycle (#393)", () => {
it("a two-member cycle: found by the diagnostic, broken at the root, back in the tree", async () => {
// 5 <-> 3, 8 under 5: none of them is reachable from a root.
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5), row(8, 5)]);
expect(visibleIds(db.rows())).toEqual([1]);
expect(await findOrphanCategories()).toEqual([{ id: 3, reason: "cycle" }]);
expect(await reattachCategory(3, null)).toBe(3);
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBeNull();
expect(await findOrphanCategories()).toEqual([]);
expect(visibleIds(db.rows())).toEqual([1, 3, 5, 8]);
expect(db.log.filter((l) => l.via === "global").map((l) => l.sql)).toEqual([HIERARCHY_SQL, HIERARCHY_SQL]);
});
it("a three-member cycle: too deep under a root, broken at the root, back in the tree", async () => {
// 6 -> 4 -> 9 -> 6, nothing under them. Broken at 4, the branch is 4 -> 6 -> 9:
// three levels, so it fits at the root and not under root 1.
const db = useFakeDb([row(1, null), row(6, 4), row(4, 9), row(9, 6)]);
expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
// Under root 1 the branch (4, 6, 9) would reach level 3: refused, nothing written.
expect((await refusal(reattachCategory(4, 1))).code).toBe("too_deep");
expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
expect(await reattachCategory(4, null)).toBe(3);
expect(await findOrphanCategories()).toEqual([]);
expect(visibleIds(db.rows())).toEqual([1, 4, 6, 9]);
});
it("moves a cycle member under a sound parent, which stops taking input", async () => {
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)]);
expect(await reattachCategory(3, 1)).toBe(2);
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(1);
expect(db.rows().find((r) => r.id === 1)?.is_inputable).toBe(0);
expect(db.writes()).toEqual([
"BEGIN",
"UPDATE categories SET parent_id = $1",
"UPDATE categories SET is_inputable = 0",
"COMMIT",
]);
expect(db.log.every((l) => l.via === "handle")).toBe(true);
expect(visibleIds(db.rows())).toEqual([1, 3, 5]);
});
it("refuses the other member of the cycle and a parent with transactions, writing nothing", async () => {
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)], { 1: 2 });
expect((await refusal(reattachCategory(3, 5))).code).toBe("cycle");
expect((await refusal(reattachCategory(3, 1))).code).toBe("parent_has_transactions");
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(5);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
});
it("reattaches a category whose parent was deleted, counting its active descendants only", async () => {
const db = useFakeDb([
row(1, null),
row(2, null, { is_active: 0 }),
row(20, 2),
row(21, 20),
row(22, 20, { is_active: 0 }),
]);
expect(await reattachCategory(20, 1)).toBe(2);
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(1);
expect(await findOrphanCategories()).toEqual([]);
});
});
describe("getCategoryDepth (#393)", () => {
it("counts the active ancestors", async () => {
useFakeDb(TREE);
expect(await getCategoryDepth(1)).toBe(0);
expect(await getCategoryDepth(10)).toBe(1);
expect(await getCategoryDepth(100)).toBe(2);
});
it("ends on a cycle and on a category that is its own parent: no depth", async () => {
useFakeDb([row(5, 3), row(3, 5), row(8, 5), row(7, 7)]);
expect(await getCategoryDepth(5)).toBe(Number.POSITIVE_INFINITY);
expect(await getCategoryDepth(8)).toBe(Number.POSITIVE_INFINITY);
expect(await getCategoryDepth(7)).toBe(Number.POSITIVE_INFINITY);
});
it("so createCategory refuses a cycle member as the parent", async () => {
const db = useFakeDb([row(5, 3), row(3, 5)]);
await expect(
createCategory({ name: "New", type: "expense", color: "#000", parent_id: 5, is_inputable: true, sort_order: 1 }),
).rejects.toThrow("maximum depth");
expect(db.rows()).toHaveLength(2);
});
});

View file

@ -1,112 +1,5 @@
import type Database from "@tauri-apps/plugin-sql"; import { getDb } from "./db";
import { getDb, withTransaction } from "./db";
import type { Keyword } from "../shared/types"; import type { Keyword } from "../shared/types";
import { toSqlFlag } from "../utils/sqlFlag";
import {
descendantsOf,
findOrphans,
isActiveRow,
parentChangeRefusal,
type HierarchyRow,
type OrphanCategory,
} 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";
/**
* Thrown when `updateCategory` or `reattachCategory` refuses a new 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.
*/
export class CategoryHierarchyError extends Error {
readonly code: CategoryHierarchyErrorCode;
constructor(code: CategoryHierarchyErrorCode, message?: string) {
super(message ?? `New parent refused by the category hierarchy rules: ${code}`);
this.name = "CategoryHierarchyError";
this.code = code;
}
}
/** The i18n key of a refusal's message. */
export function categoryHierarchyErrorKey(code: CategoryHierarchyErrorCode): string {
return `categories.hierarchy.errors.${code}`;
}
/**
* 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.
*/
const HIERARCHY_ROWS_SQL = "SELECT id, parent_id, is_active FROM categories";
async function readHierarchyRows(db: Database): Promise<HierarchyRow[]> {
return db.select<HierarchyRow[]>(HIERARCHY_ROWS_SQL);
}
/** The orphan categories of the profile, one per group to reattach (`findOrphans`, #393). */
export async function findOrphanCategories(): Promise<OrphanCategory[]> {
const db = await getDb();
return findOrphans(await readHierarchyRows(db));
}
/** A category that receives a child stops taking input, as in `createCategory`. */
async function markAsParent(db: Database, parentId: number): Promise<void> {
await db.execute(
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
[parentId]
);
}
/**
* 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.
*/
async function checkNewParent(
db: Database,
rows: HierarchyRow[],
id: number,
parentId: number | null
): Promise<void> {
if (parentId === null) return;
const active = rows.filter(isActiveRow);
// Unreachable from the UI: both pickers list active categories only.
if (parentId !== id && !active.some((r) => r.id === parentId)) {
throw new Error("Parent category not found or inactive");
}
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");
}
/** Runs `body` between BEGIN and COMMIT on the transaction handle, ROLLBACK on failure. */
async function inTransaction<T>(db: Database, body: () => Promise<T>): Promise<T> {
await db.execute("BEGIN");
try {
const result = await body();
await db.execute("COMMIT");
return result;
} catch (e) {
try {
await db.execute("ROLLBACK");
} catch {
// Keep the original error: it is the one that explains the failure.
}
throw e;
}
}
interface CategoryRow { interface CategoryRow {
id: number; id: number;
@ -134,20 +27,11 @@ export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
); );
} }
/**
* Number of active ancestors of a category: 0 for a root. A category caught in
* a `parent_id` cycle, or under one, has no depth: the walk keeps the ids it
* has seen and returns `Infinity` on the first one seen twice (#393), which
* `createCategory` refuses as a parent.
*/
export async function getCategoryDepth(categoryId: number): Promise<number> { export async function getCategoryDepth(categoryId: number): Promise<number> {
const db = await getDb(); const db = await getDb();
let depth = 0; let depth = 0;
let currentId: number | null = categoryId; let currentId: number | null = categoryId;
const visited = new Set<number>();
while (currentId !== null) { while (currentId !== null) {
if (visited.has(currentId)) return Number.POSITIVE_INFINITY;
visited.add(currentId);
const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>( const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>(
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`, `SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
[currentId] [currentId]
@ -184,22 +68,15 @@ export async function createCategory(data: {
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0 // Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
if (data.parent_id !== null) { if (data.parent_id !== null) {
await markAsParent(db, data.parent_id); await db.execute(
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
[data.parent_id]
);
} }
return result.lastInsertId as number; return result.lastInsertId as number;
} }
/**
* Saves the category form. A new parent goes through the hierarchy rules
* (#393, `checkNewParent`): `CategoryHierarchyError` on a refusal, nothing
* written. The reads and the writes (the row, then the new parent's
* `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.
*/
export async function updateCategory( export async function updateCategory(
id: number, id: number,
data: { data: {
@ -211,43 +88,10 @@ export async function updateCategory(
sort_order: number; sort_order: number;
} }
): Promise<void> { ): Promise<void> {
const parentId = data.parent_id ?? null; const db = await getDb();
await withTransaction((db) => await db.execute(
inTransaction(db, async () => { `UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
const rows = await readHierarchyRows(db); [data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id]
const current = rows.find((r) => r.id === id);
if (current === undefined) throw new Error("Category not found");
const parentChanged = (current.parent_id ?? null) !== parentId;
if (parentChanged) await checkNewParent(db, rows, id, parentId);
await db.execute(
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
[data.name, data.type, data.color, parentId, toSqlFlag(data.is_inputable), data.sort_order, id]
);
if (parentChanged && parentId !== null) await markAsParent(db, parentId);
})
);
}
/**
* Moves a category under `parentId`, or to the root with `null`: the repair
* of the « Vérifier la hiérarchie » window (#393). Same rules as a parent
* change in `updateCategory`, always checked, in one transaction through its
* handle; only `parent_id` changes, and the new parent's `is_inputable`.
*
* Returns the number of categories put back in place: the category and every
* active category under it (for a member of a cycle, the whole cycle and what
* hangs under it).
*/
export async function reattachCategory(id: number, parentId: number | null): Promise<number> {
return withTransaction((db) =>
inTransaction(db, async () => {
const rows = await readHierarchyRows(db);
if (!rows.some((r) => r.id === id)) throw new Error("Category not found");
await checkNewParent(db, rows, id, parentId);
await db.execute(`UPDATE categories SET parent_id = $1 WHERE id = $2`, [parentId, id]);
if (parentId !== null) await markAsParent(db, parentId);
return 1 + descendantsOf(rows.filter(isActiveRow), id).size;
})
); );
} }
@ -296,13 +140,6 @@ export async function fixDuplicateSortOrders(): Promise<void> {
} }
} }
/**
* Writes the order and the parent of every category after a drag-and-drop in
* the tree. Unlike `updateCategory`, no hierarchy check here (#393, out of its
* scope): its only caller, `moveCategory` of `useCategories`, cannot drop a
* category inside its own sub-tree (the drop target is not found, nothing is
* written).
*/
export async function updateCategorySortOrders( export async function updateCategorySortOrders(
updates: Array<{ id: number; sort_order: number; parent_id: number | null }> updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
): Promise<void> { ): Promise<void> {

View file

@ -736,7 +736,7 @@ describe("describePreparation (#390)", () => {
expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'"); expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'");
}); });
it.each([["\""], [";"], ["|"], ["`"], ["\n"], ["\r"]])("withoutFolders ends the folders at %j", (stop) => { it.each([["\""], [";"], ["|"], ["`"], ["\n"]])("withoutFolders ends the folders at %j", (stop) => {
expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`); expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`);
}); });

View file

@ -380,11 +380,10 @@ function lastSeparator(text: string, from: number): number {
* (Loi 25). A folder may contain blanks (`C:\Users\Jean Dupont\…`, * (Loi 25). A folder may contain blanks (`C:\Users\Jean Dupont\…`,
* `/home/marie claire/…`): from its start, a path runs to its LAST separator * `/home/marie claire/…`): from its start, a path runs to its LAST separator
* before a line break, a double quote, a backtick, `;`, `|`, a `,` or `:` * before a line break, a double quote, a backtick, `;`, `|`, a `,` or `:`
* followed by a blank, a closing parenthesis not followed by a separator, or a * followed by a blank, a closing parenthesis, or a blank followed by another
* blank followed by another path. When nothing ends it, the rule is * path. When nothing ends it, the rule is conservative: text after a path is
* conservative: text after a path is dropped up to the last separator of the * dropped up to the last separator of the line (`/a/x.db then I/O` gives
* line (`/a/x.db then I/O` gives `O`), rather than letting a folder through. * `O`), rather than letting a folder through. A quote right before the path is
* Limits in ADR 0021, section 7. A quote right before the path is
* not part of it, so both quotes stay around the file name * not part of it, so both quotes stay around the file name
* (`'C:\Users\Bob\x.db'` gives `'x.db'`). Any other separator becomes `-` (SQLite's "disk I/O error" reads * (`'C:\Users\Bob\x.db'` gives `'x.db'`). Any other separator becomes `-` (SQLite's "disk I/O error" reads
* "disk I-O error"), so an error line never contains a `/` nor a `\` (#395). * "disk I-O error"), so an error line never contains a `/` nor a `\` (#395).

View file

@ -1,220 +0,0 @@
// categoryHierarchy — the rules of the category hierarchy (#393): depths,
// heights and descent, the orphan diagnostic and the validation of a new
// parent. Every walk must end on a `parent_id` cycle: a test that loops runs
// into vitest's timeout and goes red.
import { describe, it, expect } from "vitest";
import {
MAX_CATEGORY_DEPTH,
depthsOf,
descendantsOf,
displaysAsRoot,
findOrphans,
fitsUnder,
isActiveRow,
parentChangeRefusal,
subtreeHeight,
type HierarchyNode,
type HierarchyRow,
} from "./categoryHierarchy";
const n = (id: number, parent_id: number | null): HierarchyNode => ({ id, parent_id });
/** An active row, or an inactive one with `active = false`. */
const r = (id: number, parent_id: number | null, active = true): HierarchyRow => ({
id,
parent_id,
is_active: active ? 1 : 0,
});
// Two roots. 1 -> 10 -> 100 and 1 -> 11; 2 -> 20.
const TREE = [n(1, null), n(10, 1), n(100, 10), n(11, 1), n(2, null), n(20, 2)];
const depthEntries = (nodes: HierarchyNode[]) =>
[...depthsOf(nodes).entries()].sort(([a], [b]) => a - b);
describe("depthsOf", () => {
it("gives 0 to a root, 1 to its child, 2 to a grandchild", () => {
expect(depthEntries(TREE)).toEqual([
[1, 0],
[2, 0],
[10, 1],
[11, 1],
[20, 1],
[100, 2],
]);
});
it("does not depend on the order of the list", () => {
expect(depthEntries([...TREE].reverse())).toEqual(depthEntries(TREE));
});
it("treats a category whose parent is not in the list as a root", () => {
expect(depthEntries([n(10, 1), n(100, 10)])).toEqual([
[10, 0],
[100, 1],
]);
});
it("gives no depth to the members of a cycle nor to what hangs under it, and ends", () => {
// 1 <-> 2, 3 under 1, 4 under 3; 5 is a sound root.
const depths = depthsOf([n(3, 1), n(1, 2), n(2, 1), n(4, 3), n(5, null)]);
expect([...depths.entries()]).toEqual([[5, 0]]);
});
it("gives no depth to a category that is its own parent", () => {
expect(depthsOf([n(7, 7), n(8, 7)]).size).toBe(0);
});
it("ends on a three-member cycle reached from below", () => {
const depths = depthsOf([n(9, 3), n(3, 2), n(2, 1), n(1, 3)]);
expect(depths.size).toBe(0);
});
});
describe("subtreeHeight and descendantsOf", () => {
it("count the levels and list the categories under a category", () => {
expect(subtreeHeight(TREE, 1)).toBe(2);
expect(subtreeHeight(TREE, 10)).toBe(1);
expect(subtreeHeight(TREE, 100)).toBe(0);
expect([...descendantsOf(TREE, 1)].sort((a, b) => a - b)).toEqual([10, 11, 100]);
expect(descendantsOf(TREE, 100).size).toBe(0);
});
it("end on a cycle, each category once, the start never listed", () => {
// 1 <-> 2, 3 under 2.
const cyclic = [n(1, 2), n(2, 1), n(3, 2)];
expect(subtreeHeight(cyclic, 1)).toBe(2);
expect([...descendantsOf(cyclic, 1)].sort((a, b) => a - b)).toEqual([2, 3]);
expect(subtreeHeight([n(7, 7)], 7)).toBe(0);
expect(descendantsOf([n(7, 7)], 7).size).toBe(0);
});
});
describe("fitsUnder", () => {
it("allows three levels, 0 to 2, counting the sub-tree", () => {
expect(MAX_CATEGORY_DEPTH).toBe(2);
expect(fitsUnder(0, 0)).toBe(true);
expect(fitsUnder(1, 0)).toBe(true);
expect(fitsUnder(0, 1)).toBe(true);
expect(fitsUnder(2, 0)).toBe(false);
expect(fitsUnder(1, 1)).toBe(false);
expect(fitsUnder(0, 2)).toBe(false);
});
});
describe("displaysAsRoot", () => {
const ids = new Set([1, 2, 10]);
it("is a root without a parent, or with a parent missing from the list", () => {
expect(displaysAsRoot({ id: 1, parent_id: null }, ids)).toBe(true);
expect(displaysAsRoot({ id: 1 }, ids)).toBe(true);
expect(displaysAsRoot({ id: 30, parent_id: 99 }, ids)).toBe(true);
});
it("is not a root under a parent of the list, a cycle member included", () => {
expect(displaysAsRoot({ id: 10, parent_id: 1 }, ids)).toBe(false);
expect(displaysAsRoot({ id: 1, parent_id: 2 }, ids)).toBe(false);
});
});
describe("isActiveRow", () => {
it("reads 1 (or true) as active, anything else as inactive, as `is_active = 1` does", () => {
expect(isActiveRow(r(1, null))).toBe(true);
expect(isActiveRow({ id: 1, parent_id: null, is_active: true })).toBe(true);
expect(isActiveRow(r(1, null, false))).toBe(false);
expect(isActiveRow({ id: 1, parent_id: null, is_active: false })).toBe(false);
});
});
describe("findOrphans (#393)", () => {
it("finds nothing in a sound tree", () => {
expect(findOrphans(TREE.map((c) => r(c.id, c.parent_id ?? null)))).toEqual([]);
});
it("finds a category that is its own parent", () => {
expect(findOrphans([r(1, null), r(7, 7), r(8, 7)])).toEqual([{ id: 7, reason: "cycle" }]);
});
it("lists a two-member cycle once, by its smallest id, not what hangs under it", () => {
// 5 <-> 3, 8 under 5, 9 under 8. Read from below first.
const rows = [r(9, 8), r(8, 5), r(5, 3), r(3, 5), r(1, null)];
expect(findOrphans(rows)).toEqual([{ id: 3, 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)];
expect(findOrphans(rows)).toEqual([{ id: 4, reason: "cycle" }]);
expect(findOrphans([...rows].reverse())).toEqual([{ id: 4, reason: "cycle" }]);
});
it("names a deactivated parent apart from a missing one, and only the head of each group", () => {
// 20's parent (2) is inactive; 21 under 20 follows it. 30's parent (99) does not exist.
const rows = [r(1, null), r(2, null, false), r(21, 20), r(20, 2), r(30, 99), r(31, 30)];
expect(findOrphans(rows)).toEqual([
{ id: 20, reason: "inactive_parent" },
{ id: 30, reason: "missing_parent" },
]);
});
it("never lists an inactive category, nor a cycle among inactive ones", () => {
const rows = [r(1, null), r(2, 3, false), r(3, 2, false), r(4, 4, false)];
expect(findOrphans(rows)).toEqual([]);
});
it("reports a loop that runs through a deactivated category as the deactivated parent", () => {
// 5 -> 6 (inactive) -> 5: the tree shows 5 at the root, its parent being inactive.
expect(findOrphans([r(5, 6), r(6, 5, false)])).toEqual([{ id: 5, reason: "inactive_parent" }]);
});
it("lists several groups, sorted by id", () => {
const rows = [r(40, 41), r(41, 40), r(15, 15), r(2, 77), r(1, null)];
expect(findOrphans(rows)).toEqual([
{ id: 2, reason: "missing_parent" },
{ id: 15, reason: "cycle" },
{ id: 40, reason: "cycle" },
]);
});
});
describe("parentChangeRefusal (#393)", () => {
it("accepts the root, always, even for a branch three levels deep or a cycle member", () => {
expect(parentChangeRefusal(TREE, 1, null)).toBeNull();
expect(parentChangeRefusal([n(1, 2), n(2, 1)], 1, null)).toBeNull();
});
it("refuses the category itself", () => {
expect(parentChangeRefusal(TREE, 11, 11)).toBe("self_parent");
});
it("refuses a child and a grandchild: the move would close a loop", () => {
expect(parentChangeRefusal(TREE, 1, 10)).toBe("cycle");
expect(parentChangeRefusal(TREE, 1, 100)).toBe("cycle");
});
it("refuses a parent caught in another cycle: the category would be hidden with it", () => {
const nodes = [...TREE, n(50, 51), n(51, 50)];
expect(parentChangeRefusal(nodes, 20, 50)).toBe("cycle");
});
it("refuses a fourth level, counting the sub-tree of the category", () => {
// 100 is level 2: a child under it would be level 3.
expect(parentChangeRefusal(TREE, 20, 100)).toBe("too_deep");
// Root 2 has a child (20): under 10 (level 1), 20 would land at level 3.
expect(parentChangeRefusal(TREE, 2, 10)).toBe("too_deep");
// Root 1 has grandchildren: under any parent, 100 would land at level 3.
expect(parentChangeRefusal(TREE, 1, 2)).toBe("too_deep");
});
it("accepts a valid move", () => {
expect(parentChangeRefusal(TREE, 20, 10)).toBeNull(); // a leaf under a level-1 parent
expect(parentChangeRefusal(TREE, 2, 1)).toBeNull(); // a root with one level under a root
expect(parentChangeRefusal(TREE, 11, 2)).toBeNull();
});
it("moves a member of a cycle under a sound parent when the branch fits, and ends", () => {
// 1 <-> 2 with nothing under them; 3 is a sound root.
const nodes = [n(1, 2), n(2, 1), n(3, null)];
expect(parentChangeRefusal(nodes, 1, 3)).toBeNull();
// ... but never under the other member.
expect(parentChangeRefusal(nodes, 1, 2)).toBe("cycle");
});
});

View file

@ -1,237 +0,0 @@
// categoryHierarchy — the rules of the category hierarchy, in one place (#393).
//
// Three consumers read them: the parent list of the category form
// (`buildParentOptions`, #423), the orphan diagnostic of the Categories page
// (`findOrphans`) and the validation of a parent change in `updateCategory`
// and `reattachCategory` (`parentChangeRefusal`). Every walk, up a parent chain
// or down the children, keeps a set of the categories it has seen: a
// `parent_id` cycle stored in the profile ends the walk instead of looping.
//
// Levels: a root sits at depth 0, its children at 1, their children at 2.
// Three levels at most: the budget grid stops at the grandchildren, so a
// fourth level would drop out of it.
/** What the hierarchy rules read of a category. */
export interface HierarchyNode {
id: number;
/** `null` (or absent) for a root. */
parent_id?: number | null;
}
/** A row of `SELECT id, parent_id, is_active FROM categories`, active or not. */
export interface HierarchyRow extends HierarchyNode {
/** `1` for an active category (SQLite's INTEGER flag). */
is_active: number | boolean;
}
/** Deepest depth a category may sit at: three levels, 0 to 2. */
export const MAX_CATEGORY_DEPTH = 2;
/** Whether the row is active, as the queries' `WHERE is_active = 1` reads it. */
export function isActiveRow(row: HierarchyRow): boolean {
return row.is_active === 1 || row.is_active === true;
}
/**
* Whether the category shows as a root among the categories `ids`: it has no
* parent, or a parent that is not in the list (deactivated, or gone). The
* tree, the category pickers and the budget all follow this rule (#393).
*/
export function displaysAsRoot(category: HierarchyNode, ids: ReadonlySet<number>): boolean {
return category.parent_id == null || !ids.has(category.parent_id);
}
/**
* Depth of each category among `categories`: 0 for a root, 1 for its child, 2
* for a grandchild. A category whose parent is not in the list counts as a
* root, as in the tree. A category caught in a `parent_id` cycle, or under
* one, has no depth: no root leads to it, and it is left out of the map.
*/
export function depthsOf(categories: readonly HierarchyNode[]): Map<number, number> {
const byId = new Map<number, HierarchyNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
/** Categories whose depth is known, or known to be none. */
const settled = new Set<number>();
for (const start of categories) {
if (settled.has(start.id)) continue;
// Walk up until a root, a settled category, or a category already seen on
// this walk: a cycle.
const path: number[] = [];
const seen = new Set<number>();
let node = start;
let topDepth: number | undefined;
for (;;) {
path.push(node.id);
seen.add(node.id);
const parent = node.parent_id == null ? undefined : byId.get(node.parent_id);
if (parent === undefined) {
topDepth = 0;
break;
}
if (settled.has(parent.id)) {
const above = depths.get(parent.id);
topDepth = above === undefined ? undefined : above + 1;
break;
}
if (seen.has(parent.id)) {
topDepth = undefined;
break;
}
node = parent;
}
// The last category of the path sits at `topDepth`, each one before it a
// level deeper; none has a depth when the walk ended in a cycle.
for (let i = path.length - 1, below = 0; i >= 0; i -= 1, below += 1) {
settled.add(path[i]);
if (topDepth !== undefined) depths.set(path[i], topDepth + below);
}
}
return depths;
}
/**
* The categories under `rootId`, level by level. Each category is taken once,
* so a cycle ends the walk, and `rootId` itself is never listed.
*/
function levelsBelow(categories: readonly HierarchyNode[], rootId: number): number[][] {
const childrenOf = new Map<number, number[]>();
for (const c of categories) {
if (c.parent_id == null) continue;
const bucket = childrenOf.get(c.parent_id) ?? [];
bucket.push(c.id);
childrenOf.set(c.parent_id, bucket);
}
const levels: number[][] = [];
const seen = new Set<number>([rootId]);
let frontier = [rootId];
for (;;) {
const next: number[] = [];
for (const id of frontier) {
for (const child of childrenOf.get(id) ?? []) {
if (seen.has(child)) continue;
seen.add(child);
next.push(child);
}
}
if (next.length === 0) return levels;
levels.push(next);
frontier = next;
}
}
/** Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with grandchildren. */
export function subtreeHeight(categories: readonly HierarchyNode[], rootId: number): number {
return levelsBelow(categories, rootId).length;
}
/** Every category under `rootId`, at any depth; never `rootId` itself, even inside a cycle. */
export function descendantsOf(categories: readonly HierarchyNode[], rootId: number): Set<number> {
return new Set(levelsBelow(categories, rootId).flat());
}
/**
* Whether a category whose sub-tree is `height` levels deep may sit under a
* parent at `parentDepth`: its deepest descendant must stay within the three
* levels.
*/
export function fitsUnder(parentDepth: number, height: number): boolean {
return parentDepth + 1 + height <= MAX_CATEGORY_DEPTH;
}
/** Why a parent change is refused (the service adds `parent_has_transactions`). */
export type HierarchyRefusal = "self_parent" | "cycle" | "too_deep";
/**
* Why category `id` may not move under `newParentId`, or `null` when it may.
* `active` are the active categories: depth and descent are counted on them,
* as the tree shows them. The caller has checked that `newParentId` is one of
* them.
*
* - The root (`null`) is always accepted: no place is shallower, and it is the
* repair of last resort for a category caught in a cycle.
* - `self_parent`: the category itself.
* - `cycle`: a category under it (the move would close a loop), or a category
* with no depth, caught in a cycle or under one (the move would hide it).
* - `too_deep`: its deepest descendant would land beyond the third level.
*/
export function parentChangeRefusal(
active: readonly HierarchyNode[],
id: number,
newParentId: number | null,
): HierarchyRefusal | null {
if (newParentId === null) return null;
if (newParentId === id) return "self_parent";
if (descendantsOf(active, id).has(newParentId)) return "cycle";
const parentDepth = depthsOf(active).get(newParentId);
if (parentDepth === undefined) return "cycle";
if (!fitsUnder(parentDepth, subtreeHeight(active, id))) return "too_deep";
return null;
}
/** Why an active category is out of the hierarchy (#393). */
export type OrphanReason = "cycle" | "inactive_parent" | "missing_parent";
export interface OrphanCategory {
/** The category to reattach; the categories under it follow it. */
id: number;
reason: OrphanReason;
}
/**
* The active categories the tree cannot place, one per group to reattach
* (#393), sorted by id:
* - `inactive_parent`, `missing_parent`: an active category whose parent is
* deactivated, or gone. The tree shows it as a root, out of its place.
* - `cycle`: for each `parent_id` cycle among active categories, its member
* with the smallest id. The cycle and everything under it are out of reach
* from the roots: hidden from the tree, the pickers, the budget and the
* reports. Reattaching that member breaks the cycle; the other members and
* their descendants come back under it.
* Only the head of each group is listed: a category under it follows it.
*
* `rows` are all the categories, inactive ones included: without them, a
* deactivated parent could not be told from a missing one.
*/
export function findOrphans(rows: readonly HierarchyRow[]): OrphanCategory[] {
const byId = new Map<number, HierarchyRow>();
for (const r of rows) byId.set(r.id, r);
const orphans: OrphanCategory[] = [];
/** Active categories whose group is known: visible, an orphan, or under one. */
const settled = new Set<number>();
for (const start of rows) {
if (!isActiveRow(start) || settled.has(start.id)) continue;
// Walk up the active parents until a root, a parent that is inactive or
// gone, a settled category, or a category already seen on this walk.
const path: number[] = [];
const seen = new Set<number>();
let node = start;
for (;;) {
path.push(node.id);
seen.add(node.id);
if (node.parent_id == null) break;
const parent = byId.get(node.parent_id);
if (parent === undefined) {
orphans.push({ id: node.id, reason: "missing_parent" });
break;
}
if (!isActiveRow(parent)) {
orphans.push({ id: node.id, reason: "inactive_parent" });
break;
}
if (settled.has(parent.id)) break;
if (seen.has(parent.id)) {
// The cycle runs from the parent to the end of the path; what comes
// before it on the path hangs under the cycle.
const members = path.slice(path.indexOf(parent.id));
orphans.push({ id: Math.min(...members), reason: "cycle" });
break;
}
node = parent;
}
for (const id of path) settled.add(id);
}
return orphans.sort((a, b) => a.id - b.id);
}

View file

@ -1,231 +0,0 @@
// Version history of Settings → System (#372): the parser and the rule that
// decides which versions show their notes when the history opens.
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
allVersionsExpanded,
initiallyExpandedVersions,
isPublishedVersion,
parseChangelog,
toggleAllVersions,
toggleVersion,
visibleChangelogEntries,
type ChangelogEntry,
} from "./changelog";
const ROOT = resolve(import.meta.dirname, "../..");
/** An entry with one section per heading, each with its items. */
function entry(version: string, sections: Record<string, string[]> = {}): ChangelogEntry {
return {
version,
sections: Object.entries(sections).map(([heading, items]) => ({ heading, items })),
};
}
const sorted = (set: Set<string>) => [...set].sort();
describe("parseChangelog", () => {
it("reads bracketed headings with a date, and headings without brackets", () => {
const entries = parseChangelog(
[
"# Changelog",
"",
"## [0.17.2] - 2026-10-18",
"",
"### Added",
"",
"- A",
"",
"## 0.3.2",
"",
"### Fixed",
"",
"- B",
].join("\n"),
);
expect(entries.map((e) => e.version)).toEqual(["0.17.2", "0.3.2"]);
});
it("gives each version its sections in order, with their items, bold marks kept", () => {
const [first, second] = parseChangelog(
[
"## [0.17.2] - 2026-10-18",
"### Added",
"- **Import**: A",
"- B",
"### Fixed",
"- C",
"## [0.17.1] - 2026-10-08",
"### Changed",
"- D",
].join("\n"),
);
expect(first).toEqual(
entry("0.17.2", { Added: ["**Import**: A", "B"], Fixed: ["C"] }),
);
expect(second).toEqual(entry("0.17.1", { Changed: ["D"] }));
});
it("reads an empty Unreleased heading as an entry without sections, in both languages", () => {
expect(parseChangelog("# Changelog\n\n## [Unreleased]\n\n## [0.17.2] - 2026-10-18\n")).toEqual([
entry("Unreleased"),
entry("0.17.2"),
]);
expect(parseChangelog("## [Non publié]\n\n## [0.17.2] - 2026-10-18\n")[0]).toEqual(entry("Non publié"));
});
it("ignores text outside of a section, a section before any version, and Windows line ends", () => {
const entries = parseChangelog(
[
"### Orphan",
"- before any version",
"## [1.0.0]",
"Some paragraph.",
"- item without a section",
"### Added",
"- kept",
].join("\r\n"),
);
expect(entries).toEqual([entry("1.0.0", { Added: ["kept"] })]);
});
it("reads nothing from text without a version heading", () => {
expect(parseChangelog("")).toEqual([]);
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
});
});
describe("isPublishedVersion", () => {
it("is true for a version number, false for the unreleased heading of either language", () => {
expect(isPublishedVersion("0.17.2")).toBe(true);
expect(isPublishedVersion("0.2.10")).toBe(true);
expect(isPublishedVersion("Unreleased")).toBe(false);
expect(isPublishedVersion("Non publié")).toBe(false);
});
});
describe("visibleChangelogEntries", () => {
it("hides an empty Unreleased heading, in both languages", () => {
const published = entry("0.17.2", { Added: ["A"] });
expect(visibleChangelogEntries([entry("Unreleased"), published])).toEqual([published]);
expect(visibleChangelogEntries([entry("Non publié"), published])).toEqual([published]);
});
it("hides an Unreleased heading whose sections have no item", () => {
const published = entry("0.17.2", { Added: ["A"] });
expect(visibleChangelogEntries([entry("Unreleased", { Added: [] }), published])).toEqual([published]);
});
it("keeps an Unreleased heading with content (development build)", () => {
const unreleased = entry("Unreleased", { Changed: ["Work in progress"] });
const published = entry("0.17.2", { Added: ["A"] });
expect(visibleChangelogEntries([unreleased, published])).toEqual([unreleased, published]);
});
it("keeps a published version even without any item", () => {
const bare = entry("0.2.2");
expect(visibleChangelogEntries([entry("0.2.3", { Added: ["A"] }), bare])).toContain(bare);
});
});
describe("initiallyExpandedVersions", () => {
const entries = [
entry("0.17.2", { Added: ["A"] }),
entry("0.17.1", { Changed: ["B"] }),
entry("0.3.2", { Fixed: ["C"] }),
];
it("unfolds the installed version alone", () => {
expect(sorted(initiallyExpandedVersions(entries, "0.17.1"))).toEqual(["0.17.1"]);
expect(sorted(initiallyExpandedVersions(entries, "0.3.2"))).toEqual(["0.3.2"]);
});
it("unfolds the first published version when the installed one is absent from the changelog", () => {
expect(sorted(initiallyExpandedVersions(entries, "0.18.0"))).toEqual(["0.17.2"]);
});
it("unfolds the first published version when the installed version is unknown", () => {
expect(sorted(initiallyExpandedVersions(entries, null))).toEqual(["0.17.2"]);
});
it("never takes an Unreleased heading with content for the first published version", () => {
const withUnreleased = [entry("Non publié", { Modifié: ["En cours"] }), ...entries];
expect(sorted(initiallyExpandedVersions(withUnreleased, null))).toEqual(["0.17.2"]);
expect(sorted(initiallyExpandedVersions(withUnreleased, "0.18.0"))).toEqual(["0.17.2"]);
expect(sorted(initiallyExpandedVersions(withUnreleased, "0.17.1"))).toEqual(["0.17.1"]);
});
it("unfolds nothing without any published version", () => {
expect(sorted(initiallyExpandedVersions([], "0.17.2"))).toEqual([]);
expect(sorted(initiallyExpandedVersions([entry("Unreleased", { Added: ["A"] })], null))).toEqual([]);
});
});
describe("toggleVersion", () => {
it("unfolds a folded version and folds an unfolded one, without touching the others or its input", () => {
const expanded = new Set(["0.17.2"]);
expect(sorted(toggleVersion(expanded, "0.17.1"))).toEqual(["0.17.1", "0.17.2"]);
expect(sorted(toggleVersion(expanded, "0.17.2"))).toEqual([]);
expect(sorted(expanded)).toEqual(["0.17.2"]);
});
});
describe("allVersionsExpanded and toggleAllVersions (the expand-all button)", () => {
const entries = [entry("0.17.2"), entry("0.17.1"), entry("0.3.2")];
it("unfolds every version while one or more is folded", () => {
expect(allVersionsExpanded(entries, new Set(["0.17.2"]))).toBe(false);
expect(sorted(toggleAllVersions(entries, new Set(["0.17.2"])))).toEqual(["0.17.1", "0.17.2", "0.3.2"]);
expect(sorted(toggleAllVersions(entries, new Set()))).toEqual(["0.17.1", "0.17.2", "0.3.2"]);
});
it("folds every version once all are unfolded", () => {
const all = new Set(["0.17.2", "0.17.1", "0.3.2"]);
expect(allVersionsExpanded(entries, all)).toBe(true);
expect(sorted(toggleAllVersions(entries, all))).toEqual([]);
});
it("is not all unfolded without any entry", () => {
expect(allVersionsExpanded([], new Set())).toBe(false);
});
});
describe("the real changelogs", () => {
// The app bundles both files (vite.config.ts copies them to public/), and
// getVersion() reads the version of tauri.conf.json. /release bumps that
// version and adds its heading to both files in the same commit, so the
// installed version is always found, in both languages.
const installed = (
JSON.parse(readFileSync(resolve(ROOT, "src-tauri/tauri.conf.json"), "utf-8")) as { version: string }
).version;
const files = {
en: parseChangelog(readFileSync(resolve(ROOT, "CHANGELOG.md"), "utf-8")),
fr: parseChangelog(readFileSync(resolve(ROOT, "CHANGELOG.fr.md"), "utf-8")),
};
it("read the unreleased heading first, then only version numbers, with and without brackets", () => {
for (const [lng, entries] of Object.entries(files)) {
const [unreleased, ...published] = entries;
expect(isPublishedVersion(unreleased.version), `${lng}: ${unreleased.version}`).toBe(false);
const notVersions = published.filter((e) => !isPublishedVersion(e.version)).map((e) => e.version);
expect(notVersions, lng).toEqual([]);
// Headings without brackets (`## 0.3.2`) are read too.
expect(published.map((e) => e.version), lng).toContain("0.3.2");
}
});
it("list the same published versions in both languages", () => {
const published = (entries: ChangelogEntry[]) =>
entries.filter((e) => isPublishedVersion(e.version)).map((e) => e.version);
expect(published(files.fr)).toEqual(published(files.en));
});
it("open on the installed version, in both languages", () => {
for (const [lng, entries] of Object.entries(files)) {
const visible = visibleChangelogEntries(entries);
expect(sorted(initiallyExpandedVersions(visible, installed)), lng).toEqual([installed]);
}
});
});

View file

@ -1,119 +0,0 @@
// Version history of Settings → System (#372): the parser of the bundled
// CHANGELOG files and the rule that decides which versions show their notes
// when the history opens. Pure: no fetch, no React. `UpdateCard` reads the
// notes of an available update on its own and does not use this module.
/** One `### ` heading of a version and its `- ` items. */
export interface ChangelogSection {
heading: string;
items: string[];
}
/** One `## ` heading of the changelog and its sections. */
export interface ChangelogEntry {
version: string;
sections: ChangelogSection[];
}
/**
* Parses a Keep a Changelog file, newest version first: one entry per `## `
* heading, bracketed (`## [0.17.2] - 2026-10-18` gives `0.17.2`) or not
* (`## 0.3.2`), each with its `### ` sections and their `- ` items. A line
* outside of a section is ignored.
*/
export function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: ChangelogSection | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
/**
* A released version (`0.17.2`), as opposed to the heading of what is not
* released yet, `[Unreleased]` in English and `[Non publié]` in French. The
* shape decides, not the label, so both languages read the same.
*/
export function isPublishedVersion(version: string): boolean {
return /^\d+\.\d+/.test(version);
}
/** No item under any of its sections: nothing to read under the title. */
function hasNoItem(entry: ChangelogEntry): boolean {
return entry.sections.every((section) => section.items.length === 0);
}
/**
* The entries the history shows: every one, except an unreleased heading
* with nothing under it, as right after a release. An unreleased heading
* with content (a development build) stays, and a published version always
* stays: the history is complete.
*/
export function visibleChangelogEntries(entries: ChangelogEntry[]): ChangelogEntry[] {
return entries.filter((entry) => isPublishedVersion(entry.version) || !hasNoItem(entry));
}
/**
* The versions whose notes show when the history opens: the installed
* version, or, when it is unknown (`null`) or absent from the changelog, the
* first published version, the newest. Every other version starts folded.
*/
export function initiallyExpandedVersions(
entries: ChangelogEntry[],
installedVersion: string | null,
): Set<string> {
const installed =
installedVersion === null ? undefined : entries.find((entry) => entry.version === installedVersion);
const opened = installed ?? entries.find((entry) => isPublishedVersion(entry.version));
return new Set(opened ? [opened.version] : []);
}
/** `expanded` with `version` unfolded when it was folded, folded when it was unfolded. */
export function toggleVersion(expanded: ReadonlySet<string>, version: string): Set<string> {
const next = new Set(expanded);
if (next.has(version)) {
next.delete(version);
} else {
next.add(version);
}
return next;
}
/** Every entry shows its notes: the expand-all button then folds them all. */
export function allVersionsExpanded(entries: ChangelogEntry[], expanded: ReadonlySet<string>): boolean {
return entries.length > 0 && entries.every((entry) => expanded.has(entry.version));
}
/** The expand-all button: folds every entry when all are unfolded, unfolds them all otherwise. */
export function toggleAllVersions(entries: ChangelogEntry[], expanded: ReadonlySet<string>): Set<string> {
return allVersionsExpanded(entries, expanded)
? new Set()
: new Set(entries.map((entry) => entry.version));
}

View file

@ -76,30 +76,6 @@ describe("reorderRows — subtotals at the bottom (issue #424)", () => {
expect(names(reorderRows(rows, false))).toEqual(["Autres", "Paie", "Emploi", "Alloc", "Gouv", "Revenus"]); expect(names(reorderRows(rows, false))).toEqual(["Autres", "Paie", "Emploi", "Alloc", "Gouv", "Revenus"]);
}); });
// A collapsed sub-group: its subtotal row comes with no child row (the
// children hidden by the folding), so its block is empty (#446).
// A
// Vide (collapsed sub-group)
// z <- follows the empty block of "Vide"
it("a collapsed sub-group closes on its own subtotal: the sibling leaf after it stays outside", () => {
const rows: Row[] = [sub("A", 0), sub("Vide", 1), leaf("z", 1)];
expect(names(reorderRows(rows, false))).toEqual(["Vide", "z", "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...
expect(names(reorderRows([...block, sub("B", 0), leaf("b", 1)], false))).toEqual([
"Vide",
"z",
"A",
"b",
"B",
]);
// ...or a root leaf.
expect(names(reorderRows([...block, leaf("R", 0)], false))).toEqual(["Vide", "z", "A", "R"]);
});
it("keeps every row exactly once", () => { it("keeps every row exactly once", () => {
const out = reorderRows(TWO_LEVELS, false); const out = reorderRows(TWO_LEVELS, false);
expect(out).toHaveLength(TWO_LEVELS.length); expect(out).toHaveLength(TWO_LEVELS.length);