Skip to content

Commit 556515b

Browse files
committed
feat(views): register view specs with session slot adapter
1 parent cc49b09 commit 556515b

4 files changed

Lines changed: 481 additions & 0 deletions

File tree

‎app/src/views/registry.test.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// @vitest-environment node
2+
/**
3+
* registry.test.ts — 视图注册表的**契约/键集**判据 G1 / G2 / G4 / G5 / G6(C1/C5 · T6)。
4+
*
5+
* @ai-context 为什么本文件被**拆成两件**(T6b 落地时的行数红线处置,控制方插播裁决 · 方案 1):
6+
* 把「契约与键集」(本件)与「`load` 解析 + 适配器」(`registryResolution.test.ts`)分开。
7+
* 触发事实:本件若含 G3/G7/A1/A2 则为 **354 行** > 300,而本批硬约束是「新文件一律 ≤300、
8+
* **不得新增豁免登记**」(301–600 必须进豁免表)⇒ 拆件是唯一不产生新登记、也不删判据的出路。
9+
* **判据一条没少**:拆前 22 条 → 本件 13 条 + `registryResolution.test.ts` 9 条 = 22 条。
10+
* **没有共用 helper 文件**:两件各自 import `./registry`(被测模块)即可,无需抽出共享件
11+
* ⇒ 不产生「只为测试存在的 `views/**` 生产面文件」这个新问题。
12+
* @ai-context 口径:**期望值逐字写字符串字面量**,绝不拿被测模块的导出参与构造期望值(那是
13+
* 「自引用 = 永真断言」,本仓已实证)。冻结清单与图标名都独立写在断言里。
14+
* @ai-context 「每个 `icon` 是 `ICON_NAMES` 成员」为什么要独立成 G6:`IconName` 是**类型**,
15+
* 运行期可以塞进任何字符串(`as` / JS 调用点 / 手改字面量)⇒ 类型层由 `tsc` 兜、成员层由本件兜,
16+
* 两层互补;emoji 与裸串的反向对照写在同一 `describe` 里(阳性 + 阴性成对)。
17+
* 副作用:无(纯读导出 + 只读 `registry.ts` 源码做 emoji 反向对照);不写盘、不发请求、不挂 DOM。
18+
* 边界:本件**不覆盖** `load` 的形态与目标(那是 `registryResolution.test.ts` 的 G3/G7)——
19+
* 「非默认视图真的不进首屏」由 T15 的图级判据 + T10/T18 的真实构建读数覆盖(三条口径互补)。
20+
*/
21+
import { readFileSync } from "node:fs";
22+
import { dirname, join } from "node:path";
23+
import { fileURLToPath } from "node:url";
24+
import { describe, expect, it } from "vitest";
25+
import { ICON_NAMES } from "../ui/icons";
26+
import type { ObjectType } from "./registry";
27+
import { FROZEN_VIEW_KEYS, keysFor, viewsFor } from "./registry";
28+
29+
/** G1 的**独立**冻结清单(逐字字面量;顺序 = 计划给的 spec 顺序,`[0]` = 默认视图)。 */
30+
const EXPECTED_KEYS: Readonly<Record<ObjectType, readonly string[]>> = {
31+
session: ["raw", "tritrack", "proof", "cardflow", "preview"],
32+
note: ["raw", "cardflow"],
33+
};
34+
35+
const TYPES: readonly ObjectType[] = ["session", "note"];
36+
37+
/** G1 的判据本体(抽成函数 ⇒ 阴性样本走**同一条**代码路径,而不是复述一遍期望值)。 */
38+
function keyDiff(actual: readonly string[], want: readonly string[]): string[] {
39+
const out: string[] = [];
40+
for (let i = 0; i < Math.max(actual.length, want.length); i++) {
41+
const w: string | undefined = want[i];
42+
const g: string | undefined = actual[i];
43+
if (w !== g) out.push(`[${i}] ${w ?? "<缺>"} → ${g ?? "<缺>"}`);
44+
}
45+
return out;
46+
}
47+
48+
const SRC = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "registry.ts"), "utf8");
49+
50+
describe("G1 冻结表的三条不变式", () => {
51+
it("① keysFor / FROZEN_VIEW_KEYS / viewsFor 的键序都逐字等于冻结清单(长度 + 顺序 + 拼写)", () => {
52+
for (const t of TYPES) {
53+
expect(keyDiff(keysFor(t), EXPECTED_KEYS[t]), `${t}:可用视图键漂移`).toEqual([]);
54+
expect(keyDiff(FROZEN_VIEW_KEYS[t], EXPECTED_KEYS[t]), `${t}:冻结表被改宽/改窄`).toEqual([]);
55+
expect(keyDiff(viewsFor(t).map((s) => s.key), EXPECTED_KEYS[t]), `${t}:spec 顺序漂移`).toEqual([]);
56+
}
57+
});
58+
59+
it("① 阴性样本:删项 / 多项 / 读空 各必红(走同一条判据)", () => {
60+
const dropped = EXPECTED_KEYS.session.filter((k) => k !== "cardflow");
61+
expect(keyDiff(dropped, EXPECTED_KEYS.session)).toEqual(["[3] cardflow → preview", "[4] preview → <缺>"]);
62+
expect(keyDiff([...EXPECTED_KEYS.note, "extra"], EXPECTED_KEYS.note)).toEqual(["[2] <缺> → extra"]);
63+
expect(keyDiff([], EXPECTED_KEYS.session)).toHaveLength(5);
64+
});
65+
66+
it("② 计数不变式:FROZEN_VIEW_KEYS[t].length === viewsFor(t).length(FROZEN == Σentries 的同形)", () => {
67+
for (const t of TYPES) {
68+
expect(FROZEN_VIEW_KEYS[t].length, `${t}:冻结表与实现的条目数不一致`).toBe(viewsFor(t).length);
69+
}
70+
});
71+
72+
it("③ 锚:默认视图在首位且键为 raw", () => {
73+
for (const t of TYPES) {
74+
expect(FROZEN_VIEW_KEYS[t][0]).toBe("raw");
75+
expect(viewsFor(t)[0].key).toBe("raw");
76+
}
77+
});
78+
});
79+
80+
describe("G2 默认视图不带 load;其余每个都有(无 load 者恰 1 个)", () => {
81+
it("每个对象类型:无 load 的恰 1 个,且它就是 [0](默认视图常驻、不经 React.lazy)", () => {
82+
for (const t of TYPES) {
83+
const noLoad = viewsFor(t).filter((s) => s.load === undefined);
84+
expect(noLoad.map((s) => s.key), `${t}:无 load 的 spec 必须恰为默认视图`).toEqual(["raw"]);
85+
expect(viewsFor(t)[0].load, `${t}:默认视图不得提供 load`).toBeUndefined();
86+
}
87+
});
88+
89+
it("其余每个 spec 都有可调用的 load", () => {
90+
for (const t of TYPES) {
91+
const withLoad = viewsFor(t).filter((s) => s.load !== undefined);
92+
expect(withLoad.length).toBe(viewsFor(t).length - 1);
93+
for (const spec of withLoad) expect(typeof spec.load, `${spec.key} 的 load`).toBe("function");
94+
}
95+
});
96+
});
97+
98+
describe("G4 appliesTo 与其被查询的类型一致", () => {
99+
it("viewsFor(t) 里每个 spec 的 appliesTo === t", () => {
100+
for (const t of TYPES) {
101+
expect(viewsFor(t).map((s) => s.appliesTo), `${t}:混入了别的对象类型的 spec`).toEqual(
102+
viewsFor(t).map(() => t),
103+
);
104+
}
105+
});
106+
107+
it("会话独有视图不出现在笔记侧", () => {
108+
const sessionOnly = ["tritrack", "proof", "preview"];
109+
expect(keysFor("note").filter((k) => sessionOnly.includes(k))).toEqual([]);
110+
});
111+
});
112+
113+
describe("G5 未知 objectType ⇒ [](不是抛、不是默认全集)", () => {
114+
it("viewsFor / keysFor 对未知类型都返回空表", () => {
115+
const unknown = "system" as ObjectType;
116+
expect(viewsFor(unknown)).toEqual([]);
117+
expect(keysFor(unknown)).toEqual([]);
118+
expect(viewsFor(unknown)).not.toHaveLength(viewsFor("session").length);
119+
});
120+
121+
it("阴性样本:空表**不能**由「注册表读空」冒充(G3 已要求 load 解析出真实模块)", () => {
122+
expect(viewsFor("session").length).toBeGreaterThan(0);
123+
expect(viewsFor("note").length).toBeGreaterThan(0);
124+
});
125+
});
126+
127+
describe("G6 每个 icon 都是图标注册表的成员(不是 emoji、不是裸字符串)", () => {
128+
it("所有 spec 的 icon ∈ ICON_NAMES,且形状是注册表命名(小写 + 连字符)", () => {
129+
for (const t of TYPES) {
130+
for (const spec of viewsFor(t)) {
131+
expect(ICON_NAMES, `${t}/${spec.key} 的 icon="${String(spec.icon)}" 不在图标注册表里`).toContain(spec.icon);
132+
expect(String(spec.icon)).toMatch(/^[a-z][a-z0-9-]*$/);
133+
}
134+
}
135+
});
136+
137+
it("仪器双侧自证:已知图标命中、emoji 与无意义串不命中", () => {
138+
expect(ICON_NAMES).toContain("sessions");
139+
expect(ICON_NAMES).not.toContain("🖼");
140+
expect(ICON_NAMES).not.toContain("no-such-icon");
141+
expect(ICON_NAMES.length).toBeGreaterThan(20);
142+
// 源码层反向对照:注册表里也不许出现 emoji(与行为层的 icon 断言互补)
143+
expect(SRC).not.toContain("🖼");
144+
});
145+
});

