|
| 1 | +// @vitest-environment node |
| 2 | +/** |
| 3 | + * @ai-context 批 5 **视图层架构守卫 · 图级**(Task 15;裁决 C1① / C14② / C15)。 |
| 4 | + * |
| 5 | + * 把「依赖方向」与「三条硬约束的可断言部分」变成**整目录级的机器判据** —— 判据对象是 |
| 6 | + * `app/src/views/**` 的**源码形态**,不是某个组件的单点 spy(那由 T7–T14 的行为判据承担)。 |
| 7 | + * 三条硬约束(规格 §7.3)的**图级/结构级**那一半: |
| 8 | + * ① 原文永远保留:默认视图是 `raw` ∧ 默认 spec **无 `load`**(不经惰性 ⇒ 必然常驻)。 |
| 9 | + * ② 非默认视图**模块级惰性**:每个非默认 spec 的 `load` 体内**恰一个 `import(`** ∧ 注册表顶部 |
| 10 | + * **零静态 value import**(`load` 目标集合 == 惰性目标集合 ⇒ 两边互为反向对照)。 |
| 11 | + * ③ C11 槽位跨两侧一致:`SessionViewHost.tsx` 与 `NotesReadingColumn.tsx` 各**恰 1 个** |
| 12 | + * `data-view-error-slot`,且**位于切换器之后**(源码位置判据)。 |
| 13 | + * |
| 14 | + * ★ 拆件(与 `registryResolution.test.ts` 拆 `registry.test.ts` 同因):加 A6(props ⊇ slot 的 |
| 15 | + * 类型级探针)后单文件 322 行 > 300 ⇒ **拆成 `architecture.guard.test.ts`(A1–A5)+** |
| 16 | + * **`architecture.slots.test.ts`(A6)**,判据一条未删、零新增豁免登记。两件各自带仪器副本 |
| 17 | + * (本仓既有先例:拆件不建「只为测试存在的生产面 helper」)。 |
| 18 | + * ★ A3 为什么不是「裸子串」(T8 实测的假阳性陷阱,台账 §十八 逐字):生产文件的**注释里逐字 |
| 19 | + * 写着**「零 `@tauri-apps` import」这条纪律,测试文件**必须** `vi.mock("@tauri-apps/api/core")` |
| 20 | + * ⇒ 裸子串扫描在 `views/**` 上假红一大片。故:**先剥注释**(复用 `sliceScan.stripComments`, |
| 21 | + * 等长空白 ⇒ 行号不漂)→ **排除 `*.test.tsx`** → **只匹配 import/export 声明**;两个读数 |
| 22 | + * **并列报**。阳性对照 `components/session-detail/SessionScreenCards.tsx`(`convertFileSrc`) |
| 23 | + * **必须命中**。 |
| 24 | + * ★ A2 的口径(C10#11「`import type` 零运行时边」):`views/registry.ts` 的**运行时**导入者 |
| 25 | + * **恰 2 个**(`pages/SessionsPage.tsx` / `pages/NotesPage.tsx`);`SessionDetailPanel.tsx` / |
| 26 | + * `NotesReadingColumn.tsx` / `SessionViewHost.tsx` 各 1 处 `import type` 取槽类型 ⇒ **不算导入者、 |
| 27 | + * 也不写例外名单**(三处都是 type-only,见 ④ 的读数)。 |
| 28 | + * ★ A2③ 的边界(**实测登记,不是判据放宽**):`views/**` 对 `components/**` 有 4 条**真实** |
| 29 | + * import(`NoteMarkdown` ×2 · `SessionScreenCard` · `NotePreviewView`)—— 前两条正是 C3/C8 |
| 30 | + * **要求**的复用(「复用 `NotePreviewView` 的**目标**是既定的」见 `registryResolution.test.ts:13-18`: |
| 31 | + * **取数仍由 `NotePreviewView` 自己做**,适配器只是垫片)。那条「间接取数」由图级判据抓不到 |
| 32 | + * (本守卫的诚实边界②),去向 = A7(显式登记 + 单个具名例外)与批 6/7 的 `NotePreviewView` 处置。 |
| 33 | + * 故 ③ 只钉**硬边**:`views/**` 不得 import `pages/**` / `shell/**`(今日读数 = 0)。 |
| 34 | + * |
| 35 | + * 本守卫**不能**证明什么(诚实边界):① 运行时是否不加载(产物级,见 T18)② 视图是否通过**间接** |
| 36 | + * 依赖取数(本件的 ③ 拦不住 `components/**` 那条路,见上)③ 不能替代每视图的 spy 判据(C14② 仍 |
| 37 | + * 逐组件要求)④ A4 证的是「`load` 里有 `import(`」这个**形态**,不是「该 `import()` 只加载了一个模块」。 |
| 38 | + * 副作用:只读源码。边界:路径全用 `fileURLToPath`(本仓路径含空格;`new URL().pathname` 不解 `%20`)。 |
| 39 | + */ |
| 40 | +import { existsSync, readFileSync } from "node:fs"; |
| 41 | +import { dirname, join, relative, sep } from "node:path"; |
| 42 | +import { fileURLToPath } from "node:url"; |
| 43 | +import { describe, expect, it } from "vitest"; |
| 44 | +import { readLines, relOf, stripComments, walkSources } from "../ui/primitives/sliceScan"; |
| 45 | + |
| 46 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 47 | +/** `app/src` —— 键一律是相对它的正斜杠路径(与五类棘轮同口径) */ |
| 48 | +const SRC = join(HERE, ".."); |
| 49 | + |
| 50 | +const read = (relPath: string): string => readFileSync(join(SRC, ...relPath.split("/")), "utf8"); |
| 51 | +/** 剥注释后的行数组(行号与原文一一对应 —— 顺序判据靠它) */ |
| 52 | +const stripped = (relPath: string): string[] => readLines(join(SRC, ...relPath.split("/"))).stripped; |
| 53 | + |
| 54 | +/** 相对导入一律按「导入者所在目录」解析成 `app/src` 下的正斜杠相对路径。裸模块名 ⇒ `null`。 */ |
| 55 | +const resolveSpec = (fromRel: string, spec: string): string | null => { |
| 56 | + if (!spec.startsWith(".")) return null; |
| 57 | + const dir = dirname(fromRel).split("/").filter((s) => s !== "" && s !== "."); |
| 58 | + return relative(SRC, join(SRC, ...dir, ...spec.split("/"))).split(sep).join("/"); |
| 59 | +}; |
| 60 | + |
| 61 | +/** |
| 62 | + * 导入/导出**声明**级的说明符(仪器只有这一份实现;A1/A2/A3 与三处自证共用)。覆盖 |
| 63 | + * `import/export … from "…"` · 副作用 `import "…"` · 动态 `import("…")`;**不匹配**注释或普通 |
| 64 | + * 字符串里的路径 —— 这正是 A3 与「裸子串」口径的分野。行号与 `stripped` 对齐(自证要行号)。 |
| 65 | + */ |
| 66 | +const SPECIFIER = /(?:\bfrom\s*|^\s*import\s*|\bimport\s*\(\s*)["']([^"']+)["']/; |
| 67 | +const specifiersOf = (relPath: string): string[] => |
| 68 | + stripped(relPath).map((l) => l.match(SPECIFIER)?.[1]).filter((s): s is string => s !== undefined); |
| 69 | +const specifiersIn = (source: string): string[] => |
| 70 | + stripComments(source).split(/\r?\n/).map((l) => l.match(SPECIFIER)?.[1]).filter((s): s is string => s !== undefined); |
| 71 | +/** 已解析成 `app/src` 相对路径的判定:落在 `views/**` 子树里(`views` 本身也算) */ |
| 72 | +const isViewsRel = (resolved: string | null): boolean => |
| 73 | + resolved !== null && (resolved === "views" || resolved.startsWith("views/")); |
| 74 | +/** **某文件**里指向 `views/**` 的边(先解析再判 —— 原始写法形如 `../views/registry`) */ |
| 75 | +const viewsEdgesOf = (relPath: string): string[] => |
| 76 | + specifiersOf(relPath).map((s) => resolveSpec(relPath, s)).filter(isViewsRel) as string[]; |
| 77 | + |
| 78 | +/** 域:某前缀子树下的源文件;默认只取**生产**件(测试是基建,不是被守卫的生产面)。 */ |
| 79 | +const prodUnder = (prefix: string, includeTests = false): string[] => |
| 80 | + walkSources(SRC).map((abs) => relOf(SRC, abs)).filter((r) => |
| 81 | + (prefix === "" || r === prefix || r.startsWith(`${prefix}/`)) && (includeTests || !/\.test\.tsx?$/.test(r))); |
| 82 | + |
| 83 | +describe("A1 · 首屏可达面不得指向 `views/**`", () => { |
| 84 | + const FIRST_SCREEN = ["App.tsx", ...prodUnder("shell"), ...prodUnder("ui/primitives")]; |
| 85 | + it("① 扫描器自证:声明级命中 / 无意义串 0 命中 / 测试文件不在域内", () => { |
| 86 | + const scan = (src: string): string[] => |
| 87 | + specifiersIn(src).map((s) => resolveSpec("pages/X.tsx", s)).filter(isViewsRel) as string[]; |
| 88 | + expect(scan('import { viewsFor } from "../views/registry";')).toEqual(["views/registry"]); |
| 89 | + expect(scan('const s = "views/registry";')).toEqual([]); |
| 90 | + expect(scan('// import { a } from "../views/registry";')).toEqual([]); |
| 91 | + expect(FIRST_SCREEN.length, "首屏域是空的(路径口径漂了 ⇒ 判据静默失效)").toBeGreaterThan(30); |
| 92 | + for (const anchor of ["App.tsx", "shell/ShellFallback.tsx", "ui/primitives/index.ts"]) { |
| 93 | + expect(FIRST_SCREEN.includes(anchor), `${anchor} 不在首屏域内 ⇒ 域过滤失效`).toBe(true); |
| 94 | + } |
| 95 | + expect(FIRST_SCREEN.some((r) => /\.test\.tsx?$/.test(r)), "测试文件掉进了域内").toBe(false); |
| 96 | + }); |
| 97 | + it("② 阳性对照:同一支扫描器在 `pages/SessionsPage.tsx` 上必须命中", () => { |
| 98 | + expect(viewsEdgesOf("pages/SessionsPage.tsx")).toEqual(["views/registry"]); |
| 99 | + }); |
| 100 | + it("③ 判据:`App.tsx` + `shell/**` + `ui/primitives/**` 零处指向 `views/**`", () => { |
| 101 | + const hits = FIRST_SCREEN.flatMap((r) => viewsEdgesOf(r).map((t) => `${r} -> ${t}`)); |
| 102 | + expect(hits, `首屏可达面出现了指向 views/** 的静态/动态导入:\n${hits.join("\n")}`).toEqual([]); |
| 103 | + }); |
| 104 | +}); |
| 105 | + |
| 106 | +describe("A2 · 注册表的导入者集合(C1①)", () => { |
| 107 | + const ALL = prodUnder(""); |
| 108 | + const touchesRegistry = (r: string): boolean => |
| 109 | + specifiersOf(r).some((s) => resolveSpec(r, s) === "views/registry"); |
| 110 | + it("① 运行时导入者**恰 2 个**:`pages/SessionsPage.tsx` 与 `pages/NotesPage.tsx`", () => { |
| 111 | + const runtime = ALL.filter((r) => stripped(r).some((l) => |
| 112 | + /(^|[^.\w])import\s+(?!type\b)[^;]*?\bfrom\s*["'][^"']*views\/registry["']/.test(l))); |
| 113 | + expect([...runtime].sort()).toEqual(["pages/NotesPage.tsx", "pages/SessionsPage.tsx"]); |
| 114 | + }); |
| 115 | + it("② `App.tsx` / `shell/**` / `ui/primitives/**` 零导入(**含 `import type`**)", () => { |
| 116 | + const firstScreen = ["App.tsx", ...prodUnder("shell"), ...prodUnder("ui/primitives")]; |
| 117 | + expect(firstScreen.filter(touchesRegistry), "首屏面(含 type-only)不得触碰注册表").toEqual([]); |
| 118 | + }); |
| 119 | + it("③ `views/**` 不导入 `pages/**` / `shell/**`(`components/**` 是复用面,读数见文件头)", () => { |
| 120 | + const views = prodUnder("views"); |
| 121 | + expect(views.length, "views 域是空的 ⇒ 判据静默失效").toBeGreaterThan(6); |
| 122 | + const bad = views.flatMap((r) => specifiersOf(r) |
| 123 | + .map((s) => [r, resolveSpec(r, s)] as const) |
| 124 | + .filter(([, t]) => t !== null && /^(pages|shell)\//.test(t as string)) |
| 125 | + .map(([f, t]) => `${f} -> ${t}`)); |
| 126 | + expect(bad, `views/** 出现了向上层目录的导入:\n${bad.join("\n")}`).toEqual([]); |
| 127 | + }); |
| 128 | + it("④ 反向对照(防空真):非 views 导入者恰 5 个(其中恰 3 个是 `import type`)", () => { |
| 129 | + const outsiders = ALL.filter((r) => !r.startsWith("views/") && touchesRegistry(r)).sort(); |
| 130 | + expect(outsiders, "注册表的非 views 导入者集合变了(C1① 只有那两个页面是运行时的)").toEqual([ |
| 131 | + "components/SessionDetailPanel.tsx", "components/notes/NotesReadingColumn.tsx", |
| 132 | + "components/session-detail/SessionViewHost.tsx", "pages/NotesPage.tsx", "pages/SessionsPage.tsx", |
| 133 | + ]); |
| 134 | + // C10#11 的口径落到**边**上:type-only 取注册表的恰好那 3 处,且取的都是**槽类型**(不是 `viewsFor`) |
| 135 | + const typeEdges = outsiders.flatMap((r) => stripped(r) |
| 136 | + .filter((l) => /(^|[^.\w])import\s+type\b/.test(l) && /views\/registry["']/.test(l)) |
| 137 | + .map((l) => `${r} :: ${l.match(/\{([^}]*)\}/)?.[1].replace(/\s+/g, " ").trim()}`)); |
| 138 | + expect(typeEdges, "type-only 取注册表的边集合变了").toEqual([ |
| 139 | + "components/SessionDetailPanel.tsx :: SessionViewSlot, ViewSpec", |
| 140 | + "components/notes/NotesReadingColumn.tsx :: NoteViewSlot, ViewSpec", |
| 141 | + "components/session-detail/SessionViewHost.tsx :: SessionViewSlot, ViewSpec", |
| 142 | + ]); |
| 143 | + expect(typeEdges.some((e) => e.includes("viewsFor")), "`viewsFor` 是运行时的,不许出现在 `import type` 里").toBe(false); |
| 144 | + const insiders = ALL.filter((r) => r.startsWith("views/") && r !== "views/registry.ts" && touchesRegistry(r)); |
| 145 | + expect(insiders.length, "views 内部一个槽类型消费者都没有 ⇒ 上半段空真").toBeGreaterThan(3); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +describe("A3 · `views/**` 零 `@tauri-apps` 边(C14②的整目录形态)", () => { |
| 150 | + const VIEWS = prodUnder("views"); |
| 151 | + const TESTS = prodUnder("views", true).filter((r) => /\.test\.tsx?$/.test(r)); |
| 152 | + const isTauri = (s: string): boolean => s.startsWith("@tauri-apps/"); |
| 153 | + it("① 扫描器自证:静态 / `import type` / 动态都命中;注释与无意义串 0 命中", () => { |
| 154 | + expect(specifiersIn('import { invoke } from "@tauri-apps/api/core";').filter(isTauri)).toHaveLength(1); |
| 155 | + expect(specifiersIn('import type { X } from "@tauri-apps/api/core";').filter(isTauri)).toHaveLength(1); |
| 156 | + expect(specifiersIn('void import("@tauri-apps/api/core");').filter(isTauri)).toHaveLength(1); |
| 157 | + expect(specifiersIn('// import { invoke } from "@tauri-apps/api/core";').filter(isTauri)).toEqual([]); |
| 158 | + expect(specifiersIn('/* import("@tauri-apps/api/core") */').filter(isTauri)).toEqual([]); |
| 159 | + expect(specifiersIn('import { a } from "@nowhere/nothing";').filter(isTauri)).toEqual([]); |
| 160 | + expect(VIEWS.some((r) => /\.test\.tsx?$/.test(r)), "测试文件掉进了生产域").toBe(false); |
| 161 | + }); |
| 162 | + it("② 阳性对照:`SessionScreenCards.tsx`(有 `convertFileSrc`)必须命中", () => { |
| 163 | + expect(specifiersOf("components/session-detail/SessionScreenCards.tsx").filter(isTauri)) |
| 164 | + .toEqual(["@tauri-apps/api/core"]); |
| 165 | + }); |
| 166 | + it("③ 判据:`views/**` 生产文件**声明级 0 命中**", () => { |
| 167 | + const hits = VIEWS.flatMap((r) => specifiersOf(r).filter(isTauri).map((s) => `${r} -> ${s}`)); |
| 168 | + expect(hits, `views/** 出现了 Tauri 边(含 import type):\n${hits.join("\n")}`).toEqual([]); |
| 169 | + }); |
| 170 | + it("④ 双读数并列(裸子串口径的假阳性有多大,看得见)", () => { |
| 171 | + const bare = (r: string): number => stripComments(read(r)).split("@tauri-apps").length - 1; |
| 172 | + const decl = (r: string): number => specifiersOf(r).filter(isTauri).length; |
| 173 | + const sum = (rs: readonly string[], f: (r: string) => number): number => rs.reduce((n, r) => n + f(r), 0); |
| 174 | + expect([sum(VIEWS, bare), sum(VIEWS, decl)], "生产域的裸子串 / 声明级两个读数").toEqual([0, 0]); |
| 175 | + expect(sum(TESTS, bare), "测试文件的裸子串命中(`vi.mock` 是基建,不判)").toBeGreaterThan(0); |
| 176 | + expect(sum(TESTS, decl), "测试文件的声明级命中").toBeGreaterThan(0); |
| 177 | + }); |
| 178 | +}); |
| 179 | + |
| 180 | +describe("A4 · 默认视图不经惰性 + 非默认视图模块级惰性(criterion ①②)", () => { |
| 181 | + const REG = "views/registry.ts"; |
| 182 | + /** 解析 `const <listName> … ];` 里的每个成员:`key` 与 `load` 体(多行合并成一行) */ |
| 183 | + function members(listName: string): { key: string; load: string | null }[] { |
| 184 | + const lines = stripped(REG); |
| 185 | + const start = lines.findIndex((l) => l.includes(`const ${listName}`)); |
| 186 | + expect(start, `${listName} 不在注册表里(口径漂了)`).toBeGreaterThan(0); |
| 187 | + const out: { key: string; load: string | null }[] = []; |
| 188 | + let cur: { key: string; load: string | null } | null = null; |
| 189 | + for (let i = start; i < lines.length; i += 1) { |
| 190 | + if (/^\];/.test(lines[i])) break; |
| 191 | + const key = lines[i].match(/key:\s*"([^"]+)"/); |
| 192 | + if (key) { |
| 193 | + if (cur) out.push(cur); |
| 194 | + cur = { key: key[1], load: null }; |
| 195 | + } |
| 196 | + if (cur && lines[i].includes("load:")) cur.load = (cur.load ?? "") + lines[i].slice(lines[i].indexOf("load:")); |
| 197 | + } |
| 198 | + if (cur) out.push(cur); |
| 199 | + return out; |
| 200 | + } |
| 201 | + const LISTS: readonly (readonly [string, { key: string; load: string | null }[]])[] = |
| 202 | + [["session", members("SESSION_VIEWS")], ["note", members("NOTE_VIEWS")]]; |
| 203 | + it("① 扫描器自证:`[0]` 是 `raw` ∧ 无 `load`;其余每个恰 1 个 `import(`", () => { |
| 204 | + for (const [name, list] of LISTS) { |
| 205 | + expect(list.length, `${name} 视图表解析为空 ⇒ 判据静默失效`).toBeGreaterThan(1); |
| 206 | + expect(list[0].key, `${name} 的 [0] 必须是 raw(规格 §7.3①)`).toBe("raw"); |
| 207 | + expect(list[0].load, `${name} 的默认视图**不得**有 load(不经惰性 ⇒ 必然常驻)`).toBeNull(); |
| 208 | + for (const m of list.slice(1)) { |
| 209 | + expect((m.load?.match(/import\(/g) ?? []).length, `${name}/${m.key} 的 load 体内必须恰 1 个 import(`).toBe(1); |
| 210 | + } |
| 211 | + } |
| 212 | + }); |
| 213 | + it("② 判据:无 `load` 的 spec 恰是两个默认视图;有 `load` 的每个都有惰性目标", () => { |
| 214 | + const loadless = LISTS.flatMap(([n, list]) => list.filter((m) => m.load === null).map((m) => `${n}/${m.key}`)); |
| 215 | + const loaded = LISTS.flatMap(([n, list]) => list.filter((m) => m.load !== null).map((m) => `${n}/${m.key}`)); |
| 216 | + expect(loadless, "无 load 的 spec 必须恰是两侧的默认视图").toEqual(["session/raw", "note/raw"]); |
| 217 | + expect(loaded.length, "非默认视图数为 0 ⇒ 惰性判据空真").toBeGreaterThan(2); |
| 218 | + }); |
| 219 | + it("③ 注册表顶部零静态 value import;`load` 目标都在盘上", () => { |
| 220 | + const src = stripped(REG).join("\n"); |
| 221 | + const targets = [...new Set([...src.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g)].map((m) => m[1]))]; |
| 222 | + expect(targets.length, "一个惰性目标都没有 ⇒ 判据空真").toBeGreaterThan(2); |
| 223 | + const staticValues = [...src.matchAll(/(^|\n)\s*import\s+(?!type\b)[^;\n]*?from\s*["']([^"']+)["']/g)].map((m) => m[2]); |
| 224 | + expect(staticValues, `注册表顶部出现了**静态 value** import(惰性破功):\n${staticValues.join("\n")}`).toEqual([]); |
| 225 | + for (const t of targets) { |
| 226 | + const resolved = resolveSpec(REG, t); |
| 227 | + expect(resolved, `惰性目标不是相对路径:${t}`).not.toBeNull(); |
| 228 | + const abs = join(SRC, ...resolved!.split("/")); |
| 229 | + expect(existsSync(abs) || existsSync(`${abs}.tsx`) || existsSync(`${abs}.ts`), `惰性目标不在盘上:${t}`).toBe(true); |
| 230 | + } |
| 231 | + }); |
| 232 | +}); |
| 233 | + |
| 234 | +describe("A5 · 三条硬约束的可断言部分(合取)", () => { |
| 235 | + const HOSTS = ["components/session-detail/SessionViewHost.tsx", "components/notes/NotesReadingColumn.tsx"]; |
| 236 | + /** 剥注释后某 token 的出现行号(顺序判据与「恰 1 处」共用) */ |
| 237 | + const hitLines = (h: string, token: string): number[] => |
| 238 | + stripped(h).flatMap((l, i) => (l.includes(token) ? [i + 1] : [])); |
| 239 | + it("① 默认视图 = `raw`(注册表 `[0]` ∧ 冻结清单 `[0]` 双向对拍)", () => { |
| 240 | + const frozen = stripped("views/registry.ts").join("\n").match(/FROZEN_VIEW_KEYS[\s\S]*?\n\};/)?.[0] ?? ""; |
| 241 | + expect(frozen.includes('session: ["raw"'), "冻结清单 session 的 [0] 不是 raw").toBe(true); |
| 242 | + expect(frozen.includes('note: ["raw"'), "冻结清单 note 的 [0] 不是 raw").toBe(true); |
| 243 | + }); |
| 244 | + it("② 两个宿主各有「默认视图常驻」的 `display` 三元渲染", () => { |
| 245 | + for (const h of HOSTS) { |
| 246 | + expect(stripped(h).join("\n").includes("isDefault"), `${h} 没有默认视图判定`).toBe(true); |
| 247 | + expect(/display:\s*isDefault\s*\?/.test(stripped(h).join("\n")), `${h} 没有常驻层的 display 三元`).toBe(true); |
| 248 | + } |
| 249 | + }); |
| 250 | + it("③ C11 槽位:两侧各恰 1 个 `data-view-error-slot`,且**位于切换器之后**", () => { |
| 251 | + for (const h of HOSTS) { |
| 252 | + const slot = hitLines(h, "data-view-error-slot"); |
| 253 | + const switcher = hitLines(h, "<ViewSwitcher"); |
| 254 | + expect(slot, `${h} 的 C11 槽位不是恰 1 处`).toHaveLength(1); |
| 255 | + expect(switcher, `${h} 的切换器调用点不是恰 1 处`).toHaveLength(1); |
| 256 | + expect(slot[0], `${h} 的槽位必须在切换器**之后**(C11:正文列头部、切换器下方)`).toBeGreaterThan(switcher[0]); |
| 257 | + expect(hitLines(h, 'className="ed-view-error-slot"'), `${h} 的槽位缺类名钩子`).toHaveLength(1); |
| 258 | + } |
| 259 | + }); |
| 260 | +}); |
0 commit comments