|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Text.test.tsx — L1 原语 `Text` 的接口契约(批 0-D Task 3)。 |
| 4 | + * |
| 5 | + * Why:`Text` 是「墨度 × 字阶」的**唯一出口**,它产出的类名(`.ed-text--s4` / `.ed-text--ink-2` …) |
| 6 | + * 是**跨批次的公共契约** —— 批 4 的迁移与批 6 的动效都按这些类名挂接。所以本文件钉的是 |
| 7 | + * 「标签语义 + 类名集合」,而不是 HTML 快照(快照会把无意义的 DOM 细节也变成契约)。 |
| 8 | + * 类名与 CSS 的一致性由同目录 `style-seams.test.ts` 从另一侧守住(契约名单 ⊆ Text.css)。 |
| 9 | + * |
| 10 | + * 副作用:无(纯展示组件,不读 store、不发请求、不写磁盘)。 |
| 11 | + * 边界:本仓**未装** `@testing-library/jest-dom` / `user-event`(硬约束:不新增依赖)⇒ |
| 12 | + * 断言一律用原生 DOM API(`tagName` / `getAttribute` / `style`),交互用 `fireEvent`。 |
| 13 | + * 另一处边界:本文件走 `./index` 导入面(同 `ui/icons/Icon.test.tsx` 先例),因此 |
| 14 | + * `index.ts` 的 `import "./motion.css"` 也会被执行 —— 这也是「导出面可用」的证据。 |
| 15 | + */ |
| 16 | +import { render, within } from "@testing-library/react"; |
| 17 | +import { describe, expect, it } from "vitest"; |
| 18 | +import { Text } from "./index"; |
| 19 | +import type { TextFont, TextSize, TextTag, TextTone } from "./index"; |
| 20 | + |
| 21 | +/** 契约名单:与 `Text.css` 的规则一一对应(由 style-seams.test.ts 从 CSS 侧复核) */ |
| 22 | +const SIZES: readonly TextSize[] = [1, 2, 3, 4, 5, 6]; |
| 23 | +const TONES: readonly TextTone[] = [ |
| 24 | + "ink-1", "ink-2", "ink-3", "ink-4", "stamp", "ok", "due", "link", "inherit", |
| 25 | +]; |
| 26 | +const FONTS: readonly TextFont[] = ["ui", "body", "mono"]; |
| 27 | +const TAGS: ReadonlyArray<readonly [TextTag, string]> = [ |
| 28 | + ["span", "SPAN"], ["p", "P"], ["div", "DIV"], ["label", "LABEL"], ["strong", "STRONG"], |
| 29 | + ["em", "EM"], ["h1", "H1"], ["h2", "H2"], ["h3", "H3"], |
| 30 | +]; |
| 31 | + |
| 32 | +const root = (container: HTMLElement): HTMLElement => { |
| 33 | + const el = container.firstElementChild; |
| 34 | + if (!el) throw new Error("Text 没有渲染出任何元素"); |
| 35 | + return el as HTMLElement; |
| 36 | +}; |
| 37 | +const classesOf = (container: HTMLElement): string[] => root(container).className.split(/\s+/); |
| 38 | + |
| 39 | +describe("Text 默认契约", () => { |
| 40 | + it("默认 = span + s4 + ink-2 + font-ui,类名与顺序逐字固定", () => { |
| 41 | + const { container } = render(<Text>正文</Text>); |
| 42 | + expect(root(container).tagName).toBe("SPAN"); |
| 43 | + expect(root(container).className).toBe("ed-text ed-text--s4 ed-text--ink-2 ed-text--font-ui"); |
| 44 | + expect(root(container).textContent).toBe("正文"); |
| 45 | + }); |
| 46 | + |
| 47 | + it("默认不带 truncate(截断是逐处显式选择,不是默认排版)", () => { |
| 48 | + const { container } = render(<Text>正文</Text>); |
| 49 | + expect(classesOf(container)).not.toContain("ed-text--truncate"); |
| 50 | + }); |
| 51 | +}); |
| 52 | + |
| 53 | +describe("Text 字阶 / 墨度 / 字族三轴映射到类", () => { |
| 54 | + it("6 档字阶各有对应类", () => { |
| 55 | + for (const size of SIZES) { |
| 56 | + const { container } = render(<Text size={size}>x</Text>); |
| 57 | + expect(classesOf(container), `第 ${size} 档`).toContain(`ed-text--s${size}`); |
| 58 | + } |
| 59 | + }); |
| 60 | + |
| 61 | + it("9 档墨度各有对应类(含 inherit —— 供继承父级墨度的内联片段)", () => { |
| 62 | + for (const tone of TONES) { |
| 63 | + const { container } = render(<Text tone={tone}>x</Text>); |
| 64 | + expect(classesOf(container), `墨度 ${tone}`).toContain(`ed-text--${tone}`); |
| 65 | + } |
| 66 | + }); |
| 67 | + |
| 68 | + it("3 档字族各有对应类(`mono` = 数字对齐,非等宽装饰)", () => { |
| 69 | + for (const font of FONTS) { |
| 70 | + const { container } = render(<Text font={font}>x</Text>); |
| 71 | + expect(classesOf(container), `字族 ${font}`).toContain(`ed-text--font-${font}`); |
| 72 | + } |
| 73 | + }); |
| 74 | + |
| 75 | + it("三轴组合出的类名集合可枚举(顺序固定,便于 grep 与批次迁移)", () => { |
| 76 | + const { container } = render( |
| 77 | + <Text size={1} tone="stamp" font="mono" truncate className="slot">x</Text>, |
| 78 | + ); |
| 79 | + expect(root(container).className).toBe( |
| 80 | + "ed-text ed-text--s1 ed-text--stamp ed-text--font-mono ed-text--truncate slot", |
| 81 | + ); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
| 85 | +describe("Text 语义与透传", () => { |
| 86 | + it("as 的 9 个取值各自渲染出对应标签,且携带同一组类", () => { |
| 87 | + for (const [tag, tagName] of TAGS) { |
| 88 | + const { container } = render(<Text as={tag}>x</Text>); |
| 89 | + expect(root(container).tagName, `as=${tag}`).toBe(tagName); |
| 90 | + expect(classesOf(container), `as=${tag} 的类`).toContain("ed-text"); |
| 91 | + expect(classesOf(container), `as=${tag} 的字阶`).toContain("ed-text--s4"); |
| 92 | + } |
| 93 | + }); |
| 94 | + |
| 95 | + it("children 原样渲染:中文与嵌套元素都不改写", () => { |
| 96 | + const { container } = render( |
| 97 | + <Text as="p" tone="ink-3"> |
| 98 | + 中文与 <strong>嵌套</strong> 元素 |
| 99 | + </Text>, |
| 100 | + ); |
| 101 | + expect(container.textContent).toBe("中文与 嵌套 元素"); |
| 102 | + expect(within(container).getByText("嵌套").tagName).toBe("STRONG"); |
| 103 | + }); |
| 104 | + |
| 105 | + it("className 追加在契约类之后(调用点只做定位,不参与排版权威)", () => { |
| 106 | + const { container } = render(<Text className="my-slot">x</Text>); |
| 107 | + expect(classesOf(container)).toContain("my-slot"); |
| 108 | + expect(classesOf(container)).toContain("ed-text"); |
| 109 | + }); |
| 110 | + |
| 111 | + it("style 原样透传(批 6 的「记忆浮现」按此注入 letterSpacing / color 而无需改原语)", () => { |
| 112 | + const { container } = render(<Text style={{ letterSpacing: "0.02em" }}>x</Text>); |
| 113 | + expect(root(container).style.letterSpacing).toBe("0.02em"); |
| 114 | + }); |
| 115 | + |
| 116 | + it("排版与墨度**只走类**,绝不落内联 style(否则批 6 无法一处改对所有地方)", () => { |
| 117 | + const { container } = render(<Text size={1} tone="stamp">x</Text>); |
| 118 | + const el = root(container); |
| 119 | + expect(el.style.color).toBe(""); |
| 120 | + expect(el.style.fontSize).toBe(""); |
| 121 | + expect(el.style.fontWeight).toBe(""); |
| 122 | + expect(el.style.lineHeight).toBe(""); |
| 123 | + }); |
| 124 | + |
| 125 | + it("渲染结果不含任何颜色字面量(墨度全部来自 --ed-ink-* 变量)", () => { |
| 126 | + const { container } = render(<Text tone="ink-1">x</Text>); |
| 127 | + expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}/); |
| 128 | + expect(container.innerHTML).not.toMatch(/\b(?:rgb|hsl)a?\(/); |
| 129 | + }); |
| 130 | +}); |
0 commit comments