Compare commits

...

8 commits

Author SHA1 Message Date
le king fu
5decb985f1 fix: list the System sections on the Settings home in the page's order (#372)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Self-review of PR #470: the System card of the Settings home lists the
page's sections as chips, still with the version history before the logs.
The chips now follow the page (version, update, logs, history), and a test
renders the page and compares its section headings with the card, so the
two cannot drift apart again. SECTIONS is exported for that test, as
DocsContent exports its own. The /settings/systems row of
docs/architecture.md names both unreleased headings and the card.

Resolves #372

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:12:35 -04:00
le king fu
0513d717c7 docs: describe the Systems page order and the folded version history (#372)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m2s
User guide (section 11) and the in-app docs.settings keys, FR and EN: the
order of Settings → System and how the version history folds. One Changed
entry in both changelogs. docs/architecture.md: the /settings/systems row,
why ChangelogContent keeps a local Set rather than useCollapsibleGroups,
and the utils count (19: startupConnection and rootFiles were not counted).
CLAUDE.md no longer describes /changelog as a page with the full history:
it redirects to Settings → System since #190.

Resolves #372

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:07:41 -04:00
le king fu
92b3e54d54 feat: show the Journal before the version history, folded by version (#372)
Settings → System now orders its sections Version, Update, Logs, version
history, data notice: the Journal is reachable without scrolling past the
whole changelog, which grows at each release.

The version history opens on the installed version's notes, every other
version folded behind its title (a button with aria-expanded, a chevron and
aria-controls), with an Expand all / Collapse all button. An empty
Unreleased heading is hidden; one with content (development build) stays.
State is a local Set seeded by a pure rule, in memory only.

parseChangelog moves unchanged to src/utils/changelog.ts with the rule:
installed version unfolded, or the first published version when the
installed one is unknown or absent; a published version is told from the
Unreleased / Non publié heading by its shape, so both languages read alike.
SystemsSettingsPage mounts ChangelogContent once getVersion() settles; a
failure is written to the Journal and leaves the version unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:02:48 -04:00
le king fu
db625d02ef docs: record the proven --locked relay of both release runners (#449)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
PR Check — Rust / rust (pull_request) Successful in 9m58s
- Release skill, step 2: quote the refusal cargo actually prints, the
  same under cargo 1.94.1 and 1.99.1 (`cannot update the lock file ...
  because --locked was passed to prevent this`, code 101).
- docs/architecture.md: the relay of --locked is proven for both runners
  on a lockfile out of step with Cargo.toml: cargo-xwin 0.23.1 runs
  `cargo build ... --locked ...`, and the other cargo calls of the Tauri
  CLI and cargo-xwin are `cargo metadata --no-deps`, which never touch
  the lockfile.

Resolves #449

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:39:11 -04:00
le king fu
14badbe2b0 docs: describe the --locked builds and the lockfile check of /release (#449)
- Release skill, step 2: right after the hand edit of Cargo.lock and
  before the windows-sys guard, `cargo metadata --format-version 1
  --locked` must exit 0, since the tag builds with -- --locked and the
  release commit goes to main without a pull request. Step 0 runs cargo
  check and cargo test with --locked, like CI, so the pre-flight cannot
  fold a lockfile rewrite into the release commit.
- ADR 0023: the windows-sys guard reads the committed lockfile, cargo
  check having already refused one it would have had to rewrite.
- CLAUDE.md, REVIEW.md and docs/architecture.md: CI commands with
  --locked, release builds on the committed lockfile, cargo-xwin pinned
  (REVIEW.md said it was installed unpinned), the stale REVIEW.md
  sentence about #449 removed, and why the dormant .github mirror is
  not aligned.

Resolves #449

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:28:39 -04:00
le king fu
aad85a412d ci: pass --locked to cargo in check-rust and the release builds, pin cargo-xwin (#449)
check-rust.yml ran cargo check and cargo test without --locked: a
Cargo.lock out of step with Cargo.toml was rewritten in the job's
checkout, and the suppression guard, the windows-sys guard and cargo
audit then read the rewritten lockfile instead of the committed one.
Both now pass --locked, and the windows-sys guard comment says the
lockfile it reads is the committed one.

release.yml built the shipped binaries the same way. Both npx tauri
build calls now end with `-- --locked`, which the Tauri CLI hands to its
runner (cargo, or cargo-xwin for Windows, which passes it to cargo
build).

cargo-xwin, the runner of the Windows build, runs with the updater
signing key in its environment and was installed at whatever version
crates.io served on the day of the tag. It is pinned at 0.23.1, the
version the v0.17.2 release installed (run 570), and its version is
printed in the job log.

Refs #449

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:28:34 -04:00
le king fu
25a1fdea84 docs: name the i18n key guard in REVIEW.md (#453)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m9s
The "Faux t" review pitfall said that until #453 no guard caught a key
present in one language only. It now names src/i18n/locales.keys.test.ts
and what the guard does not see: a value left in the other language, and a
key the code calls that neither file declares. The first rule of the "i18n
et documentation" zone names the guard too, as the migration and release
zones name theirs. The "Niveaux" section is untouched
(scripts/review-levels.py --check still passes).

Resolves #453

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:16:13 -04:00
le king fu
8a250b98b5 test: guard the key parity of fr.json and en.json (#453)
No test compared the key trees of the two locale files: a key added to one
language only passed the build and the suite, then showed raw to an English
user (missing from en.json) or in English to a French user (missing from
fr.json, English being the fallback).

src/i18n/locales.keys.test.ts imports both files statically, flattens every
node to a dotted path, array indices included (docs.dashboard.features.6),
and fails with the keys missing from each language in a test of its own,
plus a third test for a path held with another kind in each file (an array
against an object, a leaf against an object or an array). Values are never
compared. A canary checks both files were read whole, array items included,
and four tests on small fixtures pin the rules of the comparison.

Run on the base (6e8e157): 1712 leaves on each side, no gap, so no
translation to add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:12:32 -04:00
20 changed files with 1156 additions and 112 deletions

View file

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

View file

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

View file

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

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Modifié
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
## [0.17.2] - 2026-10-18 ## [0.17.2] - 2026-10-18
### Ajouté ### Ajouté

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Changed
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
## [0.17.2] - 2026-10-18 ## [0.17.2] - 2026-10-18
### Added ### Added

View file

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

View file

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

View file

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

View file

@ -45,7 +45,7 @@ simpl-resultat/
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript ») │ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 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) │ ├── utils/ # 19 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage, historique des versions)
│ ├── i18n/ # Config i18next + locales FR/EN │ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust │ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
│ ├── App.tsx # Router principal │ ├── App.tsx # Router principal
@ -265,6 +265,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible. - `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler). - `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
- L'historique des versions de Paramètres → Système (`ChangelogContent`, #372) n'utilise pas ce hook : c'est une liste plate dont une seule version s'ouvre au départ, choisie par une règle, alors que le hook n'a qu'un `defaultExpanded` global, sans amorce par clé. Son état est un `Set` local, en mémoire, amorcé à chaque lecture du fichier par `initiallyExpandedVersions` (`src/utils/changelog.ts`, pur, avec `parseChangelog`) : la version installée, ou la première version publiée quand la version installée est inconnue ou absente du fichier.
## Import CSV — format persisté et détection ## Import CSV — format persisté et détection
@ -579,7 +580,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
| `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales | | `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales |
| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) | | `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
| `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix | | `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix |
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) | | `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), journaux + commentaires (`LogViewerCard`), puis historique des versions (`ChangelogContent`), dans cet ordre : le Journal ne descend plus sous l'historique, qui s'allonge à chaque release (#372). `ChangelogContent` n'est monté qu'une fois `getVersion()` résolu (ou en échec : version inconnue, écrite au Journal) ; il ouvre les notes de la version installée et replie les autres, chaque titre de version étant un bouton (`aria-expanded`, chevron), avec un bouton « Tout déplier / Tout replier » ; l'en-tête non publié (« Unreleased », « Non publié ») est masqué quand il est vide. La carte Système de `SettingsHomePage` liste ces sections dans le même ordre |
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) | | `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) | | `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) | | `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
@ -646,7 +647,7 @@ Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **
### `check-rust.yml` — Vérifications Rust sur PR et chaque semaine ### `check-rust.yml` — Vérifications Rust sur PR et chaque semaine
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`). Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check --locked` puis `cargo test --locked`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`). Avec `--locked` ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)), un `Cargo.lock` qui ne correspond plus à `Cargo.toml` (édition à la main, conflit mal résolu, dépendance ajoutée sans son lock) fait échouer le job à `cargo check` au lieu d'être réécrit dans le checkout : les deux gardes et l'audit qui suivent lisent le lock commité.
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
@ -691,6 +692,8 @@ Fonctionnalités :
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`). **Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).
**Lock commité, cargo-xwin épinglé** ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)). Les deux `npx tauri build` passent `-- --locked` : la CLI de Tauri transmet ce qui suit `--` à son runner, cargo pour Linux, cargo-xwin pour Windows, qui le passe à `cargo build`. Les binaires livrés sont donc bâtis sur le `Cargo.lock` commité, ou le build échoue au lieu de le réécrire ; l'étape 2 de `/release`, qui édite le lock à la main sur `main` sans PR, vérifie ce lock par `cargo metadata --locked` avant le tag. Le journal du run n'imprime jamais la ligne de commande de cargo : la preuve que le drapeau arrive est un lock désaccordé qui fait échouer le build, faite en local dans la PR de #449 pour les deux runners (cargo-xwin 0.23.1 lance `cargo build … --locked …`, et les autres appels à cargo de la CLI et de cargo-xwin sont des `cargo metadata --no-deps`, qui ne touchent pas au lock). cargo-xwin, qui tourne avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement, est installé en version fixe (`cargo install --locked cargo-xwin --version 0.23.1`, celle qu'avait installée la release v0.17.2), et l'étape affiche `cargo-xwin --version` ; une nouvelle version se décide, après lecture de ce qu'elle change.
## Architecture Decision Records (ADRs) ## Architecture Decision Records (ADRs)
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`. Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.

