|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context `shell/ShellFallback.tsx` 迁入 L1 原语(批 4 T9)的**迁移判据**(五条)。 |
| 4 | + * |
| 5 | + * Why 单独立文件、而不动 `ShellFallback.test.tsx`:那个文件是批 3 的交付面,T9 的纪律是 |
| 6 | + * **既有断言一字不改**(它 5 条用例在迁移后原样全绿 —— 这是等价性的主证,见 `task-9-report.md`); |
| 7 | + * 本文件只补「迁移这件事本身」的判据:自足内联 `<div style=…>` 换成 `Loading` / `StatusLine` 之后, |
| 8 | + * **盘上的形态**与**渲染出的形态**各自都要有能失败的判据,否则「迁完了」只是一句话。 |
| 9 | + * |
| 10 | + * 判据(每条各带**自己的**变异体,且每个变异**新解一棵导出树**;实测见 `task-9-report.md` §变异体): |
| 11 | + * ① 经 **barrel** 导入原语、无深导入(ADR-033 §1:深导入不带 `motion.css` ⇒ reduced-motion 静默失效); |
| 12 | + * ② 文件**零行内 `style`、零颜色字面量**(ADR-033 §4 的调用点侧:迁移后不该再自带排版与色值); |
| 13 | + * ③ 渲染级:加载态根节点**就是 `Loading` 的形态**(`.ed-loading` + `.ed-probe` + `role="status"`); |
| 14 | + * ④ 渲染级:失败卡片**就是 `StatusLine kind="error"` 的形态**(`.ed-status--error` + `data-kind="error"` + `role="alert"`); |
| 15 | + * ⑤ 语义色链:**从渲染出的修饰类反查 `StatusLine.css`** ⇒ 失败卡最终必须是 `var(--ed-stamp)` 文字色。 |
| 16 | + * `kind` 与色值**都不在测试里硬写**(修饰符取自 DOM、色值取自 CSS,两端各只有一处真源); |
| 17 | + * CSS 侧 `.ed-status--error → var(--ed-stamp)` 的既有判据在 `StatusLine.test.tsx:213`,此处不复制。 |
| 18 | + * |
| 19 | + * ⚠️ 底座(照抄勿改):`vitest.config.ts` 全局 `environment: "node"` ⇒ 首行必须 jsdom 指令; |
| 20 | + * **未装** `jest-dom` / `user-event` ⇒ 原生 DOM API;无 `globals` ⇒ 显式 `afterEach(cleanup)`。 |
| 21 | + * 仪器纪律:两条源文本判据**一律先剥注释** —— 本文件头与 `ShellFallback.tsx` 的文件头都**逐字引用** |
| 22 | + * 了迁移前的形态(`ShellFallback.tsx` 的头注释里就有 `<div style=…>`),不剥注释时 ② 在自家文件上 |
| 23 | + * 就是红的;「剥注释承重」由 ② 的自证与 M6 变异体(注释里塞深导入)双向证明。 |
| 24 | + */ |
| 25 | +import { readFileSync } from "node:fs"; |
| 26 | +import { dirname, join } from "node:path"; |
| 27 | +import { fileURLToPath } from "node:url"; |
| 28 | +import { cleanup, render, screen } from "@testing-library/react"; |
| 29 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 30 | +import { ShellFallback, SlotErrorBoundary } from "./ShellFallback"; |
| 31 | + |
| 32 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 33 | +/** 迁移面源码 —— ① ② 的唯一对象 */ |
| 34 | +const TSX = readFileSync(join(HERE, "ShellFallback.tsx"), "utf8"); |
| 35 | +/** 语义色的真源(⑤ 反查它;本文件不硬写任何色值) */ |
| 36 | +const STATUS_CSS = readFileSync(join(HERE, "..", "ui", "primitives", "StatusLine.css"), "utf8"); |
| 37 | + |
| 38 | +/** 剥块注释与整行 `//` 注释(与 `ui/primitives/dialogMigration.a1.test.ts:67` 同一口径) */ |
| 39 | +const strip = (text: string): string => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); |
| 40 | +const CODE = strip(TSX); |
| 41 | + |
| 42 | +/** 深导入 = `ui/primitives/<X>`(barrel 之外的入口,ADR-033 §1 禁止) */ |
| 43 | +const RE_DEEP = /from\s*"(?:\.\.\/)+ui\/primitives\/[A-Za-z]+"/; |
| 44 | +/** barrel 导入(名字表在捕获组里,供 ① 断言导入的是哪几个) */ |
| 45 | +const RE_BARREL = /import\s*\{([^}]*)\}\s*from\s*"(?:\.\.\/)+ui\/primitives"/; |
| 46 | +/** 行内 `style=` —— 只认紧跟 `{` 的形态(`style={` / `style={{`) */ |
| 47 | +const RE_INLINE_STYLE = /\bstyle\s*=\s*\{/; |
| 48 | +/** 颜色字面量:#hex / rgb() / rgba() / hsl() / hsla() */ |
| 49 | +const RE_COLOR = /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(/; |
| 50 | + |
| 51 | +afterEach(cleanup); |
| 52 | + |
| 53 | +/** 模拟懒 chunk 加载失败(渲染期抛错 ⇒ React 落到最近的上层边界) */ |
| 54 | +function Boom(): never { |
| 55 | + throw new Error("模拟懒 chunk 加载失败"); |
| 56 | +} |
| 57 | + |
| 58 | +/** |
| 59 | + * 触发叶级边界并返回失败卡片根节点。 |
| 60 | + * 抛错时 React 仍会 `console.error` ⇒ 抑制后还原(与 `ShellFallback.test.tsx:51` 同款)。 |
| 61 | + */ |
| 62 | +function mountFailedCard(): HTMLElement { |
| 63 | + function Wrap({ bad }: { bad: boolean }) { |
| 64 | + return ( |
| 65 | + <SlotErrorBoundary> |
| 66 | + <span data-testid="child-marker">子内容</span> |
| 67 | + {bad ? <Boom /> : null} |
| 68 | + </SlotErrorBoundary> |
| 69 | + ); |
| 70 | + } |
| 71 | + const { rerender } = render(<Wrap bad={false} />); |
| 72 | + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 73 | + try { |
| 74 | + rerender(<Wrap bad />); |
| 75 | + } finally { |
| 76 | + spy.mockRestore(); |
| 77 | + } |
| 78 | + return screen.getByTestId("slot-error"); |
| 79 | +} |
| 80 | + |
| 81 | +/** 从类名里取出 `<base>--<kind>` 的 `<kind>`(档名只有 DOM 一处真源,测试不硬写) */ |
| 82 | +function modifierOf(el: Element, base: string): string | null { |
| 83 | + for (const cls of Array.from(el.classList)) { |
| 84 | + if (cls.startsWith(`${base}--`)) return cls.slice(base.length + 2); |
| 85 | + } |
| 86 | + return null; |
| 87 | +} |
| 88 | + |
| 89 | +/** 取一条 CSS 规则的原文(到第一个 `}` 为止;本仓规则体内没有花括号) */ |
| 90 | +function cssRule(css: string, selector: string): string | null { |
| 91 | + const at = css.indexOf(selector); |
| 92 | + if (at < 0) return null; |
| 93 | + return css.slice(at, css.indexOf("}", at) + 1); |
| 94 | +} |
| 95 | + |
| 96 | +describe("① 迁移面经 barrel 导入原语(ADR-033 §1)", () => { |
| 97 | + it("barrel 名字表含 Loading 与 StatusLine,且无深导入", () => { |
| 98 | + expect(RE_BARREL.test(CODE), "ShellFallback 不再从 barrel 导入原语").toBe(true); |
| 99 | + const names = (RE_BARREL.exec(CODE)?.[1] ?? "").split(",").map((s) => s.trim()); |
| 100 | + expect(names, "barrel 名字表里没有 Loading").toContain("Loading"); |
| 101 | + expect(names, "barrel 名字表里没有 StatusLine").toContain("StatusLine"); |
| 102 | + expect(CODE.match(RE_DEEP), "出现深导入(ADR-033 §1 禁止:深导入不带 motion.css)").toBeNull(); |
| 103 | + }); |
| 104 | + |
| 105 | + it("仪器自证:RE_BARREL / RE_DEEP 能区分 barrel 与深导入,且对无意义串报 0", () => { |
| 106 | + expect(RE_BARREL.test('import { Loading } from "../ui/primitives";')).toBe(true); |
| 107 | + expect(RE_DEEP.test('import { Loading } from "../ui/primitives";'), "barrel 被误判成深导入").toBe(false); |
| 108 | + expect(RE_DEEP.test('import { Loading } from "../ui/primitives/Loading";')).toBe(true); |
| 109 | + expect(RE_DEEP.test('import { Loading } from "../ui/primitivesX";'), "同前缀的另一目录被误判").toBe(false); |
| 110 | + expect(RE_BARREL.test("const k = 1;"), "无意义串命中").toBe(false); |
| 111 | + }); |
| 112 | +}); |
| 113 | + |
| 114 | +describe("② 迁移面零行内 style / 零颜色字面量(ADR-033 §4 调用点侧)", () => { |
| 115 | + it("剥注释后既无行内 style,也无任何颜色字面量", () => { |
| 116 | + expect(RE_INLINE_STYLE.test(CODE), "ShellFallback 又出现行内 style").toBe(false); |
| 117 | + expect(RE_COLOR.test(CODE), "ShellFallback 又写死了颜色(色值只许在 tokens 侧)").toBe(false); |
| 118 | + }); |
| 119 | + |
| 120 | + it("仪器自证:剥注释承重 —— 合成样本剥离前必须命中、剥离后必须归零", () => { |
| 121 | + const sample = '/* <div style={{ color: "#b91c1c" }} /> */\nconst k = 1;\n'; |
| 122 | + expect(RE_INLINE_STYLE.test(sample), "剥离前没命中 ⇒ 本判据测不到注释里的形态").toBe(true); |
| 123 | + expect(RE_COLOR.test(sample), "剥离前没命中颜色字面量").toBe(true); |
| 124 | + expect(RE_INLINE_STYLE.test(strip(sample)), "剥注释没生效(注释里的 style 仍被当代码)").toBe(false); |
| 125 | + expect(RE_COLOR.test(strip(sample)), "剥注释没生效(注释里的色值仍被当代码)").toBe(false); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +describe("③ 加载态 = Loading 原语(渲染级)", () => { |
| 130 | + it("根节点带 .ed-loading 与探针 .ed-probe,role=status,文案逐字未改", () => { |
| 131 | + render(<ShellFallback />); |
| 132 | + const el = screen.getByTestId("shell-fallback"); |
| 133 | + expect(el.classList.contains("ed-loading"), "加载态不是 Loading 原语渲染的(缺 .ed-loading)").toBe(true); |
| 134 | + expect(el.querySelector(".ed-probe"), "Loading 的探针没渲染出来 ⇒ 不是原语形态").toBeTruthy(); |
| 135 | + expect(el.getAttribute("role")).toBe("status"); |
| 136 | + expect(el.textContent, "加载文案被改动").toBe("正在载入…"); |
| 137 | + expect(screen.queryByTestId("slot-error"), "加载态不是错误态").toBeNull(); |
| 138 | + }); |
| 139 | +}); |
| 140 | + |
| 141 | +describe("④ 失败卡片 = StatusLine kind=error(渲染级)", () => { |
| 142 | + it("根节点带 .ed-status--error、data-kind=error、role=alert,文案逐字未改", () => { |
| 143 | + const card = mountFailedCard(); |
| 144 | + expect(card.classList.contains("ed-status"), "失败卡片不是 StatusLine 渲染的(缺 .ed-status)").toBe(true); |
| 145 | + expect(card.classList.contains("ed-status--error"), "kind 不是 error(危险档被降级)").toBe(true); |
| 146 | + expect(card.getAttribute("data-kind"), "机器可读的语义锚点不是 error").toBe("error"); |
| 147 | + expect(card.getAttribute("role"), "错误档必须即时播报(alert)").toBe("alert"); |
| 148 | + expect(card.textContent, "失败卡文案被改动(四处共用的措辞已冻结)").toBe( |
| 149 | + "此处加载失败——其余区域仍可使用;重启应用可恢复。", |
| 150 | + ); |
| 151 | + expect(screen.queryByTestId("shell-fallback"), "失败态误用了加载态").toBeNull(); |
| 152 | + expect(screen.queryByTestId("child-marker"), "children 仍挂着 ⇒ 没被边界卸载").toBeNull(); |
| 153 | + }); |
| 154 | +}); |
| 155 | + |
| 156 | +describe("⑤ 语义色链:渲染出的修饰类 → StatusLine.css 的 var(--ed-stamp)", () => { |
| 157 | + it("失败卡的 ed-status--<kind> 在 CSS 里映射到 var(--ed-stamp) 文字色", () => { |
| 158 | + const kind = modifierOf(mountFailedCard(), "ed-status") ?? ""; |
| 159 | + expect(kind, "失败卡片没有 ed-status--<kind> 修饰类(语义色无处落地)").not.toBe(""); |
| 160 | + const rule = cssRule(STATUS_CSS, `.ed-status--${kind}`); |
| 161 | + expect(rule, `StatusLine.css 里没有 .ed-status--${kind} 规则`).not.toBeNull(); |
| 162 | + expect(rule, `失败卡的语义色不是 var(--ed-stamp)(实得 ${String(rule)})`).toContain("var(--ed-stamp)"); |
| 163 | + }); |
| 164 | + |
| 165 | + it("仪器自证:cssRule / modifierOf 读法能区分四档、对无意义串必须读不到", () => { |
| 166 | + expect(cssRule(STATUS_CSS, ".ed-status--warn")).toContain("var(--ed-due)"); |
| 167 | + expect(cssRule(STATUS_CSS, ".ed-status--ok")).toContain("var(--ed-ok)"); |
| 168 | + expect(cssRule(STATUS_CSS, ".ed-status--nope"), "无意义修饰符必须读不到(否则 ⑤ 是空真)").toBeNull(); |
| 169 | + // 修饰类读法:`ed-status` 基类本身**不是**修饰符(前缀要求 `--`),空 classList 读到 null |
| 170 | + const probe = document.createElement("div"); |
| 171 | + probe.className = "ed-status"; |
| 172 | + expect(modifierOf(probe, "ed-status"), "基类被误当成修饰符").toBeNull(); |
| 173 | + probe.className = "ed-status ed-status--warn"; |
| 174 | + expect(modifierOf(probe, "ed-status")).toBe("warn"); |
| 175 | + }); |
| 176 | +}); |
0 commit comments