diff --git a/src/components/settings/ChangelogContent.test.tsx b/src/components/settings/ChangelogContent.test.tsx new file mode 100644 index 0000000..979cfe4 --- /dev/null +++ b/src/components/settings/ChangelogContent.test.tsx @@ -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 & { 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; +} + +let files: Record; +/** The bundled files, `files` by URL; any other URL fails, as an unreadable file. */ +const readFiles = async (url: string): Promise => { + 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 { + 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é"); + expect(html).toContain(">Corrigé"); + 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( + /]*> + + {entries.map((entry, index) => { + const open = expanded.has(entry.version); + const notesId = `${CHANGELOG_NOTES_ID_PREFIX}${index}`; + return ( +
+

+ +

+ {/* Always in the page, so `aria-controls` names an element even while folded. */} +
+ {open && ( +
+ {entry.sections.map((section, si) => ( +
+

+ {section.heading} +

+
    + {section.items.map((item, ii) => ( +
  • + {"• "} + {item.replace(/\*\*(.+?)\*\*/g, "$1")} +
  • + ))} +
+
+ ))} +
+ )}
- ))} -
- ))} + + ); + })} ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ee007fa..37e00c7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f1db1e9..bea3b6e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/pages/settings/SystemsSettingsPage.test.tsx b/src/pages/settings/SystemsSettingsPage.test.tsx new file mode 100644 index 0000000..6d4b267 --- /dev/null +++ b/src/pages/settings/SystemsSettingsPage.test.tsx @@ -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("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 & { 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", + ); + }); +}); diff --git a/src/pages/settings/SystemsSettingsPage.tsx b/src/pages/settings/SystemsSettingsPage.tsx index 6a0ba35..499377f 100644 --- a/src/pages/settings/SystemsSettingsPage.tsx +++ b/src/pages/settings/SystemsSettingsPage.tsx @@ -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(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() {

{APP_NAME}

- {t("settings.version", { version })} + {t("settings.version", { version: version ?? "" })}

@@ -54,13 +61,7 @@ export default function SystemsSettingsPage() { -
-

- {t("settings.systems.sections.changelog")} -

- -
- + {/* The Journal comes before the version history, which grows at each release (#372). */}

{t("settings.systems.sections.logs")} @@ -68,6 +69,14 @@ export default function SystemsSettingsPage() {

+
+

+ {t("settings.systems.sections.changelog")} +

+ {/* Mounted once the version is settled: the history opens on it (#372). */} + {version !== undefined && } +
+

{t("settings.dataSafeNotice")}

diff --git a/src/utils/changelog.test.ts b/src/utils/changelog.test.ts new file mode 100644 index 0000000..bb46e17 --- /dev/null +++ b/src/utils/changelog.test.ts @@ -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 = {}): ChangelogEntry { + return { + version, + sections: Object.entries(sections).map(([heading, items]) => ({ heading, items })), + }; +} + +const sorted = (set: Set) => [...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]); + } + }); +}); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts new file mode 100644 index 0000000..c414e74 --- /dev/null +++ b/src/utils/changelog.ts @@ -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 { + 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, version: string): Set { + 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): 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): Set { + return allVersionsExpanded(entries, expanded) + ? new Set() + : new Set(entries.map((entry) => entry.version)); +}