docs(onboarding): user guide, architecture and QA checklist for the guided getting-started path #407
2 changed files with 150 additions and 1 deletions
148
src/components/settings/DocsContent.locales.test.ts
Normal file
148
src/components/settings/DocsContent.locales.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue