|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Surface.test.tsx — L1 原语 `Surface` 的接口契约(批 0-D Task 4)。 |
| 4 | + * |
| 5 | + * Why:`Surface` 是「面(底 / 边框 / 圆角 / 阴影)」的**唯一出口**,它产出的类名 |
| 6 | + * (`.ed-surface--raised` / `.ed-surface--r-panel` / `.ed-surface--interactive` …)是**跨批次 |
| 7 | + * 公共契约** —— 批 4 的迁移与批 6 的动效都按这些类名挂接(hover 升起 / 边框墨度落在 |
| 8 | + * `Surface.css` 的 `.ed-surface--interactive` 一族规则上)。所以本文件钉的是「标签语义 + |
| 9 | + * 类名集合 + 参数不外溢成内联样式」,而不是 HTML 快照(快照会把无意义的 DOM 细节变成契约)。 |
| 10 | + * |
| 11 | + * 副作用:无(纯展示组件,不读 store、不发请求、不写磁盘)。 |
| 12 | + * 边界:① 本仓**未装** `@testing-library/jest-dom` / `user-event`(硬约束:不新增依赖)⇒ |
| 13 | + * 断言一律用原生 DOM API(`tagName` / `getAttribute` / `className` / `style`); |
| 14 | + * ② `:hover` / `:active` / `:focus-visible` 是 CSS 伪类,jsdom **不做样式级联** ⇒ 三条 |
| 15 | + * 交互态只能由 `Surface.css` 的文本守卫(Task 14 的 `style-contract.test.ts`)与人工 |
| 16 | + * 核对承接,本文件守的是「类名契约」这一侧,**不假装验证了视觉**; |
| 17 | + * ③ 本文件走 `./index` 导入面(同 `Text.test.tsx` 先例),因此 `index.ts` 的 |
| 18 | + * `import "./motion.css"` 也会被执行 —— 这也是「导出面可用」的证据。 |
| 19 | + */ |
| 20 | +import { render, within } from "@testing-library/react"; |
| 21 | +import { describe, expect, it } from "vitest"; |
| 22 | +import { Surface } from "./index"; |
| 23 | +import type { SurfaceLevel, SurfaceRadius, SurfaceTag } from "./index"; |
| 24 | + |
| 25 | +/** 契约名单:与 `Surface.css` 的规则一一对应(改名单必须同时改 CSS 与测试) */ |
| 26 | +const LEVELS: readonly SurfaceLevel[] = ["sunken", "canvas", "surface", "raised"]; |
| 27 | +const RADIUS: readonly SurfaceRadius[] = ["stamp", "control", "panel", "overlay"]; |
| 28 | +const TAGS: ReadonlyArray<readonly [SurfaceTag, string]> = [ |
| 29 | + ["div", "DIV"], ["section", "SECTION"], ["article", "ARTICLE"], ["aside", "ASIDE"], ["li", "LI"], |
| 30 | +]; |
| 31 | + |
| 32 | +const root = (container: HTMLElement): HTMLElement => { |
| 33 | + const el = container.firstElementChild; |
| 34 | + if (!el) throw new Error("Surface 没有渲染出任何元素"); |
| 35 | + return el as HTMLElement; |
| 36 | +}; |
| 37 | +const classesOf = (container: HTMLElement): string[] => root(container).className.split(/\s+/); |
| 38 | + |
| 39 | +describe("Surface 默认契约", () => { |
| 40 | + it("默认 = div + surface + r-panel + bordered,类名与顺序逐字固定", () => { |
| 41 | + const { container } = render(<Surface>内容</Surface>); |
| 42 | + expect(root(container).tagName).toBe("DIV"); |
| 43 | + expect(root(container).className).toBe("ed-surface ed-surface--surface ed-surface--r-panel ed-surface--bordered"); |
| 44 | + expect(root(container).textContent).toBe("内容"); |
| 45 | + }); |
| 46 | + |
| 47 | + it("默认不带 interactive / padded(可点与内边距都是逐处显式选择,不是面的默认形态)", () => { |
| 48 | + const cls = classesOf(render(<Surface>x</Surface>).container); |
| 49 | + expect(cls).not.toContain("ed-surface--interactive"); |
| 50 | + expect(cls).not.toContain("ed-surface--padded"); |
| 51 | + }); |
| 52 | + |
| 53 | + it("六轴全开时的类名集合可枚举(顺序固定,便于 grep 与批次迁移)", () => { |
| 54 | + const { container } = render( |
| 55 | + <Surface level="raised" radius="overlay" interactive padded className="slot">x</Surface>, |
| 56 | + ); |
| 57 | + expect(root(container).className).toBe( |
| 58 | + "ed-surface ed-surface--raised ed-surface--r-overlay ed-surface--bordered ed-surface--interactive ed-surface--padded slot", |
| 59 | + ); |
| 60 | + }); |
| 61 | +}); |
| 62 | + |
| 63 | +describe("Surface 四档底 / 四档圆角映射到类", () => { |
| 64 | + it("4 档 level 各有对应类(sunken 输入槽 · canvas 纸 · surface 卡面 · raised 弹层)", () => { |
| 65 | + for (const level of LEVELS) { |
| 66 | + const cls = classesOf(render(<Surface level={level}>x</Surface>).container); |
| 67 | + expect(cls, `档位 ${level}`).toContain(`ed-surface--${level}`); |
| 68 | + } |
| 69 | + }); |
| 70 | + |
| 71 | + it("4 档 radius 各有对应类(3/5/8/10px 由 token 决定,原语只出档名)", () => { |
| 72 | + for (const radius of RADIUS) { |
| 73 | + const cls = classesOf(render(<Surface radius={radius}>x</Surface>).container); |
| 74 | + expect(cls, `圆角 ${radius}`).toContain(`ed-surface--r-${radius}`); |
| 75 | + } |
| 76 | + }); |
| 77 | + |
| 78 | + it("结构不变量:level 类与 radius 类各至多一个(不产生两档叠加态)", () => { |
| 79 | + const cls = classesOf(render(<Surface level="raised" radius="overlay">x</Surface>).container); |
| 80 | + expect(cls.filter((c) => LEVELS.some((l) => c === `ed-surface--${l}`))).toHaveLength(1); |
| 81 | + expect(cls.filter((c) => c.startsWith("ed-surface--r-"))).toHaveLength(1); |
| 82 | + }); |
| 83 | + |
| 84 | + it("反例守门:radius 类必须带 `r-` 前缀(`ed-surface--panel` 是最易漏写的形态,CSS 里没有该规则)", () => { |
| 85 | + const cls = classesOf(render(<Surface radius="panel">x</Surface>).container); |
| 86 | + expect(cls).toContain("ed-surface--r-panel"); |
| 87 | + expect(cls).not.toContain("ed-surface--panel"); |
| 88 | + }); |
| 89 | +}); |
| 90 | + |
| 91 | +describe("Surface 三个布尔开关", () => { |
| 92 | + it("bordered 默认 true;bordered={false} 后类名集合精确收缩(不留空档或多余类)", () => { |
| 93 | + expect(classesOf(render(<Surface>x</Surface>).container)).toContain("ed-surface--bordered"); |
| 94 | + const { container } = render(<Surface bordered={false}>x</Surface>); |
| 95 | + expect(root(container).className).toBe("ed-surface ed-surface--surface ed-surface--r-panel"); |
| 96 | + expect(classesOf(container)).not.toContain("ed-surface--bordered"); |
| 97 | + }); |
| 98 | + |
| 99 | + it("interactive 默认 false;开启后加 `--interactive`(hover 升起 / 边框墨度 / 焦点环的挂点)", () => { |
| 100 | + expect(classesOf(render(<Surface>x</Surface>).container)).not.toContain("ed-surface--interactive"); |
| 101 | + expect(classesOf(render(<Surface interactive>x</Surface>).container)).toContain("ed-surface--interactive"); |
| 102 | + }); |
| 103 | + |
| 104 | + it("padded 默认 false;开启后加 `--padded`(内边距走 `--ed-space-12`)", () => { |
| 105 | + expect(classesOf(render(<Surface padded>x</Surface>).container)).toContain("ed-surface--padded"); |
| 106 | + }); |
| 107 | + |
| 108 | + it("interactive 只加视觉:不设 tabIndex / role(键盘可达性由消费方用真实 button/a 承载,§8.6.1 第 4 条)", () => { |
| 109 | + const el = root(render(<Surface interactive>x</Surface>).container); |
| 110 | + expect(el.hasAttribute("tabindex")).toBe(false); |
| 111 | + expect(el.getAttribute("role")).toBeNull(); |
| 112 | + }); |
| 113 | +}); |
| 114 | + |
| 115 | +describe("Surface 语义与透传", () => { |
| 116 | + it("as 的 5 个取值各自渲染出对应标签,且携带同一组类", () => { |
| 117 | + for (const [tag, tagName] of TAGS) { |
| 118 | + const { container } = render(<Surface as={tag}>x</Surface>); |
| 119 | + expect(root(container).tagName, `as=${tag}`).toBe(tagName); |
| 120 | + expect(classesOf(container), `as=${tag} 的基类`).toContain("ed-surface"); |
| 121 | + expect(classesOf(container), `as=${tag} 的底`).toContain("ed-surface--surface"); |
| 122 | + } |
| 123 | + }); |
| 124 | + |
| 125 | + it("testId 落到 data-testid;不传时不产生该属性(不留 `data-testid=\"undefined\"`)", () => { |
| 126 | + expect(root(render(<Surface testId="card">x</Surface>).container).getAttribute("data-testid")).toBe("card"); |
| 127 | + expect(root(render(<Surface>x</Surface>).container).hasAttribute("data-testid")).toBe(false); |
| 128 | + }); |
| 129 | + |
| 130 | + it("children 原样渲染:中文与嵌套元素都不改写", () => { |
| 131 | + const { container } = render( |
| 132 | + <Surface as="section" level="canvas"> |
| 133 | + 阅读面 <strong>嵌套</strong> 元素 |
| 134 | + </Surface>, |
| 135 | + ); |
| 136 | + expect(container.textContent).toBe("阅读面 嵌套 元素"); |
| 137 | + expect(within(container).getByText("嵌套").tagName).toBe("STRONG"); |
| 138 | + }); |
| 139 | + |
| 140 | + it("className 追加在契约类之后;style 原样透传(批 6 的动效按此注入 transform/boxShadow 而无需改原语)", () => { |
| 141 | + const { container } = render( |
| 142 | + <Surface className="my-slot" style={{ boxShadow: "var(--ed-shadow-2)" }}>x</Surface>, |
| 143 | + ); |
| 144 | + expect(classesOf(container)).toContain("my-slot"); |
| 145 | + expect(classesOf(container)).toContain("ed-surface"); |
| 146 | + expect(root(container).style.boxShadow).toBe("var(--ed-shadow-2)"); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe("Surface 反例守门(批 6 要一处改对所有地方)", () => { |
| 151 | + it("底 / 边框 / 圆角 / 阴影 / 内边距 / 位移**只走类**,绝不落内联 style", () => { |
| 152 | + const el = root( |
| 153 | + render(<Surface level="raised" radius="overlay" interactive padded>x</Surface>).container, |
| 154 | + ); |
| 155 | + expect(el.style.background).toBe(""); |
| 156 | + expect(el.style.borderRadius).toBe(""); |
| 157 | + expect(el.style.boxShadow).toBe(""); |
| 158 | + expect(el.style.padding).toBe(""); |
| 159 | + expect(el.style.transform).toBe(""); |
| 160 | + expect(el.style.cursor).toBe(""); |
| 161 | + }); |
| 162 | + |
| 163 | + it("渲染结果不含任何颜色字面量(底与边框全部来自 --ed-* 变量)", () => { |
| 164 | + const { container } = render(<Surface level="raised" bordered>x</Surface>); |
| 165 | + expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}/); |
| 166 | + expect(container.innerHTML).not.toMatch(/\b(?:rgb|hsl)a?\(/); |
| 167 | + }); |
| 168 | +}); |
0 commit comments