|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context EmptyState.test.tsx — L1 原语 `EmptyState` 的接口契约(批 0-D Task 10)。 |
| 4 | + * |
| 5 | + * Why:本仓此前**没有空态组件** —— 等价物是散落在 **40 行 / 28 文件**的「暂无…」灰字 |
| 6 | + * (recon §2.1:`暂无|还没有|尚无|空空|没有任何`),5 套空态并存、**首启路径无主行动按钮** |
| 7 | + * (规格 §5.1),且**无容器、无 `data-*` 锚点 ⇒ 无法挂入场动画**(recon §8.3)。⇒ 本文件钉的是 |
| 8 | + * 「类名集合 + 三段槽位 + 主行动按钮走 `Button` + 入场接缝存在」,这四样是批 4 迁移 28 个文件 |
| 9 | + * 与批 6 挂编排层动效的共同契约。 |
| 10 | + * |
| 11 | + * 副作用:只读磁盘(同目录 `EmptyState.css` / `EmptyState.tsx` / `motion.css` 的文本),不修改任何文件。 |
| 12 | + * 边界:① jsdom **不做样式级联** ⇒ `@keyframes` / `animation` 的效果无法行为级验证,只能守 |
| 13 | + * **CSS 文本**(同 `Button.test.tsx` 思路),**不假装验证了视觉**;可行为级判定的是 |
| 14 | + * 「渲染出真实 `<button>` + 类名 + 点击语义 + `aria-hidden`」这一侧。 |
| 15 | + * ② 本仓未装 `jest-dom` / `user-event`(硬约束:不新增依赖)⇒ 断言用原生 DOM API。 |
| 16 | + * ③ 走 `./index` 导入面(同 Text/Surface/Button 先例),顺带证明导出面可用。 |
| 17 | + * ④ **内联 svg 探测串必须拼接**:批 0-B 的棘轮守卫对 `.tsx` 做**全文件文本扫描** |
| 18 | + * (`includes("<" + "svg")` 即命中)⇒ 本文件里若原样写出该字面量,会把自己变成「新增内联 |
| 19 | + * svg 的文件」而让守卫红(同条纪律的另一面:jsdom 指令串也是全文件扫描,故只在首行出现)。 |
| 20 | + */ |
| 21 | +import { readFileSync } from "node:fs"; |
| 22 | +import { dirname, join } from "node:path"; |
| 23 | +import { fileURLToPath } from "node:url"; |
| 24 | +import { fireEvent, render } from "@testing-library/react"; |
| 25 | +import { describe, expect, it, vi } from "vitest"; |
| 26 | +import { EmptyState } from "./index"; |
| 27 | +import type { EmptyStateAction } from "./index"; |
| 28 | + |
| 29 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 30 | +/** 判据前先剥注释(同 `style-seams.test.ts`):注释里提到 `infinite` / 颜色名不该让守卫误报 */ |
| 31 | +const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, ""); |
| 32 | +const readText = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n"); |
| 33 | + |
| 34 | +const EMPTY_CSS = stripComments(readText("EmptyState.css")); |
| 35 | +const EMPTY_TSX = readText("EmptyState.tsx"); |
| 36 | +const MOTION_CSS = stripComments(readText("motion.css")); |
| 37 | + |
| 38 | +/** 取一段**配对花括号**的块;缺块直接抛(比 `!` 更能定位「CSS 少写了哪条」)。 |
| 39 | + 必须是配对版而非「到第一个 `}`」:`@keyframes` 里嵌着 `from`/`to` 两个子块。 */ |
| 40 | +function block(selector: string): string { |
| 41 | + const start = EMPTY_CSS.indexOf(selector); |
| 42 | + if (start < 0) throw new Error(`EmptyState.css 缺少:${selector}`); |
| 43 | + const open = EMPTY_CSS.indexOf("{", start); |
| 44 | + if (open < 0) throw new Error(`EmptyState.css 的规则未闭合:${selector}`); |
| 45 | + let depth = 0; |
| 46 | + for (let i = open; i < EMPTY_CSS.length; i += 1) { |
| 47 | + if (EMPTY_CSS[i] === "{") depth += 1; |
| 48 | + else if (EMPTY_CSS[i] === "}") { |
| 49 | + depth -= 1; |
| 50 | + if (depth === 0) return EMPTY_CSS.slice(start, i + 1); |
| 51 | + } |
| 52 | + } |
| 53 | + throw new Error(`EmptyState.css 的块未闭合:${selector}`); |
| 54 | +} |
| 55 | + |
| 56 | +/** 契约名单:与 `EmptyState.css` 的规则一一对应(改名单必须同时改 CSS 与实现) */ |
| 57 | +const CLASSES: readonly string[] = [ |
| 58 | + "ed-empty", |
| 59 | + "ed-empty--compact", |
| 60 | + "ed-empty__icon", |
| 61 | + "ed-empty__title", |
| 62 | + "ed-empty__description", |
| 63 | + "ed-empty__secondary", |
| 64 | + "ed-empty-enter", |
| 65 | +]; |
| 66 | + |
| 67 | +const root = (container: HTMLElement): HTMLElement => { |
| 68 | + const el = container.firstElementChild; |
| 69 | + if (!el) throw new Error("EmptyState 没有渲染出任何元素"); |
| 70 | + return el as HTMLElement; |
| 71 | +}; |
| 72 | +const classesOf = (container: HTMLElement): string[] => root(container).className.split(/\s+/); |
| 73 | +const buttonOf = (container: HTMLElement): HTMLButtonElement | null => container.querySelector("button"); |
| 74 | + |
| 75 | +describe("EmptyState 默认契约(无 action / icon / description)", () => { |
| 76 | + it("默认 = div + `ed-empty ed-empty-enter`,类名与顺序逐字固定", () => { |
| 77 | + const { container } = render(<EmptyState title="还没有笔记" />); |
| 78 | + expect(root(container).tagName).toBe("DIV"); |
| 79 | + expect(root(container).className).toBe("ed-empty ed-empty-enter"); |
| 80 | + expect(root(container).textContent).toBe("还没有笔记"); |
| 81 | + }); |
| 82 | + |
| 83 | + it("标题落在 `__title` 槽且走 `Text`(墨度与字阶由 Text 的类承载,空态自己不写排版)", () => { |
| 84 | + const el = root(render(<EmptyState title="还没有笔记" />).container).querySelector(".ed-empty__title"); |
| 85 | + expect(el).not.toBeNull(); |
| 86 | + expect(el?.className).toBe("ed-text ed-text--s3 ed-text--ink-1 ed-text--font-ui ed-empty__title"); |
| 87 | + expect(el?.tagName).toBe("P"); |
| 88 | + }); |
| 89 | + |
| 90 | + it("可选槽位缺省时**不产生**空壳(无 icon/description/secondary 元素,也不留空 div)", () => { |
| 91 | + const { container } = render(<EmptyState title="空空如也" />); |
| 92 | + expect(container.querySelector(".ed-empty__icon")).toBeNull(); |
| 93 | + expect(container.querySelector(".ed-empty__description")).toBeNull(); |
| 94 | + expect(container.querySelector(".ed-empty__secondary")).toBeNull(); |
| 95 | + expect(root(container).children).toHaveLength(1); |
| 96 | + }); |
| 97 | + |
| 98 | + it("无 action 时不渲染任何 button(现状 28 文件的形态:只有灰字、没有出路)", () => { |
| 99 | + const { container } = render(<EmptyState title="暂无会话" description="去「课堂助手」开始实时捕获" />); |
| 100 | + expect(buttonOf(container)).toBeNull(); |
| 101 | + }); |
| 102 | +}); |
| 103 | + |
| 104 | +describe("EmptyState 描述走 Text(弱化墨度,ReactNode 原样渲染)", () => { |
| 105 | + it("description 落在 `__description` 槽:第 4 档字阶 + ink-3 墨度", () => { |
| 106 | + const el = root(render(<EmptyState title="t" description="d" />).container).querySelector( |
| 107 | + ".ed-empty__description", |
| 108 | + ); |
| 109 | + expect(el?.className).toBe("ed-text ed-text--s4 ed-text--ink-3 ed-text--font-ui ed-empty__description"); |
| 110 | + expect(el?.tagName).toBe("P"); |
| 111 | + }); |
| 112 | + |
| 113 | + it("description 是 ReactNode:嵌套元素不改写(引导语里的强调与快捷键提示都要能进来)", () => { |
| 114 | + const { container } = render( |
| 115 | + <EmptyState title="还没有对话" description={<>点 <strong>+</strong> 开始</>} />, |
| 116 | + ); |
| 117 | + expect(container.querySelector(".ed-empty__description")?.textContent).toBe("点 + 开始"); |
| 118 | + }); |
| 119 | +}); |
| 120 | + |
| 121 | +describe("EmptyState 图标走图标层(不内联 svg)", () => { |
| 122 | + it("icon 渲染出图标层的 svg 元素:装饰性 aria-hidden + 尺寸 24 + `__icon` 槽", () => { |
| 123 | + const svg = root(render(<EmptyState title="t" icon="goals" />).container).querySelector("svg"); |
| 124 | + expect(svg).not.toBeNull(); |
| 125 | + expect(svg?.getAttribute("aria-hidden")).toBe("true"); |
| 126 | + expect(svg?.getAttribute("role")).toBeNull(); |
| 127 | + expect(svg?.getAttribute("width")).toBe("24"); |
| 128 | + expect(svg?.getAttribute("height")).toBe("24"); |
| 129 | + expect(svg?.getAttribute("class")).toBe("ed-empty__icon"); |
| 130 | + expect(svg?.querySelectorAll("path, circle, rect").length).toBeGreaterThan(0); |
| 131 | + }); |
| 132 | + |
| 133 | + it("实现从图标层 import(`../icons`),且源码里没有内联 svg 字面量", () => { |
| 134 | + expect(EMPTY_TSX).toContain('from "../icons"'); |
| 135 | + expect(EMPTY_TSX.includes("<" + "svg")).toBe(false); |
| 136 | + }); |
| 137 | + |
| 138 | + it("不 import 本层 barrel `./index`(组内互引用走相对文件路径,避免循环依赖)", () => { |
| 139 | + expect(EMPTY_TSX).not.toContain('from "./index"'); |
| 140 | + expect(EMPTY_TSX).toContain('from "./Text"'); |
| 141 | + expect(EMPTY_TSX).toContain('from "./Button"'); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe("EmptyState 主行动按钮(规格 §5.1:首启路径的落点)", () => { |
| 146 | + const ACTION: EmptyStateAction = { label: "新建第一篇笔记", onClick: () => undefined }; |
| 147 | + |
| 148 | + it("render 出**真实** `<button type=\"button\">`,且走 Button 的类(四态自动继承)", () => { |
| 149 | + const { container } = render(<EmptyState title="还没有笔记" action={ACTION} />); |
| 150 | + const btn = buttonOf(container); |
| 151 | + expect(btn?.tagName).toBe("BUTTON"); |
| 152 | + expect(btn?.getAttribute("type")).toBe("button"); |
| 153 | + expect(btn?.textContent).toBe("新建第一篇笔记"); |
| 154 | + expect(btn?.className.split(/\s+/)).toEqual( |
| 155 | + expect.arrayContaining(["ed-btn", "ed-btn--primary", "ed-btn--md"]), |
| 156 | + ); |
| 157 | + }); |
| 158 | + |
| 159 | + it("点击调用 action.onClick(恰好一次)", () => { |
| 160 | + const onClick = vi.fn(); |
| 161 | + const { container } = render(<EmptyState title="t" action={{ label: "开始", onClick }} />); |
| 162 | + fireEvent.click(buttonOf(container) as HTMLButtonElement); |
| 163 | + expect(onClick).toHaveBeenCalledTimes(1); |
| 164 | + }); |
| 165 | + |
| 166 | + it("variant 透传给 Button(工具型空态可退到 ghost);缺省是 primary(空态的唯一出路)", () => { |
| 167 | + const ghost = buttonOf( |
| 168 | + render(<EmptyState title="t" action={{ ...ACTION, variant: "ghost" }} />).container, |
| 169 | + ) as HTMLButtonElement; |
| 170 | + expect(ghost.className).toContain("ed-btn--ghost"); |
| 171 | + expect(ghost.className).not.toContain("ed-btn--primary"); |
| 172 | + expect(buttonOf(render(<EmptyState title="t" action={ACTION} />).container)?.className).toContain( |
| 173 | + "ed-btn--primary", |
| 174 | + ); |
| 175 | + }); |
| 176 | + |
| 177 | + it("disabled 透传:原生 disabled 属性 + 点击不派发 onClick(不可逆动作进行中的空态)", () => { |
| 178 | + const onClick = vi.fn(); |
| 179 | + const btn = buttonOf( |
| 180 | + render(<EmptyState title="t" action={{ label: "开始", onClick, disabled: true }} />).container, |
| 181 | + ) as HTMLButtonElement; |
| 182 | + expect(btn.hasAttribute("disabled")).toBe(true); |
| 183 | + fireEvent.click(btn); |
| 184 | + expect(onClick).not.toHaveBeenCalled(); |
| 185 | + }); |
| 186 | +}); |
| 187 | + |
| 188 | +describe("EmptyState compact 与透传", () => { |
| 189 | + it("compact 缺省 false;开启后类集合精确为 `ed-empty ed-empty--compact ed-empty-enter`", () => { |
| 190 | + expect(classesOf(render(<EmptyState title="t" />).container)).not.toContain("ed-empty--compact"); |
| 191 | + const { container } = render(<EmptyState title="t" compact />); |
| 192 | + expect(root(container).className).toBe("ed-empty ed-empty--compact ed-empty-enter"); |
| 193 | + }); |
| 194 | + |
| 195 | + it("secondary ReactNode 落在 `__secondary` 槽(次要行动/说明与主行动按钮分开)", () => { |
| 196 | + const { container } = render(<EmptyState title="t" secondary={<span>按 ⌘K 搜索</span>} />); |
| 197 | + const slot = container.querySelector(".ed-empty__secondary"); |
| 198 | + expect(slot?.textContent).toBe("按 ⌘K 搜索"); |
| 199 | + }); |
| 200 | + |
| 201 | + it("testId 落到 data-testid;不传时不产生该属性(不留 `data-testid=\"undefined\"`)", () => { |
| 202 | + expect(root(render(<EmptyState title="t" testId="empty-notes" />).container).getAttribute("data-testid")).toBe( |
| 203 | + "empty-notes", |
| 204 | + ); |
| 205 | + expect(root(render(<EmptyState title="t" />).container).hasAttribute("data-testid")).toBe(false); |
| 206 | + }); |
| 207 | + |
| 208 | + it("结构不变量:三段槽位各至多一个(不产生两档叠加态)", () => { |
| 209 | + const { container } = render( |
| 210 | + <EmptyState title="t" description="d" icon="notes" action={{ label: "a", onClick: () => undefined }} />, |
| 211 | + ); |
| 212 | + for (const cls of ["ed-empty__icon", "ed-empty__title", "ed-empty__description"]) { |
| 213 | + expect(container.querySelectorAll(`.${cls}`), cls).toHaveLength(1); |
| 214 | + } |
| 215 | + }); |
| 216 | +}); |
| 217 | + |
| 218 | +describe("EmptyState 反例守门(批 6 要一处改对所有地方)", () => { |
| 219 | + it("视觉**只走类**:根元素与按钮都不落内联 style", () => { |
| 220 | + const { container } = render( |
| 221 | + <EmptyState title="t" description="d" icon="notes" compact action={{ label: "a", onClick: () => undefined }} />, |
| 222 | + ); |
| 223 | + expect(root(container).getAttribute("style")).toBeNull(); |
| 224 | + expect(buttonOf(container)?.getAttribute("style")).toBeNull(); |
| 225 | + }); |
| 226 | + |
| 227 | + it("渲染结果不含任何颜色字面量(墨度全部来自 --ed-* 变量)", () => { |
| 228 | + const { container } = render(<EmptyState title="t" icon="goals" action={{ label: "a", onClick: () => undefined }} />); |
| 229 | + expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}/); |
| 230 | + expect(container.innerHTML).not.toMatch(/\b(?:rgb|hsl)a?\(/); |
| 231 | + }); |
| 232 | + |
| 233 | + it("CSS 零颜色字面量、无 z-index、不写媒体查询(reduced-motion 只由 motion.css 的唯一块覆盖)", () => { |
| 234 | + expect(EMPTY_CSS).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|hsl)a?\(/); |
| 235 | + expect(EMPTY_CSS).not.toMatch(/z-index/); |
| 236 | + expect(EMPTY_CSS).not.toContain("@media"); |
| 237 | + expect(MOTION_CSS.slice(MOTION_CSS.indexOf("@media (prefers-reduced-motion"))).toContain(".ed-empty"); |
| 238 | + }); |
| 239 | + |
| 240 | + it("盒模型口径由基类自带且只声明一次(本仓无全局 CSS reset,空态不声明就没人声明)", () => { |
| 241 | + expect(block(".ed-empty {")).toContain("box-sizing: border-box"); |
| 242 | + expect(EMPTY_CSS.match(/box-sizing\s*:/g)).toHaveLength(1); |
| 243 | + }); |
| 244 | +}); |
| 245 | + |
| 246 | +describe("EmptyState 入场接缝(批 6 换编排层动效的唯一挂点)", () => { |
| 247 | + it("`.ed-empty-enter` 提供一次性的透明度入场,时长取 `--ed-dur-micro`(不新造时长)", () => { |
| 248 | + const enter = block(".ed-empty-enter {"); |
| 249 | + expect(enter).toContain("animation:"); |
| 250 | + expect(enter).toContain("ed-empty-in"); |
| 251 | + expect(enter).toContain("var(--ed-dur-micro, 120ms)"); |
| 252 | + // `both`:批 6 给编排层加 stagger 延迟时,元素在延迟期仍是「先隐后现」而不是闪一下 |
| 253 | + expect(enter).toContain("both"); |
| 254 | + }); |
| 255 | + |
| 256 | + it("`@keyframes ed-empty-in` 只做透明度 0 → 1(不做位移,环境层不抢注意力)", () => { |
| 257 | + const kf = block("@keyframes ed-empty-in"); |
| 258 | + expect(kf).toMatch(/from\s*\{\s*opacity:\s*0;\s*\}/); |
| 259 | + expect(kf).toMatch(/to\s*\{\s*opacity:\s*1;\s*\}/); |
| 260 | + expect(EMPTY_CSS).not.toMatch(/translate/); |
| 261 | + }); |
| 262 | + |
| 263 | + it("空态自身**不做循环动画**(§8.6.1 第 1 条:环境层永远不抢注意力;循环留给 Loading/Probe)", () => { |
| 264 | + expect(EMPTY_CSS).not.toContain("infinite"); |
| 265 | + }); |
| 266 | + |
| 267 | + it("本文件不定义任何 `--ed-*` 变量(时长/缓动的真源在 motion.css,批 6 一整块接管)", () => { |
| 268 | + expect(EMPTY_CSS.match(/--ed-[a-z0-9-]+\s*:/g)).toBeNull(); |
| 269 | + }); |
| 270 | + |
| 271 | + it("实现产出的每个类在 CSS 里都有规则(类名拼错 = 静默无样式,编译期全绿)", () => { |
| 272 | + for (const cls of CLASSES) { |
| 273 | + expect(EMPTY_CSS, `EmptyState.css 缺少 .${cls} 的规则`).toContain(`.${cls} {`); |
| 274 | + } |
| 275 | + }); |
| 276 | +}); |
0 commit comments