Skip to content

Commit a36145c

Browse files
committed
feat(session-detail): make detail header sticky
1 parent d479422 commit a36145c

2 files changed

Lines changed: 212 additions & 2 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionDetailHeader.test.tsx — 批 5 · T11(C13)的判据面(该文件**在批 5 开工时无同名测试**)。
4+
*
5+
* @ai-context: C13 逐字「先补 1 条行为级判据(该文件无同名测试),判据形态 = 静态结构级
6+
* (`position: sticky` ∧ `top` 来自 token)」⇒ 粘性改在**无网处**,本文件先立网。
7+
* D1 **行为级**(改前就该绿、改后必须仍绿):标题/状态行/动作区可达 · ✎ 进改名 ·
8+
* Esc 取消 · Enter 提交时 `update_session_title` 的**实参与调用时机**逐字不变
9+
* (Enter 前 0 次 / Enter 后恰 1 次 / 同标题值 ⇒ 0 次)—— 粘性只许改样式,
10+
* **不许碰这条路径**(V2 的「invoke 调用点与时机零 diff」由 D1-c/D1-d 顶住)。
11+
* D2/D3 **静态结构级**(改前应红、改后绿):`position: sticky` ∧ `top` 走
12+
* **已在 `ui/tokens.css` 声明**的 token(不是裸数字、也不是编造的 var 名)∧
13+
* `z-index` 走 `zIndex("raised")` 档位 ∧ 底色走 canvas token(防正文透出)。
14+
*
15+
* @ai-context 仪器边界(**已写进 T11 报告的「未验证」单列**):jsdom **不排版** —— 它只把行内
16+
* `style` 序列化成 attribute。`position: sticky` 与 `top` 是否**真的吸顶**、
17+
* 4px 的观感、滚动时底色是否挡住正文,**本文件一行都测不出**;这是 C15「只能登记」
18+
* 清单里的 `sticky 真实粘性`。⇒ D2/D3 是**结构判据**,不是粘性判据,**不许**当成
19+
* 「粘性已验证」。
20+
* @ai-context 已知分辨力缺口(诚实登记):`z-index` 的**数值**在 DOM 上与裸等值数字(`10`)
21+
* **不可分辨** ⇒ D3 只能证明「等于 raised 档、且不等于相邻档」;「源码里写的是档位
22+
* 函数而非字面量」由 `ui/zIndex.guard.test.ts`(源码扫描,见其文件头 ④ 的口径)
23+
* 补 —— 两条仪器同批同变异体的读数见 T11 报告 §判据与变异。
24+
*/
25+
import { readFileSync } from "node:fs";
26+
import { dirname, join } from "node:path";
27+
import { fileURLToPath } from "node:url";
28+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
29+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
30+
import type { SessionDetail } from "../../types";
31+
import { zIndex } from "../../ui/zIndex";
32+
33+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
34+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
35+
36+
import SessionDetailHeader from "./SessionDetailHeader";
37+
38+
const HERE = dirname(fileURLToPath(import.meta.url));
39+
40+
/** `ui/tokens.css` 里是否**声明**了该 token(`--ed-x:` / `--ed-x :` 两种写法都认) */
41+
function declaresToken(name: string): boolean {
42+
const css = readFileSync(join(HERE, "..", "..", "ui", "tokens.css"), "utf8");
43+
return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`).test(css);
44+
}
45+
46+
function detailOf(over: Partial<SessionDetail> = {}): SessionDetail {
47+
return {
48+
session: {
49+
id: 1042,
50+
title: "化学反应速率",
51+
source_window: "Chrome",
52+
started_at: 1_700_000_000_000,
53+
ended_at: 1_700_000_600_000,
54+
status: "finished",
55+
kind: null,
56+
},
57+
segments: [
58+
{ id: 7001, session_id: 1042, start_ms: 0, end_ms: 2500, text: "第一段讲述", source: "subtitle", confidence: 0.9 },
59+
{ id: 7002, session_id: 1042, start_ms: 2500, end_ms: 5000, text: "第二段讲述", source: "asr", confidence: 0.7 },
60+
],
61+
ocr_blocks: [],
62+
screens: [],
63+
...over,
64+
};
65+
}
66+
67+
const noop = () => {};
68+
const Header = (over: Record<string, unknown> = {}) => (
69+
<SessionDetailHeader
70+
detail={detailOf()}
71+
fusing={false}
72+
degradedBanner={null}
73+
onToNote={noop}
74+
onRemove={noop}
75+
onRefreshDetail={noop}
76+
{...over}
77+
/>
78+
);
79+
80+
/** 粘性锚点的**行内** style(D2/D3 的唯一取数口;锚点不存在 ⇒ `getByTestId` 自己红,不静默返回空串) */
81+
const headerStyle = (): CSSStyleDeclaration => (screen.getByTestId("session-header") as HTMLElement).style;
82+
83+
/** 从 `var(--ed-x, fallback)` 里取出 token 名;形态不对 ⇒ 显式抛(断言失败要可归因) */
84+
function tokenNameOf(value: string): string {
85+
const m = /^var\(\s*(--ed-[\w-]+)\s*(?:,[\s\S]*)?\)$/.exec(value);
86+
if (!m) throw new Error(`不是 token 形态:${JSON.stringify(value)}`);
87+
return m[1];
88+
}
89+
90+
beforeEach(() => {
91+
invokeMock.mockReset();
92+
// 缺省 reject(与 `SessionDetailPanel.test.tsx` 同款):改名失败分支的断言只显式 resolve 的用例覆盖
93+
invokeMock.mockImplementation((cmd: string) => Promise.reject(new Error(`invoke not mocked: ${cmd}`)));
94+
});
95+
afterEach(() => cleanup());
96+
97+
describe("D1 · 行为级(改前就该绿;粘性改动不许动这条路径)", () => {
98+
it("D1-a 渲染即可达:标题 / 状态行 / 动作区 / ✎ 全在位,且渲染本身**零 IPC**", () => {
99+
render(<Header />);
100+
expect(screen.getByText("化学反应速率")).toBeTruthy();
101+
expect(screen.getByTestId("session-rename-open")).toBeTruthy();
102+
expect(screen.getByText("📝 转为笔记")).toBeTruthy();
103+
expect(screen.getByText("删除")).toBeTruthy();
104+
// 状态行是 header 的一部分(粘性后仍在头里,不被裁掉)
105+
expect(document.body.textContent).toContain("已完成 · 2 段转写 · 0 块画面");
106+
expect(invokeMock).not.toHaveBeenCalled();
107+
});
108+
109+
it("D1-b ✎ 进改名(预填原标题)· Esc 取消 ⇒ 输入框消失、标题回来、**零 IPC**", () => {
110+
render(<Header />);
111+
fireEvent.click(screen.getByTestId("session-rename-open"));
112+
const input = screen.getByTestId("session-title-input") as HTMLInputElement;
113+
expect(input.value).toBe("化学反应速率");
114+
fireEvent.change(input, { target: { value: "改了一半就反悔" } });
115+
fireEvent.keyDown(input, { key: "Escape" });
116+
expect(screen.queryByTestId("session-title-input")).toBeNull();
117+
expect(screen.getByText("化学反应速率")).toBeTruthy();
118+
expect(invokeMock).not.toHaveBeenCalled();
119+
});
120+
121+
it("D1-c Enter 提交:**时机**(Enter 前 0 次 → Enter 后恰 1 次)+ 实参逐字(已 trim)+ 成功后退出并重拉", async () => {
122+
const onRefreshDetail = vi.fn();
123+
invokeMock.mockResolvedValue(undefined);
124+
render(<Header onRefreshDetail={onRefreshDetail} />);
125+
fireEvent.click(screen.getByTestId("session-rename-open"));
126+
const input = screen.getByTestId("session-title-input");
127+
fireEvent.change(input, { target: { value: " 速率与平衡 " } });
128+
expect(invokeMock, "改值本身不该发 IPC").toHaveBeenCalledTimes(0);
129+
fireEvent.keyDown(input, { key: "Enter" });
130+
expect(invokeMock).toHaveBeenCalledTimes(1);
131+
expect(invokeMock).toHaveBeenCalledWith("update_session_title", { id: 1042, title: "速率与平衡" });
132+
await waitFor(() => expect(screen.queryByTestId("session-title-input")).toBeNull());
133+
expect(onRefreshDetail).toHaveBeenCalledWith(1042);
134+
expect(invokeMock, "成功后不该补发第二次 IPC").toHaveBeenCalledTimes(1);
135+
});
136+
137+
it("D1-d 不变量:trim 后与原标题相同 ⇒ **零 IPC** 且退出改名(拆分前的早退逐字保留)", () => {
138+
render(<Header />);
139+
fireEvent.click(screen.getByTestId("session-rename-open"));
140+
const input = screen.getByTestId("session-title-input");
141+
fireEvent.change(input, { target: { value: " 化学反应速率 " } });
142+
fireEvent.keyDown(input, { key: "Enter" });
143+
expect(invokeMock).toHaveBeenCalledTimes(0);
144+
expect(screen.queryByTestId("session-title-input")).toBeNull();
145+
});
146+
147+
it("D1-e 失败面未被顺手改动:reject ⇒ 留在改名态 + 错误行出现(逐字「改名失败: …」)", async () => {
148+
render(<Header />);
149+
fireEvent.click(screen.getByTestId("session-rename-open"));
150+
const input = screen.getByTestId("session-title-input");
151+
fireEvent.change(input, { target: { value: "新标题" } });
152+
fireEvent.keyDown(input, { key: "Enter" });
153+
await waitFor(() => expect(document.body.textContent).toContain("改名失败: Error: invoke not mocked: update_session_title"));
154+
expect(screen.getByTestId("session-title-input")).toBeTruthy();
155+
});
156+
});
157+
158+
describe("D2 · 静态结构级(C13:`position: sticky` ∧ `top` 来自 token)", () => {
159+
it("D2-a `session-header` 是粘性头:`position: sticky`(行内 style 序列化里逐字可见)", () => {
160+
render(<Header />);
161+
expect(headerStyle().position).toBe("sticky");
162+
expect(screen.getByTestId("session-header").getAttribute("style")).toContain("position: sticky");
163+
});
164+
165+
it("D2-b `top` 来自 token:`var(--ed-…)` ∧ **该 token 在 tokens.css 已声明**(不许裸数字 / 编造 var 名)", () => {
166+
render(<Header />);
167+
const top = headerStyle().top;
168+
expect(top, `top 实测 ${JSON.stringify(top)}`).toMatch(/^var\(\s*--ed-[\w-]+/);
169+
expect(top, "top 不许是裸数字 / 裸像素").not.toMatch(/^-?[\d.]/);
170+
const name = tokenNameOf(top);
171+
expect(declaresToken(name), `top 引用的 ${name} 在 ui/tokens.css 里没有声明`).toBe(true);
172+
// 反例控制:编造的 var 名必须查不到(否则上一条是空真)
173+
expect(declaresToken("--ed-no-such-token-xyzq")).toBe(false);
174+
// 兜底值只许是**长度**(不是 `0` 那种"没写"的形态;兜底的存在理由见 T11 报告)
175+
expect(top).toContain(",");
176+
});
177+
178+
it("D2-c 底色走 canvas token(防正文从粘性头下透出;不许写色值字面量)", () => {
179+
render(<Header />);
180+
const bg = headerStyle().background;
181+
expect(declaresToken(tokenNameOf(bg)), `background = ${JSON.stringify(bg)}`).toBe(true);
182+
expect(bg, "底色不许出现色值字面量").not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i);
183+
});
184+
});
185+
186+
describe("D3 · 层级走标尺(ADR-032 决策 5:不得写裸数字 z-index)", () => {
187+
it("D3 `session-header` 的 z-index = `zIndex(\"raised\")` 档位值,且与相邻档不同(有分辨力)", () => {
188+
render(<Header />);
189+
const z = headerStyle().zIndex;
190+
// 反例控制:仪器能分辨档位(否则「等于 raised」这条对任何值都成立 ⇒ 空真)
191+
expect(zIndex("raised")).not.toBe(zIndex("panel"));
192+
expect(z).toBe(String(zIndex("raised")));
193+
expect(z).not.toBe(String(zIndex("panel")));
194+
});
195+
});

