Compare commits
2 commits
db625d02ef
...
0513d717c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0513d717c7 | ||
|
|
92b3e54d54 |
13 changed files with 962 additions and 86 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ src-tauri/
|
|||
- **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)
|
||||
- **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/`)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ simpl-resultat/
|
|||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
|
||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
||||
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
|
||||
│ ├── 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
|
||||
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
||||
│ ├── 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 === 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
|
||||
|
||||
|
|
@ -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/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/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 » ; un « Unreleased » vide est masqué |
|
||||
| `/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`) |
|
||||
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- Vérification automatique des mises à jour avec installation en un clic
|
||||
- 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)
|
||||
- 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
|
||||
|
|
@ -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
|
||||
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
|
||||
5. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||
8. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
|
||||
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. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||
7. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||
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
|
||||
|
||||
|
|
|
|||
338
src/components/settings/ChangelogContent.test.tsx
Normal file
338
src/components/settings/ChangelogContent.test.tsx
Normal 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)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +1,52 @@
|
|||
import { useEffect, useState } from "react";
|
||||
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 {
|
||||
version: string;
|
||||
sections: { heading: string; items: string[] }[];
|
||||
/** Prefix of the id of each version's notes, which its title names in `aria-controls`. */
|
||||
export const CHANGELOG_NOTES_ID_PREFIX = "changelog-notes-";
|
||||
|
||||
interface ChangelogContentProps {
|
||||
/** The running app's version (`getVersion()`), `null` when it could not be read. */
|
||||
installedVersion: string | null;
|
||||
}
|
||||
|
||||
function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let current: ChangelogEntry | null = null;
|
||||
let currentSection: { heading: string; items: string[] } | null = null;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
|
||||
if (versionMatch) {
|
||||
if (currentSection && current) current.sections.push(currentSection);
|
||||
if (current) entries.push(current);
|
||||
current = { version: versionMatch[1], sections: [] };
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionMatch = trimmed.match(/^### (.+)/);
|
||||
if (sectionMatch && current) {
|
||||
if (currentSection) current.sections.push(currentSection);
|
||||
currentSection = { heading: sectionMatch[1], items: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("- ") && currentSection) {
|
||||
currentSection.items.push(trimmed.slice(2));
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection && current) current.sections.push(currentSection);
|
||||
if (current) entries.push(current);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default function ChangelogContent() {
|
||||
export default function ChangelogContent({ installedVersion }: ChangelogContentProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
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(() => {
|
||||
// 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";
|
||||
fetch(file)
|
||||
.then((r) => r.text())
|
||||
.then((text) => setEntries(parseChangelog(text)))
|
||||
.catch(() => setEntries([]));
|
||||
}, [i18n.language]);
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -59,34 +54,77 @@ export default function ChangelogContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const allExpanded = allVersionsExpanded(entries, expanded);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.version}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
|
||||
<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"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">{entry.version}</h3>
|
||||
{entry.sections.map((section, si) => (
|
||||
<div key={si} className="space-y-1.5">
|
||||
<h4 className="text-sm font-semibold text-[var(--primary)]">
|
||||
{section.heading}
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, ii) => (
|
||||
<li
|
||||
key={ii}
|
||||
className="text-sm text-[var(--muted-foreground)] pl-3"
|
||||
>
|
||||
{"• "}
|
||||
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{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 (
|
||||
<div
|
||||
key={entry.version}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl"
|
||||
>
|
||||
<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) => (
|
||||
<div key={si} className="space-y-1.5">
|
||||
<h4 className="text-sm font-semibold text-[var(--primary)]">
|
||||
{section.heading}
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, ii) => (
|
||||
<li
|
||||
key={ii}
|
||||
className="text-sm text-[var(--muted-foreground)] pl-3"
|
||||
>
|
||||
{"• "}
|
||||
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"changelog": {
|
||||
"title": "Version History",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
@ -1302,6 +1306,7 @@
|
|||
"Complete user guide accessible directly from settings",
|
||||
"Automatic update checker with one-click install",
|
||||
"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 import from a previously exported file",
|
||||
"Optional AES-256-GCM encryption for exported files",
|
||||
|
|
@ -1312,6 +1317,7 @@
|
|||
"Click User Guide to access the full documentation",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"changelog": {
|
||||
"title": "Historique des versions",
|
||||
"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": {
|
||||
"dashboard": "Tableau de bord",
|
||||
|
|
@ -1302,6 +1306,7 @@
|
|||
"Guide d'utilisation complet accessible directement depuis les paramètres",
|
||||
"Vérification automatique des mises à jour avec installation en un clic",
|
||||
"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",
|
||||
"Import des données depuis un fichier exporté précédemment",
|
||||
"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 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",
|
||||
"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",
|
||||
"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",
|
||||
|
|
|
|||
118
src/pages/settings/SystemsSettingsPage.test.tsx
Normal file
118
src/pages/settings/SystemsSettingsPage.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// 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";
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,16 +4,23 @@ import { Link } from "react-router-dom";
|
|||
import { ArrowLeft, ShieldCheck } from "lucide-react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { APP_NAME } from "../../shared/constants";
|
||||
import { logWarn } from "../../services/logService";
|
||||
import UpdateCard from "../../components/settings/UpdateCard";
|
||||
import ChangelogContent from "../../components/settings/ChangelogContent";
|
||||
import LogViewerCard from "../../components/settings/LogViewerCard";
|
||||
|
||||
export default function SystemsSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [version, setVersion] = useState("");
|
||||
// `undefined` while getVersion() runs, `null` when it failed.
|
||||
const [version, setVersion] = useState<string | null | undefined>(undefined);
|
||||
|
||||
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 (
|
||||
|
|
@ -40,7 +47,7 @@ export default function SystemsSettingsPage() {
|
|||
<div>
|
||||
<h3 className="text-lg font-semibold">{APP_NAME}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.version", { version })}
|
||||
{t("settings.version", { version: version ?? "" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -54,13 +61,7 @@ export default function SystemsSettingsPage() {
|
|||
<UpdateCard />
|
||||
</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>
|
||||
<ChangelogContent />
|
||||
</section>
|
||||
|
||||
{/* The Journal comes before the version history, which grows at each release (#372). */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{t("settings.systems.sections.logs")}
|
||||
|
|
@ -68,6 +69,14 @@ export default function SystemsSettingsPage() {
|
|||
<LogViewerCard />
|
||||
</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)]">
|
||||
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||
<p>{t("settings.dataSafeNotice")}</p>
|
||||
|
|
|
|||
231
src/utils/changelog.test.ts
Normal file
231
src/utils/changelog.test.ts
Normal 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
119
src/utils/changelog.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue