|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Loading.test.tsx — L1 原语 `Loading` / `Skeleton` / `Probe` 的接口契约(批 0-D Task 11)。 |
| 4 | + * |
| 5 | + * Why:三形态的价值全在**可判定的语义边界**(形状已知 → `Skeleton` · 要文字 → `Loading` · |
| 6 | + * 无文字的最小单点 → `Probe`)。批 4 要把 83 行/31 文件的「加载中…」按这张表迁移,边界一旦漂移, |
| 7 | + * 三个词就会退化成今天那种逐处手写的灰字。故本文件钉的是「谁渲染什么 + 类名 + 无障碍角色」。 |
| 8 | + * |
| 9 | + * ② 后半段(`describe("Loading.css …")`)是 **CSS 文本契约**,不是样式复述:本任务是全仓第一批 |
| 10 | + * `@keyframes` 的产地(recon §8.1 实测改造前 0 个),而**周期与 `infinite` 在 jsdom 里无从观测** |
| 11 | + * (jsdom 既不跑动画也不解析样式表)⇒ 只能直接读 `Loading.css` 文本断言(计划 Step 1 明写 |
| 12 | + * 「node 与 jsdom 都能跑」)。该段只读盘、不依赖 DOM。 |
| 13 | + * |
| 14 | + * 副作用:无(纯展示组件;不读 store、不发请求、不写磁盘)。 |
| 15 | + * 边界:本仓未装 `@testing-library/jest-dom` / `user-event`(硬约束:不新增依赖)⇒ 断言一律用 |
| 16 | + * 原生 DOM API(`getAttribute` / `style` / `querySelector`);`vitest.config.ts` 未开 `globals` |
| 17 | + * ⇒ RTL 的自动清理不生效,必须显式 `cleanup()`(先例 `ConfirmDialog.test.tsx:55`)。本文件从 |
| 18 | + * `./index` 导入(同 `Text.test.tsx` 先例)⇒ 连 `index.ts` 的 `import "./motion.css"` 一并执行。 |
| 19 | + */ |
| 20 | +import { readFileSync } from "node:fs"; |
| 21 | +import { dirname, join } from "node:path"; |
| 22 | +import { fileURLToPath } from "node:url"; |
| 23 | +import { cleanup, render, screen } from "@testing-library/react"; |
| 24 | +import { afterEach, describe, expect, it } from "vitest"; |
| 25 | +import { Loading, Probe, Skeleton } from "./index"; |
| 26 | + |
| 27 | +afterEach(() => cleanup()); |
| 28 | + |
| 29 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 30 | +/** 归一 EOL 后读文本(本仓无 `.gitattributes` 且 `core.autocrlf=true` ⇒ 禁止逐字节换行断言) */ |
| 31 | +const readText = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n"); |
| 32 | +/** 判据前剥注释(注释里提到 `@media` / `translateX` 这类反例不该让守卫误报 —— 同 `style-seams.test.ts`) */ |
| 33 | +const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, ""); |
| 34 | +/** 取一条规则的规则体(不含选择器);规则不存在时返回 null(让断言以「缺规则」而不是空串失败) */ |
| 35 | +const ruleBody = (css: string, selector: string): string | null => { |
| 36 | + const at = css.indexOf(`${selector} {`); |
| 37 | + return at < 0 ? null : css.slice(at, css.indexOf("}", at)); |
| 38 | +}; |
| 39 | +const classesOf = (el: Element | null): string[] => |
| 40 | + (el?.getAttribute("class") ?? "").split(/\s+/).filter(Boolean); |
| 41 | +const textOf = (el: Element): string[] => |
| 42 | + Array.from(el.querySelectorAll(".ed-text")).map((n) => (n.getAttribute("class") ?? "")); |
| 43 | + |
| 44 | +const CSS = stripComments(readText("Loading.css")); |
| 45 | +const MOTION_CSS = stripComments(readText("motion.css")); |
| 46 | + |
| 47 | +describe("Loading —— 不知道形状、但需要一句文字说明在等什么(判定顺序第 2 问)", () => { |
| 48 | + it("默认渲染可读文案「加载中…」并带 role=status(内容出现即被读到)", () => { |
| 49 | + const { container } = render(<Loading />); |
| 50 | + expect(container.textContent).toBe("加载中…"); |
| 51 | + const root = screen.getByRole("status"); |
| 52 | + expect(root.tagName).toBe("SPAN"); |
| 53 | + expect(root.textContent).toBe("加载中…"); |
| 54 | + }); |
| 55 | + |
| 56 | + it("默认不是 inline 用法(独立占位是默认档,嵌进按钮/行内才显式声明)", () => { |
| 57 | + render(<Loading />); |
| 58 | + expect(classesOf(screen.getByRole("status"))).toEqual(["ed-loading"]); |
| 59 | + }); |
| 60 | + |
| 61 | + it("inline 追加 ed-loading--inline(该档让标签跟随宿主字阶,见 CSS 规则)", () => { |
| 62 | + render(<Loading inline />); |
| 63 | + expect(classesOf(screen.getByRole("status"))).toEqual(["ed-loading", "ed-loading--inline"]); |
| 64 | + }); |
| 65 | + |
| 66 | + it("标签排版交给 `Text`(本原语不写字号/颜色):ink-3 + s4 档", () => { |
| 67 | + render(<Loading />); |
| 68 | + expect(textOf(screen.getByRole("status"))).toEqual(["ed-text ed-text--s4 ed-text--ink-3 ed-text--font-ui"]); |
| 69 | + }); |
| 70 | + |
| 71 | + it("label 是 ReactNode 槽 —— 多行说明(模型下载进度等)可直接传入而不必改原语", () => { |
| 72 | + render( |
| 73 | + <Loading |
| 74 | + label={ |
| 75 | + <> |
| 76 | + <span>⏳ 正在下载模型(~650MB)…</span> |
| 77 | + <span>encoder.onnx:12MB / 650MB</span> |
| 78 | + </> |
| 79 | + } |
| 80 | + />, |
| 81 | + ); |
| 82 | + const root = screen.getByRole("status"); |
| 83 | + expect(root.textContent).toContain("正在下载模型"); |
| 84 | + expect(root.textContent).toContain("encoder.onnx:12MB / 650MB"); |
| 85 | + expect(root.querySelectorAll("span")).toHaveLength(4); // 探针 + Text 外壳 + 两行说明 |
| 86 | + }); |
| 87 | + |
| 88 | + it("testId 落到根元素(批 4 的迁移靶子原样保留 data-testid)", () => { |
| 89 | + render(<Loading testId="group-delete-loading" />); |
| 90 | + expect(screen.getByTestId("group-delete-loading").getAttribute("role")).toBe("status"); |
| 91 | + }); |
| 92 | + |
| 93 | + it("内部复用 Probe,且圆点是装饰:可读语义由标签承载,不由圆点承载", () => { |
| 94 | + const { container } = render(<Loading />); |
| 95 | + const probe = container.querySelector(".ed-probe"); |
| 96 | + expect(probe).not.toBeNull(); |
| 97 | + expect(probe?.getAttribute("aria-hidden")).toBe("true"); |
| 98 | + }); |
| 99 | +}); |
| 100 | + |
| 101 | +describe("Skeleton —— 知道要出现什么形状(判定顺序第 1 问)", () => { |
| 102 | + it("默认 1 条 · 高 12px · 满宽(12px 与正文行高同量级 = 一行文字的形状)", () => { |
| 103 | + render(<Skeleton testId="sk" />); |
| 104 | + const bars = screen.getAllByTestId("sk-line"); |
| 105 | + expect(bars).toHaveLength(1); |
| 106 | + expect(bars[0].getAttribute("class")).toBe("ed-skeleton"); |
| 107 | + expect(bars[0].style.height).toBe("12px"); |
| 108 | + expect(bars[0].style.width).toBe("100%"); |
| 109 | + }); |
| 110 | + |
| 111 | + it("lines={3} 渲染 3 条骨架条(GoalDetail 的「加载中…」一行灰字 → 3 行骨架)", () => { |
| 112 | + render(<Skeleton lines={3} testId="sk" />); |
| 113 | + expect(screen.getAllByTestId("sk-line")).toHaveLength(3); |
| 114 | + }); |
| 115 | + |
| 116 | + it("height / width 落到行内 style(number → px;string 原样,供百分比与 calc 用)", () => { |
| 117 | + render(<Skeleton height={20} width="60%" testId="sk" />); |
| 118 | + const bar = screen.getByTestId("sk-line"); |
| 119 | + expect(bar.style.height).toBe("20px"); |
| 120 | + expect(bar.style.width).toBe("60%"); |
| 121 | + }); |
| 122 | + |
| 123 | + it("边界:lines 为 0 / 负数 / 非数时夹到 1 条(夹不住的后果是**空白区** —— 看起来像加载完了)", () => { |
| 124 | + for (const bad of [0, -3, Number.NaN]) { |
| 125 | + const { unmount } = render(<Skeleton lines={bad} testId="sk" />); |
| 126 | + expect(screen.getAllByTestId("sk-line"), `lines=${String(bad)}`).toHaveLength(1); |
| 127 | + unmount(); |
| 128 | + } |
| 129 | + }); |
| 130 | + |
| 131 | + it("边界:小数 lines 向下取整(2.9 → 2 条,不产生半条)", () => { |
| 132 | + render(<Skeleton lines={2.9} testId="sk" />); |
| 133 | + expect(screen.getAllByTestId("sk-line")).toHaveLength(2); |
| 134 | + }); |
| 135 | + |
| 136 | + it("装饰性:整组 aria-hidden(形状是纯视觉信息,可读语义由调用点的 role=status 提供)", () => { |
| 137 | + render(<Skeleton lines={2} testId="sk" />); |
| 138 | + expect(screen.getByTestId("sk").getAttribute("aria-hidden")).toBe("true"); |
| 139 | + }); |
| 140 | +}); |
| 141 | + |
| 142 | +describe("Probe —— 不需要文字的最小单元(判定顺序第 3 问)", () => { |
| 143 | + it("渲染单个 .ed-probe,且是装饰(aria-hidden)", () => { |
| 144 | + const { container } = render(<Probe />); |
| 145 | + const probe = container.firstElementChild; |
| 146 | + expect(classesOf(probe)).toEqual(["ed-probe"]); |
| 147 | + expect(probe?.getAttribute("aria-hidden")).toBe("true"); |
| 148 | + }); |
| 149 | + |
| 150 | + it("label 只作鼠标悬停的原生 title(`aria-hidden` 元素上的可访问名是死属性 ⇒ 不写 aria-label)", () => { |
| 151 | + const { container } = render(<Probe label="加载中" />); |
| 152 | + expect(container.firstElementChild?.getAttribute("title")).toBe("加载中"); |
| 153 | + expect(container.firstElementChild?.getAttribute("aria-label")).toBeNull(); |
| 154 | + }); |
| 155 | + |
| 156 | + it("无 label 时连 title 属性都不渲染(不产生空 tooltip)", () => { |
| 157 | + const { container } = render(<Probe />); |
| 158 | + expect(container.firstElementChild?.hasAttribute("title")).toBe(false); |
| 159 | + }); |
| 160 | + |
| 161 | + it("没有包装层:testId 落在单点本身(嵌进按钮/行内时不多一层 DOM)", () => { |
| 162 | + const { container } = render(<Probe testId="p" />); |
| 163 | + expect(screen.getByTestId("p").getAttribute("class")).toBe("ed-probe"); |
| 164 | + expect(container.childElementCount).toBe(1); |
| 165 | + }); |
| 166 | +}); |
| 167 | + |
| 168 | +describe("Loading.css —— 全仓第一批 @keyframes(周期与 infinite 只能从 CSS 文本钉)", () => { |
| 169 | + it("恰好两个 @keyframes 各定义一次(骨架微光 + 探针摆动;本文件就是全仓产地)", () => { |
| 170 | + expect(CSS.match(/@keyframes ed-skeleton-shimmer/g)).toHaveLength(1); |
| 171 | + expect(CSS.match(/@keyframes ed-probe-swing/g)).toHaveLength(1); |
| 172 | + expect(CSS.match(/@keyframes/g)).toHaveLength(2); |
| 173 | + }); |
| 174 | + |
| 175 | + it("周期钉住:骨架走 --ed-dur-skeleton(兜底 1200ms)· 探针 2.4s(环境层 2–6s,§8.1)", () => { |
| 176 | + expect(CSS).toContain("ed-skeleton-shimmer var(--ed-dur-skeleton, 1200ms)"); |
| 177 | + expect(CSS).toContain("ed-probe-swing 2.4s"); |
| 178 | + }); |
| 179 | + |
| 180 | + it("两条都是 infinite(§8.6.1 第 1 条:环境层不得退为纯背景 —— 一次性动画等于没有生命感)", () => { |
| 181 | + expect(CSS.match(/infinite/g)).toHaveLength(2); |
| 182 | + expect(CSS).toContain("var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)) infinite"); |
| 183 | + }); |
| 184 | + |
| 185 | + it("探针幅度 ±2px(总 4px,不抢注意力;≤8px 上限由 style-seams.test.ts 扫本文件机器判定)", () => { |
| 186 | + expect(CSS.match(/translateX\(-2px\)/g)).toHaveLength(1); |
| 187 | + expect(CSS.match(/translateX\(2px\)/g)).toHaveLength(1); |
| 188 | + }); |
| 189 | + |
| 190 | + it("微光只动 transform(合成层)· 亮带取 60% · 骨架不靠透明度闪烁(「不抢注意力」)", () => { |
| 191 | + expect(CSS).toContain("color-mix(in srgb, var(--ed-bg-surface) 60%, transparent)"); |
| 192 | + const shimmerToSwing = CSS.slice(CSS.indexOf("@keyframes ed-skeleton-shimmer"), CSS.indexOf("@keyframes ed-probe-swing")); |
| 193 | + expect(shimmerToSwing).not.toContain("opacity"); |
| 194 | + expect(CSS.match(/opacity/g)).toHaveLength(2); // 两处都在探针摆动里 |
| 195 | + }); |
| 196 | +}); |
| 197 | + |
| 198 | +describe("reduced-motion:静态由 motion.css 的既有块承担(§8.2「一条媒体查询即静态」)", () => { |
| 199 | + it("本文件不得自带第二条媒体查询(全仓唯一一条 reduced-motion 块在 motion.css)", () => { |
| 200 | + expect(CSS).not.toContain("@media"); |
| 201 | + }); |
| 202 | + |
| 203 | + it("motion.css 的块覆盖本文件三个基类,且 iteration-count: 1 让循环动画停住", () => { |
| 204 | + const at = MOTION_CSS.indexOf("@media (prefers-reduced-motion"); |
| 205 | + expect(at, "motion.css 缺少 reduced-motion 块").toBeGreaterThanOrEqual(0); |
| 206 | + const block = MOTION_CSS.slice(at); |
| 207 | + for (const cls of ["ed-loading", "ed-skeleton", "ed-probe"]) { |
| 208 | + expect(block, `reduced-motion 名单缺 .${cls}`).toContain(`.${cls}`); |
| 209 | + } |
| 210 | + expect(block).toContain("animation-iteration-count: 1 !important"); |
| 211 | + }); |
| 212 | + |
| 213 | + it("静止态不是空白:两者的可见性写在不依赖动画的基类规则里(停住后仍是灰条与实心圆点)", () => { |
| 214 | + expect(ruleBody(CSS, ".ed-skeleton")).toContain("background: var(--ed-bg-sunken)"); |
| 215 | + expect(ruleBody(CSS, ".ed-probe")).toContain("background: var(--ed-ink-3)"); |
| 216 | + expect(ruleBody(CSS, ".ed-probe")).not.toContain("opacity: 0"); |
| 217 | + }); |
| 218 | +}); |
| 219 | + |
| 220 | +describe("消费纪律:颜色只经 var(--ed-*) · 层级不归本层", () => { |
| 221 | + it("零颜色字面量(色值只在 ui/tokens.css 与生成器里)", () => { |
| 222 | + expect(CSS).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); |
| 223 | + expect(CSS).not.toMatch(/\b(?:rgba?|hsla?)\(/); |
| 224 | + }); |
| 225 | + |
| 226 | + it("不写 z-index(层级是 ui/zIndex.ts 标尺的职责,规格 §4.2①)", () => { |
| 227 | + expect(CSS).not.toMatch(/z-index/); |
| 228 | + }); |
| 229 | +}); |
0 commit comments