feat: Journal before the version history in Settings → System, history folded by version (#372) #470
8 changed files with 940 additions and 79 deletions
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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 { 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
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