Compare commits

...

79 commits

Author SHA1 Message Date
le king fu
6e8e15719c state: sync after the Linux update cycle of v0.17.2 and #421
Max ran the real update cycle v0.17.1 -> v0.17.2 on Linux (.deb):
it worked, and a password prompt appeared (its type was not noted).
The trace is in the v0.17.2 release body. #421 was checked on Linux
and closed by hand (comment 25074). Windows and .rpm cycles remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:51:27 -04:00
le king fu
af68d0623f state: pilot ranks of #464 and #465, mark v0.17.2 as published
The #458 pilot table (comment 24819) now has four rows: #459, #450,
#464 and #465, the last two judged right at the high level by Max.
#464 and #465 were counted second and third here without #450.
Entries merged since v0.17.1 now say they shipped in v0.17.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:36:45 -04:00
le king fu
34ca76d4bf state: sync after v0.17.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:14:08 -04:00
le king fu
f42f360dd0 chore: release v0.17.2
All checks were successful
Release / build-and-release (push) Successful in 23m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:49:17 -04:00
le king fu
e0c0f9ee45 state: sync after #421
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:33:44 -04:00
le king fu
ff9656d015 test(dialogs): guard against aliases of confirmAction and other native forms
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m0s
Review of PR #465 (comment 24953), suggestion 1: an alias
(`const c = confirmAction; if (!c(...))`) called confirmAction out of reach
of the "awaited" rule, and window["confirm"](...), confirm?.(...) and
`const { confirm } = window` escaped the native-call rule. Any mention of
confirmAction other than the awaited call, its declaration, a plain named
import or `confirm: confirmAction` is now refused; the three native forms
are flagged. Samples added for each.

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00
le king fu
57ac056bf2 fix(dialogs): wait for the confirmation before deleting or removing the key
On desktop, tauri-plugin-dialog 2.6.0 replaces window.confirm with an async
function: confirm(...) returned a Promise, always truthy, so every guard
`if (!confirm(...)) return;` let the action run before the user answered.
Deleting a secondary profile removed its database even on "Cancel".

- confirmAction() (src/services/confirmService.ts) calls the plugin's ask()
  with a warning icon and buttons labelled in the app language
  (common.confirm / common.cancel); a failed call resolves false and is
  logged (fail closed). @tauri-apps/plugin-dialog 2.6.0 added, the crate's
  version.
- The six sites await it: profile, category, adjustment, budget template and
  Balance category deletions, and both confirmations of the license key
  removal (performRemoveLicense takes an async confirm).
- native-dialog-guard.test.ts fails on any native confirm/alert in src/ and
  on any confirmation not awaited (TypeScript does not flag `!promise`).
- Tests: confirmService, the deletion of a profile in ProfileFormModal, and
  the license removal tests with an async confirm (test: reproduces #421:
  six of them were red on the old code).

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00
le king fu
522232947c docs(release): report edition matrix changes to the site's comparison table
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m3s
The product page of la-compagnie-maximus copies the edition matrix into a
comparison table (la-compagnie-maximus#180). Step 0 of the release skill now
says to prepare the site PR before the tag and deploy it only after the
release is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:18:17 -04:00
le king fu
c667ad0079 state: sync after #419
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:39:21 -04:00
le king fu
c859405deb fix(release): refuse an empty or multi-document latest.json, test the Publish step
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m4s
Follow-ups of the review of PR #418.

- check-latest-json.jq wants exactly one document and release.yml calls it
  with -s: under jq 1.6, the release container's, an empty file passed the
  check (no output, status 0) and two documents were judged on the last one,
  so the job would have deleted the published manifest and uploaded the bad
  one. Two tests cover both cases.
- build-latest-json.test.mjs runs the text of release.yml's "Publish
  latest.json to package registry" step against a fake curl: DELETE then PUT
  on a valid manifest, exit 1 and no request on a refused one.
- check-frontend no longer ignores .claude/** and release.yml (decision of
  Max): the test reads verify-release.sh and runs the Publish step, so "on
  every pull request" holds. REVIEW.md, CLAUDE.md and architecture.md follow.
- release.yml: stale ".deb fallback" comment fixed.
- qa-update-cycle.md: a tauri dev build on Linux shows TargetsNotFound,
  expected since #418.

Refs #461

Resolves #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:00:40 -04:00
le king fu
201a9779fe state: sync after #455 and #457
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:50:31 -04:00
le king fu
c5efc38e96 fix(deps): build the updater without its zip feature
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 4m14s
Clears RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on
zip 4.6.1, which turned the daily audit.yml red at run 558 (2026-10-17)
without any lockfile change. zip was compiled only for Windows, through
the default `zip` feature of tauri-plugin-updater 2.10.0. It is fixed
only in zip >= 9.0.0, and tauri-plugin-updater 2.14.0 still requires
zip ^4, so no cargo update can reach a fix.

That feature only unpacks archived update packages (.nsis.zip,
.msi.zip, .AppImage.tar.gz, produced by createUpdaterArtifacts
"v1Compatible"); this app ships createUpdaterArtifacts: true, whose
packages are never archives. The updater is now declared without its
default features, keeping rustls-tls. The lockfile loses zip, arbitrary
and derive_arbitrary (666 -> 663 packages, nothing added), and tar
leaves the Linux graph.

tar was the canary of the suppression guard in check-rust.yml. It is
replaced by webkit2gtk, Tauri's Linux webview, which keeps the property
that made tar useful: a dependency present on x86_64-unknown-linux-gnu
only, so finding it also proves that --target resolves conditional
edges. ADR 0018, architecture.md, qa-update-cycle.md and the release
skill are updated to match.

Verified locally: cargo-audit 0.22.2 exits 1 on the old lock with the
three IDs and 0 on the new one (663 crates, the same 12 allowed
warnings); the suppression guard is green, and red with the canary
swapped for tar or zip; windows-sys guard 9/9; cargo check and cargo
test (267 passed) on Linux; cargo xwin check for x86_64-pc-windows-msvc
is green on main (zip compiled) and on this branch (zip gone).

Refs #455

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:37:48 -04:00
le king fu
54c48f4721 state: sync after #456
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:23:21 -04:00
le king fu
24da72b7f4 docs(review): apply the review of #460, version the calibration script
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
REVIEW.md, from the suggestions of the first review of #460:
- stacked-PR pitfall: fs_commands.rs compiles two CSV files of
  src/__fixtures__/samples/ through include_str!, outside the paths of
  check-rust;
- high level: .claude/settings.json and .mcp.json, a pull_request_target
  trigger, an install step that changes the toolchain or the PATH (the
  check-workflow list is now given as examples of the principle), a lock
  origin whose host or scheme changes even with a new version, and a diff
  that removes or bypasses a consent (price consent lives on the front);
- db.ts and dataExportService.ts stay normal (Max's decision of
  2026-10-18), and the data zone now names the lock, the profile switch
  and the restore;
- release zone: cargo-xwin and the Rust toolchain, installed unpinned at
  each tag, also run with the signing key;
- windows-sys guard: EXPECTED joins CRATES and PIN;
- identifier: changing it orphans the databases and the app_data_dir
  files (license key, activation token, token fallback); the keychain
  service is a constant and does not follow.

scripts/review-levels.py is the calibration script of the PR body, now in
the repo: pages of 50 files read until an empty page (the instance caps
pages at 50), the new rules above, comment lines ignored by the workflow
checks, and --check, which compares its patterns with the Haut table
without network access. Fresh run: 3/28/8 on the 39 reference PRs and
6/33/12 on the 51, unchanged.

Refs #456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:55:37 -04:00
le king fu
fa1abb115c docs(review): add REVIEW.md with calibrated risk levels and review rules per zone
REVIEW.md sets the review level of a PR from the files its diff touches
(high, normal or low; the highest wins, never the author), lists what
matters in each zone with its source (a CLAUDE.md section or an ADR,
completed by the repo file where the fact lives), the verification
commands of the CI, and the review pitfalls of this repo. The pitfalls
taken from the global pr-review skill are corrected for this repo:
stacked PRs do get CI here since #232.

Calibration on the merged PRs #344 to #452: the 39 reference PRs of the
pilot document come out 3 low / 28 normal / 8 high (20.5 %, target
20 %), the whole range of 51 PRs 6 / 33 / 12 (23.5 %), against 3 / 19 /
17 with the trial patterns. A lockfile-only version bump of a package
already present stays normal, per Max's decision of 2026-10-17.
CLAUDE.md points to the file.

Fixes #456

Note: plan check bypassed after 3 passes, last findings applied (Max's
decision of 2026-10-18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:21:05 -04:00
le king fu
589aee0b38 state: sync after #369
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:46:13 -04:00
le king fu
a8c3fd6997 refactor(gating): one constant for the split's feature key
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Review of PR #452 (comment 24129): "adjustments" was written three
times for the split (the page's gate, the button's edition, the
dialog's locked screen). SPLIT_FEATURE in splitGate.ts is now the one
source, so moving the split to its own key or to Premium is a one-line
change. No behaviour change; setting it to "budget" turns 2 tests red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
d5d0e89d2c test(gating): pin that one click never arms the other window
Review of PR #452 (comment 24129): the upgrade to Base checked that the
upsell goes away, not that no split window appears in its place, so a
locked click that also armed the split window (R14) left the suite
green. Both edition changes now assert the other window stays closed,
during the license refresh and after it.

R14 now turns 1 test red, and its mirror (a split click that also arms
the upsell) 1 test red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
cb7f8479e4 feat(gating): reserve transaction splits for the Base edition
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m16s
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.

- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
  stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
  by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
  rendered once the edition resolves to Free, and stays mounted through
  a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
  architecture, entitlements header comment, both changelogs.

Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.

Fixes #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:02:47 -04:00
le king fu
065f580c9f state: correct the product position line (v0.17.1, Unreleased not empty)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:28:16 -04:00
le king fu
d8037be11f state: sync after #447
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:21:57 -04:00
le king fu
1763a9fdd3 fix(deps): update rustls to 0.23.46
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m33s
Clears RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and
RUSTSEC-2026-0349, published on 2026-10-14 against rustls 0.23.45. The
daily audit.yml has been red since run 553 (2026-10-15 06:00 UTC) with
the lockfile untouched since v0.17.1: the advisories alone turned it red.

rustls is reached only through tauri-plugin-updater (reqwest 0.13) on
both shipped targets, and none of the four applies to that use: no
custom ClientSessionStore (0346), ECH never enabled (0347), no `brotli`
feature (0348), and the ring provider offers no post-quantum group to
downgrade (0349). Rule 1 of .cargo/audit.toml still asks for a fix
rather than a suppression: the crate is in the graph and a fix exists.

`cargo update -p rustls --precise 0.23.46` changes two lines of
Cargo.lock (version and checksum). No companion bump: 0.23.46 declares
the same dependencies and MSRV. No windows-sys edge moves. The checksum
matches the crates.io index and the downloaded .crate.

The changelog entry scopes "no behaviour change" to the app's own code,
as 9abf474 did for the previous rustls bump: besides the four fixes,
0.23.46 carries about ten other TLS-layer corrections.

Verified locally, cargo-audit 0.22.2 (the CI binary) run from the repo
root: exit 1 with the four advisories before the bump, exit 0 after,
666 crates and the same 12 allowed warnings on both runs. cargo check,
both check-rust guards (2 and 9 checks, exit 0) and cargo test (267
passed, 0 failed) are green.

Refs #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:00:04 -04:00
le king fu
8620049585 state: sync after planned-2026-10-12-retours-onboarding-v0171 (#424, #422, #427, #426, #430, #431, #425, #432)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:39:18 -04:00
le king fu
934bcbf9aa docs(onboarding): cards of the Categories and Reports pages
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Guide and in-app docs name the five card pages, "Got it" on the two
visit cards and the Trends card that also checks the reports step;
architecture lists the new models and targets; the QA list gets the
steps 5 and 8 of the guided walk, the Free padlock variant and the
Trends click.

Resolves #432

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:47 -04:00
le king fu
00be2877f3 feat(onboarding): step cards of the Categories and Reports pages
The two visit steps get their card: /categories explains keywords and
points at "All keywords" (never "Reset"), /reports explains the hub and
points at the Trends card, with a padlock variant in the Free edition.
"Got it" acknowledges the step; opening the highlighted Trends card does
too (D17), since following the ring leaves the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:45:05 -04:00
le king fu
a064043c20 docs(onboarding): eight steps, visit steps and their one-time initialization
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
- Guide and docs.gettingStarted / docs.dashboard keys (FR, EN): eight
  steps, the two visit steps in the quick-start list, "Got it", 3/8 and
  7/7, a finished path finds them done.
- ADR 0022: acknowledgeStep and initializeVisitSteps do not notify (§2),
  ACKNOWLEDGE among the local actions (§4), the one-time initialization
  admitted like rule D9 (§5).
- architecture.md: eight rows, the amended "never by a click" invariant,
  the two new preferences, the provider's new action and initializer.
- QA list: step numbers moved to eight, five cases for the visit steps.
- CHANGELOG: Added entry in both files.

Resolves #425

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:37:30 -04:00
le king fu
eff77e47cc test(onboarding): pin the order of CHECKLIST_ITEM_KEYS to the rows
A mutation that moved "categories" after "manualCategory" in the
constant survived: the rows come from computeChecklist, the constant
only orders the skipped and acknowledged steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:33:37 -04:00
le king fu
64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
Two visit steps join the getting-started path: "Browse categories and
keywords" (5th) and "Browse the reports" (8th). Nothing in the database
counts them: they are done by "Got it" on their card, stored as
onboarding_acknowledged_<key> in user_preferences (no migration).

- computeChecklist: eight rows; the visit rows are locked until the
  profile's visit steps are initialized, so a finished path does not
  flash a "Next step" before they are marked done.
- initializeVisitSteps: once per profile, after the first successful
  load with the Budget entitlement known; acknowledges both steps when
  every other step is done or skipped, then sets the flag. No
  transaction (D16): idempotent writes, the flag last.
- Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps
  the card latch, so the card says "Done." and offers the next step.
- StepCard: "Got it" before "Skip this step" when onAcknowledge is
  given; OnboardingStepCard passes it for a visit step.
- Banner counter, card rank and end of path wait for
  budgetReady && visitStepsInitialized; the list shows the visit rows
  "pending" until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:20 -04:00
le king fu
fade01d955 docs(help): fix stale help and guide texts of Budget, Reports and Transactions
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
The page help and the guide (Markdown and in-app) described a Budget split
button removed in v0.2.5, an Annual-column warning that can no longer
appear, report tabs and four report cards (the hub has five, no tabs), a
"Category Zoom" whose displayed title is "Category Analysis", and a "+"
icon on transactions (the keyword button is a tag icon, shown on
categorized rows only; the right-click menu works on every row).

Resolves #431

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:18:03 -04:00
le king fu
6b913709af docs(onboarding): changelog and QA cases of the Transactions and Budget cards
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
The two older QA cases that quoted the previous card sentences now quote
the new ones.

Resolves #430

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:14:23 -04:00
le king fu
63d8ba7589 feat(onboarding): Transactions card suggests Add as keyword, Budget card names leaves and the Annual column
Both Transactions sentences name the context menu's "Add as keyword" and
carry the advice on the word and the boxes (D18); both Budget sentences say
only last-level rows take an amount and what the Annual column does. The
labels go through StepCardText.labels, quoted in the locale files (D11).
The keyword dialog is unchanged; a test pins that it marks the transactions
as categorized by hand, so the step still checks off through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:12:25 -04:00
le king fu
e177a7561f docs(import): changelog, guide, architecture and QA cases of the read-only source name
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Resolves #426

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:08:07 -04:00
le king fu
00b4319c44 test(import): pin the read-only source name of the configuration panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:06:07 -04:00
le king fu
ed3a4bb9a0 fix(import): name a source after its subfolder, read-only
The source name typed during configuration was saved, but the next import
looks the source and its imported files up by the subfolder name, so a
renamed source looked new: format guessed again, every file pre-checked,
and a second source created under the folder name.

The name field becomes a read-only SourceNameDisplay showing the subfolder
name with a hint, and executeImport writes selectedSource.folder_name on
create and update, whatever config.name holds, behind a selectedSource
guard. The import card sentence no longer asks to check the source name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:05:46 -04:00
le king fu
2de4d9d255 docs(import): changelog, guide, architecture and QA cases of the root files warning
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 3m41s
Resolves #427

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:02:22 -04:00
le king fu
7d3a616312 feat(import): warn about the CSV files placed directly in the import folder
scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:59:20 -04:00
le king fu
af884ca13b feat(import): show the expected folder structure, one subfolder per account, signs at the preview
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
The Import empty state now shows an "Expected structure" box in both of its
variants (no folder, folder without sources): the parent folder to choose,
one subfolder per account, and files at its root ignored, as
scan_import_folder reads them. New ImportFolderStructure component, a nested
list with decorative icons.

The getting-started cards ask for the parent folder and one subfolder per
account instead of one per bank. The preview card says that an expense must
count among the outflows and names "Flip the signs" next to "Check
duplicates": WIZARD_ROWS rows now carry every label their sentence names.
Every Import card sentence quotes the controls it names, in French and in
English, pinned by a test that reads the locale files.

Help tips, docs.import.steps, the user guide, the QA list and architecture.md
follow; CHANGELOG entry under Changed.

Resolves #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:53:05 -04:00
le king fu
22dcad7a29 docs(changelog): budget rows under their parent, subtotals on their own rows
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
Resolves #424

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:43:37 -04:00
le king fu
1c427a1b63 fix(budget): emit the grid rows depth-first and close subtotal blocks
buildBudgetYearRows sorted each top group by depth, so an expanded
subcategory showed at the end of its group instead of under its parent.
It now builds one block per child (lone leaf, or [subtotal, ...children]),
sorts the blocks once ("(direct)" first, then by name, leaves and
sub-groups mixed, as the Budget vs Actual report) and concatenates them;
top groups are sorted by type, sort_order and name.

reorderRows put a leaf that follows a sibling sub-group into that
sub-group's block. A row at the subtotal's depth or shallower now closes
the block, which also fixes "subtotals at the bottom" in the Budget vs
Actual and Compare reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:42:27 -04:00
le king fu
bf02b267b1 state: sync after planned-2026-10-10-fiabilite-tests (#408, #409, #389, #395)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:30:22 -04:00
le king fu
977ef3a1d3 docs: ADR 0024, MCP server (proposed)
Written before the code, from spec-decisions-mcp-server.md and the
reviewed spec-plan-mcp-server.md (local, git-ignored). Status Proposed;
to pass Accepted when the spec-mcp-server milestone merges. Indexed in
docs/architecture.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 20:29:06 -04:00
le king fu
c17bf5dcb4 fix(balance): translate the best-effort badge of the price fetch button
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
PR Check — Rust / rust (pull_request) Successful in 3m58s
The small badge next to "Fetch price" on a stock rendered the literal
"best-effort" without going through t, so French users saw English.
It now reads balance.priceFetching.bestEffortBadge ("non garanti" in
French). Raised by the review of PR #435 (suggestion 4).

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:01:11 -04:00
le king fu
86eff9b7a3 fix(profiles): follow-ups of the review of PR #436
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m8s
PR Check — Rust / rust (pull_request) Successful in 3m54s
- withoutFolders no longer keeps the first word of a folder that holds
  a blank (`C:\Users\Jean Dupont\...\profile_x.db` gave
  `Jean profile_x.db`): a path starts at a recognised prefix (drive,
  `/`, `~/`, `./`, UNC, `<profile folder>`) and runs to its last
  separator, blanks included, until a line break, a double quote, a
  backtick, `;`, `|`, a `,` or `:` before a blank, a closing parenthesis
  not followed by a separator, or a blank before another path. When
  nothing ends it, the text up to the last separator of the line is
  dropped rather than a folder let through. A quote before the path is
  not part of it, so `'C:\Users\Bob\x.db'` gives `'x.db'`. Relative runs
  with no recognised prefix keep the previous fallback. No error line
  contains a `/` nor a `\`, as before.
- ADR 0021 section 7 describes that rule and its limit.
- CHANGELOG.md, CHANGELOG.fr.md: no blank line between the two Fixed /
  Corrigé bullets.

Refs #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:57:29 -04:00
le king fu
ac3a981f64 test(i18n): follow-ups of the review of PR #435
- renderCategoryLabel.test.ts passes markedT itself, its assertions in
  the marked form (`t(balance.category.cash[defaultValue=cash])`): the
  second fake `t` of the repository is gone.
- i18n-mock-guard: a fake `t` is also caught as a binding (`t = (`,
  `t = vi.fn(`) or as a `t: vi.fn(` property, and a file that mocks
  react-i18next must call `i18nMock(`, comments dropped before the
  search. Each new rule has its own case in the guard's tests; the
  binding rule found renderCategoryLabel.test.ts before its migration.
- docs/architecture.md: the "only fake t" sentence is scoped to test
  files and says the guard only refuses the forms it recognises.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:53:18 -04:00
le king fu
a1c31a1da7 test(harness): follow-ups of the review of PR #434
- useOnboardingChecklist.wiring.test.ts: a data event, then a route
  change 100 ms later, still gets its deferred re-read (two LOAD_START).
  Pins the follower's deps: with `pathname` among them, the cleanup drops
  the pending timer and the test goes red (mutation M1 of the review). The
  comment that claimed "one subscription only" now points to this test.
- hookHarness.test.ts: a dependency array that shrinks, or grows by
  `undefined`, recomputes the memo (mutation M8, length branch of
  depsChanged).
- NOT SIMULATED, in hookHarness.ts and docs/architecture.md: the dispatch
  of a useReducer keeps the reducer of the first render, where React
  applies the latest one.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:20 -04:00
le king fu
74fae140b1 fix(db): follow-ups of the review of PR #433
- close() on a getDb() handle runs as a "connection close" section and
  checks its generation: a handle kept from a previous profile rejects
  with DbConnectionClosedError instead of closing the current profile.
- closeUnlocked never rejects: the state is cleared and the generation
  bumped before close(), a failing close of the old pool is logged with
  logWarn, connectToProfile still opens the next profile and closeDb()
  resolves without a connection.
- The blocking alert names a getDb() call in a callback as a possible
  cause only: a long restore also waits 15 s.
- The generation check of the transaction handle is documented as a
  defence, not reachable today.
- Tests: the "behind the queue" alert (pins the reset of lockHolder),
  close() on a stale and on the current handle, a failing close during a
  switch and in closeDb().

Refs #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:12 -04:00
le king fu
fbecfffa6e style(profiles): rewrap the sync comment of backup_to
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 3m46s
Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:33 -04:00
le king fu
419b20e769 feat(profiles): name the copy of a stopped catch-up and keep one copy per profile
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
PR Check — Rust / rust (pull_request) Successful in 3m56s
- ProfilePreparation.catch_up_backup carries the file name of the copy as
  soon as it exists, so the Journal error line of a catch-up that stops
  afterwards (history_write_failed, pragma_failed, apply_pending) names it.
- Error lines and the JS fallback message keep only the last segment of a
  path (withoutFolders): no error line contains a slash or a backslash.
- The copy is taken again at every catch-up, written as .tmp, synced and
  renamed over a same-name copy; after a successful catch-up only, the
  other copies of that profile are pruned (remove_backups keep argument).
- Pins: a pre-existing foreign key violation in the profile without
  category 31 (RV4, RV7) and the Journal line that reports it (RT2).
- Ignored test measuring prepare_at on 100 000 transactions at floor 6:
  306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021).
- Guide, docs.profiles.tips, ADR 0021, architecture and changelogs.

Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00
le king fu
3882df772b fix(startup): clear the startup error when the profile opens after the delay
The 10 s startup delay of App.tsx sets the error page while the profile
is still opening; a connection that succeeded afterwards set dbReady but
left the error, so the error page stayed. The startup logic moves to
src/utils/startupConnection.ts (startProfileConnection), which clears the
error on success and is tested with fake timers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00
le king fu
2bd347b4a0 test(i18n): guard the test sources against a local fake t
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m5s
src/__integration__/i18n-mock-guard.test.ts reads every test file of src
and fails on a useTranslation: or t: ( property (word-bounded) outside
src/test/i18nMock.ts, or on a react-i18next mock that does not go
through i18nMock. docs/architecture.md gets the convention: never a raw
t, always i18nMock.

Resolves #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:58:30 -04:00
le king fu
063f6c3d7d test(i18n): replace the local marked fake t copies with i18nMock
The 12 test files that already marked their output each carried a copy
of the same fake t. They now use the shared i18nMock; no assertion
changes. LicenseCard keeps its per-test language through a getter.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:51 -04:00
le king fu
f5e5897df1 test(i18n): move the raw fake t files to the marked i18nMock
The 15 test files whose fake t returned the bare key (the 12 of the
issue, plus the three category render tests added by #423) now mock
react-i18next with i18nMock. Assertions naming a displayed text are
rewritten in the marked form, t(key[options]), so a text that skipped
t fails them. Components without such an assertion get one:
PriceFetchControl, PriceFetchConsentToggle (rendered through the hook
harness, its loading slot preset), CategoryDetailPanel, SourceList and
SourceCard, and the region label of OnboardingChecklist.

Each component was proved by a mutation removing one t call: every one
turns at least one test red (exit 1).

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:27 -04:00
le king fu
69ef92e65f test(i18n): add the shared marked fake t and use it in useLicenseMachines
src/test/i18nMock.ts holds the one fake t of the suite: markedT renders
t(key) or t(key[a=1,b=2]), and i18nMock({ language?, fixture? }) builds
the react-i18next module for vi.mock. The local echo of
useLicenseMachines.test.ts is replaced by markedT.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:49:44 -04:00
le king fu
79ba1ed2f3 docs(architecture): add the tests section and the hook harness
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Resolves #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
1ba3b8bae9 test: move the React stubs of six test files to the shared hook harness
useOnboardingChecklist.wiring gains three pins the old stubs could not hold:
one loader for the provider's life (refresh and the shared value keep their
identity, no re-read), 'Skip' moving the next step on the next render, and a
change of mode alone reaching the consumers. UsersSettingsPage now commits
its effects, so an effect that read the preference goes red on the
assertion rather than on 'Invalid hook call'. BudgetTable.wiring,
BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local
slot copies; OnboardingStepCard plays StrictMode through replayEffects and
runs its effect only on a changed dependency, which now pins location.key.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
86fd496e77 test: add a shared React hook harness with its own proofs
src/test/hookHarness.ts gives the tests that call hooks and components as
plain functions one fake React with React's semantics: state initialized
once with stable setters, the reducer applied with a dispatch spy, refs kept,
memos and callbacks recomputed only on a changed dependency, effects run by
commit() only when a dependency changed (cleanups first), and every hook it
does not cover throws instead of reaching the real React. Each semantic is
proved by hookHarness.test.ts, which also checks that the module imports
React only as a type.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:36 -04:00
le king fu
b161d54e3a docs(architecture): add the DB access and transactions section
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Strict lock, transaction handle, single section for a profile switch,
connection generation and blocking alert.

Resolves #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:32:05 -04:00
le king fu
8efe99aa20 test(db): release gated transactions in finally so a red assertion holds no lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:28 -04:00
le king fu
08946e6d08 fix(db): keep the lock-free path for the transaction handle only
The global inTransaction flag let every execute/select bypass the FIFO
lock while a withTransaction callback ran, wherever it came from. The
callback now receives its own handle, which runs directly, refuses
close() and throws once the callback settled; any other call, getDb()
from a callback included, queues behind the transaction.

connectToProfile, initializeNewProfileDb and closeDb each run as one
exclusive section (close the old pool, prepare, load), and every open
or close bumps a connection generation: an operation queued for a
closed connection throws "connection closed by profile switch". An
operation waiting more than 15 s for the lock writes one Journal
warning naming the holder and since when.

findOrCreateSecurity now requires its executor: its getDb() fallback
would deadlock if called from a transaction callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:28:36 -04:00
le king fu
8ceb3de921 state: sync after #423
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:49:04 -04:00
le king fu
8898213e02 refactor(categories): follow-ups of the review of PR #428
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Refs #423

- parentOptions.ts: say that this list is the only guard today (updateCategory
  checks neither depth nor cycle, #393 will), instead of pointing to a
  service-side validation that does not exist yet. The height walk is also
  bounded by the number of categories, so a missing cycle guard gives a wrong
  height instead of an endless loop; a test edits a member of a cycle.
- AddKeywordDialog.tsx: drop `categoriesLoaded`, which only repeated what the
  null initial category already guarantees.
- Tests: the form and the detail panel are pinned to what they hand to
  buildParentOptions (editingId, currentParentId), through a pass-through spy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:44:54 -04:00
le king fu
04ac2e933e fix(categories): shrink the allowed parent depth by the height of the edited sub-tree
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
Refs #423

The review of PR #428 showed that the rule only looked at direct children:
editing a root that carries grandchildren still offered the other roots as
a parent, and saving created a fourth level, which nothing guards against
in updateCategory or moveCategory. The allowed parent depth is now
MAX_PARENT_DEPTH minus the height of the edited sub-tree (0, 1 or 2), so a
category with grandchildren gets no parent at all, only its current one.

Test written first on the reviewer's probe ([1, 10->1, 100->10, 2],
editing 1: [] expected, [2] obtained before the fix).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:15:52 -04:00
le king fu
4bdb00402e feat(categories): type-ahead combobox for the parent and keyword category fields
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Fixes #423

The "Parent category" field of CategoryForm and the "Category" field of
AddKeywordDialog were native selects with no typing. Both now use
CategoryCombobox, like the category cell of the Transactions page: typed
letters filter the list, which is ordered hierarchically with indentation
and shows seed categories through their i18n_key.

- CategoryCombobox accepts a minimal structural type (CategoryLike), so
  CategoryTreeNode rows pass without conversion; sortHierarchical is
  generic over it. Callers are unchanged.
- buildParentOptions (pure, tested) keeps roots and level-1 categories,
  leaves out the edited category, and keeps only roots when the edited
  category has children. That rule alone also leaves out its descent (a
  descendant is never a root), so no separate subtree walk is needed. The
  parent currently stored is always kept so the field never looks blank.
  CategoryForm gets an editingId prop, passed by CategoryDetailPanel.
- AddKeywordDialog reads the Transactions list (active and inputable,
  decision D2) through loadKeywordCategories, keeps the pre-filled
  category only when it is in that list (reconcileInitialCategory), and
  holds the apply button until the list is in. Its two fields sit above
  the scrolling block of matches, so the open list is not clipped by the
  scrollport.
- Tests: parentOptions, keywordCategory, and static renders of both
  components. Changelogs and the user guide (with its i18n mirror).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 17:22:31 -04:00
le king fu
1954502d10 state: sync after the release-deps milestone (#376, #345, #320, #358)
Milestone planned-2026-10-10-release-deps merged (PRs #414 to #418,
main 9e8c41b): new position paragraph and decision entry, the oldest
decision (#365) rotated out. Blockers: #419 point 1 before the next tag,
stale mentions of #376, #317, #345, #358 and #320 replaced by their
closure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:36:13 -04:00
le king fu
9e8c41b1f3 docs: say what still reports npm advisories after review of #417
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m53s
PR Check — Rust / rust (pull_request) Successful in 3m48s
Review follow-up of PR #417. CLAUDE.md and docs/architecture.md said
nothing re-evaluates `npm audit` in CI. No job fails on it, but the
`npm ci` step of check-frontend audits on every run and prints the count
(run 520: found 0 vulnerabilities; runs 512 and 519: 8), and the
Defenseur reports advisories on its side, which is where #358 came from.

Refs #358

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:44 -04:00
le king fu
d1d025df8d docs: fix three comments after review of #414
Review follow-ups of PR #414.
- audit.yml points to the concurrency comment of check-rust.yml, whose
  measurement covers push and pull_request runs only: say this workflow
  runs on schedule and workflow_dispatch, which it does not cover.
- reportService.test.ts said CI runs Node 20, so no node:sqlite; CI runs
  Node 24 since #376, and the harness itself is #392's.
- The release skill said /actions/tasks ignores `limit`. Measured
  2026-10-12: only without `page` (613 rows); with `page` it pages by 30
  or by `limit`.

Refs #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:44 -04:00
le king fu
08d54fd176 docs(adr): correct ADR 0023 and the guard's comment after review of #415
Review follow-ups of PR #415. The container does have python3 (pulled by
the apt packages of the install step, run 517): awk is kept because mawk
is part of the ubuntu:22.04 base image, which the workflow comment and
the ADR now say. #344 is a PR and the second drift, not the third. The
awk exit-code test is described by what it alone catches (an awk that
fails after printing every fact). The repair procedure gains the two
failures it had no answer for, and the ADR names the canary's limit.

The release commit edits Cargo.lock on main without a PR, so /release
now replays the guard's exact step text right after that edit (step 2),
checked on the stack's lock (exit 0) and on a drifted edge (exit 1).

Refs #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:32:51 -04:00
le king fu
230ecd85ad fix(release): drop the generic linux-x86_64 key from latest.json
Max's decision on PR #416, after its review measured who still read the
key. tauri-plugin-updater looks up "{os}-{arch}-{installer}" first, and
every version that ships the updater (v0.2.1 and later, plugin 2.10.0,
tauri-utils 2.8.2 in each tag's Cargo.lock) carries its bundle-type
marker, so .deb and .rpm installs never reach the generic key. Its last
readers were the AppImage installs v0.4.3 to v0.4.5 (v0.4.2 targets a
route that answers 404, earlier versions read GitHub) and binaries run
outside a package; none of them can install the .deb it served, and the
updater wrote that .deb over the running file. They now get
TargetsNotFound when checking: an error on the Updates card, nothing
installed.

The key leaves the script, scripts/check-latest-json.jq and
verify-release.sh together; the test compares the three lists. Docs fix
the readers named by #416 (v0.4.2 is not one) and the CHANGELOG entry
says what changes for those AppImage installs.

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:30:34 -04:00
le king fu
ba3db7c35d ci(release): check latest.json with one jq filter that the tests also run
Review follow-ups of PR #416. The check that release.yml runs before
deleting the published manifest moves from an inline jq program to
scripts/check-latest-json.jq, which release.yml applies with
`jq -e --arg version ... -f`. scripts/build-latest-json.test.mjs now runs
that same filter on every pull request: on the script's output, on a
wrong version, on nine broken manifests and on the shape published up to
v0.17.1. It also compares the key lists of the script, of the filter and
of verify-release.sh, so a list left behind shows before the tag.

Two new cases pin the version argument: 0.18.0 must come out as 0.18.0
(the other fixtures pass 0.17.1, the version the bundle names carry, so a
script that ignored its argument passed them all), and an empty version
must fail. Both mutations survived the review's pass.

No change to what the manifest holds: still four keys.

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:28:48 -04:00
le king fu
e8306aa260 fix(deps): refresh npm lock to clear 8 advisories
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m53s
npm audit reported 8 vulnerabilities (5 high, 3 moderate) on the lock
of main: react-router and react-router-dom (one advisory,
GHSA-qwww-vcr4-c8h2), vitest and @vitest/mocker (one advisory,
GHSA-82fw-gwwq-j7x9), browserslist, baseline-browser-mapping, nanoid
and source-map-js. Every fix sits within the range package.json
already declares, so only the lock moves; package.json is untouched.

Lock generated with npm 11.21.0 exactly:

  npx --yes npm@11.21.0 update react-router-dom react-router vitest \
    @vitest/mocker browserslist baseline-browser-mapping nanoid \
    source-map-js --package-lock-only --ignore-scripts

259 entries before and after, 20 changed, each resolved from
registry.npmjs.org with a new sha512 integrity. npm ci, under npm 10
and under npm 11.19.0, leaves the lock as is; npm audit: 0.

Only react-router 7.18.1 -> 7.18.4 changes the built app: a build from
a lock where only react-router moved equals the full build byte for
byte. Since 7.18.3, useNavigate() and <Navigate> reject a navigation
to another origin; the app only navigates to internal paths.

CLAUDE.md and docs/architecture.md no longer describe 2 high
advisories accepted for good: npm audit is at 0 on 2026-10-10, a
dated reading that nothing re-runs in CI.

Resolves #358
Resolves #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:14:34 -04:00
le king fu
5ce136f45e docs: name the readers of the linux-x86_64 fallback after self-review
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
The fallback key is also read by the AppImage installs of v0.4.2 to
v0.4.5 (bundled until v0.7.3, published on Forgejo for v0.4.2-v0.4.3
and on the GitHub mirror for v0.4.3-v0.4.5, all reading the registry),
which look up linux-x86_64-appimage, absent, then linux-x86_64. Like a
marker-less binary, they cannot install the .deb it serves: the updater
writes its bytes over the running AppImage. State since v0.4.2, not
changed by this PR; the key stays as decided.

Also quote the error the update card really shows for a refused
package (invalid updater binary format).

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:55:02 -04:00
le king fu
da7129d529 fix: give rpm installs their own key in latest.json
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
latest.json only had linux-x86_64, built from the .deb. An .rpm install
looks up linux-x86_64-rpm first, then fell back on linux-x86_64,
downloaded the .deb and refused it (InvalidUpdaterFormat).

- scripts/build-latest-json.sh writes linux-x86_64-deb,
  linux-x86_64-rpm, linux-x86_64 (the .deb, as a fallback) and
  windows-x86_64 from the release assets. It fails unless it finds
  exactly one .deb, one .rpm and one -setup.exe, each with a non-empty
  .sig. The JSON comes from jq alone, notes read with --rawfile, file
  names encoded with @uri like the upload step.
- scripts/build-latest-json.test.mjs (vitest) runs it under
  bash -e -o pipefail on a temporary assets dir; check-frontend
  installs jq.
- release.yml calls the script, writes the notes output under a random
  delimiter, checks the manifest with jq before the registry DELETE,
  and names the .rpm instead of an AppImage in the release notes.
- verify-release.sh requires the four keys, the extension of each URL
  and the minisign file: of each signature, and exits 1 on any failure.
- Docs: release skill, qa-update-cycle section 3 (rpm procedure, keys,
  reason and removal condition of the fallback), architecture, README,
  CHANGELOG, STATE.

Resolves #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:49:12 -04:00
le king fu
3b8105c253 docs: fix two sentences of ADR 0023 after self-review
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 3m36s
"Ce sous-arbre" lost its referent once the trigger paragraph stopped
naming a subtree: the sentence now says what is measured, that both past
drifts (#310, #344) came with RustSec advisory fixes. Rule 1's sentence on
a missing block is reworded.

Refs #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:17:09 -04:00
le king fu
8d9a3ca9e9 ci: guard the windows-sys 0.61.2 pin in check-rust
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 5m12s
Seven crates in Cargo.lock accept windows-sys through a wide bound
(colored, dirs-sys, errno, rustix, rustls-platform-verifier, tempfile,
winapi-util), so cargo may point them at any windows-sys version already in
the graph. All seven point at 0.61.2, the state every Windows release has
shipped with since v0.14.0. Some updates re-resolve them (cargo update -p
hyper, -p h2, -p rustls-webpki -p tar did) down to 0.48/0.52/0.59 while
cargo only reports the packages it bumped: the Windows build then compiles
five windows-sys versions instead of three, and nothing says so.

A new blocking step, "Verify windows-sys pinning", reads the lockfile with
awk right after the suppression guard and fails if any edge of the seven
crates leaves 0.61.2 (a bare edge only passes when the lockfile holds a
single windows-sys, at 0.61.2), if a guarded crate has no block, or if the
set of windows-sys versions changes. awk's exit code is tested apart from
its output, every check is logged with a summary line, and a canary
(tempfile) must be read and checked by the loop.

The step text was extracted from the YAML and run as is, with the runner's
bash options, on copies of the lockfile: main's and the five release tags'
locks pass (9 checks, exit 0); the hyper-bumped lock, an added
windows-sys 0.53.0, a renamed guarded crate, a missing lockfile and a
guarded list without the canary all exit 1.

ADR 0023 records why 0.61.2, the measured cost (3 versions compiled for
Windows instead of 5), the triggers, the exit condition (windows-sys 0.62,
or convergence on a single version) and the repair when the step fails.
CLAUDE.md and docs/architecture.md cite the guard next to the suppression
check. Cargo.lock and .cargo/audit.toml are untouched.

Resolves #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:14:25 -04:00
le king fu
f19b027c5d ci: fix the release run lookup in the /release skill
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
PR Check — Rust / rust (pull_request) Successful in 3m41s
The /actions/tasks endpoint ignores `limit` and returns the whole list,
newest first, so `?limit=5` promised five rows it does not give. The
skill now selects the release.yml run of the tag directly (python3, as
verify-release.sh does), checked on v0.17.1: run 508, `v20.20.2`.

Also clarify check-frontend.yml's concurrency comment: same setup as
check-rust.yml, not the same group.

Refs #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:43:57 -04:00
le king fu
71a4e1e88f ci: move check-frontend, audit and release from Node 20 to Node 24
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
PR Check — Rust / rust (pull_request) Successful in 4m46s
Node 20 reached end of life on 2026-04-30. check-frontend.yml, audit.yml
and release.yml now install Node.js 24 through NodeSource's setup_24.x,
as check-rust.yml has done since #375. Each step still prints
`node --version` in the job log.

The concurrency comment of check-rust.yml claimed the key cancels
obsolete runs on force-push. la-compagnie-maximus docs/forgejo-ops.md
measured the opposite on Forgejo 8.0.3: no pull_request run was
cancelled by a later one, and push runs are cancelled without the key.
The comment now states that measurement; the keys stay (harmless,
meaningful where honoured), and check-frontend.yml and audit.yml point
to it.

release.yml names the trust it places in NodeSource: the script runs as
root in the job container, whose Node.js then runs `npx tauri build`
with TAURI_SIGNING_PRIVATE_KEY. Kept on purpose, also documented in
docs/architecture.md, whose release.yml section described two jobs
(windows-latest, ubuntu-22.04) where there is one containerised job
cross-compiling Windows with cargo-xwin.

release.yml only runs on a tag, so Node 24 there is proven at the next
release: the /release skill now reads `node --version` from the release
run's log (step 9), and STATE.md lists it under the checks due at the
next release.

No CHANGELOG entry: CI only.

Resolves #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:28 -04:00
le king fu
87920ec02d state: sync after the go-live closure and the last manual checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:59:48 -04:00
le king fu
571104393b state: sync after the manual checks on the published releases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:18:12 -04:00
le king fu
c7febe582e state: sync after v0.17.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:48:37 -04:00
158 changed files with 10422 additions and 1715 deletions

View file

@ -2,7 +2,7 @@
name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true
updated: 2026-09-13
updated: 2026-10-11
---
# /release — Release Simpl-Resultat
@ -17,6 +17,7 @@ updated: 2026-09-13
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
**Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires.
1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
@ -25,6 +26,12 @@ updated: 2026-09-13
- `package.json` (champ `"version"`)
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
- Puis rejouer le garde `windows-sys` de `check-rust.yml` sur le lock édité : le commit de release touche `Cargo.lock` directement sur `main`, sans PR, et `check-rust` ne le verrait qu'au lundi suivant, après le build du tag ([ADR 0023](../../../docs/adr/0023-windows-sys-pinning.md)). Depuis la racine du dépôt, le texte exact de l'étape avec les options du runner :
```bash
python3 -c 'import sys, yaml; steps = yaml.safe_load(open(".forgejo/workflows/check-rust.yml"))["jobs"]["rust"]["steps"]; sys.stdout.write(next(s["run"] for s in steps if s.get("name") == "Verify windows-sys pinning"))' \
| bash --noprofile --norc -e -o pipefail
```
Attendu : code de sortie 0 et dernière ligne `windows-sys pinning: N checks, exit 0`. Une ligne `FAIL:` arrête la release : suivre la procédure de l'ADR 0023 avant de tagger.
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
- `CHANGELOG.md` (EN) : header `## [Unreleased]`
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
@ -43,9 +50,21 @@ updated: 2026-09-13
```bash
bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z
```
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie : `version`, `pub_date`, signature non vide et URL contenant la version pour `linux-x86_64` et `windows-x86_64`, notes non vides. Toute ligne `MANQUANT`, `VIDE` ou `MAUVAISE VERSION` bloque l'étape 10.
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement trois clés** (#320) : `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`, sans clé générique `linux-x86_64` depuis le 2026-10-11 (`docs/qa-update-cycle.md`). Pour chacune : l'extension de l'URL (`.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué.
**Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Le script, le filtre `scripts/check-latest-json.jq` et le texte de l'étape « Publish » (avec un `curl` factice) passent par les tests de `check-frontend` à chaque PR ; l'appel du script par l'étape « Generate latest.json », le délimiteur aléatoire des notes et les vrais appels au registre ne s'exercent qu'au tag. Un manifeste refusé par le filtre arrête le job sur `ERROR: release-assets/latest.json failed scripts/check-latest-json.jq`, avant le `DELETE` : registre intact, et la release Forgejo reste créée sans mise à jour servie.
Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose.
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
**Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks` : sans paramètre `page`, la route rend toute la liste, la plus récente en tête, et ignore `limit` (avec `page`, elle pagine par 30 ou par `limit`, mesuré le 2026-10-12) :
```bash
TAG=vX.Y.Z
RUN=$(curl -s -H "Authorization: token $(cat ~/.forgejo-token)" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat/actions/tasks" \
| python3 -c "import json,sys; print(next(t['run_number'] for t in json.load(sys.stdin)['workflow_runs'] if t['workflow_id']=='release.yml' and t['head_branch']==sys.argv[1]))" "$TAG")
curl -sL -H "Authorization: token $(cat ~/.forgejo-token)" \
"https://git.lacompagniemaximus.com/maximus/Simpl-Resultat/actions/runs/$RUN/jobs/0/logs" \
| grep -m1 -E 'Z v[0-9]+\.[0-9]+\.[0-9]+\s*$'
```
Attendu : `v24.x`. C'est la sortie de `node --version` dans l'étape « Install base tools and runtimes », seule ligne de ce format dans le journal (la v0.17.1, run 508, affichait `v20.20.2`). Toute autre majeure est à expliquer avant l'étape 10.
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour.
@ -69,3 +88,9 @@ updated: 2026-09-13
- 2026-07-27 — Étape 10 (post-CI) : dérouler un vrai cycle de mise à jour via `docs/qa-update-cycle.md` quand la release touche l'updater. Inspecter `latest.json` prouve qu'il est bien formé, pas qu'il installe : le téléchargement TLS, la vérification de signature, l'exécution de l'installeur et le redémarrage ne sont exercés par aucun test. Deux pièges qui font cocher sans vérifier — tester depuis la machine déjà à jour (`check()` répond « à jour ») et tester sans clé Base+ (auto-update verrouillé par édition, `notEntitled` avant toute requête). Source : session 50ac88d9 (#315, découvert en traitant les advisories `tar`/`rustls-webpki` de #310).
- 2026-09-13 — Étape 0 (pré-vol) : cross-référence vers `/issue-license` quand la release modifie `FEATURE_TIERS` ou verrouille une fonctionnalité Free — émettre et distribuer les clés testeurs avant de tagger, sinon ils perdent l'auto-update. Source : session 690a6129 via /skills-review-inbox (draft 2026-09-06).
- 2026-09-13 — Étape 0 : prérequis « clés testeurs saisies avant le tag » précisé (déclencheurs `FEATURE_TIERS`, gating, `auto-update` ; cas réel v0.15.0, deux clés Premium émises le jour même). Étape 9 : script `scripts/verify-release.sh` (7 artefacts par suffixe + contenu de `latest.json`) au lieu d'un script réécrit à chaque release ; durée de run observée 22-30 min. Source : session 690a6129 (release v0.15.0, run 402) via /skills-review-inbox (draft 2026-09-12).
- 2026-10-10 — Étape 9 (post-CI) : lire `node --version` dans le journal du run de release (route web, seule ligne `vX.Y.Z` du journal). `release.yml` n'est exercé qu'au tag, donc la montée Node 20 → 24 de #376 ne se prouve qu'à la release suivante. Source : caveat de planification de #376 (milestone `planned-2026-10-10-release-deps`).
- 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`).
- 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416.
- 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415.
- 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180.
- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418).

View file

@ -1,17 +1,24 @@
#!/usr/bin/env bash
# Step 9 of /release: verify the published Forgejo release for a tag —
# the 7 expected assets and the content of latest.json (drives auto-update).
# Exits 1 when any check fails (#320), with a VERDICT as last line.
#
# Usage: verify-release.sh vX.Y.Z [latest.json]
# The optional second argument checks that local file instead of the
# release's latest.json asset, to prove the checks on crafted files.
set -uo pipefail
TAG="${1:?usage: verify-release.sh vX.Y.Z}"
TAG="${1:?usage: verify-release.sh vX.Y.Z [latest.json]}"
LOCAL_LATEST="${2:-}"
VERSION="${TAG#v}"
TOKEN=$(cat ~/.forgejo-token)
API="https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
WORK="${TMPDIR:-/tmp}/verify-release-${TAG}"; mkdir -p "$WORK"
OUT="$WORK/latest.json"
STATUS=0
echo "=== release ${TAG} ==="
timeout 20 curl -s -H "Authorization: token $TOKEN" "${API}/releases/tags/${TAG}" > "$WORK/release.json"
python3 - "$WORK/release.json" "$VERSION" <<'EOF'
python3 - "$WORK/release.json" "$VERSION" <<'EOF' || STATUS=1
import sys, json
r = json.load(open(sys.argv[1])); version = sys.argv[2]
if "id" not in r:
@ -34,19 +41,73 @@ for n in names:
missing = [k for k, v in kinds.items() if v == 0]
print("couverture:", kinds, "| MANQUANT:" if missing else "| complet", missing if missing else "")
open(sys.argv[1] + ".latest-url", "w").write(names.get("latest.json") or "")
sys.exit(1 if missing else 0)
EOF
URL=$(cat "$WORK/release.json.latest-url" 2>/dev/null)
if [ -z "$URL" ]; then echo "latest.json absent — arrêt"; exit 1; fi
echo; echo "=== latest.json ($URL) ==="
timeout 20 curl -sL "$URL" > "$OUT"
python3 - "$OUT" "$VERSION" <<'EOF'
import sys, json
if [ -n "$LOCAL_LATEST" ]; then
OUT="$LOCAL_LATEST"
echo; echo "=== latest.json (fichier local $OUT, pas l'asset de la release) ==="
else
URL=$(cat "$WORK/release.json.latest-url" 2>/dev/null)
if [ -z "$URL" ]; then echo "latest.json absent — arrêt"; echo "VERDICT : ÉCHEC"; exit 1; fi
echo; echo "=== latest.json ($URL) ==="
timeout 20 curl -sL "$URL" > "$OUT"
fi
python3 - "$OUT" "$VERSION" <<'EOF' || STATUS=1
import sys, json, base64, binascii
from urllib.parse import unquote, urlsplit
j = json.load(open(sys.argv[1])); version = sys.argv[2]
ok = True
v = j.get("version"); pub = j.get("pub_date"); notes = j.get("notes", "")
print(f"version={v} {'OK' if v == version else 'MAUVAISE VERSION'} | pub_date={pub}")
ok &= v == version
print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}")
for plat, p in j.get("platforms", {}).items():
sig = p.get("signature", ""); url = p.get("url", "")
print(f" {plat}: url={url.rsplit('/', 1)[-1]} | signature={'OK (' + str(len(sig)) + ' car.)' if sig else 'VIDE'} | url contient version={'oui' if version in url else 'NON'}")
# The three keys (#320): tauri-plugin-updater looks up "{os}-{arch}-{installer}",
# then "{os}-{arch}". Each with the extension its URL must end with. No generic
# linux-x86_64 since 2026-10-11, so a manifest that still has it fails here as
# INATTENDUE. Same set as scripts/build-latest-json.sh writes and
# scripts/check-latest-json.jq checks: scripts/build-latest-json.test.mjs reads
# this line and compares.
EXPECTED = {"linux-x86_64-deb": ".deb", "linux-x86_64-rpm": ".rpm",
"windows-x86_64": ".exe"}
def signed_file(sig):
"""The file: field of the trusted comment, in the base64 minisign signature."""
try:
text = base64.b64decode(sig, validate=True).decode("utf-8")
except (binascii.Error, ValueError):
return None
for line in text.splitlines():
if line.startswith("trusted comment: ") and "\tfile:" in line:
return line.split("\tfile:", 1)[1]
return None
platforms = j.get("platforms", {})
for plat in sorted(set(platforms) | set(EXPECTED)):
if plat not in platforms:
print(f" {plat}: MANQUANT"); ok = False; continue
if plat not in EXPECTED:
print(f" {plat}: clé INATTENDUE"); ok = False; continue
p = platforms[plat]
sig = p.get("signature") or ""; url = p.get("url") or ""
name = unquote(urlsplit(url).path.rsplit("/", 1)[-1]) # file name, decoded
ext = EXPECTED[plat]; signed = signed_file(sig) if sig else None
checks = {
"extension": name.endswith(ext),
"signature": bool(sig),
"file:": signed == name,
"version": version in url,
}
ok &= all(checks.values())
print(f" {plat}: url={name}"
f" | extension {ext} {'OK' if checks['extension'] else 'MAUVAISE EXTENSION'}"
f" | signature {'OK (' + str(len(sig)) + ' car.)' if sig else 'VIDE'}"
f" | file: {'OK' if checks['file:'] else 'DIFFÉRENT (' + repr(signed) + ')'}"
f" | url contient version={'oui' if checks['version'] else 'NON'}")
sys.exit(0 if ok else 1)
EOF
echo
if [ "$STATUS" -eq 0 ]; then echo "VERDICT : OK"; else echo "VERDICT : ÉCHEC"; fi
exit "$STATUS"

View file

@ -27,6 +27,10 @@ on:
- cron: '0 6 * * *'
workflow_dispatch:
# What this key does on Forgejo 8.0.3: see the comment on `concurrency` in
# check-rust.yml (#376). That measurement covers push and pull_request runs;
# this workflow only runs on schedule and workflow_dispatch, which it does
# not cover.
concurrency:
group: ci-audit-${{ github.ref }}
cancel-in-progress: true
@ -39,7 +43,7 @@ jobs:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
- name: Install Node.js 24
run: |
apt-get update
apt-get install -y --no-install-recommends \
@ -47,7 +51,7 @@ jobs:
# Node.js is required by actions/checkout, a JavaScript action that
# needs `node` in the container PATH (taiki-e/install-action is a
# composite action).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
node --version

View file

@ -7,6 +7,11 @@ name: PR Check — Frontend
# running it is not. A root-level config file added later would drop out of an
# allowlist without anyone noticing; here it fails safe.
#
# .claude/** and release.yml are not ignored (#419): scripts/build-latest-json.test.mjs
# reads .claude/skills/release/scripts/verify-release.sh and runs the text of
# release.yml's "Publish latest.json to package registry" step, so a PR that
# touches only one of them must still run it.
#
# No `branches:` filter — see check-rust.yml.
on:
@ -18,14 +23,14 @@ on:
- 'reports/**'
- 'tasks/**'
- '.github/**'
- '.claude/**'
- '.forgejo/workflows/check-rust.yml'
- '.forgejo/workflows/audit.yml'
- '.forgejo/workflows/release.yml'
- '*.md'
- 'LICENSE'
# Distinct group from the rust workflow — see check-rust.yml.
# Same concurrency setup as check-rust.yml (own group, cancel-in-progress),
# whose comment says what it does on Forgejo 8.0.3 (#376) and why each
# workflow has its own group.
concurrency:
group: ci-frontend-${{ github.ref }}
cancel-in-progress: true
@ -38,14 +43,17 @@ jobs:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
- name: Install Node.js 24
run: |
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates git
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# jq: scripts/build-latest-json.test.mjs runs the script that writes
# the release's latest.json, and that script needs it (#320).
apt-get install -y --no-install-recommends curl ca-certificates git jq
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
node --version
npm --version
jq --version
- name: Checkout
uses: https://github.com/actions/checkout@v4

View file

@ -52,9 +52,21 @@ on:
- cron: '30 6 * * 1'
workflow_dispatch:
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
# Distinct from the frontend group: a shared group would make the two
# workflows cancel each other on a PR that touches both.
# What this key does on Forgejo 8.0.3 / runner v12.7.0, as measured on
# 2026-10-02 (la-compagnie-maximus docs/forgejo-ops.md, sections "Le schéma du
# workflow est validé par le runner" and "Un push annule le run du push
# précédent"):
# - it passes the runner's schema validation, but what it adds has not been
# isolated;
# - without it, Forgejo cancels a `push` run when a newer one of the same
# workflow is created on the same ref, whether the older run's job was still
# queued or already running;
# - no pull_request (or pull_request_sync) job has been cancelled by a later
# run: on a PR, a force-push does not cancel the obsolete run.
# Kept anyway (#376): harmless here, and it keeps its GitHub Actions meaning
# should these workflows run where it is honoured (GitHub, a later Forgejo).
# There, a group shared with the frontend workflow would make the two cancel
# each other on a PR that touches both, hence the distinct group.
concurrency:
group: ci-rust-${{ github.ref }}
cancel-in-progress: true
@ -165,19 +177,159 @@ jobs:
checks=$((checks + 1))
done
done
# Canary: a crate known to be present. If this stops being found, the
# loop above is broken and its silence means nothing.
# Canary: a crate known to be present on the Linux target, and only
# there. If this stops being found, the loop above is broken and its
# silence means nothing. webkit2gtk is Tauri's Linux webview (wry): a
# Tauri v2 Linux build cannot drop it, and as a Linux-only dependency
# it also proves that --target resolves target-conditional edges, which
# the per-target check above relies on. It replaced tar, a Linux-only
# dependency of the updater until #455 built it without its zip
# feature.
canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
-i tar --target x86_64-unknown-linux-gnu 2>/dev/null) || true
-i webkit2gtk --target x86_64-unknown-linux-gnu 2>/dev/null) || true
if [ -z "$canary" ]; then
echo "FAIL: canary 'tar' was not found although it is a known dependency. The guard is not proving anything."
echo "FAIL: canary 'webkit2gtk' was not found although it is a known dependency. The guard is not proving anything."
rc=1
else
echo "ok: canary 'tar' found for x86_64-unknown-linux-gnu"
echo "ok: canary 'webkit2gtk' found for x86_64-unknown-linux-gnu"
fi
echo "Suppression guard: $checks checks, exit $rc"
exit $rc
# Pin guard for windows-sys (#345). Seven crates in the lockfile accept
# windows-sys through a wide bound, so cargo may point each of them at
# any version the graph already holds. All seven point at 0.61.2, the
# state every Windows release has shipped with since v0.14.0. An update
# such as `cargo update -p hyper` re-resolves the seven edges down to
# 0.48/0.52/0.59, and cargo reports only the packages it bumped, never
# the edges: the Windows build then compiles five windows-sys versions
# instead of three, and nothing says so. Which updates do it cannot be
# told from their names (#348), so the step guards the resolved version.
# docs/adr/0023-windows-sys-pinning.md holds the measurements, the exit
# condition and what to do when this step fails.
#
# awk reads Cargo.lock: mawk is part of the ubuntu:22.04 base image,
# while python3 is only here as a transitive dependency of the apt
# packages installed above (run 517), which nothing guarantees. Its exit
# code is tested apart from its output: an empty output already fails the
# checks below (version set, missing blocks, canary), but an awk that
# fails after printing every fact would pass without that test. As in the
# step above, every check echoes its result, including the passing ones,
# and a canary must be read and checked, or the loop's silence proves
# nothing.
#
# No cargo call, so neither the warm index nor --locked matters here: the
# step reads the lockfile as cargo check left it, which is the committed
# one unless cargo had to update it.
- name: Verify windows-sys pinning
run: |
set -u
# The guarded list. Why 0.61.2, why these versions, and when either
# may change: docs/adr/0023-windows-sys-pinning.md. Edit CRATES, PIN
# or EXPECTED only on the proof that ADR asks for, never to turn this
# step green.
CRATES="colored dirs-sys errno rustix rustls-platform-verifier tempfile winapi-util"
PIN="0.61.2"
# Every windows-sys version in the lockfile, all targets included,
# as of 2026-10-10.
EXPECTED="0.45.0 0.48.0 0.52.0 0.59.0 0.60.2 0.61.2"
CANARY="tempfile"
LOCK="src-tauri/Cargo.lock"
ADR="docs/adr/0023-windows-sys-pinning.md"
rc=0
checks=0
canary_checked=0
# One fact per line: "wsys <version>" for each windows-sys package,
# and "block <crate> <version> [<target>...]" for each [[package]]
# block of a crate in CRATES, with one <target> per windows-sys
# edge: the version the edge names, or "-" for a bare edge (cargo
# drops the version when the lockfile holds a single windows-sys).
# A " (registry+...)" source suffix on an edge is dropped.
facts=$(awk -v crates="$CRATES" '
function flush( i, line) {
if (inpkg && name == "windows-sys") print "wsys", version
if (inpkg && (name in wanted)) {
line = "block " name " " version
for (i = 1; i <= n; i++) line = line " " target[i]
print line
}
inpkg = 0; name = ""; version = ""; n = 0
}
BEGIN { k = split(crates, list, " "); for (i = 1; i <= k; i++) wanted[list[i]] = 1 }
/^\[\[package\]\]$/ { flush(); inpkg = 1; next }
/^\[/ { flush(); next }
inpkg && /^name = "/ { name = $3; gsub(/"/, "", name) }
inpkg && /^version = "/ { version = $3; gsub(/"/, "", version) }
inpkg && /^[ \t]*"windows-sys[ "]/ {
s = $0; sub(/^[ \t]*"/, "", s); sub(/".*$/, "", s)
m = split(s, f, " "); target[++n] = (m >= 2 ? f[2] : "-")
}
END { flush() }
' "$LOCK") && awk_rc=0 || awk_rc=$?
if [ "$awk_rc" -ne 0 ]; then
echo "FAIL: awk exited $awk_rc reading $LOCK, so nothing was checked."
echo "windows-sys pinning: $checks checks, exit 1"
exit 1
fi
# The exact set of windows-sys versions, duplicates kept: a version
# that appears or disappears is a decision, not a side effect.
found=$(printf '%s\n' "$facts" | awk '$1 == "wsys" { print $2 }' | sort | paste -sd ' ' -)
want=$(printf '%s\n' $EXPECTED | sort | paste -sd ' ' -)
checks=$((checks + 1))
if [ "$found" = "$want" ]; then
echo "ok: windows-sys versions in the lockfile: $found"
else
echo "FAIL: windows-sys versions in the lockfile are \"$found\", expected exactly \"$want\". A version was added or removed: follow $ADR before editing EXPECTED."
rc=1
fi
# Every edge of every block of each guarded crate must name PIN. A
# bare edge passes only when the lockfile's one windows-sys is PIN.
for crate in $CRATES; do
blocks=$(printf '%s\n' "$facts" | awk -v c="$crate" '$1 == "block" && $2 == c')
if [ -z "$blocks" ]; then
checks=$((checks + 1))
echo "FAIL: no [[package]] block named $crate in $LOCK (renamed, or gone from the graph?). See $ADR."
rc=1
continue
fi
while read -r kind name version targets; do
if [ -z "$targets" ]; then
checks=$((checks + 1))
echo "FAIL: $crate $version has no windows-sys edge left. See $ADR."
rc=1
continue
fi
for target in $targets; do
checks=$((checks + 1))
if [ "$target" = "$PIN" ]; then
echo "ok: $crate $version -> windows-sys $PIN"
elif [ "$target" != "-" ]; then
echo "FAIL: $crate $version -> windows-sys $target, pinned at $PIN. See $ADR."
rc=1
elif [ "$found" = "$PIN" ]; then
echo "ok: $crate $version -> windows-sys (bare edge: the lockfile's only windows-sys is $PIN)"
else
echo "FAIL: $crate $version -> windows-sys with no version, while the lockfile holds \"$found\". See $ADR."
rc=1
fi
if [ "$crate" = "$CANARY" ]; then
canary_checked=1
fi
done
done <<<"$blocks"
done
# Canary: the loop must have read and checked an edge of a crate
# known to carry one. Otherwise its silence proves nothing.
checks=$((checks + 1))
if [ "$canary_checked" -eq 1 ]; then
echo "ok: canary '$CANARY' found, its windows-sys edge read and checked above"
else
echo "FAIL: canary '$CANARY' was not read and checked. The guard is not proving anything."
rc=1
fi
echo "windows-sys pinning: $checks checks, exit $rc"
exit $rc
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets

View file

@ -16,8 +16,14 @@ jobs:
run: |
apt-get update
apt-get install -y curl wget git sudo ca-certificates gnupg
# Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# Install Node.js 24 (#376). Trust note, kept on purpose without a
# pinned tarball: NodeSource's script runs as root in this
# container, and the Node.js it installs then runs `npx tauri build`
# with TAURI_SIGNING_PRIVATE_KEY in its environment. A compromised
# script or package would reach the updater signing key, and the
# publishing tokens of the later steps. See docs/architecture.md
# (CI/CD, release.yml).
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
@ -81,59 +87,31 @@ jobs:
NOTES=$(sed -n "/^## ${TAG}/,/^## /{/^## ${TAG}/d;/^## /d;p}" CHANGELOG.md)
fi
NOTES=$(echo "$NOTES" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba}')
# Random delimiter (#320): a changelog line equal to a fixed one
# would end the value there and turn the lines after it into step
# outputs.
DELIM="CHANGELOG_EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
{
echo "notes<<CHANGELOG_EOF"
echo "notes<<${DELIM}"
echo "$NOTES"
echo "CHANGELOG_EOF"
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"
- name: Generate latest.json
env:
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="${GITHUB_REF_NAME#v}"
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
LINUX_SIG=""
LINUX_DEB=""
WINDOWS_SIG=""
WINDOWS_EXE=""
for f in release-assets/*.deb.sig; do
[ -f "$f" ] && LINUX_SIG=$(cat "$f")
done
for f in release-assets/*.deb; do
[ -f "$f" ] && LINUX_DEB=$(basename "$f")
done
for f in release-assets/*-setup.exe.sig; do
[ -f "$f" ] && WINDOWS_SIG=$(cat "$f")
done
for f in release-assets/*-setup.exe; do
[ -f "$f" ] && WINDOWS_EXE=$(basename "$f")
done
PLATFORMS="{}"
if [ -n "$LINUX_SIG" ] && [ -n "$LINUX_DEB" ]; then
PLATFORMS=$(echo "$PLATFORMS" | jq \
--arg sig "$LINUX_SIG" \
--arg url "${BASE_URL}/${LINUX_DEB}" \
'. + {"linux-x86_64": {"signature": $sig, "url": $url}}')
fi
if [ -n "$WINDOWS_SIG" ] && [ -n "$WINDOWS_EXE" ]; then
PLATFORMS=$(echo "$PLATFORMS" | jq \
--arg sig "$WINDOWS_SIG" \
--arg url "${BASE_URL}/${WINDOWS_EXE}" \
'. + {"windows-x86_64": {"signature": $sig, "url": $url}}')
fi
jq -n \
--arg version "$VERSION" \
--arg notes "$CHANGELOG_NOTES" \
--arg pub_date "$PUB_DATE" \
--argjson platforms "$PLATFORMS" \
'{version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms}' \
# Three keys: one per Linux package (.deb, .rpm) and the NSIS
# installer, no generic linux-x86_64 key (#320); fails unless it finds
# exactly one .deb, .rpm and -setup.exe, each with a non-empty .sig.
# Notes reach jq as a file, as data.
# Called as scripts/build-latest-json.test.mjs calls it.
printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md
bash -e -o pipefail scripts/build-latest-json.sh \
release-assets \
"${GITHUB_REF_NAME#v}" \
/tmp/release-notes.md \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" \
> release-assets/latest.json
echo "Generated latest.json:"
@ -155,7 +133,7 @@ jobs:
## Installation
**Windows** : Téléchargez le fichier \`.exe\` ci-dessous.
**Linux** : Téléchargez le fichier \`.deb\` ou \`.AppImage\` ci-dessous."
**Linux** : Téléchargez le fichier \`.deb\` (Debian, Ubuntu) ou \`.rpm\` (Fedora, openSUSE) ci-dessous."
# Delete existing release for this tag (if any) to allow re-creation
EXISTING=$(curl -s "${API_URL}/repos/${REPO}/releases/tags/${TAG}" \
@ -214,6 +192,22 @@ jobs:
env:
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
# This text is run by scripts/build-latest-json.test.mjs, with a fake
# curl: keep it self-contained, no workflow expressions (dollar,
# double brace) in run:, pass values through env: (#419).
#
# Check the manifest before the DELETE below (#320): once the old
# version is deleted, installed apps find no update until a valid
# one is uploaded. A failed check stops the job, registry untouched.
# The filter lives in scripts/check-latest-json.jq; -s makes it
# refuse an empty file or one with two documents (#419).
if ! jq -e -s --arg version "${GITHUB_REF_NAME#v}" \
-f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then
echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:"
cat release-assets/latest.json
exit 1
fi
# DELETE uses API v1, PUT uses the package upload API
DELETE_URL="${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
UPLOAD_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"

View file

@ -2,6 +2,37 @@
## [Non publié]
## [0.17.2] - 2026-10-18
### Ajouté
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
- Parcours de démarrage : deux étapes de visite, « Parcourir les catégories et les mots-clés » (avant le classement) et « Parcourir les rapports » (en dernier), cochées par « J'ai compris » ; un profil qui avait terminé le parcours les trouve déjà faites ; leurs cartes, sur les pages Catégories et Rapports, expliquent les mots-clés et le hub des rapports (#425, #432).
### Modifié
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent 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 avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
- Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430).
- La répartition d'une transaction sur plusieurs catégories (bouton Répartition de la page Transactions) fait désormais partie de l'édition Base, comme l'écran de déverrouillage le promettait déjà. En édition Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran de déverrouillage au lieu de la fenêtre de répartition, pour toute ligne. Les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste (#369).
### Corrigé
- La page d'erreur du démarrage ne reste plus affichée quand le profil finit de s'ouvrir après le délai de 10 secondes : l'application s'affiche dès que le profil est ouvert (#395).
- Le petit badge à côté du bouton « Récupérer le prix » d'un titre boursier, dans le Bilan, affiche désormais « non garanti » au lieu de l'anglais « best-effort » (#389).
- Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320).
- Budget : une sous-catégorie dépliée s'affiche sous sa catégorie parente, et non plus en fin de groupe. Avec les sous-totaux en bas, chaque sous-total suit ses propres lignes, dans la grille Budget comme dans les rapports Budget vs réel et Comparables (#424).
- Import : le nom d'une source est celui de son sous-dossier, en lecture seule ; une source renommée à la configuration n'est plus prise pour une nouvelle source au prochain import (#426).
- Aide et guide : le Budget décrit la saisie dans la colonne Annuel (plus de bouton de répartition) et la saisie limitée aux catégories du dernier niveau ; les Rapports ont cinq cartes et plus d'onglets ; l'ajout d'un mot-clé depuis une transaction passe par le clic droit (#431).
- La suppression d’un profil, d’une catégorie, d’un ajustement, d’un gabarit de budget ou d’une catégorie du Bilan, et le retrait de la clé de licence, attendent maintenant votre réponse à la confirmation. Jusqu’ici, l’action partait dès l’affichage de la confirmation et « Annuler » n’empêchait rien : un profil supprimé perdait ses données. Les boutons de la confirmation suivent désormais la langue de l’app (#421).
### Sécurité
- Mise à jour de dépendances npm pour corriger les huit vulnérabilités signalées par `npm audit` (cinq élevées, trois modérées), par le seul fichier de verrouillage : chaque nouvelle version reste dans la plage que `package.json` déclarait déjà. `react-router-dom` et `react-router` 7.18.1 → 7.18.4 corrigent GHSA-qwww-vcr4-c8h2 (CSRF en mode React Server Components), l'advisory laissée en l'état jusqu'ici parce qu'en sortir semblait demander une migration vers react-router v8 ; la 7.18.2 l'a corrigée. Le reste est de l'outillage de build et de test, jamais livré dans l'application : `vitest` et `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 et `source-map-js` 1.2.1 → 1.2.2. Aucune de ces advisories n'était atteignable dans l'application livrée, qui exécute react-router dans sa propre fenêtre, sans serveur. De toutes ces mises à jour, seule celle de react-router modifie l'application construite : elle refuse désormais une navigation vers une autre origine, ce que l'application ne fait jamais, donc la navigation se comporte comme avant (#358).
- Mise à jour de la dépendance Rust `rustls` (0.23.45 → 0.23.46), corrigeant RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 et RUSTSEC-2026-0349. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` ; les appels de licence, d'authentification, de cours et de rétroaction de l'application passent par un autre moteur TLS. Aucune des quatre ne s'applique à l'usage qu'en fait l'application : aucun magasin de sessions maison ne peut journaliser de secret de session (0346), l'Encrypted Client Hello n'est jamais activé (0347), la décompression Brotli des certificats, optionnelle, n'est pas compilée (0348), et le fournisseur cryptographique `ring` ne propose aucun échange de clés post-quantique qui pourrait être rabattu (0349). Mise à jour quand même, plutôt que contournée par un raisonnement, puisqu'un correctif existe. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Ce qui change, c'est le comportement de la couche TLS elle-même : outre ces quatre correctifs, la 0.23.46 apporte une dizaine d'autres corrections (conformité au protocole, durcissement, entrées-sorties), comme le refus des enregistrements `change_cipher_spec` protégés et la mise à jour des clés TLS 1.3 au moment voulu (#447).
- Retrait de l'extraction d'archive de la mise à jour automatique (sa dépendance `zip` sous Windows et `tar` sous Linux), corrigeant RUSTSEC-2026-0367, RUSTSEC-2026-0368 et RUSTSEC-2026-0369. La mise à jour n'extrayait un paquet que s'il arrivait sous forme d'archive (un zip sous Windows, une AppImage en `.tar.gz` sous Linux), après vérification de sa signature, et les mises à jour de cette application ne sont jamais des archives : l'installeur Windows (`.exe`) est lancé tel quel et les paquets Linux (`.deb`, `.rpm`) sont installés par le gestionnaire de paquets du système. Aucun des trois avis, dont deux portent sur des permissions Unix alors que `zip` n'était compilé que pour Windows, n'était atteignable ; la dépendance est retirée plutôt que contournée par un raisonnement, faute de version corrigée compatible avec la mise à jour. Les mises à jour de l'installeur Windows et des paquets Linux fonctionnent comme avant (#455).
## [0.17.1] - 2026-10-08
### Ajouté

View file

@ -2,6 +2,37 @@
## [Unreleased]
## [0.17.2] - 2026-10-18
### Added
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
- Getting started: two visit steps, “Browse categories and keywords” (before categorizing) and “Browse the reports” (last), checked with “Got it”; a profile that had finished the path finds them already done; their cards, on the Categories and Reports pages, explain keywords and the reports hub (#425, #432).
### Changed
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
- Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430).
- Splitting a transaction across categories (the Split button of the Transactions page) is now part of the Base edition, as the unlock screen already promised. On the Free edition the button keeps its place, shows a lock and opens the unlock screen instead of the split window, for every row. Splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the list (#369).
### Fixed
- The startup error page no longer stays on screen when the profile finishes opening after the 10-second delay: the app now shows as soon as the profile is open (#395).
- In French, the small badge next to the "Fetch price" button of a stock in the Balance sheet now reads "non garanti" instead of the English "best-effort" (#389).
- Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320).
- Budget: an expanded subcategory now shows under its parent category instead of at the end of the group. With subtotals at the bottom, each subtotal follows its own rows, in the Budget grid as in the Budget vs Actual and Compare reports (#424).
- Import: a source is named after its subfolder, read-only; a source renamed during configuration is no longer taken for a new source at the next import (#426).
- Help and guide: Budget describes typing in the Annual column (no more split button) and that only last-level categories take an amount; Reports have five cards and no tabs; adding a keyword from a transaction goes through the right-click menu (#431).
- Deleting a profile, a category, an adjustment, a budget template or a Balance sheet category, and removing the license key, now wait for your answer to the confirmation. Until now the action ran as soon as the confirmation appeared, and “Cancel” stopped nothing: a deleted profile lost its data. The confirmation buttons now follow the app language (#421).
### Security
- Updated npm dependencies to clear the eight vulnerabilities reported by `npm audit` (five high, three moderate), through the lockfile alone: every new version stays within the range `package.json` already declares. `react-router-dom` and `react-router` 7.18.1 → 7.18.4 clear GHSA-qwww-vcr4-c8h2 (CSRF in React Server Components mode), the advisory left as is until now because leaving its range seemed to require migrating to react-router v8; 7.18.2 fixed it. The rest is build and test tooling, never shipped in the app: `vitest` and `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 and `source-map-js` 1.2.1 → 1.2.2. None of these advisories was reachable in the shipped app, which runs react-router in its own window, with no server. Of all these updates, only react-router's changes the built app: it now refuses a navigation to another origin, which the app never makes, so navigation behaves as before (#358).
- Updated the Rust dependency `rustls` (0.23.45 → 0.23.46), clearing RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and RUSTSEC-2026-0349. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater`; the application's own licence, authentication, market-price and feedback calls use a different TLS backend. None of the four applies to how the application uses it: no custom session store exists that could log session secrets (0346), Encrypted Client Hello is never enabled (0347), the optional Brotli certificate decompression is not compiled in (0348), and the `ring` cryptography provider offers no post-quantum key exchange that could be downgraded (0349). Updated anyway rather than reasoned around, since a fix exists. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. What changes is the TLS layer's own behaviour: besides these four fixes, 0.23.46 carries about ten other corrections (protocol conformance, hardening, I/O), such as rejecting protected `change_cipher_spec` records and performing TLS 1.3 key updates on time (#447).
- Removed the archive extraction of the automatic updater (its `zip` dependency on Windows and `tar` on Linux), clearing RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369. The updater only extracted an update that arrived as an archive (a zip on Windows, a `.tar.gz` AppImage on Linux), after checking its signature, and this application's updates are never archives: the Windows installer (`.exe`) is run as it is and the Linux packages (`.deb`, `.rpm`) are installed by the system's package manager. None of the three advisories, two of which concern Unix permissions while `zip` was only compiled for Windows, was reachable; the dependency is removed rather than reasoned around, since no fixed version fits the updater. Updates of the Windows installer and of the Linux packages work as before (#455).
## [0.17.1] - 2026-10-08
### Added

View file

@ -11,7 +11,7 @@
**Stockage :** SQLite local (tauri-plugin-sql)
**Langues supportées :** Français (FR) et Anglais (EN)
**Plateformes :** Windows, Linux
**Version actuelle :** 0.17.1
**Version actuelle :** 0.17.2
**Licence :** GPL-3.0-only
---
@ -134,6 +134,8 @@ La documentation technique est centralisée dans `docs/` :
- `docs/guide-utilisateur.md` — Guide utilisateur
- `docs/archive/` — Anciennes spécifications archivées
Les règles de revue des PR (niveaux de risque, ce qui compte par zone, commandes de vérification, pièges) sont dans [`REVIEW.md`](REVIEW.md), à la racine ; un changement qui rend faux un fait qu'il cite le met à jour dans la même PR.
**Règle : quand un changement touche l'architecture, mettre à jour la documentation :**
- Nouveau service, hook, commande Tauri, page/route, ou table SQL → mettre à jour `docs/architecture.md`
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
@ -162,8 +164,8 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle.
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown. Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` remonte 2 high en permanence — une seule advisory `react-router` (mode RSC, inatteignable dans une app de bureau sans serveur), acceptée et suivie en [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317).
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient).
- **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier.
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.

View file

@ -38,11 +38,10 @@ Application de bureau 100 % locale pour importer, catégoriser et analyser vos t
### Linux
1. Rendez-vous sur la page [**Releases**](https://github.com/Le-King-Fu/simpl-resultat/releases/latest)
2. Téléchargez le format adapté à votre distribution :
2. Téléchargez le paquet adapté à votre distribution :
- `.deb` pour Debian / Ubuntu
- `.rpm` pour Fedora / openSUSE
- `.AppImage` pour toute distribution (exécutable universel)
3. Installez le paquet ou lancez l'AppImage directement
3. Installez le paquet
## Démarrage rapide

190
REVIEW.md Normal file
View file

@ -0,0 +1,190 @@
# REVIEW.md — Simpl'Résultat
Règles de revue des PR de ce dépôt : niveau de risque, ce qui compte dans chaque zone, commandes de vérification et pièges connus. Ce fichier complète `CLAUDE.md` et `.claude/rules/` sans les remplacer : une zone en reprend ce qui sert à la revue, avec sa source.
À partir de #457, `/pr-review` lit ce fichier sur `main`, y compris pour une PR empilée : une PR qui le modifie est jugée par la version de `main`, et ses nouvelles règles valent pour les PR suivantes.
## Niveaux
Le niveau fixe la profondeur de la revue, que `/pr-review` règle pour chaque niveau à partir de #457. Il se calcule sur les fichiers que touche le diff, jamais sur l'auteur (Max, `/fix-issue`, `/autopilot`) :
- un fichier désigné par une ligne du tableau **Haut** dont la condition est remplie est de niveau haut ; sinon, un fichier désigné par **Bas** est de niveau bas ; sinon il est normal ;
- la PR prend le niveau le plus haut de ses fichiers, et la revue l'annonce avec le fichier qui le fixe ;
- un fichier renommé, déplacé ou supprimé se juge sur son ancien et sur son nouveau chemin : un fichier de niveau haut déplacé reste haut, et la PR qui le déplace met ce tableau à jour ;
- une condition qu'on ne sait pas trancher à la lecture du diff compte comme remplie.
Motifs : chemins depuis la racine. `*` ne traverse jamais un `/` ; `**/` vaut zéro, un ou plusieurs dossiers (`**/*.md` couvre `CLAUDE.md`, à la racine) ; `{a,b}` liste des variantes.
### Haut
| Motif | Condition | Zone |
|---|---|---|
| `src-tauri/src/database/**` | toujours | Migrations, profils et données |
| `src-tauri/src/lib.rs` | sauf si le diff se limite à des entrées de `generate_handler![…]` ou au module `tests` | Migrations (`migrations()`), plugins, deep link OAuth |
| `src-tauri/src/commands/profile_commands.rs` | toujours | Migrations, profils et données |
| `src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs`, `src/services/{licenseService,authService}.ts`, `.claude/skills/issue-license/**` | toujours | Jetons, licence et édition |
| `src-tauri/src/commands/balance_commands.rs` | le diff touche `fetch_price` ou la lecture du jeton d'activation | Jetons, licence et édition |
| `src-tauri/src/commands/export_import_commands.rs` | toujours | Jetons, licence et édition (export chiffré) |
| `src-tauri/src/commands/feedback_commands.rs`, `src/services/feedbackService.ts` | toujours | Vie privée |
| code livré : `src/**`, `src-tauri/src/**`, `index.html`, `public/**` | le diff ajoute un appel réseau, change ce qu'un appel existant envoie, ouvre une URL qui porte une donnée de l'utilisateur (donnée financière, identifiant, contenu d'un profil, ligne du Journal), ou retire ou contourne un consentement (celui des cours vit côté front : `src/components/balance/PriceFetchControl.tsx`, `src/components/settings/PriceFetchConsentToggle.tsx`) | Vie privée |
| `src-tauri/capabilities/**` | toujours | Vie privée |
| `src-tauri/{tauri*.conf.json*,Tauri*.toml}` | sauf si le diff se limite au champ `"version"` ; un fichier propre à une plateforme est fusionné avec `tauri.conf.json` | Vie privée (CSP), Release (updater, `beforeBuildCommand`) |
| `.forgejo/workflows/release.yml`, `scripts/build-latest-json*`, `scripts/check-latest-json.jq`, `.claude/skills/release/**` | toujours | Release et mise à jour |
| `src-tauri/build.rs`, `{vite,vitest,postcss}.config.*`, `.postcssrc*`, `.npmrc`, `**/rust-toolchain*`, `Cargo.{toml,lock}` à la racine | toujours | Release et mise à jour, CI et gardes (lu ou exécuté par le build de la release ou par `npm test`) |
| fichier à la racine du dépôt ou directement dans `src-tauri/`, hors `*.md` | ajouté, renommé ou supprimé, comme tout fichier d'un dossier caché nouveau : une configuration nouvelle peut changer le build ou la CI sans motif qui la nomme | Release et mise à jour, CI et gardes |
| `src-tauri/Cargo.toml`, `package.json` | sauf si le diff se limite au champ `version` de l'app (dépendances, features dont `dev-override`, `overrides`, scripts) | Dépendances ; Release pour les `scripts` de `package.json` |
| `src-tauri/Cargo.lock`, `package-lock.json` | le diff ajoute ou retire un paquet (un bloc `[[package]]` dont la ligne `name =` apparaît ou disparaît, une seconde version d'un crate comprise ; une clé `"node_modules/<nom>"` qui apparaît ou disparaît), ou change l'origine d'un paquet : hôte ou schéma de `source` (Cargo) ou de `resolved` (npm), avec ou sans nouvelle version, ou `checksum`, `integrity` changés sans nouvelle version | Dépendances |
| `**/.cargo/**` | toujours | Dépendances |
| `.forgejo/workflows/**` | fichier ajouté, ou diff qui ajoute une référence à `secrets` ou un déclencheur `pull_request_target`, qui exécute la définition de la base avec ses secrets pour toute PR, forks compris | CI et gardes |
| `.forgejo/workflows/audit.yml` | le diff change une ligne active : sans déclencheur `pull_request`, il tourne pour la première fois sur `main` | CI et gardes |
| `.forgejo/workflows/{check-rust,check-frontend}.yml` | le diff peut faire passer au vert ce qui échouait, par exemple : déclencheur retiré ou restreint (`paths`, `paths-ignore`, `branches`, `schedule`) ; étape de verdict modifiée ou retirée (`npm ci`, `npm run build`, `npm test`, `cargo check`, `cargo test`, `Verify …`, `cargo audit`) ; `continue-on-error` ou `\|\| true` ajouté ; étape `run:` ajoutée qui écrit `$GITHUB_ENV`, `$GITHUB_PATH` ou un fichier du dépôt ; action `uses:` ajoutée, hors cache ; étape d'installation modifiée (toolchain, binaire placé dans le `PATH`) ; `if:`, `defaults:`, `permissions:`, `env:` ou `container:` modifiés | CI et gardes |
| `.claude/settings.json`, `.mcp.json` | toujours : hooks, permissions et serveurs MCP décident de ce que les agents exécutent sur le poste, sans CI pour le voir | CI et gardes |
| `REVIEW.md` | le diff touche la section « Niveaux » | Règles de revue |
Restent au moins normaux, et leurs règles de zone s'appliquent quand même :
- une montée de version d'un paquet déjà présent dans un lock, correctif d'advisory compris (décision de Max du 2026-10-17, consignée sur #456), et une arête re-résolue vers une version déjà présente : seul le garde `windows-sys` de la CI en surveille, pour ses sept crates ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)) ;
- dans `check-rust.yml` et `check-frontend.yml` : un déclencheur ajouté, sauf `pull_request_target`, une étape `run:` ajoutée qui ne fait que lire, un cache (`Swatinem/rust-cache`, `actions/cache`), un commentaire, la version de Node ;
- `src/services/db.ts` et `src/services/dataExportService.ts`, même quand le diff touche le verrou, le changement de profil ou les suppressions de la restauration (décision de Max du 2026-10-18, revue de #460) : contre leurs défauts, la mutation du niveau normal est le bon outil ;
- `src-tauri/src/commands/{fs_commands,backup_commands}.rs`, et le reste de `balance_commands.rs` ;
- `src/shared/entitlements.ts` : la matrice ne verrouille que l'interface ([ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2) ;
- `CLAUDE.md`, `REVIEW.md` hors de la section « Niveaux », `docs/adr/**`, `docs/architecture.md`, `docs/qa-update-cycle.md` et `.claude/**` : sources de ce fichier et instructions des agents, jamais de niveau bas.
### Bas
Une PR est de niveau bas quand tous ses fichiers sont désignés par `**/*.md`, `docs/**`, `LICENSE`, `tasks/**` ou `reports/**`, hors des exceptions de la liste précédente. `STATE.md` reste bas malgré son import par `CLAUDE.md` : il résume l'état du projet, il ne fixe pas de règle.
### Normal
Tout le reste : code applicatif et tests, traductions (`src/i18n/locales/*.json`), `public/`, configuration TypeScript, `.github/**` (miroir dormant).
### Calibration
Rejouée le 2026-10-17 (#456) sur les PR mergées de #344 à #452 : les 39 PR de référence du document de départ du pilote, dont la liste est figée dans la PR qui ferme #456, et la plage complète, soit 51 PR.
| Ensemble | Bas | Normal | Haut |
|---|---|---|---|
| 39 PR de référence, motifs d'essai | 3 | 19 | 17 (44 %) |
| 39 PR de référence, ces motifs | 3 | 28 | 8 (21 %, pour une cible de 20 %) |
| 51 PR de la plage, ces motifs | 6 | 33 | 12 (24 %) |
Niveau haut sur les 51 : licence (#359, #364, #366, #368, #386, #387, #388), migrations et profils (#394, #436), release (#414, #416, #418) ; parmi les 39 de référence, #386, #387, #388, #394, #414, #416, #418 et #436. L'écart à la cible est justifié dans la PR qui ferme #456, qui consigne aussi le niveau de chaque PR. Le script qui les calcule est `scripts/review-levels.py` (API Forgejo, lecture seule) ; `python3 scripts/review-levels.py --check` vérifie, sans réseau, que ses motifs sont ceux du tableau Haut.
Une PR qui modifie cette section dit, pour chaque motif retiré ou assoupli, ce qu'il protégeait, met `scripts/review-levels.py` à jour dans le même diff et le rejoue sur les mêmes PR ; le pilote #458 mesure si les niveaux tiennent.
## Ce qui compte par zone
Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR normale qui touche une zone les respecte aussi.
### Vie privée
- Aucune donnée financière ne quitte le poste : ni télémétrie, ni analytics, et le CSV comme la base restent locaux. Un appel réseau ajouté se justifie dans la PR : il n'envoie rien de financier ni aucun identifiant brut, et il passe derrière un consentement ou l'édition. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), [ADR 0020](docs/adr/0020-identifiant-machine-derive.md)
- Les appels sortants partent tous du Rust (`reqwest`) : `auth_commands.rs` (compte), `license_commands.rs` (licence et activation), `balance_commands.rs` (`fetch_price`, qui porte le jeton d'activation) et `feedback_commands.rs`, plus l'endpoint du plugin d'updater. Le `connect-src` de `app.security.csp` (`api.`, `auth.` et `feedback.lacompagniemaximus.com`) ne borne que la webview : lire chaque URL, chaque en-tête et chaque corps envoyé. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src-tauri/tauri.conf.json`, `src-tauri/src/commands/`
- Les cours passent par `maximus-api`, après un consentement explicite par profil, avec les seuls en-têtes `Authorization`, `Accept` et `User-Agent` : le fournisseur tiers ne voit jamais le poste. Ce consentement n'est vérifié que côté front (`PriceFetchControl.tsx`, `PriceFetchConsentToggle.tsx`) : `fetch_price` ne le revérifie pas. — [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), `src-tauri/src/commands/balance_commands.rs`
- Seule l'activation envoie un identifiant de la machine, et seulement le dérivé (`HMAC-SHA256` de l'identifiant du système) ; un jeton émis avant la dérivation porte encore l'identifiant brut jusqu'à la réactivation. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 2 et 5
- Le Feedback Hub reçoit le texte saisi, suivi au choix des dernières erreurs du Journal, un `userId` facultatif et un contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage) : un champ ajouté au contexte élargit ce qui part. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src/services/feedbackService.ts`, `src/components/settings/FeedbackDialog.tsx`
- La webview n'a que les permissions de `src-tauri/capabilities/default.json` : l'accès aux fichiers passe par les commandes Rust, jamais par une permission `fs`, et une permission ajoutée se justifie. — `CLAUDE.md` « Règles d'architecture »
- Un message du Journal nomme un fichier, jamais son dossier : le chemin contient le nom d'utilisateur, et une ligne d'erreur du Journal peut partir avec une rétroaction (règle posée pour le rattrapage des profils). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
### Précision financière et import
- Montants : virgule ou point décimal, espaces, symboles monétaires, négatifs, cellules vides et formats inattendus ont chacun leur test. — `CLAUDE.md` « Précision financière » et « Conventions de code » (Général)
- Encodages CSV : UTF-8, Windows-1252, ISO-8859-15. — `CLAUDE.md` « Précision financière »
- Le format d'import est persisté en entier sur la source et lu tel quel, jamais re-deviné ; l'aperçu reste obligatoire avant tout import. — [ADR 0019](docs/adr/0019-format-import-persiste.md), `CLAUDE.md` « Fonctionnalités principales »
### Migrations, profils et données
- Jamais de modification d'une migration existante : son checksum est vérifié au démarrage et épinglé par le test `migration_checksums_match_the_released_ones`. On ajoute une version. — `CLAUDE.md` « Base de données », [ADR 0003](docs/adr/0003-sqlx-migrations.md), `.claude/rules/sql-migrations.md`
- Une migration atteint deux chemins et reste correcte sur les deux : le profil par défaut, migré par tauri-plugin-sql (sqlx), et chaque profil secondaire, rattrapé à son ouverture par `prepare_profile_db` (rusqlite, clés étrangères actives). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
- Tout changement de schéma et toute correction de données (seed, mots-clés, catégories) se reportent dans `consolidated_schema.sql` : un profil neuf naît avec l'historique complet et ne rejoue aucune migration. Aucun marqueur ajouté à `infer_floor`, réservé aux bases créées avant #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
- Les comptes de tables, d'index et de migrations de `CLAUDE.md` et de `docs/architecture.md` suivent. — `CLAUDE.md` « Base de données » et « Documentation technique »
- Les bases vivent sous `app_config_dir()`, qui n'est pas `app_data_dir()` sous Linux. Les deux dossiers dérivent de l'`identifier` de `tauri.conf.json` : le changer orpheline les bases des profils et, sous `app_data_dir()`, la clé de licence, le jeton d'activation et le fichier de repli des jetons OAuth. Le service du trousseau (`KEYCHAIN_SERVICE` de `token_store.rs`) est une constante alignée sur l'`identifier` par convention : il ne suit pas. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `CLAUDE.md` « Base de données », `src-tauri/src/commands/license_commands.rs`
- Toute requête passe par le verrou unique de `db.ts`, et une transaction par `withTransaction` et son handle ; un changement de profil ferme puis rouvre la connexion. La restauration d'une sauvegarde (`runRestore` de `dataExportService.ts`) efface puis réinsère dans une seule transaction et ne notifie qu'après le `COMMIT`, une fois le verrou relâché. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md), [ADR 0005](docs/adr/0005-multi-profile-db.md), `docs/architecture.md` « Accès DB et transactions »
- Un booléen se lie en `1`/`0` (`toSqlFlag`), jamais en `true`/`false`. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- Une base par profil, PIN haché par Argon2. — [ADR 0005](docs/adr/0005-multi-profile-db.md)
- Une nouvelle écriture d'une table que lit `COUNTERS_SQL` appelle `notifyDataChanged()` après succès, avec un test qui prouve la notification. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md)
### Jetons, licence et édition
- Jetons OAuth dans le trousseau du système ; repli sur un fichier `0600` seulement sans trousseau, et plus de retour au fichier une fois le trousseau utilisé. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md)
- Le callback `simpl-resultat://auth/callback` n'est traité que par l'instance qui détient le vérificateur PKCE : `tauri-plugin-single-instance` reste le premier plugin enregistré, et un callback sans `code` remonte une erreur au front. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `docs/architecture.md` « OAuth2 et deep-link (Compte Maximus) »
- Export chiffré SREF : AES-256-GCM, clé dérivée du mot de passe par Argon2id, sel et nonce aléatoires. — [ADR 0004](docs/adr/0004-aes-256-gcm-encryption.md)
- La dérivation de l'identifiant machine est figée (`derive_machine_id`), et un jeton émis avant elle reste accepté. Une montée de `machine-uid` se vérifie : une forme renvoyée différente changerait l'identifiant dérivé de chaque machine. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 1, 3 et « À surveiller »
- `isEntitled` est fail-closed en Free : le court-circuit `free` passe avant l'override signé `features[]` (CWE-863). `useEntitlement` rend `{ allowed, ready }`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 1 et 3
- Une fonctionnalité verrouillée reste visible, jamais masquée ; le gating bloque l'accès, jamais les données. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 4 et 5
- Le gating ne s'applique qu'à l'interface (soft-paywall assumé, l'app est GPL), et `SR_DEV_EDITION` n'existe que dans un build compilé avec la feature `dev-override`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2 et 6
### Dépendances
- `.cargo/audit.toml` n'admet une advisory que si son crate est absent de toutes les cibles livrées (`cargo tree -i <crate> --target <triple>`, Linux et Windows, jamais `--target all`) ou si aucun correctif n'existe. Une entrée porte un ID, jamais un crate ; elle ajoute son crate à la liste du garde de `check-rust.yml` et a son issue de suivi, qui porte la condition de retrait. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), en-tête de `.cargo/audit.toml`, `CLAUDE.md` « CI/CD »
- Garde `windows-sys` : s'il rougit après un `cargo update`, remettre les arêtes à la main selon l'ADR ; ne jamais modifier la liste gardée (`CRATES`), la version épinglée (`PIN`), l'ensemble attendu (`EXPECTED`) ni le canari pour le faire passer. Un `cargo update` ciblé peut déplacer d'autres arêtes sans le dire : chaque entrée qui bouge dans le lock doit s'expliquer. — [ADR 0023](docs/adr/0023-windows-sys-pinning.md), `CLAUDE.md` « CI/CD »
- Une version montée exécute son nouveau code au build de la release, clé de signature dans l'environnement : scripts de build et macros des crates, code des paquets npm que lancent `npm run build` et `npx tauri build`. Lire ce que change la version, pas seulement son numéro. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- Une advisory corrigée se prouve par `cargo audit` lancé depuis la racine, là où `.cargo/audit.toml` s'applique. `npm audit` n'est pas un gate de CI. — `CLAUDE.md` « CI/CD »
### Release et mise à jour
- `release.yml` met la clé de signature de l'updater (`TAURI_SIGNING_PRIVATE_KEY`) dans l'environnement des deux étapes `npx tauri build`. S'exécutent avec elle : `beforeBuildCommand` (`npm run build`, donc `tsc`, `vite.config.ts` et les `scripts` de `package.json`), `@tauri-apps/cli` et son binaire natif, qui lit la clé, `cargo-xwin` (`--runner cargo-xwin` pour Windows) et la toolchain Rust, installés à chaque tag sans version épinglée, `src-tauri/build.rs`, les scripts de build et les macros des crates, et tout module JS qu'une directive CSS `@plugin` ou `@config` fait charger. `npm ci` tourne avant, sans elle, mais ce qu'il installe s'exécute ensuite avec elle. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `latest.json` porte une clé par paquet (`linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), contrôlée par `scripts/check-latest-json.jq` avant le `DELETE` du registre ; `scripts/build-latest-json.test.mjs` tourne dans `npm test`. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `release.yml` signe les binaires et publie le JSON d'updater que lisent toutes les installations : une clé publique de `plugins.updater` changée sans bascule prévue coupe leurs mises à jour, comme un endpoint déplacé. — `CLAUDE.md` « CI/CD » et « Fonctionnalités principales »
- Une release qui touche l'updater, ses dépendances, `release.yml` ou la config `updater` exige un cycle réel de mise à jour (étape 10 de `/release`, `docs/qa-update-cycle.md`) : la PR le signale pour la release suivante. `verify-release.sh` contrôle la release publiée. — `CLAUDE.md` « CI/CD », `.claude/skills/release/SKILL.md`
### CI et gardes
- Aucun workflow `check-*` ne filtre sur `branches:` : `branches: [main]` avait laissé sans CI quatre des cinq PR empilées de la milestone feature-gating. `check-frontend.yml` exclut par `paths-ignore` pour qu'un fichier nouveau déclenche la CI au lieu de la sauter. — `CLAUDE.md` « CI/CD », en-têtes de `check-rust.yml` et `check-frontend.yml`
- `audit.yml` est le seul canal des avis RustSec : quotidien et bloquant. L'étape `cargo audit` de `check-rust` est non bloquante par choix. — `CLAUDE.md` « CI/CD »
- Une garde affiche chacune de ses vérifications, y compris celles qui passent, et lit un canari : une garde muette ne prouve rien. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md) (règle 3), [ADR 0023](docs/adr/0023-windows-sys-pinning.md)
- Une PR qui ne touche que `audit.yml` n'a aucune CI : les `paths` de `check-rust.yml` ne le listent pas, et le `paths-ignore` de `check-frontend.yml` l'exclut. Une PR qui ne touche que `release.yml` lance `check-frontend`, dont un test exécute le texte de l'étape « Publish latest.json to package registry » avec un `curl` factice (#419) ; le reste de `release.yml` ne s'exerce qu'au tag. La revue rejoue elle-même ce qui peut l'être. — `CLAUDE.md` « CI/CD », `check-rust.yml`, `check-frontend.yml`
- Les secrets du dépôt (clé de signature et son mot de passe, jeton du registre de paquets qui remplace `latest.json`) sont lisibles par tout workflow : un workflow ajouté, une référence à `secrets` ajoutée ou un déclencheur `pull_request_target` se lit comme du code de release. — `CLAUDE.md` « CI/CD », `release.yml`
### i18n et documentation
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`
- Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG)
- Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique »
### Règles de revue
- Un changement qui rend faux un fait cité ici (sites d'appel réseau, hôtes de `connect-src`, commandes et déclencheurs de la CI, chemins du tableau Haut) met ce fichier à jour dans la même PR. — `CLAUDE.md` « Documentation technique »
## Commandes de vérification
Les workflows font foi ; ce tableau les résume au 2026-10-18. Le runner a une capacité de 1 : les jobs se suivent, et chaque maillon d'une pile a sa CI. — `CLAUDE.md` « CI/CD »
| Workflow | Déclencheur | Commandes |
|---|---|---|
| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) |
| `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) |
| `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) |
| `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` |
`cargo check` et `cargo test` tournent sans `--locked` dans la CI ; #449 l'ajoute, et sa PR met ce tableau à jour.
En local, les mêmes commandes, depuis la racine :
```bash
npm ci && npm run build && npm test
cargo check --manifest-path src-tauri/Cargo.toml --all-targets
cargo test --manifest-path src-tauri/Cargo.toml --all-targets
cargo audit --file src-tauri/Cargo.lock # depuis la racine : .cargo/audit.toml s'applique
```
- `npm test` lancé à la racine parcourt aussi `.claude/worktrees/` (`vite.config.ts` ne l'exclut pas) : vider ce dossier avant, ou lancer la suite depuis le worktree. — `.claude/skills/release/SKILL.md` (étape 0)
- Les gardes de `check-rust.yml` se rejouent en exécutant le bloc `run:` de leur étape depuis la racine, sous `bash --noprofile --norc -e -o pipefail`, le shell du runner dans un conteneur `ubuntu:22.04`.
- Windows, pour une dépendance, une feature ou du code sous `cfg(windows)`, sur un poste où cargo-xwin est déjà installé : `cargo xwin check --manifest-path src-tauri/Cargo.toml --locked --target x86_64-pc-windows-msvc`, précédé d'un témoin sur `main` (un témoin rouge met l'outil en cause, pas la PR). Installer cargo-xwin vaut acceptation de la licence du CRT et du SDK Windows de Microsoft : une revue ne l'installe jamais. (#455)
- Un `npm test` rejoué hors CI ne compte qu'après une sonde : un test volontairement rouge doit le faire sortir en code non nul.
## Pièges de revue
Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dépôt.
- **CI d'une pile.** Chaque maillon a sa CI depuis #232 : la section « CI coverage gap » du skill (CI réservée aux PR qui ciblent `main`) ne vaut pas ici. `check-rust` ne tourne que si le diff du maillon touche ses chemins : l'état Rust d'une pile est prouvé par le dernier maillon qui en a touché. Exception : `fs_commands.rs` compile par `include_str!` les deux CSV de `src/__fixtures__/samples/`, hors de ces chemins ; une PR qui les renomme, les déplace ou les supprime casse `cargo check` sans que `check-rust` tourne. — `CLAUDE.md` « CI/CD », `src-tauri/src/commands/fs_commands.rs`
- **Un filtre `paths:` n'est pas prouvé par une PR qui touche le chemin exact.** Le glob (`src-tauri/**`) l'est par une PR qui touche un autre fichier du dossier, comme la PR #344 pour `check-rust`. — skill `pr-review` (section CI), #232
- **CI Rust et revue en fork.** Un fork qui attend la CI pour poster son verdict peut s'arrêter sans rien publier (PR #303) : poster les constats, surveiller le statut depuis la session principale, relancer la revue au vert. `check-rust` prend environ 3 min 30 avec le cache et 10 min à froid, sans compter la file du runner. — skill `pr-review` (délégation), #234
- **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447
- **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455
- **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) »
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389
- **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452
- **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions »
- **Dépôt public.** Aucune coordonnée d'un tiers (courriel, adresse, téléphone), aucune clé de licence ni aucun jeton dans un fichier commité, `STATE.md`, `docs/` et `.claude/skills/` compris ; un testeur se désigne par son identifiant Logto. — `.claude/skills/issue-license/SKILL.md`

106
STATE.md
View file

@ -1,8 +1,8 @@
# STATE — Simpl'Résultat
> Derniere MAJ : 2026-10-08 (**Parcours de démarrage guidé mergé — pile #402 → #407 en avance rapide par l'API, `main` `ee17f31`, milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6.** Six `/pr-review` APPROVE, puis sept commits en tête de pile sur décision de Max, dont la voie A : l'étape « Importer un premier fichier » ne compte plus qu'un fichier qui a amené une transaction. Suites #408 et #409. Restent les vérifications manuelles sur un build, à faire avec celles de #390.)
> Derniere MAJ : 2026-10-18 (**Étape 10 faite sous Linux et #421 fermée — cycle réel v0.17.1 → v0.17.2 réussi (`.deb`, invite de mot de passe vue, type non consigné), trace dans le corps de la release ; #421 vérifiée sous Linux par Max, fermée à la main (commentaire 25074).** Restent les cycles Windows et `.rpm`, et les vérifications dans l'app de #369 et des retours de la v0.17.1, non confirmées à ce jour. Aucune migration (v1→v17).)
>
> Précédente MAJ : 2026-10-06 (**#390 mergée — PR #394 en avance rapide par l'API, `main` `5910387`.** L'étape 5 de la liste de démarrage se coche enfin. Les profils secondaires rattrapent les migrations qu'ils n'avaient jamais reçues. Sous Linux, les commandes cherchent les bases dans le bon dossier. #390 reste ouverte en `status:blocked` jusqu'à la liste post-merge. Suivis de revue en #395.)
> Précédente MAJ : 2026-10-18 (**Relevé du pilote #458 à 4 PR sur 20 : #464 et #465 ajoutées au tableau (commentaire 24819), niveau haut jugé juste par Max pour les deux ; rangs corrigés ici (#450 était la deuxième, #464 est la troisième, #465 la quatrième).** Les PR mergées depuis la v0.17.1 sont marquées publiées dans la v0.17.2. Vérifications dans l'app et étape 10 en cours chez Max, concluantes à date. Aucune migration (v1→v17).)
## Position actuelle
@ -10,7 +10,7 @@ v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16).
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'est plus l'état courant** : des advisories publiées depuis l'ont ramené à 3, traitées en #311 le 2026-07-27 → **2 restantes, acceptées** (`react-router`, cf. entrée du jour). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'a pas tenu** : des advisories publiées depuis l'ont ramené à 3 (#311, 2026-07-27, 2 acceptées sur `react-router`), puis à 8 ; **0 de nouveau au 2026-10-10** par le rafraîchissement du lock de #358 (mergé le 2026-10-12), qui ferme aussi #317. Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
**v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe).
@ -18,65 +18,67 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**v0.15.0 shippée** (2026-09-12, tag `v0.15.0` → CI release run 402, 7 artefacts + `latest.json` vérifiés) — première version où le gating par édition est actif : sans clé, Budget, Ajustements, rapports avancés, profils multiples, Bilan et mises à jour automatiques sont verrouillés (données intactes, ADR 0017). Les deux testeurs (Max, Michel) ont une clé Premium émise avant le tag. `[Unreleased]` vide. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` **consignée le 2026-09-13** : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs, trace rédigée pour le corps de la release Forgejo (invite polkit Linux non consignée, `.rpm` hors test → #320).
**v0.16.0 shippée** (2026-10-03, tag `v0.16.0` → CI release run 451, 7 artefacts + `latest.json` vérifiés, registre de l'updater en 0.16.0) — première version vendable de bout en bout : « Obtenir Base » ouvre la page produit (#346), le client de licence parle `/v1` et nomme chaque refus (#270), l'édition ne dépend que de la clé (#347), l'activation envoie un identifiant dérivé (#350), une clé révoquée retombe en Gratuite après un contrôle au démarrage (#365), et `rustls` 0.23.45 (#348). `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Sur la release publiée : #365 vérifiée et fermée le 2026-10-03, étape 10 de `/release` faite sous Linux le même jour (trace dans le corps de la release) ; reste l'étape 10 sous Windows (Michel) ; #350 fermée et #346 vérifiée le 2026-10-04.
**v0.16.0 shippée** (2026-10-03, tag `v0.16.0` → CI release run 451, 7 artefacts + `latest.json` vérifiés, registre de l'updater en 0.16.0) — première version vendable de bout en bout : « Obtenir Base » ouvre la page produit (#346), le client de licence parle `/v1` et nomme chaque refus (#270), l'édition ne dépend que de la clé (#347), l'activation envoie un identifiant dérivé (#350), une clé révoquée retombe en Gratuite après un contrôle au démarrage (#365), et `rustls` 0.23.45 (#348). `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Sur la release publiée : #365 vérifiée et fermée le 2026-10-03, étape 10 de `/release` faite sous Linux le même jour (trace dans le corps de la release) ; étape 10 sous Windows faite par Michel (confirmée le 2026-10-10) ; #350 fermée et #346 vérifiée le 2026-10-04.
**Onboarding phase 1 mergée sur `main`** (2026-10-04, `667e058`, publiée dans la v0.17.0) — milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 (#351-#357, PRs #377-#383, produites par `/autopilot` le 2026-10-03 puis relues une à une par `/pr-review`) : états vides guidés (dashboard, Import, Transactions, Budget), liste de démarrage à 6 cases calculée depuis la base, bouton « Créer un dossier d'exemple », guide « Exporter un CSV depuis votre banque » (Desjardins, Tangerine, RBC, BNC, BMO). Aucune migration DB (v1→v17). Suivis : #384 (non bloquants) et #371 (BMO sur compte réel, saisie de Max).
**Suites de licence mergées sur `main`** (2026-10-05, `2cbc886`, publiées dans la v0.17.0) — milestone `planned-2026-10-03-suites-licence` fermée 3/3 (#373, #370, #367 ; PRs #386 → #388, produites par `/autopilot` le 2026-10-04, relues par trois `/pr-review` APPROVE, suivis appliqués en tête de pile) : contrôle d'une clé révoquée dès sa saisie (au plus une fois par 24 h et par clé), « Retirer la clé » dans la carte Licence (place libérée d'abord sur une machine activée, contrôle quotidien arrêté), jeton d'une ancienne licence retiré sur un refus des cours ou à l'ouverture de la liste des machines. Aucune migration DB (v1→v17). Suivi : #389 (huit autres fichiers de tests au faux `t` brut).
**v0.17.0 shippée** (2026-10-05, tag `v0.17.0` sur `07255e1` → CI release run 491 en 22 min, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.0) — onboarding phase 1 et suites de licence. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Restent les vérifications manuelles sur la release publiée (Blockers) et, de la v0.16.0, le cycle de mise à jour Windows (Michel).
**v0.17.0 shippée** (2026-10-05, tag `v0.17.0` sur `07255e1` → CI release run 491 en 22 min, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.0) — onboarding phase 1 et suites de licence. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Vérifications manuelles des suites de licence faites sur la release publiée (confirmées le 2026-10-10).
**Parcours de démarrage guidé mergé sur `main`** (2026-10-08, `ee17f31`, non publié) — milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 (#396-#401, PRs #402 → #407, produites par `/autopilot` le 2026-10-08, relues par six `/pr-review` APPROVE, suivis appliqués en tête de pile) : bandeau « Démarrage n/N · Étape suivante » sur chaque page, cartes d'étape avec mise en évidence du contrôle sur Import, Transactions et Budget, état partagé par un fournisseur et canal d'événements de données (ADR 0022), règle D9 (un profil déjà actif trouve le parcours masqué). `[Unreleased]` porte aussi #390. Aucune migration DB (v1→v17). Suivis : #408 (`db.ts` hors verrou pendant une transaction) et #409 (harnais de test).
**Parcours de démarrage guidé mergé sur `main`** (2026-10-08, `ee17f31`, publié dans la v0.17.1) — milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 (#396-#401, PRs #402 → #407, produites par `/autopilot` le 2026-10-08, relues par six `/pr-review` APPROVE, suivis appliqués en tête de pile) : bandeau « Démarrage n/N · Étape suivante » sur chaque page, cartes d'étape avec mise en évidence du contrôle sur Import, Transactions et Budget, état partagé par un fournisseur et canal d'événements de données (ADR 0022), règle D9 (un profil déjà actif trouve le parcours masqué). Aucune migration DB (v1→v17). Suivis : #408 (`db.ts` hors verrou pendant une transaction) et #409 (harnais de test).
**v0.17.1 shippée** (2026-10-08, tag `v0.17.1` sur `e4db715` → CI release run 508, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.1) — parcours de démarrage guidé et #390 (étape 5 de la liste, rattrapage des migrations des profils secondaires, dossier des bases sous Linux). Numéro de correctif choisi par Max malgré les entrées Added. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Vérifications manuelles faites sur la release publiée (parcours guidé, #390), complétion confirmée le 2026-10-10.
**Chaîne de release et dépendances mergée sur `main`** (2026-10-12, `9e8c41b`, publiée dans la v0.17.2) — milestone `planned-2026-10-10-release-deps` fermée 4/4 (#376, #345, #320, #358, plus #317 ; PRs #414 → #417 produites par `/autopilot` le 2026-10-10 et relues par quatre `/pr-review` APPROVE, puis PR #418 de suivis des revues, relue à son tour) : Node 24 dans les trois workflows encore en Node 20 ; garde `Verify windows-sys pinning` dans `check-rust` (ADR 0023) ; `latest.json` avec une clé par paquet Linux (`linux-x86_64-deb`, `linux-x86_64-rpm`) et sans clé générique, contrôlé avant le `DELETE` du registre par `scripts/check-latest-json.jq` ; lock npm rafraîchi, `npm audit` à 0. Aucune migration DB (v1→v17). `[Unreleased]` porte un Corrigé (#320) et un Sécurité (#358). Suivi : #419 (point 1 avant le prochain tag).
**#423 mergée sur `main`** (2026-10-12, `8898213`, publiée dans la v0.17.2) — les champs « Catégorie parente » (formulaire de catégorie) et « Catégorie » (dialogue « Nouveau mot-clé ») utilisent `CategoryCombobox` : saisie qui filtre, ordre hiérarchique, noms du seed traduits. Liste des parents : racines et niveau 1, moins la catégorie éditée, profondeur réduite de la hauteur de son sous-arbre (pas de 4ᵉ niveau), parent courant toujours gardé ; seule garde aujourd'hui, `updateCategory` ne vérifie rien (#393). Le dialogue mot-clé prend la liste des Transactions (actives et saisissables, D2) et ne garde la catégorie pré-remplie que si elle en fait partie. `[Unreleased]` porte un Modifié. Suivi : #429. Aucune migration (v1→v17).
**Fiabilité des tests et de `db.ts` mergée sur `main`** (2026-10-13, `c17bf5d`, publiée dans la v0.17.2) — milestone `planned-2026-10-10-fiabilite-tests` fermée 4/4 (#408, #409, #389, #395 ; PRs #433 → #436 produites par `/autopilot` le 2026-10-12, relues par quatre `/pr-review` APPROVE, cinq commits de suivi en tête de pile) : `withTransaction` remet au rappel son propre handle, révoqué à la fin, toute autre requête attend derrière le verrou ; changement de profil en une seule section exclusive avec numéro de génération ; alerte Journal après 15 s d'attente ; harnais `src/test/hookHarness.ts` et faux `t` marqué `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts` ; le Journal nomme la copie d'un rattrapage arrêté et ne contient plus de chemin, une seule copie gardée par profil, la page d'erreur du démarrage disparaît quand le profil s'ouvre après le délai ; badge « non garanti » traduit. `[Unreleased]` porte un Modifié et deux Corrigé de plus. Suivi : #437. Aucune migration (v1→v17).
**Retours d'onboarding de la v0.17.1 mergés sur `main`** (2026-10-15, `934bcbf`, publiés dans la v0.17.2) — milestone `planned-2026-10-12-retours-onboarding-v0171` fermée 8/8 (#424, #422, #427, #426, #430, #431, #425, #432 ; PRs #438 → #445 produites par `/autopilot` le 2026-10-13, relues par huit `/pr-review` APPROVE sans bloquant) : grille Budget émise en profondeur et sous-totaux en bas rattachés à leur bloc (`reorderRows`, corrige aussi Budget vs réel et Comparables) ; encadré « Structure attendue » (un sous-dossier par compte), avertissement des CSV posés à la racine (`scan_import_folder` → `ImportFolderScan`), nom de source en lecture seule = sous-dossier ; cartes Transactions (« Ajouter comme mot-clé ») et Budget (feuilles, colonne Annuel) ; aide et guide périmés corrigés ; parcours à huit lignes avec deux étapes de visite (Catégories, Rapports) cochées par « J'ai compris » ou un clic sur Tendances, initialisées une fois par profil sans transaction (ADR 0022 amendé). `[Unreleased]` porte des Added, Changed et Fixed de plus. Suivi : #446. Aucune migration (v1→v17).
**#447 mergée sur `main`** (2026-10-16, `1763a9f`, publiée dans la v0.17.2) — `rustls` 0.23.45 → 0.23.46 (PR #448 : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée) corrige RUSTSEC-2026-0346 à -0349, publiées le 2026-10-14, qui rougissaient `audit.yml` depuis le 2026-10-15 sans que le lock ait bougé depuis la v0.17.1. Aucune des quatre n'est atteignable (`rustls` n'est atteint que par `tauri-plugin-updater` → `reqwest` 0.13), mais la règle 1 de `.cargo/audit.toml` demande de corriger plutôt que de supprimer ; `audit.yml` est de nouveau vert sur `main` (run 556). Pas de release corrective : le bump part avec le prochain tag, qui devra dérouler le cycle réel de mise à jour. `[Unreleased]` porte un second Sécurité. Suivis : #449 et agent-defenseurs#186. Aucune migration (v1→v17).
**#369 mergée sur `main`** (2026-10-17, `a8c3fd6`, publiée dans la v0.17.2) — la Répartition (bouton de la page Transactions) est réservée à l'édition Base, comme l'écran de déverrouillage le promettait (`upsell.features.adjustments`) : en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne ; tant que la licence n'est pas `ready`, bouton inactif, ni cadenas ni fenêtre ; les répartitions existantes restent comptées et gardent l'icône orange. Pièces : `splitAccessFor` (`src/shared/splitGate.ts`, `open`/`locked`/`pending`, plus strict que `profileGate` : `allowed` ne court-circuite pas `ready`) et la constante `SPLIT_FEATURE`, `SplitButton`, `UpsellDialog`, prop `splitAccess` obligatoire de `TransactionTable`, calculée par `TransactionsPage` depuis `useEntitlement(SPLIT_FEATURE)` ; ADR 0017 amendé, guide, docs intégrées FR/EN et `architecture.md` à jour, `[Unreleased]` porte un Modifié de plus. Suivis : #451 (fenêtre d'incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (reclasser une transaction répartie laisse sa ligne de compensation dans l'ancienne catégorie, irréparable en Gratuite depuis #369). Aucune migration (v1→v17).
**`REVIEW.md` mergé sur `main`** (2026-10-18, `24da72b`) — niveau d'une PR (haut, normal, bas) fixé par les fichiers que touche son diff, jamais par son auteur ; tableau Haut de 21 lignes (licence et jetons, migrations et bases de profils, release et code exécuté avec la clé de signature, dépendances qui changent la confiance, CI qui pourrait passer au vert à tort, `.claude/settings.json` et `.mcp.json`, et une règle qui échoue fermée pour tout fichier ajouté, renommé ou supprimé à la racine ou directement dans `src-tauri/`) ; neuf zones, chaque règle sourcée par une section du `CLAUDE.md` ou un ADR ; commandes de vérification ; onze pièges, dont les trois du skill global `pr-review`, corrigés pour ce dépôt ; script de calibration versionné `scripts/review-levels.py` (`--check` compare ses motifs au tableau). `CLAUDE.md` y renvoie. Depuis #457 (fermée le 2026-10-18), `/pr-review` le lit sur `main` et annonce le niveau en tête de chaque verdict ; le pilote #458 mesure les 20 PR suivantes, la première étant #459 ; relevé au 2026-10-18 (commentaire 24819) : 4 sur 20, #459, #450, #464 et #465, toutes de niveau haut, jugé juste par Max sauf pour #450 (trop haut, #462). Suivis : #461 et #462. Aucune migration (v1→v17).
**#455 mergée sur `main`** (2026-10-18, `c5efc38`, publiée dans la v0.17.2) — `tauri-plugin-updater` compilé sans sa feature `zip` (`default-features = false`, `rustls-tls` gardé) : `zip`, `arbitrary` et `derive_arbitrary` sortent du lock (666 → 663 paquets, aucun ajouté), et avec eux RUSTSEC-2026-0367 à -0369, retirés et non supprimés dans `.cargo/audit.toml` ; `tar` quitte le graphe Linux, d'où le canari de la garde des suppressions de `check-rust` passé de `tar` à `webkit2gtk` (ADR 0018 amendé ; `architecture.md`, `docs/qa-update-cycle.md` et skill `/release` à jour). `[Unreleased]` porte un troisième Sécurité. `audit.yml` vert sur `main` (run 564). Suivi : #463. Aucune migration (v1→v17).
**#419 mergée sur `main`** (2026-10-18, `c859405`, publiée dans la v0.17.2) — `scripts/check-latest-json.jq` exige exactement un document (`length == 1 and (.[0] | …)`) et l'étape « Publish » de `release.yml` l'appelle avec `-s` : sous jq 1.6, la version du conteneur de release, un `latest.json` vide passait (rc 0) et un fichier à deux documents était jugé sur le dernier. `scripts/build-latest-json.test.mjs` (28 → 36 tests) exécute le texte exact de l'étape « Publish » avec un `curl` factice : `DELETE` puis `PUT` sur un manifeste valide, sortie 1 sans aucune requête sur un refus. `check-frontend` ne saute plus `.claude/**` ni `release.yml` (`REVIEW.md`, `CLAUDE.md` et `architecture.md` à jour) ; commentaire périmé de `release.yml:104` corrigé (point 3 de #461) ; `docs/qa-update-cycle.md` note qu'un build `tauri dev` sous Linux affiche `TargetsNotFound`, attendu depuis #418. Aucune entrée de CHANGELOG (rien de visible pour l'utilisateur). Aucune migration (v1→v17).
**#421 mergée sur `main`** (2026-10-18, `ff9656d`, publiée dans la v0.17.2, vérifiée sous Linux et fermée le 2026-10-18) — sur desktop, le script d'initialisation de `tauri-plugin-dialog` 2.6.0 remplace `window.confirm` par une fonction asynchrone : `confirm(...)` rendait une promesse, toujours vraie, et six confirmations laissaient l'action partir avant la réponse (profil et sa base supprimés même sur « Annuler » ; suppressions de catégorie, d'ajustement, de modèle de budget et de catégorie du Bilan ; les deux confirmations du retrait de la clé de licence), dans toutes les versions publiées. `confirmAction()` (`src/services/confirmService.ts`) appelle `ask` de `@tauri-apps/plugin-dialog` 2.6.0 (ajouté à `package.json` à la version du crate, une seule clé de lock) avec une icône d'avertissement et les boutons `common.confirm` et `common.cancel` dans la langue de l'app ; un échec vaut refus et passe par `logWarn`. Les six sites l'attendent avec `await`, et `performRemoveLicense` prend une confirmation asynchrone. Garde `src/__integration__/native-dialog-guard.test.ts` (34 tests) : aucun `confirm` ni `alert` natif dans `src/` (nu, par `window`, `globalThis` ou `self`, en `?.(`, par index, déstructuré), toute confirmation attendue, aucune autre mention de `confirmAction` (alias). `architecture.md` à jour (service, ligne du plugin) ; `[Unreleased]` porte un Corrigé de plus. Suivi : #467. Aucune migration (v1→v17).
**v0.17.2 shippée** (2026-10-18, tag `v0.17.2` sur `f42f360` → CI release run 570, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.2 avec les trois clés `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`) — tout ce qui s'était accumulé depuis la v0.17.1 : retours d'onboarding (#424-#432), Répartition en Base (#369), clé `.rpm` de `latest.json` (#320), Node 24 dans les workflows (#376), garde `windows-sys` (#345), fiabilité des tests et de `db.ts` (#408, #409, #389, #395), menus de catégorie (#423), `rustls` 0.23.46 (#447), updater sans `zip` (#455), garde de `latest.json` sous jq 1.6 (#419), confirmations attendues (#421). Numéro de correctif choisi par Max malgré les entrées Added. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` obligatoire (`release.yml` et `rustls` changent) : cycle Linux `.deb` v0.17.1 → v0.17.2 réussi le 2026-10-18 (Max, trace dans le corps de la release), Windows et `.rpm` pas encore déroulés.
## Decisions recentes
- 2026-10-08 : **Parcours de démarrage guidé livré de bout en bout — `/autopilot` (6 PRs stackées en 3 h, CI verte à chaque maillon) → six `/pr-review` APPROVE → sept commits en tête de pile → merge ff par l'API (`main` `ee17f31`).**
- **Revues** : aucun bloquant, un verdict indépendant par PR (commentaires 20659 à 20730) ; `status:approved` posé par les seuls relecteurs, les workers ayant laissé `status:review` (skill corrigé).
- **Décision de Max : voie A** sur le critère de #398 « pas de « Fait. » pour un import qui n'ajoute rien ». Le relecteur a corrigé le rapport du run : les doublons seuls sont sans effet (leurs transactions sont venues avec un fichier `completed`) ; le vrai cas est l'import sans ligne valide (en-tête seul, format mal détecté), qui cochait l'étape 4 et faisait boucler /transactions et /import. L'étape compte désormais les `imported_files` en `completed` ayant au moins une transaction (`EXISTS` sur `transactions.file_id`, index `idx_transactions_file`), avec une entrée Corrigé : la v0.17.0 a déjà le défaut.
- **Autres suivis en tête** : D9 lit son drapeau hors transaction, donc plus de `BEGIN`/`COMMIT` à chaque ouverture, pendant lesquels `db.ts` laisse tout passer hors verrou ; `:focus-visible { outline: revert }` sur le contrôle entouré (WCAG 2.4.7) ; carte Budget sans cible sur l'état vide ; épingles (séquence réelle du reducer, phrase selon le filtre, notification de restauration hors verrou, fuseau du test du mois) ; docs et liste QA à 44 cas.
- **Preuves** : 16 mutations sur ces correctifs, toutes rouges, dont 4 survivantes des revues ; compteur rejoué avec sqlite3 sur le schéma consolidé ; tip en worktree `npm ci` propre (sonde rc=1, build, 1904 vitest) ; CI 507. Les commits ajoutés après la revue, non relus, sont nommés dans le commentaire 20835 de #407.
- **Merge** : chaque enfant reciblé sur `main` puis mergé en ff épinglé, branches gardées jusqu'au dernier merge puis supprimées, donc 6 PRs `merged`. #384 : trois items cochés (réglés par la pile), #382 en partie.
- **Acquis** : au `/plan-run`, confronter un critère à la définition du compteur hérité qu'il suppose (celui de l'étape 4 venait de la phase 1).
- Aucune migration DB (v1→v17). (ref #396-#401, PRs #402-#407, #408, #409, #384)
- 2026-10-06 : **#390 mergée (PR #394, `main` `5910387`) — l'étape 5 de la liste de démarrage se coche, les profils secondaires rattrapent leurs migrations, et les bases sont cherchées dans le bon dossier sous Linux.**
- **Constat** : en testant l'onboarding de la v0.17.0, Max a vu que classer une transaction ne cochait pas l'étape 5.
- **Cause** : depuis la v0.1.0, `updateTransactionCategory` lie un booléen JS. tauri-plugin-sql 2.3.2 le stocke en texte `'true'`, que le compteur `= 1` ne trouve pas.
- **Deux défauts plus anciens, trouvés en corrigeant** :
- tauri-plugin-sql ne migre que `simpl_resultat.db`. Un profil secondaire gardait le schéma de sa création ; il existe sept états publiés depuis la v0.3.0.
- Le plugin range les bases sous `app_config_dir()`. `repair_migrations`, `delete_profile_db` et `compute_account_return` cherchaient sous `app_data_dir()`, dossier différent sous Linux seulement. Supprimer un profil laissait sa base sur le disque, et les rendements du Bilan s'affichaient en tirets.
- **Décisions de Max** : réparer les drapeaux à l'ouverture de chaque profil plutôt que par une v18 ; inclure le rattrapage dans #390 ; appliquer les suggestions rapides de la revue, puis merger.
- **Livré** :
- `toSqlFlag`, à l'écriture et à la restauration d'une sauvegarde ;
- `prepare_profile_db` : plancher déduit du schéma, copie `VACUUM INTO` avant la première écriture, historique `_sqlx_migrations` au format sqlx, clés étrangères actives avec une seule reprise ciblée, réparation des drapeaux texte, échec ouvert ;
- l'historique écrit dès la création d'un profil ;
- `profile_db_path` : dossier `app_config_dir()` et nom de fichier validé ;
- l'ADR 0021, et les amendements des ADR 0003 et 0005.
- **Preuves** :
- les sept états publiés sont rejoués en fixtures ;
- 22 mutations de l'auteur, toutes rouges ;
- le plan checker a refusé trois fois (dossier Linux, rejeu de v2, clés étrangères), puis Max a validé la suite ;
- `/pr-review` APPROVE sur `71147c5` ;
- les quatre suggestions rapides sont appliquées en tête (`5910387`, des tests seulement, non relu), les quatre autres sont reportées en #395 ;
- tête éprouvée dans un worktree propre : sonde rc=1, 1520 vitest, 255 Rust, CI 495 et 496.
- **Suite** : #390 reste ouverte en `status:blocked` jusqu'à la liste post-merge.
- Aucune migration ajoutée (v1→v17). (ref #390, PR #394, #395, ADR 0021)
- 2026-10-05 : **v0.17.0 taggée et publiée (`07255e1`, run 491 vert) ; #234 close ; textes de la-compagnie-maximus#184 postés.** Pré-vol du skill sur le tip `8f964e0` : build, 1498 vitest, `cargo check` sans avertissement, 224 Rust ; `.claude/worktrees/` vide. Avant le bump, diff vide depuis `v0.16.0` sur `Cargo.lock`, `Cargo.toml`, `release.yml`, `tauri.conf.json` et `package*.json`, et aucune ligne du diff ne touche le gating par édition : ni clés testeurs à émettre, ni cycle de mise à jour réel exigé (étape 10), la raison étant écrite dans le corps de la release plutôt que tue. Version mineure (fonctionnalités ajoutées) : 5 manifestes, 2 changelogs et la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`. Tag annoté confirmé par Max, poussé seul (jamais `--tags`). Run 491 en 22 min ; `verify-release.sh` vert (7 artefacts, signatures et URL en 0.17.0) ; l'endpoint de `tauri.conf.json` (registre de paquets) sert aussi la 0.17.0. Le même jour : **#234 fermée** sur sa quatrième preuve (`actcache` à 999 Mo, une seule entrée : celle, isolée, de la PR #375 a été évincée par le ménage par clé), avec la milestone `spec-ci-build-optimization` (4/4) ; la-compagnie-maximus#184 reçoit les deux textes des suites de licence (contrôle à la saisie, retrait de la clé), rattachés aux deux passages de son corps que la v0.17.0 rend faux. Aucune migration DB (v1→v17). (ref v0.17.0, #234, #373, #370, #367, la-compagnie-maximus#184)
- 2026-10-05 : **Suites de licence livrées — `/autopilot` (2026-10-04, 3 workers séquentiels de 36, 39 et 41 min, CI attendue verte entre deux maillons) → trois `/pr-review` APPROVE → suivis en tête de pile → merge ff par l'API (`main` `2cbc886`).** #373 : contrôle de révocation à la saisie, en arrière-plan, sous un verrou `tauri::async_runtime::Mutex` qui sérialise lecture de l'état → tentative → requête → verdict ; écart accepté : le test de concurrence rougit sans verrou sur le verdict lu par le second contrôle, pas sur le nombre de requêtes (une sonde à deux fils de 200 rondes n'a jamais ouvert la fenêtre). #370 : `remove_license` (clé supprimée d'abord, puis le jeton ; `license.check` gardé ; libération préalable par `deactivate_idempotent` + `note_revocation`, jamais `deactivate_in`, qui supprimerait le jeton avant la clé). #367 : suppression du jeton seulement s'il est « inchangé depuis la requête » (CWE-367), sur un 403 `license_revoked` des cours et à la lecture d'une liste complète sans ligne de ce poste ; garde étendue à la liste par le worker ; lignes de Journal à code fixe. Revues : aucun bloquant ; les 5 mutations survivantes des relecteurs (relais de « déjà révoquée », deux `t` oubliés, `Storage` de `store_license_in`, jeton non UTF-8) et deux autres suggestions (rechargement après un retrait en échec, budget de 6 requêtes) appliquées dans `2cbc886`, prouvées par 2, 18, 4, 1, 10, 1 et 1 tests rouges. Tip validé en worktree `npm ci` propre (sonde rouge rc=1, build, 1498 vitest, 224 cargo), CI 489/490. **Acquis** : appliquer les suggestions de revue en tête de pile garde les têtes revues intactes, donc le merge ff épinglé reste possible maillon par maillon ; un faux `t` qui rend la clé brute rend invisible tout `t` oublié (suite : #389). Aucune migration DB (v1→v17). (ref #373, #370, #367, PRs #386-#388, #389)
- 2026-10-04 : **#350 fermée sur l'étape 3, sans vérification en production de l'acceptation d'un jeton brut, sur décision de Max.** Le `/warmup` a relevé que la vérification de #365 (clé Base révoquée enregistrée sur le poste Linux le 10-03) avait dû effacer le jeton de la v0.15.0, puisqu'un changement de clé l'efface (#347), alors que l'entrée du 10-03 demandait de faire #350 avant. Un script local en lecture seule (`verif-350.py`, déposé sur le poste de Max : même clé HMAC et même ordre de lecture du `machine-id` que l'app, contrôlé contre le vecteur `THIS_DERIVED` et sur 7 cas synthétiques, n'affiche ni identifiant ni clé) l'a confirmé : clé révoquée enregistrée, aucun jeton. Clé testeur ressaisie puis « Activer » : jeton émis le 2026-10-04, claim dérivé (`64 brut: False dérivé: True`), poste marqué « Cette machine ». La liste ne montre qu'une machine, aucune ligne brute active, donc les étapes 1-2 ne sont plus vérifiables : l'acceptation d'un jeton brut (ADR 0020, D1) reste gardée par `activation_token_from_before_the_derivation_still_matches` et `activation_status_accepts_a_token_from_before_the_derivation`, et un défaut ne toucherait que les installations activées par la v0.15.0 (testeurs), réparable par « Activer ». Dans la même séance, en Gratuite par la clé révoquée, **#346 vérifiée** : « J'ai déjà une clé » ferme le modal sur la carte Licence. Le corps de #370 passe donc sa PR en `Fixes #370`, sans `status:blocked` après la PR. **Leçon** : une vérification manuelle qui change la clé détruit l'état dont dépend une autre ; ordonner les vérifications d'une release par ce qu'elles consomment. Aucune migration DB (v1→v17), pas d'entrée CHANGELOG. (ref #350, #346, #365, #370)
- 2026-10-04 : **Onboarding phase 1 livrée de bout en bout — `/plan-run` (2026-10-03) → `/autopilot` (7 PRs stackées en 1 h 15) → 7 `/pr-review` → `/merge-stack` ff (`main` `667e058`).** Trois faits d'ancrage avaient réécrit la spec : le seed IPC met 180 mots-clés dans tout profil neuf et le wizard catégorise à l'import (`useImportWizard.ts:1016`), donc la case « première règle » devient « une transaction classée à la main » (`is_manually_categorized = 1`) et les gabarits portent 7 + 4 lignes hors seed ; aucune commande Rust n'écrivait de fichier, d'où `create_sample_import_folders(import_folder, today)` avec `today` venu du webview (`chrono` compilé sans `clock`) ; une seule banque (Tangerine) a une page officielle d'export, chaque bloc du guide porte son statut de vérification. `/review-spec` avait trouvé 2 critiques (le corpus `CsvFixtureName` gelé à 15 par 6 boucles ; `DocsContent` non testable par appel direct) et corrigé une erreur du plan (33/40, pas 35/40, mesuré avec la vraie regex). Run autopilot : pile linéaire honorée malgré un graphe à 3 maillons parallélisables, pour un merge ff sans conflit `fr.json`/`en.json` ; un seul report inter-maillons (réalignement JSON du « Démarrage rapide », #355 → #357). Revues : 3 correctifs en tête de pile — statut BMO sans provenance interne (le corps d'issue avait fui dans l'UI), garde `budgetReady` sur la liste compacte (fenêtre 5/5 → 5/6 au boot), test `formatLocalDate` aveugle sous `TZ=UTC` (mutation `toISOString` vérifiée rouge). Merge : tip éprouvé en worktree `npm ci` propre (sonde rouge rc=1, tsc, 1440 vitest, build, 192 cargo), API ff du bas vers le haut avec recible de chaque enfant sur `main` → 7 PRs `merged`, pas `closed`. **Constat de process** : les workers autopilot posent `status:approved` eux-mêmes après auto-revue, le label ne prouve plus une revue indépendante (mémoire, #384). Max est client BMO, banque sans signature : la validation sur compte réel vit dans #371, la signature d'import BMO est reportée. Aucune migration DB (v1→v17). (ref #351-#357, PRs #377-#383, #384, #371)
- 2026-10-03 : **#234 mergée (PR #375, `main` `f821492`) — le cache Rust de la CI fonctionne, `check-rust` tombe de 9 min 37 s à 3 min 22 s, et la panne tenait au VPS, pas à l'adresse `cache.host` que visait l'issue.** Diagnostic par la PR #374, fermée sans merge : ufw n'ouvrait l'hôte aux conteneurs que depuis `172.16.0.0/12`, alors que Docker (`daemon.json` de Coolify) alloue ses réseaux, ceux des jobs compris, dans `10.0.0.0/8` ; et le runner, sans fichier de config, tirait le port de son proxy de cache au hasard à chaque démarrage. Correctif appliqué par Max sur le VPS : `runner-config.yml` avec `cache.proxy_port: 28100`, passé en `--config` par un fichier complémentaire systemd, règle ufw de `10.0.0.0/8` vers ce port, ports du cache vérifiés fermés depuis Internet ; consigné avec son retour arrière dans `forgejo-ops.md` (la-compagnie-maximus `66787ea`). **Le design vient d'un constat** : le runner v12.7.0 isole ce qu'écrit un run `pull_request` sous la référence de la PR, et les autres PR ne retombent que sur les entrées non isolées. Décisions de Max : **D1** `Swatinem/rust-cache@v2` sur les PR, avec un run hebdomadaire sur `main` (lundi 06:30 UTC) et `workflow_dispatch` pour seuls écrivains du cache partagé ; **D2** pas de cache npm (`npm ci` prend 9 s). Mesuré : run 459 à froid, sauvegarde d'environ 999 Mo ; run 461 en `full match: true`, `cargo check` en 9,9 s. **`/pr-review` APPROVE**, trois suggestions appliquées dans `f821492` sans nouvelle relecture : le ménage du runner garde une entrée par clé, toutes portées confondues (une PR qui sauvegarde sous la clé partagée l'évince jusqu'au lundi) ; la clé hache aussi les variables `CARGO*`/`RUST*` du job et ignore la version de l'app ; un 404 un lundi après une semaine sans requête est une course connue du runner (forgejo/runner#1741), pas un retour de #234. **Après le merge**, `check-rust` lancé sur `main` par `workflow_dispatch` (run 464) : même clé que l'entrée de la PR, `No cache found.` (isolation prouvée), puis `Cache saved successfully` en portée partagée. **#234 reste ouverte en `status:blocked`** jusqu'à la restauration de cette entrée par la prochaine PR Rust, un premier run planifié vert le lundi 2026-10-05 et la taille de `actcache` relevée par Max ; fermeture à la main ensuite. Suite ouverte : #376 (Node 20 en fin de vie dans `check-frontend` et `audit`). Hors dépôt, `/warmup` doit filtrer les échecs `check-rust` sur l'événement `schedule`, et `forgejo-ops.md` décrire l'isolation des PR et l'éviction par clé. Aucune migration DB (v1→v17), pas d'entrée CHANGELOG (CI seule), pas de release. (ref #234, PR #375, #374, #376)
- 2026-10-03 : **v0.16.0 taggée et publiée (`56fc612`, run 451 vert) — sur le constat de Max qu'il n'y a pas d'autres utilisateurs que les testeurs, les vérifications manuelles passent après la publication.** Aucune PR ouverte à merger. Pré-vol du skill sur le tip `6625d25` : build, vitest 1348, `cargo check` sans avertissement, 187 Rust ; aucun worktree résiduel ; les deux testeurs ont une clé Premium valide depuis la v0.15.0 et rien ne touche `FEATURE_TIERS`. Version mineure (fonctionnalités ajoutées) : 5 manifestes, 2 changelogs et la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`. Tag annoté confirmé par Max avant le push, poussé seul (jamais `--tags`). Run 451 en 22 min ; `verify-release.sh` vert ; l'endpoint de `tauri.conf.json` (registre de paquets) sert la 0.16.0, au-delà de la copie attachée à la release que lit le script. **Correction faite en route** : j'avais écrit que les installations Base+ recevraient la mise à jour d'elles-mêmes ; `docs/qa-update-cycle.md` et le code disent l'inverse, aucune vérification automatique, quatre déclencheurs manuels. Personne ne reçoit donc la release sans cliquer « Vérifier les mises à jour ». **Étape 10 due** (`rustls` 0.23.45 est sur le chemin TLS de l'updater) : Linux par Max, enchaînée avec #350 sur le poste activé en v0.15.0 (à faire avant tout changement de clé, qui efface le jeton) ; Windows par Michel. Trace à consigner dans le corps de la release. En parallèle : la-compagnie-maximus#184 (politique de confidentialité) par Max ; #370 ouverte (retrait de la clé, `status:triage`). **Tests de Max le jour même** : mise à jour Linux v0.15.0 → v0.16.0 réussie, trace consignée dans le corps de la release (invite d'authentification apparue, type non noté ; Windows à dérouler par Michel) ; #365 vérifiée avec la clé réellement révoquée et fermée. Anomalie relevée : saisie en cours de session, cette clé gardait l'édition Base jusqu'au clic sur « Activer », comportement livré (contrôle au démarrage seulement) → **#373** propose le contrôle dès la saisie. La vérification de #346, qui demande une édition Gratuite propre, est reportée à #370 (instructions ajoutées à son corps) ; **#372** ouverte (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes). Aucune migration DB (v1→v17). (ref v0.16.0, #270, #346, #347, #348, #350, #365, #370, #372, #373)
- 2026-10-03 : **#365 mergée (PR #368, `main` `b107cf4`) — une clé révoquée retombe en Gratuite, et l'app envoie pour cela sa première requête non sollicitée.** Constat du go-live (09-30) : une licence remboursée, révoquée côté serveur, restait pleinement fonctionnelle, parce que l'édition se résout hors ligne depuis la clé et que le drapeau de #347 (D13) ne vivait que la session. `/analyze` (10-01) : **D1** contrôle au démarrage, au plus une tentative par 24 h ; **D2** révocation nommée là où le verrou apparaît, sans bandeau global ; **D3** fail-open ; **D4** un seul état. **Livré** : `license.check` à côté de la clé (`{key_sha256, attempted_at, revoked}`, condensé SHA-256, jamais la clé) ; `entitlements_from_dir` rend `("free", [])` pour une clé révoquée, `auto-update` compris ; `check_license_revocation` (`POST /v1/licenses/verify`, produit et clé seulement, rien sans clé valide, tentative écrite avant l'appel, seul `valid:true` lève et seul « révoquée » pose) ; l'activation, la liste et la désactivation enregistrent un `license_revoked` sous la clé envoyée ; `LicenseContext` lit `info.revoked` (fin de `markRevoked`), lance le contrôle une fois par démarrage, écrit son issue dans le Journal et ne recharge que si l'état change ; une clé révoquée ressaisie reste en Gratuite ; carte Licence, `UpsellGate` et carte Mises à jour nomment la révocation ; amendement #365 de l'ADR 0017. **Écart tranché par Max le 10-02**, après trois échecs du plan checker : la route des cours **nomme** le 403 `license_revoked` sans l'enregistrer, parce que `/v1/prices` juge la licence du jeton d'activation, que le client ne relie pas à la clé, et que la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle (`git show v0.15.0`) : l'enregistrer aurait rétrogradé une clé valide. Ce jeton périmé est suivi en **#367**. **Preuves** : 33 mutations Rust et 27 front de l'auteur, toutes rouges ; la première passe en avait laissé 5 vertes pour 4 manques, corrigés dans `4225dad`, dont la tentative écrite après la requête (un test lit maintenant le fichier d'état depuis le serveur mockito, pendant que la requête est en vol) et l'intervalle de 24 h que suivaient les lignes construites depuis la constante (mesuré sur `e24c30f` : 184 verts avec l'intervalle à 1 h). Contrat sondé en production : `verify` d'une clé factice répond `200 {"valid":false,"reason":"not_found"}`. **`/pr-review` APPROVE** (37 mutations du relecteur, 35 rouges). Appliqué dans `b107cf4` : le test « une tentative non inscriptible n'envoie rien » (mutation de la revue : 0 rouge sur 185, puis 1 sur 187) et celui du verdict non inscriptible ; `storage` reformulé, puisqu'il couvre aussi un verdict non écrit. Décisions de Max le 10-03 : une clé révoquée **reste contrôlée** une fois par 24 h tant qu'elle est enregistrée, et c'est déclaré dans le guide (aucune interface ne retire une clé : seul l'enregistrement d'une autre l'arrête) ; la carte Mises à jour nomme la révocation, la page d'erreur non. Rust 168 → **187**, vitest 1314 → **1348**, CI lue dans les logs (runs 446/447 puis 449/450). Hors PR : la-compagnie-maximus#184 corrigée, son commentaire du 10-02 disant à tort que l'édition Gratuite ne fait jamais cette requête. **Non gardé par les tests** : l'effet du fournisseur React qui lance le contrôle, l'enregistrement de la commande dans `lib.rs` et la réponse réelle du serveur pour une clé révoquée, d'où la vérification manuelle. **Acquis** : la fermeture `match_request` de mockito tourne pendant que la requête est en vol, ce qui prouve un ordre « écrit avant la requête » ; une suite Rust rouge imprime `error: test failed`, donc seul `could not compile` signale une erreur de compilation. Aucune migration DB (v1→v17). (ref #365, PR #368, #367, la-compagnie-maximus#184)
- 2026-10-01 : **#350 mergée (PR #366, `main` `0a9c9f7`) — l'activation envoie un identifiant dérivé, et l'issue reste ouverte pour une vérification manuelle due avant le tag.** `/warmup` (audit quotidien vert, 10 runs d'affilée), puis `/analyze 350` : le corps du 09-24 n'avait jamais été édité (sa timeline ne porte que des références), et #270, #347 et le go-live l'avaient périmé sur quatre points — « avant go-live », « retomberaient en Gratuite » (faux depuis D12), `current_entitlements` qui ne compare plus rien au jeton, `get_machine_id` sans appelant. Trois sites manquaient : la suppression du jeton dans `deactivate_machine_impl`, le marqueur « Cette machine » de `LicenseCard`, et le Bearer des cours, qui envoie un jeton antérieur avec son claim brut. Décisions de Max : **D1** une machine déjà activée garde son activation (jeton brut accepté) ; **D2** le critère « aucun identifiant brut ne quitte le poste » est borné aux activations faites avec la version corrigée ; **D3** le client ne suppose rien du serveur. Deux suites ouvertes dans leurs dépôts : maximus-api#82 (identifiants bruts déjà stockés, lignes désactivées comprises) et la-compagnie-maximus#184 (la politique de confidentialité ne déclare aucune donnée d'activation). **Livré** : `derive_machine_id` (`HMAC-SHA256` sous la clé figée `com.simpl.resultat/license-machine-id/v1`, 64 caractères hexadécimaux, identifiant vide refusé) ; `MachineIds` (dérivé et brut) ; activation qui dérive juste avant l'appel et exige le dérivé dans le jeton reçu ; `is_this_machine` calculé côté Rust pour les deux formes ; règle de suppression du jeton à la désactivation ; `get_machine_id` retirée et `ActivationStatus` sans identifiant ; ADR 0020. Aucune dépendance ajoutée, `Cargo.lock` intact. **Écarts au corps de l'issue**, déclarés dans la PR : la règle de suppression est plus large que la phrase de l'issue (toute ligne de ce poste emporte le jeton, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée), parce que la règle littérale laissait un jeton expiré sur le disque ; la liste, la désactivation et l'état d'activation n'échouent plus sur un identifiant du système illisible. Plan checker `pass` au premier tour, ses remarques mineures intégrées : tests épinglés sur les vecteurs en littéraux, « aucun appel » observé contre un mock et non déduit. **`/pr-review` APPROVE**, cinq suggestions appliquées dans `0a9c9f7` : la condition de retrait de l'ADR, reprise du corps de l'issue, comptait deux ans depuis la v0.15.0, or une v0.15.0 gratuite ne se met plus à jour et peut encore activer en brut des mois plus tard, donc la borne est la dernière émission d'un jeton brut, connue du serveur seul ; « plus aucune commande ne remet l'identifiant du système au webview » était plus large que le code (la liste porte encore la ligne brute d'une activation antérieure) ; deux lignes de test ; la seconde ligne que crée une réactivation tant que le serveur n'a pas rehaché ses lignes ; la vérification manuelle par la valeur et non par la longueur. **Preuves** : 17 mutations de l'auteur rouges ; la mutation de la revue restée verte remesurée à 0 rouge sur `e5662ad`, puis 1 rouge sur `0a9c9f7` ; vecteurs recalculés hors du code ; Rust 151 → **168**, vitest 1311 → **1314** ; CI lue dans les logs sur les deux têtes (runs 441 à 444). **Non gardé par les tests** : le câblage des commandes à l'identifiant réel du poste, d'où la vérification manuelle. **Acquis** : mockito sert le premier mock correspondant qui attend encore des hits, donc un mock `expect(0)` posé à côté d'un mock valide ne prouve rien ; un seul mock avec `match_request`. Merge par l'API en `fast-forward-only` épinglé sur la tête, branche supprimée. Aucune migration DB (v1→v17). (ref #350, PR #366, maximus-api#82, la-compagnie-maximus#184)
- 2026-10-18 : **v0.17.2 taggée et publiée (`f42f360`, run 570 vert), sur demande de Max (« /release 0.17.2 »).** Pré-vol sur le tip `e0c0f9e` : build, 2297 vitest, `cargo check`, 267 tests Rust, `.claude/worktrees/` vide. Bump des 5 manifestes, 2 changelogs et de la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`, garde `windows-sys` rejoué sur le lock édité (9 contrôles, exit 0). `[Unreleased]` portait des Added (la convention donnerait 0.18.0) : question posée, Max a confirmé 0.17.2. Gating changé par #369 (Répartition en Base) : clés Premium des deux testeurs déjà émises, rien à émettre ; le tableau comparatif du site était déjà en avance, commentaire 25050 sur la-compagnie-maximus#180. Tag annoté poussé seul (jamais `--tags`). Étape 9 : `verify-release.sh` en `VERDICT : OK` (7 artefacts, trois clés, extensions, `file:` des signatures, version) ; journal du run : Node `v24.22.0` (#376 prouvé), « Generate latest.json » un paquet par type, garde jq passée, `Delete HTTP 204` puis `Upload HTTP 201` ; endpoint de `tauri.conf.json` en 0.17.2 à trois clés. Restent à Max : étape 10 (cycle réel v0.17.1 → v0.17.2, Linux et Windows, clé Base+), vérifications dans l'app (#421, #369, retours v0.17.1), cycle `.rpm` si une machine est disponible. Aucune migration DB (v1→v17). (ref v0.17.2, #419, #421, #369, #376, #320, la-compagnie-maximus#180)
- 2026-10-18 : **#421 mergée — `/fix-issue` (contrôle du plan passé du premier coup) → `/pr-review` APPROVE de niveau haut → un commit de suivi en tête → merge ff par l'API (PR #465, `main` `ff9656d`) : les confirmations attendent la réponse de l'utilisateur ; issue gardée ouverte (`Refs`, `status:blocked`) jusqu'à la vérification manuelle de Max.** Cause : le script d'initialisation desktop de `tauri-plugin-dialog` 2.6.0 remplace `window.confirm` par une fonction asynchrone, donc chaque garde `if (!confirm(...)) return;` testait une promesse, toujours vraie ; défaut présent dans toutes les versions publiées, le plus grave étant la suppression d'un profil secondaire et de sa base sur « Annuler ». Correctif : `confirmAction()` sur `ask` du plugin, un échec valant refus ; `@tauri-apps/plugin-dialog` ajouté à la version du crate, d'où le niveau haut (dépendance ajoutée). `tsc` ne relève pas `!promise` (mutation M1 verte sous `tsc`), d'où la garde `native-dialog-guard.test.ts`. Preuves : reproduction par six tests rouges de `useLicenseMachines.test.ts` avec une confirmation asynchrone ; `ProfileFormModal.delete.test.ts` sur le harnais `hookHarness` ; mutations de l'auteur rouges à 8 sur 8 ; revue (commentaire 24953) avec 8 mutations du relecteur sur 9 rouges, la survivante étant un alias (`const c = confirmAction`). Suggestion 1 appliquée après la revue (`ff9656d`, tests seuls, non relu, nommé dans le commentaire 25025) : l'alias et trois formes natives (`window["confirm"]`, `confirm?.(`, `const { confirm } = window`) sont désormais rouges. Rebase sur `5222329` (PR #450, mergée entre-temps, `SKILL.md` seul) ; `check-frontend` vert sur `ff9656d`, 2297 tests (2256 avant #421). Suggestion 2 → #467 (`status:triage`). Quatrième PR mesurée du pilote #458. Les deux prérequis du tag de la v0.17.2, #419 et #421, sont mergés. Aucune migration (v1→v17). (ref #421, PR #465, #467, #458)
- 2026-10-18 : **#419 mergée — `/fix-issue` (contrôle du plan refusé deux fois, chaque fois sur un point réel) → `/pr-review` APPROVE de niveau haut → merge ff par l'API (PR #464, `main` `c859405`) : un `latest.json` vide ou à plusieurs documents ne passe plus la garde d'avant le `DELETE` du registre, et l'étape « Publish » est testée telle quelle.** Le filtre `scripts/check-latest-json.jq` exige exactement un document, appelé avec `-s` par `release.yml` ; défaut reproduit avant le correctif avec le binaire statique officiel de jq 1.6 (celui d'`ubuntu:22.04`) : fichier vide accepté en rc 0, refusé en rc 4 par jq 1.7. `build-latest-json.test.mjs` (28 → 36 tests) extrait le `run:` de l'étape « Publish », identique à l'octet à une lecture PyYAML (46 lignes), et le lance sous `bash -e -o pipefail` avec un `curl` factice en tête du `PATH` ; une garde vérifie que ce texte ne contient aucune expression de workflow, que le runner substituerait même dans un commentaire sans que le test le voie. Refus du plan check : `REVIEW.md` non mis à jour alors que le déclencheur de `check-frontend` changeait, puis une expression de workflow littérale dans un commentaire du `run:`, que le runner aurait évaluée ; troisième passage : pass. **Décision de Max** : `check-frontend` ne saute plus `.claude/**` ni `release.yml`, puisque le test lit `verify-release.sh` et exécute l'étape « Publish » (environ 2,5 min de runner de plus sur ces PR) ; `REVIEW.md`, `CLAUDE.md` et `architecture.md` suivent. Aussi : commentaire périmé de `release.yml:104` (point 3 de #461, commentaire 24939, points 1 et 2 toujours ouverts), ligne `TargetsNotFound` de `tauri dev` sous Linux dans `docs/qa-update-cycle.md`, skill `/release` à jour. Hors dépôt : `la-compagnie-maximus/docs/forgejo-ops.md`, commit `9a8e3f5` (`/actions/tasks` ne lit `limit` qu'avec `page`, remesuré le 2026-10-18 sur 654 tâches ; cas du run 513, que `cancel-in-progress` n'a pas annulé). Preuves : cinq mutations de l'auteur, toutes rouges sous jq 1.6 et 1.7 ; revue de niveau haut (commentaire 24912), six mutations du relecteur toutes rouges, passe sécurité sans constat ; `check-frontend` run 565 vert sous jq 1.6 (134 fichiers, 2256 tests) ; #419 fermée par le merge (commentaire 24941). Troisième PR mesurée du pilote #458. **Reste à prouver** (suggestion 1 de la revue) : aucune PR ne touchant que `.claude/**` ou `release.yml` n'a encore montré que `check-frontend` se déclenche, la PR #464 touchant aussi `scripts/` ; la première de ce type en fera la preuve. Reste avant le tag de la v0.17.2 : #421. Aucune migration (v1→v17). (ref #419, PR #464, #461, #458)
- 2026-10-18 : **#455 mergée — `/fix-issue` → `/pr-review` de niveau haut REQUEST_CHANGES, corrigé dans le texte → merge ff par l'API sans second tour (PR #459, `main` `c5efc38`) : `tauri-plugin-updater` compilé sans sa feature `zip`, `audit.yml` de nouveau vert. #457 fermée : les skills de revue lisent `REVIEW.md`.** `zip` 4.6.1, compilé pour Windows seulement par la feature par défaut `zip` de l'updater, n'est corrigé qu'en 9.0.0, hors de portée de `tauri-plugin-updater` 2.14.0 (`zip ^4`) : l'updater déclaré en `default-features = false` avec `rustls-tls` sort `zip`, `arbitrary` et `derive_arbitrary` du lock (666 → 663) et avec eux RUSTSEC-2026-0367 à -0369, sans entrée dans `.cargo/audit.toml` (règle 1 de l'ADR 0018). `tar` quittant le graphe Linux, le canari de la garde des suppressions passe à `webkit2gtk`, conditionnel à Linux comme l'était `tar` (le contrôle du plan a écarté `tauri`, inconditionnel, que proposait l'analyse) ; preuve Windows par `cargo xwin check`, témoin sur `main` (`zip` compilé) puis branche (sans `zip`). Première PR mesurée du pilote #458, de niveau haut (fixé par `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, l'étape de verdict de `check-rust.yml` et le skill `/release`) : la revue (commentaire 24757) rejoue le correctif (quatre mutations du canari, `cargo xwin check`, passe sécurité sans constat de §1 à §4) et bloque sur une fausse preuve du corps de la PR : le cycle de mise à jour de la prochaine release part de la v0.17.1, dont l'updater a encore `zip`, il n'exécute donc pas le chemin d'installation sans `zip`. Corrigé sans commit dans le corps de la PR et dans celui de #455 (commentaire 24806) ; **décision de Max** : merger sans second tour. Preuves : rebase sur `main`, les neuf fichiers de la PR identiques à l'octet à la tête relue `85b7ef9` ; `check-rust` run 563 vert sur `c5efc38` (canari `webkit2gtk`, gardes 2/2 et 9/9, 267 tests, audit à 663 crates, lock non réécrit) ; après le merge, `workflow_dispatch` d'`audit.yml` : run 564 vert (cargo-audit 0.22.2, 1338 avis, 663 crates, aucune vulnérabilité, aucun des trois IDs `zip` ; 18 avertissements autorisés, les 12 d'avant et six `unmaintained` parus le 2026-10-18, RUSTSEC-2026-0370 à -0372) ; PR en `Refs #455`, d'où une fermeture à la main (commentaire 24830). #457 (skills globaux, hors git, aucune PR) : `pr-review` lit `REVIEW.md` sur la branche par défaut (Context), calcule le niveau au Step 0bis et l'annonce en première ligne du verdict ; profondeur par niveau (bas = lecture du diff ; haut = mutation obligatoire, passe sécurité bloquante faite dans le fork, car un fork ne lance pas d'agent et `security-scan` n'est défini qu'au niveau du monorepo, APPROVE avec « Max doit lire avant le merge. ») ; savoir propre à simpl-resultat retiré du skill ; `sprint` 4b renvoie à `/pr-review` au lieu d'une copie de checklist (la copie avait perdu la section Tests) ; `DAILY-REPORT` d'`autopilot` avec une section « Max doit lire » et `prs_high_level` ; `fix-issue` 6.5 prend ses commandes dans `REVIEW.md` ; une entrée de CHANGELOG par skill. Preuve : la revue de la PR #459 ouvre sur la ligne de niveau (commentaire 24757) ; fermeture au commentaire 24764. Pilote #458 en `status:in-progress`, relevé ouvert (commentaire 24819), PR 1 = #459. **Acquis** : le cycle réel de mise à jour d'une release exécute l'updater de la version précédente ; seul le `check()` final passe par le nouveau. Suivi #463 (l'étape 10 de `/release` et `docs/qa-update-cycle.md` rattachent le cycle à la release qui livre le changement, alors qu'un changement de l'updater ne se prouve qu'au cycle N → N+1). Aucune migration (v1→v17). (ref #455, PR #459, #457, #458, #463)
- 2026-10-18 : **#456 mergée — `/fix-issue` (contrôle du plan refusé trois fois, chaque fois sur des points réels, derniers points appliqués, poursuite décidée par Max) → deux `/pr-review` APPROVE, le second après application des suggestions du premier → merge ff par l'API (PR #460, `main` `24da72b`) : `REVIEW.md` à la racine.** Calibration sur les PR mergées de #344 à #452 : 3/28/8 sur les 39 PR de référence (20,5 % de niveau haut pour une cible de 20 % ; écart justifié, les huit touchent la licence, les bases de profils et les migrations, ou `release.yml`, qui détient la clé de signature), contre 3/19/17 avec les motifs d'essai du document de départ, et 6/33/12 sur les 51 de la plage. Les « 39 dernières PR » de ce document venaient d'un tri par date de mise à jour (`limit=40&sort=recentupdate`) : liste figée dans la PR. **Décisions de Max** : une montée de version d'un paquet déjà présent dans un lock reste de niveau normal, correctifs d'advisory compris (2026-10-17) ; `db.ts` et `dataExportService.ts` restent normaux même quand le diff touche le verrou ou la restauration (2026-10-18). Preuves : script relancé à neuf contre l'API à chaque changement de règle, niveaux identiques PR par PR ; `--check` rouge sur une copie du tableau privée d'un motif ; CI `check-frontend` 562 verte (134 fichiers, 2248 tests). **Acquis** : l'API Forgejo marque un fichier modifié `changed`, pas `modified` (un test sur `modified` mettait toutes les PR de lock en haut) ; un contrôle de contenu de workflow doit ignorer les commentaires (#375 sortait haute sur le mot « toolchain ») ; les pages de l'API plafonnent à 50 éléments. Suivis : #461 (CSV de `src/__fixtures__/samples/` compilés par `include_str!` hors des `paths` de `check-rust`, `cargo-xwin` et toolchain non épinglés dans `release.yml`, commentaire périmé de `release.yml:104`), #462 (suggestions de la seconde revue, entrées du pilote #458), commentaire sur #457 (lecture sur `main`). Aucune migration (v1→v17). (ref #456, PR #460, #461, #462, #457, #458)
- 2026-10-17 : **#369 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → deux commits de suivi en tête → merge ff par l'API (PR #452, `main` `a8c3fd6`) : la Répartition réservée à l'édition Base.** L'écran de déverrouillage la rangeait déjà en Base (`upsell.features.adjustments`) alors que l'app la laissait ouverte en Gratuite ; désormais, en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé (`UpsellDialog`) pour toute ligne, jamais la fenêtre de répartition ; bouton inactif tant que la licence n'est pas `ready` ; répartitions existantes comptées, icône orange gardée. `splitAccessFor` est plus strict que `profileGate` (`allowed` ne court-circuite pas `ready`) ; `TransactionTable` ne rend pas la fenêtre de répartition en `locked` mais la garde pendant un rafraîchissement de licence, et ne rend pas l'incitation en `open`. Refus du plan check (MAJOR) : le câblage de `TransactionTable` n'était pas testé, d'où `TransactionTable.wiring.test.ts` sur le harnais `hookHarness` de #409 ; second passage : pass. Preuves : 21 mutations de l'auteur, toutes rouges ; revue (commentaire 24129) avec 14 des 15 mutations du relecteur rouges, la survivante R14 (un clic verrouillé qui ouvrirait aussi la fenêtre de répartition) épinglée par `d5d0e89` ; `a8c3fd6` réunit la clé de la Répartition dans `SPLIT_FEATURE` ; ces deux commits en tête, non relus, sont nommés dans le commentaire 24148 ; tip en worktree `npm ci` propre (sonde rc=1, build, 2248 vitest sur 134 fichiers, contre 2222) ; CI 557 et 559 ; #369 fermée par le merge. Site : le tableau comparatif de la-compagnie-maximus#180 (PR #298 de ce dépôt-là), en production depuis le 2026-10-16, range déjà la Répartition en Base, en avance sur l'app, décalage accepté par Max ; commentaire 24164 posé le 2026-10-17 (mergé, publié avec la prochaine release, pas encore disponible). Suivis : #451 (incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (`type:bug`, compensation d'une transaction répartie laissée dans l'ancienne catégorie au reclassement, car `updateTransactionCategory` ne touche que le parent ; rare, mais irréparable en Gratuite depuis #369). Aucune migration (v1→v17). (ref #369, PR #452, #451, #454, la-compagnie-maximus#180)
- 2026-10-16 : **#447 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → merge ff par l'API (PR #448, `main` `1763a9f`) : `rustls` 0.23.45 → 0.23.46, corrigeant RUSTSEC-2026-0346 à -0349.** Publiés le 2026-10-14, les quatre avis rougissaient `audit.yml` (runs 553 et 555) sans que le lock ait bougé depuis la v0.17.1 ; repérés par la Phase 5.6 du `/warmup` le 2026-10-15, pas par le Défenseur, dont le check `deps` ne lance jamais `cargo audit` (**décision de Max** le 2026-10-15 : sous-check `cargo-audit`, agent-defenseurs#186). Aucun n'est atteignable (`rustls` atteint par `tauri-plugin-updater` → `reqwest` 0.13 seulement ; fournisseur `ring` sans groupe post-quantique, `brotli` non compilée, ni ECH ni `ClientSessionStore` maison), mais la règle 1 de `.cargo/audit.toml` dit corriger, pas supprimer. Diff : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée, une puce Sécurité par CHANGELOG. Refus du plan check (MAJOR) : « No behaviour change » avait déjà été recadré par `9abf474` au bump précédent, et la 0.23.46 apporte une dizaine d'autres corrections de la couche TLS (21 fichiers de `src/` en amont), donc l'entrée restreint « aucun changement » au code de l'application ; second passage : pass. Preuves : `cargo-audit` 0.22.2 (binaire de la CI) rouge sur l'arbre intact (4 IDs) puis vert ; `check-rust` run 554 lu ligne à ligne (gardes 2/2 et 9/9, 267 tests, 0 vulnérabilité ; à froid, rustc 1.99.1 du 2026-10-13 ayant changé la clé du cache) ; revue (commentaire 23853) avec mutation inverse rouge sur les quatre IDs ; tip en worktree propre (`npm ci`, sonde rc=1, build, 2222 vitest, `cargo test --locked` à 267, gardes, audit exit 0) ; après le merge, `audit.yml` en `workflow_dispatch` sur `main` : run 556 vert (666 crates, 12 warnings autorisés, 0 vulnérabilité). PR en `Refs #447` (critère post-merge), d'où une fermeture à la main (commentaire 23894). Pas de release corrective (décision de Max) : le bump part avec le prochain tag, cycle réel de mise à jour compris. **Acquis** : `cargo-audit` écrit « 1 vulnerability found! » mais « N vulnerabilities found! », chercher `vulnerabilit(y|ies) found` ou `error: [0-9]+ vulnerabilit` ; le journal de `check-rust` porte des codes ANSI (`CARGO_TERM_COLOR=always`) à retirer avant tout `grep`. Suivi #449 (`--locked` absent de `check-rust.yml` et de `release.yml`). Aucune migration (v1→v17). (ref #447, PR #448, #449, agent-defenseurs#186)
- 2026-10-15 : **Milestone `planned-2026-10-12-retours-onboarding-v0171` mergée — `/autopilot` (8 workers en série, 1 h 14, worker N+1 lancé pendant la CI du maillon N, CI lue à chaque maillon, runs 543-551) → huit `/pr-review` APPROVE en parallèle (aucun bloquant) → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `934bcbf`, 8 PRs `merged`).** Tip éprouvé en worktree `npm ci` propre : sonde rc=1, build, 2222 vitest (2065 avant), 267 Rust. 31 décisions HIGH, 25 MEDIUM, 0 LOW ; mutations des workers et des relecteurs presque toutes rouges, les survivantes sans effet de comportement ou épinglées. **Acquis** : deux workers sur huit ont perdu leur implémentation non commitée au `git checkout --` de la passe de mutations → consigne « commit avant la première mutation » ajoutée en cours de run, zéro incident ensuite ; les corps de PR des workers ne portent pas `Resolves #N` en clair (seulement les commits), sans effet sur un merge ff. Trou global relevé par deux revues : aucun test ne détecte une clé i18n présente dans une seule langue (#446). Aucune migration (v1→v17). (ref #424, #422, #427, #426, #430, #431, #425, #432, PRs #438-#445, #446)
- 2026-10-13 : **Milestone `planned-2026-10-10-fiabilite-tests` mergée — `/autopilot` (4 workers en série, 1 h 25, CI lue à chaque maillon) → quatre `/pr-review` APPROVE en parallèle → suivis en tête de pile → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `c17bf5d`, 4 PRs `merged`).** Mesure du rattrapage : 306 ms en release pour 100 001 transactions (T490, NVMe), sous la règle des 3 s, donc `STARTUP_TIMEOUT_MS` reste à 10 000 (ADR 0021). Inventaire de #389 : 27 fichiers et non 24, trois mocks bruts venus de #423. Suivis appliqués en tête (`74fae14`, `a1c31a1`, `ac3a981`, `86eff9b`, `c17bf5d`), non relus : `close()` d'un ancien proxy rejeté, échec de fermeture consigné sans bloquer le changement de profil, `withoutFolders` qui ne laisse plus passer un dossier contenant une espace, garde i18n plus stricte, badge traduit. Tip éprouvé en worktree `npm ci` propre : sonde rc=1, tsc, build, 2065 vitest, 262 Rust. ADR 0024 (serveur MCP, Proposed) rebasé sur le merge puis poussé (`977ef3a`). **Acquis** : l'orchestrateur a d'abord contredit à tort un constat juste du worker (badge `best-effort` codé en dur), sur un `grep` tronqué par `head` ; ne jamais conclure à l'absence depuis une sortie tronquée. Aucune migration (v1→v17). (ref #408, #409, #389, #395, PRs #433-#436, #437)
- 2026-10-12 : **#423 mergée (PR #428, `main` `8898213`) — menus déroulants de catégorie avec saisie dans le formulaire de catégorie et le dialogue mot-clé.** Issue créée et analysée le jour même (`/analyze` : types `Category` vs `CategoryTreeNode` incompatibles → type structurel `CategoryLike` sur le combobox ; le formulaire ne connaissait pas l'id édité → prop `editingId` ; deux listes différentes → **D2** de Max : liste des Transactions ; **D1** : #423 porte l'exclusion UI, #393 garde la validation service). Plan check refusé une fois (la liste `absolute` était rognée par le scrollport du dialogue, réglé en sortant les champs du bloc défilant). Première revue **REQUEST_CHANGES** : la règle ne regardait que les enfants directs, une racine avec petits-enfants pouvait recevoir un parent (4ᵉ niveau) ; corrigé par la hauteur du sous-arbre, test rouge d'abord sur la sonde du relecteur. Seconde revue **APPROVE**, quatre suivis appliqués en tête (`8898213`, non relu : commentaires #393 corrigés, garde de cycle épinglée, espions sur `buildParentOptions`, garde redondante retirée). Preuves : 1963 vitest, 9 mutations rouges de l'auteur (une survivante remplacée : la règle « descendance » était impliquée par la règle de profondeur), CI 526-528, worktree `npm ci` propre. **Acquis** : `/commits/<sha>/statuses` renvoie l'historique (`pending` puis `success`), lire `/commits/<sha>/status` agrégé ; `head_commit_id` du merge exige le SHA complet (409 « head out of date » sur un SHA court). Aucune migration (v1→v17). (ref #423, PR #428, #429, #393)
## Blockers actifs
- Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Clôture du go-live = la-compagnie-maximus#141** (`status:blocked`), qui n'attend plus que ce dépôt : l'updater vérifié (Free refuse et invite à acheter, Base accepte), #350 étant fermée depuis le 2026-10-04. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). Reste l'étape 10 de `/release` sous Windows (Michel).
- **Vérifications post-merge de #390** (PR #394, sur le prochain build) : la case 5 se coche après un classement manuel ; sous Linux, un vrai profil secondaire ancien affiche « database updated with versions … » au Journal, sa copie est dans `~/.config/com.simpl.resultat/migration-backups/` et l'import y fonctionne ; supprimer un profil retire son `.db` ; les rendements du Bilan s'affichent. #390 reste en `status:blocked` jusque-là.
- **Vérifications manuelles dues sur la v0.17.0 publiée** (onboarding + suites de licence ; Linux puis Windows), dans un ordre qui ne détruit le jeton d'activation qu'à la fin de ce qui en dépend : (1) #370, machine activée hors ligne → « Retirer la clé » : seconde confirmation qui donne la raison, Annuler ne change rien, sauts de ligne des fenêtres ; (2) #367, s'il existe un second poste avec la même clé : y désactiver ce poste, puis ouvrir ici la liste des machines → « Activer » revient sans recharger, ligne au Journal ; (3) #370 en ligne → place libérée, carte en Gratuite ; (4) #373 et #370 avec la clé révoquée du 2026-09-30 : Base une à deux secondes puis Gratuite, ligne « License check: the license server says the key was revoked » ; retirée → aucune désactivation envoyée, puis au redémarrage « no valid key stored, nothing sent » ; ressaisie → reste en Gratuite ; (5) clé testeur Premium ressaisie, « Activer », `verif-350.py` rend `64 brut: False dérivé: True` ; (6) #367, dossier v0.15.0 (clé A à côté du jeton d'une licence B révoquée) : couvert par un test de bout en bout, à faire seulement si un tel dossier existe encore. Plus la liste QA de l'onboarding (`docs/qa-onboarding.html`).
- **Vérifications manuelles dues sur la prochaine release : parcours de démarrage guidé** (#396 à #401, milestone `planned-2026-10-06-onboarding-parcours-guide`), d'abord sous Linux avec Orca, puis sous Windows avec NVDA : les sections « Parcours guidé » de `docs/qa-onboarding.html` (profil neuf de bout en bout, bandeau et liste, « Passer » et « Reprendre », règle D9 sur un vrai profil ancien, édition Gratuite à 5/5, contour dans WebKitGTK et WebView2, mouvement réduit, clavier et anneau de focus du contrôle entouré, annonces, et les limites connues renvoyées par les revues). Le même build sert aux vérifications post-merge de #390. #401 se ferme au merge ; la liste de la phase 1 pour la v0.17.0 reste lisible par `git show v0.17.0:docs/qa-onboarding.html`.
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone, en `status:ready` : **#369** (réserver le fractionnement de transactions à l'édition Base) et **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes). **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; reste de #363 (plafond de `/issue-license` à 36525 jours et essai d'une vraie clé de 100 ans, débloqués par le déploiement de maximus-api#63) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; clôture du go-live = la-compagnie-maximus#141 seule, maximus-api#62 étant fermée ; `spec-paiements` reste le parapluie des trois dépôts (ici 4/5, reste #363) ; onboarding phase 2 (parcours guidé) **mergée** : milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 le 2026-10-08 (#396 à #401, `ee17f31`), suivis **#408** et **#409** (`status:triage`) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** (`status:triage` : Node 20, en fin de vie depuis le 2026-04-30, encore installé par `check-frontend.yml` et `audit.yml`, et commentaire `concurrency` de `check-rust.yml` démenti par `forgejo-ops.md` pour les runs de PR). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. Restent **#317** (prémisse caduque depuis le 2026-09-26 : `react-router-dom` 7.18.2 et suivantes corrigent l'advisory par un bump de patch dans `^7.18.1`, sans migration v8 ; absorbée par #358, dont la PR la fermera), **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications) et **#345** (domicilier la contrainte `windows-sys 0.61.2` — analysée le 09-04 : le corps d'origine, écrit de mémoire en fin de revue, se trompait sur trois points. Le déclencheur n'est pas « n'importe quel `cargo update` » mais tout bump atteignant le sous-arbre réseau/TLS — mesuré : `-p anyhow` ne bouge rien, `-p hyper` fait descendre les 7 — soit précisément là où tombent les advisories. **Prémisse restreinte le 2026-09-21 par #348** : `-p rustls`, au cœur de ce même sous-arbre, n'en déplace **aucun** — le déclencheur tient à `hyper` et à ce qu'il tire, pas à la couche TLS en général, donc « sous-arbre réseau/TLS » prédit une dérive là où il n'y en a pas et ferait écrire une strophe à la main sans nécessité. D'où la suggestion portée en commentaire sur l'issue : formuler le garde-fou sur la **version résolue de `windows-sys`** plutôt que sur une liste de crates déclencheurs — la liste est ce qui vient d'être trouvé faux, la version est ce qui est protégé. #310 avait **bien** vu la dérive et l'avait jugée inoffensive *sur Linux*, où elle l'est ; son coût tombe sur le build Windows que personne ne mesurait : **3 versions de `windows-sys` compilées contre 5**. Aucun risque de correction — les 7 crates déclarent des bornes larges couvrant les deux états. Et cargo annonce « Locking 1 package » en écrivant 8 changements, donc le silence est le sien. Décision : garde-fou CI sur la version + ADR portant la condition de sortie. Le check croisé Windows depuis Linux a été **testé et écarté** — `cc-rs` s'arrête sur `lib.exe`, il faudrait `cargo-xwin`). Côté npm, **#358** ouverte le 2026-09-26 par `/analyse-vulnerabilite` : `npm audit` remonte 7 findings (4 high, 3 moderate) sur 4 dépendances racines, contre les 2 acceptés au 07-27, et chaque correctif tient dans la borne déjà déclarée ; le lock se régénère avec `npx npm@11` (npm 10 plante sur un bump de vitest 4.1.x).
- Nouveau bug ouvert : **#320** — les installations `.rpm` ne reçoivent aucune mise à jour (`latest.json` ne porte qu'une entrée `linux-x86_64` construite depuis le `.deb`, `release.yml:104-122`), alors que les `.rpm` continuent d'être publiés en assets. Documenté en §3 de `docs/qa-update-cycle.md` (« ne pas tester : cassé, pas seulement non vérifié »).
- **Vérifications dues sur la v0.17.2 publiée** (étape 9 de `/release` faite le 2026-10-18 : `verify-release.sh` OK, Node `v24.22.0`, trois clés ; étape 10 faite sous Linux le 2026-10-18, trace dans le corps de la release ; restent les points ci-dessous, à Max) :
- Retours d'onboarding v0.17.1 (#438-#445), dans l'app : cas de la section « Retours de la v0.17.1 » de `docs/qa-onboarding.html` et parcours guidé à huit étapes, en particulier `v0171-import-source-reopen`, `v0171-budget-card-leaves`, l'encadré « Structure attendue » dans les deux thèmes et la première ouverture d'un profil qui avait fini le parcours (#446).
- Répartition réservée à Base (#369), dans l'app : en Gratuite, cadenas sur le bouton Répartition de la page Transactions et écran de déverrouillage au clic, jamais la fenêtre de répartition, lignes déjà réparties toujours marquées de l'icône orange ; en Base, fenêtre de répartition comme avant. Commentaire posé sur la-compagnie-maximus#180 à la publication (25050).
- `release.yml` sous Node 24 (#376) : **fait** (`v24.22.0`, run 570). Le diff touchant `release.yml`, l'étape 10 de `/release` (cycle réel de mise à jour, `docs/qa-update-cycle.md`) s'applique aussi, selon la règle du skill : **faite sous Linux** (`.deb`, v0.17.1 → v0.17.2, Max, 2026-10-18 ; invite de mot de passe vue, type non consigné), **reste Windows**. Le bump de `rustls` de #447 (dépendance de `tauri-plugin-updater`) la rend obligatoire lui aussi. L'updater compilé sans `zip` (#455) est aussi dans cette release, mais ce cycle part de la v0.17.1, dont l'updater a encore `zip` : il ne prouve rien de l'updater sans `zip` (#463).
- `latest.json` à trois clés (#320, clé générique `linux-x86_64` retirée le 2026-10-11) : **fait** (journal et `verify-release.sh`, run 570). Cycle réel depuis une installation `.rpm` (§3 de `docs/qa-update-cycle.md`) dès qu'une machine Fedora ou openSUSE est disponible, sinon « non déroulé » avec la raison dans la trace de la release.
- Confirmations de #421 : **fait** (Max, sous Linux, 2026-10-18, concluant ; Windows non déroulé) ; #421 fermée à la main le même jour (commentaire 25074).
- **Vérifications dues à la release suivante** (celle d'après la v0.17.2) : cycle réel de mise à jour Windows depuis la première version qui embarque #455 (et #447), première exécution de l'installation par l'updater sans `zip` et du téléchargement par `rustls` 0.23.46 ; dû même si cette release ne touche pas l'updater (#463).
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Go-live clos : la-compagnie-maximus#141** fermée le 2026-10-10 sur décision de Max, l'updater vérifié (Free refuse et invite à acheter, Base accepte ; confirmé le même jour). Restent pour mémoire, sans issue : le Dashboard Stripe (logo, paramètres Checkout, reçus automatiques, alertes d'échec de webhook) et les relevés du commentaire de fermeture. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). L'étape 10 sous Windows est faite (Michel, confirmée le 2026-10-10).
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone : **#369** (réserver le fractionnement de transactions à l'édition Base) **fermée le 2026-10-17** (PR #452, voir Décisions) ; **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes) reste en `status:ready`. **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; #363 fermée le 2026-10-10 sur décision de Max (ses tests, absorbés par #347, sont livrés dans la v0.16.0) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; go-live clos (la-compagnie-maximus#141 fermée le 2026-10-10, maximus-api#62 le 2026-10-03) ; `spec-paiements` fermée ici 5/5 ; onboarding phase 2 (parcours guidé) **mergée** : milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 le 2026-10-08 (#396 à #401, `ee17f31`), suivis **#408** et **#409** (`status:triage`) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** fermée le 2026-10-12 (Node 24, PR #414). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. **#447 fermée le 2026-10-16** (PR #448, run 556 vert), quatre avis `rustls` sur ce même chemin ; le Défenseur n'audite pas le Rust (agent-defenseurs#186), `audit.yml` reste le seul canal pour les avis RustSec. **#455 fermée le 2026-10-18** (PR #459, run 564 vert), trois avis `zip` sur ce même chemin, retirés avec la feature `zip` de l'updater plutôt que supprimés. **#317** fermée le 2026-10-12 par la PR #417 (#358). Reste **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications). **#345** fermée le 2026-10-12 (PR #415) : garde `Verify windows-sys pinning` dans `check-rust.yml` et ADR 0023, qui portent la mesure, le coût (3 contre 5 versions compilées pour Windows) et la condition de sortie. Côté npm, **#358** fermée le 2026-10-12 (PR #417) : lock régénéré par `npm@11.21.0` dans les bornes de `package.json`, `npm audit` 8 → 0 au 2026-10-10.
- **#320** fermée le 2026-10-12 (PR #416, clé générique retirée par la PR #418) : une clé `latest.json` par paquet Linux, vérifiée sur la v0.17.2 publiée (run 570, `verify-release.sh`) ; cycle `.rpm` encore dû (puce « Vérifications dues sur la v0.17.2 publiée ») ; le §3 de `docs/qa-update-cycle.md` est devenu la procédure de test rpm.
- Suivis ouverts du chantier import (milestone fermée) : le refus du format « montant absolu + indicateur D/C » ne scanne que `amountCol ± 1`, donc une colonne D/C placée ailleurs passe encore ; dérives de comptes préexistantes dans `CLAUDE.md` et `docs/architecture.md` (composants, pages, services, `Version actuelle : 0.6.3` vs tag v0.14.0) mesurées et documentées en revue, non corrigées.
- **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.17.0 (2026-10-05) ; `[Unreleased]` vide.
- **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.17.2 (2026-10-18) ; `[Unreleased]` vide.

View file

@ -26,6 +26,8 @@
>
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
> **Amendement du 2026-10-16 (#369)** — la répartition d'une transaction sur plusieurs catégories (bouton « Répartition » de la page Transactions) passe dans l'édition Base, avec les ajustements, sur décision de Max du 2026-10-03 (analyse de maximus/la-compagnie-maximus#180). L'écran de déverrouillage le promettait déjà (`upsell.features.adjustments`) alors que l'app ne la verrouillait pas : le code s'aligne sur l'offre, il ne la change pas. La page Transactions reste Free ; seul son bouton est gaté, par la clé `adjustments`. En Gratuite, le bouton garde sa place et son icône, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne, répartie ou non. Tant que la licence n'est pas `ready`, il n'affiche aucun cadenas et ne fait rien (`splitAccessFor`, plus strict que `isProfileSwitchLocked` : `allowed` n'y court-circuite pas `ready`). Conformément au §5, les répartitions existantes (`transactions.parent_transaction_id`, `is_split`) restent en base, comptées par les rapports et le tableau de bord, et signalées par l'icône orange ; seules leur ouverture et leur modification sont verrouillées. Écartés : une fenêtre de répartition en lecture seule, et l'annulation d'une répartition permise en Gratuite.
## Contexte
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
@ -34,12 +36,12 @@ Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
| Module | Gratuite | Base | Premium |
|---|---|---|---|
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
| Tableau de bord, Import, Transactions (sans la Répartition, #369), Catégories | ✓ | ✓ | ✓ |
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
| Profil (un seul) | ✓ | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Ajustements | — | ✓ | ✓ |
| Ajustements, Répartition des transactions (bouton de la page Transactions, #369) | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Profils multiples | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
@ -83,11 +85,11 @@ Le gating est appliqué **uniquement dans l'interface** (routes, navigation, poi
### 4. Upsell verrouillé visible, jamais masqué
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**. Depuis #369, une action gatée peut aussi vivre dans une page Free : le bouton Répartition de la page Transactions porte le cadenas (`SplitButton`, si `ready && !allowed` pour `adjustments`) et ouvre `UpsellGate` dans une fenêtre (`UpsellDialog`).
### 5. Durcissement sec, non destructif
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils. Même règle pour les répartitions de transactions (#369) : en Gratuite, elles restent comptées par les rapports et signalées dans la liste ; seules leur ouverture et leur modification sont verrouillées.
### 6. Dev-override compilé hors des builds normaux
@ -144,6 +146,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src/shared/splitGate.ts` — accès du bouton Répartition (`open`, `locked`, `pending` ; #369) ; `src/components/transactions/SplitButton.tsx` ; `src/components/shared/UpsellDialog.tsx` — écran verrouillé dans une fenêtre
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0)
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0) ; #369 (Répartition réservée à l'édition Base, voir l'amendement en tête)

View file

@ -2,13 +2,15 @@
- Status: **Accepted**
- Date: 2026-07-27
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré)
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré), #455 (canari `tar` → `webkit2gtk`)
- S'appuie sur #232 (split de `check.yml`, création de `audit.yml`, retrait du `|| true` qui masquait la sortie de `cargo audit`)
> **Amendement du 2026-07-27 (#312/#313)** — la décision et ses trois règles sont inchangées ; seules les entrées ont bougé. Les deux advisories `quick-xml` ont **quitté la liste le jour même** : `plist` 1.10.0 tire `quick-xml` 0.41.0, qui porte le correctif, dans la borne existante de `tauri`. Elles sont donc **résolues, pas acceptées**, et le mécanisme a fonctionné comme prévu — la vérification par cible a montré qu'un correctif était devenu atteignable, et l'entrée est partie. Il ne reste que `RUSTSEC-2023-0071` (`rsa`), et le garde-fou ne boucle plus que sur ce crate.
>
> Trois passages ci-dessous sont **datés du 2026-07-27 avant ce bump** et à lire comme historiques : `quick-xml` comme exemple de l'insuffisance de `--target all` (règle 1), l'entrée `quick-xml` du tableau de contexte, et l'alternative « override / `[patch.crates-io]` » — cette dernière **n'est plus vraie**, `plist` 1.10.0 déclarant désormais `quick-xml ^0.41.0`. L'argument de fond qu'elle illustre (`[patch.crates-io]` ne franchit pas une frontière semver-incompatible) reste correct, seul le cas d'espèce a disparu.
> **Amendement du 2026-10-17 (#455)** — le canari du garde-fou change ; la décision et ses trois règles restent. Pour retirer `zip` 4.6.1 (RUSTSEC-2026-0367 à -0369, sans version corrigée compatible avec l'updater), `tauri-plugin-updater` est compilé sans sa feature `zip`, qui tirait aussi `tar` : `tar` a quitté le graphe Linux, et le canari d'origine aurait rougi par construction. Il est remplacé par `webkit2gtk`, la webview Linux de Tauri (`wry`), qu'un build Linux de Tauri v2 ne peut pas perdre. Le choix garde la propriété qui faisait la valeur de `tar` : une dépendance présente sur `x86_64-unknown-linux-gnu` et absente de `x86_64-pc-windows-msvc`, dont la présence prouve aussi que `--target` résout les arêtes conditionnelles sur lesquelles repose la règle 1. `tauri`, envisagé à l'analyse de #455, est inconditionnel et n'aurait prouvé que l'affichage de `cargo tree`.
## Contexte
#232 a retiré le `|| true` qui avalait la sortie de `cargo audit` et créé `audit.yml`, un job quotidien (06:00 UTC) **volontairement bloquant** : il *est* le canal de notification pour les advisories publiées entre deux PR Rust, `check-rust.yml` ne tournant que sur ~1 PR sur 40.
@ -52,7 +54,7 @@ L'atteignabilité est une propriété du graphe **résolu aujourd'hui**, pas une
Trois détails la rendent fiable plutôt que décorative :
- le code de sortie de `cargo tree` est testé séparément de sa sortie standard — un crate absent sort en 0 avec un stdout vide, tandis qu'un échec de `cargo tree` sort en non-zéro avec un stdout vide lui aussi ; sans cette distinction, une panne de l'outil se lirait comme une preuve d'absence ;
- un **canari** (`tar`, dépendance réellement présente) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
- un **canari** (`webkit2gtk`, dépendance réellement présente, et seulement sous Linux ; `tar` jusqu'à #455) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
- `.cargo/**` est ajouté aux `paths` de `check-rust.yml` pour qu'une PR ne touchant que la liste déclenche bien la vérification.
Le pendant — une entrée devenue *inutile*, que rien ne signale — est couvert par une issue de suivi portant la condition de retrait : #312 l'a joué pour `quick-xml` (et a servi : le déclencheur avait sauté sans que personne le remarque), #319 le fait pour `rsa`. **Toute entrée de la liste doit en avoir une**, sinon le garde-fou ne couvre qu'une moitié du risque.
@ -91,4 +93,4 @@ Le pendant — une entrée devenue *inutile*, que rien ne signale — est couver
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify suppressed advisories are still unreachable »
- [`.forgejo/workflows/audit.yml`](../../.forgejo/workflows/audit.yml) — le gate quotidien bloquant
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) — récupération de cours via `reqwest`, l'un des deux chemins qui rendent `rustls-webpki` atteignable
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante)
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante), #455 (updater sans sa feature `zip`, canari `tar` → `webkit2gtk`)

View file

@ -42,7 +42,9 @@ Les profils créés avant #390 n'ont pas d'historique. Ils forment un ensemble f
### 4. Une copie avant la première écriture
Quand une version reste à appliquer, la base est d'abord copiée par `VACUUM INTO` vers `<dossier des bases>/migration-backups/<profil>-before-v<N>.db`. Le chemin est lié, jamais formaté dans le SQL, ce qui tient un dossier comme `C:\Users\L'Heureux`. La copie est écrite en `.tmp`, synchronisée sur disque, puis renommée : un fichier final existant est donc complet, et il est gardé tel quel. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil.
Quand une version reste à appliquer, la base est d'abord copiée par `VACUUM INTO` vers `<dossier des bases>/migration-backups/<profil>-before-v<N>.db`. Le chemin est lié, jamais formaté dans le SQL, ce qui tient un dossier comme `C:\Users\L'Heureux`. La copie est écrite en `.tmp`, synchronisée sur disque, puis renommée : un fichier final existant est donc complet. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil.
**Rétention (#395, décision de Max du 2026-10-10) : une seule copie par profil, la plus récente.** La copie est refaite à chaque rattrapage, même quand un fichier du même nom existe : laissé par un rattrapage qui s'est arrêté, il précède les données saisies depuis. Le renommage remplace ce fichier, qui n'est jamais supprimé d'abord. Après un rattrapage **réussi**, et seulement après le renommage, les autres `<profil>-before-v*.db` (et `.db.tmp`) de ce profil sont supprimés (`remove_backups(dossier, profil, Some(copie))`, au mieux : une copie restée ne nuit pas). Rien n'est supprimé quand le rattrapage s'arrête, quand la copie échoue, ni quand rien n'est à appliquer. Coût accepté : tant qu'un rattrapage échoue, chaque ouverture refait un `VACUUM INTO`.
Elle reste dans le dossier des bases, pas dans `~/Documents/Simpl-Resultat/backups/` que la sauvegarde manuelle utilise : ce dossier est souvent synchronisé vers un service en ligne (OneDrive sous Windows), et une copie automatique en clair d'une base financière n'a rien à y faire.
@ -56,7 +58,7 @@ Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étr
### 7. L'échec reste ouvert
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25).
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`.
### 8. Un seul dossier des bases
@ -79,6 +81,7 @@ Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant
- **Une correction de données livrée seulement en migration n'atteint pas un nouveau profil**, qui naît avec l'historique complet. Elle doit aussi entrer dans `consolidated_schema.sql` (règle `.claude/rules/sql-migrations.md`). Le test qui compare chaque état rattrapé à un profil neuf ne regarde que le schéma.
- **Deux moteurs appliquent les mêmes migrations** : sqlx pour le profil par défaut, rusqlite pour les autres. Le SQL est le même et les clés étrangères sont actives des deux côtés. La reprise sans clés étrangères n'existe que du côté rusqlite.
- **La commande est synchrone.** Tauri l'exécute sur le fil principal, ce qui sérialise deux ouvertures du même profil (React StrictMode monte deux fois en développement). L'interface attend pendant un rattrapage, une fois par profil ancien, et la durée est écrite au Journal.
- **Le délai de démarrage couvre le rattrapage du profil actif** (#395). `App.tsx` affiche la page d'erreur si l'ouverture dépasse `STARTUP_TIMEOUT_MS` (10 s, `src/utils/startupConnection.ts`), délai qui couvre `repair_migrations`, `prepare_profile_db` et `Database.load`. Mesure (test `#[ignore]` `a_large_profile_catch_up_duration`) : un profil au plus bas plancher reconnu (6, schéma v0.3.0) avec 100 001 transactions (17 Mo, 10 001 drapeaux texte), rattrapé de la v7 à la v17 avec la copie `VACUUM INTO`, le `foreign_key_check` final et la réparation des drapeaux, prend **306 ms en release** et 422 ms en debug, le 2026-10-12, sur le ThinkPad T490 de Max (Intel Core i5-8365U, 4 cœurs / 8 fils, 1,6 à 4,1 GHz, 16 Go, SSD NVMe), sous Linux. Règle fixée en planification : au-delà de 3 s, le délai passait à 30 s ; la mesure étant sous 3 s, il reste à 10 s. Risque résiduel : un disque lent (HDD, antivirus Windows qui inspecte la copie), une base bien plus grosse ou une machine bien plus faible peuvent dépasser 10 s. La page d'erreur s'affiche alors, mais l'ouverture continue et, depuis #395, l'application s'affiche dès qu'elle aboutit (`setStartupError(null)` au succès). Au-delà de 15 s, l'alerte « profile opening » du verrou de `db.ts` (#408) s'écrit aussi au Journal, ce qui est attendu.
- **Le test qui distingue les deux dossiers ne voit l'écart que sous Linux**, la plateforme de la CI. La vérification à la main sous Linux reste due.
- **Les profils supprimés sous Linux avant ce correctif** ont laissé leur base dans `~/.config/com.simpl.resultat/`. Rien ne supprime automatiquement un fichier que `profiles.json` ne cite pas : un `profiles.json` abîmé coûterait alors de vrais profils.

View file

@ -2,7 +2,7 @@
- Status: **Accepted**
- Date: 2026-10-08
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391)
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391) ; amendé par #425 (étapes de visite, §2, §4 et §5)
- S'appuie sur l'[ADR 0002](0002-useReducer-vs-redux.md) (un `useReducer` par domaine, pas de store global) et l'[ADR 0016](0016-persistance-etat-ui-par-profil.md) (préférences par profil dans `user_preferences`)
## Contexte
@ -45,7 +45,7 @@ Ne notifient pas :
- `updateSource`, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
- `categoryMigrationService.applyMigration`, qui réécrit `budget_entries` (`DELETE` des mois en double sous la catégorie cible, puis `UPDATE` de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
- `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, et `initializeGuidedPath`, la règle D9). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, `acknowledgeStep`, et `initializeGuidedPath`, la règle D9, et `initializeVisitSteps`, l'initialisation des étapes de visite, #425). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
**Règle pour la suite** : une nouvelle écriture d'une table que lit `COUNTERS_SQL` s'ajoute à ce tableau et notifie, avec un test qui prouve la notification après un succès et son absence après un échec. Une écriture faite dans `withTransaction` notifie après la fin de `withTransaction`, jamais à l'intérieur du rappel : le `COMMIT` est fait et le verrou relâché.
@ -55,7 +55,7 @@ L'émetteur n'attend rien. L'abonné attend 150 ms sans nouvel événement avant
### 4. Les réponses périmées sont ignorées
Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.
Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.
### 5. Abonnés admis
@ -66,6 +66,8 @@ Un abonné :
- se désabonne à son démontage ;
- ne fait rien de coûteux dans l'appel lui-même, qui est synchrone : il arme un minuteur.
Une exception admise, comme la règle D9 : le cochage d'office des étapes de visite (#425). Il n'est pas une réponse à une notification, mais une écriture unique par profil (`initializeVisitSteps`), lancée après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, gardée par une promesse partagée contre le double montage de StrictMode, et qui relit elle-même une fois écrite. Elle ne notifie pas, et ses écritures sont idempotentes, sans transaction : un arrêt entre deux refait le même calcul à l'ouverture suivante.
Aujourd'hui, un seul abonné : le fournisseur du parcours de démarrage (`OnboardingProvider`, par `useOnboardingChecklist`). Un abonné qui aurait besoin d'autres tables élargit la portée du canal : il amende cet ADR, avec la liste des écritures concernées et leurs tests.
## Conséquences

View file

@ -0,0 +1,161 @@
# ADR 0023 — Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite
- Status: **Accepted**
- Date: 2026-10-10
- Issues: #345 (cette décision), PR #344 (deuxième dérive, contournée en n'appliquant que la strophe `h2` à la main), #348 (mesure `rustls` : la couche TLS en général ne déclenche pas la dérive)
- S'appuie sur l'[ADR 0018](0018-suppression-advisories-non-atteignables.md) : même motif de garde-fou (chaque vérification loggée, canari, code de sortie de l'outil testé à part de sa sortie)
## Contexte
Sept crates du fichier de verrouillage déclarent `windows-sys` par une borne qui couvre plusieurs mineures :
| crate | exigence déclarée sur `windows-sys` |
|---|---|
| `colored` 3.1.1, `winapi-util` 0.1.11 | `>=0.48, <=0.61` |
| `errno` 0.3.14, `rustix` 1.1.3, `tempfile` 3.24.0 | `>=0.52, <0.62` |
| `rustls-platform-verifier` 0.6.2 | `>=0.52.0, <0.62.0` |
| `dirs-sys` 0.5.0 | `>=0.59.0` |
Ce sont les seuls du graphe dans ce cas (relevé par `cargo metadata` le 2026-10-10). Toutes les autres déclarations sont des `^0.x`, qui n'admettent qu'une mineure, donc une seule des versions présentes : elles ne peuvent pas dériver. Le lock contient six versions de `windows-sys`, toutes cibles confondues (0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2), et les sept crates peuvent pointer n'importe laquelle de celles que leur borne admet, sans ajouter de paquet au lock et sans violer aucun contrat.
Les sept pointent **`0.61.2`**. C'est l'état du tag v0.14.0 (2026-07-18), dont la release (run 322) a traversé un vrai build Windows NSIS, et celui de chaque release depuis : v0.15.0 (run 402), v0.16.0 (run 451), v0.17.0 (run 491), v0.17.1 (run 508), vérifié le 2026-10-10 en rejouant le garde ci-dessous sur le lock de chaque tag. L'état dérivé n'a traversé aucun build Windows : aucune release n'a eu lieu pendant la seule fenêtre où `main` l'a porté, de #310 (2026-07-27) à la PR #321 (2026-08-15).
### La contrainte n'était tenue par rien
| | |
|---|---|
| #310 (2026-07-27) | `cargo update -p rustls-webpki -p tar` fait descendre les sept arêtes ; vu, et jugé inoffensif parce que sans effet sur Linux |
| PR #321 (2026-08-15) | la revue les remonte à `0.61.2`, crate par crate contre le tag v0.14.0 |
| PR #344 (2026-09-04) | `cargo update -p h2` recommence ; contourné en n'appliquant que la strophe `h2` à la main |
| #348 (2026-09-21) | `cargo update -p rustls --precise 0.23.45` ne bouge rien, mais il a fallu remesurer pour le savoir |
Hors du lock, la contrainte ne vivait que dans de la prose (`STATE.md`, des messages de commit) : la prochaine mise à jour la régresserait, et il faudrait qu'un relecteur reconnaisse sept noms de crates pour le voir. C'est le mode d'échec que l'ADR 0018 traite pour les suppressions d'advisories : une décision qui ne vit que dans de la prose n'est pas une décision tenue.
### Le déclencheur, mesuré
| commande, sur une copie du lock | effet sur les sept arêtes | mesure |
|---|---|---|
| `cargo update -p anyhow` | aucun | 2026-09-04 |
| `cargo update -p rustls --precise 0.23.45` | aucun ; le `diff` de toutes les lignes `windows-sys` du lock est vide | 2026-09-21 (#348) |
| `cargo update -p hyper` | les sept descendent : `colored` et `winapi-util` vers 0.48.0, `errno`, `rustix`, `tempfile` et `rustls-platform-verifier` vers 0.52.0, `dirs-sys` vers 0.59.0 | 2026-09-04, refaite le 2026-10-10 (`hyper` 1.8.1 → 1.12.0) |
Dans cette mesure, chaque arête est descendue vers la plus basse version déjà présente que sa borne admet, et la dérive de #310 (commit `f4b09b0`) a la même répartition : deux arêtes vers 0.48.0, quatre vers 0.52.0, une vers 0.59.0. `hyper` et ce qu'il tire (`h2` dans la PR #344) la déclenchent, la couche TLS en général non : `rustls`, en son cœur, ne déplace rien. Mais `rustls-webpki` et `tar` l'ont déclenchée en #310. Aucune règle simple ne dit quelle mise à jour la provoque, et la dernière qu'on a formulée (« tout bump du sous-arbre réseau/TLS ») a été démentie par #348. C'est donc la version résolue qu'il faut garder, pas une liste de déclencheurs.
**Et cargo n'en dit rien.** Le 2026-10-10, `cargo update -p hyper` annonce :
```
Locking 2 packages to latest compatible versions
Updating hyper v1.8.1 -> v1.12.0
Updating want v0.3.1 -> v0.3.2
```
pendant qu'il réécrit aussi les sept arêtes. L'ensemble des paquets ne bouge pas (666 avant et après) ; seules les arêtes changent, et cargo ne rapporte que les paquets. Or les deux dérives passées sont venues avec des correctifs d'advisories RustSec (`rustls-webpki` et `tar` en #310, `h2` dans la PR #344) : la dérive arrive avec les changements les plus pressés et les moins relus.
### Ce que coûte la dérive
Pas un risque de correction : les deux états sont dans les bornes déclarées par les sept auteurs. Le coût est de taille et de temps de compilation, et il tombe sur le build Windows, que personne ne mesurait :
| état | versions de `windows-sys` compilées pour `x86_64-pc-windows-msvc` |
|---|---|
| épinglé (`0.61.2`) | **3** : 0.59.0, 0.60.2, 0.61.2 |
| dérivé | **5** : les mêmes, plus 0.48.0 et 0.52.0 |
Mesuré par `cargo tree --target x86_64-pc-windows-msvc -e normal,build`, refait le 2026-10-10 sur le lock de `main` et sur sa copie dérivée par `cargo update -p hyper`. Deux crates `windows-sys` complets de plus dans chaque build Windows, ajoutés en effet de bord, dans une configuration qu'aucun build Windows n'a traversée.
## Décision
**Un garde-fou CI bloquant sur la version résolue, et cet ADR pour en porter la raison et la sortie.**
L'étape `Verify windows-sys pinning` de `check-rust.yml`, placée après `cargo check` à côté du garde des suppressions, lit `src-tauri/Cargo.lock` avec `awk` et applique quatre règles. `awk` (mawk) fait partie de l'image de base `ubuntu:22.04`, alors que `python3` n'est présent dans ce conteneur que comme dépendance transitive des paquets `apt` installés à l'étape précédente (journal du run 517), ce que rien ne garantit :
1. **Chaque arête des sept crates.** Pour `colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile` et `winapi-util`, chaque bloc `[[package]]` de ce nom doit porter au moins une arête `windows-sys`, et chacune doit nommer `0.61.2`. L'absence de bloc pour l'un de ces noms fait échouer l'étape (crate renommé ou sorti du graphe). Un suffixe de source sur une arête (` (registry+…)`) est toléré.
2. **Arête nue.** Cargo n'écrit `"windows-sys 0.61.2"` que tant que plusieurs versions coexistent ; quand il n'en reste qu'une, il écrit `"windows-sys"`. Une arête nue ne passe que si le lock ne contient qu'un seul paquet `windows-sys`, en `0.61.2`.
3. **Ensemble des versions.** Les versions de `windows-sys` du lock, toutes cibles confondues et doublons compris, doivent égaler exactement `{0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2}` (état du 2026-10-10). Une version qui apparaît ou disparaît fait échouer l'étape avec un renvoi à cet ADR : c'est soit le coût réel (une version de plus), soit le début de la sortie, et dans les deux cas une décision.
4. **Canari.** La boucle doit avoir lu et vérifié une arête de `tempfile`, crate connu pour en porter une, faute de quoi son silence ne prouve rien et l'étape échoue.
Comme dans l'ADR 0018, chaque vérification est loggée, succès compris (`ok: rustix 1.1.3 -> windows-sys 0.61.2`), puis un bilan (`windows-sys pinning: 9 checks, exit 0`), et le code de sortie d'`awk` est testé à part de sa sortie. Une sortie vide échoue déjà (ensemble des versions, blocs introuvables, canari) ; ce test attrape seul un `awk` qui échoue après avoir tout imprimé, et sans lui le garde sortirait en 0 (éprouvé à la revue de la PR #415). Le garde n'appelle pas cargo, donc ni l'index chaud ni `--locked` ne le concernent ; il lit le lock tel que `cargo check` l'a laissé.
La liste gardée, la version épinglée et l'ensemble attendu sont en tête du script, sous un commentaire qui renvoie ici. On ne les modifie que sur la preuve que demandent les sections suivantes, jamais pour faire passer l'étape au vert.
**Friction assumée** : chaque mise à jour qui déplace ces arêtes (celles qui atteignent `hyper`, entre autres) fera échouer l'étape et demandera une édition à la main du lock. C'est l'objet : un effet de bord silencieux devient une décision explicite.
### Éprouvé par mutation
Le 2026-10-10, le texte du `run:` a été extrait du workflow et lancé tel quel, avec les options du runner (`bash --noprofile --norc -e -o pipefail`), sur des copies du lock :
| lock | sortie |
|---|---|
| celui de `main`, et ceux des tags v0.14.0 à v0.17.1 | exit 0, 9 vérifications |
| dérivé par `cargo update -p hyper` | exit 1, les sept arêtes en échec |
| un bloc `windows-sys` 0.53.0 ajouté | exit 1, ensemble des versions |
| `colored` renommé | exit 1, bloc introuvable |
| absent (`awk` sort en 2) | exit 1, 0 vérification |
| `tempfile` retiré de la liste gardée, ou liste vide (script modifié) | exit 1, canari |
## Procédure quand le garde échoue
La ligne `FAIL:` dit quelle règle a cédé.
- **Une arête a quitté `0.61.2`** (règle 1), en général après un `cargo update` (`-p hyper`, `-p h2` et `-p rustls-webpki -p tar` l'ont fait). Garder la mise à jour voulue et remettre les arêtes, de l'une de deux façons qui mènent au même lock :
- n'appliquer à la main que la ou les strophes du paquet visé (version, checksum, dépendances), comme la PR #344 l'a fait pour `h2` ;
- ou garder la sortie de `cargo update` et ramener à la main les arêtes signalées à `"windows-sys 0.61.2"`, les seules lignes à toucher.
Puis vérifier que cargo accepte le lock tel quel, sans le réécrire :
```
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked > /dev/null
```
Éprouvé le 2026-10-10 sur la copie dérivée par `cargo update -p hyper` : les sept arêtes remises, le diff contre `main` se réduit aux strophes `hyper` et `want`, `cargo metadata --locked` l'accepte sans modifier le fichier, et le garde repasse.
- **L'ensemble des versions a changé** (règle 3). Pour une version ajoutée, chercher qui la tire et si elle est compilée pour Windows (`cargo tree --manifest-path src-tauri/Cargo.toml -i windows-sys@<version> --target x86_64-pc-windows-msvc`) ; si c'est le début du passage à 0.62, la condition de sortie est atteinte (section suivante). Une version retirée est un pas vers la convergence. Dans les deux cas, `EXPECTED` change dans la PR qui change le lock, preuve à l'appui dans la PR.
- **Bloc introuvable** : le crate a été renommé ou est sorti du graphe. Retirer son nom de `CRATES` après avoir revérifié la liste des bornes larges (commande de la section Conséquences).
- **Plus d'arête `windows-sys` dans un bloc gardé** (`has no windows-sys edge left`) : le crate a cessé de dépendre de `windows-sys` dans cette version. Même traitement qu'un bloc introuvable ; si c'est `tempfile`, choisir aussi un autre canari parmi les crates restants.
- **Un second bloc d'un crate gardé dont la borne exclut `0.61.2`** (par exemple un `tempfile` plus ancien, d'avant les bornes larges, tiré par une autre dépendance) : la règle 1 échoue, et la réparation de la première puce est impossible, puisque `cargo metadata --locked` refuse une arête hors borne (code 101, éprouvé à la revue de la PR #415). Décider dans la PR qui change le lock : admettre ce bloc par nom et version dans le script, ou écarter la dépendance qui le tire ; dans les deux cas, amender cet ADR.
- **Canari ou `awk` en échec** : le garde lui-même est cassé. Le réparer ; ne jamais changer le canari ou la liste pour le faire taire.
## Condition de sortie
L'épinglage n'est pas fait pour durer indéfiniment. Il change ou disparaît dans deux cas.
- **Au passage de l'écosystème à `windows-sys` 0.62.** La borne `<=0.61` de `colored` et `winapi-util`, comme le `<0.62` d'`errno`, `rustix`, `tempfile` et `rustls-platform-verifier`, les empêche de suivre ; seul `dirs-sys` (`>=0.59.0`, sans plafond) le peut. Quand un crate du graphe tirera 0.62, la règle 3 rougira et il faudra décider sur preuve : une nouvelle configuration n'est tenue qu'après un vrai build Windows, c'est-à-dire un run `release.yml`. `PIN`, `EXPECTED` et au besoin `CRATES` changent alors dans la même PR que le lock, avec un amendement de cet ADR. Le garde est mis à jour, jamais contourné.
- **À la convergence vers un seul `windows-sys`**, la sortie souhaitée. Plus aucune arête n'a où dériver, et le coût mesuré disparaît. Le garde le signalera (règle 3 en échec, arêtes nues admises par la règle 2) ; l'étape peut alors être retirée, avec un amendement de cet ADR.
## Alternatives considérées
- **Check croisé Windows en CI** (`cargo check --target x86_64-pc-windows-msvc` depuis Linux). L'option la plus attirante, puisqu'elle prouverait que le graphe Windows compile quel que soit l'état. Testée à l'analyse de #345, elle échoue : `cargo check` a besoin du toolchain natif de la cible dès qu'un build script compile du C, et `cc-rs` s'arrête sur `failed to find tool "lib.exe"`. Il faudrait installer sur chaque PR Rust le toolchain croisé que `release.yml` n'installe qu'au tag (`cargo-xwin` compilé depuis les sources, `clang`, `lld`), sur un runner à capacité 1 : un chantier, pas une étape bon marché. À rouvrir si le besoin d'une preuve Windows revient pour d'autres raisons.
- **Garde sur la propriété** (« au plus 3 `windows-sys` dans le graphe Windows »). Il encode le dommage mesuré plutôt qu'un numéro, survit au passage à 0.62 et couvrirait un nouveau crate à borne large. Mais il reste indirect à la lecture, tolère un échange à compte constant, et demande `cargo tree` : le lock ne dit pas quelle arête est compilée pour quelle cible. Écarté au profit de la cohérence avec le motif existant.
- **Garde sur une liste de crates déclencheurs** (`hyper`, la couche TLS). La liste est précisément ce que #348 a trouvé faux.
- **Commentaire seul dans `Cargo.toml`.** `Cargo.toml` n'est pas ce qu'on lit avant de lancer `cargo update`, et rien ne l'exécute.
## Conséquences
### Positives
- La dérive ne passe plus en silence : une mise à jour qui déplace une arête rougit `check-rust` sur la PR même, avec le nom de chaque arête déplacée, au lieu d'attendre qu'un relecteur reconnaisse sept noms de crates dans un diff que cargo n'a pas annoncé.
- La contrainte est domiciliée : qui voit l'étape rougir trouve ici pourquoi, et quoi faire.
- Le motif est celui que le dépôt connaît déjà (ADR 0018), pièges compris.
### Négatives
- **Friction** : chaque mise à jour qui déplace ces arêtes demande une édition à la main du lock.
- **La liste des sept est tenue à la main.** Un crate à borne large qui entrerait dans le graphe ne serait pas gardé, et s'il pointait une version déjà présente, la règle 3 ne le verrait pas non plus. À l'inverse, retirer de `CRATES` un autre nom que le canari passe, avec une vérification de moins dans le bilan (9 → 8) : seule la relecture du diff, sous le commentaire qui renvoie ici, l'attrape. La commande suivante liste les exigences sur `windows-sys` qui ne sont pas un `^0.x` ; le 2026-10-10, elle sort exactement les sept crates de `CRATES` :
```
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked \
| python3 -c 'import json, sys; [print(p["name"], p["version"], d["req"]) for p in json.load(sys.stdin)["packages"] for d in p["dependencies"] if d["name"] == "windows-sys"]' \
| grep -v ' \^0\.'
```
- La règle 3 compte toutes les cibles : 0.45.0, 0.48.0 et 0.52.0 ne sont pas compilées pour `x86_64-pc-windows-msvc` aujourd'hui, et un changement qui ne touche qu'elles la fera rougir quand même. Assumé : le lock ne dit pas ce qui est compilé pour quelle cible, et une fausse alerte se lève en une vérification `cargo tree`.
- Le garde tourne sur les PR qui touchent `src-tauri/**`, `.cargo/**` ou le workflow, et le lundi sur `main`, pas en continu. Une exception : le commit de `/release` modifie `Cargo.lock` directement sur `main`, sans PR, et le garde ne le verrait qu'au lundi suivant, après le build du tag. Le skill `/release` le rejoue donc en local juste après avoir édité le lock, avant de tagger (étape 2).
### Neutre
- Le garde lit le lock, il ne prouve pas que le graphe Windows compile. Cette preuve reste le run `release.yml`.
- Rien ne change pour l'application : #345 lit `Cargo.lock` sans le modifier, et ne déplace aucune dépendance.
## Liens
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify windows-sys pinning »
- [ADR 0018](0018-suppression-advisories-non-atteignables.md) — le garde-fou des suppressions, dont celui-ci reprend le motif
- Issues #345 (cette décision), #348 (mesure `rustls`), #310 (première dérive) ; PR #344 (strophe `h2` appliquée à la main), PR #321 (arêtes remontées à `0.61.2`)

View file

@ -0,0 +1,87 @@
# ADR 0024 — Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium
- Status: **Proposed** (écrit avant le code, à passer Accepted au merge de la milestone `spec-mcp-server`)
- Date: 2026-10-12
- Issues: milestone `spec-mcp-server` (à créer depuis `spec-plan-mcp-server.md`), revue `/review-spec` du 2026-10-12
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md) (gating par édition, `FEATURE_TIERS` côté Rust), l'[ADR 0021](0021-migrations-profils-secondaires.md) (`prepare_at`, rattrapage des profils secondaires) et l'[ADR 0022](0022-canal-evenements-donnees.md) (canal d'événements de données), qu'il amende
## Contexte
Simpl'Résultat garde toutes les données financières en local et n'envoie rien à un serveur tiers. Les agents IA (Claude Code, Claude Desktop, Hermes, Cursor) parlent un protocole commun, le Model Context Protocol (MCP), pour appeler des outils exposés par un programme local. Aujourd'hui rien ne relie l'app à ces agents : pour demander « où déborde mon budget », l'utilisateur exporte un CSV et le colle.
Trois faits du code structurent la décision :
- **La logique métier vit dans la webview**, en TypeScript, via `tauri-plugin-sql` (`src/services/*`). Côté Rust, seul `rusqlite` ouvre les bases, pour les migrations des profils secondaires et le rendement Modified Dietz.
- **Le PIN ne protège que l'interface** : les bases SQLite sont en clair sous `app_config_dir()`.
- **L'édition se résout en Rust** depuis `license.key` (et le cache de compte), avec révocation et fail-closed.
Et un fait du protocole : en MCP, ce n'est pas l'app qui transmet des données, c'est l'hôte. Chaque résultat d'outil part chez le fournisseur du modèle de l'agent. Le principe privacy-first de l'app reste vrai au sens strict, mais l'utilisateur doit l'activer en connaissance de cause.
## Décision
**Un mode `--mcp` de l'exécutable existant sert un serveur MCP sur stdio avec `rmcp` (SDK Rust officiel, spec 2026-07-28), ouvre en direct la base SQLite d'un profil que l'utilisateur a explicitement autorisé dans l'app, et expose des outils de lecture, deux outils d'écriture et deux prompts. Réservé à l'édition Premium.**
### 1. Un binaire autonome, pas un serveur hébergé par l'app
`simpl_result_lib::entry(args)` choisit `mcp::run_stdio()` si `--mcp` est présent, sinon `run()`. Aucun `tauri::Builder`, aucun plugin single-instance ni deep-link dans ce mode. Le serveur marche app fermée, et rien n'est à empaqueter : pas de sidecar `externalBin`, pas de changement dans `release.yml`.
Le coût accepté : le SQL des outils est réécrit en Rust. Les services TypeScript restent la référence, et des **tests de parité** exécutent le texte SQL de référence (`compareDeltaSql` pour le résumé mensuel, `getExpensesByCategory` pour la répartition) sur une fixture commune et le comparent aux outils. Chaque constante SQL Rust porte un commentaire `// ref: src/services/<fichier>.ts:<ligne>`.
### 2. stdio seul
Aucun port, aucune authentification à inventer, pas de rebinding DNS depuis une page web (CVE-2025-66414 sur les serveurs MCP HTTP locaux ; les trois CVE connues de `rmcp` touchent ses transports HTTP, absents des features retenues). Tout client stdio local fonctionne avec la même commande ; Paramètres affiche celle de Claude Code et le JSON de Claude Desktop. Un hôte distant (Hermes sur le VPS) est hors scope : il demanderait Streamable HTTP, un jeton et un tunnel.
Stdout appartient au protocole : rien d'autre que `rmcp` n'y écrit, les journaux vont sur stderr. Un test lance le binaire et vérifie que le premier octet reçu ouvre un objet JSON.
### 3. Accès par profil, explicite, avec avertissement
Un drapeau `agent_access` dans `profiles.json` (`#[serde(default)]`, absent = faux), posé dans l'app par un interrupteur sur la fiche du profil. Pour tout profil, avec PIN ou non ; la saisie du PIN précède l'activation quand le profil en a un. L'avertissement dit ce qui se passe : l'agent pourra lire toutes les transactions, catégories, budgets et notes du profil, en modifier la catégorisation, et ces données seront transmises au fournisseur du modèle de cet agent ; Simpl'Résultat n'envoie rien lui-même.
Le serveur ne voit que les profils autorisés (`list_profiles`), en sélectionne un par session (`select_profile`), et **relit le drapeau à chaque appel d'outil** : désactivé dans l'app, l'appel suivant répond `profile_not_allowed` et la session se ferme.
Le PIN n'est jamais demandé par le serveur ni transmis à l'hôte.
### 4. Premium, vérifié par le même résolveur que l'app
`("mcp", &[EDITION_PREMIUM])` dans `FEATURE_TIERS`. La chaîne de résolution (dev-override → compte → clé, révocation, fail-closed) est extraite en `license_commands::entitlements_in(data_dir)`, appelée par l'app et par le serveur : un seul résolveur. Le serveur démarre et répond à `initialize` en Free ou Base, mais chaque outil répond `license_required` ; l'édition est relue à chaque appel, donc une clé saisie dans l'app prend effet sans redémarrer l'hôte. Le fichier `license.check` n'est écrit que par l'app : une révocation en ligne n'est détectée qu'au prochain démarrage de l'app.
En mode `--mcp`, le keyring est lu sans jamais créer d'entrée, toute erreur vaut « pas de compte vérifié », et l'appel est borné par un délai : un trousseau verrouillé donne `license_required`, jamais un blocage de `initialize`.
Le binaire release ignore toute variable d'environnement : les dossiers de test (`SR_MCP_DATA_DIR`, `SR_MCP_CONFIG_DIR`) ne sont lus que sous la feature `dev-override`, comme `SR_DEV_EDITION` (CWE-489).
### 5. Lecture et écritures ciblées
Lecture : profils, résumé mensuel (par type de catégorie, comme les rapports : revenus et transferts en somme signée, dépenses en valeur absolue, résultat avant et après transferts), répartition hiérarchique par catégorie, transactions filtrées et paginées, non-catégorisées, budget vs réel, catégories, règles de mots-clés. Écriture : `set_transaction_category` (feuille active et saisissable seulement, `is_manually_categorized = 1`, drapeaux en 1/0) et `create_keyword_rule` (mêmes règles que l'app : 2 à 64 caractères, priorité 100, réactivation d'une règle existante vers la même catégorie, refus vers une autre ; application aux transactions existantes par `LIKE ? ESCAPE` puis frontière de mot, en dry-run tant que `apply_to_existing` est faux). Rien d'autre : ni suppression, ni import, ni budget, ni catégorie nouvelle.
Les libellés, notes et noms de catégorie sont des données importées, jamais des consignes, et les `instructions` du serveur le disent : un libellé piégé ne peut provoquer qu'une catégorisation, réversible dans l'app.
Les refus sont des résultats d'outil (`isError: true`) avec un code stable et un libellé fixe, sans chemin ni message SQLite (le détail va sur stderr, passé par `scrub`), pour que le modèle les lise et se corrige. Les erreurs JSON-RPC sont réservées aux fautes de protocole.
### 6. Bases en WAL, cohérence avec l'app ouverte
Les bases passent en `journal_mode = WAL`, posé par l'app seule (`prepare_at` et après chaque `Database.load`) : lecteurs et écrivain ne se bloquent plus, deux écrivains se sérialisent par `busy_timeout`. Le serveur refuse une base qui n'y est pas (`profile_not_prepared`) ou dont les migrations ne sont pas au dernier niveau (`profile_not_migrated`) : il ne migre ni ne prépare jamais une base qu'il n'a pas créée. `VACUUM INTO` emporte les pages encore dans le WAL, sans checkpoint (vérifié).
L'app détecte les écritures externes au retour du focus : une connexion rusqlite sentinelle, ouverte paresseusement en `tauri::State`, lit `PRAGMA data_version`, qui est par connexion (le pool de `tauri-plugin-sql` ne convient pas). Si la valeur a changé, `notifyDataChanged()` ; la resynchronisation après une écriture interne se fait en un seul point, dans `notifyDataChanged`. Faux positif toléré, courte fenêtre de faux négatif acceptée.
**Amendement de l'ADR 0022** : §2, un nouvel émetteur, le contrôle au focus ; §5, de nouveaux abonnés, les hooks des pages que le serveur peut modifier (`useTransactions`, `useDashboard`, `useBudget`, liste des non-catégorisées), avec les règles existantes (attente de 150 ms, réponses périmées ignorées, désabonnement au démontage, un test par hook).
### 7. Ce que le serveur ne fait pas
Pas de transport HTTP, pas de resources MCP, pas d'outils Bilan, pas d'écriture hors catégorisation, pas de masquage des libellés, pas de PIN par élicitation, pas de chiffrement des bases. Chaque point est une phase 2 possible, listée dans `spec-decisions-mcp-server.md`.
## Conséquences
- **Positives** : un accès agent standard, utilisable par tout client stdio local ; un consentement explicite par profil ; une seule chaîne d'édition ; le WAL profite aussi à l'app (une lecture ne bloque plus une écriture) ; les hooks de page gagnent un rafraîchissement ciblé réutilisable au-delà du MCP.
- **Négatives** : du SQL en double, tenu par les tests de parité ; trois fichiers par base (`.db`, `-wal`, `-shm`), à dire dans le guide pour les sauvegardes à la main ; une base jamais ouverte par la version courante de l'app est refusée par le serveur ; le binaire Windows release est compilé sans console, et l'héritage des pipes de l'hôte se prouve en QA (repli : retirer `windows_subsystem` et masquer la console au démarrage GUI).
- **Privacy** : la politique de confidentialité (la-compagnie-maximus#184) doit dire que l'accès agent, une fois activé par l'utilisateur, fait transiter les données du profil par l'hôte MCP et son fournisseur de modèle.
## Alternatives écartées
- **Serveur hébergé par l'app en marche** (sidecar stdio relié par socket, pont Rust ↔ webview vers les services TS) : zéro duplication et `dataEvents` natif, mais l'app doit tourner, un binaire de plus dans `externalBin` et `release.yml`, et un pont à concevoir.
- **Hybride** (lecture directe app fermée, relais app ouverte) : deux chemins à tester ; réservé à une phase 2 si le binaire autonome montre ses limites.
- **Streamable HTTP** : un port ouvert sur les données financières, un jeton à gérer, le rebinding DNS à contrer ; aucun hôte local n'en a besoin.
- **PIN demandé par élicitation MCP** : le PIN transiterait par l'hôte, et tous les clients ne supportent pas l'élicitation.
- **Profils sans PIN seulement** : un utilisateur qui protège son profil perdrait la fonctionnalité.
- **Agrégats seulement par défaut** : catégoriser exige le libellé ; deux niveaux d'exposition à expliquer pour un gain incertain.
- **Remonter `refreshKey` au focus** : trois lignes, mais la page perdrait filtres et défilement à chaque retour.
- **`wal_checkpoint(TRUNCATE)` avant `VACUUM INTO`** : inutile (la copie lit la vue de la connexion, WAL compris) et nuisible (échoue en `SQLITE_BUSY` dès qu'un lecteur est ouvert).

View file

@ -32,19 +32,19 @@ simpl-resultat/
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
│ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState)
│ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
│ │ └── transactions/ # 5 composants
│ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
│ │ └── transactions/ # 6 composants (dont SplitButton, #369)
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
│ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
@ -75,6 +75,7 @@ simpl-resultat/
│ ├── audit.yml
│ └── release.yml
├── .github/workflows/ # Miroir GitHub (dormant)
├── scripts/ # build-latest-json.sh (latest.json de l'updater, appelé par release.yml, #320) et son test vitest
├── docs/ # Documentation technique
└── config/ # Configuration
```
@ -97,7 +98,7 @@ simpl-resultat/
| `budget_templates` | Modèles de budget réutilisables |
| `budget_template_entries` | Catégories et montants dans les modèles |
| `import_config_templates` | Modèles prédéfinis de config d'import |
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396), `onboarding_acknowledged_<clé>` (« 1 » par étape de visite cochée par « J'ai compris », #425), `onboarding_visit_steps_initialized` (« 1 » une fois les étapes de visite initialisées pour le profil, #425) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
@ -141,6 +142,17 @@ Index Bilan (9) — 7 ajoutés en v9 :
- FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE`
- FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
### Accès DB et transactions
`src/services/db.ts` fait passer **toute** opération sur la base par un verrou FIFO unique (`runExclusive`). tauri-plugin-sql charge SQLite dans un pool sqlx multi-connexions et n'expose aucune transaction côté JS : un `BEGIN` et son `COMMIT` envoyés par deux `execute` séparés pouvaient partir sur deux connexions et laisser une transaction d'écriture ouverte (le « database is locked » de la 0.10.0). Une lecture ou une écriture isolée prend le verrou le temps d'une instruction ; `withTransaction` le tient pour tout son rappel.
- **Verrou strict, non réentrant (#408).** Le rappel de `withTransaction` reçoit un **handle de transaction** (typé `Database`) qui exécute directement sur la connexion. Toute autre requête attend derrière la transaction, y compris un `getDb()` appelé depuis le rappel ou depuis une fonction qu'il appelle : celui-ci attend la transaction qui l'attend, un interblocage. Règle : dans un rappel, n'utiliser **que** le handle reçu et le passer aux fonctions appelées (par exemple `exec`, obligatoire sur `findOrCreateSecurity`). Avant #408, un drapeau global laissait passer hors verrou toute requête lancée pendant un rappel, d'où qu'elle vienne.
- **Handle révoqué.** À la fin du rappel (succès ou erreur), le handle est révoqué : un appel tardif depuis une promesse orpheline lève `TransactionHandleRevokedError` au lieu de passer hors verrou. `close()` sur le handle lève toujours `TransactionHandleCloseError`.
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule. Un `close()` de l'ancien pool qui échoue ne bloque pas la bascule : l'état est vidé et la génération incrémentée avant le `close()`, l'échec part au Journal (`logWarn`) et le profil suivant s'ouvre quand même. `closeDb()` seul se résout aussi dans ce cas, sans connexion, et tout ancien handle refuse de tourner.
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`. Son `close()` est vérifié de la même façon et lève `DbConnectionClosedError`, au lieu de fermer la connexion du profil courant. Dans le handle de transaction, la vérification est une défense, inatteignable aujourd'hui : rien ne ferme la connexion pendant qu'une transaction tient le verrou.
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. L'alerte cite un `getDb()` dans un rappel comme cause possible, pas comme diagnostic : une transaction longue (grosse restauration, migration de catégories sur un gros profil) dépasse aussi 15 s. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
- **Tests.** Seul `src/services/db.test.ts` voit le verrou (un `Database` factice qui journalise le début et la fin de chaque opération). Les tests de services mockent `db.ts` (`withTransaction: fn => fn(await getDb())`) et ne le voient pas.
## Système de migrations
Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plugin_sql::Migration` :
@ -172,17 +184,17 @@ La liste est rendue par `migrations()` (lib.rs) ; tauri-plugin-sql ne l'applique
Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profile_init_sql` : le fichier `consolidated_schema.sql`, qui porte le schéma complet, puis `history_sql`, qui inscrit toutes les versions dans `_sqlx_migrations` au format sqlx. Le plugin ne les migre jamais. Depuis #390, `prepare_profile_db` les rattrape à chaque ouverture, avant `Database.load` ([ADR 0021](adr/0021-migrations-profils-secondaires.md), `src-tauri/src/database/profile_db.rs`) :
- une base sans historique (créée avant #390) reçoit un **plancher** déduit de son schéma (`infer_floor`) : 6, 8, 10, 16 ou 17 selon la version qui l'a créée, tous rejoués en test depuis `database/fixtures/`. Aucune version au plus égale au plancher n'est rejouée ;
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases, puis supprimée avec le profil ;
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases ; elle est refaite à chaque rattrapage, une seule est gardée par profil (les plus anciennes sont supprimées après un rattrapage réussi), elle est nommée au Journal même quand le rattrapage s'arrête (`catch_up_backup`), et supprimée avec le profil (#395) ;
- chaque version s'applique dans sa transaction, clés étrangères actives comme sous sqlx. Une version qui bute sur une clé est reprise une fois sans elles, et ses lignes sans parent repassent à NULL si la clé déclare `ON DELETE SET NULL` ;
- la même commande répare dans tous les profils les drapeaux `is_manually_categorized` stockés en texte (`'true'` → 1).
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
## Services TypeScript (20)
## Services TypeScript (21)
| Service | Responsabilité |
|---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
| `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
@ -196,9 +208,10 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden` et les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les six clés) ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `hideChecklist` / `showChecklist` et `skipStep` / `resumeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » |
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `confirmService.ts` | `confirmAction(message)` : seule voie de confirmation de l'app ([#421](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/421)), par `ask` de `@tauri-apps/plugin-dialog` (icône d'avertissement, boutons `common.confirm` / `common.cancel` dans la langue de l'app). Résout `true` sur la confirmation seulement ; un refus, la fermeture de la boîte ou un échec de l'appel (consigné par `logWarn`) résolvent `false`. Toujours attendu (`await`) : sur desktop, le plugin remplace `window.confirm` par une fonction asynchrone (une `Promise`, toujours vraie) et `window.alert` par une fonction qui ne bloque pas. `src/__integration__/native-dialog-guard.test.ts` refuse tout `confirm` / `alert` natif et toute confirmation non attendue. Seul service qui traduit, par l'instance i18next partagée |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
@ -223,12 +236,12 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useCategories` | Catégories avec hiérarchie |
| `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 |
| `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) |
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
| `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prioritaires sur le snapshot d'un chargement parti avant elles, rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total) et les étapes passées ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `refresh()` |
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prioritaires sur le snapshot d'un chargement parti avant elles (`ACKNOWLEDGE` garde le verrou de carte, contrairement à `SKIP`), rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. `createVisitStepsInitializer` lance `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` une fois par fournisseur (promesse partagée), après le premier chargement réussi où `budgetReady` est vrai et le drapeau absent (`needsVisitStepsInitialization`), puis relit (#425). Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total), les étapes passées et reconnues et `visitStepsInitialized` ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`, la fin attendant `budgetReady && visitStepsInitialized`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `acknowledge()` / `refresh()` |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
@ -315,11 +328,11 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
| Page | Signal | Composant | Action proposée |
|---|---|---|---|
| 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) | « 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` |
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
**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 six lignes dans un ordre fixe. Chaque case est cochée par une **donnée**, jamais par un clic ; la source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped })` (`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é |
|---|---|
@ -327,16 +340,18 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
| Choisir un dossier d'import | préférence `import_folder` non vide |
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
| Importer un premier fichier | `COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)` ≥ 1 — un fichier qui n'a amené aucune transaction (toutes ses lignes en erreur, ou sa seule ligne d'en-tête) ne compte pas : `executeImport` écrit la ligne `completed` avant d'insérer |
| Parcourir les catégories et les mots-clés | étape de visite (#425) : préférence `onboarding_acknowledged_categories` = `"1"`, posée par « J'ai compris » sur sa carte ; verrouillée tant que `visitStepsInitialized` est faux, alors exclue du total |
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (5 au lieu de 6) et renvoyée vers `/settings/users` |
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (7 au lieu de 8) et renvoyée vers `/settings/users` |
| Parcourir les rapports | étape de visite (#425) : préférence `onboarding_acknowledged_reports` = `"1"` ; même verrou que la ligne Catégories avant l'initialisation |
`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. 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 ».
### Parcours guidé
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `acknowledge(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
**Le canal de données.** `src/services/dataEvents.ts` ([ADR 0022](adr/0022-canal-evenements-donnees.md)) : chaque écriture qui peut changer un compteur du parcours appelle `notifyDataChanged()` une fois réussie (la liste des écritures et la règle d'ajout sont dans l'ADR), et le fournisseur, seul abonné, relit son état 150 ms après la dernière écriture d'une rafale (`followDataChanges`). C'est ce qui coche une étape pendant que sa page est ouverte.
@ -345,31 +360,35 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
| Clé | Valeur | Écrite par |
|---|---|---|
| `onboarding_checklist_hidden` | `"1"` quand le parcours est masqué ; un seul état pour le bandeau et la liste. Supprimée au réaffichage | `hideChecklist` (« Masquer », et « Fermer » à la fin du parcours) et la règle D9 ; `showChecklist` la supprime |
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des six clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des huit clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
| `onboarding_guided_initialized` | `"1"` une fois la règle D9 appliquée au profil | `initializeGuidedPath` |
| `onboarding_acknowledged_<clé>` | `"1"` par étape de visite faite (#425), `<clé>` étant l'une des deux clés de `VISIT_STEP_KEYS` (`categories`, `reports`). Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `acknowledgeStep` (« J'ai compris ») et `initializeVisitSteps` |
| `onboarding_visit_steps_initialized` | `"1"` une fois les étapes de visite initialisées pour le profil (#425) | `initializeVisitSteps` |
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Le drapeau `onboarding_guided_initialized` est d'abord lu hors transaction : une fois posé, chaque ouverture suivante coûte cette lecture et n'ouvre aucune transaction. S'il manque, une transaction le relit, puis un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, et le drapeau est posé. Après le `COMMIT`, et seulement si le parcours vient d'être masqué, une ligne est écrite au Journal. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
**Le cochage d'office des étapes de visite** (#425). Une seule fois par profil, après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, le fournisseur appelle `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` (promesse partagée contre le double montage de StrictMode), puis relit. `shouldAcknowledgeVisitSteps` (pur) est vrai quand chaque ligne déverrouillée hors des étapes de visite est faite ou passée : un profil qui avait terminé le parcours les trouve faites et garde « Démarrage terminé », un profil au parcours incomplet les garde à faire. L'initialisation n'ouvre **aucune transaction** : sous le verrou strict de `db.ts` (#408), un appel de service lancé depuis le rappel d'une transaction attendrait derrière ce même verrou. Les écritures sont idempotentes, les reconnaissances d'abord, le drapeau en dernier : un arrêt entre deux refait le même calcul à l'ouverture suivante. Elle ne notifie pas le canal ([ADR 0022](adr/0022-canal-evenements-donnees.md), §5).
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
- `CARD_PAGES` (`/import`, `/transactions`, `/budget`) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
- `CARD_PAGES` (`/import`, `/categories`, `/transactions`, `/budget`, `/reports`, ces deux pages pour les étapes de visite, #432) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») et `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`) ;
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`), `categoriesCardModel` (les mots-clés, cible « Tous les mots-clés », jamais « Réinitialiser ») et `reportsCardModel(checklist, advancedLocked)` (le hub, cible la carte Tendances ; variante qui mentionne le cadenas quand les rapports avancés sont verrouillés, licence connue) ;
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady && visitStepsInitialized`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
**Le verrou de carte.** `latchCardPage(previous, { pathname, nextStep, hidden })` rend la page qui porte la carte, ou null. Il est posé quand la route courante est celle de l'étape suivante et figure dans `CARD_PAGES`. Il tient quand l'étape se coche : la carte reste pour annoncer « Fait. » et proposer la suite, même quand l'étape suivante vit sur une autre page. Il est relâché au changement de route, quand le parcours est masqué, et par « Passer » (`SKIP`) ; le rendu suivant le repose si la nouvelle étape suivante est sur la même page. La fonction est idempotente : le fournisseur la dérive pendant le rendu et range le résultat dans son reducer (`SET_CARD_PAGE`), ce qui évite l'image de bandeau qu'un effet laisserait passer sur une page à carte.
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « Passer cette étape » tant que l'étape n'est pas faite ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, et sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, une seule cible à la fois. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
**Paramètres → Utilisateurs** lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
@ -388,15 +407,17 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell, qui réutilise `UpsellGate`) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Gate de la Répartition | `src/shared/splitGate.ts`, `src/components/transactions/SplitButton.tsx` | `splitAccessFor(gate)` → `open` / `locked` / `pending` sur `useEntitlement("adjustments")`, lu par `TransactionsPage` et passé à `TransactionTable` en prop obligatoire (#369). `locked` : cadenas sur le bouton et `UpsellDialog` au lieu de la fenêtre de répartition, pour toute ligne ; `pending` (licence pas `ready`, chargement ou erreur) : ni cadenas ni fenêtre, bouton inactif, `allowed` ne court-circuitant pas `ready`. La fenêtre de répartition n'est rendue que hors `locked`, l'incitation que hors `open`. Non destructif : les répartitions existantes restent comptées et gardent leur icône orange |
| `UpsellDialog` | `src/components/shared/UpsellDialog.tsx` | `UpsellGate` dans une fenêtre (`role="dialog"`, `aria-modal`, nom accessible `upsell.title`), pour une action gatée dans une page Free ; `onNavigate` ferme la fenêtre après « J'ai déjà une clé ». Même balisage que la fenêtre d'incitation de `ProfileSwitcher`, pas encore migrée dessus |
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. 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)
### `fs_commands.rs` — Système de fichiers (7)
- `scan_import_folder` — Scan récursif de dossier pour fichiers CSV/TXT
- `scan_import_folder` — Liste les sous-dossiers directs du dossier d'import (une source chacun) avec leurs fichiers CSV/TXT directs, et les CSV/TXT posés à la racine, ignorés (`root_files`)
- `read_file_content` — Lecture avec gestion de l'encodage
- `hash_file` — Hash SHA-256 (détection de doublons)
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
@ -496,10 +517,10 @@ Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ord
|--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier ; confirmations par `ask` (`confirmService.ts`, paquet JS `@tauri-apps/plugin-dialog`, #421) | Sur desktop, son script d'initialisation remplace `window.confirm` par une fonction asynchrone et `window.alert` par une fonction non bloquante : jamais de `confirm` / `alert` natif (garde `native-dialog-guard.test.ts`) ; paquet JS à la version du crate |
| `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus)
@ -532,7 +553,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
- **Timeout au démarrage** : `App.tsx` appelle `startProfileConnection` (`src/utils/startupConnection.ts`), qui applique un timeout de 10 secondes (`STARTUP_TIMEOUT_MS`) sur `connectActiveProfile()` et réessaie trois fois — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue ; une ouverture qui aboutit après le délai efface l'erreur et affiche l'application (#395, mesure dans l'ADR 0021)
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
- **Préparation du profil** : `prepare_profile_db` (Rust/rusqlite) rattrape les migrations d'un profil secondaire et répare les drapeaux texte avant le chargement ; un échec est écrit au Journal, le profil s'ouvre quand même (#390)
@ -575,6 +596,50 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
## Tests (vitest sans jsdom)
Le dépôt n'a pas de jsdom : les tests rendent les composants par `renderToStaticMarkup`, ou appellent hooks et composants comme des fonctions pour lire l'arbre rendu et les props. Un rendu statique ne lance aucun effet et aucun clic ; ce qu'un composant fait avec React entre deux rendus passe par un faux module `react`.
### Harnais de hooks — `src/test/hookHarness.ts` (#409)
Un seul harnais pour tous les tests qui simulent React. Motif d'import, unique, en tête du fichier de test :
```ts
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
import { hookHarness } from "../test/hookHarness";
const harness = hookHarness();
```
Puis `harness.clear()` dans `beforeEach` (un montage neuf), `harness.render(() => Composant(props))` pour un rendu du composant monté, `harness.commit()` pour jouer ses effets. Les valeurs que le test change entre deux rendus (route, valeur de contexte) vivent dans un objet `vi.hoisted` du test. `hookHarness.ts` n'importe React qu'en `import type` : un import de valeur, depuis la fabrique qui construit le faux `react`, chargerait ce faux.
Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
- emplacements par ordre d'appel, curseur remis à zéro à chaque `render()` ; un test peut prérégler l'état d'un `useState` ou d'un `useReducer` avant le premier rendu ;
- `useState` : initialiseur appelé une fois, setter stable, mises à jour fonctionnelles ; **aucun nouveau rendu automatique**, le test rend de nouveau quand il veut lire l'état ;
- `useReducer` : le reducer est appliqué, `dispatch` est stable, et chaque action est aussi enregistrée par l'espion `harness.dispatch` ;
- `useRef` : un objet par emplacement, gardé d'un rendu à l'autre ;
- `useMemo` / `useCallback` : recalculés seulement quand une dépendance change (`Object.is`) ;
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
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.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
```ts
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
```
Options de la fabrique : `language` (« fr » par défaut ; une fonction est relue à chaque accès, pour un test qui change de langue entre deux rendus : `i18nMock({ language: () => language })`) et `fixture` (valeurs rendues telles quelles pour certaines clés, comme ce que déclare la locale, un objet sous `returnObjects` par exemple ; une valeur lue par la fabrique au chargement du module se déclare par `vi.hoisted`). Une fonction qui prend `t` en paramètre reçoit `markedT` lui-même.
Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque.
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
## CI/CD
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
@ -583,7 +648,9 @@ Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`tar`, réellement présent) garantit que son silence prouve quelque chose. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Juste après, une seconde étape **bloquante**, `Verify windows-sys pinning` ([#345](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/345)), lit `src-tauri/Cargo.lock` avec `awk`. Les sept crates qui déclarent `windows-sys` par une borne couvrant plusieurs mineures (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) doivent tous pointer `0.61.2`, l'état de chaque release Windows depuis v0.14.0, et l'ensemble des versions de `windows-sys` du lock ne doit pas changer. Une mise à jour comme `cargo update -p hyper` re-résout ces arêtes sans que cargo l'annonce, et le build Windows compile alors cinq versions de `windows-sys` au lieu de trois. Chaque vérification est loggée avec un bilan, un canari (`tempfile`) doit être lu et vérifié, et une panne d'`awk` fait échouer l'étape. Quand elle rougit, on remet les arêtes à la main : procédure et condition de sortie dans l'[ADR 0023](adr/0023-windows-sys-pinning.md).
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI.
@ -596,9 +663,9 @@ Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que
### `check-frontend.yml` — Vérifications frontend sur PR
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s.
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` (`paths-ignore`). `.claude/**` et `release.yml` le déclenchent ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)) : `scripts/build-latest-json.test.mjs` lit la liste de clés de `verify-release.sh` et exécute le texte de l'étape « Publish latest.json to package registry ». Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. Conséquence à connaître : `npm audit` remonte **2 high en permanence**, les deux clés d'une même advisory `react-router` ([GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2), mode React Server Components). Elle est acceptée : l'app est un client de bureau sans serveur — `App.tsx` monte un `BrowserRouter` client-only — et `react-router-dom` étant figé en 7.18.1, sortir de la plage vulnérable demanderait une migration vers react-router v8, pas un bump. Déclencheur de re-évaluation : [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
### `audit.yml` — Audit RustSec quotidien
@ -612,14 +679,17 @@ Les deux workflows `check-*` doivent être verts avant tout merge. Ils évitent
### `release.yml` — Build et publication
Déclenché par les tags `v*`. Deux jobs :
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm`
Déclenché par les tags `v*`. Un seul job, `build-and-release`, dans un conteneur `ubuntu:22.04` :
1. Linux → `.deb` + `.rpm`
2. Windows, compilé depuis Linux par `cargo-xwin` → installeur `.exe` (NSIS)
Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
- JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`.
- JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Il est appelé avec `-s` et n'accepte qu'un seul document, si bien qu'un fichier vide ou à deux documents est refusé sous jq 1.6 comme sous 1.7 ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)). Le test du script applique ce même filtre à chaque PR, compare sa liste de clés à celles du script et de `verify-release.sh`, et exécute le texte de l'étape « Publish » avec un `curl` factice (`DELETE` puis `PUT` sur un manifeste valide, aucun appel sur un manifeste refusé), si bien qu'un écart se voit avant le tag.
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur)
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).
## Architecture Decision Records (ADRs)
@ -649,3 +719,5 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
| [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted |
| [0023](adr/0023-windows-sys-pinning.md) | Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite | 2026-10-10 | Accepted |
| [0024](adr/0024-serveur-mcp.md) | Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium | 2026-10-12 | Proposed |

View file

@ -20,28 +20,30 @@ Simpl'Résultat vous aide à suivre vos finances personnelles en important des r
### Démarrage rapide
Les six étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
1. Créer un profil — chaque profil a sa propre base de données
2. Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire
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é
5. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
6. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
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
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
### Le parcours de démarrage
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/6 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
2. « Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
3. Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
2. « Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
3. Sur les pages Import, Catégories, Transactions, Budget et Rapports, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte. Sur les pages Catégories et Rapports, la carte se termine par « J'ai compris », qui coche l'étape ; sur la page Rapports, ouvrir la carte Tendances entourée la coche aussi
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
6. « Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page
7. Quand toutes les étapes sont faites ou passées, le bandeau, ou la carte de la dernière étape, annonce « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. » « Ouvrir le guide » mène à cette section Premiers pas, et « Fermer » masque le parcours
8. « Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué
9. Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5.
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. Les deux étapes de visite, « Parcourir les catégories et les mots-clés » et « Parcourir les rapports », se cochent par « J'ai compris » sur leur carte ; un profil qui avait terminé le parcours avant leur arrivée les trouve déjà faites. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 7/7.
### Astuces
@ -76,7 +78,7 @@ Gérez plusieurs profils indépendants, chacun avec sa propre base de données.
- Un profil par défaut est créé automatiquement au premier lancement
- Le NIP est demandé à chaque fois que vous accédez à un profil protégé
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). La copie est supprimée avec le profil
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). Une seule copie est gardée par profil, la plus récente : elle est refaite à chaque mise à jour de la base et supprimée avec le profil
- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main
- Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres
@ -94,7 +96,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
- Histogramme empilé des dépenses par catégorie et par mois
- Sélecteur de période ajustable (par défaut : année à ce jour)
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : six étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
### Comment faire
@ -137,12 +139,12 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
### Comment faire
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite
2. Créez un sous-dossier par compte et placez-y ses fichiers CSV : la carte de crédit et le compte chèques d'une même banque font deux sources, et les fichiers posés directement dans le dossier parent sont ignorés — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite
3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat
4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain
5. Sélectionnez les fichiers à importer
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants ; une dépense doit compter parmi les sorties, en négatif
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de ce compte se liront correctement d'eux-mêmes
8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
### Exporter un CSV depuis votre banque
@ -222,6 +224,8 @@ L'historique téléchargeable est limité à **123 jours** au maximum, et à env
- Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons
- Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions
- Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer
- Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte
- Le nom d'une source est celui de son sous-dossier
---
@ -237,7 +241,7 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
- Assignation de catégorie en ligne via menu déroulant
- Auto-catégorisation basée sur les règles de mots-clés
- Ajout de mots-clés directement depuis une transaction
- Répartition (split) d'une transaction sur plusieurs catégories
- Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)
- Notes sur les transactions
### Comment faire
@ -246,15 +250,15 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
2. Cliquez sur un en-tête de colonne pour trier par ordre croissant ou décroissant
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
5. Pour ajouter une règle de mot-clé depuis une transaction, cliquez sur l'icône + et entrez le mot-clé
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie
5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)
### Astuces
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
- Les transactions réparties affichent un indicateur visuel et le détail de la répartition
- Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)
---
@ -275,7 +279,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
### Comment faire
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel (tapez quelques lettres pour filtrer la liste des parents)
2. Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés
3. Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
@ -336,14 +340,14 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
1. Utilisez le navigateur d'année pour sélectionner l'année du budget
2. Cliquez sur une cellule de mois pour entrer un montant prévu
3. Appuyez sur Entrée pour sauvegarder, Échap pour annuler, ou Tab pour passer au mois suivant
4. Utilisez le bouton de répartition (sur la colonne Annuel) pour distribuer également sur tous les mois
4. Saisissez un montant dans la colonne Annuel pour le répartir également sur les 12 mois ; il remplace les montants mensuels de la ligne
5. Sauvegardez votre budget comme modèle pour le réutiliser les années suivantes
### Astuces
- La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas
- La colonne Annuel additionne automatiquement les 12 mois
- Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup
- Les catégories parentes affichent les sous-totaux agrégés de leurs enfants
- Seules les catégories du dernier niveau acceptent un montant ; les catégories parentes affichent les sous-totaux de leurs enfants
- Replier une catégorie est **purement visuel** : les sous-totaux et les totaux annuels sont toujours calculés sur toutes les lignes, jamais seulement sur les lignes visibles — aucun montant saisi n'est perdu quand vous repliez
---
@ -354,7 +358,7 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
### Le hub
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés.
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, cinq cartes mènent aux cinq sous-rapports dédiés.
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager.
@ -437,7 +441,7 @@ Trois modes accessibles via un tab bar :
Les trois modes ci-dessus, comme le tableau **Par catégorie** des Tendances, présentent les catégories en arbre repliable **à chaque niveau**. Chaque catégorie parente — y compris les niveaux intermédiaires — porte son propre chevron : le déplier révèle ses enfants directs (eux-mêmes repliés), de sorte que vous descendez d'un niveau à la fois. Le tableau s'ouvre **entièrement replié** (seules les catégories de premier niveau visibles) ; le bouton **« Tout déplier / Tout replier »** ouvre ou ferme tous les niveaux d'un coup. Replier une catégorie est **purement visuel** — les sous-totaux, totaux et lignes de résultat restent calculés sur l'ensemble des lignes, jamais sur les seules lignes visibles. Vos choix de repli sont **mémorisés par profil** (dans la base du profil, ils disparaissent donc avec lui).
### Rapport Zoom catégorie (`/reports/category`)
### Rapport Analyse par catégorie (`/reports/category`)
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
@ -447,10 +451,10 @@ Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclu
### Édition contextuelle des mots-clés
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
**Clic droit** sur n'importe quelle transaction (dans l'analyse par catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
2. Un sélecteur de catégorie cible
2. Un sélecteur de catégorie cible, où taper quelques lettres filtre la liste
3. Un bouton **Appliquer et recatégoriser**
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
@ -459,7 +463,7 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage)
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives)
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
- L'analyse par catégorie est **protégée contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
---
@ -618,13 +622,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|---|:---:|:---:|:---:|
| Tableau de bord | ✓ | ✓ | ✓ |
| Import CSV | ✓ | ✓ | ✓ |
| Transactions | ✓ | ✓ | ✓ |
| Transactions (sans la Répartition) | ✓ | ✓ | ✓ |
| Catégories | ✓ | ✓ | ✓ |
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré | ✓ | ✓ | ✓ |
| Journal des modifications | ✓ | ✓ | ✓ |
| Profils | 1 profil | ✓ multiples | ✓ multiples |
| Ajustements | — | ✓ | ✓ |
| Répartition des transactions | — | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
@ -632,14 +637,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
### Comment ça se présente
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan), sur les tuiles de rapports avancés du hub Rapports et sur le bouton Répartition de la page Transactions. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
### Comment faire
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition
1. Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition
2. Cliquez sur un module verrouillé pour voir l'édition requise
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
@ -662,5 +667,6 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
- En édition Gratuite, les **répartitions déjà faites restent dans vos données** : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite

View file

@ -182,8 +182,8 @@
expect: "L'installateur s'exécute sans avertissement bloquant et l'application s'ouvre sur la sélection de profil." },
{ id: "profile", title: "Créer un profil",
expect: "Le profil s'ouvre directement après sa création (NIP facultatif)." },
{ id: "dashboard-empty", title: "Tableau de bord vide avec la liste de démarrage à 1 / 6",
expect: "Une carte d'accueil remplace les graphiques, la liste montre six lignes, seule « Créer un profil » est cochée, le compteur indique 1 / 6 (ou 1 / 5 sans clé)." },
{ id: "dashboard-empty", title: "Tableau de bord vide avec la liste de démarrage à 1 / 8",
expect: "Une carte d'accueil remplace les graphiques, la liste montre huit lignes, seule « Créer un profil » est cochée, le compteur indique 1 / 8 (ou 1 / 7 sans clé)." },
{ id: "cta-folder", title: "Cliquer « Choisir un dossier d'import »",
expect: "Le bouton de la carte d'accueil mène à la page Import." },
{ id: "import-no-folder", title: "Page Import sans dossier",
@ -201,7 +201,7 @@
{ id: "budget", title: "Budget (clé Base ou Premium) : saisir un montant",
expect: "La page Budget montre la grille (pas l'état vide, puisqu'il y a des transactions) ; un montant saisi dans une cellule est conservé." },
{ id: "hide-reshow", title: "« Masquer » sur un second profil, puis réaffichage depuis Paramètres",
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 6. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 8. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
{ id: "guide-link", title: "Ouvrir le guide « Exporter un CSV depuis votre banque » depuis l'état vide d'Import",
expect: "Sur un profil sans dossier ou sans source, le lien de l'état vide ouvre le guide directement sur la sous-section, avec les procédures par banque." }
]
@ -209,26 +209,30 @@
{
id: "parcours-guide",
title: "Parcours guidé : un profil neuf de bout en bout",
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 6, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 8, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
steps: [
{ id: "guided-dashboard", title: "Tableau de bord vide sans bandeau, autres pages avec bandeau",
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/6 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/8 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 6 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 8 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 6 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte de ta banque et entoure la première source." },
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 8 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → » ; après « Terminé », elle garde « Fait. »." },
{ id: "guided-transactions", title: "Étape 5 : Transactions, entrée guidée",
expect: "Clique « Étape suivante » sur la carte Import. La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 5 sur 6 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
{ id: "guided-budget", title: "Étape 6 : Budget, « Tout déplier » puis la cellule du mois",
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 6 sur 6 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. », puis le message de fin, avec « Ouvrir le guide » et « Fermer »." },
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Parcourir les catégories et les mots-clés → » ; après « Terminé », elle garde « Fait. »." },
{ id: "guided-categories", title: "Étape 5 : la carte Catégories, « Tous les mots-clés » et « J'ai compris » (#432)",
expect: "Clique « Étape suivante » sur la carte Import. La page Catégories s'ouvre sur la carte « Étape 5 sur 8 · Parcourir les catégories et les mots-clés », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction. » Le contour entoure « Tous les mots-clés », jamais « Réinitialiser ». Ouvrir « Tous les mots-clés » ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → », et le contour disparaît. En anglais, “All Keywords” est entre “ ”." },
{ id: "guided-transactions", title: "Étape 6 : Transactions, entrée guidée",
expect: "Clique « Étape suivante » sur la carte Catégories, après « J'ai compris » (étape 5, #432). La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 6 sur 8 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
{ id: "guided-budget", title: "Étape 7 : Budget, « Tout déplier » puis la cellule du mois",
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 7 sur 8 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. » et propose « Étape suivante : Parcourir les rapports → »." },
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
{ id: "guided-reports", title: "Étape 8 : la carte Rapports, la carte Tendances et « J'ai compris » (#432)",
expect: "Clique « Étape suivante » sur la carte Budget. La page Rapports s'ouvre sur la carte « Étape 8 sur 8 · Parcourir les rapports », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». » Le contour entoure la carte Tendances, seule. Changer la période ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et le message de fin « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. », et le contour disparaît. En anglais, “Trends” est entre “ ”." },
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
expect: "Pendant les étapes 2 à 6, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
expect: "Pendant les étapes 2 à 8, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, chaque « J'ai compris », le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
expect: "Quitte la page Budget : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
expect: "Après « J'ai compris » sur la carte Rapports (étape 8, #432), quitte la page : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
]
},
{
@ -237,7 +241,7 @@
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
steps: [
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les six étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
expect: "Sur une page sans carte (Paramètres, par exemple), « Voir la liste » déplie les huit étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
@ -251,28 +255,73 @@
steps: [
{ id: "guided-d9", title: "Profil déjà actif masqué d'office, puis réaffiché (règle D9)",
expect: "Ouvre un profil qui avait des transactions avant cette version. Aucun bandeau ni carte, sur aucune page ; le Journal (Paramètres → Système) porte « Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again) ». Paramètres → Utilisateurs propose « Afficher la liste de démarrage » : un clic ramène le bandeau, avec un compteur fidèle aux données du profil. Rouvrir le profil ne le masque plus." },
{ id: "guided-free", title: "Édition Gratuite : 5 étapes, fin à 5/5",
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/5, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 5 sur 5 ») annonce « Fait. » puis le message de fin : aucune carte Budget, fin à 5/5." },
{ id: "guided-free", title: "Édition Gratuite : 7 étapes, fin à 7/7",
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/7, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 6 sur 7 ») annonce « Fait. » puis « Étape suivante : Parcourir les rapports → » : aucune carte Budget ; après « J'ai compris » sur la carte Rapports, fin à 7/7." },
{ id: "guided-noop-import", title: "Un import qui n'ajoute rien",
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 6 » (« 4 sur 5 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 8 » (« 4 sur 7 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
{ id: "guided-skip-folder", title: "Limite connue : « Passer » sur l'étape du dossier, sans dossier",
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 6 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 6 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 8 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 8 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
{ id: "guided-empty-profile", title: "Étapes d'import passées : Transactions et Budget sans transaction",
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape ». Sur la page Transactions, la carte demande de cliquer sur la catégorie d'une transaction au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Sur la page Budget, la carte « Étape 6 sur 6 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez Tout déplier… »." },
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape » (l'étape 5 sur la carte Catégories, #432). Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Passe l'étape 6 depuis cette carte. Sur la page Budget, la carte « Étape 7 sur 8 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez « Tout déplier »… »." },
{ id: "guided-fallback", title: "Transactions : aucune transaction à classer",
expect: "Sur un profil de test où l'étape 5 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Cliquez sur la catégorie d'une transaction… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
expect: "Sur un profil de test où l'étape 6 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Faites un clic droit sur une transaction, puis « Ajouter comme mot-clé »… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
{ id: "guided-same-page", title: "Limite connue : un lien d'étape suivi depuis la page Transactions",
expect: "Passe l'étape 5 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
expect: "Passe l'étape 6 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
{ id: "guided-budget-expanded", title: "Budget : grille déjà dépliée par le profil",
expect: "Sur un profil où l'étape 6 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 6 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
expect: "Sur un profil où l'étape 7 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 7 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
{ id: "guided-budget-offscreen", title: "Limite connue : cellule du mois hors de la zone visible",
expect: "En fenêtre étroite, la grille du budget défile horizontalement et la cellule du mois courant peut être hors de vue (les derniers mois de l'année sont à droite) : rien ne défile tout seul. À juger : la phrase et le contour suffisent-ils pour la trouver ?" },
{ id: "guided-budget-zero", title: "Budget : une cellule quittée vide enregistre 0 $",
expect: "Sur un profil où l'étape 6 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
expect: "Sur un profil où l'étape 7 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
{ id: "guided-import-flash", title: "Limite connue : « Aucun dossier configuré » à l'ouverture d'Import",
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
]
},
{
id: "retours-v0171",
title: "Retours de la v0.17.1 : textes et cartes du parcours",
intro: "Jalon planned-2026-10-12-retours-onboarding-v0171. Sur un profil neuf, en français, puis en anglais pour relire les mêmes phrases.",
steps: [
{ id: "v0171-import-structure", title: "Import : l'encadré « Structure attendue » (#422)",
expect: "Sans dossier choisi (« Aucun dossier d'import »), puis dans un dossier vide (« Aucune source trouvée »), l'état vide montre l'encadré « Structure attendue » au-dessus du lien « Comment exporter un CSV depuis ma banque ? » : « Dossier parent : choisissez celui-ci », dessous « Carte de crédit : une source » avec releve-2026-08.csv et releve-2026-09.csv, puis « Compte chèques : une autre source », et la note « Les fichiers posés directement dans le dossier parent sont ignorés. ». Les niveaux se lisent en retrait, dans les deux thèmes ; un lecteur d'écran lit une liste imbriquée, sans annoncer les icônes." },
{ id: "v0171-import-card-folder", title: "Import : cartes « Choisir un dossier d'import » et « Configurer une source d'import » (#422)",
expect: "Sans dossier, la carte dit : « Choisissez avec le bouton « Choisir un dossier » le dossier parent de vos relevés. Simpl'Résultat y lit un sous-dossier par compte : un pour la carte de crédit, un pour le compte chèques, et ainsi de suite. » Dans un dossier vide, elle nomme le dossier entre guillemets, demande un sous-dossier par compte et cite « Actualiser » et « Créer un dossier d'exemple » entre guillemets (« deux comptes fictifs »). Une fois les sources présentes : « Cliquez sur la carte d'un compte… ». Nulle part la carte ne parle d'un sous-dossier par banque." },
{ id: "v0171-import-card-preview", title: "Import : la carte de l'aperçu parle des signes (#422)",
expect: "À l'écran d'aperçu de l'assistant, la carte dit qu'une dépense doit compter parmi les sorties, en négatif, cite « Inverser les signes » comme correction, puis « Vérifier les doublons », chacun entre guillemets ; le contour reste sur « Vérifier les doublons ». Aux écrans suivants, « Confirmer » puis « Importer » sont aussi entre guillemets. En anglais, les mêmes libellés sont entre “ ”." },
{ id: "v0171-import-help", title: "Import : l'aide de la page et le guide (#422)",
expect: "L'aide de la page Import (icône ?) commence par « Choisissez le dossier parent de vos relevés… » et porte, après « Prévisualisez vos données… », la règle des signes avec « Inverser les signes ». Dans le guide d'utilisation (section Import), l'étape 2 dit « Créez un sous-dossier par compte… », l'étape 6 qu'une dépense doit compter parmi les sorties, et l'étape 7 parle des prochains fichiers « de ce compte »." },
{ id: "v0171-import-root-files-with-source", title: "Import : fichier posé à la racine, à côté d'une source (#427)",
expect: "Dans le dossier d'import, pose a.csv directement à la racine et crée un sous-dossier visa avec un relevé, puis « Actualiser » : la carte de la source visa s'affiche, et au-dessus d'elle un avertissement (icône triangle) dit « 1 fichier est posé directement dans « <nom du dossier> » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte. ». Le dossier est nommé par son dernier segment, jamais par son chemin." },
{ id: "v0171-import-root-files-no-source", title: "Import : trois fichiers à la racine, aucune source (#427)",
expect: "Dans un dossier sans sous-dossier mais avec trois CSV à la racine, « Actualiser » : l'état vide « Aucune source trouvée » s'affiche, et sous lui l'avertissement au pluriel nomme les trois fichiers, séparés par des virgules. Avec sept fichiers, il en nomme cinq puis « et 2 autres ». En anglais : “… sit directly in “<folder>” and are ignored: … and 2 more.”" },
{ id: "v0171-import-root-files-lifecycle", title: "Import : l'avertissement suit le dossier (#427)",
expect: "Déplace les fichiers de la racine dans un sous-dossier puis « Actualiser » : l'avertissement disparaît. Remets-en un, « Actualiser » : il revient. Ouvre une source puis « Annuler » (ou va au bout d'un import puis « Terminé ») : l'avertissement est toujours là. Dans Paramètres → Systèmes → Journal, aucune ligne ne contient le nom d'un de ces fichiers ni le chemin du dossier." },
{ id: "v0171-import-source-name", title: "Import : le nom de la source est celui de son sous-dossier (#426)",
expect: "Ouvre une source : la configuration affiche sous « Nom de la source » le nom du sous-dossier, en texte, sans champ de saisie, et dessous « Le nom de la source est celui de son sous-dossier. ». La carte de démarrage dit « Vérifiez les fichiers cochés, puis cliquez « Aperçu ». » et ne parle plus du nom. En anglais : “A source is named after its subfolder.” et “Check the selected files, then click “Preview”.”" },
{ id: "v0171-import-source-reopen", title: "Import : la source est retrouvée au prochain import (#426)",
expect: "Importe un fichier d'une source, puis « Terminé » et rouvre la même source : aucun bandeau de détection (le format enregistré est restauré tel quel), le fichier déjà importé porte le badge « Importé » et n'est pas coché. Sur une base créée avant ce correctif, une source déjà renommée garde ce défaut (hors périmètre)." },
{ id: "v0171-transactions-card-keyword", title: "Transactions : la carte propose « Ajouter comme mot-clé » (#430)",
expect: "Par le lien de l'étape 6, la carte dit : « Seules les transactions non catégorisées sont affichées. Faites un clic droit sur l'une d'elles, puis « Ajouter comme mot-clé » : le mot-clé classe cette transaction et les transactions semblables de l'aperçu, et servira aux prochains imports. Gardez un mot propre au commerçant, pas « PAIEMENT », et décochez les transactions déjà bien classées. Vous pouvez aussi cliquer sur sa catégorie et en choisir une. » Avec le filtre « Toutes », la phrase commence par « Faites un clic droit sur une transaction… » et garde la même consigne. Le contour reste sur la catégorie de la première ligne. En anglais, “Add as keyword” et “PAYMENT” sont entre “ ”." },
{ id: "v0171-transactions-keyword-checks", title: "Transactions : la boîte du mot-clé coche l'étape (#430)",
expect: "Sur un profil où l'étape 6 reste à faire, suis la carte : clic droit sur une transaction, « Ajouter comme mot-clé », remplace le mot prérempli par un mot propre au commerçant si besoin, choisis une catégorie, décoche une transaction de l'aperçu, puis « Appliquer et recatégoriser ». La carte annonce « Fait. » sans quitter la page ; la transaction décochée garde sa catégorie. La boîte elle-même n'a pas changé." },
{ id: "v0171-budget-card-leaves", title: "Budget : la carte nomme les feuilles et la colonne « Annuel » (#430)",
expect: "Grille repliée, la carte dit : « Cliquez « Tout déplier » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « Annuel » est réparti également sur les 12 mois et remplace les montants mensuels. » Après « Tout déplier », la phrase commence par « Seules les catégories du dernier niveau… » et garde la phrase sur « Annuel ». Vérifie dans la grille que l'affirmation tient : une ligne à chevron n'accepte pas de montant ; 120 $ dans « Annuel » donne 10 $ par mois et remplace les montants déjà saisis. En anglais, “Expand all” et “Annual” sont entre “ ”." },
{ id: "v0171-visit-eight-rows", title: "Liste de démarrage : huit lignes, deux étapes de visite (#425)",
expect: "Sur un profil neuf, la liste montre dans l'ordre : « Créer un profil », « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget », « Parcourir les rapports ». Le compteur dit 1 / 8 (1 / 7 sans clé, la ligne Budget verrouillée « Base et plus »). Après l'import, « Étape suivante » mène à « Parcourir les catégories et les mots-clés ». Ouvrir Catégories ou Rapports sans cliquer ne coche rien. En anglais : “Browse categories and keywords”, “Browse the reports”." },
{ id: "v0171-visit-got-it", title: "« J'ai compris » coche une étape de visite (#425, #432)",
expect: "Sur la carte d'une étape de visite (pages Catégories et Rapports), « J'ai compris » s'affiche avant « Passer cette étape ». Un clic coche l'étape : la carte reste, annonce « Fait. » et propose l'étape suivante (ou le message de fin après Rapports) ; dans la liste, la ligne est barrée, sans le badge « Passée ». « Passer cette étape » donne, lui, une ligne « Passée » avec « Reprendre ». En anglais : “Got it”." },
{ id: "v0171-reports-free-padlock", title: "Rapports : la carte mentionne le cadenas en édition Gratuite (#432)",
expect: "Sur une session ou une machine sans clé, arrive sur la carte Rapports par « Étape suivante » (« Étape 7 sur 7 »). La phrase finit par « Les cartes à cadenas demandent l'édition Base. », et les cartes Faits saillants, Comparables, Zoom catégorie et Cartes portent un cadenas ; la carte Tendances, entourée, n'en a pas. Avec une clé Base ou plus, la phrase s'arrête à « commencez par « Tendances ». ». En anglais : “Cards with a padlock require the Base edition.”" },
{ id: "v0171-reports-trends-click", title: "Rapports : ouvrir la carte Tendances entourée coche l'étape (#432)",
expect: "Sur un profil où l'étape 8 est la dernière à faire, arrive sur la carte Rapports par « Étape suivante », puis clique la carte Tendances entourée (pas « J'ai compris »). Le rapport Tendances s'ouvre, et le bandeau, en haut de cette page, annonce « Démarrage terminé. » ; dans la liste, « Parcourir les rapports » est barrée, sans le badge « Passée ». Sur un profil où une étape antérieure reste à faire, ouvre Rapports par le menu : le bandeau s'affiche, sans carte d'étape ni contour, et cliquer Tendances ne coche pas « Parcourir les rapports »." },
{ id: "v0171-visit-auto-done", title: "Profil au parcours fini : les étapes de visite sont faites d'office (#425)",
expect: "Ouvre, avec cette version, un profil dont les six anciennes étapes étaient faites ou passées et dont le parcours n'est pas masqué (il affichait « Démarrage terminé »). À aucun moment le bandeau ne passe par « Étape suivante : Parcourir les catégories et les mots-clés » : il garde « Démarrage terminé ». Dans la liste, les deux étapes de visite sont barrées. Le Journal (Paramètres → Système) porte « Onboarding: the path was already complete, visit steps marked done ». Rouvrir le profil ne change plus rien." },
{ id: "v0171-visit-incomplete", title: "Profil au parcours incomplet : les étapes de visite restent à faire (#425)",
expect: "Ouvre, avec cette version, un profil dont au moins une des six anciennes étapes restait à faire. Les deux étapes de visite sont à faire, et le restent une fois les six autres terminées : le compteur dit 6/8, et « Étape suivante » mène à la première étape de visite non faite." },
{ id: "v0171-visit-pending", title: "Limite connue : lignes de visite « en attente » avant l'initialisation (#425)",
expect: "À la toute première ouverture d'un profil avec cette version, le temps de lire la licence et d'initialiser les étapes de visite (un instant), la liste peut montrer les deux étapes de visite « en attente » (cercle pâle, lu « en attente » par un lecteur d'écran), jamais avec le badge « Base et plus », et sans compteur. Elles passent ensuite à faire ou faites. À juger si l'instant est visible." }
]
},
{
id: "parcours-guide-a11y",
title: "Parcours guidé : accessibilité et rendu",
@ -287,7 +336,7 @@
{ id: "guided-contrast", title: "Contraste du contour, thème clair et thème sombre",
expect: "Dans les deux thèmes (bouton de la barre latérale), le contour se distingue nettement du fond de la page, des cartes et du contour de focus du clavier, sur les pages Import, Transactions et Budget." },
{ id: "guided-keyboard", title: "Clavier seul",
expect: "Sans souris, de l'étape 2 à l'étape 6 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
expect: "Sans souris, de l'étape 2 à l'étape 8 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « J'ai compris », « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
{ id: "guided-focus-after-hide", title: "Limite connue : le focus après « Masquer » et « Fermer »",
expect: "Au clavier, active « Masquer » dans le bandeau, puis, sur un autre profil, « Fermer » au message de fin : le bouton disparaît avec le bandeau et le focus retombe sur la page elle-même, comme avec l'ancienne barre compacte ; note d'où Tab repart. Même chose après un lien du bandeau vers une page sans carte (« Ouvrir le guide », ligne Budget verrouillée). À juger, avec Orca puis NVDA : le lecteur d'écran perd-il le fil ?" },
{ id: "guided-orca", title: "Annonces d'Orca (Linux)",

View file

@ -1,6 +1,6 @@
# QA — Cycle de mise à jour automatique
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo test` prouvent que le chemin de mise à jour **compile**, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité **Base+**, ce sont des utilisateurs payants.
@ -12,9 +12,9 @@ Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo t
Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode d'échec où la checklist se coche « OK » sans avoir rien vérifié.
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**. Les deux flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
- [ ] Sur la machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**, plus, pour le §3, **une machine Fedora ou openSUSE** installée depuis le `.rpm`. Les flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
- [ ] Sur chaque machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` comme du `.rpm` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb`, `.rpm` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
> Sans ça, `check()` compare `release.version > version_courante`, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.
@ -27,9 +27,9 @@ Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode
À écrire explicitement, parce que la tentation est de croire qu'un cycle réussi couvre tout le chemin de mise à jour.
**Prouvé** sur les deux cibles : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
**Prouvé** sur chaque cible déroulée : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
**Non prouvé** : l'extraction d'archive, donc `tar`. Dans `tauri-plugin-updater`, `tar` n'est atteint que par `install_appimage` et la branche macOS `.app.tar.gz`. Aucun de nos bundles ne passe par là — le `.deb` fait `pkexec dpkg -i`, le NSIS écrit le `.exe` dans un répertoire temporaire et le lance. **Tant qu'on ne livre pas d'AppImage, le chemin `tar` reste mort** et aucune checklist manuelle ne le couvrira.
**Non prouvé** : l'extraction d'archive, et pour cause : depuis #455, `tauri-plugin-updater` est compilé sans sa feature `zip`, donc sans `zip` sous Windows ni `tar` sous Linux. Avec `createUpdaterArtifacts: true`, aucun de nos paquets de mise à jour n'est une archive : le NSIS écrit le `.exe` dans un répertoire temporaire et le lance, le `.deb` passe par `pkexec dpkg -i` et le `.rpm` par `pkexec rpm -U` (§3) ; une AppImage brute, si on en livrait une, serait réécrite en place par la branche « rewriting AppImage », qui ne dépend pas de la feature. Seul `createUpdaterArtifacts: "v1Compatible"` produit des archives (`.nsis.zip`, `.msi.zip`, `.AppImage.tar.gz`). **Passer à ce mode exige de rétablir la feature** (`src-tauri/Cargo.toml`) : sans elle, un zip échoue sous Windows (`InvalidUpdaterFormat`, rien n'est installé) et un `.tar.gz` serait écrit par-dessus l'AppImage sous Linux. La branche macOS `.app.tar.gz` garde `tar`, mais l'app n'est pas livrée sur macOS.
---
@ -62,11 +62,40 @@ Point d'entrée : **Paramètres → Systèmes → carte Mises à jour → « Vé
- [ ] Cliquer → état `installing`, l'app redémarre.
- [ ] Vérifier la version, re-vérifier → `upToDate`.
## 3. RPM — cassé, connu
## 3. Linux (.rpm)
- [ ] **Ne pas tester** : ce chemin est cassé, pas seulement non vérifié.
Même parcours que le §2, sur la machine Fedora ou openSUSE. L'updater y lance `pkexec rpm -U` au lieu de `pkexec dpkg -i` (`install_rpm`, même cascade polkit → zenity/kdialog → `sudo`) : mêmes états, même moment pour l'invite, même piège.
`latest.json` ne porte qu'une entrée `linux-x86_64`, construite à partir du `.deb` (`release.yml:104-122`). Une installation rpm reçoit donc des octets deb, que `Installer::Rpm` rejette après vérification de la signature magique du payload. Les artefacts `.rpm` continuent pourtant d'être publiés comme assets de release. Suivi en **#320**.
- [ ] Même point d'entrée ; la carte affiche `available` avec le **numéro de la nouvelle version**.
- [ ] **Pendant que la carte affiche encore « Téléchargement en cours… »**, une invite d'authentification apparaît. **Noter laquelle.**
Invite observée : `________________`
- [ ] Authentifier → `readyToInstall`, puis cliquer → `installing`, l'app redémarre.
- [ ] Vérifier la version, re-vérifier → `upToDate`.
> Une erreur `invalid updater binary format` sur la carte, juste après le téléchargement, est le défaut d'avant #320 : l'installation a reçu le `.deb` et l'a refusé. Vérifier que le `latest.json` publié porte la clé `linux-x86_64-rpm` (`verify-release.sh`, étape 9 de `/release`).
**Pas encore déroulé** : aucune machine Fedora ou openSUSE n'était disponible quand #320 a ajouté la clé `linux-x86_64-rpm`. Le dérouler dès qu'il y en a une ; d'ici là, l'écrire « non déroulé », avec la raison, dans la trace de la release.
Les installations `.rpm` existantes n'ont rien à réinstaller : le binaire du `.rpm` v0.17.1 publié porte le marqueur `__TAURI_BUNDLE_TYPE_VAR_RPM` (vérifié le 2026-10-10), donc il cherche `linux-x86_64-rpm` dès qu'une release publie cette clé. La première release qui la publie est la première que ces installations peuvent recevoir.
### Les clés de `latest.json`
`tauri-plugin-updater` cherche la clé `{os}-{arch}-{installer}`, puis `{os}-{arch}` (`get_urls`) ; l'installeur est le type de paquet que le bundler grave dans le binaire de chaque paquet. `scripts/build-latest-json.sh`, appelé par `release.yml`, écrit donc :
| Clé | Fichier | Lue par |
|---|---|---|
| `linux-x86_64-deb` | `.deb` | les installations `.deb` |
| `linux-x86_64-rpm` | `.rpm` | les installations `.rpm` (#320) |
| `windows-x86_64` | `-setup.exe` | les installations NSIS, qui cherchent d'abord `windows-x86_64-nsis` |
**Pas de clé générique `linux-x86_64`** (retirée le 2026-10-11, décision de Max sur #320). Toutes les versions qui embarquent l'updater (v0.2.1 et suivantes) verrouillent `tauri-plugin-updater` 2.10.0 et `tauri-utils` 2.8.2, et leurs paquets portent le marqueur de type (vérifié dans le `.deb` et le `.rpm` de la v0.17.1) : une installation `.deb` ou `.rpm` trouve sa clé avant de chercher `linux-x86_64`. Cette clé servait le `.deb`, et ses derniers lecteurs ne savaient pas l'installer :
- les AppImage des v0.4.3 à v0.4.5, publiées sur Forgejo pour la v0.4.3 et sur le miroir GitHub pour les v0.4.3 à v0.4.5. Leur updater lit le registre et cherche `linux-x86_64-appimage`, absente, puis `linux-x86_64`. La v0.4.2 vise `…/releases/latest/download/latest.json`, qui répond 404 sur cette instance Forgejo, et les versions antérieures lisent GitHub ;
- un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. C'est le cas d'un build `npm run tauri dev` sous Linux : sa carte Mises à jour affiche l'erreur `TargetsNotFound` à chaque vérification. Elle est attendue depuis le retrait de la clé, ce n'est pas une régression (#419).
Avec la clé, l'updater les traitait en AppImage (`install_appimage`) et écrivait les octets du `.deb` à la place du fichier lancé, ce qui cassait l'installation. Sans elle, leur vérification échoue avec `TargetsNotFound` et rien n'est installé : la carte Mises à jour affiche une erreur. La voie, pour ces installations, est de télécharger le `.deb` ou le `.rpm` de la dernière release.
---
@ -76,7 +105,7 @@ L'ordre compte, et la première action ne répare pas ce qu'on croit.
1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi.
**Les deux verbes ne visent pas le même préfixe d'API**, et `release.yml:217-219` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
**Les deux verbes ne visent pas le même préfixe d'API**, et l'étape « Publish latest.json to package registry » de `release.yml` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
```
DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest

187
package-lock.json generated
View file

@ -1,18 +1,19 @@
{
"name": "simpl_result_scaffold",
"version": "0.17.1",
"version": "0.17.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.17.1",
"version": "0.17.2",
"license": "GPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2",
@ -1767,6 +1768,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
@ -1980,16 +1990,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -1998,13 +2008,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2025,9 +2035,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2038,13 +2048,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@ -2052,14 +2062,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2068,9 +2078,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@ -2078,13 +2088,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -2103,9 +2113,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.41",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
"version": "2.11.28",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
"integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -2116,9 +2126,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"version": "4.29.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
"integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
"dev": true,
"funding": [
{
@ -2136,11 +2146,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.48",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.26",
"caniuse-lite": "^1.0.30001813",
"electron-to-chromium": "^1.5.439",
"node-releases": "^2.0.57",
"update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
@ -2150,9 +2160,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001800",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
"version": "1.0.30001815",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
"integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
"dev": true,
"funding": [
{
@ -2171,9 +2181,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
@ -2356,9 +2366,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.384",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
"version": "1.5.452",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz",
"integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==",
"dev": true,
"license": "ISC"
},
@ -2889,9 +2899,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
@ -2908,9 +2918,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"version": "2.0.58",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
"integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
"dev": true,
"license": "MIT",
"engines": {
@ -3071,9 +3081,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -3093,12 +3103,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
@ -3226,10 +3236,11 @@
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@ -3306,9 +3317,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@ -3340,9 +3351,9 @@
"dev": true
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
"dev": true,
"funding": [
{
@ -3475,19 +3486,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -3515,12 +3526,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -1,7 +1,7 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.17.1",
"version": "0.17.2",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2",

116
scripts/build-latest-json.sh Executable file
View file

@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Build latest.json, the updater manifest of a release, from its assets (#320).
#
# Usage: build-latest-json.sh <assets-dir> <version> <notes-file> <base-url>
#
# assets-dir directory holding the bundles and their .sig files
# version release version without the leading v, e.g. 0.17.1
# notes-file release notes, embedded verbatim as data (jq --rawfile)
# base-url download URL of the release, without a trailing slash, e.g.
# https://<forgejo>/<owner>/<repo>/releases/download/v0.17.1
#
# Writes the JSON on stdout and its log on stderr. release.yml redirects
# stdout to release-assets/latest.json; scripts/build-latest-json.test.mjs
# runs it under bash -e -o pipefail, as the CI runner does.
#
# Fails closed: unless the assets dir holds exactly one .deb, one .rpm and
# one -setup.exe, each with a non-empty "<asset>.sig" next to it, the script
# exits non-zero before writing anything on stdout. The JSON is built by jq
# alone: notes, file names and signatures enter it as jq values, never as
# text spliced into JSON.
#
# Keys. tauri-plugin-updater 2.x looks up "{os}-{arch}-{installer}" first,
# then "{os}-{arch}" (get_urls in its updater.rs). The installer is the
# bundle type that the bundler writes into the binary of each package.
#
# linux-x86_64-deb the .deb, for .deb installs.
# linux-x86_64-rpm the .rpm, for .rpm installs. Without this key they fell
# back on linux-x86_64, downloaded the .deb and refused it.
# windows-x86_64 the NSIS installer. An NSIS install looks up
# windows-x86_64-nsis first, then this key.
#
# No generic linux-x86_64 key since 2026-10-11 (Max's decision on #320). Every
# version that ships the updater (v0.2.1 and later, tauri-plugin-updater
# 2.10.0) carries its bundle-type marker, so .deb and .rpm installs never
# reach that key. Its only readers were the AppImage installs v0.4.3 to
# v0.4.5 and binaries run outside a package, and the .deb it served overwrote
# them: they now get TargetsNotFound when checking, nothing installed. See
# docs/qa-update-cycle.md, "Les clés de latest.json".
#
# Each URL ends with the file name percent-encoded by jq's @uri, as the upload
# step of release.yml encodes it: the bundle names contain a space.
set -euo pipefail
die() {
printf 'build-latest-json: %s\n' "$*" >&2
exit 1
}
[ "$#" -eq 4 ] || die "usage: build-latest-json.sh <assets-dir> <version> <notes-file> <base-url>"
assets_dir=$1
version=$2
notes_file=$3
base_url=$4
[ -d "$assets_dir" ] || die "assets dir not found: $assets_dir"
[ -n "$version" ] || die "empty version"
[ -f "$notes_file" ] || die "notes file not found: $notes_file"
[ -n "$base_url" ] || die "empty base URL"
# read_asset <kind> <matches...>: the single bundle of a kind and its
# signature, in asset_name and asset_sig. Any other count of matches, or a
# missing or blank "<asset>.sig", stops the script.
asset_name=""
asset_sig=""
read_asset() {
local kind=$1
shift
if [ "$#" -ne 1 ]; then
printf 'build-latest-json: expected exactly one %s in %s, found %d\n' "$kind" "$assets_dir" "$#" >&2
[ "$#" -eq 0 ] || printf ' %s\n' "$@" >&2
exit 1
fi
local file=$1
[ -s "$file.sig" ] || die "missing or empty signature: $file.sig"
asset_name=${file##*/}
asset_sig=$(cat "$file.sig")
[ -n "$asset_sig" ] || die "blank signature: $file.sig"
printf 'build-latest-json: %s <- %s\n' "$kind" "$asset_name" >&2
}
shopt -s nullglob
read_asset .deb "$assets_dir"/*.deb
deb_name=$asset_name
deb_sig=$asset_sig
read_asset .rpm "$assets_dir"/*.rpm
rpm_name=$asset_name
rpm_sig=$asset_sig
read_asset -setup.exe "$assets_dir"/*-setup.exe
exe_name=$asset_name
exe_sig=$asset_sig
pub_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq -n \
--arg version "$version" \
--rawfile notes "$notes_file" \
--arg pub_date "$pub_date" \
--arg base "$base_url" \
--arg deb "$deb_name" --arg deb_sig "$deb_sig" \
--arg rpm "$rpm_name" --arg rpm_sig "$rpm_sig" \
--arg exe "$exe_name" --arg exe_sig "$exe_sig" \
'def asset($name; $sig): {signature: $sig, url: ($base + "/" + ($name | @uri))};
{
version: $version,
notes: $notes,
pub_date: $pub_date,
platforms: {
"linux-x86_64-deb": asset($deb; $deb_sig),
"linux-x86_64-rpm": asset($rpm; $rpm_sig),
"windows-x86_64": asset($exe; $exe_sig)
}
}'

View file

@ -0,0 +1,462 @@
/**
* scripts/build-latest-json.sh — the updater manifest of a release (#320).
*
* release.yml runs this script to write latest.json, which every installed
* app reads to find its update. tauri-plugin-updater looks up
* "{os}-{arch}-{installer}" first, then "{os}-{arch}". Before #320 the
* manifest only had linux-x86_64 (the .deb), so an .rpm install downloaded
* the .deb and refused it. Since 2026-10-11 it has no linux-x86_64 key at
* all: its last readers (old AppImage installs, binaries run outside a
* package) could not install the .deb it served.
*
* Each test runs the script as release.yml does, under bash -e -o pipefail
* (the shell of the runner's steps), on a temporary assets directory. The
* bundle names are the real ones of v0.17.1: they contain a space, which the
* URLs must percent-encode. Expected values are literals, never rebuilt from
* the inputs.
*
* It also runs scripts/check-latest-json.jq, the filter release.yml applies
* before deleting the published manifest, so that check is exercised on every
* pull request instead of only at the tag, and compares the key lists of the
* script, of that filter and of verify-release.sh. Last, it runs the text of
* release.yml's "Publish latest.json to package registry" step against a fake
* curl, so the way the step calls the filter is tested too (#419).
*
* check-frontend.yml does not ignore .claude/** nor release.yml for that
* reason: a pull request that only touches verify-release.sh or release.yml
* still runs this file.
*
* Plain .mjs on purpose: tsconfig only covers src/, so a .ts file here would
* look type-checked without being so. Needs bash and jq on PATH;
* check-frontend.yml installs jq for this file.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "build-latest-json.sh");
const CHECK = join(HERE, "check-latest-json.jq");
const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh");
const RELEASE_YML = join(HERE, "..", ".forgejo", "workflows", "release.yml");
const BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1";
const DEB = "Simpl Resultat_0.17.1_amd64.deb";
const RPM = "Simpl Resultat-0.17.1-1.x86_64.rpm";
const EXE = "Simpl Resultat_0.17.1_x64-setup.exe";
// One distinct signature per bundle, to tell which .sig each key carries.
const SIG = {
[DEB]: "RWRkZWItc2lnbmF0dXJl",
[RPM]: "RWRycG0tc2lnbmF0dXJl",
[EXE]: "RWRleGUtc2lnbmF0dXJl",
};
let work;
let assets;
let notesFile;
function writeAsset(name, sig = SIG[name]) {
writeFileSync(join(assets, name), `bytes of ${name}\n`);
if (sig !== null) writeFileSync(join(assets, `${name}.sig`), sig);
}
function writeAllAssets() {
for (const name of [DEB, RPM, EXE]) writeAsset(name);
}
function run(args = [assets, "0.17.1", notesFile, BASE]) {
return spawnSync("bash", ["-e", "-o", "pipefail", SCRIPT, ...args], { encoding: "utf8" });
}
function expectFailure(result, message) {
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain(message);
}
// The manifest the script writes for the three bundles of v0.17.1.
function builtManifest() {
writeAllAssets();
const result = run();
expect(result.status).toBe(0);
return JSON.parse(result.stdout);
}
// Runs scripts/check-latest-json.jq with the options release.yml passes, on a
// manifest written to a file: an object is serialized, a string is written as
// is. The "release.yml Publish step" tests below run the step's own text.
function check(manifest, version = "0.17.1") {
const file = join(work, "latest.json");
writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest));
return spawnSync("jq", ["-e", "-s", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" });
}
beforeAll(() => {
const jq = spawnSync("jq", ["--version"], { encoding: "utf8" });
if (jq.status !== 0) {
throw new Error("jq not found: build-latest-json.sh needs it (check-frontend.yml installs it)");
}
});
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), "build-latest-json-"));
assets = join(work, "release-assets");
mkdirSync(assets);
notesFile = join(work, "notes.md");
writeFileSync(notesFile, "### Fixed\n\n- Something (#320).");
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
});
describe("build-latest-json.sh", () => {
it("writes the three keys, each with its own URL and its own asset's signature", () => {
writeAllAssets();
const result = run();
expect(result.status).toBe(0);
const manifest = JSON.parse(result.stdout);
expect(manifest.version).toBe("0.17.1");
expect(manifest.notes).toBe("### Fixed\n\n- Something (#320).");
expect(manifest.pub_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
// No generic linux-x86_64: an install without a key of its own must fail
// its check (TargetsNotFound) rather than receive the .deb.
expect(manifest.platforms).toStrictEqual({
"linux-x86_64-deb": {
signature: "RWRkZWItc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_amd64.deb",
},
"linux-x86_64-rpm": {
signature: "RWRycG0tc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat-0.17.1-1.x86_64.rpm",
},
"windows-x86_64": {
signature: "RWRleGUtc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe",
},
});
// The step log names the bundle picked for each kind.
expect(result.stderr).toContain("build-latest-json: .rpm <- Simpl Resultat-0.17.1-1.x86_64.rpm");
});
it.each([
[".deb", DEB],
[".rpm", RPM],
["-setup.exe", EXE],
])("fails without a %s bundle, writing nothing on stdout", (kind, missing) => {
for (const name of [DEB, RPM, EXE]) {
if (name !== missing) writeAsset(name);
}
expectFailure(run(), `expected exactly one ${kind} in ${assets}, found 0`);
});
it("fails on two .deb bundles", () => {
writeAllAssets();
writeAsset("Simpl Resultat_0.17.0_amd64.deb", "RWRvbGQtZGViLXNpZ25hdHVyZQ==");
expectFailure(run(), `expected exactly one .deb in ${assets}, found 2`);
});
it("serves Windows the NSIS -setup.exe only, not another .exe", () => {
writeAllAssets();
writeAsset("simpl-result.exe", "RWRhcHAtZXhlLXNpZ25hdHVyZQ==");
const result = run();
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).platforms["windows-x86_64"]).toStrictEqual({
signature: "RWRleGUtc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe",
});
});
it("fails on an empty .sig", () => {
writeAllAssets();
writeFileSync(join(assets, `${RPM}.sig`), "");
expectFailure(run(), `missing or empty signature: ${assets}/${RPM}.sig`);
});
it("fails on a .sig holding only line breaks", () => {
writeAllAssets();
writeFileSync(join(assets, `${EXE}.sig`), "\n\n");
expectFailure(run(), `blank signature: ${assets}/${EXE}.sig`);
});
it("fails on a missing .sig", () => {
writeAllAssets();
unlinkSync(join(assets, `${DEB}.sig`));
expectFailure(run(), `missing or empty signature: ${assets}/${DEB}.sig`);
});
it("keeps notes with quotes, backslashes and a CHANGELOG_EOF line intact", () => {
writeAllAssets();
const notes =
'### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' +
"CHANGELOG_EOF\n" +
'"version": "9.9.9", "platforms": {}\n';
writeFileSync(notesFile, notes);
const result = run();
expect(result.status).toBe(0);
const manifest = JSON.parse(result.stdout);
expect(manifest.notes).toBe(
'### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' +
"CHANGELOG_EOF\n" +
'"version": "9.9.9", "platforms": {}\n',
);
expect(manifest.version).toBe("0.17.1");
expect(Object.keys(manifest.platforms)).toHaveLength(3);
});
it("fails without its four arguments", () => {
writeAllAssets();
expectFailure(run([assets, "0.17.1", notesFile]), "usage: build-latest-json.sh");
});
// The other tests pass 0.17.1, the version the bundle names also carry: a
// script that ignored its argument would pass them all.
it("takes the version from its argument, not from the bundle names", () => {
writeAllAssets();
const result = run([assets, "0.18.0", notesFile, BASE]);
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).version).toBe("0.18.0");
});
it("fails on an empty version", () => {
writeAllAssets();
expectFailure(run([assets, "", notesFile, BASE]), "empty version");
});
});
describe("check-latest-json.jq", () => {
it("passes the script's output for the tag's version", () => {
const result = check(builtManifest());
expect(result.status).toBe(0);
expect(result.stdout).toBe("true\n");
});
it("refuses a manifest whose version is not the tag's", () => {
expect(check(builtManifest(), "0.18.0").status).not.toBe(0);
});
it.each([
["a missing key", (m) => delete m.platforms["linux-x86_64-rpm"]],
["an extra key", (m) => (m.platforms["linux-x86_64-appimage"] = m.platforms["linux-x86_64-deb"])],
["the generic linux-x86_64 key, removed on 2026-10-11", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
["the .rpm key on the .deb", (m) => (m.platforms["linux-x86_64-rpm"] = m.platforms["linux-x86_64-deb"])],
["the .deb key on the .rpm", (m) => (m.platforms["linux-x86_64-deb"] = m.platforms["linux-x86_64-rpm"])],
["the Windows key on a .sig", (m) => (m.platforms["windows-x86_64"].url += ".sig")],
["an empty signature", (m) => (m.platforms["linux-x86_64-deb"].signature = "")],
["a signature that is not a string", (m) => (m.platforms["windows-x86_64"].signature = 42)],
["a key without a URL", (m) => delete m.platforms["linux-x86_64-rpm"].url],
["a manifest without platforms", (m) => delete m.platforms],
])("refuses %s", (_, mutate) => {
const manifest = builtManifest();
mutate(manifest);
expect(check(manifest).status).not.toBe(0);
});
it("refuses the shape published up to v0.17.1, without a key per Linux package", () => {
const published = {
version: "0.17.1",
notes: "### Fixed",
pub_date: "2026-10-09T01:44:40Z",
platforms: {
"linux-x86_64": { signature: "RWRkZWItc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_amd64.deb` },
"windows-x86_64": { signature: "RWRleGUtc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_x64-setup.exe` },
},
};
expect(check(published).status).not.toBe(0);
});
it("refuses a file that is not JSON", () => {
expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0);
});
// Without -s, jq 1.6 (the CI's and the release container's) produces no
// output for an empty file, which -e lets through with status 0 (#419).
it("refuses an empty file", () => {
expect(check("").status).not.toBe(0);
});
// Without -s, jq judges each document and its exit status follows the last
// one, under 1.6 and 1.7 alike: a valid last document hid a broken first one.
it("refuses a file with two documents, even when the last one is valid", () => {
const valid = JSON.stringify(builtManifest());
expect(check(`{"version": "0.0.0"}\n${valid}\n`).status).not.toBe(0);
expect(check(`${valid}\n${valid}\n`).status).not.toBe(0);
});
});
describe("key lists", () => {
it("are the same in the script, in check-latest-json.jq and in verify-release.sh", () => {
const manifest = builtManifest();
const written = Object.keys(manifest.platforms).sort();
// The filter compares the exact key set: passing the script's output
// proves its list is the script's.
expect(check(manifest).status).toBe(0);
// verify-release.sh keeps its own list, in a Python dict literal.
const dict = readFileSync(VERIFY_RELEASE, "utf8").match(/^EXPECTED = \{([^}]*)\}/m);
expect(dict).not.toBeNull();
const expected = [...dict[1].matchAll(/"([a-z0-9_-]+)":/g)].map((m) => m[1]).sort();
expect(expected).toStrictEqual(written);
});
});
// The text of release.yml's "Publish latest.json to package registry" step,
// dedented. A whitespace-only line stays in the block; the block ends at the
// first non-blank line indented no deeper than "run:", or at the end of file.
function publishStepScript() {
const lines = readFileSync(RELEASE_YML, "utf8").split("\n");
const step = lines.findIndex((l) => /^\s*- name: Publish latest\.json to package registry\s*$/.test(l));
expect(step).toBeGreaterThan(-1);
const runAt = lines.findIndex((l, i) => i > step && /^\s*run: \|\s*$/.test(l));
expect(runAt).toBeGreaterThan(step);
const runIndent = lines[runAt].search(/\S/);
const block = [];
for (const line of lines.slice(runAt + 1)) {
if (line.trim() !== "" && line.search(/\S/) <= runIndent) break;
block.push(line);
}
while (block.length > 0 && block[block.length - 1].trim() === "") block.pop();
const indent = block[0].search(/\S/);
return block.map((l) => l.slice(indent)).join("\n") + "\n";
}
describe("release.yml Publish step", () => {
const SERVER = "https://git.example.test";
const DELETE_URL = `${SERVER}/api/v1/packages/owner/generic/simpl-resultat/latest`;
const PUT_URL = `${SERVER}/api/packages/owner/generic/simpl-resultat/latest/latest.json`;
let curlLog;
// A curl that records "METHOD URL" (and the uploaded file for a PUT) and
// prints the status code the step reads through -w: 204 to the DELETE, 201
// to the PUT. It never touches the network.
function writeFakeCurl(bin) {
const curl = join(bin, "curl");
writeFileSync(
curl,
`#!/usr/bin/env bash
method=GET url= data=
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift ;;
-H|-o|-w) shift ;;
--data-binary) data="$2"; shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
if [ "$method" = PUT ]; then
[ -f "\${data#@}" ] || { echo "fake curl: no file \${data#@}" >&2; exit 2; }
echo "PUT $url \${data#@}" >> "$CURL_LOG"
printf 201
else
echo "$method $url" >> "$CURL_LOG"
printf 204
fi
`,
);
chmodSync(curl, 0o755);
}
// Runs the step's text as the runner's default "shell: bash" does, in a
// directory laid out like the checkout: scripts/check-latest-json.jq and
// release-assets/latest.json.
function publish(manifest, tag = "v0.17.1") {
const bin = join(work, "bin");
mkdirSync(bin);
writeFakeCurl(bin);
mkdirSync(join(work, "scripts"));
copyFileSync(CHECK, join(work, "scripts", "check-latest-json.jq"));
writeFileSync(
join(assets, "latest.json"),
typeof manifest === "string" ? manifest : JSON.stringify(manifest),
);
curlLog = join(work, "curl.log");
return spawnSync("bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publishStepScript()], {
cwd: work,
encoding: "utf8",
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
CURL_LOG: curlLog,
GITHUB_REF_NAME: tag,
GITHUB_SERVER_URL: SERVER,
GITHUB_REPOSITORY_OWNER: "owner",
FORGEJO_TOKEN: "fake-token",
},
});
}
function curlCalls() {
return existsSync(curlLog) ? readFileSync(curlLog, "utf8").trim().split("\n") : [];
}
it("is extracted whole, without workflow expressions", () => {
const script = publishStepScript();
expect(script).toContain("-f scripts/check-latest-json.jq");
expect(script).toContain("-X DELETE");
expect(script).toContain("-X PUT");
// The runner would substitute them before bash runs; this test would not.
expect(script).not.toContain("${{");
});
it("deletes the published manifest, then uploads the new one", () => {
const result = publish(builtManifest());
expect(result.status).toBe(0);
expect(curlCalls()).toStrictEqual([
`DELETE ${DELETE_URL}`,
`PUT ${PUT_URL} release-assets/latest.json`,
]);
});
it.each([
["the generic linux-x86_64 key", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
["a version that is not the tag's", (m) => (m.version = "0.18.0")],
])("stops before any request on %s", (_, mutate) => {
const manifest = builtManifest();
mutate(manifest);
const result = publish(manifest);
expect(result.status).toBe(1);
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
expect(curlCalls()).toStrictEqual([]);
});
it.each([
["an empty file", () => ""],
["two documents, the last one valid", () => `{"version": "0.0.0"}\n${JSON.stringify(builtManifest())}\n`],
])("stops before any request on %s", (_, manifest) => {
const result = publish(manifest());
expect(result.status).toBe(1);
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
expect(curlCalls()).toStrictEqual([]);
});
});

View file

@ -0,0 +1,34 @@
# Check of latest.json, the updater manifest, before release.yml deletes the
# published one from the package registry (#320). Once that version is
# deleted, installed apps find no update until a valid one is uploaded, so a
# manifest that fails this check stops the job and leaves the registry as is.
#
# Run as: jq -e -s --arg version <X.Y.Z> -f scripts/check-latest-json.jq latest.json
# -e turns false, null and any jq error (missing field, wrong type, invalid
# JSON) into a non-zero exit status: the check fails closed.
# -s reads the whole file as one array of documents, and the filter wants
# exactly one (#419). Without it, jq 1.6 runs the filter once per document: an
# empty file gives no output, which -e lets through with status 0, and two
# documents are judged on the last one alone. jq 1.7 refuses the empty file
# (status 4) but still passes the two documents.
#
# The key set must stay the one scripts/build-latest-json.sh writes and
# .claude/skills/release/scripts/verify-release.sh expects.
# scripts/build-latest-json.test.mjs runs this file on the script's output and
# on broken manifests, and compares the three lists, on every pull request
# through check-frontend: a misaligned list shows before the tag, not at it.
# It also runs the text of release.yml's Publish step, which calls this file.
# Written for jq 1.6, the version ubuntu:22.04 ships.
#
# Not checked here: that each signature belongs to its own file. The script's
# test checks it before the merge, and verify-release.sh after publication
# (the file: field of the minisign trusted comment).
length == 1
and (.[0]
| .version == $version
and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
and (.platforms["windows-x86_64"].url | endswith(".exe")))

393
scripts/review-levels.py Executable file
View file

@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Replay the review levels of REVIEW.md on merged pull requests (#456).
Usage:
python3 scripts/review-levels.py [FIRST LAST] (default: 344 452)
python3 scripts/review-levels.py --check (no network)
The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else
~/.forgejo-token; read-only calls) and prints each merged PR of the range
with its trial level (pilot document, section 4.3) and its REVIEW.md level,
then the distributions for the 39 reference PRs and for the whole range.
--check compares the patterns of this script with the first column of the
Haut table of REVIEW.md and exits 1 if they differ: a PR that changes the
Niveaux section updates this script in the same diff.
The content conditions of the Haut table are approximated from the diff:
lib.rs (only generate_handler![] entries are exempt), Tauri configuration
and manifests (only the app's version is exempt), lock files (a package
added or removed, an origin changed), check workflows (a change that can
turn a failing check green, read against the PR's merge base), network
calls (reqwest in Rust, fetch to an absolute URL in TS), consent files.
"""
import itertools
import json
import os
import re
import sys
import urllib.request
API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md")
# The 39 reference PRs of the pilot document, frozen by number: the listing
# that produced them (closed PRs, limit=40, sort=recentupdate) is not stable.
REF39 = [344, 375, 377, 378, 379, 380, 381, 382, 383, 386, 387, 388, 394, 402, 403,
407, 412, 413, 414, 415, 416, 417, 418, 420, 428, 433, 434, 435, 436, 438,
439, 440, 441, 442, 443, 444, 445, 448, 452]
TRIAL_HIGH = [
"src-tauri/src/commands/{auth_commands,token_store,license_commands,entitlements,account_cache,backup_commands,fs_commands,export_import_commands}.rs",
"src-tauri/src/lib.rs", "src-tauri/src/database/**", "src-tauri/tauri.conf.json",
"src-tauri/capabilities/**", ".forgejo/workflows/**", ".cargo/**", "src-tauri/Cargo.{toml,lock}",
"package.json", "package-lock.json",
"src/services/{authService,licenseService,db,feedbackService,dataExportService}.ts",
"src/services/entitlements*", "scripts/build-latest-json*",
]
TRIAL_LOW = ["**/*.md", "docs/**", "reports/**", "tasks/**", "public/CHANGELOG*", "LICENSE"]
# REVIEW.md, table Haut: rows without a condition.
HIGH_ALWAYS = [
"src-tauri/src/database/**",
"src-tauri/src/commands/profile_commands.rs",
"src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs",
"src/services/{licenseService,authService}.ts",
".claude/skills/issue-license/**",
"src-tauri/src/commands/export_import_commands.rs",
"src-tauri/src/commands/feedback_commands.rs", "src/services/feedbackService.ts",
"src-tauri/capabilities/**",
".forgejo/workflows/release.yml", "scripts/build-latest-json*", "scripts/check-latest-json.jq",
".claude/skills/release/**",
"src-tauri/build.rs", "{vite,vitest,postcss}.config.*", ".postcssrc*", ".npmrc", "**/rust-toolchain*",
"Cargo.{toml,lock}",
"**/.cargo/**",
".claude/settings.json", ".mcp.json",
]
CONSENT_FILES = {"src/components/balance/PriceFetchControl.tsx",
"src/components/settings/PriceFetchConsentToggle.tsx"}
TAURI_CONFIG = ["src-tauri/{tauri*.conf.json*,Tauri*.toml}"]
CHECK_WORKFLOWS = {".forgejo/workflows/check-rust.yml", ".forgejo/workflows/check-frontend.yml"}
SHIPPED = ["src/**", "src-tauri/src/**", "index.html", "public/**"]
LOW = ["**/*.md", "docs/**", "LICENSE", "tasks/**", "reports/**"]
NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "docs/adr/**", "docs/architecture.md", "docs/qa-update-cycle.md", ".claude/**"]
def token():
return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip()
def get(path, raw=False):
req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()})
data = urllib.request.urlopen(req).read()
return data if raw else json.loads(data)
def expand_braces(pat):
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
return list(itertools.chain.from_iterable(
expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(",")))
def glob_re(pat):
# '**/' spans zero or more directories, '*' and '?' never cross '/'
i, out = 0, ""
while i < len(pat):
if pat.startswith("**/", i):
out += "(?:.*/)?"; i += 3; continue
if pat.startswith("**", i):
out += ".*"; i += 2; continue
c = pat[i]
out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
i += 1
return re.compile("^" + out + "$")
def match(pats, path):
# '{a,b}' alternates, wildcards allowed inside
return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat))
def file_blocks(diff):
out = {}
for block in re.split(r"(?m)^diff --git ", diff)[1:]:
out[block.split("\n", 1)[0].split(" b/")[-1]] = block
return out
def meaningful(text):
t = text.strip()
return t != "" and not t.startswith(("#", "//"))
def changed(block):
return [l for l in block.split("\n")
if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])]
def lock_adds_or_removes(name, block):
cargo = name.endswith("Cargo.lock")
lines = block.split("\n")
if cargo and any(re.match(r'^[+-]name = ', l) for l in lines):
return True
if not cargo and any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in lines):
return True
source = r'^([+-])source = "([^"]+)"' if cargo else r'^([+-])\s+"resolved": "([^"]+)"'
digest = r'^\+(checksum) = ' if cargo else r'^\+\s+"(integrity)": '
version = r'^\+version = ' if cargo else r'^\+\s+"version": '
for hunk in re.split(r"(?m)^@@", block)[1:]:
hl = hunk.split("\n")
# an origin whose host or scheme changes, with or without a new version
before, after = set(), set()
for l in hl:
m = re.match(source, l)
if m:
(before if m.group(1) == "-" else after).add(origin_of(m.group(2)))
if after - before:
return True
# a checksum or integrity that changes without a new version
if any(re.match(digest, l) for l in hl) and not any(re.match(version, l) for l in hl):
return True
return False
def origin_of(url):
m = re.match(r"^([a-z+]+)://([^/]+)", url)
return m.group(1) + "://" + m.group(2) if m else url
STEP = re.compile(r"^\s*- (name|uses):")
VERDICT = re.compile(r"npm ci|npm run build|npm test|cargo check|cargo test|cargo audit|cargo-audit|Verify", re.I)
def regions(base):
lines = base.split("\n")
region, top, job_key = {}, None, None
for i, l in enumerate(lines, 1):
m = re.match(r"^([A-Za-z_][\w-]*):", l)
if m:
top = m.group(1)
m = re.match(r"^ ([A-Za-z_][\w-]*):", l)
if m:
job_key = m.group(1)
elif re.match(r"^\S", l) or re.match(r"^ \S", l):
job_key = None
if top in ("on", "true"):
region[i] = "on"
elif top == "permissions" or job_key in ("env", "container", "permissions"):
region[i] = "jobcfg"
else:
region[i] = "other"
starts = [i for i, l in enumerate(lines, 1) if STEP.match(l)]
for k, s in enumerate(starts):
e = starts[k + 1] - 1 if k + 1 < len(starts) else len(lines)
if VERDICT.search("\n".join(lines[s - 1:e])):
for i in range(s, e + 1):
if region[i] == "other":
region[i] = "verdict"
return region, lines
def weakening(block, base):
"""Ways a check-workflow diff could turn a failing check green (empty = none)."""
region, lines = regions(base)
reasons, old, plus = set(), 0, []
def flush(pos):
texts = [p[1:] for p in plus]
active = [t for t in texts if meaningful(t)] # comments never count
if any(re.search(r"continue-on-error|\|\|\s*true", t) for t in active):
reasons.add("continue-on-error or || true added")
if any(re.search(r"GITHUB_ENV|GITHUB_PATH|npm pkg set|git checkout|git restore", t) for t in active):
reasons.add("writes the job environment or the repository")
if any(re.search(r"^\s*(- )?uses:", t) and not re.search(r"Swatinem/rust-cache|actions/cache", t) for t in active):
reasons.add("action added (not a cache)")
if any(re.search(r"rustup|toolchain|\bPATH\b|/bin/", t) for t in active):
reasons.add("install step changes the toolchain or the PATH")
if any(re.match(r"^(\s{4})?(if|defaults):", t) for t in active):
reasons.add("job-level if/defaults changed")
first = next((t for t in texts if meaningful(t)), None)
if first is None or STEP.match(first):
return
prev, nxt = region.get(pos - 1, "other"), region.get(pos, "other")
if "on" in (prev, nxt):
if any(re.search(r"branches|paths-ignore", t) for t in active):
reasons.add("trigger narrowed")
j = pos - 1
while j > 0 and not re.match(r"^\s*[\w-]+:", lines[j - 1]):
j -= 1
if j > 0 and "paths-ignore" in lines[j - 1] and any(re.match(r"^\s*- ", t) for t in active):
reasons.add("paths-ignore entry added")
elif prev == nxt == "verdict":
reasons.add("verdict step modified")
elif "jobcfg" in (prev, nxt):
reasons.add("job env/container/permissions modified")
for line in block.split("\n"):
m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line)
if m:
flush(old); plus = []
old = int(m.group(1)); continue
if line.startswith("+") and not line.startswith("+++"):
plus.append(line); continue
flush(old); plus = []
if line.startswith("-") and not line.startswith("---"):
if meaningful(line[1:]):
if re.match(r"^(\s{4})?(if|defaults):", line[1:]):
reasons.add("job-level if/defaults changed")
if re.search(r"rustup|toolchain|\bPATH\b|/bin/", line[1:]):
reasons.add("install step changes the toolchain or the PATH")
r = region.get(old, "other")
if r == "on":
reasons.add("trigger removed or changed")
elif r == "verdict":
reasons.add("verdict step modified or removed")
elif r == "jobcfg":
reasons.add("job env/container/permissions modified")
old += 1
elif line.startswith(" "):
old += 1
flush(old)
return sorted(reasons)
def adds_network_call(path, block):
if not match(SHIPPED, path) or re.search(r"test", path):
return False
for l in block.split("\n"):
if l.startswith("+") and not l.startswith("+++"):
if path.endswith(".rs") and "reqwest" in l:
return True
if re.search(r"\.(ts|tsx)$", path) and re.search(r"fetch\(\s*[`'\"]https?://", l):
return True
return False
def trial_level(paths):
if any(match(TRIAL_HIGH, p) for p in paths):
return "haut"
return "bas" if all(match(TRIAL_LOW, p) for p in paths) else "normal"
def review_level(number, files, diff, merge_base):
blocks = file_blocks(diff)
triggers = []
for f in files:
b = blocks.get(f["filename"], "")
for path in filter(None, {f["filename"], f.get("previous_filename")}):
if match(HIGH_ALWAYS, path):
triggers.append(path)
elif path == "src-tauri/src/lib.rs":
if [l for l in changed(b) if not re.match(r"^[+-]\s*commands::\w+,?\s*$", l)]:
triggers.append(path)
elif path == "src-tauri/src/commands/balance_commands.rs":
hunks = re.split(r"(?m)^(?=@@)", b)[1:]
if any(re.search(r"fetch_price|activation", h.split("\n")[0]) or
any(re.search(r"fetch_price|activation", l) for l in changed(h)) for h in hunks):
triggers.append(path)
elif match(TAURI_CONFIG, path) or path in ("src-tauri/Cargo.toml", "package.json"):
if [l for l in changed(b) if not re.match(r'^[+-]\s*"?version"?\s*[:=]', l)]:
triggers.append(path)
elif path in ("src-tauri/Cargo.lock", "package-lock.json"):
if lock_adds_or_removes(path, b):
triggers.append(path)
elif path == ".forgejo/workflows/audit.yml" and changed(b):
triggers.append(path)
elif path in CHECK_WORKFLOWS:
wf = path.split("/")[-1]
base = get(f"/raw/.forgejo/workflows/{wf}?ref={merge_base}", raw=True).decode()
if weakening(b, base):
triggers.append(path)
elif path.startswith(".forgejo/workflows/") and f["status"] == "added":
triggers.append(path + " (new workflow)")
elif path == "REVIEW.md":
triggers.append(path + " (check the « Niveaux » section by hand)")
if adds_network_call(path, b):
triggers.append(path + " (network call)")
added = [l for l in b.split("\n") if l.startswith("+") and not l.startswith("+++")]
if path.startswith(".forgejo/workflows/") and any(re.search(r"\bsecrets\b|pull_request_target", l) for l in added):
triggers.append(path + " (secrets or pull_request_target added)")
if path in CONSENT_FILES and any(re.search(r"consent", l, re.I) for l in changed(b)):
triggers.append(path + " (consent)")
parts = path.split("/")
top_level = len(parts) == 1 or (parts[0] == "src-tauri" and len(parts) == 2)
new_hidden = f["status"] == "added" and any(
x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude", ".cargo", ".github")
# Forgejo reports an edited file as "changed"
if (f["status"] not in ("changed", "modified") and top_level and not path.endswith(".md")) or new_hidden:
triggers.append(path + " (" + f["status"] + " at top level)")
paths = [f["filename"] for f in files]
if triggers:
return "haut", sorted(set(triggers))
if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths):
return "bas", []
return "normal", []
CONDITIONAL = {
"src-tauri/src/lib.rs", "src-tauri/src/commands/balance_commands.rs",
"src-tauri/Cargo.toml", "package.json", "src-tauri/Cargo.lock", "package-lock.json",
".forgejo/workflows/**", ".forgejo/workflows/audit.yml",
".forgejo/workflows/{check-rust,check-frontend}.yml", "REVIEW.md",
}
def check():
"""Compare this script's patterns with the first column of the Haut table."""
text = open(REVIEW_MD, encoding="utf-8").read()
table = text.split("### Haut", 1)[1].split("\n\n", 2)[1]
documented = set()
for row in table.split("\n"):
if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"):
continue
documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0]))
coded = set(HIGH_ALWAYS) | set(TAURI_CONFIG) | set(SHIPPED) | CONDITIONAL
for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)):
for pattern in sorted(extra):
print(f"{label}: {pattern}")
if documented != coded:
return 1
print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md")
return 0
def main():
if sys.argv[1:] == ["--check"]:
sys.exit(check())
first, last = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) == 3 else (344, 452)
merged, page = [], 1
while True:
batch = get(f"/pulls?state=closed&limit=50&page={page}")
if not batch:
break
merged += [p for p in batch if p.get("merged") and first <= p["number"] <= last]
page += 1
merged.sort(key=lambda p: p["number"])
rows = []
for p in merged:
n = p["number"]
files, fpage = [], 1
while True:
# the instance caps a page at 50 items: read until an empty page
fb = get(f"/pulls/{n}/files?limit=50&page={fpage}")
if not fb:
break
files += fb
fpage += 1
diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace")
merge_base = get(f"/pulls/{n}")["merge_base"]
trial = trial_level([f["filename"] for f in files])
level, triggers = review_level(n, files, diff, merge_base)
rows.append((n, trial, level, triggers))
print(f"#{n}\t{'ref' if n in REF39 else ' '}\t{trial}\t{level}\t{'; '.join(triggers)}")
for label, keep in (("39 reference PRs", lambda n: n in REF39), (f"{len(rows)} PRs #{first}-#{last}", lambda n: True)):
sel = [r for r in rows if keep(r[0])]
for col, name in ((1, "trial"), (2, "REVIEW.md")):
counts = {lvl: sum(1 for r in sel if r[col] == lvl) for lvl in ("bas", "normal", "haut")}
share = 100 * counts["haut"] / len(sel)
print(f"{label}, {name}: {counts} -> haut {share:.1f} %")
if __name__ == "__main__":
main()

39
src-tauri/Cargo.lock generated
View file

@ -100,15 +100,6 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
@ -935,17 +926,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@ -4049,9 +4029,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"once_cell",
"ring",
@ -4509,7 +4489,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simpl-result"
version = "0.17.1"
version = "0.17.2"
dependencies = [
"aes-gcm",
"argon2",
@ -5363,7 +5343,6 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
@ -7183,18 +7162,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.19"

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.17.1"
version = "0.17.2"
description = "Personal finance management app"
license = "GPL-3.0-only"
authors = ["you"]
@ -23,7 +23,12 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
# Built without its default `zip` feature (#455): that feature only pulls
# `zip` on Windows and `tar` on Linux to unpack archived update packages, and
# ours are never archives (`createUpdaterArtifacts: true` serves the NSIS
# `.exe`, the `.deb` and the `.rpm` as they are). Restore it only if
# `createUpdaterArtifacts` becomes "v1Compatible" (docs/qa-update-cycle.md).
tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls"] }
tauri-plugin-process = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }

View file

@ -22,14 +22,39 @@ pub struct ScannedSource {
pub files: Vec<ScannedFile>,
}
/// Result of a scan of the import folder: the sources (one per direct
/// subfolder) and the names of the statement files placed directly at the
/// root, which no source reads and the Import page names in a warning (#427).
#[derive(Debug, Serialize, Clone)]
pub struct ImportFolderScan {
pub sources: Vec<ScannedSource>,
pub root_files: Vec<String>,
}
/// True when the file name has a statement extension (`csv` or `txt`, any
/// case), the same rule for the root and for the subfolders.
fn has_statement_extension(path: &Path) -> bool {
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
ext == "csv" || ext == "txt"
}
/// Scans the import folder: each non-hidden direct subfolder is a source with
/// its direct CSV/TXT files, sorted by name. CSV/TXT files placed directly at
/// the root are read by no source; their names (non-hidden, sorted) are
/// returned in `root_files` so the Import page can warn about them (#427).
#[tauri::command]
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
pub fn scan_import_folder(folder_path: String) -> Result<ImportFolderScan, String> {
let root = Path::new(&folder_path);
if !root.is_dir() {
return Err(format!("Folder does not exist: {}", folder_path));
}
let mut sources: Vec<ScannedSource> = Vec::new();
let mut root_files: Vec<String> = Vec::new();
let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?;
@ -38,6 +63,16 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
let path = entry.path();
if !path.is_dir() {
if path.is_file() && has_statement_extension(&path) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !name.starts_with('.') {
root_files.push(name);
}
}
continue;
}
@ -60,13 +95,7 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
continue;
}
let ext = file_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if ext != "csv" && ext != "txt" {
if !has_statement_extension(file_path) {
continue;
}
@ -105,8 +134,12 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
}
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
root_files.sort();
Ok(sources)
Ok(ImportFolderScan {
sources,
root_files,
})
}
#[tauri::command]
@ -427,3 +460,94 @@ mod sample_folder_tests {
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
}
}
#[cfg(test)]
mod scan_import_folder_tests {
use super::*;
use std::path::PathBuf;
/// One scratch directory per case: the cases run in parallel in one process.
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-scan-{}-{}",
std::process::id(),
name
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("sandbox created");
dir
}
fn touch(path: &Path) {
fs::write(path, "Date;Montant\n").expect("file written");
}
fn scan(dir: &Path) -> ImportFolderScan {
scan_import_folder(dir.to_string_lossy().to_string()).expect("scan succeeds")
}
#[test]
fn lists_the_root_statement_files_sorted_by_name() {
let dir = sandbox("root-sorted");
touch(&dir.join("b.csv"));
touch(&dir.join("a.csv"));
touch(&dir.join("c.TXT"));
fs::create_dir_all(dir.join("visa")).unwrap();
touch(&dir.join("visa").join("releve.csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["a.csv", "b.csv", "c.TXT"]);
assert_eq!(result.sources.len(), 1);
assert_eq!(result.sources[0].folder_name, "visa");
assert_eq!(result.sources[0].files.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn keeps_subfolder_files_out_of_root_files() {
let dir = sandbox("subfolder-only");
fs::create_dir_all(dir.join("cheques")).unwrap();
touch(&dir.join("cheques").join("a.csv"));
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources[0].files[0].filename, "a.csv");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ignores_other_extensions_and_hidden_files_at_the_root() {
let dir = sandbox("ignored");
touch(&dir.join("notes.pdf"));
touch(&dir.join("noextension"));
touch(&dir.join(".hidden.csv"));
touch(&dir.join("kept.Csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["kept.Csv"]);
assert!(result.sources.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn returns_no_root_file_for_a_root_without_files() {
let dir = sandbox("empty-root");
fs::create_dir_all(dir.join("visa")).unwrap();
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn serializes_root_files_in_snake_case() {
let dir = sandbox("serialize");
touch(&dir.join("a.csv"));
let json = serde_json::to_value(scan(&dir)).unwrap();
assert_eq!(json["root_files"], serde_json::json!(["a.csv"]));
assert!(json["sources"].is_array());
let _ = fs::remove_dir_all(&dir);
}
}

View file

@ -160,6 +160,7 @@ fn delete_profile_files(db_path: &Path, db_filename: &str) -> Result<(), String>
profile_db::remove_backups(
&dir.join(profile_db::MIGRATION_BACKUP_DIR),
db_filename.trim_end_matches(".db"),
None,
)?;
}
Ok(())

View file

@ -75,6 +75,10 @@ pub struct ProfilePreparation {
pub catch_up: Option<CatchUp>,
/// Why the catch-up stopped, when it did. The profile still opens.
pub catch_up_error: Option<String>,
/// File name (never a path) of the copy this opening took before its
/// catch-up, set as soon as the copy exists: a catch-up that stops
/// afterwards still names it, which is when it is needed (#395).
pub catch_up_backup: Option<String>,
/// Rows whose manual-categorization flag was repaired.
pub repaired_flags: usize,
pub elapsed_ms: u64,
@ -328,7 +332,20 @@ fn apply_pending(conn: &mut Connection, pending: &[&Migration], report: &mut Cat
Ok(())
}
/// Bring a secondary profile database to the last migration.
/// `catch_up_into` returning its report, for the tests.
#[cfg(test)]
pub fn catch_up<F>(conn: &mut Connection, migrations: &[Migration], backup: F) -> Result<CatchUp, String>
where
F: FnOnce(&Connection, i64) -> Result<String, String>,
{
let mut report = CatchUp::default();
catch_up_into(conn, migrations, backup, &mut report)?;
Ok(report)
}
/// Bring a secondary profile database to the last migration, filling
/// `report` as it goes: on an error, `report` still holds what was done
/// before it, the name of the copy included (#395).
///
/// - `floor` comes from the schema itself, not from the history, so an
/// interrupted first run can never make a version below it look pending.
@ -345,7 +362,12 @@ fn apply_pending(conn: &mut Connection, pending: &[&Migration], report: &mut Cat
/// declares ON DELETE SET NULL; anything else rolls it back. The case it
/// exists for: v7 adds subcategories under category 31, which a profile
/// whose categories were restored from a newer export no longer has.
pub fn catch_up<F>(conn: &mut Connection, migrations: &[Migration], backup: F) -> Result<CatchUp, String>
pub fn catch_up_into<F>(
conn: &mut Connection,
migrations: &[Migration],
backup: F,
report: &mut CatchUp,
) -> Result<(), String>
where
F: FnOnce(&Connection, i64) -> Result<String, String>,
{
@ -376,10 +398,7 @@ where
return Err("refused_initial_migrations".to_string());
}
let mut report = CatchUp {
floor: Some(floor),
..CatchUp::default()
};
report.floor = Some(floor);
if let Some(first) = pending.first() {
report.backup_file = Some(backup(conn, first.version)?);
@ -399,13 +418,14 @@ where
}
if pending.is_empty() {
return Ok(report);
return Ok(());
}
// Foreign keys on, as sqlx runs the default profile's migrations.
conn.execute_batch("PRAGMA foreign_keys = ON")
fail_point("pragma")
.and_then(|()| conn.execute_batch("PRAGMA foreign_keys = ON").map_err(|e| e.to_string()))
.map_err(|e| format!("pragma_failed: {}", e))?;
if let Err(e) = apply_pending(conn, &pending, &mut report) {
if let Err(e) = apply_pending(conn, &pending, report) {
return Err(if report.applied.is_empty() {
e
} else {
@ -414,7 +434,24 @@ where
}
let left = violations(conn).map_err(read_err)?;
report.fk_violations = count_by_table(left.iter().map(|(table, _, _)| table.as_str()));
Ok(report)
Ok(())
}
#[cfg(test)]
thread_local! {
/// Test only: the step of `catch_up` made to fail on this thread, for the
/// one failure real SQLite offers no way to provoke (`PRAGMA foreign_keys`).
static FAIL_STEP: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
}
/// Always `Ok` outside tests.
fn fail_point(step: &'static str) -> Result<(), String> {
#[cfg(test)]
if FAIL_STEP.with(|s| s.get()) == Some(step) {
return Err(format!("injected failure at {}", step));
}
let _ = step;
Ok(())
}
/// Normalize the manual-categorization flag of every transaction stored as
@ -428,16 +465,15 @@ pub fn repair_manual_flags(conn: &Connection) -> Result<usize, String> {
}
/// Copy the database to `<dir>/<stem>-before-v<first_pending>.db` and return
/// the file name. The copy is written as `.tmp` and renamed only once
/// complete, so an existing final file is trusted as is and kept; a `.tmp`
/// left by an interrupted run is replaced. The path is bound, never formatted
/// into the SQL, so a folder such as `C:\Users\L'Heureux` works.
/// the file name. The copy is taken afresh at every catch-up (#395): a copy of
/// the same name, left by a catch-up that stopped, predates what was entered
/// since. It is written as `.tmp`, synced, then renamed over that file, which
/// is never deleted first; a `.tmp` left by an interrupted run is replaced.
/// The path is bound, never formatted into the SQL, so a folder such as
/// `C:\Users\L'Heureux` works.
pub fn backup_to(conn: &Connection, dir: &Path, stem: &str, first_pending: i64) -> Result<String, String> {
let name = format!("{}-before-v{}.db", stem, first_pending);
let final_path = dir.join(&name);
if final_path.exists() {
return Ok(name);
}
fs::create_dir_all(dir).map_err(|e| format!("backup_failed: {}", e))?;
let tmp_path = dir.join(format!("{}.tmp", name));
if tmp_path.exists() {
@ -449,8 +485,8 @@ pub fn backup_to(conn: &Connection, dir: &Path, stem: &str, first_pending: i64)
conn.execute("VACUUM INTO ?1", [tmp])
.map_err(|e| format!("backup_failed: {}", e))?;
// VACUUM INTO does not sync its output; the rename must not publish a
// copy a power cut could still truncate. Write access: Windows refuses
// to flush a file opened read-only.
// copy a power cut could still truncate, nor replace a good one with it.
// Write access: Windows refuses to flush a file opened read-only.
fs::OpenOptions::new()
.write(true)
.open(&tmp_path)
@ -496,9 +532,11 @@ pub fn scrub(message: String, dir: &Path) -> String {
message.replace(full.as_ref(), "<profile folder>")
}
/// Remove the catch-up copies of one profile (and any `.tmp` left behind).
/// Returns how many files were removed.
pub fn remove_backups(dir: &Path, stem: &str) -> Result<usize, String> {
/// Remove the catch-up copies of one profile (and any `.tmp` left behind),
/// except the file named `keep`. Returns how many files were removed.
/// `None` when the profile is deleted; `Some(copy)` after a catch-up that
/// succeeded, once its copy is renamed into place, to keep only that one.
pub fn remove_backups(dir: &Path, stem: &str, keep: Option<&str>) -> Result<usize, String> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
@ -510,6 +548,9 @@ pub fn remove_backups(dir: &Path, stem: &str) -> Result<usize, String> {
let entry = entry.map_err(|e| format!("backup_cleanup_failed: {}", e))?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if keep == Some(name) {
continue;
}
if name.starts_with(&prefix) && (name.ends_with(".db") || name.ends_with(".db.tmp")) {
fs::remove_file(entry.path()).map_err(|e| format!("backup_cleanup_failed: {}", e))?;
removed += 1;
@ -545,8 +586,27 @@ pub fn prepare_at(db_path: &Path, db_filename: &str, migrations: &[Migration]) -
if db_filename != DEFAULT_PROFILE_DB {
let backup_dir = dir.join(MIGRATION_BACKUP_DIR);
let stem = db_filename.trim_end_matches(".db");
match catch_up(&mut conn, migrations, |conn, first| backup_to(conn, &backup_dir, stem, first)) {
Ok(done) => report.catch_up = Some(done),
let mut done = CatchUp::default();
let result = catch_up_into(
&mut conn,
migrations,
|conn, first| backup_to(conn, &backup_dir, stem, first),
&mut done,
);
// Named whatever happened next: a stopped catch-up needs it most.
report.catch_up_backup = done.backup_file.clone();
match result {
Ok(()) => {
// Retention (#395): one copy per profile, the one just taken.
// Only now, the catch-up done and the copy renamed into
// place; nothing is pruned on an error or a failed copy.
if let Some(copy) = done.backup_file.as_deref() {
// Best effort: an older copy left behind harms nothing,
// and the next catch-up prunes it.
let _ = remove_backups(&backup_dir, stem, Some(copy));
}
report.catch_up = Some(done);
}
Err(e) => report.catch_up_error = Some(scrub(e, dir)),
}
}
@ -865,6 +925,7 @@ mod tests {
assert_eq!(done.fk_violations, Vec::<(String, i64)>::new(), "{}", state.name);
let expected_backup = format!("profile_0a1b2c3d-before-v{}.db", state.floor + 1);
assert_eq!(done.backup_file.as_deref(), Some(expected_backup.as_str()), "{}", state.name);
assert_eq!(report.catch_up_backup.as_deref(), Some(expected_backup.as_str()), "{}", state.name);
assert_eq!(report.repaired_flags, 1, "{}", state.name);
let conn = open(&path);
@ -1068,6 +1129,7 @@ mod tests {
assert!(done.recorded.is_empty(), "{}", state.name);
assert!(done.applied.is_empty(), "{}", state.name);
assert_eq!(done.backup_file, None, "{}", state.name);
assert_eq!(again.catch_up_backup, None, "{}", state.name);
assert_eq!(again.repaired_flags, 0, "{}", state.name);
assert_eq!(fs::read_dir(dir.join(MIGRATION_BACKUP_DIR)).unwrap().count(), 1, "{}", state.name);
}
@ -1121,14 +1183,24 @@ mod tests {
// subcategories have no parent to point at. v7 is retried without
// foreign keys, and its three rows become root categories, as the key's
// ON DELETE SET NULL declares.
//
// The profile also carries a violation older than the catch-up: a
// transaction left pointing at a category that no longer exists,
// written with foreign keys off. Its key is SET NULL too, so
// only the `before` set keeps the retry of v7 from "repairing" it
// (#395, RV7), and the final check must report it (RV4).
let dir = unique_dir("no31");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]);
open(&path)
.execute_batch(
"DELETE FROM keywords; DELETE FROM categories; \
INSERT INTO categories (id, name, type, sort_order) VALUES (1000, 'Logement', 'expense', 1);",
INSERT INTO categories (id, name, type, sort_order) VALUES (1000, 'Logement', 'expense', 1); \
PRAGMA foreign_keys = OFF; \
INSERT INTO transactions (date, description, amount, category_id) \
VALUES ('2026-02-01', 'Orphan', -10, 4242);",
)
.unwrap();
assert_eq!(violations(&open(&path)).unwrap().len(), 1, "the probe needs its pre-existing violation");
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
@ -1136,10 +1208,16 @@ mod tests {
let done = report.catch_up.unwrap();
assert_eq!(done.applied, versions(7, max_version()));
assert_eq!(done.fk_repaired, vec![("categories".to_string(), 3)]);
assert_eq!(done.fk_violations, Vec::<(String, i64)>::new());
assert_eq!(done.fk_violations, vec![("transactions".to_string(), 1)]);
let conn = open(&path);
assert_history_complete(&conn, "no31");
assert!(violations(&conn).unwrap().is_empty());
let left: Vec<String> = violations(&conn).unwrap().into_iter().map(|(table, _, _)| table).collect();
assert_eq!(left, vec!["transactions".to_string()], "only the pre-existing violation is left");
assert_eq!(
count(&conn, "SELECT category_id FROM transactions WHERE description = 'Orphan'"),
4242,
"a violation older than the catch-up is reported, never touched"
);
assert_eq!(
count(&conn, "SELECT COUNT(*) FROM categories WHERE id IN (310, 311, 312) AND parent_id IS NULL"),
3
@ -1292,18 +1370,186 @@ mod tests {
}
#[test]
fn a_complete_backup_is_kept() {
let dir = unique_dir("kept");
fn a_copy_of_the_same_name_is_refreshed() {
// Left by a catch-up that stopped at v17: it predates what was entered
// since, so the next catch-up takes the copy again (#395).
let dir = unique_dir("refreshed");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
fs::create_dir_all(&backups).unwrap();
let existing = backups.join("profile_0a1b2c3d-before-v17.db");
fs::write(&existing, b"earlier copy").unwrap();
seed_user_rows(&open(&path));
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_eq!(report.catch_up.unwrap().applied, vec![17]);
assert_eq!(fs::read(&existing).unwrap(), b"earlier copy");
assert_eq!(report.catch_up_backup.as_deref(), Some("profile_0a1b2c3d-before-v17.db"));
assert_ne!(fs::read(&existing).unwrap(), b"earlier copy");
let copy = open(&existing);
assert_eq!(count(&copy, "SELECT COUNT(*) FROM transactions"), 1, "the copy holds today's data");
assert!(!column_exists(&copy, "import_sources", "header_signature").unwrap());
assert!(!backups.join("profile_0a1b2c3d-before-v17.db.tmp").exists());
}
/// Copies an earlier catch-up left: two of this profile, one of another.
fn plant_old_copies(backups: &Path) -> Vec<&'static str> {
fs::create_dir_all(backups).unwrap();
let planted = vec![
"profile_0a1b2c3d-before-v7.db",
"profile_0a1b2c3d-before-v12.db.tmp",
"profile_99999999-before-v7.db",
];
for name in &planted {
fs::write(backups.join(name), b"old copy").unwrap();
}
planted
}
fn files_in(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
names.sort();
names
}
#[test]
fn a_successful_catch_up_keeps_only_its_own_copy() {
let dir = unique_dir("pruned");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
plant_old_copies(&backups);
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_eq!(report.catch_up_error, None);
assert_eq!(report.catch_up_backup.as_deref(), Some("profile_0a1b2c3d-before-v17.db"));
assert_eq!(
files_in(&backups),
vec!["profile_0a1b2c3d-before-v17.db", "profile_99999999-before-v7.db"],
"older copies of this profile pruned, another profile's kept"
);
assert!(has_table(&open(&backups.join("profile_0a1b2c3d-before-v17.db")), "transactions"));
}
#[test]
fn nothing_is_pruned_without_a_copy_taken() {
// Nothing pending: no copy, so no retention either.
let dir = unique_dir("no-prune-idle");
let path = create_db(&dir, SECONDARY, &[crate::database::CONSOLIDATED_SCHEMA]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let mut planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
planted.sort();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_eq!(report.catch_up_error, None);
assert_eq!(report.catch_up_backup, None);
assert_eq!(files_in(&backups), planted);
}
/// The live migrations up to v16, then a v17 that works and a v18 that
/// fails halfway.
fn list_failing_at_v18() -> Vec<Migration> {
let mut list: Vec<Migration> = migrations().into_iter().filter(|m| m.version <= 16).collect();
list.push(Migration {
version: 17,
description: "works",
sql: "CREATE TABLE ok_one (x INTEGER);",
kind: MigrationKind::Up,
});
list.push(Migration {
version: 18,
description: "breaks halfway",
sql: "CREATE TABLE half_done (x INTEGER); INSERT INTO no_such_table VALUES (1);",
kind: MigrationKind::Up,
});
list
}
/// The copy a stopped catch-up took is named in the report and still on
/// disk, and the older copies are all kept.
fn assert_stopped_after_the_copy(report: &ProfilePreparation, backups: &Path, prefix: &str, planted: &[String]) {
let error = report.catch_up_error.as_deref().unwrap();
assert!(error.starts_with(prefix), "{}", error);
assert_eq!(report.catch_up, None);
let copy = report.catch_up_backup.as_deref().unwrap();
assert_eq!(copy, "profile_0a1b2c3d-before-v17.db", "a file name, never a path");
assert!(has_table(&open(&backups.join(copy)), "transactions"));
let mut expected: Vec<String> = planted.to_vec();
expected.push(copy.to_string());
expected.sort();
assert_eq!(files_in(backups), expected, "nothing is pruned when the catch-up stops");
}
#[test]
fn a_failing_version_names_the_copy_and_prunes_nothing() {
let dir = unique_dir("stop-apply");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
let report = prepare_at(&path, SECONDARY, &list_failing_at_v18()).unwrap();
assert_stopped_after_the_copy(&report, &backups, "migration_failed:v18", &planted);
assert!(report.catch_up_error.as_deref().unwrap().ends_with("(applied before stopping: [17])"));
}
#[test]
fn a_failed_history_write_names_the_copy_and_prunes_nothing() {
// A history table that refuses every row: the copy is taken, then the
// first write of the run fails.
let dir = unique_dir("stop-history");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
open(&path)
.execute_batch(&format!(
"{} CREATE TRIGGER no_history BEFORE INSERT ON _sqlx_migrations \
BEGIN SELECT RAISE(ABORT, 'history refused'); END;",
SQLX_MIGRATIONS_DDL
))
.unwrap();
let backups = dir.join(MIGRATION_BACKUP_DIR);
let planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_stopped_after_the_copy(&report, &backups, "history_write_failed", &planted);
}
#[test]
fn a_failed_pragma_names_the_copy_and_prunes_nothing() {
let dir = unique_dir("stop-pragma");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
FAIL_STEP.with(|s| s.set(Some("pragma")));
let report = prepare_at(&path, SECONDARY, &migrations());
FAIL_STEP.with(|s| s.set(None));
assert_stopped_after_the_copy(&report.unwrap(), &backups, "pragma_failed", &planted);
assert!(!column_exists(&open(&path), "import_sources", "header_signature").unwrap());
}
#[test]
fn a_failed_copy_prunes_nothing() {
// A folder where the `.tmp` goes: removing it fails, so does the copy.
let dir = unique_dir("stop-copy");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let mut planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
fs::create_dir(backups.join("profile_0a1b2c3d-before-v17.db.tmp")).unwrap();
planted.push("profile_0a1b2c3d-before-v17.db.tmp".to_string());
planted.sort();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
let error = report.catch_up_error.unwrap();
assert!(error.starts_with("backup_failed"), "{}", error);
assert_eq!(report.catch_up_backup, None, "no copy, nothing to name");
assert_eq!(files_in(&backups), planted);
}
#[test]
@ -1363,14 +1609,24 @@ mod tests {
fs::write(dir.join(name), b"x").unwrap();
}
assert_eq!(remove_backups(&dir, "profile_0a1b2c3d"), Ok(2));
let mut left: Vec<String> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
left.sort();
assert_eq!(left, vec!["profile_0a1b2c3d-notes.txt", "profile_99999999-before-v17.db"]);
assert_eq!(remove_backups(&dir.join("absent"), "profile_0a1b2c3d"), Ok(0));
assert_eq!(remove_backups(&dir, "profile_0a1b2c3d", None), Ok(2));
assert_eq!(files_in(&dir), vec!["profile_0a1b2c3d-notes.txt", "profile_99999999-before-v17.db"]);
assert_eq!(remove_backups(&dir.join("absent"), "profile_0a1b2c3d", None), Ok(0));
}
#[test]
fn remove_backups_keeps_the_named_copy() {
let dir = unique_dir("cleanup-keep");
for name in [
"profile_0a1b2c3d-before-v7.db",
"profile_0a1b2c3d-before-v17.db",
"profile_0a1b2c3d-before-v18.db.tmp",
] {
fs::write(dir.join(name), b"x").unwrap();
}
assert_eq!(remove_backups(&dir, "profile_0a1b2c3d", Some("profile_0a1b2c3d-before-v17.db")), Ok(2));
assert_eq!(files_in(&dir), vec!["profile_0a1b2c3d-before-v17.db"]);
}
// -- the default profile, missing files and the repair ------------------
@ -1442,6 +1698,73 @@ mod tests {
assert_eq!(repair_manual_flags(&conn), Ok(0));
}
// -- the startup budget (#395) -------------------------------------------
/// How long `prepare_at` takes on a large profile at the lowest floor
/// `infer_floor` knows (6, the v0.3.0 schema): copy (`VACUUM INTO`),
/// versions 7 to 17, the final `foreign_key_check` and the flag repair,
/// all inside the startup delay of `App.tsx`. Prints, never asserts:
/// `cargo test --release -- --ignored a_large_profile_catch_up_duration --nocapture`.
/// The measure and the rule it fed are in ADR 0021 (points de vigilance).
#[test]
#[ignore]
fn a_large_profile_catch_up_duration() {
const TRANSACTIONS: i64 = 100_000;
let dir = unique_dir("large");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]);
{
let mut conn = open(&path);
seed_user_rows(&conn);
let categories: Vec<i64> = conn
.prepare("SELECT id FROM categories ORDER BY id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let tx = conn.transaction().unwrap();
{
let mut insert = tx
.prepare(
"INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, \
is_manually_categorized) VALUES (?1, ?2, ?3, ?4, 1, 1, ?5)",
)
.unwrap();
for i in 0..TRANSACTIONS {
// One in ten classified by hand while the bug stood.
let flag: rusqlite::types::Value = if i % 10 == 0 { "true".to_string().into() } else { 0_i64.into() };
insert
.execute(params![
format!("20{:02}-{:02}-{:02}", 20 + i % 7, 1 + i % 12, 1 + i % 28),
format!("PURCHASE {} STORE {}", i, i % 500),
-((i % 20_000) as f64) / 100.0,
categories[(i as usize) % categories.len()],
flag
])
.unwrap();
}
}
tx.commit().unwrap();
}
let size = fs::metadata(&path).unwrap().len();
let started = Instant::now();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
let elapsed = started.elapsed();
assert_eq!(report.catch_up_error, None);
assert_eq!(report.catch_up.as_ref().unwrap().floor, Some(6));
println!(
"prepare_at: {} transactions, {:.1} MB, floor 6 -> v{}, {} flags repaired: {} ms (report: {} ms)",
TRANSACTIONS + 1,
size as f64 / 1_048_576.0,
max_version(),
report.repaired_flags,
elapsed.as_millis(),
report.elapsed_ms
);
}
// -- the migration list itself ------------------------------------------
#[test]

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.17.1",
"version": "0.17.2",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useProfile } from "./contexts/ProfileContext";
import AppShell from "./components/layout/AppShell";
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage";
import RequireFeature from "./components/shared/RequireFeature";
const STARTUP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1_000;
import { startProfileConnection } from "./utils/startupConnection";
export default function App() {
const { t } = useTranslation();
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
const [dbReady, setDbReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
if (activeProfile && !isLoading) {
setDbReady(false);
setStartupError(null);
cancelledRef.current = false;
timeoutRef.current = setTimeout(() => {
setStartupError(t("error.startupTimeout"));
}, STARTUP_TIMEOUT_MS);
const attemptConnect = async (attempt: number): Promise<void> => {
try {
await connectActiveProfile();
if (cancelledRef.current) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setDbReady(true);
} catch (err) {
if (cancelledRef.current) return;
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
if (!cancelledRef.current) return attemptConnect(attempt + 1);
} else {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setStartupError(err instanceof Error ? err.message : String(err));
}
}
};
attemptConnect(1);
}
return () => {
cancelledRef.current = true;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
if (!activeProfile || isLoading) return;
return startProfileConnection({
connect: connectActiveProfile,
setDbReady,
setStartupError,
timeoutMessage: t("error.startupTimeout"),
});
}, [activeProfile, isLoading, connectActiveProfile, t]);
if (isLoading) {

View file

@ -0,0 +1,112 @@
/**
* Guard: one fake `t` in the test suite, the marked one of `src/test/i18nMock.ts` (#389).
*
* A local fake `t` that returns the key as is hides a forgotten call to `t`
* (#387 review). The migration moved every test file to `i18nMock`; this guard
* keeps it that way. It reads the sources, as `transactions-transfer-icon.test.ts`
* does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the
* module itself that:
* 1. declares a `useTranslation:` property (a hand-written `react-i18next` module);
* 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
* `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a
* function that takes `t`);
* 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name
* alone, in a comment for instance, is not enough: comments are dropped
* before the search).
*
* Keywords rather than an analysis of the factory: less fragile. The matches
* are anchored on a word boundary, so `useLicenseContext: () =>`,
* `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This
* file names the patterns only inside regular expressions and excludes itself.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "fs";
import { relative, resolve, sep } from "path";
const SRC = resolve(import.meta.dirname, "..");
const MODULE = resolve(SRC, "test", "i18nMock.ts");
const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts");
const LOCAL_MODULE = /\buseTranslation\s*:/;
const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/;
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/;
const CALLS_I18NMOCK = /\bi18nMock\s*\(/;
/** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The offending lines of one source, as `path:line: text`. */
function offences(path: string, source: string): string[] {
const name = relative(SRC, path).split(sep).join("/");
const found = source
.split("\n")
.flatMap((line, i) =>
LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [],
);
if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) {
found.push(`${name}: mocks react-i18next without i18nMock`);
}
return found;
}
const TEST_FILES = (readdirSync(SRC, { recursive: true }) as string[])
.filter((entry) => /\.test\.tsx?$/.test(entry))
.map((entry) => resolve(SRC, entry))
.filter((path) => path !== MODULE && path !== SELF);
describe("i18n mock guard (#389)", () => {
it("scans the test files of src, recursively", () => {
expect(TEST_FILES.length).toBeGreaterThan(100);
expect(TEST_FILES).toContain(resolve(SRC, "components", "settings", "LicenseCard.render.test.tsx"));
expect(TEST_FILES).toContain(resolve(SRC, "pages", "settings", "UsersSettingsPage.test.tsx"));
});
it("recognises a local fake, and only that", () => {
const local = [
'vi.mock("react-i18next", () => ({',
" useTranslation: () => ({ t: (k: string) => k }),",
"}));",
].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), local)).toHaveLength(2);
expect(offences(resolve(SRC, "x.test.tsx"), 'vi.mock("react-i18next", () => fake);')).toHaveLength(1);
const clean = [
'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());',
"const context = { useLicenseContext: () => ctx, get: (target: object) => target };",
"await view.removeKey({ confirm, t: markedT });",
"const at = (n: number) => n; const format = (v: string) => v;",
"if (t === (other as unknown)) return; const run = (t) => t;",
"const t = markedT; const set = { t: markedT };",
].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]);
});
it("recognises a fake t bound to a name or built with vi.fn", () => {
const x = resolve(SRC, "x.test.ts");
expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1);
expect(offences(x, " t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1);
expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1);
});
it("requires an actual call to i18nMock(, not its name in a comment", () => {
const x = resolve(SRC, "x.test.tsx");
const inComment = [
"// Not i18nMock(): a hand-written module.",
"/* i18nMock( would be the pattern */",
'vi.mock("react-i18next", () => fake);',
].join("\n");
expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]);
expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1);
const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));';
expect(offences(x, called)).toEqual([]);
});
it("finds no other fake t than the one of src/test/i18nMock.ts", () => {
const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8")));
expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]);
});
});

View file

@ -0,0 +1,148 @@
/**
* Guard: no native `confirm` or `alert` in the app, and every confirmation awaited (#421).
*
* On desktop, tauri-plugin-dialog replaces `window.confirm` with an async function and
* `window.alert` with one that does not block. `if (!confirm(...)) return;` then tests a
* Promise, always truthy, and the deletion ran before the user answered. TypeScript does not
* see it: the DOM type of `confirm` stays `boolean`, and `!promise` is not an error.
*
* It reads the non-test sources of `src/`, comments removed, as `i18n-mock-guard.test.ts` does,
* and fails on:
* 1. a native `confirm` or `alert`: called bare or through `window`, `globalThis` or `self`
* (a member call such as `deps.confirm(` is not the native one), with `?.(` too, read by
* index (`window["confirm"]`) or destructured from one of those objects;
* 2. a call to `confirmAction(` or to an injected `confirm(` (`deps.confirm(`,
* `ui.confirm(`) that is not directly preceded by `await`;
* 3. any other mention of `confirmAction`: an alias (`const c = confirmAction`) or a renamed
* import would call it out of reach of rule 2 (#465 review). Allowed besides the awaited
* call: its declaration, a plain named import, and `confirm: confirmAction`, the value of
* an injected `confirm` whose calls rule 2 checks.
* `confirmAction` itself calls the plugin's `ask`, so `src/services/confirmService.ts` is
* scanned like any other file. Keywords rather than a parse: the samples below check that each
* rule flags what it targets and lets the right form through.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "fs";
import { relative, resolve, sep } from "path";
const SRC = resolve(import.meta.dirname, "..");
const NATIVE_CALL = /(?:\b(?:window|globalThis|self)\s*\.\s*|(?<![.\w$]))(?:confirm|alert)\s*(?:\?\.\s*)?\(/;
const NATIVE_INDEX = /\b(?:window|globalThis|self)\s*(?:\?\.\s*)?\[\s*["'`](?:confirm|alert)["'`]\s*\]/;
const NATIVE_DESTRUCTURED = /\{[^}]*\b(?:confirm|alert)\b[^}]*\}\s*=\s*(?:window|globalThis|self)\b/;
// `confirmAction(` or `<receiver>.confirm(`, the receiver captured to skip the native ones.
const CONFIRM_CALL = /\b(?:confirmAction|(\w+)\s*\.\s*confirm)\s*\(/g;
const NATIVE_RECEIVER = /^(?:window|globalThis|self)$/;
const AWAITED = /\bawait\s+$/;
const DECLARED = /\bfunction\s+$/;
// Mentions of `confirmAction` that rule 3 allows; any other one is an offence.
const CONFIRM_ACTION = /\bconfirmAction\b/g;
const ALLOWED_MENTION = [
/\bawait\s+confirmAction\s*\(/g,
/\bfunction\s+confirmAction\s*\(/g,
/^\s*import\s*\{\s*confirmAction\s*\}\s*from\s/g,
/\bconfirm\s*:\s*confirmAction\s*[,}]/g,
];
/** The source without its comments, line breaks kept so that line numbers stay right. */
function withoutComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The offences of one line: a native call, or a confirmation not awaited. */
function lineOffences(line: string): string[] {
const found: string[] = [];
if (NATIVE_CALL.test(line) || NATIVE_INDEX.test(line) || NATIVE_DESTRUCTURED.test(line)) {
found.push("native confirm/alert");
}
for (const match of line.matchAll(CONFIRM_CALL)) {
if (match[1] !== undefined && NATIVE_RECEIVER.test(match[1])) continue;
const before = line.slice(0, match.index);
if (!AWAITED.test(before) && !DECLARED.test(before)) found.push("confirmation not awaited");
}
const allowed = ALLOWED_MENTION.reduce((n, rule) => n + [...line.matchAll(rule)].length, 0);
if ([...line.matchAll(CONFIRM_ACTION)].length > allowed) found.push("confirmAction out of reach");
return found;
}
/** The offending lines of one source, as `path:line: kind: text`. */
function offences(path: string, source: string): string[] {
const name = relative(SRC, path).split(sep).join("/");
return withoutComments(source)
.split("\n")
.flatMap((line, i) => lineOffences(line).map((kind) => `${name}:${i + 1}: ${kind}: ${line.trim()}`));
}
const SOURCE_FILES = (readdirSync(SRC, { recursive: true }) as string[])
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) && !entry.endsWith(".d.ts"))
.map((entry) => resolve(SRC, entry));
describe("native dialog guard (#421)", () => {
it("scans the app's sources", () => {
const names = SOURCE_FILES.map((path) => relative(SRC, path).split(sep).join("/"));
expect(names.length).toBeGreaterThan(200);
expect(names).toContain("components/profile/ProfileFormModal.tsx");
expect(names).toContain("hooks/useLicenseMachines.ts");
expect(names).toContain("services/confirmService.ts");
});
it.each([
"if (!confirm(t(\"profile.deleteConfirm\"))) return;",
"if (confirm(message)) {",
"if (!window.confirm(message)) return;",
"onClick={() => removeKey({ confirm: (m) => window.confirm(m), t })}",
"globalThis.confirm(message);",
"self . alert(message);",
"alert(message);",
"if (!window[\"confirm\"](message)) return;",
"globalThis['alert'](message);",
"if (!confirm?.(message)) return;",
"const { confirm } = window;",
"const { alert: show, confirm: ask } = globalThis;",
])("flags the native call in %s", (line) => {
expect(lineOffences(line)).toContain("native confirm/alert");
});
it.each([
"if (!confirmAction(t(\"categories.deleteConfirm\"))) return;",
"if (confirmAction(message)) {",
"if (!deps.confirm(deps.t(key))) return;",
"const ok = ui.confirm(message);",
])("flags the confirmation not awaited in %s", (line) => {
expect(lineOffences(line)).toContain("confirmation not awaited");
});
it.each([
"const c = confirmAction;",
"import { confirmAction as ask } from \"../services/confirmService\";",
"const handlers = { remove: confirmAction };",
"items.map(confirmAction);",
"if (await confirmAction(a) && check(confirmAction)) {",
])("flags confirmAction out of reach in %s", (line) => {
expect(lineOffences(line)).toContain("confirmAction out of reach");
});
it.each([
"if (!(await confirmAction(t(\"profile.deleteConfirm\")))) return;",
"if (await confirmAction(message)) {",
"if (!(await deps.confirm(deps.t(key)))) return;",
"const ok = await confirmAction(message);",
"onClick={() => void removeKey({ confirm: confirmAction, t })}",
"import { confirmAction } from \"../../services/confirmService\";",
"export async function confirmAction(message: string): Promise<boolean> {",
"if ((await confirmAction(a)) && (await confirmAction(b))) {",
"deps.confirm.mockClear();",
"const confirmed = await ask(message, { kind: \"warning\" });",
"t(\"common.confirm\")",
])("lets %s through", (line) => {
expect(lineOffences(line)).toEqual([]);
});
it("finds no native dialog and no confirmation left unawaited", () => {
const found = SOURCE_FILES.flatMap((path) => offences(path, readFileSync(path, "utf8")));
expect(found).toEqual([]);
});
});

View file

@ -3,6 +3,7 @@ import { Pencil, RefreshCw } from "lucide-react";
import type { Adjustment, Category } from "../../shared/types";
import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
import { confirmAction } from "../../services/confirmService";
import AdjustmentForm from "./AdjustmentForm";
interface Props {
@ -34,9 +35,9 @@ export default function AdjustmentDetailPanel({
}: Props) {
const { t } = useTranslation();
const handleDelete = () => {
const handleDelete = async () => {
if (!selectedAdjustment) return;
if (!confirm(t("adjustments.deleteConfirm"))) return;
if (!(await confirmAction(t("adjustments.deleteConfirm")))) return;
onDelete(selectedAdjustment.id);
};

View file

@ -27,19 +27,8 @@ vi.mock("../../services/userPreferenceService", () => ({
setPreference: vi.fn(),
}));
// react-i18next: return the key as-is for tests
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string, opts?: Record<string, unknown>) => {
// Include interpolation values in the returned string for assertions
if (opts) {
return `${key}(${JSON.stringify(opts)})`;
}
return key;
},
i18n: { language: "fr" },
})),
}));
// react-i18next: the shared marked `t` (#389), so a text that skipped `t` fails
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// lucide-react: return simple stubs
vi.mock("lucide-react", () => ({
@ -58,7 +47,8 @@ import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
import {
import { renderToStaticMarkup } from "react-dom/server";
import PriceFetchControl, {
__resetBestEffortDismissForTests,
} from "./PriceFetchControl";
@ -363,3 +353,33 @@ describe("PriceFetchControl — fetchPrice invocation args", () => {
expect(mockSetPreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: what the control displays (#389)
// ---------------------------------------------------------------------------
describe("PriceFetchControl — displayed texts", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
});
it("names the fetch button and the stock best-effort notice through t", () => {
const html = renderToStaticMarkup(
<PriceFetchControl
symbol="AAPL"
date="2026-04-25"
categoryKind="priced"
assetType="stock"
onPriceFetched={vi.fn()}
/>,
);
expect(html).toContain('aria-label="t(balance.priceFetching.button)"');
expect(html).toContain(">t(balance.priceFetching.button)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortBadge)<");
expect(html).not.toContain(">best-effort<");
expect(html).toContain('aria-label="t(common.close)"');
});
});

View file

@ -201,7 +201,7 @@ export default function PriceFetchControl({
{/* Best-effort badge (stock only) */}
{assetType === "stock" && (
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
best-effort
{t("balance.priceFetching.bestEffortBadge")}
</span>
)}
</button>

View file

@ -10,9 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
const harness = vi.hoisted(() => ({
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */

View file

@ -1,8 +1,7 @@
// BudgetTable — how the grid tells the page its target (#400).
// BudgetTable — how the grid tells the page its target (#400, #409).
//
// renderToStaticMarkup runs no effect, so the report would stay unguarded.
// React's `useState`, `useRef` and `useEffect` are stubbed (technique of
// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// grid runs as a plain function, its state and refs survive from one call to
// the next as between two renders of a mounted component, and `commit()` runs
// the effects as React does, an effect only when one of its dependencies
@ -12,50 +11,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
effects: [] as Effect[],
/** Dependencies of each effect at its last run. */
ran: new Map<number, readonly unknown[] | undefined>(),
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(),
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
const slot = (init: () => unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) harness.slots[index] = init();
return index;
};
return {
...actual,
useState: (init: unknown) => {
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
useRef: (init: unknown) => harness.slots[slot(() => ({ current: init }))],
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
const isCollapsed = (row: T) => isCollapsedFor(ctx.expanded, acc.keyOf(row), false);
return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed,
@ -64,7 +30,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
collapseAll: () => undefined,
allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
return keys.length > 0 && keys.every((key) => ctx.expanded.has(key));
},
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
};
@ -72,6 +38,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
};
});
import { hookHarness } from "../../test/hookHarness";
import BudgetTable from "./BudgetTable";
import type { BudgetOnboardingTargetKind } from "../../shared/types";
@ -89,43 +56,31 @@ const leaf = (category_id: number, parent_id: number | null = null): BudgetYearR
});
const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)];
const harness = hookHarness();
/** One render of the mounted grid: its hooks are called in the same order each time. */
function render(
rows: BudgetYearRow[],
onChange: (kind: BudgetOnboardingTargetKind | null) => void,
onboardingTarget?: boolean,
) {
harness.cursor = 0;
harness.effects = [];
BudgetTable({
rows,
onUpdatePlanned: () => undefined,
onSplitEvenly: () => undefined,
onboardingTarget,
onOnboardingTargetChange: onChange,
});
harness.render(() =>
BudgetTable({
rows,
onUpdatePlanned: () => undefined,
onSplitEvenly: () => undefined,
onboardingTarget,
onOnboardingTargetChange: onChange,
}),
);
}
/** After a render: run each effect whose dependencies changed since its last run, as React does. */
function commit() {
harness.effects.forEach((effect, index) => {
const previous = harness.ran.get(index);
const changed =
!harness.ran.has(index) ||
effect.deps === undefined ||
previous === undefined ||
effect.deps.some((dep, k) => !Object.is(dep, previous[k]));
harness.ran.set(index, effect.deps);
if (changed) effect.fn();
});
}
const commit = () => harness.commit();
beforeEach(() => {
harness.slots = [];
harness.setters = [];
harness.effects = [];
harness.ran = new Map();
harness.expanded = new Set();
harness.clear();
ctx.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
});
@ -146,7 +101,7 @@ describe("BudgetTable — the target told to the page (#400)", () => {
expect(onChange).toHaveBeenCalledTimes(1);
// The user opens the group: an editable row shows.
harness.expanded = new Set(["p:10"]);
ctx.expanded = new Set(["p:10"]);
render(GROUPED, onChange, true);
commit();
render(GROUPED, onChange, true);

View file

@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BookTemplate, Save, Trash2 } from "lucide-react";
import type { BudgetTemplate } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
interface TemplateActionsProps {
templates: BudgetTemplate[];
@ -56,9 +57,9 @@ export default function TemplateActions({
setShowSave(false);
};
const handleDelete = (e: React.MouseEvent, templateId: number) => {
const handleDelete = async (e: React.MouseEvent, templateId: number) => {
e.stopPropagation();
if (confirm(t("budget.deleteTemplateConfirm"))) {
if (await confirmAction(t("budget.deleteTemplateConfirm"))) {
onDelete(templateId);
}
};

View file

@ -0,0 +1,66 @@
// AddKeywordDialog — the category field is a combobox placed outside the
// scrolling block (#423). No jsdom: react-dom/server renders the initial
// markup, before any effect runs, in vitest's node environment.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("./keywordCategory", () => ({
loadKeywordCategories: vi.fn(() => new Promise(() => undefined)),
reconcileInitialCategory: vi.fn(() => null),
}));
vi.mock("../../services/categorizationService", () => ({
KEYWORD_MAX_LENGTH: 50,
KEYWORD_MIN_LENGTH: 3,
KEYWORD_PREVIEW_LIMIT: 50,
applyKeywordWithReassignment: vi.fn(),
previewKeywordMatches: vi.fn(() => new Promise(() => undefined)),
validateKeyword: (raw: string) => {
const value = raw.trim();
return value.length >= 3 ? { ok: true, value } : { ok: false, reason: "tooShort" };
},
}));
import AddKeywordDialog from "./AddKeywordDialog";
const markup = (initialCategoryId: number | null = null) =>
renderToStaticMarkup(
<AddKeywordDialog
initialKeyword="METRO"
initialCategoryId={initialCategoryId}
onClose={vi.fn()}
onApplied={vi.fn()}
/>,
);
describe("AddKeywordDialog — category field (#423)", () => {
it("renders the category as a combobox and no select", () => {
const html = markup();
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
expect(html).not.toContain("<select");
});
it("starts empty, with the 'no category' placeholder, until the list is in", () => {
const [input] = markup(7).match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(input).toContain('value=""');
expect(input).toContain('placeholder="t(transactions.table.noCategory)"');
});
it("keeps the apply button disabled before the list is in", () => {
const html = markup(7);
const [apply] = html.match(/<button [^>]*>t\(reports\.keyword\.applyAndRecategorize\)<\/button>/g) ?? [];
expect(apply).toBeDefined();
expect(apply).toContain("disabled");
});
it("places the combobox before any scrolling block, so its list is not clipped", () => {
const html = markup();
const at = html.indexOf('role="combobox"');
expect(at).toBeGreaterThan(0);
expect(html.slice(0, at)).not.toContain("overflow-y-auto");
// The preview block below still scrolls on its own.
expect(html.slice(at)).toContain("overflow-y-auto");
});
});

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
import type { Category, RecentTransaction } from "../../shared/types";
import {
KEYWORD_MAX_LENGTH,
KEYWORD_MIN_LENGTH,
@ -9,12 +9,8 @@ import {
previewKeywordMatches,
validateKeyword,
} from "../../services/categorizationService";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
}
import CategoryCombobox from "../shared/CategoryCombobox";
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
export interface AddKeywordDialogProps {
initialKeyword: string;
@ -37,8 +33,11 @@ export default function AddKeywordDialog({
}: AddKeywordDialogProps) {
const { t } = useTranslation();
const [keyword, setKeyword] = useState(initialKeyword);
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
const [categories, setCategories] = useState<CategoryOption[]>([]);
// Held at null until the list is in, which keeps the apply button disabled
// meanwhile: the pre-filled category is only kept when it is among the
// categories the dialog offers (`reconcileInitialCategory`).
const [categoryId, setCategoryId] = useState<number | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
@ -50,10 +49,22 @@ export default function AddKeywordDialog({
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
.catch(() => setCategories([]));
}, []);
let cancelled = false;
loadKeywordCategories()
.then((rows) => {
if (cancelled) return;
setCategories(rows);
setCategoryId(reconcileInitialCategory(initialCategoryId, rows));
})
.catch(() => {
if (cancelled) return;
setCategories([]);
setCategoryId(null);
});
return () => {
cancelled = true;
};
}, [initialCategoryId]);
useEffect(() => {
if (!validation.ok) {
@ -135,7 +146,10 @@ export default function AddKeywordDialog({
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
{/* The two fields sit outside the scrolling block, so the open category
list is drawn over the preview below instead of being cut by its
scrollport. */}
<div className="px-5 pt-4 flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")}
@ -160,19 +174,16 @@ export default function AddKeywordDialog({
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t("transactions.table.noCategory")}
/>
</label>
</div>
<div className="px-5 pb-4 pt-4 flex flex-col gap-4 overflow-y-auto min-h-0">
<section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">

View file

@ -0,0 +1,89 @@
// CategoryDetailPanel — the edited category's id reaches the parent options (#423).
// No jsdom: react-dom/server renders in vitest's node environment.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import CategoryDetailPanel from "./CategoryDetailPanel";
import { buildParentOptions } from "./parentOptions";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const node = (id: number, parent_id: number | null, name: string): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
const HOUSING = node(1, null, "Logement");
const RENT = node(10, 1, "Loyer");
const CATEGORIES = [HOUSING, RENT];
const formOf = (c: CategoryTreeNode): CategoryFormData => ({
name: c.name,
type: c.type,
color: "#4A90A4",
parent_id: c.parent_id,
is_inputable: c.is_inputable,
sort_order: c.sort_order,
});
const markup = (selected: CategoryTreeNode | null, editing: CategoryFormData | null, isCreating: boolean) =>
renderToStaticMarkup(
<CategoryDetailPanel
categories={CATEGORIES}
selectedCategory={selected}
keywords={[]}
editingCategory={editing}
isCreating={isCreating}
isSaving={false}
onStartEditing={vi.fn()}
onCancelEditing={vi.fn()}
onSave={vi.fn()}
onDelete={vi.fn(async () => ({ blocked: false, count: 0 }))}
onAddKeyword={vi.fn()}
onUpdateKeyword={vi.fn()}
onRemoveKeyword={vi.fn()}
/>,
);
describe("CategoryDetailPanel — editingId wiring (#423)", () => {
it("passes the selected category's id when editing", () => {
vi.mocked(buildParentOptions).mockClear();
const html = markup(RENT, formOf(RENT), false);
expect(html).toContain('role="combobox"');
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
});
it("passes no id when creating", () => {
vi.mocked(buildParentOptions).mockClear();
const html = markup(null, formOf(node(0, null, "")), true);
expect(html).toContain('role="combobox"');
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
});
});
describe("CategoryDetailPanel — displayed texts (#389)", () => {
it("titles the edit view and the create view through t", () => {
expect(markup(RENT, formOf(RENT), false)).toContain(">t(categories.editCategory)<");
expect(markup(null, formOf(node(0, null, "")), true)).toContain(">t(categories.addCategory)<");
});
it("asks for a selection through t when nothing is selected", () => {
expect(markup(null, null, false)).toContain(">t(categories.selectCategory)<");
});
});

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil } from "lucide-react";
import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
import CategoryForm from "./CategoryForm";
import KeywordList from "./KeywordList";
@ -41,7 +42,7 @@ export default function CategoryDetailPanel({
const handleDelete = async () => {
if (!selectedCategory) return;
if (!confirm(t("categories.deleteConfirm"))) return;
if (!(await confirmAction(t("categories.deleteConfirm")))) return;
setDeleteError(null);
const result = await onDelete(selectedCategory.id);
if (result.blocked) {
@ -90,6 +91,7 @@ export default function CategoryDetailPanel({
<CategoryForm
initialData={editingCategory}
categories={categories}
editingId={selectedCategory.id}
isCreating={false}
isSaving={isSaving}
onSave={onSave}

View file

@ -0,0 +1,108 @@
// CategoryForm — the parent field is a combobox, not a select (#423). No jsdom:
// react-dom/server renders in vitest's node environment; the markup says what
// the closed combobox shows as its value.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// Pass-through spy on the pure module, to pin what the form hands it.
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import CategoryForm from "./CategoryForm";
import { buildParentOptions } from "./parentOptions";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const node = (
id: number,
parent_id: number | null,
name: string,
i18n_key: string | null = null,
): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key,
children: [],
});
const HOUSING = node(1, null, "Logement", "categoriesSeed.housing");
const RENT = node(10, 1, "Loyer");
const CATEGORIES = [HOUSING, RENT];
const form = (parent_id: number | null): CategoryFormData => ({
name: "Taxes",
type: "expense",
color: "#4A90A4",
parent_id,
is_inputable: true,
sort_order: 0,
});
const markup = (data: CategoryFormData, editingId?: number) =>
renderToStaticMarkup(
<CategoryForm
initialData={data}
categories={CATEGORIES}
editingId={editingId}
isCreating={editingId === undefined}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);
/** The parent combobox input (the only `role="combobox"` in the form). */
const comboboxInput = (html: string) => {
const inputs = html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(inputs).toHaveLength(1);
return inputs[0];
};
describe("CategoryForm — parent field (#423)", () => {
it("renders the parent as a combobox labelled by the field name, with no parent select left", () => {
const html = markup(form(null));
const input = comboboxInput(html);
expect(input).toContain('aria-label="t(categories.parent)"');
// Only the type select remains.
expect(html.match(/<select/g) ?? []).toHaveLength(1);
expect(html).not.toContain("categories.noParent</option>");
});
it("shows the 'no parent' option as the value when creating without a parent", () => {
expect(comboboxInput(markup(form(null)))).toContain('value="t(categories.noParent)"');
});
it("shows the parent's translated name as the value when editing a category with a parent", () => {
// Seed rows translate through their i18n_key with the name as default value.
expect(comboboxInput(markup(form(1), 10))).toContain(
'value="t(categoriesSeed.housing[defaultValue=Logement])"',
);
});
it("shows a user-created parent by its name", () => {
expect(comboboxInput(markup(form(10), 42))).toContain('value="Loyer"');
});
it("hands the edited id and the stored parent to buildParentOptions", () => {
vi.mocked(buildParentOptions).mockClear();
markup(form(1), 10);
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
});
it("hands no edited id when creating", () => {
vi.mocked(buildParentOptions).mockClear();
markup(form(null));
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
});
});

View file

@ -1,7 +1,9 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
const PRESET_COLORS = [
"#4A90A4",
@ -18,6 +20,8 @@ const PRESET_COLORS = [
interface Props {
initialData: CategoryFormData;
categories: CategoryTreeNode[];
/** Id of the category being edited; omitted when creating. */
editingId?: number | null;
isCreating: boolean;
isSaving: boolean;
onSave: (data: CategoryFormData) => void;
@ -28,6 +32,7 @@ interface Props {
export default function CategoryForm({
initialData,
categories,
editingId = null,
isCreating,
isSaving,
onSave,
@ -41,36 +46,16 @@ export default function CategoryForm({
setForm(initialData);
}, [initialData]);
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
// Also build indentation info
const parentOptions: Array<CategoryTreeNode & { indent: number }> = [];
for (const cat of categories) {
if (cat.parent_id === null) {
// Level 0 — always allowed as parent
parentOptions.push({ ...cat, indent: 0 });
}
}
for (const cat of categories) {
if (cat.parent_id !== null) {
// Check if this category's parent is a root (making this level 1)
const parent = categories.find((c) => c.id === cat.parent_id);
if (parent && parent.parent_id === null) {
// Level 1 — allowed as parent (would create level 3 children)
parentOptions.push({ ...cat, indent: 1 });
}
// Level 2 categories are NOT shown (would create level 4)
}
}
// Sort to keep hierarchy order: group by root parent sort_order
parentOptions.sort((a, b) => {
const rootA = a.indent === 0 ? a : categories.find((c) => c.id === a.parent_id);
const rootB = b.indent === 0 ? b : categories.find((c) => c.id === b.parent_id);
const orderA = rootA?.sort_order ?? 999;
const orderB = rootB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
if (a.indent !== b.indent) return a.indent - b.indent;
return a.sort_order - b.sort_order;
});
// Roots and level-1 categories, minus the edited category and its descent;
// the combobox orders them hierarchically. See `buildParentOptions`.
const parentOptions = useMemo(
() => buildParentOptions(categories, { editingId, currentParentId: form.parent_id }),
[categories, editingId, form.parent_id],
);
// "No parent" sits first in the list and is how the field goes back to null:
// the combobox itself never emits null.
const noParentExtra = useMemo(() => [{ value: "", label: t("categories.noParent") }], [t]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -130,20 +115,15 @@ export default function CategoryForm({
<div>
<label className="block text-sm font-medium mb-1">{t("categories.parent")}</label>
<select
value={form.parent_id ?? ""}
onChange={(e) =>
setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null })
}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("categories.noParent")}</option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={parentOptions}
value={form.parent_id}
onChange={(id) => setForm({ ...form, parent_id: id })}
extraOptions={noParentExtra}
activeExtra={form.parent_id === null ? "" : null}
onExtraSelect={() => setForm({ ...form, parent_id: null })}
ariaLabel={t("categories.parent")}
/>
</div>
<div className="flex items-center gap-2">

View file

@ -0,0 +1,50 @@
// The category list of the "add a keyword" dialog and its pre-filled value (#423, D2).
import { describe, it, expect, vi, beforeEach } from "vitest";
// `vi.mock` is hoisted above the imports: the mocks must come from `vi.hoisted`.
const { getAllCategories, getAllCategoriesWithCounts } = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllCategoriesWithCounts: vi.fn(),
}));
vi.mock("../../services/transactionService", () => ({ getAllCategories }));
vi.mock("../../services/categoryService", () => ({ getAllCategoriesWithCounts }));
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
const cats = [
{ id: 1, name: "Logement", sort_order: 1 },
{ id: 7, name: "Épicerie", sort_order: 2 },
];
describe("loadKeywordCategories", () => {
beforeEach(() => {
getAllCategories.mockReset();
getAllCategoriesWithCounts.mockReset();
});
it("reads the Transactions list (active and inputable), not the full category list", async () => {
getAllCategories.mockResolvedValue(cats);
await expect(loadKeywordCategories()).resolves.toBe(cats);
expect(getAllCategories).toHaveBeenCalledTimes(1);
expect(getAllCategoriesWithCounts).not.toHaveBeenCalled();
});
});
describe("reconcileInitialCategory", () => {
it("keeps the pre-filled category when it is in the list", () => {
expect(reconcileInitialCategory(7, cats)).toBe(7);
});
it("drops a pre-filled category that is not in the list", () => {
expect(reconcileInitialCategory(42, cats)).toBeNull();
});
it("returns null without a pre-filled category", () => {
expect(reconcileInitialCategory(null, cats)).toBeNull();
expect(reconcileInitialCategory(undefined, cats)).toBeNull();
});
it("returns null when the list is empty", () => {
expect(reconcileInitialCategory(7, [])).toBeNull();
});
});

View file

@ -0,0 +1,25 @@
import type { Category } from "../../shared/types";
import { getAllCategories } from "../../services/transactionService";
import type { CategoryLike } from "../shared/CategoryCombobox";
/**
* Categories offered by the "add a keyword" dialog: the same list as the
* category cell of the Transactions page (active and inputable), so a keyword
* can only target a category the user can also pick by hand (#423, D2).
*/
export function loadKeywordCategories(): Promise<Category[]> {
return getAllCategories();
}
/**
* The category pre-filled by the right-click menu is kept only when it is in
* the list; a category the user cannot see (hidden from input, inactive) is
* dropped so the dialog never applies an id that is not shown.
*/
export function reconcileInitialCategory(
initialId: number | null | undefined,
categories: CategoryLike[],
): number | null {
if (initialId == null) return null;
return categories.some((c) => c.id === initialId) ? initialId : null;
}

View file

@ -0,0 +1,106 @@
// buildParentOptions — which categories the category form offers as a parent (#423).
import { describe, it, expect } from "vitest";
import { buildParentOptions } from "./parentOptions";
import type { CategoryTreeNode } from "../../shared/types";
const node = (id: number, parent_id: number | null, name = `c${id}`): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
// Two roots. Root 1 has a child (10) with its own child (100), and a leaf child (11).
// Root 2 has a leaf child (20).
const TREE = [node(1, null), node(10, 1), node(100, 10), node(11, 1), node(2, null), node(20, 2)];
const ids = (list: CategoryTreeNode[]) => list.map((c) => c.id).sort((a, b) => a - b);
describe("buildParentOptions — creating a category", () => {
it("offers roots and level-1 categories, never level 2", () => {
expect(ids(buildParentOptions(TREE))).toEqual([1, 2, 10, 11, 20]);
});
it("offers roots alone when there is nothing deeper", () => {
expect(ids(buildParentOptions([node(1, null), node(2, null)]))).toEqual([1, 2]);
});
it("treats a category whose parent is missing as a root", () => {
// 10's parent (1) is not in the list: 10 is a root, 100 a level-1 child.
expect(ids(buildParentOptions([node(10, 1), node(100, 10), node(1000, 100)]))).toEqual([10, 100]);
});
it("returns [] for an empty list", () => {
expect(buildParentOptions([])).toEqual([]);
});
});
describe("buildParentOptions — editing a category", () => {
it("leaves out the edited category itself", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11 }))).toEqual([1, 2, 10, 20]);
});
it("offers nothing when the edited category has grandchildren", () => {
// Root 1 carries 10 -> 100: under any parent, 100 would sit at level 3.
// Probe of the review of PR #428: `[1, 10->1, 100->10, 2]`, editing 1.
expect(ids(buildParentOptions([node(1, null), node(10, 1), node(100, 10), node(2, null)], { editingId: 1 }))).toEqual([]);
expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([]);
});
it("leaves out the whole descent of the edited category", () => {
// Editing 10 (child 100): a level-1 category with a child; 100 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100);
// Editing root 2 (child 20): only roots remain, so 20 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).not.toContain(20);
});
it("keeps only roots when the edited category has children but no grandchildren", () => {
// 10 has a child (100): under a level-1 parent its child would sit at level 3.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).toEqual([1, 2]);
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).toEqual([1]);
});
it("keeps level-1 categories when the edited category is a leaf", () => {
expect(ids(buildParentOptions(TREE, { editingId: 20 }))).toEqual([1, 2, 10, 11]);
});
it("always keeps the parent currently set on the form, even one the rules would drop", () => {
// Legacy data: 1000 sits under 100 (level 3). Editing 1000, its parent 100 is
// level 2 and would be dropped; it stays so the field shows what is stored.
const deep = [...TREE, node(1000, 100)];
expect(ids(buildParentOptions(deep, { editingId: 1000, currentParentId: 100 }))).toEqual([
1, 2, 10, 11, 20, 100,
]);
});
it("does not re-add the edited category as its own current parent", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 11 }))).toEqual([1, 2, 10, 20]);
});
it("ignores a current parent that is not in the list", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 999 }))).toEqual([1, 2, 10, 20]);
});
it("terminates on a cycle and never offers the edited category", () => {
// Which members of the cycle are offered is not specified (#393 repairs
// cycles); the walk must end, and 3 must stay out.
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
const offered = ids(buildParentOptions(cyclic, { editingId: 3 }));
expect(offered).not.toContain(3);
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
});
it("terminates when the edited category is itself a member of a cycle", () => {
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
// unbounded). Only roots remain, so the root outside the cycle is offered.
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
expect(ids(buildParentOptions(cyclic, { editingId: 1 }))).toEqual([3]);
});
});

View file

@ -0,0 +1,109 @@
import type { CategoryTreeNode } from "../../shared/types";
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
const MAX_PARENT_DEPTH = 1;
export interface ParentOptionsContext {
/** Id of the category being edited; absent or null when creating. */
editingId?: number | null;
/**
* Parent currently stored on the form. Always kept in the options when it
* exists, so a legacy or inconsistent parent never leaves the field blank
* while `parent_id` is set.
*/
currentParentId?: number | null;
}
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
const byId = new Map<number, CategoryTreeNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
const depthOf = (id: number, seen: Set<number>): number => {
const known = depths.get(id);
if (known !== undefined) return known;
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
seen.add(id);
const cat = byId.get(id);
const parentId = cat?.parent_id ?? null;
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
depths.set(id, d);
return d;
};
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
/**
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
* walk can never take more steps than there are categories.
*/
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
const childrenOf = new Map<number, number[]>();
for (const c of categories) {
if (c.parent_id === null) continue;
const bucket = childrenOf.get(c.parent_id) ?? [];
bucket.push(c.id);
childrenOf.set(c.parent_id, bucket);
}
let height = 0;
const seen = new Set<number>([rootId]);
let frontier = [rootId];
while (frontier.length > 0 && height < categories.length) {
const next: number[] = [];
for (const id of frontier) {
for (const child of childrenOf.get(id) ?? []) {
if (seen.has(child)) continue;
seen.add(child);
next.push(child);
}
}
if (next.length === 0) break;
height += 1;
frontier = next;
}
return height;
}
/**
* Categories that may be chosen as the parent in the category form.
*
* - Roots and level-1 categories only: a level-2 parent would create a fourth
* level, which nothing else in the app knows how to display.
* - When editing, the edited category is left out, and the allowed parent depth
* shrinks by the height of its sub-tree: with children, only roots remain
* (the children would otherwise land at level 3); with grandchildren, no
* parent at all (they would land at level 4). This alone also leaves out its
* whole descent, which would create a cycle that hides the branch everywhere
* (#393): a descendant sits at least one level below the edited category,
* deeper than any depth still allowed.
* - The parent currently set on the form is always kept when it exists.
*
* This list is the only guard: `updateCategory` writes any `parent_id` it is
* given, without a depth or cycle check (#393 will add one). The form therefore
* never offers a new parent that the rules above reject.
*
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
*/
export function buildParentOptions(
categories: CategoryTreeNode[],
ctx: ParentOptionsContext = {},
): CategoryTreeNode[] {
const editingId = ctx.editingId ?? null;
const currentParentId = ctx.currentParentId ?? null;
const depths = depthsOf(categories);
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
// Negative when the edited category has grandchildren: only the current parent survives.
const maxDepth = MAX_PARENT_DEPTH - height;
return categories.filter((c) => {
if (c.id === editingId) return false;
// Kept even when the depth rule would drop it: the field must show what is
// stored. Keeping it changes nothing on save (it is already the parent);
// repairing such data is the job of #393.
if (c.id === currentParentId) return true;
return (depths.get(c.id) ?? 0) <= maxDepth;
});
}

View file

@ -7,13 +7,7 @@ import type { ReactElement, ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import DashboardEmptyState from "./DashboardEmptyState";
import OnboardingChecklist from "./OnboardingChecklist";
@ -21,11 +15,18 @@ import { computeChecklist } from "../../utils/onboardingChecklist";
const checklist = computeChecklist(
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
{ importFolderSet: false, budgetAllowed: true },
{ importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
);
function cardProps(hidden = false) {
return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() };
return {
checklist,
budgetReady: true,
visitStepsInitialized: true,
hidden,
onHide: vi.fn(),
onResume: vi.fn(),
};
}
const markup = (hidden = false) =>
@ -42,7 +43,7 @@ describe("DashboardEmptyState (#352, #397)", () => {
expect(html).toContain("t(dashboard.empty.description)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])");
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=8])");
});
it("keeps the card and drops the list once the path is hidden", () => {
@ -58,6 +59,13 @@ describe("DashboardEmptyState (#352, #397)", () => {
expect(list.type).toBe(OnboardingChecklist);
expect(list.props.items).toBe(checklist.items);
expect(list.props.budgetReady).toBe(true);
expect(list.props.visitStepsInitialized).toBe(true);
const waiting = DashboardEmptyState({ ...props, visitStepsInitialized: false }) as ReactElement<{
children?: ReactNode;
}>;
expect(
(waiting.props.children as ReactElement<Record<string, unknown>>).props.visitStepsInitialized,
).toBe(false);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
});

View file

@ -8,6 +8,8 @@ import type { ChecklistItemKey } from "../../shared/types";
interface DashboardEmptyStateProps {
checklist: Checklist;
budgetReady: boolean;
/** The visit steps are initialized (#425): their rows stay "pending" until then. */
visitStepsInitialized: boolean;
/** The user dismissed the checklist: the welcome card stays, the list goes. */
hidden: boolean;
onHide: () => void;
@ -24,6 +26,7 @@ interface DashboardEmptyStateProps {
export default function DashboardEmptyState({
checklist,
budgetReady,
visitStepsInitialized,
hidden,
onHide,
onResume,
@ -43,6 +46,7 @@ export default function DashboardEmptyState({
done={checklist.done}
total={checklist.total}
budgetReady={budgetReady}
visitStepsInitialized={visitStepsInitialized}
onHide={onHide}
onResume={onResume}
/>

View file

@ -8,13 +8,7 @@ import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) =>
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("lucide-react", () => ({
CheckCircle2: () => null,
@ -73,8 +67,14 @@ function render(
counters = ZERO,
budgetAllowed = true,
skipped: ChecklistItemKey[] = [],
visitStepsInitialized = true,
) {
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped });
const checklist = computeChecklist(counters, {
importFolderSet: false,
budgetAllowed,
skipped,
visitStepsInitialized,
});
const onHide = vi.fn();
const onResume = vi.fn();
const props: OnboardingChecklistProps = {
@ -82,6 +82,7 @@ function render(
done: checklist.done,
total: checklist.total,
budgetReady: true,
visitStepsInitialized,
onHide,
onResume,
...overrides,
@ -103,24 +104,36 @@ function stateTextsOf(row: AnyElement): string[] {
}
describe("OnboardingChecklist", () => {
it("renders the title, the counter 1 / 6 and the hide button", () => {
it("renders the title, the counter 1 / 8 and the hide button", () => {
const { element, onHide } = render();
const text = texts(element);
expect(text).toContain("onboarding.checklist.title");
expect(text).toContain("onboarding.checklist.counter[done=1,total=6]");
expect(text).toContain("t(onboarding.checklist.title)");
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])");
const [hide] = find(element, (e) => e.type === "button");
expect(hide.props.type).toBe("button");
expect(texts(hide.props.children)).toContain("onboarding.checklist.hide");
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
(hide.props.onClick as () => void)();
expect(onHide).toHaveBeenCalledTimes(1);
});
it("renders six rows with their i18n labels", () => {
it("renders eight rows, in order, with their i18n labels (#425)", () => {
const { element } = render();
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
const rows = find(element, (e) => e.type === "li");
const order = [
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
];
expect(rows.map((r) => r.key)).toEqual(order);
const text = texts(element);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
expect(text).toContain(`onboarding.checklist.items.${key}`);
for (const key of order) {
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
}
});
@ -154,12 +167,12 @@ describe("OnboardingChecklist", () => {
const { element } = render({}, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
expect(texts(budget)).toContain("t(onboarding.checklist.lockedBudget)");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
// The license card carries no step card: nothing to focus there.
expect(link.props.state).toBeUndefined();
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])");
});
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
@ -169,35 +182,35 @@ describe("OnboardingChecklist", () => {
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
expect(texts(budget)).not.toContain("onboarding.checklist.lockedBudget");
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
expect(texts(budget)).not.toContain("t(onboarding.checklist.lockedBudget)");
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
});
});
describe("OnboardingChecklist — states read out (#384, #397)", () => {
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
const { element } = render({}, ZERO, false, ["source"]);
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["onboarding.state.done"]);
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["onboarding.state.skipped"]);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.locked"]);
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["t(onboarding.state.done)"]);
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["t(onboarding.state.skipped)"]);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
});
it("reads the budget row as pending while the entitlement is unknown", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.pending"]);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
});
it("puts the state text right after the label, inside the same element", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const [labelled] = find(
rowOf(element, "source"),
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.items.source"),
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.items.source)"),
);
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
"onboarding.checklist.items.source",
"onboarding.state.done",
"t(onboarding.checklist.items.source)",
"t(onboarding.state.done)",
]);
});
});
@ -210,11 +223,11 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
const [badge] = find(
source,
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.skippedBadge"),
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.skippedBadge)"),
);
// The hidden "(skipped)" text already says it: the badge is not read twice.
expect(badge.props["aria-hidden"]).toBe("true");
expect(texts(element)).toContain("onboarding.checklist.counter[done=2,total=6]");
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
});
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
@ -223,7 +236,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
// The label is no link: only "Resume" leads to the page, after taking the step back.
expect(sourceLinks).toHaveLength(1);
const [resume] = sourceLinks;
expect(texts(resume.props.children)).toEqual(["onboarding.checklist.resume"]);
expect(texts(resume.props.children)).toEqual(["t(onboarding.checklist.resume)"]);
expect(resume.props.to).toBe("/import");
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
(resume.props.onClick as () => void)();
@ -242,7 +255,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
expect(texts(source)).not.toContain("onboarding.checklist.resume");
expect(texts(source)).not.toContain("t(onboarding.checklist.resume)");
});
it("a locked row stays locked even when skipped", () => {
@ -250,8 +263,56 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
expect(texts(budget)).not.toContain("onboarding.checklist.resume");
expect(texts(budget)).not.toContain("t(onboarding.checklist.resume)");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
});
});
describe("OnboardingChecklist — visit steps (#425)", () => {
it("a visit row to do links to its page with the step's navigation state", () => {
const { element } = render();
const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link);
expect(categories.props.to).toBe("/categories");
expect(categories.props.state).toEqual({ onboarding: { focus: true } });
const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link);
expect(reports.props.to).toBe("/reports");
});
it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => {
const { element } = render({}, ZERO, false, [], false);
for (const key of ["categories", "reports"]) {
const row = rowOf(element, key);
expect(find(row, (e) => e.type === Lock), key).toHaveLength(0);
expect(find(row, (e) => e.type === Circle), key).toHaveLength(1);
expect(find(row, (e) => e.type === Link), key).toHaveLength(0);
expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)");
expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]);
}
// The Budget row keeps its own rule: entitlement known, Free → locked.
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
});
it("the Budget row's pending state follows budgetReady alone", () => {
const { element } = render({ budgetReady: false }, ZERO, true, [], true);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]);
const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element;
expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]);
});
it("an acknowledged visit row shows as done", () => {
const checklist = computeChecklist(ZERO, {
importFolderSet: false,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["reports"],
});
const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total });
const reports = rowOf(element, "reports");
expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(reports, (e) => e.type === Link)).toHaveLength(0);
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
});
});

View file

@ -2,13 +2,13 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import { stepNavState } from "../../utils/onboardingPath";
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
import { VISIT_STEP_KEYS, type ChecklistItem, type ChecklistItemKey } from "../../shared/types";
export interface OnboardingChecklistProps {
items: ChecklistItem[];
/** Number of unlocked rows that are done or skipped. */
done: number;
/** Number of unlocked rows (6, or 5 when Budget is locked). */
/** Number of unlocked rows (8, or 7 when Budget is locked). */
total: number;
/**
* False until the Budget entitlement is known. The budget row is then
@ -16,6 +16,12 @@ export interface OnboardingChecklistProps {
* `total` depends on the lock.
*/
budgetReady: boolean;
/**
* False until the profile's visit steps are initialized (#425). Their two
* rows are then neutral ("pending", never "locked", which would show the
* Base edition badge) and the counter is hidden, `total` depending on them.
*/
visitStepsInitialized: boolean;
onHide: () => void;
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
onResume: (key: ChecklistItemKey) => void;
@ -28,8 +34,13 @@ type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
* done shows as done even if it was skipped too: "Resume" would bring back
* nothing to do.
*/
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
function rowState(
item: ChecklistItem,
budgetReady: boolean,
visitStepsInitialized: boolean,
): RowState {
if (item.key === "budget" && !budgetReady) return "pending";
if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending";
if (item.locked) return "locked";
if (item.done) return "done";
return item.skipped ? "skipped" : "todo";
@ -75,6 +86,7 @@ export default function OnboardingChecklist({
done,
total,
budgetReady,
visitStepsInitialized,
onHide,
onResume,
}: OnboardingChecklistProps) {
@ -88,7 +100,7 @@ export default function OnboardingChecklist({
>
<div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{budgetReady && (
{budgetReady && visitStepsInitialized && (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
@ -105,7 +117,7 @@ export default function OnboardingChecklist({
</div>
<ul className="space-y-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const state = rowState(item, budgetReady, visitStepsInitialized);
const label = t(`onboarding.checklist.items.${item.key}`);
const stateText =
state === "todo" ? null : (

View file

@ -9,20 +9,16 @@ import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) =>
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("lucide-react", () => ({
FolderOpen: () => null,
Inbox: () => null,
Folder: () => null,
FileText: () => null,
}));
import { FolderOpen, Inbox } from "lucide-react";
import { FileText, Folder, FolderOpen, Inbox } from "lucide-react";
import ImportEmptyState, {
CSV_EXPORT_GUIDE_PATH,
folderNameFromPath,
@ -46,7 +42,7 @@ function texts(node: ReactNode): string[] {
}
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox]);
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox, Folder, FileText]);
/** Resolves the inner `EmptyState` component so its buttons and links are visible. */
function expand(node: ReactNode): ReactNode {
@ -83,11 +79,11 @@ describe("ImportEmptyState — no folder (#356)", () => {
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
const { props, tree, buttons, links } = render({ hasFolder: false });
expect(texts(tree)).toContain("import.empty.noFolder.title");
expect(texts(tree)).toContain("import.empty.noFolder.description");
expect(texts(tree)).toContain("t(import.empty.noFolder.title)");
expect(texts(tree)).toContain("t(import.empty.noFolder.description)");
expect(buttons).toHaveLength(1);
expect(texts(buttons[0].props.children)).toContain("import.empty.noFolder.cta");
expect(texts(buttons[0].props.children)).toContain("t(import.empty.noFolder.cta)");
expect(buttons[0].props.disabled).toBe(false);
buttons[0].props.onClick();
expect(props.onBrowse).toHaveBeenCalledTimes(1);
@ -96,13 +92,13 @@ describe("ImportEmptyState — no folder (#356)", () => {
expect(links).toHaveLength(1);
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
expect(links[0].props.to).toBe("/settings/users#import-export");
expect(texts(links[0].props.children)).toContain("import.empty.guideLink");
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
});
it("never shows the sample or refresh actions without a folder", () => {
const { tree } = render({ hasFolder: false });
expect(texts(tree)).not.toContain("import.empty.createSample");
expect(texts(tree)).not.toContain("import.empty.refresh");
expect(texts(tree)).not.toContain("t(import.empty.createSample)");
expect(texts(tree)).not.toContain("t(import.empty.refresh)");
});
});
@ -110,22 +106,22 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
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" });
expect(texts(tree)).toContain("import.empty.noSources.title");
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
expect(buttons).toHaveLength(2);
expect(texts(buttons[0].props.children)).toContain("import.empty.createSample");
expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)");
expect(buttons[0].props.disabled).toBe(false);
buttons[0].props.onClick();
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
expect(texts(buttons[1].props.children)).toContain("import.empty.refresh");
expect(texts(buttons[1].props.children)).toContain("t(import.empty.refresh)");
buttons[1].props.onClick();
expect(props.onRefresh).toHaveBeenCalledTimes(1);
expect(props.onBrowse).not.toHaveBeenCalled();
expect(links).toHaveLength(1);
expect(links[0].props.to).toBe("/settings/users#import-export");
expect(texts(links[0].props.children)).toContain("import.empty.guideLink");
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
});
it("disables the create button while the sample folders are being written", () => {
@ -138,13 +134,37 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
it("names the folder in the description, never the full path", () => {
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
const { tree } = render({ hasFolder: true, folderName });
const description = texts(tree).find((s) => s.startsWith("import.empty.noSources.description"));
const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description"));
expect(description).toBe("import.empty.noSources.description[folderName=Relevés]");
expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])");
expect(description).not.toContain("Users");
});
});
describe("ImportEmptyState — expected structure of the folder (#422)", () => {
const STRUCTURE = [
"title",
"parent",
"account1",
"file1",
"file2",
"account2",
"rootNote",
].map((name) => `t(import.empty.structure.${name})`);
it.each([
["no folder", { hasFolder: false }],
["a folder without sources", { hasFolder: true, folderName: "Relevés" }],
] as const)("shows the expected structure with %s, above the guide link", (_, overrides) => {
const { tree } = render(overrides);
const all = texts(tree);
for (const text of STRUCTURE) expect(all).toContain(text);
expect(all.indexOf("t(import.empty.structure.rootNote)")).toBeLessThan(
all.indexOf("t(import.empty.guideLink)"),
);
});
});
describe("ImportEmptyState — getting-started target (#398)", () => {
const highlighted = (tree: ReactNode) =>
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
@ -154,14 +174,14 @@ describe("ImportEmptyState — getting-started target (#398)", () => {
const targets = highlighted(tree);
expect(targets).toHaveLength(1);
expect(targets[0].type).toBe("button");
expect(texts(targets[0].props.children)).toContain("import.empty.noFolder.cta");
expect(texts(targets[0].props.children)).toContain("t(import.empty.noFolder.cta)");
});
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
const targets = highlighted(tree);
expect(targets).toHaveLength(1);
expect(texts(targets[0].props.children)).toContain("import.empty.createSample");
expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)");
});
it("highlights nothing without the property", () => {

View file

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { FolderOpen, Inbox } from "lucide-react";
import EmptyState from "../shared/EmptyState";
import ImportFolderStructure from "./ImportFolderStructure";
/** Where the user guide explains how to export a CSV from each bank (#355). */
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
@ -36,9 +37,10 @@ export function folderNameFromPath(path: string): string {
/**
* Empty state of the import page (#356), replacing the empty `SourceList` in
* both of its situations: no import folder yet (choose one), or a folder with
* no bank sub-folder (create the sample folders, or refresh after dropping
* files). Both variants link to the bank CSV export guide. Pure: no hook
* besides `useTranslation`, so it is tested by direct call.
* no account sub-folder (create the sample folders, or refresh after dropping
* files). Both variants show the expected structure of the folder (#422) and
* link to the bank CSV export guide. Pure: no hook besides `useTranslation`,
* so it is tested by direct call.
*/
export default function ImportEmptyState({
hasFolder,
@ -51,15 +53,18 @@ export default function ImportEmptyState({
}: ImportEmptyStateProps) {
const { t } = useTranslation();
const guideLink = (
<p className="text-center">
<Link
to={CSV_EXPORT_GUIDE_PATH}
className="text-sm text-[var(--primary)] hover:underline"
>
{t("import.empty.guideLink")}
</Link>
</p>
const details = (
<>
<ImportFolderStructure />
<p className="mt-4 text-center">
<Link
to={CSV_EXPORT_GUIDE_PATH}
className="text-sm text-[var(--primary)] hover:underline"
>
{t("import.empty.guideLink")}
</Link>
</p>
</>
);
if (!hasFolder) {
@ -70,7 +75,7 @@ export default function ImportEmptyState({
description={t("import.empty.noFolder.description")}
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
>
{guideLink}
{details}
</EmptyState>
);
}
@ -88,7 +93,7 @@ export default function ImportEmptyState({
}}
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
>
{guideLink}
{details}
</EmptyState>
);
}

View file

@ -0,0 +1,62 @@
// ImportFolderStructure — render tests (#422).
//
// `renderToStaticMarkup`, no jsdom; the shared marked `t` (#389), so every
// text is asserted in its `t(key)` form and a string that skipped `t` fails.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import ImportFolderStructure from "./ImportFolderStructure";
const KEYS = [
"title",
"parent",
"account1",
"file1",
"file2",
"account2",
"rootNote",
].map((name) => `import.empty.structure.${name}`);
/** The markup with the class attributes dropped, so the structure reads. */
function bare(): string {
return renderToStaticMarkup(<ImportFolderStructure />)
.replace(/ class="[^"]*"/g, "")
.replace(/<svg[^>]*>.*?<\/svg>/g, "<svg/>");
}
describe("ImportFolderStructure (#422)", () => {
it("renders its seven texts, each through t, in reading order", () => {
const html = renderToStaticMarkup(<ImportFolderStructure />);
const positions = KEYS.map((key) => html.indexOf(`t(${key})`));
for (const [i, position] of positions.entries()) {
expect(position, KEYS[i]).toBeGreaterThanOrEqual(0);
}
expect([...positions].sort((a, b) => a - b)).toEqual(positions);
});
it("nests the levels: parent folder > account folders > files", () => {
// The two files sit in a list inside the first account's item, itself in
// a list inside the parent's item; the second account is the first's
// sibling, and the root note is outside the tree.
expect(bare()).toBe(
"<div><h3>t(import.empty.structure.title)</h3>" +
"<ul><li><span><svg/>t(import.empty.structure.parent)</span>" +
"<ul>" +
"<li><span><svg/>t(import.empty.structure.account1)</span>" +
"<ul><li><svg/>t(import.empty.structure.file1)</li><li><svg/>t(import.empty.structure.file2)</li></ul>" +
"</li>" +
"<li><span><svg/>t(import.empty.structure.account2)</span></li>" +
"</ul></li></ul>" +
"<p>t(import.empty.structure.rootNote)</p></div>",
);
});
it("hides every icon from assistive technologies", () => {
const html = renderToStaticMarkup(<ImportFolderStructure />);
const icons = html.match(/<svg[^>]*>/g) ?? [];
expect(icons).toHaveLength(5);
for (const icon of icons) expect(icon).toContain('aria-hidden="true"');
});
});

View file

@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { FileText, Folder } from "lucide-react";
const ITEM_CLASS = "flex items-center gap-2";
const NESTED_CLASS = "mt-1 ml-5 space-y-1 border-l border-[var(--border)] pl-3";
/**
* "Expected structure" box of the import page's empty state (#422): the
* folder to choose is the parent one, each of its sub-folders is one source
* (one account), and only the files inside a sub-folder are read. It mirrors
* `scan_import_folder` (src-tauri/src/commands/fs_commands.rs): direct,
* non-hidden sub-folders become sources, files at the root are ignored.
*
* A nested list rather than a picture, so a screen reader reads the levels;
* the icons are decorative. Pure: no hook besides `useTranslation`, so it is
* tested by direct call.
*/
export default function ImportFolderStructure() {
const { t } = useTranslation();
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm text-[var(--foreground)]">
<h3 className="mb-2 font-semibold">{t("import.empty.structure.title")}</h3>
<ul className="space-y-1">
<li>
<span className={`${ITEM_CLASS} font-medium`}>
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--primary)]" />
{t("import.empty.structure.parent")}
</span>
<ul className={NESTED_CLASS}>
<li>
<span className={ITEM_CLASS}>
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.account1")}
</span>
<ul className={NESTED_CLASS}>
<li className={ITEM_CLASS}>
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.file1")}
</li>
<li className={ITEM_CLASS}>
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.file2")}
</li>
</ul>
</li>
<li>
<span className={ITEM_CLASS}>
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.account2")}
</span>
</li>
</ul>
</li>
</ul>
<p className="mt-3 text-[var(--muted-foreground)]">{t("import.empty.structure.rootNote")}</p>
</div>
);
}

View file

@ -0,0 +1,55 @@
// RootFilesWarning (#427) through the REAL locales: the plural forms
// (`message_one`/`message_other`, `more_one`/`more_other`) and the quotes
// around the folder name only exist in fr.json and en.json, which the marked
// `t` of RootFilesWarning.test.tsx never reads.
import { describe, it, expect, afterAll } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import i18n from "../../i18n/config";
import RootFilesWarning from "./RootFilesWarning";
const text = (names: string[]) =>
renderToStaticMarkup(<RootFilesWarning names={names} folderName="Relevés" />)
.replace(/<[^>]+>/g, "");
const SEVEN = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
afterAll(async () => {
await i18n.changeLanguage("fr");
});
describe("RootFilesWarning — French (#427)", () => {
it("singular", async () => {
await i18n.changeLanguage("fr");
expect(text(["a.csv"])).toBe(
"1 fichier est posé directement dans « Relevés » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte.",
);
});
it("plural, five names then « et 2 autres »", async () => {
await i18n.changeLanguage("fr");
expect(text(SEVEN)).toBe(
"7 fichiers sont posés directement dans « Relevés » et sont ignorés : 1.csv, 2.csv, 3.csv, 4.csv, 5.csv et 2 autres. Déplacez-les dans un sous-dossier par compte.",
);
});
it("one more: « et 1 autre »", async () => {
await i18n.changeLanguage("fr");
expect(text(SEVEN.slice(0, 6))).toContain("5.csv et 1 autre.");
});
});
describe("RootFilesWarning — English (#427)", () => {
it("singular", async () => {
await i18n.changeLanguage("en");
expect(text(["a.csv"])).toBe(
"1 file sits directly in “Relevés” and is ignored: a.csv. Move it into a subfolder per account.",
);
});
it("plural, five names then “and 2 more”", async () => {
await i18n.changeLanguage("en");
expect(text(SEVEN)).toBe(
"7 files sit directly in “Relevés” and are ignored: 1.csv, 2.csv, 3.csv, 4.csv, 5.csv and 2 more. Move them into a subfolder per account.",
);
});
});

View file

@ -0,0 +1,39 @@
// RootFilesWarning (#427). No jsdom: rendered to static markup with the shared
// marked `t`, so the message key, its count and its interpolations are read.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import RootFilesWarning from "./RootFilesWarning";
const render = (names: string[], folderName = "Relevés") =>
renderToStaticMarkup(<RootFilesWarning names={names} folderName={folderName} />);
describe("RootFilesWarning (#427)", () => {
it("one file: the message with count 1, the folder name and the file", () => {
expect(render(["a.csv"])).toContain(
"t(import.rootFiles.message[count=1,folder=Relevés,names=a.csv])",
);
});
it("three files: the message with count 3 and the names joined by a comma", () => {
expect(render(["a.csv", "b.csv", "c.txt"])).toContain(
"t(import.rootFiles.message[count=3,folder=Relevés,names=a.csv, b.csv, c.txt])",
);
});
it("seven files: five names, then 'and 2 more'", () => {
const names = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
expect(render(names)).toContain(
"t(import.rootFiles.message[count=7,folder=Relevés,names=1.csv, 2.csv, 3.csv, 4.csv, 5.csv t(import.rootFiles.more[count=2])])",
);
});
it("an icon hidden from assistive tech, and no live region", () => {
const html = render(["a.csv"]);
expect(html).toMatch(/<svg[^>]*aria-hidden="true"/);
expect(html).not.toContain("role=");
expect(html).not.toContain("aria-live");
});
});

View file

@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import { formatRootFiles } from "../../utils/rootFiles";
export interface RootFilesWarningProps {
/** Names of the CSV/TXT files placed directly at the root, sorted by the scan. */
names: readonly string[];
/** Last segment of the import folder path, never the full path. */
folderName: string;
}
/**
* Warning about the statement files placed directly in the import folder,
* which no source reads (#427). Names up to five of them, then counts the
* rest. Shown when the page loads, so no live region. On screen only: the
* names never reach the logs. Pure besides `useTranslation`, tested by direct
* render.
*/
export default function RootFilesWarning({ names, folderName }: RootFilesWarningProps) {
const { t } = useTranslation();
const { shown, more } = formatRootFiles(names);
const listed =
shown.join(", ") + (more > 0 ? ` ${t("import.rootFiles.more", { count: more })}` : "");
return (
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)] flex items-start gap-2">
<AlertTriangle size={16} aria-hidden="true" className="text-[var(--accent)] shrink-0 mt-0.5" />
<p className="text-sm text-[var(--foreground)]">
{t("import.rootFiles.message", {
count: names.length,
folder: folderName,
names: listed,
})}
</p>
</div>
);
}

View file

@ -0,0 +1,44 @@
// SourceConfigPanel — the source name is read-only, the subfolder's (#426).
// No jsdom: rendered to static markup with the shared marked `t`.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SourceConfigPanel from "./SourceConfigPanel";
import { initialWizardState } from "../../hooks/useImportWizard";
const noop = () => undefined;
const render = () =>
renderToStaticMarkup(
<SourceConfigPanel
source={{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] }}
config={{ ...initialWizardState.sourceConfig, name: "Mon compte chèques" }}
selectedFiles={[]}
headers={[]}
configTemplates={[]}
onConfigChange={noop}
onFileToggle={noop}
onSelectAllFiles={noop}
onAutoDetect={noop}
onSaveAsTemplate={noop}
onApplyTemplate={noop}
onUpdateTemplate={noop}
onDeleteTemplate={noop}
selectedTemplateId={null}
/>,
);
describe("SourceConfigPanel — source name (#426)", () => {
it("shows the subfolder name with its hint", () => {
const html = render();
expect(html).toContain("t(import.config.sourceNameHint)");
expect(html).toMatch(/<p[^>]*>Desjardins<\/p>/);
});
it("has no field holding the configured name", () => {
const html = render();
expect(html).not.toContain("Mon compte chèques");
});
});

View file

@ -15,6 +15,7 @@ import {
type BankSignatureId,
} from "../../utils/bankSignatures";
import ColumnMappingEditor from "./ColumnMappingEditor";
import SourceNameDisplay from "./SourceNameDisplay";
interface SourceConfigPanelProps {
source: ScannedSource;
@ -249,18 +250,8 @@ export default function SourceConfigPanel({
)}
</div>
{/* Source name */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</label>
<input
type="text"
value={config.name}
onChange={(e) => updateConfig({ name: e.target.value })}
className={inputClass}
/>
</div>
{/* Source name: the subfolder name, read-only (#426) */}
<SourceNameDisplay folderName={source.folder_name} />
{/* Basic settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View file

@ -4,13 +4,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SourceList from "./SourceList";
import { SOURCE_CARD_TARGET } from "./SourceCard";
@ -61,3 +55,27 @@ describe("SourceList — getting-started target (#398)", () => {
expect(markup(false)).not.toContain("data-onboarding");
});
});
describe("SourceList and SourceCard — displayed texts (#389)", () => {
it("titles the list and counts each source's files through t", () => {
const html = markup();
expect(html).toContain(">t(import.sources.title)<");
expect(cards(html)[0]).toContain("t(import.sources.fileCount[count=1])");
});
it("marks the files not yet imported through t", () => {
const html = renderToStaticMarkup(
<SourceList
sources={[source("Desjardins")]}
configuredSourceNames={new Set()}
importedFileNames={new Map([["Desjardins", new Set<string>()]])}
onSelectSource={vi.fn()}
/>,
);
expect(html).toContain("t(import.sources.new)");
});
it("says the folder is empty through t", () => {
expect(markup(false, [])).toContain(">t(import.sources.empty)<");
});
});

View file

@ -0,0 +1,27 @@
// SourceNameDisplay (#426). No jsdom: rendered to static markup with the shared
// marked `t`, so the label and hint keys are read as written.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SourceNameDisplay from "./SourceNameDisplay";
const render = (folderName: string) =>
renderToStaticMarkup(<SourceNameDisplay folderName={folderName} />);
describe("SourceNameDisplay (#426)", () => {
it("shows the subfolder name, the label and the hint", () => {
const html = render("Desjardins chèques");
expect(html).toContain("Desjardins chèques");
expect(html).toContain("t(import.config.sourceName)");
expect(html).toContain("t(import.config.sourceNameHint)");
});
it("has no input field: the name cannot be edited", () => {
const html = render("Tangerine");
expect(html).not.toContain("<input");
expect(html).not.toContain("<textarea");
expect(html).not.toContain("contenteditable");
});
});

View file

@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
interface SourceNameDisplayProps {
/** Name of the source subfolder, which is the source name (#426). */
folderName: string;
}
/**
* Read-only source name in the source configuration (#426). A source is
* looked up by its subfolder name on the next import, so its name is that
* of the subfolder and cannot be edited.
*/
export default function SourceNameDisplay({ folderName }: SourceNameDisplayProps) {
const { t } = useTranslation();
return (
<div>
<span className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</span>
<p className="text-sm font-medium text-[var(--foreground)] break-words">{folderName}</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("import.config.sourceNameHint")}
</p>
</div>
);
}

View file

@ -4,9 +4,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
@ -36,9 +34,9 @@ describe("WizardNavigation — getting-started target (#398)", () => {
expect(next).toContain("NEXT_LABEL");
expect(next).toContain(HIGHLIGHT);
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
expect(cancel).toContain("common.cancel");
expect(cancel).toContain("t(common.cancel)");
expect(cancel).not.toContain("data-onboarding");
expect(back).toContain("import.wizard.back");
expect(back).toContain("t(import.wizard.back)");
expect(back).not.toContain("data-onboarding");
});

View file

@ -13,13 +13,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
// plugins) and useEntitlement (→ LicenseContext): stub both.
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
let onboarding: OnboardingValue;
@ -45,12 +39,19 @@ const NEW_PROFILE: OnboardingSnapshot = {
importFolderSet: false,
hidden: false,
skipped: [],
acknowledged: [],
visitStepsInitialized: true,
};
/** Folder, source and import done: the next step is the manual category, on /transactions. */
/**
* Folder, source and import done: the next step is the manual category, on
* /transactions. Stopped after the import, so the visit steps are
* acknowledged (rule of the templates, #425).
*/
const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true,
acknowledged: ["categories", "reports"],
};
/** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = {
@ -81,6 +82,8 @@ function pathAt(
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
})
: null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
@ -90,6 +93,7 @@ function pathAt(
counters: state.counters,
hidden: state.hidden,
budgetReady,
visitStepsInitialized: state.visitStepsInitialized,
nextStep,
mode,
cardPage,
@ -97,6 +101,7 @@ function pathAt(
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
@ -188,9 +193,9 @@ describe("AppShell — the getting-started banner (#397)", () => {
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
});
it("in the Free edition, /budget never gets a card and the path ends at 5/5 (#400)", () => {
it("in the Free edition, /budget never gets a card and the path ends at 7/7 (#400)", () => {
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
expect(value.checklist?.total).toBe(5);
expect(value.checklist?.total).toBe(7);
expect(value.nextStep).toBeNull();
expect(value.cardPage).toBeNull();
expect(value.mode).toBe("complete");
@ -215,10 +220,19 @@ describe("AppShell — the getting-started banner (#397)", () => {
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
"t(onboarding.banner.progress[done=4,total=6])",
"t(onboarding.banner.progress[done=6,total=8])",
);
});
it("before the visit steps' initialization: no counter, and no end for a path that was complete (#425)", () => {
const waiting = renderAt("/reports", loaded({ ...IMPORTED, acknowledged: [], visitStepsInitialized: false }));
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
const complete = renderAt("/reports", loaded({ ...ALL_DONE, acknowledged: [], visitStepsInitialized: false }));
expect(complete).not.toContain(BANNER);
expect(complete).not.toContain("onboarding.banner.next");
});
it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"');

View file

@ -1,53 +1,37 @@
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409).
//
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a
// plain function, and its state survives from one call to the next as it
// would between two renders of a mounted component.
// renderToStaticMarkup runs no click, so React's hooks come from the shared
// harness (src/test/hookHarness.ts): the slot runs as a plain function, and
// its state survives from one call to the next as it would between two
// renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
cursor: 0,
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
pathname: "/reports",
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }),
useLocation: () => ({ pathname: ctx.pathname }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => harness.onboarding,
useOnboarding: () => ctx.onboarding,
}));
vi.mock("./Sidebar", () => ({ default: () => null }));
import { hookHarness } from "../../test/hookHarness";
import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -59,13 +43,20 @@ function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[]
function onboardingValue(): OnboardingValue {
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "banner",
cardPage: null,
@ -73,42 +64,49 @@ function onboardingValue(): OnboardingValue {
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
/** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps {
harness.cursor = 0;
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
expect(element.type).toBe(OnboardingBanner);
return element.props;
}
beforeEach(() => {
harness.slots = [];
harness.pathname = "/reports";
harness.onboarding = onboardingValue();
harness.clear();
ctx.pathname = "/reports";
ctx.onboarding = onboardingValue();
});
describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => {
const value = harness.onboarding as OnboardingValue;
const value = ctx.onboarding as OnboardingValue;
const props = renderSlot();
expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist);
expect(props.nextStep).toBe(value.nextStep);
expect(props.budgetReady).toBe(true);
expect(props.visitStepsInitialized).toBe(true);
expect(props.listOpen).toBe(false);
expect(props.onHide).toBe(value.hide);
expect(props.onResume).toBe(value.resume);
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
ctx.onboarding = {
...onboardingValue(),
mode: "none",
budgetReady: false,
visitStepsInitialized: false,
};
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
expect(props.visitStepsInitialized).toBe(false);
});
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
@ -121,19 +119,19 @@ describe("OnboardingBannerSlot (#397)", () => {
});
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
harness.pathname = "/import";
const shell = AppShell() as ReactNode;
ctx.pathname = "/import";
const shell = harness.render(() => AppShell() as ReactNode);
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined();
expect(slot.key).toBe("/import");
harness.pathname = "/transactions";
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
ctx.pathname = "/transactions";
const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions");
});
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
const shell = AppShell() as ReactNode;
const shell = harness.render(() => AppShell() as ReactNode);
const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function");

View file

@ -24,6 +24,7 @@ export function OnboardingBannerSlot() {
checklist={onboarding.checklist}
nextStep={onboarding.nextStep}
budgetReady={onboarding.budgetReady}
visitStepsInitialized={onboarding.visitStepsInitialized}
listOpen={listOpen}
onToggleList={() => setListOpen((open) => !open)}
onHide={onboarding.hide}

View file

@ -7,15 +7,9 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Link, MemoryRouter } from "react-router-dom";
// The fake `t` marks what it translates, `t(key[options])`: a string that
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that
// skipped `t` would show unmarked.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import OnboardingBanner, {
GETTING_STARTED_GUIDE,
@ -50,7 +44,7 @@ const ZERO: OnboardingCounters = {
budgetEntries: 0,
transactions: 0,
};
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
/** Profile, folder and source done: 3/8, the next step is "import" on /import. */
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
/** Up to the import: the next step is "manualCategory" on /transactions. */
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
@ -66,12 +60,19 @@ function bannerProps(
overrides: Partial<OnboardingBannerProps> = {},
counters: OnboardingCounters = THREE_DONE,
): OnboardingBannerProps {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
// A template stopped after the import has its visit steps acknowledged (#425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
});
return {
mode: checklist.complete ? "complete" : "banner",
checklist,
nextStep: nextStep(checklist),
budgetReady: true,
visitStepsInitialized: true,
listOpen: false,
onToggleList: vi.fn(),
onHide: vi.fn(),
@ -100,7 +101,7 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const html = markup(bannerProps());
expect(html).toContain('role="region"');
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
expect(html).toContain("t(onboarding.banner.progress[done=3,total=8])");
expect(html).toContain("t(onboarding.banner.next)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
expect(html).toContain("t(onboarding.checklist.hide)");
@ -123,6 +124,12 @@ describe("OnboardingBanner — banner mode (#397)", () => {
expect(html).toMatch(/href="\/import"/);
});
it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => {
const html = markup(bannerProps({ visitStepsInitialized: false }));
expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/);
});
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
const html = markup(bannerProps());
expect(html).toMatch(
@ -140,7 +147,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(container).toBeGreaterThan(-1);
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
for (const key of [
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
]) {
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
}
});
@ -150,8 +166,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
expect(list.props.items).toBe(props.checklist!.items);
expect(list.props.done).toBe(3);
expect(list.props.total).toBe(6);
expect(list.props.total).toBe(8);
expect(list.props.budgetReady).toBe(true);
expect(list.props.visitStepsInitialized).toBe(true);
// Each flag reaches the list on its own: the Budget row's pending state follows budgetReady alone.
const [waiting] = find(
tree(bannerProps({ listOpen: true, visitStepsInitialized: false })),
(e) => e.type === OnboardingChecklist,
);
expect(waiting.props.budgetReady).toBe(true);
expect(waiting.props.visitStepsInitialized).toBe(false);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);

View file

@ -24,6 +24,8 @@ export interface OnboardingBannerProps {
nextStep: ChecklistItem | null;
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
budgetReady: boolean;
/** False until the visit steps are initialized (#425): the counter waits too, its total depends on them. */
visitStepsInitialized: boolean;
/** The detailed list is unfolded under the banner. */
listOpen: boolean;
onToggleList: () => void;
@ -51,7 +53,7 @@ function BannerIcon() {
*
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
* which unfolds the detailed list in place, and "Hide". The counter waits
* for `budgetReady`, like the list's own counter.
* for `budgetReady && visitStepsInitialized`, like the list's own counter.
* - `complete` (D4): the end message, "Open the guide" and "Close", which
* hides the path until it is shown again from Settings → Users.
* - Anything else (`none`, `card`) renders nothing.
@ -61,12 +63,14 @@ export default function OnboardingBanner({
checklist,
nextStep,
budgetReady,
visitStepsInitialized,
listOpen,
onToggleList,
onHide,
onResume,
}: OnboardingBannerProps) {
const { t } = useTranslation();
const totalKnown = budgetReady && visitStepsInitialized;
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
if (mode === "complete") {
@ -92,12 +96,12 @@ export default function OnboardingBanner({
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className={ROW_CLASS}>
<BannerIcon />
{budgetReady && (
{totalKnown && (
<span className="font-semibold text-[var(--foreground)] tabular-nums">
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
</span>
)}
{budgetReady && nextStep && (
{totalKnown && nextStep && (
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
)}
{nextStep && (
@ -133,6 +137,7 @@ export default function OnboardingBanner({
done={checklist.done}
total={checklist.total}
budgetReady={budgetReady}
visitStepsInitialized={visitStepsInitialized}
onHide={onHide}
onResume={onResume}
/>

View file

@ -1,46 +1,27 @@
// OnboardingStepCard — the connected card's wiring (#398).
// OnboardingStepCard — the connected card's wiring (#398, #409).
//
// renderToStaticMarkup runs no effect, so the focus move would stay unguarded.
// React's `useRef` and `useEffect` are stubbed (technique of
// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): the card
// runs as a plain function, its refs survive from one call to the next as they
// would between two renders of a mounted component, and its effects are
// captured and run by hand. The router and the path are faked.
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// card runs as a plain function, its refs survive from one call to the next as
// they would between two renders of a mounted component, and `commit()` runs
// its effect when one of its dependencies changed, as React does. The router
// and the path are faked.
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactElement } from "react";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
refs: [] as Array<{ current: unknown }>,
cursor: 0,
effects: [] as Effect[],
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
location: { pathname: "/import", state: null as unknown, key: "arrival-1" },
navigate: vi.fn(),
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useRef: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.refs)) harness.refs[index] = { current: init };
return harness.refs[index];
},
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => harness.location,
useNavigate: () => harness.navigate,
useLocation: () => ctx.location,
useNavigate: () => ctx.navigate,
}));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
import { hookHarness } from "../../test/hookHarness";
import OnboardingStepCard from "./OnboardingStepCard";
import StepCard, { type StepCardProps } from "./StepCard";
import { computeChecklist } from "../../utils/onboardingChecklist";
@ -48,6 +29,8 @@ import { nextStep, type StepCardModel } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
import type { OnboardingCounters } from "../../shared/types";
const harness = hookHarness();
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
@ -62,13 +45,20 @@ const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transact
/** The path latched on /import, as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
// A template stopped after the import has its visit steps acknowledged (#425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/import",
@ -76,6 +66,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
@ -86,28 +77,27 @@ const DONE: StepCardModel = { text: null, done: true };
/** One render of the mounted card: its hooks are called in the same order each time. */
function render(model: StepCardModel | null = SOURCE_LIST): ReactElement<StepCardProps> | null {
harness.cursor = 0;
harness.effects = [];
return OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null;
return harness.render(() => OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null);
}
/** The card's title ref: its first hook. */
const titleRef = () => harness.slots[0] as { current: unknown };
/** The title the card's ref points at, once React would have attached it. */
function attachTitle(): { focus: ReturnType<typeof vi.fn> } {
const title = { focus: vi.fn() };
harness.refs[0].current = title;
titleRef().current = title;
return title;
}
const runEffects = () => {
for (const effect of harness.effects) effect.fn();
};
/** The commit of the last render: its effect runs if one of its dependencies changed. */
const commit = () => harness.commit();
beforeEach(() => {
harness.refs = [];
harness.effects = [];
harness.location = { pathname: "/import", state: null, key: "arrival-1" };
harness.navigate.mockReset();
harness.onboarding = path(FOLDER_SET);
harness.clear();
ctx.location = { pathname: "/import", state: null, key: "arrival-1" };
ctx.navigate.mockReset();
ctx.onboarding = path(FOLDER_SET);
});
describe("OnboardingStepCard — when it shows (#398)", () => {
@ -119,18 +109,18 @@ describe("OnboardingStepCard — when it shows (#398)", () => {
it("renders nothing in another mode, on a route that is not the latched one, or without a model", () => {
for (const mode of ["none", "banner", "complete"] as const) {
harness.onboarding = path(FOLDER_SET, { mode });
ctx.onboarding = path(FOLDER_SET, { mode });
expect(render(), mode).toBeNull();
}
harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
ctx.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
expect(render()).toBeNull();
harness.onboarding = path(FOLDER_SET);
ctx.onboarding = path(FOLDER_SET);
expect(render(null)).toBeNull();
});
it("stays once the page's steps are done: the latch keeps it, not the next step", () => {
harness.onboarding = path(IMPORTED);
const value = harness.onboarding as OnboardingValue;
ctx.onboarding = path(IMPORTED);
const value = ctx.onboarding as OnboardingValue;
expect(value.nextStep?.to).toBe("/transactions");
const card = render(DONE);
expect(card).not.toBeNull();
@ -142,30 +132,83 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
const props = render()!.props;
expect(props.step.key).toBe("source");
expect(props.position).toEqual({ n: 3, total: 6 });
expect(props.position).toEqual({ n: 3, total: 8 });
expect(props.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false);
expect(props.titleRef).toBe(harness.refs[0]);
expect(props.titleRef).toBe(titleRef());
});
it("the next step once done, and a wait while the next step is still on this page", () => {
harness.onboarding = path(IMPORTED);
ctx.onboarding = path(IMPORTED);
expect(render(DONE)!.props.next).toEqual({
kind: "step",
step: (harness.onboarding as OnboardingValue).nextStep,
step: (ctx.onboarding as OnboardingValue).nextStep,
});
// Before the re-read, the next step is still the source step, on /import.
harness.onboarding = path(FOLDER_SET);
ctx.onboarding = path(FOLDER_SET);
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
});
it("no 'Step n of N' until the Budget entitlement is known", () => {
harness.onboarding = path(FOLDER_SET, { budgetReady: false });
ctx.onboarding = path(FOLDER_SET, { budgetReady: false });
expect(render()!.props.position).toBeNull();
});
it("no 'Step n of N' and no end until the visit steps are initialized (#425)", () => {
ctx.onboarding = path(FOLDER_SET, { visitStepsInitialized: false });
expect(render()!.props.position).toBeNull();
ctx.onboarding = path(IMPORTED, { visitStepsInitialized: false, nextStep: null });
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
ctx.onboarding = path(IMPORTED, { nextStep: null });
expect(render(DONE)!.props.next).toEqual({ kind: "end" });
});
it("no 'Got it' on a data step", () => {
expect(render()!.props.onAcknowledge).toBeUndefined();
});
it("'Got it' on the Categories card acknowledges the categories step (#425, #432)", () => {
ctx.location = { pathname: "/categories", state: null, key: "arrival-1" };
ctx.onboarding = path(IMPORTED, {
cardPage: "/categories",
checklist: computeChecklist(IMPORTED, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
}),
});
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;
expect(props.step.key).toBe("categories");
expect(props.position).toEqual({ n: 5, total: 8 });
expect(props.onAcknowledge).toBeTypeOf("function");
props.onAcknowledge!();
expect(value.acknowledge).toHaveBeenCalledWith("categories");
expect(value.skip).not.toHaveBeenCalled();
});
it("'Got it' on the Reports card acknowledges the reports step, last of eight (#432)", () => {
const allButReports: OnboardingCounters = { ...IMPORTED, manualCategorized: 1, budgetEntries: 1 };
ctx.location = { pathname: "/reports", state: null, key: "arrival-1" };
ctx.onboarding = path(allButReports, {
cardPage: "/reports",
checklist: computeChecklist(allButReports, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories"],
}),
});
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;
expect(props.step.key).toBe("reports");
expect(props.position).toEqual({ n: 8, total: 8 });
props.onAcknowledge!();
expect(value.acknowledge).toHaveBeenCalledWith("reports");
});
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
const value = harness.onboarding as OnboardingValue;
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;
props.onSkip();
expect(value.skip).toHaveBeenCalledWith("source");
@ -179,58 +222,60 @@ describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => {
const ARRIVAL = { onboarding: { focus: true } };
it("moves the focus to the title, then consumes the navigation state", () => {
harness.location = { ...harness.location, state: ARRIVAL };
ctx.location = { ...ctx.location, state: ARRIVAL };
render();
const title = attachTitle();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
expect(ctx.navigate).toHaveBeenCalledTimes(1);
expect(ctx.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
expect(vi.mocked(ctx.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
title.focus.mock.invocationCallOrder[0],
);
});
it("does it once per arrival: StrictMode's second run and later renders do nothing", () => {
harness.location = { ...harness.location, state: ARRIVAL };
ctx.location = { ...ctx.location, state: ARRIVAL };
render();
const title = attachTitle();
runEffects();
runEffects();
commit();
harness.replayEffects();
render();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
expect(ctx.navigate).toHaveBeenCalledTimes(1);
// A new arrival on the same mounted page asks again.
harness.location = { ...harness.location, key: "arrival-2" };
ctx.location = { ...ctx.location, key: "arrival-2" };
render();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(2);
expect(harness.navigate).toHaveBeenCalledTimes(2);
expect(ctx.navigate).toHaveBeenCalledTimes(2);
});
it("waits for the card to show before focusing and consuming", () => {
harness.location = { ...harness.location, state: ARRIVAL };
ctx.location = { ...ctx.location, state: ARRIVAL };
expect(render(null)).toBeNull();
runEffects();
expect(harness.navigate).not.toHaveBeenCalled();
commit();
expect(ctx.navigate).not.toHaveBeenCalled();
render();
const title = attachTitle();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
expect(ctx.navigate).toHaveBeenCalledTimes(1);
});
it("an ordinary visit, without navigation state or with a malformed one, does nothing and does not fail", () => {
for (const state of [null, undefined, "focus", { onboarding: "focus" }, { onboarding: { focus: "yes" } }]) {
harness.location = { ...harness.location, state };
// A fresh mount per visit: its effect runs at its first commit.
harness.clear();
ctx.location = { ...ctx.location, state };
render();
const title = attachTitle();
expect(() => runEffects()).not.toThrow();
expect(() => commit()).not.toThrow();
expect(title.focus).not.toHaveBeenCalled();
}
expect(harness.navigate).not.toHaveBeenCalled();
expect(ctx.navigate).not.toHaveBeenCalled();
});
});

View file

@ -9,6 +9,7 @@ import {
showsStepCard,
type StepCardModel,
} from "../../utils/onboardingPath";
import { VISIT_STEP_KEYS } from "../../shared/types";
export interface OnboardingStepCardProps {
/** The page's card model; null for no card yet (the page's own data is not read). */
@ -28,8 +29,11 @@ export interface OnboardingStepCardProps {
* then consumes the navigation state, once per arrival: StrictMode runs the
* effect twice on mount, a guard on the location key keeps it to one focus
* and one replace. An ordinary visit carries no state and does nothing.
* - It wires "Skip this step" to the step it shows, "Close" to `hide`, and
* the next step's link to the path's next step.
* - It wires "Skip this step" to the step it shows, "Got it" to
* `acknowledge` when that step is a visit step (#425), "Close" to `hide`,
* and the next step's link to the path's next step.
* - Its rank "Step n of N" and its end wait for the Budget entitlement and
* the visit steps' initialization (#425): N depends on both.
*/
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
const onboarding = useOnboarding();
@ -55,15 +59,18 @@ export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
if (!shown) return null;
const { skip, hide } = onboarding;
const { skip, acknowledge, hide } = onboarding;
const totalKnown = onboarding.budgetReady && onboarding.visitStepsInitialized;
const isVisitStep = VISIT_STEP_KEYS.includes(current.step.key);
return (
<StepCard
step={current.step}
position={onboarding.budgetReady ? { n: current.n, total: current.total } : null}
position={totalKnown ? { n: current.n, total: current.total } : null}
text={model.text}
done={model.done}
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)}
next={cardNext(onboarding.nextStep, pathname, totalKnown)}
onSkip={() => void skip(current.step.key)}
onAcknowledge={isVisitStep ? () => void acknowledge(current.step.key) : undefined}
onHide={() => void hide()}
titleRef={titleRef}
/>

View file

@ -7,15 +7,9 @@ import { createRef, isValidElement, type ReactElement, type ReactNode } from "re
import { renderToStaticMarkup } from "react-dom/server";
import { Link, MemoryRouter } from "react-router-dom";
// The fake `t` marks what it translates, `t(key[options])`: a string that
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that
// skipped `t` would show unmarked.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import StepCard, { type StepCardProps } from "./StepCard";
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
@ -46,15 +40,19 @@ const ZERO: OnboardingCounters = {
transactions: 0,
};
const rows = (counters: OnboardingCounters = ZERO) =>
computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }).items;
computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
}).items;
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
rows(counters).find((item) => item.key === key)!;
/** The source step on /import, step 3 of 6, not done yet. */
/** The source step on /import, step 3 of 8, not done yet. */
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
return {
step: row("source"),
position: { n: 3, total: 6 },
position: { n: 3, total: 8 },
text: { key: "onboarding.card.import.sourceList" },
done: false,
next: { kind: "wait" },
@ -68,7 +66,7 @@ function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
cardProps({
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
position: { n: 4, total: 6 },
position: { n: 4, total: 8 },
text: null,
done: true,
next: { kind: "step", step: row("manualCategory") },
@ -98,7 +96,7 @@ describe("StepCard — the card (#398)", () => {
expect(heading).not.toBeNull();
expect(heading![0]).toContain('tabindex="-1"');
const title = heading![1];
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=6])");
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=8])");
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
title.indexOf("t(onboarding.checklist.items.source)"),
);
@ -210,3 +208,52 @@ describe("StepCard — end of the path (#398)", () => {
expect(html).toContain("t(onboarding.card.skip)");
});
});
describe("StepCard — \"Got it\" on a visit step (#425)", () => {
/** The categories step, step 5 of 8, not acknowledged yet. */
const visitProps = (overrides: Partial<StepCardProps> = {}) =>
cardProps({
step: row("categories"),
position: { n: 5, total: 8 },
text: null,
onAcknowledge: vi.fn(),
...overrides,
});
it("offers 'Got it' before 'Skip this step', wired to onAcknowledge", () => {
const props = visitProps();
const html = markup(props);
expect(html).toContain("t(onboarding.card.acknowledge)");
expect(html.indexOf("t(onboarding.card.acknowledge)")).toBeLessThan(
html.indexOf("t(onboarding.card.skip)"),
);
const buttons = find(tree(props), (e) => e.type === "button");
expect(buttons.map((b) => texts(b.props.children))).toEqual([
["t(onboarding.card.acknowledge)"],
["t(onboarding.card.skip)"],
]);
(buttons[0].props.onClick as () => void)();
expect(props.onAcknowledge).toHaveBeenCalledTimes(1);
expect(props.onSkip).not.toHaveBeenCalled();
});
it("is never there without onAcknowledge", () => {
expect(markup(cardProps())).not.toContain("onboarding.card.acknowledge");
expect(markup(visitProps({ onAcknowledge: undefined }))).not.toContain(
"onboarding.card.acknowledge",
);
});
it("is never there on a done step, nor once the page's steps are done", () => {
const acknowledged = computeChecklist(ZERO, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories"],
}).items.find((item) => item.key === "categories")!;
expect(markup(visitProps({ step: acknowledged }))).not.toContain("onboarding.card.acknowledge");
const done = markup(visitProps({ done: true, next: { kind: "step", step: row("manualCategory") } }));
expect(done).not.toContain("onboarding.card.acknowledge");
expect(done).toContain("t(onboarding.card.done)");
});
});

View file

@ -13,7 +13,7 @@ import type { ChecklistItem } from "../../shared/types";
export interface StepCardProps {
/** The step the card shows (`cardStep`): its label is the title. */
step: ChecklistItem;
/** "Step n of N", null until the Budget entitlement is known: N depends on its lock. */
/** "Step n of N", null until the Budget entitlement is known and the visit steps initialized: N depends on both. */
position: { n: number; total: number } | null;
/** The page's sentence, null when "Done." says it all. */
text: StepCardText | null;
@ -23,6 +23,8 @@ export interface StepCardProps {
next: StepCardNext;
/** "Skip this step": the provider's `skip`, for `step`. */
onSkip: () => void;
/** "Got it" on a visit step (#425): the provider's `acknowledge`, for `step`. No button when omitted. */
onAcknowledge?: () => void;
/** "Close" at the end of the path: the provider's `hide`. */
onHide: () => void;
/** The title, which takes the focus after an arrival by "Next step". */
@ -33,6 +35,8 @@ const REGION_CLASS =
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
const TEXT_BUTTON_CLASS =
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
const ACKNOWLEDGE_BUTTON_CLASS =
"rounded-lg bg-[var(--primary)] px-3 py-1 font-medium text-white hover:opacity-90 transition-opacity";
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
/**
@ -48,7 +52,8 @@ const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--prima
* "Done." it receives once the page's steps are done is announced.
* - "Skip this step" shows until the page's steps are done, and only while
* the step itself is not: skipping a done step would only leave a stale
* "skipped" mark behind.
* "skipped" mark behind. A visit step (#425) gets "Got it" before it, on
* the same condition, when `onAcknowledge` is given.
* - Once done: "Next step: <step> →", linked to its page with the step's
* navigation state; the end message, "Open the guide" and "Close" when no
* step is left; nothing yet while `next` waits.
@ -60,6 +65,7 @@ export default function StepCard({
done,
next,
onSkip,
onAcknowledge,
onHide,
titleRef,
}: StepCardProps) {
@ -105,6 +111,11 @@ export default function StepCard({
</>
)}
</p>
{!done && !step.done && onAcknowledge && (
<button type="button" onClick={onAcknowledge} className={ACKNOWLEDGE_BUTTON_CLASS}>
{t("onboarding.card.acknowledge")}
</button>
)}
{!done && !step.done && (
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
{t("onboarding.card.skip")}

View file

@ -0,0 +1,84 @@
// ProfileFormModal — deleting a secondary profile waits for the confirmation (#421).
//
// Before #421 the dialog plugin's async `window.confirm` returned a Promise, always truthy:
// the profile and its database were deleted before the user answered, and "Cancel" stopped
// nothing. The modal runs as a plain function under the shared hook harness (#409); the test
// clicks the delete button of the secondary profile through its props, the confirmation
// answered by a mocked `confirmAction`.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn<(message: string) => Promise<boolean>>(),
deleteProfile: vi.fn(async () => undefined),
}));
vi.mock("../../services/confirmService", () => ({ confirmAction: mocks.confirmAction }));
vi.mock("../../hooks/useEntitlement", () => ({
useEntitlement: () => ({ allowed: true, ready: true }),
}));
vi.mock("../../contexts/ProfileContext", () => ({
useProfile: () => ({
profiles: [
{ id: "default", name: "Principal", color: "#4A90A4", pin_hash: null, db_filename: "simpl_resultat.db", created_at: "" },
{ id: "second", name: "Second", color: "#C17767", pin_hash: null, db_filename: "profile_second.db", created_at: "" },
],
createProfile: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: mocks.deleteProfile,
setPin: vi.fn(),
}),
}));
import { Trash2 } from "lucide-react";
import { hookHarness } from "../../test/hookHarness";
import ProfileFormModal from "./ProfileFormModal";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode; onClick?: () => Promise<void> };
/** The elements of the tree whose children hold an element of the given type. */
function holding(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => holding(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
const children = ([] as ReactNode[]).concat(el.props.children ?? []);
const holds = children.some((c) => isValidElement(c) && c.type === type);
return [...(holds ? [el] : []), ...holding(el.props.children, type)];
}
/** Clicks the only delete button, the secondary profile's, and waits for its handler. */
async function clickDelete() {
const tree = harness.render(() => ProfileFormModal({ onClose: vi.fn() }));
const buttons = holding(tree, Trash2);
expect(buttons).toHaveLength(1);
await buttons[0].props.onClick?.();
}
beforeEach(() => {
harness.clear();
mocks.confirmAction.mockReset();
mocks.deleteProfile.mockClear();
});
describe("ProfileFormModal deletion (#421)", () => {
it("keeps the profile when the confirmation is refused", async () => {
mocks.confirmAction.mockResolvedValue(false);
await clickDelete();
expect(mocks.confirmAction).toHaveBeenCalledWith("t(profile.deleteConfirm)");
expect(mocks.deleteProfile).not.toHaveBeenCalled();
});
it("deletes the profile once confirmed", async () => {
mocks.confirmAction.mockResolvedValue(true);
await clickDelete();
expect(mocks.deleteProfile).toHaveBeenCalledWith("second");
});
});

View file

@ -5,6 +5,7 @@ import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements";
import { isProfileCreationLocked } from "../../shared/profileGate";
import { confirmAction } from "../../services/confirmService";
import UpsellGate from "../shared/UpsellGate";
const PRESET_COLORS = [
@ -88,7 +89,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const handleDelete = async (id: string) => {
const profile = profiles.find((p) => p.id === id);
if (!profile || profile.db_filename === "simpl_resultat.db") return;
if (!confirm(t("profile.deleteConfirm"))) return;
if (!(await confirmAction(t("profile.deleteConfirm")))) return;
await deleteProfile(id);
};

View file

@ -12,12 +12,7 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
i18n: { language: "fr" },
})),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("lucide-react", () => ({
Inbox: () => null,
@ -164,8 +159,8 @@ describe("FilterPanel — render", () => {
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("reports.filters.accounts.label");
expect(text).toContain("reports.filters.accounts.hint");
expect(text).toContain("t(reports.filters.accounts.label)");
expect(text).toContain("t(reports.filters.accounts.hint)");
});
});

View file

@ -0,0 +1,66 @@
// HubReportNavCard — the report card of the hub and its getting-started props
// (#432). No jsdom: react-dom/server renders the markup, and the component
// called as a function exposes the props of the link it returns (the
// FilterPanel.test.tsx pattern), for the click handler.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import HubReportNavCard, { REPORTS_TRENDS_TARGET, type HubReportNavCardProps } from "./HubReportNavCard";
const BASE: HubReportNavCardProps = {
to: "/reports/trends",
icon: null,
title: "Tendances",
description: "Évolution",
};
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const render = (props: Partial<HubReportNavCardProps> = {}) =>
renderToStaticMarkup(
<MemoryRouter>
<HubReportNavCard {...BASE} {...props} />
</MemoryRouter>,
);
/** The link the card returns, read without rendering it. */
const link = (props: Partial<HubReportNavCardProps> = {}) =>
HubReportNavCard({ ...BASE, ...props }) as ReactElement<{ onClick?: () => void; to: string }>;
describe("HubReportNavCard — the getting-started target (#432)", () => {
it("carries the highlight on its link with onboardingTarget", () => {
const html = render({ onboardingTarget: true });
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
expect(html.indexOf(HIGHLIGHT)).toBeLessThan(html.indexOf('href="/reports/trends"'));
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
});
it("carries no highlight without it", () => {
for (const onboardingTarget of [undefined, false]) {
const html = render({ onboardingTarget });
expect(html).not.toContain("data-onboarding-target");
expect(html).not.toContain("data-onboarding-highlight");
}
});
it("a click calls onNavigate when it is given", () => {
const onNavigate = vi.fn();
const element = link({ onNavigate });
expect(element.props.to).toBe("/reports/trends");
element.props.onClick!();
expect(onNavigate).toHaveBeenCalledTimes(1);
});
it("without onNavigate, the link has no click handler of its own", () => {
expect(link().props.onClick).toBeUndefined();
});
it("keeps its lock badge, translated", () => {
expect(render({ locked: true })).toContain('aria-label="t(nav.locked)"');
expect(render()).not.toContain("t(nav.locked)");
});
});

View file

@ -2,6 +2,10 @@ import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { Lock } from "lucide-react";
import { useTranslation } from "react-i18next";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
/** The target key of the Trends card while the Reports step card points at it (#432). */
export const REPORTS_TRENDS_TARGET = "reports-trends-card";
export interface HubReportNavCardProps {
to: string;
@ -13,6 +17,13 @@ export interface HubReportNavCardProps {
* entitled). The card stays clickable — the gated route renders the upsell.
*/
locked?: boolean;
/**
* The getting-started card of the Reports page points at this card (#432):
* the link carries the highlight attributes.
*/
onboardingTarget?: boolean;
/** Called on a click, before the navigation (the Trends card acknowledges the step, D17). */
onNavigate?: () => void;
}
export default function HubReportNavCard({
@ -21,12 +32,16 @@ export default function HubReportNavCard({
title,
description,
locked,
onboardingTarget,
onNavigate,
}: HubReportNavCardProps) {
const { t } = useTranslation();
return (
<Link
to={to}
onClick={onNavigate}
{...onboardingTargetAttributes(REPORTS_TRENDS_TARGET, onboardingTarget)}
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
>
{locked && (

View file

@ -1,13 +1,14 @@
// DocsSubsections — unit tests (Issue #355).
//
// Same approach as FilterPanel.test.tsx: no jsdom in this project, and the
// component's only hook is `useTranslation` (mocked into a lookup over a
// component's only hook is `useTranslation` (mocked by `i18nMock` over a
// fixture), so it is invoked directly as a function and its React-element
// tree is walked without a renderer.
import { describe, it, expect, vi } from "vitest";
const FIXTURE: Record<string, unknown> = {
// Hoisted: the factory of `vi.mock` below reads it when the module loads.
const FIXTURE = vi.hoisted((): Record<string, unknown> => ({
"docs.import.subsections": [
{
id: "import-export",
@ -22,16 +23,14 @@ const FIXTURE: Record<string, unknown> = {
steps: ["Expected header"],
},
],
};
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
// i18next returns the key itself when nothing is declared under it.
t: (key: string) => (key in FIXTURE ? FIXTURE[key] : key),
i18n: { language: "fr" },
})),
}));
// The fixture stands for what the locale declares; every other key goes
// through the marked `t` (#389), as i18next returns a string for a missing key.
vi.mock("react-i18next", async () =>
(await import("../../test/i18nMock")).i18nMock({ fixture: FIXTURE }),
);
import DocsSubsections, { readSubsections } from "./DocsSubsections";
// ---------------------------------------------------------------------------

View file

@ -10,16 +10,10 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
let language = "fr";
// The fake `t` marks what it translates, `t(key[options])`: the assertions written in that form
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: the assertions written in that form
// fail on a string that skipped `t` (#387 review). Substring assertions on a bare key still read
// the key inside the mark.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock({ language: () => language }));
let context: Record<string, unknown>;
vi.mock("../../contexts/LicenseContext", async (importOriginal) => ({

View file

@ -7,6 +7,7 @@ import { useLicenseMachines } from "../../hooks/useLicenseMachines";
import { parseLicenseError, type LicenseError } from "../../services/licenseService";
import { purchaseUrl } from "../../shared/purchaseUrl";
import { licenseExpiryDisplay } from "../../shared/licenseExpiry";
import { confirmAction } from "../../services/confirmService";
import { logError } from "../../services/logService";
import LicenseKeyForm from "./LicenseKeyForm";
@ -158,7 +159,7 @@ function LicenseCardBody() {
{info !== null && (
<button
type="button"
onClick={() => void removeKey({ confirm: (message) => window.confirm(message), t })}
onClick={() => void removeKey({ confirm: confirmAction, t })}
// Not while a key is being stored: the removal would delete it.
disabled={!canRemoveKey || validating}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm text-[var(--negative)] disabled:opacity-50"

View file

@ -4,12 +4,7 @@ import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import LicenseKeyForm from "./LicenseKeyForm";
@ -30,18 +25,18 @@ function render(validationError: string | null, busy = false): string {
describe("LicenseKeyForm", () => {
it("names a refused key in the app language", () => {
const html = render('{"code":"invalid_prefix"}');
expect(html).toContain("license.errors.invalidPrefix");
expect(html).toContain("t(license.errors.invalidPrefix)");
expect(html).not.toContain("{&quot;code&quot;");
});
it("never prints a raw backend string", () => {
const html = render("License key must start with SR-BASE- or SR-PREMIUM-");
expect(html).toContain("license.errors.unknown");
expect(html).toContain("t(license.errors.unknown)");
expect(html).not.toContain("License key must start");
});
it("shows no error line before a submission is refused", () => {
expect(render(null)).not.toContain("license.errors.");
expect(render(null)).not.toContain("t(license.errors.");
});
it("blocks the submission while a machine activation runs", () => {

View file

@ -11,6 +11,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mocks — declared before imports to satisfy vi.mock hoisting
// ---------------------------------------------------------------------------
// React's hooks from the shared harness (#409): the toggle renders nothing while
// it loads, so the render test below presets its `loading` slot.
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(),
}));
@ -21,16 +25,16 @@ vi.mock("../../services/userPreferenceService", () => ({
deletePreference: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
})),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// ---------------------------------------------------------------------------
// Imports (after mock declarations)
// ---------------------------------------------------------------------------
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { hookHarness } from "../../test/hookHarness";
import { PriceFetchConsentToggle } from "./PriceFetchConsentToggle";
import { useIsPremium } from "../../hooks/useIsPremium";
import {
getPreference,
@ -42,6 +46,7 @@ import {
// Helpers
// ---------------------------------------------------------------------------
const harness = hookHarness();
const mockUseIsPremium = vi.mocked(useIsPremium);
const mockGetPreference = vi.mocked(getPreference);
const mockSetPreference = vi.mocked(setPreference);
@ -210,3 +215,39 @@ describe("PriceFetchConsentToggle — premium guard", () => {
expect(buttonDisabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: what the toggle displays (#389)
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — displayed texts", () => {
beforeEach(() => {
vi.resetAllMocks();
harness.clear();
});
/** One render once loaded: slots by call order, hasConsent, showConfirm, loading. */
function markup(premium: boolean, consent: boolean, confirming: boolean): string {
setPremium(premium);
harness.slots[0] = consent;
harness.slots[1] = confirming;
harness.slots[2] = false;
return renderToStaticMarkup(harness.render(() => PriceFetchConsentToggle() as ReactNode));
}
it("names the section, the consent row and the not-premium tooltip through t", () => {
const html = markup(false, false, false);
expect(html).toContain(">t(settings.privacy.title)<");
expect(html).toContain(">t(settings.privacy.priceFetchConsent.label)<");
expect(html).toContain(">t(settings.privacy.priceFetchConsent.description)<");
expect(html).toContain('title="t(settings.privacy.priceFetchConsent.notPremium)"');
});
it("names the revoke confirmation through t", () => {
const html = markup(true, true, true);
expect(html).toContain('aria-label="t(settings.privacy.priceFetchConsent.revokeButton)"');
expect(html).toContain(">t(settings.privacy.priceFetchConsent.confirmRevoke)<");
expect(html).toContain(">t(common.cancel)<");
expect(html).toContain(">t(settings.privacy.priceFetchConsent.revokeButton)<");
});
});

View file

@ -1,9 +1,21 @@
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Category } from "../../shared/types";
/**
* Minimal shape the combobox needs. `Category` (transactions, reports) and
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
* own rows without converting: `parent_id` may be `null` or absent for a root,
* `i18n_key` names the seed translation when the row comes from the seed.
*/
export interface CategoryLike {
id: number;
name: string;
parent_id?: number | null;
sort_order: number;
i18n_key?: string | null;
}
interface CategoryComboboxProps {
categories: Category[];
interface CategoryComboboxProps<T extends CategoryLike = CategoryLike> {
categories: T[];
value: number | null;
onChange: (id: number | null) => void;
placeholder?: string;
@ -23,8 +35,8 @@ function normalize(s: string): string {
}
// Compute depth of each category based on parent_id chain
function computeDepths(categories: Category[]): Map<number, number> {
const byId = new Map<number, Category>();
function computeDepths(categories: CategoryLike[]): Map<number, number> {
const byId = new Map<number, CategoryLike>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
function depthOf(id: number, seen: Set<number>): number {
@ -58,18 +70,18 @@ function computeDepths(categories: Category[]): Map<number, number> {
* Orphans (category whose parent is missing or inactive / filtered out) are
* emitted at the end, each treated as a pseudo-root, so nothing disappears.
*/
export function sortHierarchical(
categories: Category[],
resolveName: (cat: Category) => string,
): Category[] {
export function sortHierarchical<T extends CategoryLike>(
categories: T[],
resolveName: (cat: T) => string,
): T[] {
if (categories.length === 0) return [];
const ids = new Set<number>();
for (const c of categories) ids.add(c.id);
// Group by parent bucket: root (`null`) or parent id.
const childrenByParent = new Map<number | null, Category[]>();
const orphans: Category[] = [];
const childrenByParent = new Map<number | null, T[]>();
const orphans: T[] = [];
for (const c of categories) {
if (c.parent_id == null) {
const bucket = childrenByParent.get(null) ?? [];
@ -84,16 +96,16 @@ export function sortHierarchical(
}
}
const compare = (a: Category, b: Category) => {
const compare = (a: T, b: T) => {
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
return resolveName(a).localeCompare(resolveName(b));
};
for (const bucket of childrenByParent.values()) bucket.sort(compare);
orphans.sort(compare);
const out: Category[] = [];
const out: T[] = [];
const visited = new Set<number>();
const visit = (cat: Category) => {
const visit = (cat: T) => {
if (visited.has(cat.id)) return; // defensive against cycles
visited.add(cat.id);
out.push(cat);
@ -108,7 +120,7 @@ export function sortHierarchical(
return out;
}
export default function CategoryCombobox({
export default function CategoryCombobox<T extends CategoryLike = CategoryLike>({
categories,
value,
onChange,
@ -118,7 +130,7 @@ export default function CategoryCombobox({
extraOptions,
onExtraSelect,
activeExtra,
}: CategoryComboboxProps) {
}: CategoryComboboxProps<T>) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@ -135,7 +147,7 @@ export default function CategoryCombobox({
// Resolve the display name for a category: seed rows carry an i18n_key that
// we translate with name as defaultValue; user-created rows just use name.
const displayName = useCallback(
(c: Category) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
(c: T) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
[t]
);

View file

@ -16,13 +16,7 @@ vi.mock("../../services/logService", async (importOriginal) => ({
}));
let language = "fr";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock({ language: () => language }));
// The locked screen and the Updates card read one thing from the license context:
// whether the stored key was revoked (#365).
@ -69,19 +63,19 @@ describe("UpsellGate with a revoked key (#365)", () => {
);
it("says nothing about a revocation for a key that is not revoked", () => {
expect(gate()).not.toContain("upsell.revoked");
expect(gate()).not.toContain("t(upsell.revoked)");
});
it("names the revocation between the feature description and the CTAs", () => {
licenseRevoked = true;
const html = gate();
const line = html.indexOf("upsell.revoked");
expect(line).toBeGreaterThan(html.indexOf("upsell.features.budget"));
expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
expect(html.split("upsell.revoked")).toHaveLength(2);
const line = html.indexOf("t(upsell.revoked)");
expect(line).toBeGreaterThan(html.indexOf("t(upsell.features.budget)"));
expect(line).toBeLessThan(html.indexOf("t(upsell.ctaGet["));
expect(html.split("t(upsell.revoked)")).toHaveLength(2);
// The way out stays: buying, or entering another key.
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).toContain("upsell.ctaHaveKey");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
expect(html).toContain("t(upsell.ctaHaveKey)");
});
});
@ -92,10 +86,10 @@ describe("UpsellGate wiring", () => {
<UpsellGate feature="budget" requiredTier="base" />
</MemoryRouter>,
);
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
expect(html).not.toContain("disabled");
expect(html).not.toContain("upsell.premiumNotForSale");
expect(html).toContain("upsell.ctaHaveKey");
expect(html).not.toContain("t(upsell.premiumNotForSale)");
expect(html).toContain("t(upsell.ctaHaveKey)");
});
it("Premium screen: disabled button with the not-for-sale note", () => {
@ -105,14 +99,14 @@ describe("UpsellGate wiring", () => {
</MemoryRouter>,
);
expect(html).toContain('disabled=""');
expect(html).toContain("upsell.ctaGet[tier=license.editions.premium]");
expect(html).toContain("upsell.premiumNotForSale");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.premium)])");
expect(html).toContain("t(upsell.premiumNotForSale)");
});
it("outside any router (ProfileSelectionPage, ErrorPage): renders, without I already have a key", () => {
const html = renderToStaticMarkup(<UpsellGate feature="multi-profile" requiredTier="base" />);
expect(html).toContain("upsell.ctaGet");
expect(html).not.toContain("upsell.ctaHaveKey");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
expect(html).not.toContain("t(upsell.ctaHaveKey)");
});
it("forwards onNavigate to I already have a key only", () => {
@ -157,11 +151,11 @@ describe("UpdateCard in the free edition", () => {
<UpdateCard />
</MemoryRouter>,
);
expect(html).toContain("settings.updates.notEntitled");
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).toContain("upsell.ctaHaveKey");
expect(html).toContain("settings.updates.downloadLatest");
expect(html).not.toContain("upsell.revoked");
expect(html).toContain("t(settings.updates.notEntitled)");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
expect(html).toContain("t(upsell.ctaHaveKey)");
expect(html).toContain("t(settings.updates.downloadLatest)");
expect(html).not.toContain("t(upsell.revoked)");
});
it("names a revoked key between the message and the CTAs (#365)", () => {
@ -171,9 +165,9 @@ describe("UpdateCard in the free edition", () => {
<UpdateCard />
</MemoryRouter>,
);
const line = html.indexOf("upsell.revoked");
expect(line).toBeGreaterThan(html.indexOf("settings.updates.notEntitled"));
expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
expect(html.split("upsell.revoked")).toHaveLength(2);
const line = html.indexOf("t(upsell.revoked)");
expect(line).toBeGreaterThan(html.indexOf("t(settings.updates.notEntitled)"));
expect(line).toBeLessThan(html.indexOf("t(upsell.ctaGet["));
expect(html.split("t(upsell.revoked)")).toHaveLength(2);
});
});

View file

@ -0,0 +1,60 @@
// UpsellDialog — the locked screen in a modal (#369). No jsdom: react-dom/server
// renders the markup in vitest's node environment, and calling the component as
// a function (UpsellCtas.render.test.tsx pattern) exposes the props it hands
// to UpsellGate and to its close button.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// The locked screen reads one thing from the license context: a revoked key (#365).
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => ({ revoked: false }) }));
import UpsellDialog from "./UpsellDialog";
import UpsellGate from "./UpsellGate";
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, pred: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
describe("UpsellDialog (#369)", () => {
it("shows the locked screen of the feature in a named modal dialog", () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<UpsellDialog feature="adjustments" onClose={vi.fn()} />
</MemoryRouter>,
);
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-label="t(upsell.title[tier=t(license.editions.base)])"');
expect(html).toContain("t(upsell.features.adjustments)");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
expect(html).toContain("t(upsell.ctaHaveKey)");
expect(html).toContain('aria-label="t(common.close)"');
});
it("closes on its X button and after 'I already have a key'", () => {
const onClose = vi.fn();
const tree = UpsellDialog({ feature: "adjustments", onClose });
const gates = find(tree, (el) => el.type === UpsellGate);
expect(gates).toHaveLength(1);
expect(gates[0].props).toMatchObject({ feature: "adjustments", requiredTier: "base" });
expect(gates[0].props.onNavigate).toBe(onClose);
const close = find(tree, (el) => el.type === "button" && el.props["aria-label"] === "t(common.close)");
expect(close).toHaveLength(1);
(close[0].props.onClick as () => void)();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("asks for the tier the feature needs, not always Base", () => {
const tree = UpsellDialog({ feature: "balance", onClose: vi.fn() });
expect(find(tree, (el) => el.type === UpsellGate)[0].props.requiredTier).toBe("premium");
});
});

View file

@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { requiredTierFor, type FeatureKey } from "../../shared/entitlements";
import UpsellGate from "./UpsellGate";
interface UpsellDialogProps {
feature: FeatureKey;
onClose: () => void;
}
/**
* The locked screen in a modal, for a gated action inside a free page (#369:
* the Répartition button of the Transactions page). Same markup as the upsell
* dialog of ProfileSwitcher, which has not moved onto this component yet.
*
* "I already have a key" navigates to the license card and closes the dialog
* through UpsellGate's `onNavigate`, so it does not linger over the destination
* page; the purchase button leaves it open (the product page opens in the
* browser).
*/
export default function UpsellDialog({ feature, onClose }: UpsellDialogProps) {
const { t } = useTranslation();
const requiredTier = requiredTierFor(feature);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-label={t("upsell.title", { tier: t(`license.editions.${requiredTier}`) })}
className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]"
>
<div className="flex justify-end p-3 pb-0">
<button
type="button"
onClick={onClose}
aria-label={t("common.close")}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</div>
<div className="px-4 pb-8">
<UpsellGate feature={feature} requiredTier={requiredTier} onNavigate={onClose} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
// SplitButton — the Répartition button of a transaction row (#369). No jsdom:
// react-dom/server renders the markup in vitest's node environment, and calling
// the component as a function (UpsellCtas.render.test.tsx pattern) hands over
// the button's props, so a click runs without a DOM.
import { describe, it, expect, vi } from "vitest";
import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import fr from "../../i18n/locales/fr.json";
import en from "../../i18n/locales/en.json";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SplitButton from "./SplitButton";
import type { SplitAccess } from "../../shared/splitGate";
import type { TransactionRow } from "../../shared/types";
const row = (isSplit: boolean): TransactionRow => ({
id: isSplit ? 1 : 2,
date: "2026-09-12",
description: isSplit ? "COSTCO 123" : "IGA 456",
amount: -120,
category_id: 7,
category_name: "Épicerie",
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: true,
is_split: isSplit,
});
const ACCESSES: SplitAccess[] = ["open", "locked", "pending"];
const LOCK = "lucide-lock";
const ORANGE = "text-orange-500";
const markup = (access: SplitAccess, isSplit = false) =>
renderToStaticMarkup(
<SplitButton row={row(isSplit)} access={access} onOpenSplit={vi.fn()} onOpenUpsell={vi.fn()} />,
);
/** One click on the button, through its own props: which callback ran. */
function click(access: SplitAccess, isSplit: boolean) {
const onOpenSplit = vi.fn();
const onOpenUpsell = vi.fn();
const button = SplitButton({ row: row(isSplit), access, onOpenSplit, onOpenUpsell }) as ReactElement<{
onClick: () => void;
}>;
button.props.onClick();
return { split: onOpenSplit.mock.calls.length, upsell: onOpenUpsell.mock.calls.length };
}
describe("SplitButton — what it shows (#369)", () => {
it("shows the lock in the locked state only", () => {
expect(markup("locked")).toContain(LOCK);
expect(markup("open")).not.toContain(LOCK);
expect(markup("pending")).not.toContain(LOCK);
});
it("keeps the orange of a split row in every state, and only for a split row", () => {
for (const access of ACCESSES) {
expect(markup(access, true), access).toContain(ORANGE);
expect(markup(access, false), access).not.toContain(ORANGE);
}
});
it("is disabled while the license is not ready, and only then", () => {
// The attribute itself: the class also names the `disabled:` variants.
expect(markup("pending")).toContain('disabled=""');
expect(markup("open")).not.toContain('disabled=""');
expect(markup("locked")).not.toContain('disabled=""');
});
it("names the edition in the locked title, keeps the usual title otherwise", () => {
expect(markup("locked")).toContain('title="t(transactions.splitLocked[tier=t(license.editions.base)])"');
expect(markup("open")).toContain('title="t(transactions.splitAdjustment)"');
expect(markup("pending")).toContain('title="t(transactions.splitAdjustment)"');
});
});
describe("SplitButton — what a click does (#369)", () => {
// Every row: the decision never depends on the row being split already.
it("locked: opens the upsell, never the split window, on a split row and on a plain one", () => {
expect(click("locked", true)).toEqual({ split: 0, upsell: 1 });
expect(click("locked", false)).toEqual({ split: 0, upsell: 1 });
});
it("open: opens the split window, never the upsell", () => {
expect(click("open", true)).toEqual({ split: 1, upsell: 0 });
expect(click("open", false)).toEqual({ split: 1, upsell: 0 });
});
it("pending: opens nothing", () => {
expect(click("pending", true)).toEqual({ split: 0, upsell: 0 });
expect(click("pending", false)).toEqual({ split: 0, upsell: 0 });
});
});
// The i18n mock renders any key: a key missing from one locale would only show
// up in the app (the raw key in English, or English in French).
describe("transactions.splitLocked in both locales", () => {
it("exists in fr and en and interpolates the edition", () => {
for (const messages of [fr, en]) {
expect(messages.transactions.splitLocked).toContain("{{tier}}");
expect(messages.transactions.splitLocked).not.toBe(messages.transactions.splitAdjustment);
}
});
});

View file

@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { Lock, Split } from "lucide-react";
import type { TransactionRow } from "../../shared/types";
import { requiredTierFor } from "../../shared/entitlements";
import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate";
interface SplitButtonProps {
row: TransactionRow;
access: SplitAccess;
onOpenSplit: () => void;
onOpenUpsell: () => void;
}
/**
* The Répartition button of a transaction row (#369).
*
* Locked, never hidden: on the Free edition the button keeps its place and its
* icon, carries a lock and opens the upsell instead of the split window, for
* every row, split or not. The orange colour of a split row is kept in every
* state: existing splits stay flagged whatever the edition. While the license
* is not ready ("pending") the button shows no lock and does nothing.
*/
export default function SplitButton({ row, access, onOpenSplit, onOpenUpsell }: SplitButtonProps) {
const { t } = useTranslation();
const locked = access === "locked";
const handleClick = () => {
if (access === "open") onOpenSplit();
else if (access === "locked") onOpenUpsell();
};
return (
<button
type="button"
onClick={handleClick}
disabled={access === "pending"}
className={`relative p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 disabled:opacity-50 disabled:cursor-default ${
row.is_split ? "text-orange-500" : "text-[var(--muted-foreground)]"
}`}
title={
locked
? t("transactions.splitLocked", {
tier: t(`license.editions.${requiredTierFor(SPLIT_FEATURE)}`),
})
: t("transactions.splitAdjustment")
}
>
<Split size={14} />
{locked && (
<Lock
size={8}
strokeWidth={3}
aria-hidden="true"
className="absolute bottom-0 right-0 rounded-full bg-[var(--card)]"
/>
)}
</button>
);
}

View file

@ -5,16 +5,11 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language: "fr" },
}),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
import type { Category, TransactionRow } from "../../shared/types";
import type { SplitAccess } from "../../shared/splitGate";
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
@ -29,7 +24,12 @@ const GROCERIES: Category = {
created_at: "",
};
const row = (id: number, description: string, category: Category | null = null): TransactionRow => ({
const row = (
id: number,
description: string,
category: Category | null = null,
isSplit = false,
): TransactionRow => ({
id,
date: "2026-09-12",
description,
@ -40,13 +40,13 @@ const row = (id: number, description: string, category: Category | null = null):
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: false,
is_split: false,
is_split: isSplit,
});
/** The guided entry: uncategorized rows only. */
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess: SplitAccess = "open") =>
renderToStaticMarkup(
<TransactionTable
rows={rows}
@ -59,6 +59,7 @@ const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
onLoadSplitChildren={vi.fn(async () => [])}
onSaveSplit={vi.fn(async () => undefined)}
onDeleteSplit={vi.fn(async () => undefined)}
splitAccess={splitAccess}
onboardingTarget={onboardingTarget}
/>,
);
@ -66,11 +67,11 @@ const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
/** The rows of the table's body, in order. */
const bodyRows = (html: string) => html.split("<tbody")[1].split("<tr").slice(1);
/** The highlighted wrapper around a closed combobox, whose input shows `value`. */
/** The highlighted wrapper around a closed combobox, whose input shows `value` (escaped). */
const ringedCategory = (value: string) =>
new RegExp(
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value}"[^>]*/></div></div>`,
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value.replace(/[()[\]]/g, "\\$&")}"[^>]*/></div></div>`,
);
describe("TransactionTable — getting-started target (#399)", () => {
@ -79,7 +80,7 @@ describe("TransactionTable — getting-started target (#399)", () => {
expect(count(html, HIGHLIGHT)).toBe(1);
const [first, ...others] = bodyRows(html);
expect(first).toContain("IGA 123");
expect(first).toMatch(ringedCategory("transactions.table.noCategory"));
expect(first).toMatch(ringedCategory("t(transactions.table.noCategory)"));
expect(others).toHaveLength(2);
for (const other of others) {
expect(other).not.toContain("data-onboarding");
@ -102,7 +103,30 @@ describe("TransactionTable — getting-started target (#399)", () => {
it("highlights nothing without a row: the card stays, without a ring", () => {
const html = markup([], true);
expect(html).toContain("transactions.noTransactions");
expect(html).toContain("t(transactions.noTransactions)");
expect(html).not.toContain("data-onboarding");
});
});
describe("TransactionTable — the Répartition button (#369)", () => {
/** A split row, then a plain one, both categorized (the button needs a category). */
const CATEGORIZED = [row(1, "COSTCO 123", GROCERIES, true), row(2, "IGA 456", GROCERIES)];
/** The rows' split buttons, in order (their title names them). */
const splitButtons = (html: string) =>
html.match(/<button type="button"[^>]*title="t\(transactions\.split(?:Adjustment|Locked\[[^"]*\])\)"[^>]*>.*?<\/button>/g) ?? [];
it("Free: every categorized row's button carries the lock, the split row keeps its orange", () => {
const buttons = splitButtons(markup(CATEGORIZED, undefined, "locked"));
expect(buttons).toHaveLength(2);
expect(buttons.every((b) => b.includes("lucide-lock"))).toBe(true);
expect(buttons[0]).toContain("text-orange-500");
expect(buttons[1]).not.toContain("text-orange-500");
});
it("Base and Premium: no lock, the split row keeps its orange", () => {
const buttons = splitButtons(markup(CATEGORIZED));
expect(buttons).toHaveLength(2);
expect(buttons.some((b) => b.includes("lucide-lock"))).toBe(false);
expect(buttons[0]).toContain("text-orange-500");
});
});

View file

@ -1,6 +1,6 @@
import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Link2 } from "lucide-react";
import type {
TransactionRow,
TransactionSort,
@ -8,9 +8,12 @@ import type {
SplitChild,
} from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import CategoryCombobox from "../shared/CategoryCombobox";
import UpsellDialog from "../shared/UpsellDialog";
import SplitAdjustmentModal from "./SplitAdjustmentModal";
import SplitButton from "./SplitButton";
interface TransactionTableProps {
rows: TransactionRow[];
@ -23,6 +26,12 @@ interface TransactionTableProps {
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>;
/**
* What the Répartition button does (#369): `splitAccessFor` over
* `useEntitlement(SPLIT_FEATURE)`. Required on purpose: a default of "open"
* would let a new caller leave the split ungated without a word.
*/
splitAccess: SplitAccess;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
/**
* Issue #142 — when supplied, a small Link2 icon appears next to the
@ -69,6 +78,7 @@ export default function TransactionTable({
onLoadSplitChildren,
onSaveSplit,
onDeleteSplit,
splitAccess,
onRowContextMenu,
linkedTransfersByTxId,
onboardingTarget,
@ -80,6 +90,7 @@ export default function TransactionTable({
const [keywordText, setKeywordText] = useState("");
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
const [showSplitUpsell, setShowSplitUpsell] = useState(false);
const noCategoryExtra = useMemo(
() => [{ value: "", label: t("transactions.table.noCategory") }],
[t]
@ -235,17 +246,12 @@ export default function TransactionTable({
>
<Tag size={14} />
</button>
<button
onClick={() => setSplitRow(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
row.is_split
? "text-orange-500"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.splitAdjustment")}
>
<Split size={14} />
</button>
<SplitButton
row={row}
access={splitAccess}
onOpenSplit={() => setSplitRow(row)}
onOpenUpsell={() => setShowSplitUpsell(true)}
/>
</>
)}
</div>
@ -326,7 +332,14 @@ export default function TransactionTable({
))}
</tbody>
</table>
{splitRow && (
{/*
The edition can change under an open window: the revocation check at
startup refreshes the license. The split window never stays open once
the edition resolves to Free, but a refresh ("pending") keeps it
mounted: `refresh()` goes back through "loading", and an `=== "open"`
guard would unmount the window and lose what was being typed.
*/}
{splitRow && splitAccess !== "locked" && (
<SplitAdjustmentModal
transaction={splitRow}
categories={categories}
@ -336,6 +349,10 @@ export default function TransactionTable({
onClose={() => setSplitRow(null)}
/>
)}
{/* Symmetric: the upsell goes away once the edition resolves to Base or Premium. */}
{showSplitUpsell && splitAccess !== "open" && (
<UpsellDialog feature={SPLIT_FEATURE} onClose={() => setShowSplitUpsell(false)} />
)}
</div>
);
}

View file

@ -0,0 +1,142 @@
// TransactionTable — what the Répartition button opens (#369).
//
// A static render never shows a window: they open on a click, through the
// table's own state. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the table runs as a plain function, its
// state survives from one call to the next as between two renders of a mounted
// component, and the test clicks through the props the table hands to each
// SplitButton, then renders again to read what opened.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import { hookHarness } from "../../test/hookHarness";
import TransactionTable from "./TransactionTable";
import SplitButton from "./SplitButton";
import SplitAdjustmentModal from "./SplitAdjustmentModal";
import UpsellDialog from "../shared/UpsellDialog";
import type { SplitAccess } from "../../shared/splitGate";
import type { Category, TransactionRow } from "../../shared/types";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(el.type === type ? [el] : []), ...find(el.props.children, type)];
}
const GROCERIES: Category = {
id: 7,
name: "Épicerie",
type: "expense",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
const row = (id: number, isSplit: boolean): TransactionRow => ({
id,
date: "2026-09-12",
description: `TX ${id}`,
amount: -42.5,
category_id: GROCERIES.id,
category_name: GROCERIES.name,
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: true,
is_split: isSplit,
});
/** A split row and a plain one: the button must behave the same on both. */
const ROWS = [row(1, true), row(2, false)];
/** One render of the mounted table, under the given access. */
const render = (splitAccess: SplitAccess) =>
harness.render(() =>
TransactionTable({
rows: ROWS,
sort: { column: "date", direction: "desc" },
categories: [GROCERIES],
onSort: vi.fn(),
onCategoryChange: vi.fn(),
onNotesChange: vi.fn(),
onAddKeyword: vi.fn(async () => undefined),
onLoadSplitChildren: vi.fn(async () => []),
onSaveSplit: vi.fn(async () => undefined),
onDeleteSplit: vi.fn(async () => undefined),
splitAccess,
}),
);
const splitButtons = (tree: ReactNode) => find(tree, SplitButton);
const upsells = (tree: ReactNode) => find(tree, UpsellDialog);
const splitWindows = (tree: ReactNode) => find(tree, SplitAdjustmentModal);
beforeEach(() => harness.clear());
describe("TransactionTable — the Répartition button's wiring (#369)", () => {
it("hands the table's access to every row's button, and opens nothing before a click", () => {
for (const access of ["open", "locked", "pending"] as const) {
harness.clear();
const tree = render(access);
const buttons = splitButtons(tree);
expect(buttons.map((b) => b.props.access)).toEqual([access, access]);
expect(buttons.map((b) => b.props.row)).toEqual(ROWS);
expect(upsells(tree)).toHaveLength(0);
expect(splitWindows(tree)).toHaveLength(0);
}
});
it("Free: the click opens the upsell for 'adjustments', never the split window, on any row", () => {
for (const index of [0, 1]) {
harness.clear();
(splitButtons(render("locked"))[index].props.onOpenUpsell as () => void)();
const opened = render("locked");
const dialogs = upsells(opened);
expect(dialogs, `row ${index}`).toHaveLength(1);
expect(dialogs[0].props.feature).toBe("adjustments");
expect(splitWindows(opened)).toHaveLength(0);
// The dialog's close hands control back to the table.
(dialogs[0].props.onClose as () => void)();
expect(upsells(render("locked"))).toHaveLength(0);
}
});
it("Base and Premium: the click opens the split window on its own row, no upsell", () => {
(splitButtons(render("open"))[1].props.onOpenSplit as () => void)();
const opened = render("open");
const windows = splitWindows(opened);
expect(windows).toHaveLength(1);
expect(windows[0].props.transaction).toBe(ROWS[1]);
expect(upsells(opened)).toHaveLength(0);
});
// One click arms one window: an edition change never brings up the other one.
it("an open split window survives a license refresh and closes once the edition is Free", () => {
(splitButtons(render("open"))[0].props.onOpenSplit as () => void)();
const refreshing = render("pending");
expect(splitWindows(refreshing)).toHaveLength(1);
expect(upsells(refreshing)).toHaveLength(0);
const downgraded = render("locked");
expect(splitWindows(downgraded)).toHaveLength(0);
expect(upsells(downgraded)).toHaveLength(0);
});
it("an open upsell survives a license refresh and goes away once the edition is Base", () => {
(splitButtons(render("locked"))[0].props.onOpenUpsell as () => void)();
const refreshing = render("pending");
expect(upsells(refreshing)).toHaveLength(1);
expect(splitWindows(refreshing)).toHaveLength(0);
const upgraded = render("open");
expect(upsells(upgraded)).toHaveLength(0);
expect(splitWindows(upgraded)).toHaveLength(0);
});
});

View file

@ -27,6 +27,8 @@ function Probe() {
String(onboarding.checklist),
typeof onboarding.skip,
typeof onboarding.refresh,
typeof onboarding.acknowledge,
String(onboarding.visitStepsInitialized),
].join("|")}
</output>
);
@ -51,7 +53,7 @@ describe("OnboardingProvider (#396)", () => {
</OnboardingProvider>
</MemoryRouter>,
);
expect(html).toContain("loading|none|null|null|null|function|function");
expect(html).toContain("loading|none|null|null|null|function|function|function|false");
});
it("AppShell mounts the provider around the routed page", () => {

View file

@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import type { BudgetYearRow, Category, BudgetEntry } from "../shared/types";
import { buildBudgetYearRows } from "./useBudget";
import { sumLeavesForType } from "../components/budget/budgetTableResults";
import { reorderRows } from "../utils/reorderRows";
import {
type CollapseAccessors,
visibleRows,
@ -9,10 +10,11 @@ import {
} from "../utils/collapsibleRows";
// The Budget grid's rows are built by `useBudget` (the "3rd builder" the
// abandoned v1 collapse plan ignored). Its final sort is LEVEL-ORDER (depth
// ascending / BFS), not depth-first. These tests pin that invariant: the
// shipped ancestor-walk collapse (issue #288) is order-INDEPENDENT, but the v1
// depth-cursor algorithm assumed DFS and would have broken exactly here.
// abandoned v1 collapse plan ignored). Since issue #424 they are emitted
// DEPTH-FIRST: each parent row is followed directly by its whole subtree, so an
// expanded subcategory shows under its parent and `reorderRows` (subtotals at
// the bottom) attaches each subtotal to its own rows. The collapse (ancestor
// walk, issue #288) does not depend on the order.
function cat(
id: number,
@ -62,29 +64,261 @@ const ACCESSORS: CollapseAccessors<BudgetYearRow> = {
isParent: (row) => row.is_parent,
};
describe("buildBudgetYearRows — level-order emission (issue #289 invariant)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []);
/**
* Depth-first invariant: every non-root row comes after its parent row, and
* every row between a parent row and the end of its subtree is one of its
* descendants. A "(direct)" leaf carries its own category as parent: it is
* attached to the subtotal row of that category (the `p:` key of the grid).
*/
function expectDepthFirst(rows: BudgetYearRow[]) {
const parentIndex = new Map<number, number>();
rows.forEach((r, i) => {
if (r.is_parent) parentIndex.set(r.category_id, i);
});
// Ancestor ids of a row, climbing the parent subtotal rows.
const ancestorsOf = (r: BudgetYearRow): number[] => {
const out: number[] = [];
let pid = r.parent_id;
while (pid != null && !out.includes(pid)) {
out.push(pid);
const idx = parentIndex.get(pid);
if (idx === undefined) break;
pid = rows[idx].parent_id;
}
return out;
};
rows.forEach((r, i) => {
if (r.parent_id != null) {
const p = parentIndex.get(r.parent_id);
expect(p, `${r.category_name} has a parent row`).toBeDefined();
expect(p!, `${r.category_name} comes after its parent`).toBeLessThan(i);
}
});
rows.forEach((r, i) => {
if (!r.is_parent) return;
const depth = r.depth ?? 0;
// The subtree runs until the next row of this depth or shallower.
let j = i + 1;
while (j < rows.length && (rows[j].depth ?? 0) > depth) {
expect(ancestorsOf(rows[j]), `${rows[j].category_name} is under ${r.category_name}`).toContain(r.category_id);
j++;
}
// ...and every descendant of the parent lies inside that range.
rows.forEach((d, k) => {
if (k === i) return;
if (ancestorsOf(d).includes(r.category_id)) {
expect(k, `${d.category_name} lies inside the subtree of ${r.category_name}`).toBeGreaterThan(i);
expect(k, `${d.category_name} lies inside the subtree of ${r.category_name}`).toBeLessThan(j);
}
});
});
}
it("emits income before expense, then each group in LEVEL order (BFS), not DFS", () => {
// Salary (income) first; then the Housing expense group depth-ascending:
// root subtotal, then the depth-1 rows (parent Utilities before leaf Rent),
// then the depth-2 grandchildren.
expect(rows.map((r) => r.category_id)).toEqual([10, 1, 3, 2, 4, 5]);
const names = (rows: BudgetYearRow[]) => rows.map((r) => r.category_name);
// Shape of the "Revenus" group of a new profile (consolidated_schema.sql): one
// depth-1 leaf ("Autres revenus") and three intermediate parents.
const REVENUS: Category[] = [
cat(1000, "Revenus", "income", { is_inputable: false, sort_order: 1 }),
cat(1010, "Emploi", "income", { parent_id: 1000, is_inputable: false, sort_order: 1 }),
cat(1011, "Paie régulière", "income", { parent_id: 1010, sort_order: 1 }),
cat(1012, "Primes & bonus", "income", { parent_id: 1010, sort_order: 2 }),
cat(1013, "Travail autonome", "income", { parent_id: 1010, sort_order: 3 }),
cat(1020, "Gouvernemental", "income", { parent_id: 1000, is_inputable: false, sort_order: 2 }),
cat(1021, "Remboursement impôt", "income", { parent_id: 1020, sort_order: 1 }),
cat(1022, "Allocations familiales", "income", { parent_id: 1020, sort_order: 2 }),
cat(1023, "Crédits TPS/TVQ", "income", { parent_id: 1020, sort_order: 3 }),
cat(1024, "Assurance-emploi / RQAP", "income", { parent_id: 1020, sort_order: 4 }),
cat(1030, "Revenus de placement", "income", { parent_id: 1000, is_inputable: false, sort_order: 3 }),
cat(1031, "Intérêts & dividendes", "income", { parent_id: 1030, sort_order: 1 }),
cat(1032, "Gains en capital", "income", { parent_id: 1030, sort_order: 2 }),
cat(1033, "Revenus locatifs", "income", { parent_id: 1030, sort_order: 3 }),
cat(1090, "Autres revenus", "income", { parent_id: 1000, sort_order: 9 }),
];
// Shape of "Finances & obligations" of a new profile: leaves before AND after
// intermediate parents ("Retrait cash" sorts after "Impôts & taxes").
const FINANCES: Category[] = [
cat(1900, "Finances & obligations", "expense", { is_inputable: false, sort_order: 10 }),
cat(1910, "Frais bancaires", "expense", { parent_id: 1900, is_inputable: false, sort_order: 1 }),
cat(1911, "Frais de compte", "expense", { parent_id: 1910, sort_order: 1 }),
cat(1912, "Intérêts & frais de crédit", "expense", { parent_id: 1910, sort_order: 2 }),
cat(1913, "Frais de change", "expense", { parent_id: 1910, sort_order: 3 }),
cat(1920, "Impôts & taxes", "expense", { parent_id: 1900, is_inputable: false, sort_order: 2 }),
cat(1921, "Impôt fédéral", "expense", { parent_id: 1920, sort_order: 1 }),
cat(1922, "Impôt provincial", "expense", { parent_id: 1920, sort_order: 2 }),
cat(1923, "Acomptes provisionnels", "expense", { parent_id: 1920, sort_order: 3 }),
cat(1930, "Dons & cotisations", "expense", { parent_id: 1900, is_inputable: false, sort_order: 3 }),
cat(1931, "Dons de charité", "expense", { parent_id: 1930, sort_order: 1 }),
cat(1932, "Cotisations professionnelles", "expense", { parent_id: 1930, sort_order: 2 }),
cat(1933, "Cotisations syndicales", "expense", { parent_id: 1930, sort_order: 3 }),
cat(1940, "Cadeaux", "expense", { parent_id: 1900, sort_order: 4 }),
cat(1945, "Retrait cash", "expense", { parent_id: 1900, sort_order: 5 }),
cat(1946, "Achats divers non catégorisés", "expense", { parent_id: 1900, sort_order: 6 }),
];
// Shape of the older taxonomy (default profile): an inputable root, so a
// "(direct)" row, and one intermediate parent (Assurances, migration v7).
const RECURRENTES: Category[] = [
cat(2, "Dépenses récurrentes", "expense", { sort_order: 2 }),
cat(20, "Loyer", "expense", { parent_id: 2, sort_order: 1 }),
cat(21, "Électricité", "expense", { parent_id: 2, sort_order: 2 }),
cat(31, "Assurances", "expense", { parent_id: 2, is_inputable: false, sort_order: 12 }),
cat(310, "Assurance-auto", "expense", { parent_id: 31, sort_order: 1 }),
cat(311, "Assurance-habitation", "expense", { parent_id: 31, sort_order: 2 }),
cat(312, "Assurance-vie", "expense", { parent_id: 31, sort_order: 3 }),
cat(32, "Pharmacie", "expense", { parent_id: 2, sort_order: 13 }),
cat(33, "Taxes municipales", "expense", { parent_id: 2, sort_order: 14 }),
];
describe("buildBudgetYearRows — depth-first emission (issue #424)", () => {
it("emits income before expense, each group depth-first: Utilities is followed by its own leaves", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []);
// Salary (income) first; then Housing, its children sorted by name with
// leaves and sub-groups mixed: Rent, then Utilities and its leaves.
expect(rows.map((r) => r.category_id)).toEqual([10, 1, 2, 3, 4, 5]);
expectDepthFirst(rows);
});
it("keeps depth non-decreasing within a top group — the property that would have killed the v1 (DFS-assuming) collapse algorithm", () => {
const expenseGroup = rows.filter((r) => r.category_type === "expense");
const depths = expenseGroup.map((r) => r.depth ?? 0);
for (let i = 1; i < depths.length; i++) {
expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]);
}
it("Revenus: the depth-1 leaf sorts first by name, each sub-group carries its own leaves", () => {
const rows = buildBudgetYearRows(REVENUS, [], []);
expect(names(rows)).toEqual([
"Revenus",
"Autres revenus",
"Emploi",
"Paie régulière",
"Primes & bonus",
"Travail autonome",
"Gouvernemental",
"Allocations familiales",
"Assurance-emploi / RQAP",
"Crédits TPS/TVQ",
"Remboursement impôt",
"Revenus de placement",
"Gains en capital",
"Intérêts & dividendes",
"Revenus locatifs",
]);
expectDepthFirst(rows);
});
// Explicit DFS-killer: the depth-2 grandchildren (Hydro/Internet) come AFTER
// the depth-1 leaf sibling (Rent). A depth-first emission would interleave
// them directly under Utilities, i.e. BEFORE Rent.
const leafIdx = (id: number) => rows.findIndex((r) => r.category_id === id && !r.is_parent);
expect(leafIdx(4)).toBeGreaterThan(leafIdx(2)); // Hydro after Rent
expect(leafIdx(5)).toBeGreaterThan(leafIdx(2)); // Internet after Rent
it("Finances & obligations: leaves before and after sub-groups, sorted by name together", () => {
const rows = buildBudgetYearRows(FINANCES, [], []);
expect(names(rows)).toEqual([
"Finances & obligations",
"Achats divers non catégorisés",
"Cadeaux",
"Dons & cotisations",
"Cotisations professionnelles",
"Cotisations syndicales",
"Dons de charité",
"Frais bancaires",
"Frais de change",
"Frais de compte",
"Intérêts & frais de crédit",
"Impôts & taxes",
"Acomptes provisionnels",
"Impôt fédéral",
"Impôt provincial",
"Retrait cash",
]);
expectDepthFirst(rows);
});
it("older taxonomy: the (direct) row leads, Assurances is followed by its three leaves", () => {
const rows = buildBudgetYearRows(RECURRENTES, [], []);
expect(names(rows)).toEqual([
"Dépenses récurrentes",
"Dépenses récurrentes (direct)",
"Assurances",
"Assurance-auto",
"Assurance-habitation",
"Assurance-vie",
"Électricité",
"Loyer",
"Pharmacie",
"Taxes municipales",
]);
expectDepthFirst(rows);
});
it("top groups are sorted by type, then sort_order, then name — whatever the input order", () => {
const all = [
...FINANCES,
...RECURRENTES,
...REVENUS,
cat(9000, "Divers", "expense", { sort_order: 2 }),
cat(9100, "Virements", "transfer", { sort_order: 0 }),
];
const rows = buildBudgetYearRows([...all].reverse(), [], []);
const roots = rows.filter((r) => (r.depth ?? 0) === 0).map((r) => r.category_name);
expect(roots).toEqual([
"Revenus", // income
"Dépenses récurrentes", // expense, sort_order 2, name before "Divers"
"Divers", // expense, sort_order 2 (lone leaf)
"Finances & obligations", // expense, sort_order 10
"Virements", // transfer, last despite sort_order 0
]);
expectDepthFirst(rows);
});
it("subtotals at the bottom: each subtotal follows its own rows (reorderRows on the builder output)", () => {
const rows = reorderRows(buildBudgetYearRows([...FINANCES, ...RECURRENTES], [], []), false);
expect(names(rows)).toEqual([
"Dépenses récurrentes (direct)",
"Assurance-auto",
"Assurance-habitation",
"Assurance-vie",
"Assurances",
"Électricité",
"Loyer",
"Pharmacie",
"Taxes municipales",
"Dépenses récurrentes",
"Achats divers non catégorisés",
"Cadeaux",
"Cotisations professionnelles",
"Cotisations syndicales",
"Dons de charité",
"Dons & cotisations",
"Frais de change",
"Frais de compte",
"Intérêts & frais de crédit",
"Frais bancaires",
"Acomptes provisionnels",
"Impôt fédéral",
"Impôt provincial",
"Impôts & taxes",
"Retrait cash",
"Finances & obligations",
]);
});
it("expanding a single intermediate parent shows its leaves right under it", () => {
const rows = buildBudgetYearRows(REVENUS, [], []);
const flipped = new Set<string>(["p:1000", "p:1010"]); // Revenus + Emploi expanded
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(flipped, ACCESSORS.keyOf(r), false);
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
expect(names(visible)).toEqual([
"Revenus",
"Autres revenus",
"Emploi",
"Paie régulière",
"Primes & bonus",
"Travail autonome",
"Gouvernemental",
"Revenus de placement",
]);
});
it("subtotals are unchanged by the order: each subtotal sums its own leaves", () => {
const ENTRIES = [entry(1011, 100), entry(1012, 20), entry(1021, 7), entry(1033, 3), entry(1090, 1000)];
const rows = buildBudgetYearRows(REVENUS, ENTRIES, []);
const annualOf = (id: number) => rows.find((r) => r.category_id === id && r.is_parent)!.annual;
expect(annualOf(1000)).toBe(1130);
expect(annualOf(1010)).toBe(120);
expect(annualOf(1020)).toBe(7);
expect(annualOf(1030)).toBe(3);
});
});
@ -102,9 +336,9 @@ describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", (
const flipped = new Set<string>(["p:1"]); // Housing expanded
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(flipped, ACCESSORS.keyOf(r), false);
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
// Housing's direct children: Utilities subtotal + Rent leaf. Hydro/Internet
// remain hidden under the collapsed Utilities.
expect(visible.map((r) => r.category_id)).toEqual([10, 1, 3, 2]);
// Housing's direct children, by name: Rent leaf + Utilities subtotal.
// Hydro/Internet remain hidden under the collapsed Utilities.
expect(visible.map((r) => r.category_id)).toEqual([10, 1, 2, 3]);
});
});

Some files were not shown because too many files have changed in this diff Show more