Skip to content

Commit 6b81f41

Browse files
committed
fix(classroom): unify right pane width wrapper
1 parent ecc36a8 commit 6b81f41

2 files changed

Lines changed: 205 additions & 70 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context 课堂右栏「唯一内容宽决定点」守卫(规格 §6.2「统一 wrapper,消灭 640/全宽两档跳动」;批 3 T10)。
4+
* 判据三层,各自能独立变红:① DOM 结构 = **恰好一个**元素带内联宽度上限、且是三个分支的共同祖先
5+
* (结果态预览此前**没有**上限,正是「跳到全宽」那一档的来源);② 值的来源 = 渲染出的上限 === 注册表
6+
* `settings-main` 的默认宽(数字只有一份);③ 源码文本(**先剥注释**,先例 `shell/TopBar.test.tsx:35`)
7+
* = 宽度上限只出现一次且无裸 640 / 裸 860,自带阳性 + 阴性对照。
8+
*
9+
* ⚠️ 仪器局限:jsdom **不做布局** ⇒ 判的是「谁决定宽」而非「宽多少像素」;两个子面板被替身化。
10+
* 真正的像素证据只有 T14 的探针能给 —— `ClassroomPage` 至今**零自动化覆盖**(批 0-C2 实测)。
11+
*/
12+
import { readFileSync } from "node:fs";
13+
import { dirname, join } from "node:path";
14+
import { fileURLToPath } from "node:url";
15+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
16+
import { afterEach, describe, expect, it, vi } from "vitest";
17+
import { columnSpec } from "../shell/columnRegistry";
18+
import type { Note } from "../types";
19+
20+
// 子面板替身(同时让用例不依赖 Tauri IPC)。vi.mock 会被提升到 import 之上。
21+
vi.mock("./LiveActivityPanel", () => ({ default: () => <div data-testid="stub-live" /> }));
22+
vi.mock("./ProfileDetector", () => ({ default: () => <div data-testid="stub-profile" /> }));
23+
24+
import ClassroomRightPane from "./ClassroomRightPane";
25+
26+
const HERE = dirname(fileURLToPath(import.meta.url));
27+
const SRC = readFileSync(join(HERE, "ClassroomRightPane.tsx"), "utf8");
28+
/** 只留代码:剥块注释(含 JSX 注释)与整行 `//` 注释 —— 注释里提到宽度上限不算犯规、也不算数 */
29+
const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
30+
31+
const note: Note = { id: 1, title: "课堂笔记", content: "正文", source: "manual", tags: "[]", pin: 0, group_id: null, created_at: 0, updated_at: 0 };
32+
const base = {
33+
liveActive: false,
34+
stopping: false,
35+
fusionActive: false,
36+
liveSessionId: null,
37+
lastNote: null,
38+
selectedWindow: null,
39+
fusedSessionId: null,
40+
onDismissFused: vi.fn(),
41+
onProfileChange: vi.fn(),
42+
};
43+
44+
afterEach(cleanup);
45+
46+
/** 带内联宽度上限的元素(= 候选的「宽度决定点」) */
47+
function capped(container: HTMLElement): HTMLElement[] {
48+
return Array.from(container.querySelectorAll<HTMLElement>("*")).filter((el) => el.style.maxWidth !== "");
49+
}
50+
51+
describe("课堂右栏统一 wrapper(规格 §6.2)", () => {
52+
it("① 恰好一个宽度决定点,且它是三个分支的共同祖先", () => {
53+
const { container } = render(<ClassroomRightPane {...base} fusedSessionId={7} />);
54+
const hit = capped(container);
55+
expect(hit, `带宽度上限的元素应恰好 1 个,实测 ${hit.length}`).toHaveLength(1);
56+
for (const t of ["✅ 融合完成", "使用说明"]) {
57+
expect(hit[0].contains(screen.getByText(t)), `${t} 不在 wrapper 内`).toBe(true);
58+
}
59+
expect(hit[0].contains(screen.getByTestId("stub-profile"))).toBe(true);
60+
});
61+
62+
it("① 结果态预览也在同一个 wrapper 里(它此前没有上限,是「全宽」那一档)", () => {
63+
const { container } = render(<ClassroomRightPane {...base} lastNote={note} />);
64+
const hit = capped(container);
65+
expect(hit).toHaveLength(1);
66+
expect(hit[0].contains(screen.getByText(note.title))).toBe(true);
67+
});
68+
69+
it("① 采集态由活动面板独占(内容 wrapper 只在非采集态介入)", () => {
70+
const { container } = render(<ClassroomRightPane {...base} liveActive liveSessionId={3} />);
71+
expect(screen.getByTestId("stub-live")).toBeTruthy();
72+
expect(capped(container)).toHaveLength(0);
73+
});
74+
75+
it("② 渲染出的上限 === 注册表 settings-main 的默认宽(数字只有一份)", () => {
76+
const { container } = render(<ClassroomRightPane {...base} />);
77+
expect(capped(container)[0].style.maxWidth).toBe(`${columnSpec("settings-main").default}px`);
78+
});
79+
80+
it("① 融合卡的行为不变(wrapper 只改宽,不改事件语义)", () => {
81+
const onOpenSessions = vi.fn();
82+
const onDismissFused = vi.fn();
83+
render(
84+
<ClassroomRightPane {...base} fusedSessionId={7} onOpenSessions={onOpenSessions} onDismissFused={onDismissFused} />,
85+
);
86+
fireEvent.click(screen.getByText("查看时间轴 →"));
87+
expect(onOpenSessions).toHaveBeenCalledWith(7);
88+
fireEvent.click(screen.getByTitle("关闭提示"));
89+
expect(onDismissFused).toHaveBeenCalledTimes(1);
90+
});
91+
92+
it("③ 剥注释后:宽度上限只出现一次(V2 口径)", () => {
93+
expect([...CODE.matchAll(/maxWidth/g)]).toHaveLength(1);
94+
// 仪器自检:剥注释确实起了作用,且剥完不是空文件(否则 ③ 是空判据)
95+
expect(SRC.length).toBeGreaterThan(CODE.length);
96+
expect(CODE).toContain("export default function ClassroomRightPane");
97+
});
98+
99+
it("③ 值来自具名常量,且该常量取自注册表(不是裸数字)", () => {
100+
const init = CODE.match(/const\s+PANE_BODY_MAX\s*=\s*([^;\n]+)/);
101+
expect(init?.[1]?.trim()).toBe('columnSpec("settings-main").default');
102+
expect(/maxWidth:\s*PANE_BODY_MAX\b/.test(CODE), "宽度决定点必须消费该常量").toBe(true);
103+
});
104+
105+
it("③ 代码里不再有裸 640 / 裸 860(数字只从注册表来)", () => {
106+
expect(/(?<![A-Za-z0-9_])640(?![0-9])/.test(CODE), "仍硬编码 640").toBe(false);
107+
expect(/(?<![A-Za-z0-9_])860(?![0-9])/.test(CODE), "860 又被抄了一份(应从注册表取)").toBe(false);
108+
});
109+
110+
it("③ 仪器自检:上面的计数与裸数字正则会命中已知样本(否则那几条是空判据)", () => {
111+
const probe = "const a = { maxWidth: 640, width: 860 };";
112+
expect([...probe.matchAll(/maxWidth/g)]).toHaveLength(1);
113+
expect(/(?<![A-Za-z0-9_])640(?![0-9])/.test(probe)).toBe(true);
114+
expect(/(?<![A-Za-z0-9_])860(?![0-9])/.test(probe)).toBe(true);
115+
// 阴性对照:「640」是更长数字的前缀时不算命中(防子串误判)
116+
expect(/(?<![A-Za-z0-9_])640(?![0-9])/.test("width: 6401")).toBe(false);
117+
});
118+
});

