返回 CodeWhale
docs-ia.test.ts
根目录 / web / lib / docs-ia.test.ts
1 /**
2 * Information-architecture contracts: docs-map registration, sitemap and
3 * hreflang preservation, navigation parity across breakpoints and locales,
4 * and the accessibility hooks (skip link, labelled nav, aria-current).
5 *
6 * These are deterministic source/unit contracts in the same style as
7 * public-copy.test.ts: they read the real sources and assert structure, so a
8 * future IA change fails here first instead of drifting silently.
9 */
10 import { existsSync, readFileSync } from "node:fs";
11 import { describe, expect, it } from "vitest";
12 import { DOC_TOPICS, docTopicHref, getTopic } from "./docs-map";
13 import { docsTopicIsCurrent } from "./docs-navigation";
14 import { locales } from "./i18n/config";
15 import { getChrome, getHome } from "./i18n/dictionaries";
16 import {
17 footerProductLinks,
18 footerProjectLinks,
19 navLinks as buildNavLinks,
20 } from "./i18n/links";
21
22 const webRoot = new URL("../", import.meta.url);
23 const repoRoot = new URL("../../", import.meta.url);
24
25 function webText(path: string): string {
26 return readFileSync(new URL(path, webRoot), "utf8");
27 }
28
29 const sitemap = webText("app/sitemap.ts");
30 const nav = webText("components/nav.tsx");
31 const navLinks = webText("components/nav-links.tsx");
32 const mobileMenu = webText("components/mobile-menu.tsx");
33 const footer = webText("components/footer.tsx");
34 const localeLayout = webText("app/[locale]/layout.tsx");
35 const css = webText("app/globals.css");
36
37 describe("docs-map registration", () => {
38 it("registers the guide and vocabulary topics as first-party pages", () => {
39 const guide = getTopic("guide");
40 const vocabulary = getTopic("vocabulary");
41 expect(guide?.hasPage).toBe(true);
42 expect(vocabulary?.hasPage).toBe(true);
43 expect(vocabulary?.category).toBe("core-concepts");
44 expect(docTopicHref(guide!, "en")).toBe("/en/docs/guide");
45 expect(docTopicHref(vocabulary!, "zh")).toBe("/zh/docs/vocabulary");
46 expect(docsTopicIsCurrent(vocabulary!, "en", "/en/docs/vocabulary")).toBe(true);
47 });
48
49 it("keeps every docs topic repo source on disk", () => {
50 for (const topic of DOC_TOPICS) {
51 const sources = Array.isArray(topic.repoSource) ? topic.repoSource : [topic.repoSource];
52 for (const source of sources) {
53 expect(existsSync(new URL(source, repoRoot)), `${topic.id}: ${source}`).toBe(true);
54 }
55 }
56 });
57
58 it("keeps topic labels and descriptions bilingual", () => {
59 for (const topic of DOC_TOPICS) {
60 for (const pair of [topic.label, topic.description]) {
61 expect(pair.en.trim().length, `${topic.id} en`).toBeGreaterThan(0);
62 expect(pair.zh.trim().length, `${topic.id} zh`).toBeGreaterThan(0);
63 }
64 }
65 });
66 });
67
68 describe("sitemap and hreflang preservation", () => {
69 it("indexes every first-party docs page", () => {
70 for (const topic of DOC_TOPICS) {
71 if (!topic.hasPage) continue;
72 const path = topic.sitePath ? `/${topic.sitePath}` : `/docs/${topic.slug}`;
73 expect(sitemap, path).toContain(`"${path}"`);
74 }
75 expect(sitemap).toContain('"/docs/guide"');
76 expect(sitemap).toContain('"/docs/vocabulary"');
77 });
78
79 it("keeps per-locale alternate pairs for every indexed route", () => {
80 expect(sitemap).toContain("alternates");
81 // Both the routes and their hreflang alternates are generated from the
82 // canonical locale registry, never hardcoded per locale — asserting the
83 // literal `en:` / `zh:` pairs would forbid exactly that generalization.
84 expect(sitemap).toContain("locales.map");
85 expect(sitemap).toContain("locales.map((l) => [l, `${SITE_URL}/${l}${path}`])");
86 expect(locales).toContain("en");
87 expect(locales).toContain("zh");
88 });
89
90 it("keeps the new docs pages on the shared metadata helper", () => {
91 for (const route of ["guide", "vocabulary"]) {
92 const page = webText(`app/[locale]/docs/${route}/page.tsx`);
93 expect(page, route).toContain('import { buildPageMetadata } from "@/lib/page-meta"');
94 expect(page, route).toContain(`path: "/docs/${route}"`);
95 }
96 });
97 });
98
99 describe("navigation parity and accessibility", () => {
100 it("keeps desktop and mobile navigation on one shared link set", () => {
101 // Both surfaces consume the same `links` prop from nav.tsx — assert the
102 // wiring rather than duplicating the arrays.
103 expect(nav).toContain("<NavLinks links={links} primaryAria={chrome.navPrimaryAria} />");
104 expect(nav).toContain("links={links}");
105 expect(mobileMenu).toContain("links.map");
106 expect(navLinks).toContain("links.map");
107 // One generator feeds both surfaces — no per-locale hardcoded arrays.
108 expect(nav).toContain("navLinks(locale, chrome)");
109 expect(nav).not.toMatch(/const (EN|ZH)_LINKS/);
110 });
111
112 it("keeps nav link paths in exact locale-swap parity for every routed locale", () => {
113 // The hardcoded /en/ and /zh/ arrays are gone; assert the generated set
114 // directly, across every routed locale rather than only two of them.
115 const reference = buildNavLinks("en", getChrome("en")).map((l) =>
116 l.href.replace(/^\/en\//, ""),
117 );
118 expect(reference.length).toBeGreaterThanOrEqual(4);
119 expect(reference).toContain("docs/guide");
120 expect(reference).toContain("faq");
121 for (const locale of locales) {
122 const links = buildNavLinks(locale, getChrome(locale));
123 expect(
124 links.map((l) => l.href.replace(new RegExp(`^/${locale}/`), "")),
125 `${locale} nav routes`,
126 ).toEqual(reference);
127 for (const link of links) {
128 expect(link.href.startsWith(`/${locale}/`), `${locale} ${link.href}`).toBe(true);
129 expect(link.label.trim().length, `${locale} empty nav label`).toBeGreaterThan(0);
130 }
131 }
132 });
133
134 it("keeps footer link paths in exact locale-swap parity for every routed locale", () => {
135 const reference = footerProductLinks("en", getChrome("en")).map((l) =>
136 l.href.replace(/^\/en\//, ""),
137 );
138 expect(reference).toContain("docs/guide");
139 expect(reference).toContain("faq");
140 for (const locale of locales) {
141 const product = footerProductLinks(locale, getChrome(locale));
142 expect(
143 product.map((l) => l.href.replace(new RegExp(`^/${locale}/`), "")),
144 `${locale} footer product routes`,
145 ).toEqual(reference);
146 const project = footerProjectLinks(locale, getChrome(locale));
147 expect(project.map((l) => l.href), `${locale} footer project routes`).toEqual([
148 "https://github.com/Hmbown/CodeWhale",
149 "https://github.com/Hmbown/CodeWhale/issues",
150 "https://discord.gg/37gfS3ksug",
151 `/${locale}/contribute`,
152 "https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
153 ]);
154 }
155 });
156
157 it("labels the primary nav and marks the current page accessibly", () => {
158 expect(navLinks).toContain("aria-label={primaryAria}");
159 expect(getChrome("en").navPrimaryAria).toBe("Primary");
160 expect(getChrome("zh").navPrimaryAria).toBe("主导航");
161 expect(navLinks).toContain('aria-current={isActive ? "page" : undefined}');
162 expect(mobileMenu).toContain('aria-current={isActive ? "page" : undefined}');
163 expect(mobileMenu).toContain('aria-expanded={open}');
164 expect(mobileMenu).toContain('aria-controls="mobile-menu"');
165 expect(mobileMenu).toContain('role="dialog"');
166 });
167
168 it("ships a keyboard-reachable skip link to the main landmark", () => {
169 expect(localeLayout).toContain('href="#main-content"');
170 expect(localeLayout).toContain('className="skip-link"');
171 expect(localeLayout).toContain('<main id="main-content">');
172 expect(css).toContain(".skip-link:focus-visible");
173 });
174
175 it("keeps responsive breakpoints for the getting-started steps", () => {
176 // 4-up grid by default, 2-up at the tablet breakpoint, 1-up on phones —
177 // the same responsive ladder as the existing workflow steps.
178 expect(css).toMatch(/\.gs-steps\s*\{[^}]*repeat\(4, minmax\(0, 1fr\)\)/);
179 expect(css).toMatch(
180 /@media \(max-width: 760px\)[\s\S]*?\.gs-steps\s*\{[^}]*repeat\(2, minmax\(0, 1fr\)\)/,
181 );
182 expect(css).toMatch(
183 /@media \(max-width: 520px\)[\s\S]*?\.gs-steps\s*\{\s*grid-template-columns: 1fr/,
184 );
185 });
186
187 it("keeps the footer discovery links alongside the pinned legal links", () => {
188 // The link sets moved to lib/i18n/links.ts, so assert the rendered
189 // contract for en AND zh rather than scraping literals out of the TSX.
190 for (const locale of ["en", "zh"]) {
191 const product = footerProductLinks(locale, getChrome(locale)).map((l) => l.href);
192 expect(product, `${locale} footer product`).toContain(`/${locale}/docs/guide`);
193 expect(product, `${locale} footer product`).toContain(`/${locale}/faq`);
194 }
195 const license = footerProjectLinks("en", getChrome("en")).at(-1);
196 expect(license).toEqual({
197 label: "MIT license",
198 href: "https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
199 });
200 expect(footer).toContain("footerProductLinks(locale, chrome)");
201 expect(footer).toContain("footerProjectLinks(locale, chrome)");
202 });
203 });
204
205 describe("homepage integration", () => {
206 const homepage = webText("app/[locale]/page.tsx");
207
208 it("renders the shared getting-started path on the homepage", () => {
209 expect(homepage).toContain('import { GettingStartedSteps } from "@/components/getting-started-steps"');
210 expect(homepage).toContain("<GettingStartedSteps locale={locale} />");
211 expect(homepage).toContain("product-start");
212 expect(homepage).toContain("/docs/guide");
213 expect(homepage).toContain("/docs/vocabulary");
214 });
215
216 it("keeps the previously pinned homepage facts intact", () => {
217 // Guard against the new band accidentally displacing the public-copy
218 // gate's required surface (the full contract lives in public-copy.test.ts).
219 expect(homepage).toContain("facts.latestPublishedRelease");
220 // "Source candidate" is now the EN dictionary value the page renders.
221 expect(homepage).toContain("d.sourceCandidate");
222 expect(getHome("en").sourceCandidate).toBe("Source candidate");
223 expect(homepage).toContain('src="/codewhale-tui.png"');
224 for (const label of ["Plan", "Act", "Operate", "Ask", "Auto-Review", "Full Access"]) {
225 expect(homepage).toContain(label);
226 }
227 });
228 });
229
229 lines TYPESCRIPT