Simpl-Resultat/src/components/settings/ChangelogContent.test.tsx
le king fu 2389d51b48 test(settings): a late failure of the previous language keeps the history (#372)
Review 25424, point 1: the `if (!cancelled)` of ChangelogContent's
.catch had no test (V1). Modelled on the late-answer test: the French
file rejects after the English one was read, and the history still shows
the English notes, not the empty message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:10:03 -04:00

363 lines
13 KiB
TypeScript

// 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)/);
});
it("never lets a late failure of the previous language empty the current history", async () => {
const pending: Array<{ url: string; resolve: (text: string) => void; reject: (error: Error) => void }> = [];
fetchMock.mockImplementation(
(url: string) =>
new Promise<FetchedFile>((resolveFetch, rejectFetch) => {
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }), reject: rejectFetch });
}),
);
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].reject(new Error("the French file could not be read"));
await settle();
const html = renderToStaticMarkup(render("0.17.1"));
expect(html).toContain("note-0171-en");
expect(html).not.toContain("t(changelog.empty)");
});
});