‎app/src/components/ClassroomRightPane.tsx‎

Lines changed: 87 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,28 @@
77
* 视频类型档案卡(选定窗口后出现)+ 融合完成直达卡片(A4)。
88
* @ai-context: 纯展示装配(无自有事件监听——活动态由 LiveActivityPanel 自监听),
99
* 状态全部由父组件注入。
10+
* @ai-context: 批 3(规格 §6.2「统一 wrapper,消灭 640/全宽两档跳动」):非采集态内容
11+
* 的宽度上限**只由 wrapper 一处**决定——此前三处分支各写一份 640,而结果态
12+
* 预览完全没有上限 ⇒ 状态切换时在 640 与全宽之间跳。
1013
*/
1114
import LiveActivityPanel from "./LiveActivityPanel";
1215
import ProfileDetector from "./ProfileDetector";
16+
import { columnSpec } from "../shell/columnRegistry";
1317
import type { Note, ProfileKind, WindowInfo } from "../types";
1418

1519
const btn: React.CSSProperties = { padding: "6px 12px", cursor: "pointer", fontSize: 13 };
1620
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
1721

22+
/**
23+
* 非采集态内容区的**唯一**宽度上限(规格 §6.2:右面板是 flex 列,「统一 wrapper」)。
24+
* Why 不取 `classroom-right`:注册表里它是 `default: 0` 的 flex 列(宽由容器决定,连 `max`
25+
* 也是 0)⇒ 取不到内容宽。这里改为引用注册表里**语义就是「居中阅读舒适宽」的那一行**
26+
* (`settings-main`,规格 §6.2 写「居中 860」)⇒ 这个数字全仓只有一份,改注册表即两边同步,
27+
* 本文件不复制字面量(重复登记的风险由此消除)。
28+
* 边界:只管非采集态内容;采集态由 `LiveActivityPanel` 独占整栏(宿主 `flex: 1` 未变)。
29+
*/
30+
const PANE_BODY_MAX = columnSpec("settings-main").default;
31+
1832
interface Props {
1933
/** 活动态(采集中/停止过渡/融合中——右栏由活动面板独占) */
2034
liveActive: boolean;
@@ -55,80 +69,83 @@ export default function ClassroomRightPane({
5569
/>
5670
) : (
5771
<div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
58-
{/* A4:融合完成直达卡片(停止后右侧顶部;一键跳会话页定位) */}
59-
{fusedSessionId && (
60-
<div style={{ padding: "12px 16px 0", maxWidth: 640 }}>
61-
<div style={{ display: "flex", alignItems: "center", gap: 8, border: "1px solid #99f6e4", background: "#f0fdfa", borderRadius: 8, padding: "8px 12px" }}>
62-
<span style={{ fontSize: 12, color: "#0f766e" }}>✅ 融合完成</span>
63-
<button
64-
onClick={() => onOpenSessions?.(fusedSessionId)}
65-
style={{ ...btn, marginLeft: "auto", background: "#0d9488", color: "#fff", border: "none", borderRadius: 6 }}
66-
>
67-
查看时间轴 →
68-
</button>
69-
<button
70-
onClick={onDismissFused}
71-
style={{ ...btn, border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff" }}
72-
title="关闭提示"
73-
>
74-
✕
75-
</button>
72+
{/* 批 3(规格 §6.2):唯一的内容宽决定点(值取自注册表,见上) */}
73+
<div style={{ maxWidth: PANE_BODY_MAX, margin: "0 auto" }}>
74+
{/* A4:融合完成直达卡片(停止后右侧顶部;一键跳会话页定位) */}
75+
{fusedSessionId && (
76+
<div style={{ padding: "12px 16px 0" }}>
77+
<div style={{ display: "flex", alignItems: "center", gap: 8, border: "1px solid #99f6e4", background: "#f0fdfa", borderRadius: 8, padding: "8px 12px" }}>
78+
<span style={{ fontSize: 12, color: "#0f766e" }}>✅ 融合完成</span>
79+
<button
80+
onClick={() => onOpenSessions?.(fusedSessionId)}
81+
style={{ ...btn, marginLeft: "auto", background: "#0d9488", color: "#fff", border: "none", borderRadius: 6 }}
82+
>
83+
查看时间轴 →
84+
</button>
85+
<button
86+
onClick={onDismissFused}
87+
style={{ ...btn, border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff" }}
88+
title="关闭提示"
89+
>
90+
✕
91+
</button>
92+
</div>
7693
</div>
94+
)}
95+
{/* 视频类型档案(v0.5.0 M1:REQ-043 混合检测——自动候选 + 用户确认 + 记忆偏好;
96+
右侧配置区:选定窗口后出现,未选窗口自动隐藏) */}
97+
<div style={{ padding: "12px 16px 0" }}>
98+
<ProfileDetector
99+
windowTitle={selectedWindow?.title ?? null}
100+
onProfileChange={onProfileChange}
101+
/>
77102
</div>
78-
)}
79-
{/* 视频类型档案(v0.5.0 M1:REQ-043 混合检测——自动候选 + 用户确认 + 记忆偏好;
80-
右侧配置区:选定窗口后出现,未选窗口自动隐藏) */}
81-
<div style={{ padding: "12px 16px 0", maxWidth: 640 }}>
82-
<ProfileDetector
83-
windowTitle={selectedWindow?.title ?? null}
84-
onProfileChange={onProfileChange}
85-
/>
86-
</div>
87-
{lastNote ? (
88-
/* 结果态:最近生成的笔记预览 */
89-
<div style={{ padding: 16 }}>
90-
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
91-
<h2 style={{ margin: 0, fontSize: 16 }}>{lastNote.title}</h2>
92-
<span style={{ fontSize: 11, color: "#6b7280" }}>
93-
{/* REQ-277:元信息不带裸 # 数字(id 仅内部引用) */}
94-
{lastNote.source} · 更新于 {new Date(lastNote.updated_at * 1000).toLocaleString()}
95-
</span>
103+
{lastNote ? (
104+
/* 结果态:最近生成的笔记预览 */
105+
<div style={{ padding: 16 }}>
106+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
107+
<h2 style={{ margin: 0, fontSize: 16 }}>{lastNote.title}</h2>
108+
<span style={{ fontSize: 11, color: "#6b7280" }}>
109+
{/* REQ-277:元信息不带裸 # 数字(id 仅内部引用) */}
110+
{lastNote.source} · 更新于 {new Date(lastNote.updated_at * 1000).toLocaleString()}
111+
</span>
112+
</div>
113+
<pre
114+
style={{
115+
background: "#f9fafb",
116+
border: "1px solid #e5e7eb",
117+
borderRadius: 8,
118+
padding: 14,
119+
whiteSpace: "pre-wrap",
120+
fontSize: 13,
121+
lineHeight: 1.7,
122+
}}
123+
>
124+
{lastNote.content}
125+
</pre>
126+
<p style={{ fontSize: 12, color: "#6b7280" }}>已保存至笔记,可在「笔记」页继续编辑与检索。</p>
96127
</div>
97-
<pre
98-
style={{
99-
background: "#f9fafb",
100-
border: "1px solid #e5e7eb",
101-
borderRadius: 8,
102-
padding: 14,
103-
whiteSpace: "pre-wrap",
104-
fontSize: 13,
105-
lineHeight: 1.7,
106-
}}
107-
>
108-
{lastNote.content}
109-
</pre>
110-
<p style={{ fontSize: 12, color: "#6b7280" }}>已保存至笔记,可在「笔记」页继续编辑与检索。</p>
111-
</div>
112-
) : (
113-
/* 空态:当前配置说明书(参考原项目 IdleGuidePanel) */
114-
<div style={{ padding: "16px 24px 24px", maxWidth: 640 }}>
115-
<h2 style={{ fontSize: 18 }}>使用说明</h2>
116-
<ol style={{ fontSize: 13, lineHeight: 2, color: "#374151" }}>
117-
<li><strong>选择目标窗口/进程</strong>:自动推荐疑似网课/视频窗口(B站/播放器/浏览器),也可展开全部手动选择——将作为笔记标题与实时捕获目标;无法采集的窗口(最小化/悬浮层)与站点首页(如 B站首页)已自动过滤</li>
118-
<li><strong>实时捕获</strong>:系统声音 + 屏幕字幕 + 流式转写(Zipformer)边看边记,右侧实时显示转写与画面图片,停止后到「会话」页查看时间轴并可一键转笔记</li>
119-
<li><strong>添加学习素材</strong>:音频文件(WAV,本地 SenseVoice 转写)与图片(本地 PP-OCRv6 识别)</li>
120-
<li><strong>一键提取</strong>:转写 + OCR → 本地拼接为 Markdown 笔记 → 自动保存</li>
121-
</ol>
122-
<div style={{ ...panel, marginTop: 16, fontSize: 12, color: "#6b7280", lineHeight: 1.9 }}>
123-
<div><strong>当前配置</strong></div>
124-
<div>目标窗口:{selectedWindow ? `${selectedWindow.title}(${selectedWindow.processName || "未知进程"})` : "未选择(实时捕获将抓全屏)"}</div>
125-
<div>流式转写:sherpa-onnx Zipformer(实时字幕,需模型就绪)</div>
126-
<div>转写引擎:sherpa-onnx SenseVoice(本地,已就绪)</div>
127-
<div>OCR 引擎:oar-ocr PP-OCRv6(本地,首次使用自动下载模型)</div>
128-
<div>数据主权:全部本地处理,内容不出本机</div>
128+
) : (
129+
/* 空态:当前配置说明书(参考原项目 IdleGuidePanel) */
130+
<div style={{ padding: "16px 24px 24px" }}>
131+
<h2 style={{ fontSize: 18 }}>使用说明</h2>
132+
<ol style={{ fontSize: 13, lineHeight: 2, color: "#374151" }}>
133+
<li><strong>选择目标窗口/进程</strong>:自动推荐疑似网课/视频窗口(B站/播放器/浏览器),也可展开全部手动选择——将作为笔记标题与实时捕获目标;无法采集的窗口(最小化/悬浮层)与站点首页(如 B站首页)已自动过滤</li>
134+
<li><strong>实时捕获</strong>:系统声音 + 屏幕字幕 + 流式转写(Zipformer)边看边记,右侧实时显示转写与画面图片,停止后到「会话」页查看时间轴并可一键转笔记</li>
135+
<li><strong>添加学习素材</strong>:音频文件(WAV,本地 SenseVoice 转写)与图片(本地 PP-OCRv6 识别)</li>
136+
<li><strong>一键提取</strong>:转写 + OCR → 本地拼接为 Markdown 笔记 → 自动保存</li>
137+
</ol>
138+
<div style={{ ...panel, marginTop: 16, fontSize: 12, color: "#6b7280", lineHeight: 1.9 }}>
139+
<div><strong>当前配置</strong></div>
140+
<div>目标窗口:{selectedWindow ? `${selectedWindow.title}(${selectedWindow.processName || "未知进程"})` : "未选择(实时捕获将抓全屏)"}</div>
141+
<div>流式转写:sherpa-onnx Zipformer(实时字幕,需模型就绪)</div>
142+
<div>转写引擎:sherpa-onnx SenseVoice(本地,已就绪)</div>
143+
<div>OCR 引擎:oar-ocr PP-OCRv6(本地,首次使用自动下载模型)</div>
144+
<div>数据主权:全部本地处理,内容不出本机</div>
145+
</div>
129146
</div>
130-
</div>
131-
)}
147+
)}
148+
</div>
132149
</div>
133150
)}
134151
</div>

0 commit comments

Comments
 (0)