|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * SessionTriTrackView.align.test.tsx — T27 在**视图层**的集成判据(#1「对齐」的接线:共轴高亮 + |
| 4 | + * 落点登记 + 两条非参考轨的位移真的被写)。 |
| 5 | + * |
| 6 | + * @ai-context 与另外两件的分工:`SessionTriTrackView.test.tsx` 的 5 条(T1–T5,批 5 交付)与 |
| 7 | + * `SessionTriTrackView.rail.test.tsx`(T25)**逐字不动**;本件只加 T27 新增的面。⚠️ 本件落在 |
| 8 | + * **新文件**而不是往那 239 行里追加:既有文件只剩 61 行余量(R8.8「贴边文件动手前先量」), |
| 9 | + * 而本件要断的面(逐序高亮集合 + 落点登记 + 属性集合审计)不是几十行能写完的 —— **零余量**的 |
| 10 | + * 文件不该再动(先例:T25 也是新开 `…rail.test.tsx` 而非改既有件)。 |
| 11 | + * @ai-context 生产侧时间线**不 paused**(ticker 驱动)⇒ 本件只断**方向 / 范围 / 结构**,逐端点的 |
| 12 | + * 精确读数归 `useTriTrackAlign.test.tsx`(那是 `paused: true` + `freezeAt` 的确定性面,同 |
| 13 | + * `phaseFreeze.seams.test.tsx` 与 `usePhaseFreeze.test.tsx` 的分工)。 |
| 14 | + * @ai-context **不 mock Tauri**:本件不取数、不真播放 ⇒ 没有 `@tauri-apps` 的模块边界可 mock。 |
| 15 | + * 副作用:挂 React 树 + 建 GSAP 时间线(`afterEach` 收尸)+ 只读同目录 `TriTrackAlign.css` 的文本。 |
| 16 | + * 边界:**jsdom 不排版** ⇒ 「三轨看起来真的对齐了吗」不可判(如实进报告 `## 诚实边界`)。 |
| 17 | + */ |
| 18 | +import { readFileSync } from "node:fs"; |
| 19 | +import { dirname, join } from "node:path"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; |
| 22 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 23 | +import { gsap } from "../../motion/engine"; |
| 24 | +import { animatedProps, currentTransform, tweenCount } from "../../test/motionHarness"; |
| 25 | +import { stripComments } from "../../ui/primitives/sliceScan"; |
| 26 | +import type { SessionDetail } from "../../types/session"; |
| 27 | +import SessionTriTrackView, { COALIGNED_CLASS, ITEM_CLASS } from "./SessionTriTrackView"; |
| 28 | +import { shiftYOf } from "./useTriTrackAlign"; |
| 29 | + |
| 30 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 31 | +const CSS = stripComments(readFileSync(join(HERE, "TriTrackAlign.css"), "utf8")); |
| 32 | +/** 动态 import 的到达窗口(**不是**动画的等待窗口);同 T29/T25 先例放宽上限)。 */ |
| 33 | +const WAIT = { timeout: 5000 } as const; |
| 34 | +/** 夹具自己写进轨容器的**布局**行内属性(= 视图的 `LANE_STYLE`)。 */ |
| 35 | +const LAYOUT_PROPS = ["display", "flex-direction", "gap"]; |
| 36 | +/** 共轴组:9000 时刻三条轨都有条目;另有 0 / 500 两条**不在组里** ⇒ 高亮的**选择性**可判。 */ |
| 37 | +const MS = 9_000; |
| 38 | + |
| 39 | +function detailOf(): SessionDetail { |
| 40 | + return { |
| 41 | + session: { id: 1042, title: "构图与调色", source_window: "Chrome", started_at: 1_700_000_000_000, ended_at: 1_700_003_800_000, status: "finished", kind: null }, |
| 42 | + segments: [ |
| 43 | + { id: 9001, session_id: 1042, start_ms: 0, end_ms: 2_500, text: "开场:构图三要素", source: "subtitle", confidence: 0.9 }, |
| 44 | + { id: 9003, session_id: 1042, start_ms: MS, end_ms: 12_000, text: "第二段:色轮", source: "fused", confidence: 0.8 }, |
| 45 | + ], |
| 46 | + screens: [ |
| 47 | + { session_id: 1042, screen_id: 1, first_seen_ms: MS, last_seen_ms: 12_000, title: "调色面板", body: ["色相环"], labels: [], image_ref: null, structure: [] }, |
| 48 | + ], |
| 49 | + ocr_blocks: [ |
| 50 | + { id: 5001, session_id: 1042, timestamp_ms: MS, text: "色相 / 饱和度", score: 0.93, region: "full" }, |
| 51 | + { id: 5002, session_id: 1042, timestamp_ms: 500, text: "课程封面", score: 0.81, region: "subtitle" }, |
| 52 | + ], |
| 53 | + }; |
| 54 | +} |
| 55 | +const q = (container: HTMLElement, sel: string): HTMLElement => { |
| 56 | + const el = container.querySelector(sel); |
| 57 | + if (el === null) throw new Error(`没有渲染出 ${sel}`); |
| 58 | + return el as HTMLElement; |
| 59 | +}; |
| 60 | +const laneEl = (container: HTMLElement, track: string): HTMLElement => q(container, `[data-track="${track}"]`); |
| 61 | +const yOf = (container: HTMLElement, track: string): number => shiftYOf(currentTransform(laneEl(container, track))); |
| 62 | +/** 时间码按钮(T25 起时间码是定位按钮)。 */ |
| 63 | +const codeAt = (container: HTMLElement, ms: number): HTMLElement => q(container, `[data-track="transcript"] [data-ms="${ms}"] button`); |
| 64 | +/** 带共轴高亮类的条目,按 **DOM 序**(= 转写 → 画面 → OCR,T2 的既有判据)写成 `轨@ms`。 */ |
| 65 | +const highlighted = (container: HTMLElement): string[] => |
| 66 | + [...container.querySelectorAll(`.${COALIGNED_CLASS}`)].map((el) => { |
| 67 | + const lane = el.closest("[data-track]") as HTMLElement | null; |
| 68 | + return `${lane?.dataset.track ?? "?"}@${(el as HTMLElement).dataset.ms ?? "?"}`; |
| 69 | + }); |
| 70 | +const rails = () => gsap.globalTimeline.getChildren(false, true, true).length; |
| 71 | + |
| 72 | +afterEach(() => { |
| 73 | + cleanup(); |
| 74 | + for (const el of [...document.body.children]) { |
| 75 | + gsap.killTweensOf(el); |
| 76 | + el.remove(); |
| 77 | + } |
| 78 | +}); |
| 79 | + |
| 80 | +describe("A1 · 共轴 + 同 ms 高亮(R5.1 的后半句)", () => { |
| 81 | + it("点时间码 ⇒ **恰**三个条目(转写/画面/OCR,逐序)带高亮类;非组条目一个都不带;再点 ⇒ 清空", async () => { |
| 82 | + const onSeekMs = vi.fn(); |
| 83 | + const { container } = render(<SessionTriTrackView detail={detailOf()} onSeekMs={onSeekMs} />); |
| 84 | + expect(highlighted(container), "未触发时高亮必须是**空集**(不是「全部」)").toEqual([]); |
| 85 | + |
| 86 | + fireEvent.click(codeAt(container, MS)); |
| 87 | + expect(onSeekMs.mock.calls.length, "既有上行口被改动(仍须恰 1 次)").toBe(1); |
| 88 | + expect(onSeekMs.mock.calls[0][0]).toBe(MS); |
| 89 | + expect(highlighted(container), "同 ms 的三个条目高亮共轴关系(DOM 序 = 转写 → 画面 → OCR)").toEqual([ |
| 90 | + "transcript@9000", |
| 91 | + "screen@9000", |
| 92 | + "ocr@9000", |
| 93 | + ]); |
| 94 | + // 🔴 第三轨 = OCR(§8.6 的批 5 加注逐字)⇒ 上面那三行里必须有 `ocr@9000`;DOM 顺序另用既有判据钉住 |
| 95 | + const at = (track: string): HTMLElement => q(container, `[data-track="${track}"] [data-ms="${MS}"]`); |
| 96 | + const following = (a: Element, b: Element): boolean => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; |
| 97 | + expect([following(at("transcript"), at("screen")), following(at("screen"), at("ocr"))]).toEqual([true, true]); |
| 98 | + // 每个条目都带**基类**(高亮类必须是 `<基类>--<修饰>` 形状,而不是一个孤立的类名) |
| 99 | + const bare = [...container.querySelectorAll("[data-ms]")].filter((el) => !el.classList.contains(ITEM_CLASS)); |
| 100 | + expect(bare.map((el) => (el as HTMLElement).dataset.ms), "有条目掉了基类").toEqual([]); |
| 101 | + |
| 102 | + fireEvent.click(codeAt(container, MS)); // 反向:再次触发 ⇒ 对齐 → 错位 ⇒ 高亮清空 |
| 103 | + expect(highlighted(container), "反向没有清掉共轴高亮").toEqual([]); |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +describe("A2 · 落点登记与位移接线(只有两条**非参考轨**被写)", () => { |
| 108 | + it("动效落点带 `data-tone=\"instrument\"`(R3.4);挂载即物化错位位形;转写轨(时间基)零位移", async () => { |
| 109 | + const { container } = render(<SessionTriTrackView detail={detailOf()} />); |
| 110 | + expect(q(container, "[data-track]").parentElement?.parentElement?.getAttribute("data-tone"), "落点没有声明基调").toBe("instrument"); |
| 111 | + await waitFor(() => expect([yOf(container, "screen"), yOf(container, "ocr")]).toEqual([8, -8]), WAIT); |
| 112 | + expect(animatedProps(laneEl(container, "transcript")), "转写轨是时间基 ⇒ 不许被位移").not.toContain("transform"); |
| 113 | + expect(rails(), "挂载只物化静态位形 ⇒ 零编排时间线").toBe(0); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe("A3 · 触发真的起了编排(方向 / 范围口径)与属性集合审计(R8.4)", () => { |
| 118 | + it("点时间码 ⇒ 两条非参考轨各起 1 条时间线、位移落在 [对齐位, 错位位] 之间;越界属性恰是夹具的布局属性", async () => { |
| 119 | + const { container } = render(<SessionTriTrackView detail={detailOf()} />); |
| 120 | + await waitFor(() => expect(yOf(container, "screen")).toBe(8), WAIT); |
| 121 | + fireEvent.click(codeAt(container, MS)); |
| 122 | + await waitFor(() => expect(rails(), "两条非参考轨各 1 条编排时间线").toBe(2), WAIT); |
| 123 | + expect([tweenCount(laneEl(container, "screen")), tweenCount(laneEl(container, "ocr"))]).toEqual([1, 1]); |
| 124 | + // 生产侧不 paused ⇒ 只断方向/范围(端点精确读数在 `useTriTrackAlign.test.tsx` 的 paused 面) |
| 125 | + expect([yOf(container, "screen") <= 8, yOf(container, "screen") >= 0], "位移必须落在错位位与对齐位之间").toEqual([true, true]); |
| 126 | + const props = animatedProps(laneEl(container, "screen")); |
| 127 | + expect(props, "transform 必须真被写(否则本条是空真)").toContain("transform"); |
| 128 | + expect(props.filter((p) => !["transform", "translate", "rotate", "scale", "opacity", "filter"].includes(p)), "越界集合必须恰是夹具的布局属性(delta 口径,同 T33 W4)").toEqual(LAYOUT_PROPS); |
| 129 | + }); |
| 130 | +}); |
| 131 | + |
| 132 | +describe("A4 · 高亮落点的样式接缝(TriTrackAlign.css)", () => { |
| 133 | + it("选择器 = 修饰类 · 颜色只经 `var(--ed-link)` · **零** transition/animation/@media/z-index/色值字面量", () => { |
| 134 | + expect(CSS, "修饰类没有规则 ⇒ 「高亮」是空话").toContain(`.${COALIGNED_CLASS}`); |
| 135 | + expect(COALIGNED_CLASS, "高亮类必须是 `<基类>--<修饰>` 形状").toBe(`${ITEM_CLASS}--coaligned`); |
| 136 | + expect(CSS, "高亮取 `--ed-link`(其语义含「时间码」)").toContain("var(--ed-link)"); |
| 137 | + expect(CSS, "色值只许在 tokens.css 与生成器里").not.toMatch(/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|hsl)a?\(/); |
| 138 | + // 零 transition/animation ⇒ 不必进 `motion.css` 的 reduced-motion 名单(R48.1 的机制缺口不适用本落点) |
| 139 | + expect(CSS, "transition-only 落点会加不进 reduced-motion 名单(R48.1)").not.toMatch(/transition|animation|@keyframes/); |
| 140 | + expect(CSS, "本层不新增媒体查询(全仓只许一条 reduced-motion 块)").not.toContain("@media"); |
| 141 | + expect(CSS, "层级只在 ui/zIndex.ts").not.toContain("z-index"); |
| 142 | + }); |
| 143 | +}); |
0 commit comments