|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context `useColumnFlip` 的**行为级判据**(批 6 波 C · T33 · 控制方 2026-09-13 裁决「授权路径 B」)。 |
| 4 | + * |
| 5 | + * Why 必须有本文件(裁决第 3 条逐字:「hook **无测试 = 无判据**」):本 hook 是列折叠 Flip 的**唯一**编排点, |
| 6 | + * 而它的正确性在 jsdom 里**没有几何证据**(R5.7 实测:`elementStates[0].bounds` 全 0、位移恒 |
| 7 | + * `translate3d(0px,0px,0px)`、`Flip.fit` 退化成 `scale(0,0)`)⇒ 只能判「**身份契约 + 调用不抛 + timeline 存在 + |
| 8 | + * **增量**属性集合 ⊆ 合成属性白名单 + 降级 + 三档真源**」这些**弱判据**;**真实几何位移 / 观感 / 帧率本批未测** |
| 9 | + * (逐字登记在 `task-33-report.md` §5/§9)。 |
| 10 | + * |
| 11 | + * 判据与**各自专属**的变异体(变异只在导出副本里做 ⇒ 见报告「变异体实测」节): |
| 12 | + * C1 身份契约(跨两棵子树:引用不等 ∧ 同一 `data-flip-id` ∧ 选择器恰命中 1)—— M1:`ColumnBar.tsx` 摘掉 `data-flip-id` ⇒ 红 |
| 13 | + * C1b/C1c **两个真实落点**(`ChatSidebar` / `GoalsPage`)的两态带同一身份 —— M1 同样打红这两条 |
| 14 | + * C2 调用不抛 + 真 Timeline(计划 V1 的**有牙**形态)—— M2:`engine.ts` 的 `registerPlugin` 摘掉 `Flip` ⇒ 红 |
| 15 | + * (⚠️ 计划原写法 `typeof Flip.getState === "function"` 是**空真**:未注册时它照样是 function,T33 实测) |
| 16 | + * C3 `scale: true` 的机器抓手(R12.3):Flip **自身写入**的属性增量 ⊆ 白名单 —— M3:hook 去掉 `scale: true` ⇒ 红 |
| 17 | + * C4 reduced-motion 降级(不建 timeline)—— M4:hook 删掉 reduced 分支 ⇒ 红 |
| 18 | + * C5 三档时长取自 token 真源 —— M5:`rich` 回落到 `card` 档 ⇒ 红 |
| 19 | + * |
| 20 | + * ⚠️ 仪器边界:jsdom **不排版** ⇒ 本文件**一条几何断言都没有**;动效确定性走 `paused: true` + `timeline.time(t)` |
| 21 | + * (R8.1);本仓未装 jest-dom / user-event ⇒ 断言用原生 API、交互用 `fireEvent`;无 `globals` ⇒ 显式 `cleanup`。 |
| 22 | + * 副作用:临时装 `matchMedia` 桩(`installMatchMediaStub`,每例 `restore()`)、清 `localStorage`、改 `innerWidth`(例末复原); |
| 23 | + * `@tauri-apps/api/core` 走 mock ⇒ 不连 IPC、不碰真实数据。 |
| 24 | + */ |
| 25 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 26 | +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
| 27 | +import { useState } from "react"; |
| 28 | +import { animatedProps, installMatchMediaStub } from "../test/motionHarness"; |
| 29 | +import { ANIMATABLE_PROPERTIES } from "../motion/shift"; |
| 30 | +import { DURATION_TOKENS } from "../ui/tokens.gen"; |
| 31 | +import { flipDurationSec, flipSelector, useColumnFlip, type ColumnFlip } from "./useColumnFlip"; |
| 32 | +import type { ColumnLayout } from "../hooks/useColumnLayout"; |
| 33 | +import ColumnBar from "../components/ColumnBar"; |
| 34 | +import ChatSidebar, { type ChatSidebarProps } from "../components/ChatSidebar"; |
| 35 | +import GoalsPage from "../pages/GoalsPage"; |
| 36 | + |
| 37 | +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); |
| 38 | +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); |
| 39 | + |
| 40 | +const ID = "col-harness"; |
| 41 | +const CHAT_KEY = "chat-sidebar"; |
| 42 | +const GOALS_KEY = "goals-left"; |
| 43 | + |
| 44 | +/** 等动态 `import("../motion/engine")` 与它的 `.then` 落地(**不是** GSAP 时基:本文件的 timeline 恒 `paused`)。 */ |
| 45 | +async function flush(): Promise<void> { |
| 46 | + for (let i = 0; i < 4; i += 1) await Promise.resolve(); |
| 47 | + await new Promise<void>((resolve) => { |
| 48 | + setTimeout(resolve, 0); |
| 49 | + }); |
| 50 | +} |
| 51 | + |
| 52 | +/** 折叠态的注入式列状态(判据只关心 `folded`;其余成员给足接口面)。 */ |
| 53 | +function colStub(folded: boolean): ColumnLayout { |
| 54 | + return { |
| 55 | + width: 260, folded, manuallyFolded: folded, |
| 56 | + resizeBy: vi.fn(), resetWidth: vi.fn(), setManualFolded: vi.fn(), expand: vi.fn(), |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +const CHAT_BASE: Omit<ChatSidebarProps, "col"> = { |
| 61 | + sessions: [], tasks: [], activeChatId: null, activeTaskId: null, |
| 62 | + onSelectChat: vi.fn(), onSelectTask: vi.fn(), onNewChat: vi.fn(), |
| 63 | + onRenameChat: vi.fn(), onDeleteChat: vi.fn(), |
| 64 | + sessionTitles: new Map<number, string>(), noteTitles: new Map<number, string>(), |
| 65 | +}; |
| 66 | + |
| 67 | +/** 判据用的句柄读取口(渲染期赋值 —— 只有测试 harness 会这么做)。 */ |
| 68 | +let handle: ColumnFlip["handle"] | null = null; |
| 69 | + |
| 70 | +/** 与生产同形的接线:折叠三元的两棵子树各带同一个 `data-flip-id`(`ChatSidebar` / `GoalsPage` 同款)。 */ |
| 71 | +function Harness(): React.ReactElement { |
| 72 | + const [folded, setFolded] = useState(false); |
| 73 | + handle = useColumnFlip(ID, folded, { paused: true }).handle; |
| 74 | + return ( |
| 75 | + <div> |
| 76 | + <button data-testid="toggle" onClick={() => setFolded((f) => !f)}>t</button> |
| 77 | + {folded ? ( |
| 78 | + <ColumnBar flipId={ID} icon="📁" title="户" onClick={() => setFolded(false)} /> |
| 79 | + ) : ( |
| 80 | + <div data-flip-id={ID} data-testid="panel" style={{ width: 240 }}>面板</div> |
| 81 | + )} |
| 82 | + </div> |
| 83 | + ); |
| 84 | +} |
| 85 | + |
| 86 | +beforeEach(() => { |
| 87 | + handle = null; |
| 88 | + window.localStorage.clear(); |
| 89 | + invokeMock.mockReset(); |
| 90 | + invokeMock.mockResolvedValue([]); |
| 91 | +}); |
| 92 | +afterEach(() => { |
| 93 | + cleanup(); |
| 94 | + window.localStorage.clear(); |
| 95 | + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 }); |
| 96 | +}); |
| 97 | + |
| 98 | +describe("T33 · 列折叠 Flip(路径 B:`data-flip-id` 跨元素配对)", () => { |
| 99 | + it("C1 身份契约:折叠前后是**两棵子树**(引用不等),但两者带**逐字相同**的 `data-flip-id`,且选择器恰命中 1 个", () => { |
| 100 | + const { container } = render(<Harness />); |
| 101 | + const panel = container.querySelector('[data-testid="panel"]'); |
| 102 | + expect(panel?.getAttribute("data-flip-id"), "展开态面板未带列身份").toBe(ID); |
| 103 | + expect(document.querySelectorAll(flipSelector(ID)).length, "展开态选择器命中数").toBe(1); |
| 104 | + |
| 105 | + fireEvent.click(screen.getByTestId("toggle")); |
| 106 | + const bar = screen.getByTestId("column-bar"); |
| 107 | + expect(bar.getAttribute("data-flip-id"), "折叠态窄条未带同一个列身份").toBe(ID); |
| 108 | + expect(bar === panel, "折叠前后竟是同一个 DOM 节点(那是路径 A 的形态,不是本路径)").toBe(false); |
| 109 | + expect(document.querySelectorAll(flipSelector(ID)).length, "折叠态选择器命中数").toBe(1); |
| 110 | + expect(panel?.isConnected, "旧子树应已卸载").toBe(false); |
| 111 | + }); |
| 112 | + |
| 113 | + it("C1b 真实落点 1/2 `ChatSidebar`:展开态面板与折叠态窄条带同一个 `data-flip-id`", () => { |
| 114 | + const expanded = render(<ChatSidebar {...CHAT_BASE} col={colStub(false)} />); |
| 115 | + expect( |
| 116 | + expanded.container.querySelector(`[data-flip-id="col-${CHAT_KEY}"]`), |
| 117 | + "ChatSidebar 展开态未带列身份(T33 的 2/8 落点之一)", |
| 118 | + ).not.toBeNull(); |
| 119 | + expect(screen.queryByTestId("column-bar")).toBeNull(); |
| 120 | + cleanup(); |
| 121 | + |
| 122 | + render(<ChatSidebar {...CHAT_BASE} col={colStub(true)} />); |
| 123 | + expect(screen.getByTestId("column-bar").getAttribute("data-flip-id"), "折叠态窄条未带同一身份").toBe( |
| 124 | + `col-${CHAT_KEY}`, |
| 125 | + ); |
| 126 | + }); |
| 127 | + |
| 128 | + it("C1c 真实落点 2/2 `GoalsPage`:展开态左列与折叠态窄条带同一个 `data-flip-id`", () => { |
| 129 | + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1280 }); |
| 130 | + const expanded = render(<GoalsPage />); |
| 131 | + expect( |
| 132 | + expanded.container.querySelector(`[data-flip-id="col-${GOALS_KEY}"]`), |
| 133 | + "GoalsPage 展开态左列未带列身份(T33 的 2/8 落点之二)", |
| 134 | + ).not.toBeNull(); |
| 135 | + cleanup(); |
| 136 | + |
| 137 | + window.localStorage.setItem(`layout:col-fold:${GOALS_KEY}`, "1"); // 手动折叠记忆 |
| 138 | + render(<GoalsPage />); |
| 139 | + expect(screen.getByTestId("column-bar").getAttribute("data-flip-id")).toBe(`col-${GOALS_KEY}`); |
| 140 | + }); |
| 141 | + |
| 142 | + it("C2 调用不抛 + 真 Timeline(计划 V1 的**有牙**形态):折叠变化后句柄里有一条 `duration()` = card 档的真时间线", async () => { |
| 143 | + render(<Harness />); |
| 144 | + await flush(); // 先让首挂载存下基线(`Flip.getState` 必须不抛:未注册时抛 TypeError) |
| 145 | + expect(handle?.current, "首挂载不该建时间线(只存基线)").toBeNull(); |
| 146 | + |
| 147 | + fireEvent.click(screen.getByTestId("toggle")); |
| 148 | + await flush(); |
| 149 | + const tl = handle?.current?.timeline ?? null; |
| 150 | + expect(tl, "折叠后没有时间线 ⇒ `Flip.getState`/`Flip.from` 没跑成(未注册时会抛 `_toArray`)").not.toBeNull(); |
| 151 | + expect(typeof tl?.duration(), "返回值不是真 Timeline").toBe("number"); |
| 152 | + expect(tl?.duration(), "时长未接 token 真源").toBeCloseTo(flipDurationSec("standard") ?? -1, 5); |
| 153 | + expect(tl?.getChildren().length, "Flip.from 的补间数(位置 + 尺寸两条)").toBe(2); |
| 154 | + }); |
| 155 | + |
| 156 | + it("C3 `scale: true`(R12.3)**增量**口径:Flip 自身写入的属性 ⊆ 合成属性白名单(含 `transform`,不含 layout 属性)", async () => { |
| 157 | + render(<Harness />); |
| 158 | + await flush(); |
| 159 | + fireEvent.click(screen.getByTestId("toggle")); |
| 160 | + const bar = screen.getByTestId("column-bar") as HTMLElement; |
| 161 | + const before = animatedProps(bar); // 折叠刚提交、Flip 还没跑:此刻的行内集合全是渲染期写的 |
| 162 | + await flush(); |
| 163 | + handle?.current?.timeline.time(0.25); // 确定性推进(R8.1) |
| 164 | + const delta = animatedProps(bar).filter((p) => !before.includes(p)); |
| 165 | + |
| 166 | + expect(delta, "Flip 一条属性都没写 ⇒ 本判据会退化成空真").not.toEqual([]); |
| 167 | + expect(delta, "Flip 写出的属性越出白名单(默认 `scale:false` 会写 width/height/max-*/min-*)").toEqual( |
| 168 | + delta.filter((p) => ANIMATABLE_PROPERTIES.includes(p)), |
| 169 | + ); |
| 170 | + expect(delta, "transform 路径未生效").toContain("transform"); |
| 171 | + expect(before.filter((p) => ANIMATABLE_PROPERTIES.includes(p)), "渲染期不该写合成属性").toEqual([]); |
| 172 | + }); |
| 173 | + |
| 174 | + it("C4 reduced-motion 降级:命中 `prefers-reduced-motion: reduce` ⇒ **不建**时间线(宽度照旧瞬跳)", async () => { |
| 175 | + const stub = installMatchMediaStub({ reduce: true }); |
| 176 | + try { |
| 177 | + render(<Harness />); |
| 178 | + await flush(); |
| 179 | + fireEvent.click(screen.getByTestId("toggle")); |
| 180 | + await flush(); |
| 181 | + expect(handle?.current, "reduced-motion 下仍建了时间线(§8.5 要求跳终态)").toBeNull(); |
| 182 | + expect(screen.getByTestId("column-bar").getAttribute("data-flip-id"), "降级不该破坏结构契约").toBe(ID); |
| 183 | + } finally { |
| 184 | + stub.restore(); |
| 185 | + } |
| 186 | + }); |
| 187 | + |
| 188 | + it("C5 三档时长取自 token 真源(逐档数组相等):`eco` 不播 · `standard` = card · `rich` = reveal", () => { |
| 189 | + const ms = (name: string): number => DURATION_TOKENS.find((t) => t.name === name)?.ms ?? -1; |
| 190 | + const tiers = ["eco", "standard", "rich"] as const; |
| 191 | + expect(tiers.map((t) => flipDurationSec(t))).toEqual([null, ms("card") / 1000, ms("reveal") / 1000]); |
| 192 | + expect(tiers.map((t) => flipDurationSec(t))).toEqual([null, 0.22, 0.5]); |
| 193 | + }); |
| 194 | +}); |
0 commit comments