|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Toast.placement.test.tsx —— `Toast` 的**位置档**契约(批 4 Task 3;B6 特殊条款)。 |
| 4 | + * |
| 5 | + * Why(这条判据为什么存在):`App.tsx:509` 的 AI toast 需要 `top: "calc(var(--ed-nav-h) + 8px)"`, |
| 6 | + * 而 `--ed-nav-h` 是**壳层 token**(`ui/tokens.css:73`),L1 原语**结构上读不到**它 ⇒ 迁移时唯一 |
| 7 | + * 「改调用点」的走法是行内 `style` 覆盖类语义,而 ADR-033 §4 **逐字禁止**。B6 因此给了特殊条款: |
| 8 | + * 允许改原语,**只许加一个具名、有文档的 prop**(`placement`),且实现**走类**不走行内 `style`。 |
| 9 | + * |
| 10 | + * 五条判据(对计划 Task 3 Step 1 的 ①–⑤ 逐条): |
| 11 | + * ① 默认档(`viewport`)**不产生**修饰类 —— 精确类名断言:默认档多一个类会立刻红 |
| 12 | + * (先例 `EmptyState.test.tsx:79` 的 `toBe` 形态); |
| 13 | + * ② `placement="belowNav"` ⇒ 类名含 `ed-toast--below-nav`; |
| 14 | + * ③ **容器上没有行内 `top`**(两种档位都判)—— 防"用行内 style 绕过类语义"(ADR-033 §4); |
| 15 | + * ④ `Toast.css` 里该修饰类的 `top` **消费 `var(--ed-nav-h…)`**,且 `bottom: auto` 一并落位 |
| 16 | + * —— **读文件判,不是读 DOM**(jsdom 不排版,DOM 侧永远看不到"其实没生效"); |
| 17 | + * ⑤ 三档 `kind` × 两档 `placement` 的**笛卡尔积**类名逐字固定。 |
| 18 | + * |
| 19 | + * 副作用:只挂 React 树 + 只读同目录 `Toast.css`(不写文件、不发请求)。 |
| 20 | + * 边界:本仓未装 jest-dom ⇒ 一律 `getAttribute` / `className` / `style.top`; |
| 21 | + * ④ 判的是**文本**(`var()` 的真值解析在浏览器里,jsdom 不做)—— 故 ④ 额外带一个**反例样本**, |
| 22 | + * 证明该正则抓得到"写死 64px"的回归(否则它只是一条永真的字符串包含)。 |
| 23 | + */ |
| 24 | +import { readFileSync } from "node:fs"; |
| 25 | +import { dirname, join } from "node:path"; |
| 26 | +import { fileURLToPath } from "node:url"; |
| 27 | +import { cleanup, render, screen } from "@testing-library/react"; |
| 28 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 29 | +import { Toast } from "./Toast"; |
| 30 | +import type { ToastKind, ToastPlacement } from "./Toast"; |
| 31 | + |
| 32 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 33 | +const read = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n"); |
| 34 | +const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, ""); |
| 35 | + |
| 36 | +/** 取 `.ed-toast--below-nav` 规则体(`选择器 {` 到第一个 `}`)—— 防「写在别的规则里也算过」 */ |
| 37 | +const CSS = stripComments(read("Toast.css")); |
| 38 | +const BELOW_NAV_BODY = (() => { |
| 39 | + const at = CSS.indexOf(".ed-toast--below-nav"); |
| 40 | + return at < 0 ? "" : CSS.slice(at, CSS.indexOf("}", at)); |
| 41 | +})(); |
| 42 | + |
| 43 | +/** 位置档的 `top` 判据本体(抽成常量:反例样本要用**同一条**正则,否则证明不了它有区分度) */ |
| 44 | +const TOKEN_TOP = /top:\s*calc\(\s*var\(--ed-nav-h/; |
| 45 | + |
| 46 | +const noop = (): void => {}; |
| 47 | + |
| 48 | +function Host({ kind, placement }: { kind?: ToastKind; placement?: ToastPlacement }) { |
| 49 | + return ( |
| 50 | + <Toast open message="已保存" kind={kind} placement={placement} onDismiss={noop} testId="toast" /> |
| 51 | + ); |
| 52 | +} |
| 53 | + |
| 54 | +const toast = (): HTMLElement => screen.getByTestId("toast"); |
| 55 | +const cls = (el: Element): string => el.getAttribute("class") ?? ""; |
| 56 | + |
| 57 | +beforeEach(() => { |
| 58 | + vi.useFakeTimers(); |
| 59 | +}); |
| 60 | + |
| 61 | +afterEach(() => { |
| 62 | + cleanup(); |
| 63 | + vi.useRealTimers(); |
| 64 | +}); |
| 65 | + |
| 66 | +describe("① 默认档不产生修饰类(精确类名断言)", () => { |
| 67 | + it("不传 placement ⇒ `ed-toast ed-toast--info`,且不含 below-nav", () => { |
| 68 | + render(<Host />); |
| 69 | + expect(cls(toast())).toBe("ed-toast ed-toast--info"); |
| 70 | + expect(cls(toast())).not.toContain("below-nav"); |
| 71 | + }); |
| 72 | +}); |
| 73 | + |
| 74 | +describe("② belowNav 档 ⇒ 修饰类", () => { |
| 75 | + it("placement=\"belowNav\" ⇒ 类名含 `ed-toast--below-nav`(基类与 kind 档仍在)", () => { |
| 76 | + render(<Host placement="belowNav" />); |
| 77 | + expect(cls(toast())).toContain("ed-toast--below-nav"); |
| 78 | + expect(cls(toast())).toContain("ed-toast ed-toast--info"); |
| 79 | + }); |
| 80 | +}); |
| 81 | + |
| 82 | +describe("③ 不许用行内 style 绕过(ADR-033 §4)", () => { |
| 83 | + it("两种档位的容器都没有行内 `top`(jsdom 的 style.top 为空串)", () => { |
| 84 | + for (const placement of ["viewport", "belowNav"] as const) { |
| 85 | + render(<Host placement={placement} />); |
| 86 | + expect(toast().style.top, `${placement} 档写了行内 top ⇒ 位置语义脱离类`).toBe(""); |
| 87 | + expect(toast().getAttribute("style") ?? "").not.toContain("top"); |
| 88 | + cleanup(); |
| 89 | + } |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +describe("④ 位置档的 top 消费壳层 token(读文件判;带反例样本)", () => { |
| 94 | + it("`.ed-toast--below-nav` 的 top 取 `var(--ed-nav-h…)`,且 bottom 归 auto(防固定定位被拉伸)", () => { |
| 95 | + expect(BELOW_NAV_BODY, "Toast.css 缺 `.ed-toast--below-nav` 规则").not.toBe(""); |
| 96 | + expect(BELOW_NAV_BODY, "位置档的 top 必须消费壳层 token `--ed-nav-h`,不许写死数值").toMatch(TOKEN_TOP); |
| 97 | + expect( |
| 98 | + BELOW_NAV_BODY, |
| 99 | + "基类有 `bottom` ⇒ 本档不写 `bottom: auto` 时固定定位元素会被上下拉伸(观感全错)", |
| 100 | + ).toContain("bottom: auto"); |
| 101 | + }); |
| 102 | + |
| 103 | + it("判据有区分度(反例守门):写死 `top: 64px` 的样本必须被同一条正则判否", () => { |
| 104 | + const mutated = ".ed-toast--below-nav {\n top: 64px;\n bottom: auto;\n}"; |
| 105 | + expect(mutated).not.toMatch(TOKEN_TOP); |
| 106 | + expect(mutated).toContain("bottom: auto"); // 反例只在 top 上变异 ⇒ 证明 ④ 的两条各自能红 |
| 107 | + }); |
| 108 | +}); |
| 109 | + |
| 110 | +describe("⑤ 三档 kind × 两档 placement 的笛卡尔积", () => { |
| 111 | + it("6 种组合的类名逐字固定(默认档不附加类,belowNav 只在末位多一个)", () => { |
| 112 | + for (const kind of ["info", "ok", "err"] as const) { |
| 113 | + for (const placement of ["viewport", "belowNav"] as const) { |
| 114 | + render(<Host kind={kind} placement={placement} />); |
| 115 | + const expected = ["ed-toast", `ed-toast--${kind}`, placement === "belowNav" ? "ed-toast--below-nav" : ""] |
| 116 | + .filter(Boolean) |
| 117 | + .join(" "); |
| 118 | + expect(cls(toast())).toBe(expected); |
| 119 | + cleanup(); |
| 120 | + } |
| 121 | + } |
| 122 | + }); |
| 123 | +}); |
0 commit comments