‎app/src/components/session-detail/SessionDetailHeader.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,32 @@
99
* @ai-context: REQ-031(融合停止异步化):fusing 时显示「⏳ 融合中」徽标;session:fused 到达后
1010
* 父层自动刷新 detail。REQ-080 降级分级:degradedBanner 为一次性横幅(null=不渲染)。
1111
* @ai-context: DOM 契约——`data-testid="session-title-input"` 与 `data-testid="session-rename-open"`
12-
* 是既有测试锚点,不得改名;本组件不含 position:sticky(粘性头未实现,勿顺手加)。
12+
* 是既有测试锚点,不得改名;`data-testid="session-header"` 是批 5 T11 新增的**粘性锚点**。
13+
* @ai-context: 批 5 C13:本组件**已是**粘性头(`position: sticky` + `top: var(--ed-space-4, 4px)`
14+
* + `zIndex("raised")`);**祖先链必须无 `overflow: hidden`**(实测滚动容器 =
15+
* `SessionsPage.tsx:306` 的 `overflowY:auto`,中间只有 Fragment 一层、无溢出层 ⇒ 可粘)。
1316
* @ai-context: 样式口径——「删除」已走 `Button`(批 4 B4);「转为笔记」仍是未迁的 spread 形态(余量登记批 5/7)。
1417
*/
1518
import { useState } from "react";
1619
import { invoke } from "@tauri-apps/api/core";
1720
import type { SessionDetail } from "../../types";
21+
import { zIndex } from "../../ui/zIndex";
1822
import { Button } from "../../ui/primitives";
1923

