test(docs): hold both locales to the shape the user guide reads

DocsContent and DocsSubsections read the guide straight from fr.json and
en.json, but their tests run on fixtures: a string where an array is
expected, a sub-section without its steps or a French list longer than
its English one only showed in the app. DocsContent.locales.test.ts reads
the real files: every section DocsContent renders and no other, the
strings and string arrays it reads, sub-sections that readSubsections
keeps whole, anchors of their own, French and English item for item, and
the path's sub-section in the section "Open the guide" opens.

DocsContent exports SECTIONS for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 09:53:52 -04:00
parent b0db1d3102
commit 7ce5816bb2
2 changed files with 150 additions and 1 deletions

View file

@ -0,0 +1,148 @@
// The user guide's docs.* keys, as DocsContent and DocsSubsections read them (#401).
//
// Both components read the guide straight from the locale files: `title` and
// `overview` as strings, `features`, `steps` and `tips` as string arrays
// (`returnObjects: true`), and the optional `subsections` through
// `readSubsections`, which drops a malformed entry without a word. Their own
// tests run on fixtures, so these tests hold the real files to that shape: a
// string where an array is expected, a sub-section without its steps, or a
// French list longer than its English one would otherwise only show in the app.
import { describe, it, expect } from "vitest";
import fr from "../../i18n/locales/fr.json";
import en from "../../i18n/locales/en.json";
import { SECTIONS } from "./DocsContent";
import { readSubsections } from "./DocsSubsections";
import { GETTING_STARTED_GUIDE } from "../onboarding/OnboardingBanner";
type Docs = Record<string, unknown>;
const LOCALES: Record<string, Docs> = {
fr: (fr as { docs: Docs }).docs,
en: (en as { docs: Docs }).docs,
};
const SECTION_KEYS: string[] = SECTIONS.map((section) => section.key);
/** The labels DocsContent reads besides the sections. */
const SHARED_LABELS = ["title", "print", "features", "howTo", "quickStart", "tipsHeader"];
const LISTS = ["features", "steps", "tips"] as const;
function isText(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function section(docs: Docs, key: string): Record<string, unknown> {
const value = docs[key];
expect(value, `docs.${key}`).toBeTypeOf("object");
return value as Record<string, unknown>;
}
/** The section's sub-sections as declared, or an empty array when it has none. */
function declaredSubsections(docs: Docs, key: string): Array<Record<string, unknown>> {
const value = section(docs, key).subsections;
return value === undefined ? [] : (value as Array<Record<string, unknown>>);
}
describe("docs.* keys of the user guide", () => {
it("declare, in both locales, every section DocsContent renders and no other", () => {
for (const [lng, docs] of Object.entries(LOCALES)) {
const sectionsInFile = Object.keys(docs).filter(
(key) => typeof docs[key] === "object" && docs[key] !== null,
);
expect(sectionsInFile, lng).toEqual(SECTION_KEYS);
}
});
it("give every section the strings and the string arrays DocsContent reads", () => {
for (const [lng, docs] of Object.entries(LOCALES)) {
for (const key of SECTION_KEYS) {
const value = section(docs, key);
expect(isText(value.title), `${lng} docs.${key}.title`).toBe(true);
expect(isText(value.overview), `${lng} docs.${key}.overview`).toBe(true);
for (const list of LISTS) {
const items = value[list];
expect(Array.isArray(items), `${lng} docs.${key}.${list} is an array`).toBe(true);
expect((items as unknown[]).length, `${lng} docs.${key}.${list}`).toBeGreaterThan(0);
for (const item of items as unknown[]) {
expect(isText(item), `${lng} docs.${key}.${list}: ${JSON.stringify(item)}`).toBe(true);
}
}
}
for (const label of SHARED_LABELS) {
expect(isText(docs[label]), `${lng} docs.${label}`).toBe(true);
}
}
});
it("keep every declared sub-section whole: readSubsections drops none", () => {
for (const [lng, docs] of Object.entries(LOCALES)) {
for (const key of SECTION_KEYS) {
const declared = section(docs, key).subsections;
if (declared === undefined) continue;
expect(Array.isArray(declared), `${lng} docs.${key}.subsections is an array`).toBe(true);
const kept = readSubsections(declared);
expect(kept, `${lng} docs.${key}.subsections`).toHaveLength((declared as unknown[]).length);
for (const sub of kept) {
const where = `${lng} docs.${key}.subsections[${sub.id}]`;
expect(isText(sub.id), `${where}.id`).toBe(true);
expect(isText(sub.title), `${where}.title`).toBe(true);
expect(sub.steps.length, `${where}.steps`).toBeGreaterThan(0);
for (const step of sub.steps) expect(isText(step), `${where}.steps`).toBe(true);
if (sub.status !== undefined) expect(isText(sub.status), `${where}.status`).toBe(true);
if (sub.note !== undefined) expect(isText(sub.note), `${where}.note`).toBe(true);
}
}
}
});
it("give each sub-section an anchor of its own, apart from the section keys", () => {
// DocsContent keeps sections and sub-sections in one table of anchors
// (`/settings/users#import-export`): a shared id would hijack the scroll.
for (const [lng, docs] of Object.entries(LOCALES)) {
const ids = SECTION_KEYS.flatMap((key) =>
declaredSubsections(docs, key).map((sub) => String(sub.id)),
);
expect(new Set(ids).size, `${lng}: ${ids.join(", ")}`).toBe(ids.length);
for (const id of ids) expect(SECTION_KEYS, `${lng}: ${id}`).not.toContain(id);
}
});
it("match French and English item for item", () => {
for (const key of SECTION_KEYS) {
const frSection = section(LOCALES.fr, key);
const enSection = section(LOCALES.en, key);
for (const list of LISTS) {
expect((enSection[list] as unknown[]).length, `docs.${key}.${list}`).toBe(
(frSection[list] as unknown[]).length,
);
}
const frSubs = declaredSubsections(LOCALES.fr, key);
const enSubs = declaredSubsections(LOCALES.en, key);
expect(enSubs.map((sub) => sub.id), `docs.${key}.subsections`).toEqual(
frSubs.map((sub) => sub.id),
);
frSubs.forEach((frSub, i) => {
const enSub = enSubs[i];
const where = `docs.${key}.subsections[${String(frSub.id)}]`;
expect((enSub.steps as unknown[]).length, `${where}.steps`).toBe(
(frSub.steps as unknown[]).length,
);
expect("status" in enSub, `${where}.status`).toBe("status" in frSub);
expect("note" in enSub, `${where}.note`).toBe("note" in frSub);
});
}
});
it("describe the getting-started path in the section that \"Open the guide\" opens", () => {
// The end of the path links to the guide by its hash (#397); the section it
// opens carries the path's own sub-section (#401).
const target = GETTING_STARTED_GUIDE.split("#")[1];
expect(SECTION_KEYS).toContain(target);
for (const [lng, docs] of Object.entries(LOCALES)) {
const ids = readSubsections(section(docs, target).subsections).map((sub) => sub.id);
expect(ids, lng).toContain("getting-started-path");
}
});
});

View file

@ -21,7 +21,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import DocsSubsections from "./DocsSubsections"; import DocsSubsections from "./DocsSubsections";
const SECTIONS = [ /** The guide's sections, in order; `DocsContent.locales.test.ts` holds both locales to them. */
export const SECTIONS = [
{ key: "gettingStarted", icon: Rocket }, { key: "gettingStarted", icon: Rocket },
{ key: "profiles", icon: Users }, { key: "profiles", icon: Users },
{ key: "dashboard", icon: LayoutDashboard }, { key: "dashboard", icon: LayoutDashboard },