|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * SessionsPage.deepLink.test.tsx — `[[ts:ms]]` 深链**落到会话页之后的那一跳**(批 7 T17)。 |
| 4 | + * |
| 5 | + * @ai_context 这条判据补的是 §C27.4 的 M3(「`focusSeekMs` 链今天**是死的**」)在**页面层**的缺口: |
| 6 | + * `App.goSessions(sessionId, ms)` 写入的 ms 载体要经 `SessionsPage` →(配对目标会话)→ |
| 7 | + * `SessionDetailPanel` → `SessionViewHost` → 三轨视图,最终**可见为播放头**。 |
| 8 | + * 本件用**状态级/命令级代理**判它(真机 WebView2 冒烟由用户裁决跳过 ⇒ §C6.4): |
| 9 | + * ① `get_session_detail` 被以目标 id 调用;② 三轨视图出现且播放头 `data-ms` == 深链的 ms; |
| 10 | + * ③ 消费回调恰 1 次(App 靠它复位焦点);④ `localStorage` 的视图记忆**逐字未变**(§C11.2)。 |
| 11 | + * 🔴 逐字登记:**这是代理判据**,不证明真机上「视频真的跳到了那一帧」(定位精度还受音频对齐 |
| 12 | + * 块粒度 ±200 ms 限制)。 |
| 13 | + * @ai_context 第二条用例钉**配对**(`SessionsPage` 的 `seekFor`):面板在 `detail` 到达前仍显示 |
| 14 | + * 上一个会话 ⇒ 若把 ms 无条件下传,它会被旧会话的面板消费掉、换到新会话时焦点已清空 |
| 15 | + * (深链静默失效)。该用例把「旧会话在位 + 新会话详情挂起」这一瞬态**构造出来**。 |
| 16 | + * 仪器边界:只 mock 模块边界(三个 Tauri 模块);真 `SessionsPage` + 真 `SessionDetailPanel` + |
| 17 | + * 真 `SessionViewHost` + 真惰性三轨视图(与 `SessionDetailPanel.test.tsx` 同一形态)。 |
| 18 | + */ |
| 19 | +import { act, cleanup, render, screen } from "@testing-library/react"; |
| 20 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 21 | +import type { SessionDetail } from "../types"; |
| 22 | + |
| 23 | +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); |
| 24 | +vi.mock("@tauri-apps/api/core", () => ({ |
| 25 | + invoke: invokeMock, |
| 26 | + convertFileSrc: (p: string) => `asset://localhost/${p}`, |
| 27 | +})); |
| 28 | +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); |
| 29 | +vi.mock("@tauri-apps/plugin-dialog", () => ({ confirm: vi.fn(() => Promise.resolve(true)) })); |
| 30 | + |
| 31 | +import SessionsPage from "./SessionsPage"; |
| 32 | + |
| 33 | +const noop = (): void => {}; |
| 34 | +const MEMORY = "view:default:session"; |
| 35 | + |
| 36 | +function detailOf(id: number, title: string): SessionDetail { |
| 37 | + return { |
| 38 | + session: { id, title, source_window: "Chrome", started_at: 0, ended_at: 8000, status: "finished", kind: null }, |
| 39 | + segments: [{ id: id * 10 + 1, session_id: id, start_ms: 0, end_ms: 4000, text: `${title} 第一段`, source: "asr", confidence: 0.9 }], |
| 40 | + ocr_blocks: [], |
| 41 | + screens: [], |
| 42 | + }; |
| 43 | +} |
| 44 | + |
| 45 | +/** 详情响应的可控延迟(第二条用例要「新会话详情仍在飞」这一瞬态) */ |
| 46 | +function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } { |
| 47 | + let resolve!: (v: T) => void; |
| 48 | + const promise = new Promise<T>((r) => { |
| 49 | + resolve = r; |
| 50 | + }); |
| 51 | + return { promise, resolve }; |
| 52 | +} |
| 53 | + |
| 54 | +function install(details: Record<number, SessionDetail | Promise<SessionDetail>>): void { |
| 55 | + invokeMock.mockImplementation((cmd: string, args?: Record<string, unknown>) => { |
| 56 | + const id = args?.id as number | undefined; |
| 57 | + if (cmd === "get_session_detail") { |
| 58 | + const hit = id === undefined ? undefined : details[id]; |
| 59 | + return hit === undefined ? Promise.reject(new Error("未登记的会话")) : Promise.resolve(hit); |
| 60 | + } |
| 61 | + if (cmd === "session_audio_path") return Promise.resolve({ path: "C:/tmp/a.wav", aligned: true, durationMs: 8000 }); |
| 62 | + if (cmd === "get_feature_flags") return Promise.resolve({ feedCapture: true }); |
| 63 | + if (cmd.startsWith("list_") || cmd.startsWith("note_versions")) return Promise.resolve([]); |
| 64 | + return Promise.reject(new Error(`invoke not mocked: ${cmd}`)); |
| 65 | + }); |
| 66 | +} |
| 67 | + |
| 68 | +const snapshot = (): Record<string, string> => { |
| 69 | + const out: Record<string, string> = {}; |
| 70 | + for (let i = 0; i < localStorage.length; i += 1) { |
| 71 | + const k = localStorage.key(i); |
| 72 | + if (k !== null) out[k] = localStorage.getItem(k) ?? ""; |
| 73 | + } |
| 74 | + return out; |
| 75 | +}; |
| 76 | +const playheads = (c: HTMLElement): (string | null)[] => |
| 77 | + [...c.querySelectorAll('[data-testid="session-timerail-playhead"]')].map((el) => el.getAttribute("data-ms")); |
| 78 | + |
| 79 | +beforeEach(() => { |
| 80 | + invokeMock.mockReset(); |
| 81 | + localStorage.clear(); |
| 82 | +}); |
| 83 | +afterEach(() => { |
| 84 | + cleanup(); |
| 85 | + localStorage.clear(); |
| 86 | +}); |
| 87 | + |
| 88 | +describe("SessionsPage · [[ts:ms]] 深链(T17 · C27.4 的 M3 的页面层闭合)", () => { |
| 89 | + it("① 深链到达:目标会话被打开 ∧ 视图切三轨 ∧ 播放头 = ms ∧ 消费恰 1 次 ∧ 视图记忆未变", async () => { |
| 90 | + install({ 1042: detailOf(1042, "会话甲") }); |
| 91 | + localStorage.setItem(MEMORY, "proof"); // 用户既有记忆:深链**不许**改它 |
| 92 | + const onFocusSeekConsumed = vi.fn(); |
| 93 | + const { container } = render( |
| 94 | + <SessionsPage |
| 95 | + active |
| 96 | + focusSessionId={1042} |
| 97 | + focusSeekMs={{ ms: 5000, key: 1 }} |
| 98 | + onFocusSeekConsumed={onFocusSeekConsumed} |
| 99 | + onOpenNote={noop} |
| 100 | + />, |
| 101 | + ); |
| 102 | + await screen.findByTestId("session-tritrack-view"); |
| 103 | + expect(invokeMock.mock.calls.filter((c) => c[0] === "get_session_detail"), "深链必须拉目标会话详情") |
| 104 | + .toEqual([["get_session_detail", { id: 1042 }]]); |
| 105 | + expect(playheads(container), "深链的 ms 没落到播放头上(「定位到 ms」不可感知)").toEqual(["5000"]); |
| 106 | + expect(onFocusSeekConsumed, "消费回调必须恰一次(App 靠它复位焦点,防陈旧 ms 复触发)").toHaveBeenCalledTimes(1); |
| 107 | + // 视图记忆是**逐字未变**(本页的列布局键 `layout:*` 不属视图记忆 ⇒ 只钉记忆前缀这一个面) |
| 108 | + expect(localStorage.getItem(MEMORY), "一次深链把用户的默认视图改掉了(§C11.2 禁止持久化)").toBe("proof"); |
| 109 | + expect(Object.keys(snapshot()).filter((k) => k.startsWith("view:default:")), "深链不得新增视图记忆键") |
| 110 | + .toEqual([MEMORY]); |
| 111 | + }); |
| 112 | + |
| 113 | + it("② 配对:新会话详情仍在飞时,ms **不被**上一个会话的面板消费;到达后才消费恰 1 次", async () => { |
| 114 | + const pending = deferred<SessionDetail>(); |
| 115 | + install({ 1042: detailOf(1042, "会话甲"), 2043: pending.promise }); |
| 116 | + const onFocusSeekConsumed = vi.fn(); |
| 117 | + const view = render( |
| 118 | + <SessionsPage active focusSessionId={1042} onFocusSeekConsumed={onFocusSeekConsumed} onOpenNote={noop} />, |
| 119 | + ); |
| 120 | + await screen.findByText("会话甲 第一段"); // 旧会话的面板已就位 |
| 121 | + view.rerender( |
| 122 | + <SessionsPage |
| 123 | + active |
| 124 | + focusSessionId={2043} |
| 125 | + focusSeekMs={{ ms: 5000, key: 2 }} |
| 126 | + onFocusSeekConsumed={onFocusSeekConsumed} |
| 127 | + onOpenNote={noop} |
| 128 | + />, |
| 129 | + ); |
| 130 | + await act(async () => {}); |
| 131 | + expect(onFocusSeekConsumed, "目标会话还没到达就消费了 ms(换到新会话时焦点已清空 ⇒ 深链静默失效)").toHaveBeenCalledTimes(0); |
| 132 | + expect(screen.queryByTestId("session-tritrack-view"), "ms 把**旧**会话的视图切走了").toBeNull(); |
| 133 | + |
| 134 | + await act(async () => { |
| 135 | + pending.resolve(detailOf(2043, "会话乙")); |
| 136 | + }); |
| 137 | + await screen.findByTestId("session-tritrack-view"); |
| 138 | + expect(onFocusSeekConsumed).toHaveBeenCalledTimes(1); |
| 139 | + expect(playheads(view.container)).toEqual(["5000"]); |
| 140 | + }); |
| 141 | +}); |
0 commit comments