2024
/** 通用小按钮基础样式(拆分前 SessionDetailPanel 的 `btn`——本文件「转为笔记/删除」复用) */
2125
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 };
2226

27+
/** 批 5 C13 粘性头(**只加样式、不加逻辑**):`top` 走 token(`ui/tokens.css:--ed-space-4: 4px`)
28+
* —— 右栏滚动容器 `SessionsPage.tsx:306` 自带 `padding:16`,要贴住容器顶得留一点余量(不新增 token);
29+
* `zIndex("raised")` 的档位注释逐字就是「吸顶头 / 粘性列头 / 粘性工具栏」;底色取 canvas 档,
30+
* 防正文从粘性头下透出(无色值字面量)。 */
31+
const stickyHeader: React.CSSProperties = {
32+
position: "sticky",
33+
top: "var(--ed-space-4, 4px)",
34+
zIndex: zIndex("raised"),
35+
background: "var(--ed-bg-canvas)",
36+
};
37+
2338
const STATUS_LABEL: Record<string, string> = {
2439
recording: "录制中",
2540
finished: "已完成",
@@ -94,7 +109,7 @@ export default function SessionDetailHeader({ detail, fusing, degradedBanner, on
94109
</div>
95110
)}
96111

97-
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
112+
<div data-testid="session-header" style={{ ...stickyHeader, display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
98113
{renameMode ? (
99114
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 220 }}>
100115
<input

0 commit comments

Comments
 (0)