Compare commits
5 commits
dad81fa53f
...
a64ac3b8a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64ac3b8a8 | ||
|
|
038bc41cb3 | ||
|
|
66bdb19f44 | ||
|
|
3d917ed803 | ||
|
|
11eb911657 |
22 changed files with 871 additions and 103 deletions
|
|
@ -10,11 +10,13 @@
|
||||||
|
|
||||||
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
|
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus 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).
|
- 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).
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), un parent qui créerait un quatrième niveau et un parent qui a des transactions à son nom (devenu parent, il ne serait plus saisissable) sont refusés avec un message, et rien n'est enregistré. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393).
|
- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), 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).
|
- 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), 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).
|
||||||
|
|
||||||
## [0.17.2] - 2026-10-18
|
## [0.17.2] - 2026-10-18
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@
|
||||||
|
|
||||||
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
|
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the 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).
|
- 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).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), a parent that would make a fourth level, and a parent with transactions of its own (once a parent, it would no longer take input) are refused with a message, and nothing is saved. A category saved with its parent unchanged is never refused by these checks (#393).
|
- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), 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).
|
- 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), 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).
|
||||||
|
|
||||||
## [0.17.2] - 2026-10-18
|
## [0.17.2] - 2026-10-18
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/ # 13 hooks custom (useReducer)
|
├── hooks/ # 29 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
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,17 @@ simpl-resultat/
|
||||||
│ └── main.tsx # Point d'entrée
|
│ └── main.tsx # Point d'entrée
|
||||||
├── src-tauri/ # Backend Rust
|
├── src-tauri/ # Backend Rust
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── commands/ # 6 modules de commandes Tauri
|
│ │ ├── commands/ # 10 modules de commandes Tauri (45 commandes), plus account_cache.rs et return_calculator.rs, sans commande
|
||||||
│ │ │ ├── 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
|
||||||
│ │ │ └── entitlements.rs
|
│ │ │ ├── token_store.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
|
||||||
|
|
@ -223,17 +227,19 @@ 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** — *(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.
|
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.
|
||||||
|
|
||||||
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`).
|
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`).
|
||||||
|
|
||||||
## Hooks (25+)
|
## Hooks (29)
|
||||||
|
|
||||||
Chaque hook encapsule la logique d'état via `useReducer` :
|
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` :
|
||||||
|
|
||||||
| 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. 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) |
|
||||||
|
| `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, 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) |
|
||||||
|
|
@ -258,6 +264,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `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`
|
||||||
|
|
||||||
|
|
@ -342,9 +349,11 @@ 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 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0`, `totalInDb` via `getTransactionCount`, lu au montage : la carte vient dès que le compte est lu, sans attendre la première page (#384, D1) | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||||
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
| 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), chacune pour son compte (`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é |
|
||||||
|
|
@ -360,7 +369,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
||||||
|
|
||||||
`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. 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 ».
|
**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 ».
|
||||||
|
|
||||||
### Parcours guidé
|
### Parcours guidé
|
||||||
|
|
||||||
|
|
@ -426,7 +435,9 @@ 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 (39)
|
## Commandes Tauri (45)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -436,7 +447,7 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
||||||
- `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é. 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é. 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/`
|
||||||
|
|
||||||
### `export_import_commands.rs` — Export/Import de données (5)
|
### `export_import_commands.rs` — Export/Import de données (5)
|
||||||
|
|
||||||
|
|
@ -514,14 +525,26 @@ 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`)
|
||||||
|
|
||||||
### `balance_commands.rs` — Bilan (1)
|
### `feedback_commands.rs` — Feedback Hub (2)
|
||||||
|
|
||||||
|
- `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) :
|
||||||
|
|
@ -637,7 +660,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.
|
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é.
|
||||||
|
|
||||||
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
|
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
|
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
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,15 @@ 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
|
||||||
|
|
@ -231,18 +240,21 @@ const SAMPLE_TANGERINE_TEMPLATE: &str =
|
||||||
/// `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 `import_folder` is not a directory, and when either
|
/// Refuses to run when `today` is not a date (`invalid_date`), when
|
||||||
/// sample folder already exists — checked BEFORE anything is written, so an
|
/// `import_folder` is not a directory (`import_folder_missing`, without the
|
||||||
/// existing folder is never touched. The command only ever receives the import
|
/// path), and when either sample folder already exists
|
||||||
/// folder and a date: the names of what it writes are fixed here. Returns the
|
/// (`sample_folder_exists:<name>`) — checked BEFORE anything is written, so an
|
||||||
/// two folder NAMES (not paths).
|
/// existing folder is never touched. A write error keeps its text: the sample
|
||||||
|
/// folder's name, the file name and the OS error, never a 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(|e| format!("Invalid date '{}': {}", today, e))?;
|
.map_err(|_| SAMPLE_ERROR_INVALID_DATE.to_string())?;
|
||||||
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())?;
|
||||||
|
|
@ -255,7 +267,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(format!("Folder does not exist: {}", import_folder));
|
return Err(SAMPLE_ERROR_IMPORT_FOLDER_MISSING.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let samples: [(&str, &str); 2] = [
|
let samples: [(&str, &str); 2] = [
|
||||||
|
|
@ -267,7 +279,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!("Folder already exists: {}", folder_name));
|
return Err(format!("{}:{}", SAMPLE_ERROR_FOLDER_EXISTS, folder_name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,14 +383,39 @@ 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);
|
||||||
let err = result.unwrap_err();
|
// The code alone, translated by the front: never the folder's path.
|
||||||
assert!(err.contains("does not exist"), "{}", err);
|
assert_eq!(result.unwrap_err(), "import_folder_missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -392,12 +429,32 @@ mod sample_folder_tests {
|
||||||
.next()
|
.next()
|
||||||
.is_none();
|
.is_none();
|
||||||
cleanup(&dir);
|
cleanup(&dir);
|
||||||
let err = result.unwrap_err();
|
assert_eq!(result.unwrap_err(), "sample_folder_exists:Exemple Desjardins");
|
||||||
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");
|
||||||
|
|
@ -428,6 +485,9 @@ 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]
|
||||||
|
|
@ -436,8 +496,7 @@ 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);
|
||||||
let err = result.unwrap_err();
|
assert_eq!(result.unwrap_err(), "invalid_date");
|
||||||
assert!(err.contains("Invalid date"), "{}", err);
|
|
||||||
assert!(!anything_written);
|
assert!(!anything_written);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,6 +517,8 @@ 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,8 +225,13 @@ 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 active keywords by descending priority; the
|
// `categorizeBatch` asks for the active keywords by descending priority
|
||||||
// seed is all priority 0, so the SQL order is the file order.
|
// and keeps the first one that matches. The seed is not all priority 0:
|
||||||
|
// 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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ 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(),
|
||||||
|
|
@ -77,7 +76,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({ hasFolder: false });
|
const { props, tree, buttons, links } = render({ folderName: null });
|
||||||
|
|
||||||
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)");
|
||||||
|
|
@ -96,7 +95,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({ hasFolder: false });
|
const { tree } = render({ folderName: null });
|
||||||
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)");
|
||||||
});
|
});
|
||||||
|
|
@ -104,7 +103,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({ hasFolder: true, folderName: "Relevés" });
|
const { props, tree, buttons, links } = render({ folderName: "Relevés" });
|
||||||
|
|
||||||
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
|
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
|
||||||
|
|
||||||
|
|
@ -125,7 +124,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({ hasFolder: true, folderName: "Relevés", isCreatingSample: true });
|
const { buttons } = render({ 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);
|
||||||
|
|
@ -133,12 +132,22 @@ 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({ hasFolder: true, folderName });
|
const { tree } = render({ 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)", () => {
|
||||||
|
|
@ -153,8 +162,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", { hasFolder: false }],
|
["no folder", { folderName: null }],
|
||||||
["a folder without sources", { hasFolder: true, folderName: "Relevés" }],
|
["a folder without sources", { 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);
|
||||||
|
|
@ -170,7 +179,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({ hasFolder: false, onboardingTarget: true });
|
const { tree } = render({ folderName: null, 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");
|
||||||
|
|
@ -178,16 +187,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({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
|
const { tree } = render({ 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({ hasFolder: false }).tree)).toHaveLength(0);
|
expect(highlighted(render({ folderName: null }).tree)).toHaveLength(0);
|
||||||
expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
|
expect(highlighted(render({ folderName: "Relevés" }).tree)).toHaveLength(0);
|
||||||
expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
|
expect(highlighted(render({ folderName: "Relevés", onboardingTarget: false }).tree)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ 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. */
|
/**
|
||||||
hasFolder: boolean;
|
* Last segment of the import folder path, never the full path; null when no
|
||||||
/** Last segment of the import folder path, never the full path. */
|
* import folder is configured. With a folder, the empty state speaks of its
|
||||||
|
* 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;
|
||||||
|
|
@ -43,7 +45,6 @@ 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,
|
||||||
|
|
@ -67,7 +68,7 @@ export default function ImportEmptyState({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasFolder) {
|
if (folderName === null) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FolderOpen size={40} />}
|
icon={<FolderOpen size={40} />}
|
||||||
|
|
@ -84,7 +85,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: folderName ?? "" })}
|
description={t("import.empty.noSources.description", { folderName })}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: t("import.empty.createSample"),
|
label: t("import.empty.createSample"),
|
||||||
onClick: onCreateSample,
|
onClick: onCreateSample,
|
||||||
|
|
|
||||||
|
|
@ -428,17 +428,23 @@ 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).
|
// useTrends.ts) and the profile-wide transaction count (#353). Each read
|
||||||
|
// 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 () => {
|
||||||
try {
|
const [accounts, total] = await Promise.allSettled([
|
||||||
const [accounts, total] = await Promise.all([
|
getAllImportSources(),
|
||||||
getAllImportSources(),
|
getTransactionCount(),
|
||||||
getTransactionCount(),
|
]);
|
||||||
]);
|
if (accounts.status === "fulfilled") dispatch({ type: "SET_ACCOUNTS", payload: accounts.value });
|
||||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value });
|
||||||
dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
|
const failed = [accounts, total].find(
|
||||||
} catch (e) {
|
(r): r is PromiseRejectedResult => r.status === "rejected"
|
||||||
|
);
|
||||||
|
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) });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
97
src/hooks/useBudget.wiring.test.ts
Normal file
97
src/hooks/useBudget.wiring.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
176
src/hooks/useImportWizard.samples.test.ts
Normal file
176
src/hooks/useImportWizard.samples.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,10 +22,13 @@ 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";
|
||||||
|
|
@ -220,3 +223,93 @@ describe("wizardReducer — rootFiles (#427)", () => {
|
||||||
expect(after.rootFiles).toBe(before.rootFiles);
|
expect(after.rootFiles).toBe(before.rootFiles);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ import {
|
||||||
mapRow,
|
mapRow,
|
||||||
ROW_ERROR_KEYS,
|
ROW_ERROR_KEYS,
|
||||||
} from "../utils/importFormat";
|
} from "../utils/importFormat";
|
||||||
import { logInfo } from "../services/logService";
|
import { logInfo, logWarn } 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,6 +69,32 @@ 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;
|
||||||
|
|
@ -101,6 +127,12 @@ 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[];
|
||||||
|
|
@ -139,7 +171,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 }
|
| { type: "SET_ERROR"; payload: string | null; values?: Record<string, string> }
|
||||||
| { 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" }
|
||||||
|
|
@ -196,6 +228,7 @@ 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: [],
|
||||||
|
|
@ -215,7 +248,7 @@ 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, isLoading: false };
|
return { ...state, error: action.payload, errorValues: action.values ?? null, 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":
|
||||||
|
|
@ -390,8 +423,13 @@ 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) => {
|
async (folder: string): Promise<boolean> => {
|
||||||
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.
|
||||||
|
|
@ -400,11 +438,13 @@ 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]
|
||||||
|
|
@ -435,10 +475,17 @@ 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. The Rust command refuses when either folder already exists and
|
* banner — only once the rescan went through: a failed rescan puts up its
|
||||||
* writes nothing; its message lands in the regular error banner. Only the
|
* own error, and the two banners would contradict each other (#384).
|
||||||
* folder NAMES reach the logs: the logs travel with a feedback report, the
|
*
|
||||||
* absolute path of the import folder must not.
|
* The Rust command refuses with a stable code (a folder already there, the
|
||||||
|
* 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;
|
||||||
|
|
@ -451,13 +498,14 @@ 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(", ")}`);
|
||||||
await scanFolderInternal(folder);
|
if (await scanFolderInternal(folder)) {
|
||||||
dispatch({ type: "SET_SAMPLE_CREATED", payload: names });
|
dispatch({ type: "SET_SAMPLE_CREATED", payload: names });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
const raw = e instanceof Error ? e.message : String(e);
|
||||||
type: "SET_ERROR",
|
logWarn(`[import] Sample folders not created: ${raw}`);
|
||||||
payload: e instanceof Error ? e.message : String(e),
|
const known = sampleFolderError(raw);
|
||||||
});
|
dispatch({ type: "SET_ERROR", payload: known ? known.key : raw, values: known?.values });
|
||||||
}
|
}
|
||||||
}, [state.importFolder, scanFolderInternal]);
|
}, [state.importFolder, scanFolderInternal]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,19 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
|
||||||
expect(showsEmptyState(filled)).toBe(false);
|
expect(showsEmptyState(filled)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the regular layout while a page is loading, even at 0", () => {
|
it("shows the empty state as soon as the count is 0, even while the first page loads (#384, D1)", () => {
|
||||||
// The loading placeholder stays in place of the table so the card does not
|
// Same rule as Budget: the count alone decides. On an empty profile the
|
||||||
// flash during the first fetch of a profile whose count is already known.
|
// card comes up as soon as the count is known, instead of the filter bar,
|
||||||
|
// 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(showsEmptyState(loadingEmpty)).toBe(false);
|
expect(loadingEmpty.isLoading).toBe(true);
|
||||||
|
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')", () => {
|
||||||
|
|
|
||||||
|
|
@ -207,11 +207,13 @@ 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,
|
||||||
* and a loading page keep the regular layout.
|
* or its read failed) keeps the regular layout. A 0 shows the card at once,
|
||||||
|
* 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" | "isLoading">): boolean {
|
export function showsEmptyState(state: Pick<TransactionsState, "totalInDb">): boolean {
|
||||||
return state.totalInDb === 0 && !state.isLoading;
|
return state.totalInDb === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -225,19 +227,26 @@ 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 mount
|
// Load categories, sources and the profile-wide transaction count once on
|
||||||
|
// 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 () => {
|
||||||
try {
|
const [cats, srcs, total] = await Promise.allSettled([
|
||||||
const [cats, srcs, total] = await Promise.all([
|
getAllCategories(),
|
||||||
getAllCategories(),
|
getAllImportSources(),
|
||||||
getAllImportSources(),
|
getTransactionCount(),
|
||||||
getTransactionCount(),
|
]);
|
||||||
]);
|
if (cats.status === "fulfilled") dispatch({ type: "SET_CATEGORIES", payload: cats.value });
|
||||||
dispatch({ type: "SET_CATEGORIES", payload: cats });
|
if (srcs.status === "fulfilled") dispatch({ type: "SET_SOURCES", payload: srcs.value });
|
||||||
dispatch({ type: "SET_SOURCES", payload: srcs });
|
if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value });
|
||||||
dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
|
const failed = [cats, srcs, total].find(
|
||||||
} catch (e) {
|
(r): r is PromiseRejectedResult => r.status === "rejected"
|
||||||
|
);
|
||||||
|
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),
|
||||||
|
|
|
||||||
121
src/hooks/useTransactions.wiring.test.ts
Normal file
121
src/hooks/useTransactions.wiring.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -334,7 +334,12 @@
|
||||||
"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 to see it, or 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.",
|
||||||
|
|
@ -941,7 +946,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 keyword seed does most of the work, a manual correction refines the rest",
|
"Categorize a transaction by hand — the keywords that come with the app do 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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,12 @@
|
||||||
"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 pour le voir, ou 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.",
|
||||||
|
|
@ -941,7 +946,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 — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste",
|
"Classer une transaction à la main — les mots-clés fournis avec l'application font 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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,23 @@ 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[";
|
||||||
|
|
||||||
|
|
@ -367,3 +384,22 @@ 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}])`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,12 @@ 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) and a raw message otherwise; `defaultValue`
|
format it cannot decode, a refusal of the sample folders with its
|
||||||
renders the latter unchanged.
|
values, #384) and a raw message otherwise; `defaultValue` renders
|
||||||
|
the latter unchanged.
|
||||||
*/}
|
*/}
|
||||||
<p className="text-sm text-[var(--foreground)]">
|
<p className="text-sm text-[var(--foreground)]">
|
||||||
{t(state.error, { defaultValue: state.error })}
|
{t(state.error, { ...(state.errorValues ?? {}), defaultValue: state.error })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -151,8 +152,7 @@ export default function ImportPage() {
|
||||||
(showEmptyState ? (
|
(showEmptyState ? (
|
||||||
<>
|
<>
|
||||||
<ImportEmptyState
|
<ImportEmptyState
|
||||||
hasFolder={state.importFolder !== null}
|
folderName={state.importFolder !== null ? folderNameFromPath(state.importFolder) : null}
|
||||||
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
|
||||||
onBrowse={browseFolder}
|
onBrowse={browseFolder}
|
||||||
onCreateSample={createSampleFolders}
|
onCreateSample={createSampleFolders}
|
||||||
onRefresh={refreshFolder}
|
onRefresh={refreshFolder}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ 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[],
|
||||||
|
|
@ -39,7 +41,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, rows: harness.rows, filters } };
|
return { ...hook, state: { ...hook.state, ...harness.state, rows: harness.rows, filters } };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -140,6 +142,7 @@ 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 = [];
|
||||||
});
|
});
|
||||||
|
|
@ -305,3 +308,64 @@ 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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue