From 7ce5816bb289a32ab448e9420f8cdf3b8825d953 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 09:53:52 -0400 Subject: [PATCH] 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 --- .../settings/DocsContent.locales.test.ts | 148 ++++++++++++++++++ src/components/settings/DocsContent.tsx | 3 +- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/DocsContent.locales.test.ts diff --git a/src/components/settings/DocsContent.locales.test.ts b/src/components/settings/DocsContent.locales.test.ts new file mode 100644 index 0000000..3915ef5 --- /dev/null +++ b/src/components/settings/DocsContent.locales.test.ts @@ -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; + +const LOCALES: Record = { + 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 { + const value = docs[key]; + expect(value, `docs.${key}`).toBeTypeOf("object"); + return value as Record; +} + +/** The section's sub-sections as declared, or an empty array when it has none. */ +function declaredSubsections(docs: Docs, key: string): Array> { + const value = section(docs, key).subsections; + return value === undefined ? [] : (value as Array>); +} + +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"); + } + }); +}); diff --git a/src/components/settings/DocsContent.tsx b/src/components/settings/DocsContent.tsx index e48eed8..c5bb6bb 100644 --- a/src/components/settings/DocsContent.tsx +++ b/src/components/settings/DocsContent.tsx @@ -21,7 +21,8 @@ import { } from "lucide-react"; 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: "profiles", icon: Users }, { key: "dashboard", icon: LayoutDashboard },