View file

@ -583,6 +583,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres - Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic - Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement - Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Historique des versions : les notes de la version installée s'affichent d'emblée, les autres versions sont repliées et se déplient d'un clic sur leur numéro, ou toutes ensemble avec « Tout déplier »
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi) - Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV - Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
- Import des données depuis un fichier exporté précédemment - Import des données depuis un fichier exporté précédemment
@ -595,10 +596,11 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible 2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez 3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut 4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut
5. Utilisez la section Gestion des données pour exporter ou importer vos données 5. Dans Paramètres → Système, les sections se suivent dans cet ordre : Version, Mise à jour, Journaux et commentaires, puis Historique des versions. L'historique s'ouvre sur les notes de la version installée ; cliquez sur un autre numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (« Tout replier » le referme)
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement 6. Utilisez la section Gestion des données pour exporter ou importer vos données
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe 7. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
8. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page 8. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
9. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
### Astuces ### Astuces

View file

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

View file

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

View file

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

View file

@ -5,7 +5,11 @@
"changelog": { "changelog": {
"title": "Version History", "title": "Version History",
"description": "View what's new and fixed in each version", "description": "View what's new and fixed in each version",
"empty": "No entries available" "empty": "No entries available",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"showNotes": "Show this version's notes",
"hideNotes": "Hide this version's notes"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@ -1302,6 +1306,7 @@
"Complete user guide accessible directly from settings", "Complete user guide accessible directly from settings",
"Automatic update checker with one-click install", "Automatic update checker with one-click install",
"Application logs viewable with level filters, copy, and clear", "Application logs viewable with level filters, copy, and clear",
"Version history: the installed version's notes show right away, other versions are folded and unfold with a click on their number, or all together with \"Expand all\"",
"Data export (transactions, categories, or both) in JSON or CSV format", "Data export (transactions, categories, or both) in JSON or CSV format",
"Data import from a previously exported file", "Data import from a previously exported file",
"Optional AES-256-GCM encryption for exported files", "Optional AES-256-GCM encryption for exported files",
@ -1312,6 +1317,7 @@
"Click User Guide to access the full documentation", "Click User Guide to access the full documentation",
"Click Check for Updates to see if a new version is available", "Click Check for Updates to see if a new version is available",
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear", "View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
"In Settings → System, the sections come in this order: Version, Update, Logs and feedback, then Version history. The history opens on the installed version's notes; click another version number to read its notes, or \"Expand all\" to open the whole history (\"Collapse all\" folds it again)",
"Use the Data Management section to export or import your data", "Use the Data Management section to export or import your data",
"When exporting, choose what to include and optionally set a password for encryption", "When exporting, choose what to include and optionally set a password for encryption",
"When importing, select a previously exported file — encrypted files will prompt for the password", "When importing, select a previously exported file — encrypted files will prompt for the password",

View file

@ -5,7 +5,11 @@
"changelog": { "changelog": {
"title": "Historique des versions", "title": "Historique des versions",
"description": "Consultez les nouveautés et corrections de chaque version", "description": "Consultez les nouveautés et corrections de chaque version",
"empty": "Aucune entrée disponible" "empty": "Aucune entrée disponible",
"expandAll": "Tout déplier",
"collapseAll": "Tout replier",
"showNotes": "Afficher les notes de cette version",
"hideNotes": "Masquer les notes de cette version"
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
@ -1302,6 +1306,7 @@
"Guide d'utilisation complet accessible directement depuis les paramètres", "Guide d'utilisation complet accessible directement depuis les paramètres",
"Vérification automatique des mises à jour avec installation en un clic", "Vérification automatique des mises à jour avec installation en un clic",
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement", "Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
"Historique des versions : les notes de la version installée s'affichent d'emblée, les autres versions sont repliées et se déplient d'un clic sur leur numéro, ou toutes ensemble avec « Tout déplier »",
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV", "Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
"Import des données depuis un fichier exporté précédemment", "Import des données depuis un fichier exporté précédemment",
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés", "Chiffrement AES-256-GCM optionnel pour les fichiers exportés",
@ -1312,6 +1317,7 @@
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète", "Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
"Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible", "Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible",
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez", "Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
"Dans Paramètres → Système, les sections se suivent dans cet ordre : Version, Mise à jour, Journaux et commentaires, puis Historique des versions. L'historique s'ouvre sur les notes de la version installée ; cliquez sur un autre numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (« Tout replier » le referme)",
"Utilisez la section Gestion des données pour exporter ou importer vos données", "Utilisez la section Gestion des données pour exporter ou importer vos données",
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement", "Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe", "Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe",

View file

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

View file

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

View file

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

231
src/utils/changelog.test.ts Normal file
View file

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

119
src/utils/changelog.ts Normal file
View file

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