// 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( /]*>