‎app/src/views/registry.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,55 @@ export interface NoteViewSlot {
5555
readonly onOpenSession?: (sessionId: number) => void;
5656
readonly onImageOpen: (src: string, title?: string) => void;
5757
}
58+
59+
/**
60+
* ── 视图清单(本文件的下半部)─────────────────────────────────────────────────────────────
61+
* @ai-context 会话 spec 顺序(`[0]` = 默认):`raw` → `tritrack` → `proof` → `cardflow` → `preview`。
62+
* 既有第二视图 `preview` 今天是被页 chunk **静态** import 的 `NotePreviewView`;进注册表后
63+
* **必须惰性**(R-1:否则「非默认视图模块级惰性」这条硬约束当场破功)。
64+
* @ai-context `preview` 的 `load` 指向 **`./session/SessionNotePreview`(会话槽适配器)**,不是
65+
* `NotePreviewView` 本体:`NotePreviewView` 的 props 是 `{sessionId, autoTaskId, onTaskStarted}`
66+
* (容器形态、自带取数),而注册表的 `P` 是 `SessionViewSlot`(纯注入槽)—— 两者不同形,
67+
* 直接指向它得到 `TS2322`。适配器把槽映射成 props(**不是**第二套取数),详见该文件头部。
68+
* @ai-context 笔记 spec 顺序:`raw` → `cardflow`。「带证据三轨」是 T13 的**条件项** —— T1 的探针
69+
* 判定「不存在逐段级『笔记 ↔ 证据』读取路径」⇒ **不进表**(C2:不许空壳视图)。
70+
* @ai-context `FROZEN_VIEW_KEYS` 是**独立于实现的**冻结声明:`keysFor` 由 `viewsFor` 派生,冻结表
71+
* 逐字写死 ⇒ 二者对拍(G1)能同时抓住「实现偷偷增删视图」与「冻结表被手工改宽」。
72+
*/
73+
const SESSION_VIEWS: readonly ViewSpec<SessionViewSlot>[] = [
74+
// 默认视图:**无 `load`** —— 容器同步渲染、常驻(§7.3①);图标 = 堆叠的行(逐行原文)
75+
{ key: "raw", label: "原文", icon: "sessions", appliesTo: "session" },
76+
{ key: "tritrack", label: "三轨对齐", icon: "clock", appliesTo: "session", load: () => import("./session/SessionTriTrackView") },
77+
{ key: "proof", label: "印样", icon: "image", appliesTo: "session", load: () => import("./session/SessionProofView") },
78+
{ key: "cardflow", label: "卡片流", icon: "classroom", appliesTo: "session", load: () => import("./session/SessionCardFlowView") },
79+
{ key: "preview", label: "笔记预览", icon: "notes", appliesTo: "session", load: () => import("./session/SessionNotePreview") },
80+
];
81+
82+
const NOTE_VIEWS: readonly ViewSpec<NoteViewSlot>[] = [
83+
// 默认视图:**无 `load`**(同上);图标 = 文档 + 文字行
84+
{ key: "raw", label: "原文", icon: "notes", appliesTo: "note" },
85+
{ key: "cardflow", label: "卡片流", icon: "action", appliesTo: "note", load: () => import("./note/NoteCardFlowView") },
86+
];
87+
88+
/** 可用视图清单(按对象类型;`viewsFor(t)[0]` 恒为默认视图)。未知 `objectType` ⇒ `[]`(G5)。 */
89+
export function viewsFor(objectType: "session"): readonly ViewSpec<SessionViewSlot>[];
90+
export function viewsFor(objectType: "note"): readonly ViewSpec<NoteViewSlot>[];
91+
export function viewsFor(objectType: ObjectType): readonly (ViewSpec<SessionViewSlot> | ViewSpec<NoteViewSlot>)[];
92+
export function viewsFor(objectType: ObjectType): readonly (ViewSpec<SessionViewSlot> | ViewSpec<NoteViewSlot>)[] {
93+
if (objectType === "session") return SESSION_VIEWS;
94+
if (objectType === "note") return NOTE_VIEWS;
95+
// 未知类型:**返回空表**,不是抛错、也不是「默认全集」(G5)—— 调用点拿不到视图就渲染空态,
96+
// 而不是把会话视图塞进笔记面板。
97+
return [];
98+
}
99+
100+
/** 可用视图键。**由 `viewsFor` 派生**(视图清单只有一处),与 `FROZEN_VIEW_KEYS` 由 G1 对拍。 */
101+
export function keysFor(objectType: ObjectType): readonly string[] {
102+
return viewsFor(objectType).map((spec) => spec.key);
103+
}
104+
105+
/** 冻结的视图键清单(顺序 = spec 顺序,`[0]` = 默认视图)。增删视图必须**同时**改此处与 `viewsFor`。 */
106+
export const FROZEN_VIEW_KEYS: Readonly<Record<ObjectType, readonly string[]>> = {
107+
session: ["raw", "tritrack", "proof", "cardflow", "preview"],
108+
note: ["raw", "cardflow"],
109+
};

0 commit comments

Comments
 (0)