|
| 1 | +/** |
| 2 | + * @ai-context 导航注册表守卫 —— 规格 §10 批 3 行「9 页全走注册表」的**机器判据**。 |
| 3 | + * |
| 4 | + * Why 用 vitest 而不是 `scripts/*.mjs`:`scripts/*.mjs` 不在 `line-limits` 扫描域 |
| 5 | + * (批 2 follow-up #16),而 `app/src/**` 在 ⇒ 守卫自己也被 300 行红线看着。 |
| 6 | + * |
| 7 | + * 判据分五层,每层都能**独立变红**、报错各自点名(每层的红都有对应的变异体实测,见 T6 报告): |
| 8 | + * ① 键集:组件表键集 = 注册表键集(9)· 唯一 · `pages/` 下每个非测试 `.tsx` 都被注册 |
| 9 | + * (枚举目录、不靠手写清单;且注册表**实际 import 的页面模块路径**必须与目录枚举一致); |
| 10 | + * ② 接线:每个 key 在 `App.tsx` 里确有 `page === "key"` —— 且 `App.tsx` **不直接 import 任何 |
| 11 | + * 页面模块**、组件一律经 `navComponent("key")` 取得(这是「全走注册表」的本体); |
| 12 | + * ③ 图标:9 个图标名都在 `ui/icons` 注册表里且互不相同(拼错必须报错,不许渲染成空白); |
| 13 | + * ④ 取值口:`navEntry` / `isPageKey` 的**正样本 + 阴性样本**; |
| 14 | + * ⑤ 不变量:顶栏顺序与 label 逐字冻结(T6 不许有观感变化)· `mountedPages` **只增不减** |
| 15 | + * (批 2 裁决 2 的保活硬要求:访问过的页面永不卸载)。 |
| 16 | + * |
| 17 | + * 口径:页面文件枚举 = `pages/*.tsx` 去掉 `*.test.tsx`(今日恰好 9 个)。 |
| 18 | + * ⇒ 将来加第 10 个页面文件而没登记,① 当场红(这正是「全走注册表」的意思)。 |
| 19 | + */ |
| 20 | +import { readdirSync, readFileSync } from "node:fs"; |
| 21 | +import { dirname, join } from "node:path"; |
| 22 | +import { fileURLToPath } from "node:url"; |
| 23 | +import { describe, expect, it } from "vitest"; |
| 24 | +import { ALL_ENTRIES, NAV_ENTRIES, PAGE_COMPONENTS, isPageKey, navComponent, navEntry, type PageKey } from "./navRegistry"; |
| 25 | +import { ICON_PATHS } from "../ui/icons"; |
| 26 | + |
| 27 | +const SRC = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 28 | +const APP_TSX = join(SRC, "App.tsx"); |
| 29 | +const REGISTRY_TS = join(SRC, "shell", "navRegistry.ts"); |
| 30 | +const PAGES = join(SRC, "pages"); |
| 31 | +const appSource = () => readFileSync(APP_TSX, "utf8"); |
| 32 | + |
| 33 | +/** 页面文件(不含测试)—— 从目录枚举,不手写清单 */ |
| 34 | +function pageFiles(): string[] { |
| 35 | + return readdirSync(PAGES) |
| 36 | + .filter((f) => f.endsWith(".tsx") && !f.endsWith(".test.tsx")) |
| 37 | + .map((f) => f.replace(/\.tsx$/, "")) |
| 38 | + .sort(); |
| 39 | +} |
| 40 | + |
| 41 | +/** 顶栏 8 项的**冻结**顺序与 label(逐字抄自 T6 之前的 `NAV_ITEMS`;T7 切到 `e.label` 时同提交改这里) */ |
| 42 | +const FROZEN_TOP_BAR: readonly (readonly [PageKey, string])[] = [ |
| 43 | + ["classroom", "📡 课堂助手"], |
| 44 | + ["sessions", "🗂 会话"], |
| 45 | + ["notes", "📝 笔记"], |
| 46 | + ["action", "✅ 行动"], |
| 47 | + ["review", "🔄 复习"], |
| 48 | + ["chat", "💬 AI 对话"], |
| 49 | + ["knowledge", "🧠 体系"], |
| 50 | + ["goals", "🎯 目标"], |
| 51 | +]; |
| 52 | + |
| 53 | +describe("导航注册表(规格 §10 批 3「9 页全走注册表」)", () => { |
| 54 | + it("① 注册表 9 项、键唯一", () => { |
| 55 | + expect(ALL_ENTRIES).toHaveLength(9); |
| 56 | + expect(new Set(ALL_ENTRIES.map((e) => e.key)).size).toBe(9); |
| 57 | + }); |
| 58 | + |
| 59 | + it("① 组件表键集 = 注册表键集(两张表不许各自另立清单,双向都判)", () => { |
| 60 | + expect(ALL_ENTRIES.map((e) => e.key).slice().sort()).toEqual(Object.keys(PAGE_COMPONENTS).slice().sort()); |
| 61 | + }); |
| 62 | + |
| 63 | + it("① pages/ 下每个非测试 .tsx 都在注册表里(孤儿页面 = 没走注册表)", () => { |
| 64 | + const declared = new Set(ALL_ENTRIES.map((e) => `${e.key[0].toUpperCase()}${e.key.slice(1)}Page`)); |
| 65 | + const orphans = pageFiles().filter((f) => !declared.has(f)); |
| 66 | + expect(orphans, `这些页面文件没有登记进注册表:\n${orphans.join("\n")}`).toEqual([]); |
| 67 | + }); |
| 68 | + |
| 69 | + it("① 注册表实际 import 的页面模块 = pages/ 目录枚举(第二判据:名字对不上也算没接线)", () => { |
| 70 | + const referenced = [...readFileSync(REGISTRY_TS, "utf8").matchAll(/["']\.\.\/pages\/([A-Za-z0-9_]+)["']/g)] |
| 71 | + .map((m) => m[1]) |
| 72 | + .sort(); |
| 73 | + // 枚举一侧非空(今日 9 个)⇒ 抽取器失效会以「差集非空」的形式变红,而不是静默判绿 |
| 74 | + expect(pageFiles().length).toBe(9); |
| 75 | + expect(referenced).toEqual(pageFiles()); |
| 76 | + }); |
| 77 | + |
| 78 | + it("② 每个 key 都在 App.tsx 里被渲染(注册了但没接线 = 点不到)", () => { |
| 79 | + const app = appSource(); |
| 80 | + const missing = ALL_ENTRIES.filter((e) => !app.includes(`page === "${e.key}"`)).map((e) => e.key); |
| 81 | + expect(missing, `这些 key 在 App.tsx 里没有对应的 page === 判断:\n${missing.join("\n")}`).toEqual([]); |
| 82 | + }); |
| 83 | + |
| 84 | + it("② App.tsx 不直接 import 任何页面模块(页面只能经注册表到达)", () => { |
| 85 | + const direct = [...appSource().matchAll(/["']\.\.?\/pages\/[A-Za-z0-9_]+["']/g)].map((m) => m[0]); |
| 86 | + expect(direct, `App.tsx 仍在直连页面模块:\n${direct.join("\n")}`).toEqual([]); |
| 87 | + }); |
| 88 | + |
| 89 | + it("② App.tsx 的 9 个页面组件标识符都取自 navComponent(key)", () => { |
| 90 | + const app = appSource(); |
| 91 | + const missing = ALL_ENTRIES.filter((e) => !app.includes(`navComponent("${e.key}")`)).map((e) => e.key); |
| 92 | + expect(missing, `这些 key 没有从注册表取组件:\n${missing.join("\n")}`).toEqual([]); |
| 93 | + expect(app).toContain('type Page = PageKey;'); |
| 94 | + }); |
| 95 | + |
| 96 | + it("③ 图标名都在 ui/icons 注册表里,且 9 页各用一个(不重复)", () => { |
| 97 | + const unknown = ALL_ENTRIES.filter((e) => !(e.icon in ICON_PATHS)).map((e) => `${e.key} → ${e.icon}`); |
| 98 | + expect(unknown, `这些图标名没注册:\n${unknown.join("\n")}`).toEqual([]); |
| 99 | + expect(new Set(ALL_ENTRIES.map((e) => e.icon)).size).toBe(9); |
| 100 | + }); |
| 101 | + |
| 102 | + it("④ navEntry / isPageKey:9 个 key 全是正样本,无意义串是阴性样本", () => { |
| 103 | + for (const e of ALL_ENTRIES) { |
| 104 | + expect(navEntry(e.key)).toBe(e); |
| 105 | + expect(isPageKey(e.key)).toBe(true); |
| 106 | + // 注册表条目持有的组件必须与取值口**同一个引用**(否则渲染路径与注册表脱钩、且每次渲染可能重挂载) |
| 107 | + expect(navComponent(e.key)).toBe(e.Component); |
| 108 | + } |
| 109 | + expect(isPageKey("not-a-page")).toBe(false); |
| 110 | + expect(isPageKey("")).toBe(false); |
| 111 | + expect(() => navEntry("not-a-page" as PageKey)).toThrow(/未注册的页面键/); |
| 112 | + }); |
| 113 | + |
| 114 | + it("⑤ 顶栏 8 项顺序 + label 逐字冻结(T6 零观感变化),设置项只在注册表不在顶栏", () => { |
| 115 | + expect(NAV_ENTRIES.map((e) => [e.key, e.legacyLabel])).toEqual(FROZEN_TOP_BAR); |
| 116 | + expect(ALL_ENTRIES.map((e) => e.key)).toEqual([...FROZEN_TOP_BAR.map(([k]) => k), "settings"]); |
| 117 | + // 注:`NAV_ENTRIES` 的键是 8 个字面量,`e.key === "settings"` 会被 tsc 判为「无重叠比较」 |
| 118 | + // (TS2367)⇒ 先宽成 string[] 再比(判据不变,仍能红) |
| 119 | + expect(NAV_ENTRIES.map((e) => e.key as string)).not.toContain("settings"); |
| 120 | + expect(ALL_ENTRIES.map((e) => e.key as string)).toContain("settings"); |
| 121 | + }); |
| 122 | + |
| 123 | + it("⑤ 顶栏 label 不再在 App.tsx 里重复(同一事实不再写两遍)", () => { |
| 124 | + const app = appSource(); |
| 125 | + // 判据必须是**引号定界的字面量**,不能用裸 `includes`:App.tsx 的注释里本来就有 |
| 126 | + // 「笔记」「会话」这些词(批 1 实测 6 例子串误判),裸 includes 会把注释判成重复。 |
| 127 | + const duplicated = ALL_ENTRIES.filter((e) => new RegExp(`["'\`]${e.legacyLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'\`]`).test(app)).map( |
| 128 | + (e) => e.key, |
| 129 | + ); |
| 130 | + expect(duplicated, `这些 label 仍在 App.tsx 里硬编码:\n${duplicated.join("\n")}`).toEqual([]); |
| 131 | + }); |
| 132 | + |
| 133 | + it("⑤ 保活不变量:mountedPages 只增不减,PageSlot 用 display 门控而非卸载", () => { |
| 134 | + const app = appSource(); |
| 135 | + // 只增的更新式(逐字)—— 换成「按当前页重建集合」即「切走即卸载」,是本仓禁止形态 |
| 136 | + expect(app).toContain("prev.has(page) ? prev : new Set(prev).add(page)"); |
| 137 | + expect(app).toContain("if (!mounted) return null;"); |
| 138 | + expect(app).toContain('display: show ? "block" : "none"'); |
| 139 | + expect(app).not.toMatch(/mountedPages\.delete\(/); |
| 140 | + expect(app).not.toMatch(/setMountedPages\(new Set\(\[page\]\)\)/); |
| 141 | + expect(app).not.toMatch(/setMountedPages\(\(\) =>/); |
| 142 | + }); |
| 143 | +}); |
0 commit comments