Skip to content

Commit 6509e04

Browse files
committed
test(notes): 补 NotePreviewView 行渲染的表征测试
1 parent 8af217f commit 6509e04

1 file changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context 批 7 T16 —— `NotePreviewView.tsx:40 renderMarkdown` 的**表征测试**(T17 迁移前的回归网)。
4+
* Why:T15 的变异体 M1(h2 字号 15 → 14px)**全仓 vitest 全绿**(§C38.2 实测)⇒ 这条链今天无覆盖;
5+
* T17 要迁走它的实现(§C10.1)⇒ 迁移前先补网(§C9.3)。口径:**只钉住今天实际的行为**(含不喜欢的
6+
* 边角),**不判断它是否正确**(现行 bug 会被一起钉住)。做法:该函数非导出且本任务禁用生产改动 ⇒
7+
* 渲染真组件、读预览面 `innerHTML`(jsdom 往返后的 DOM 串 = 用户可见结果)。边界:① 只覆盖**行渲染器**
8+
* (组件级取数/状态/交互不在本文件);② 串里文本的 `"`/`'` 经解析往返为字面量,属性的 `"` 仍见 `"`。
9+
*/
10+
import { createElement } from "react";
11+
import { act, cleanup, render } from "@testing-library/react";
12+
import { afterEach, describe, expect, it, vi } from "vitest";
13+
14+
const { invokeMock, convertMock } = vi.hoisted(() => ({
15+
invokeMock: vi.fn(),
16+
convertMock: vi.fn((p: string): string => `CF(${p})`),
17+
}));
18+
// `invoke` 收 5 条挂载期命令;`convertFileSrc` 只被图片行的本地分支调用(断言其调用次数与实参)
19+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock, convertFileSrc: convertMock }));
20+
// AiRefineCard 挂 `listen("ai:task-update")` —— jsdom 下真模块抛 transformCallback 未定义(本仓既有范式)
21+
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) }));
22+
23+
import NotePreviewView from "./NotePreviewView";
24+
25+
type Opts = { baseUrl?: string; dataDir?: string };
26+
27+
/** `preview_session_note` 的最小合法载荷(只有 `markdown` 参与本判据) */
28+
function installInvoke(md: string, o: Opts = {}): void {
29+
const r = { title: "t", markdown: md, kept: [], ocr_points: [], ocr_screens: [],
30+
stats: { ui_junk: 0, duplicates: 0, fragments: 0, low_confidence: 0, ai_delete: 0 }, filtered: [], merged: [] };
31+
invokeMock.mockImplementation((cmd: string): Promise<unknown> => {
32+
switch (cmd) {
33+
case "preview_session_note": return Promise.resolve(r);
34+
case "text_filter_status": return Promise.resolve({ enabled: false, model: "m" });
35+
case "session_images_base_url": return Promise.resolve(o.baseUrl ?? "");
36+
case "app_data_dir": return Promise.resolve(o.dataDir ?? "");
37+
default: return Promise.resolve(null);
38+
}
39+
});
40+
}
41+
42+
/** 渲染真组件并冲净挂载期异步(`act` 后 DOM 已定;连跑 3 次逐字相同,实测见 T16 报告) */
43+
async function mount(md: string, o: Opts = {}): Promise<HTMLElement> {
44+
convertMock.mockClear();
45+
installInvoke(md, o);
46+
let container!: HTMLElement;
47+
await act(async () => { ({ container } = render(createElement(NotePreviewView, { sessionId: 1 }))); });
48+
return container;
49+
}
50+
51+
/** 预览面的 innerHTML = `renderMarkdown(preview.markdown, baseUrl, dataDir)` 的产物 */
52+
async function previewHtml(md: string, o: Opts = {}): Promise<string> {
53+
const surfaces = (await mount(md, o)).querySelectorAll(".ed-surface");
54+
if (surfaces.length !== 1) throw new Error(`预览面不唯一:${surfaces.length}`);
55+
return (surfaces[0] as HTMLElement).innerHTML;
56+
}
57+
58+
const cfArgs = (): string[] => convertMock.mock.calls.map((c) => c[0]);
59+
60+
// 逐字模板 = 今天实际产出的字面量(任一处字面量被改 ⇒ 对应用例必红)
61+
const P = (s: string): string => `<p style="font-size:13px;color:#374151;margin:4px 0">${s}</p>`;
62+
const LI = (s: string): string => `<div style="font-size:12px;color:#4b5563">• ${s}</div>`;
63+
const H3 = (s: string): string => `<h3 style="font-size:13px;margin:8px 0 4px;color:#0f766e">${s}</h3>`;
64+
const QUOTE = (s: string): string => `<div style="font-size:12px;color:#b45309;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:6px 10px;margin:6px 0">${s}</div>`;
65+
const IMG = (src: string, alt: string): string => `<img src="${src}" alt="${alt}" loading="lazy" style="max-width:260px;border-radius:6px;border:1px solid #e5e7eb;margin:4px 0">`;
66+
const CHIP = (ms: string, t: string): string => `<span data-ts-ms="${ms}" data-ts-chip="" style="color:#0d9488;border-bottom:1px dashed #14b8a6;background:#f0fdfa;border-radius:3px;padding:0 4px" title="⏱ ${t} 跳转到会话对应片段">⏱ ${t}</span>`;
67+
68+
describe("NotePreviewView.renderMarkdown 表征(钉住今天的行渲染行为,不判断对错)", () => {
69+
afterEach(() => { cleanup(); invokeMock.mockReset(); });
70+
71+
it("可达性:真树里预览面是唯一的 .ed-surface ⇒ 下面每条断言都落在 renderMarkdown 的产物上", async () => {
72+
const cls = Array.from((await mount("X")).querySelectorAll(".ed-surface")).map((e) => e.className);
73+
expect(cls).toEqual(["ed-surface ed-surface--surface ed-surface--r-panel ed-surface--bordered ed-surface--padded"]);
74+
});
75+
76+
it("普通段落:正文唯一出口是 <p> 且内联样式逐字(T17 迁移后 DOM 不得变)", async () => {
77+
expect(await previewHtml("Q05 普通段落")).toBe(P("Q05 普通段落"));
78+
});
79+
80+
it("h1 `# ` → <h2> 15px(T15 的 M1 把 15 改成 14 曾全仓绿 —— 本条就是那个洞)", async () => {
81+
expect(await previewHtml("# 标题一")).toBe(`<h2 style="font-size:15px;margin:10px 0 4px">标题一</h2>`);
82+
});
83+
it("h2 `## ` → <h3> 13px 青绿(第二级标题不是 h2:标签名与「级」错位是现状)", async () => {
84+
expect(await previewHtml("## 标题二")).toBe(H3("标题二"));
85+
});
86+
it("h3/h4 `### `/`#### ` 无专支 ⇒ 仍落 <p>(只有 h1/h2 两档存在)", async () => {
87+
expect(await previewHtml("### 标题三\n#### 标题四")).toBe(P("### 标题三") + P("#### 标题四"));
88+
});
89+
it("标题分支不判内容:`# ` 后为空仍产出空 <h2>;行首有空格则落 <p>(判定不做 trim)", async () => {
90+
expect(await previewHtml("# ")).toBe(`<h2 style="font-size:15px;margin:10px 0 4px"></h2>`);
91+
expect(await previewHtml(" # 缩进标题")).toBe(P(" # 缩进标题"));
92+
});
93+
94+
it("反引号(行内 `` ` `` 与围栏 ```)原样保留、不产 <code>/<pre>(该链不做行内/块级语法解析)", async () => {
95+
expect(await previewHtml("前 `code` 后")).toBe(P("前 `code` 后"));
96+
expect(await previewHtml("```js")).toBe(P("```js"));
97+
});
98+
it("表格行只当普通文本、不产 <table>(T17 迁移后不得顺手升级成表格)", async () => {
99+
expect(await previewHtml("| a | b |")).toBe(P("| a | b |"));
100+
});
101+
it("未闭合的 `**` 原样输出(无行内强调解析 ⇒ 不存在「半个标签」)", async () => {
102+
expect(await previewHtml("**未闭合")).toBe(P("**未闭合"));
103+
});
104+
105+
it("列表 `- x` → 圆点 div(12px 灰),不是 <ul>/<li>", async () => {
106+
expect(await previewHtml("- 列表项")).toBe(LI("列表项"));
107+
});
108+
it("列表分支不判内容:`- ` 空项仍产出 `• `;`-x` 缺空格则落 <p>", async () => {
109+
expect(await previewHtml("- ")).toBe(LI(""));
110+
expect(await previewHtml("-x")).toBe(P("-x"));
111+
});
112+
it("缩进列表 ` - x` ⇒ 落 <p> 且保留前导空格(列表判定不 trim,与图片行的 `\\s*` 不对称)", async () => {
113+
expect(await previewHtml(" - 缩进列表")).toBe(P(" - 缩进列表"));
114+
});
115+
it("引用 `> x` → 琥珀警示块 div(T17 迁移后样式串必须逐字不变)", async () => {
116+
expect(await previewHtml("> 会话异常")).toBe(QUOTE("会话异常"));
117+
});
118+
119+
it("markdown 链接不解析:`[t](url)` 原样落 <p>,全树 0 个 <a>(该链不产任何链接)", async () => {
120+
expect(await previewHtml("[t](https://e.com)")).toBe(P("[t](https://e.com)"));
121+
});
122+
it("`javascript:` 链接同样不产 <a>/href ⇒ 注入面无「可点协议」入口", async () => {
123+
const out = await previewHtml("[x](javascript:alert(1))");
124+
expect(out).toBe(P("[x](javascript:alert(1))"));
125+
expect(out).not.toContain("<a");
126+
});
127+
it("<script> 载荷被 escapeHtml ⇒ 产物无可解析标签(该链走 dangerouslySetInnerHTML)", async () => {
128+
const out = await previewHtml("<script>alert(1)</script>");
129+
expect(out).toBe(P("&lt;script&gt;alert(1)&lt;/script&gt;"));
130+
expect(out).not.toContain("<script");
131+
});
132+
it("`<img onerror>` 载荷同样只落文本(事件属性注入面为零)", async () => {
133+
expect(await previewHtml("<img src=x onerror=alert(1)>")).toBe(P("&lt;img src=x onerror=alert(1)&gt;"));
134+
});
135+
it("`&` 被转义、引号在文本节点里字面量往返(DOM 串口径;属性口径见下方图片行)", async () => {
136+
expect(await previewHtml(`a & b " c ' d`)).toBe(P(`a &amp; b " c ' d`));
137+
});
138+
139+
it("时间码芯片(T15 后的现行形态):保毫秒 data-ts-ms + data-ts-chip + title 逐字", async () => {
140+
expect(await previewHtml("[⏱ 00:05]([[ts:5000]]) 尾巴")).toBe(P(`${CHIP("5000", "00:05")} 尾巴`));
141+
});
142+
it("🔴 芯片「今天不可点」:<span> 属性表恰 4 项、不在 <a> 内(T17 会刻意改变这一点)", async () => {
143+
const c = await mount("[⏱ 00:05]([[ts:5000]])");
144+
const chip = c.querySelector("[data-ts-chip]") as HTMLElement;
145+
expect([chip.tagName, ...Array.from(chip.attributes).map((a) => a.name)]).toEqual(["SPAN", "data-ts-ms", "data-ts-chip", "style", "title"]);
146+
expect(c.querySelectorAll("a").length).toBe(0);
147+
});
148+
it("章节锚点包裹形态 `## x [[⏱ mm:ss]([[ts:ms]])]` 也替换成芯片(并吃掉外层方括号)", async () => {
149+
expect(await previewHtml("## 章节 [[⏱ 00:09]([[ts:9000]])]")).toBe(H3(`章节 ${CHIP("9000", "00:09")}`));
150+
});
151+
it("伪锚点(ms 为空 / ms 非数字 / 秒非两位)一律不替换 ⇒ 原样落 <p>", async () => {
152+
const a = "[⏱ 00:05]([[ts:]])", b = "[⏱ 00:05]([[ts:abc]])", c = "[⏱ 0:5]([[ts:5000]])";
153+
expect(await previewHtml(`${a}\n${b}\n${c}`)).toBe(P(a) + P(b) + P(c));
154+
});
155+
156+
it("图片行 `session-images/` 前缀 ⇒ dataDir 经 convertFileSrc(恰 1 次,实参逐字)", async () => {
157+
const out = await previewHtml("- ![a](session-images/q.png)", { dataDir: "/data", baseUrl: "/base" });
158+
expect(out).toBe(IMG("CF(/data/session-images/q.png)", "a"));
159+
expect(cfArgs()).toEqual(["/data/session-images/q.png"]);
160+
});
161+
it("图片行相对路径 ⇒ imageBaseUrl 经 convertFileSrc(session-images/ 不重复拼 baseUrl)", async () => {
162+
const out = await previewHtml("- ![b](q.png)", { dataDir: "/data", baseUrl: "/base" });
163+
expect(out).toBe(IMG("CF(/base/q.png)", "b"));
164+
expect(cfArgs()).toEqual(["/base/q.png"]);
165+
});
166+
it("http(s) 与 data: 外链直出、**不**经 convertFileSrc(唯一离开本机的渲染请求)", async () => {
167+
const out = await previewHtml("- ![c](https://evil.com/c.png)\n- ![d](data:image/png;base64,AAAA)");
168+
expect(out).toBe(IMG("https://evil.com/c.png", "c") + IMG("data:image/png;base64,AAAA", "d"));
169+
expect(cfArgs()).toEqual([]);
170+
});
171+
it("前缀基准为空 ⇒ src=\"\" 且 convertFileSrc 0 次(宁缺不拼半截路径;两条分支对称)", async () => {
172+
expect(await previewHtml("- ![a](session-images/q.png)", { baseUrl: "/base" })).toBe(IMG("", "a"));
173+
expect(await previewHtml("- ![b](q.png)", { dataDir: "/data" })).toBe(IMG("", "b"));
174+
expect(cfArgs()).toEqual([]);
175+
});
176+
it("多图行:每张本地图各 1 次 convertFileSrc、外链 0 次(调用数与图数对齐)", async () => {
177+
const md = "- ![a](session-images/a.png)\n- ![b](b.png)\n- ![c](https://x/c.png)";
178+
expect(await previewHtml(md, { dataDir: "/data", baseUrl: "/base" }))
179+
.toBe(IMG("CF(/data/session-images/a.png)", "a") + IMG("CF(/base/b.png)", "b") + IMG("https://x/c.png", "c"));
180+
expect(cfArgs()).toEqual(["/data/session-images/a.png", "/base/b.png"]);
181+
});
182+
it("图片行允许前导空白与制表符(`\\s*`)—— 与列表分支的不对称是现状", async () => {
183+
expect(await previewHtml(" - ![a](q.png)", { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", "a"));
184+
expect(await previewHtml("\t- ![a](q.png)", { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", "a"));
185+
});
186+
it("🔴 图片行尾多一个空格 ⇒ 整行匹配失败、退化成列表行(T17 最易做坏的退化形态)", async () => {
187+
const out = await previewHtml("- ![a](q.png) ", { baseUrl: "/base" });
188+
expect(out).toBe(LI("![a](q.png) "));
189+
expect(cfArgs()).toEqual([]);
190+
});
191+
it("图片正则的两个非匹配边界:src 含 `)`(`[^)]*` 硬边界)与 `- !x` ⇒ 都退化成列表行", async () => {
192+
expect(await previewHtml("- ![a](q).png)", { baseUrl: "/base" })).toBe(LI("![a](q).png)"));
193+
expect(await previewHtml("- !x")).toBe(LI("!x"));
194+
});
195+
it("alt/src 属性按不可信输入转义(`\" onerror=\"` 逃不出属性);空 alt 仍产出 alt=\"\"", async () => {
196+
expect(await previewHtml(`- ![a" onerror="x](q.png)`, { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", "a&quot; onerror=&quot;x"));
197+
expect(await previewHtml(`- ![](q"onerror="x.png)`, { baseUrl: "/base" })).toBe(IMG('CF(/base/q&quot;onerror=&quot;x.png)', ""));
198+
expect(await previewHtml("- ![](q.png)", { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", ""));
199+
});
200+
201+
it("空串与纯空白(含空行、制表符)⇒ 产物为空串,不是 <p></p>", async () => {
202+
expect(await previewHtml("")).toBe("");
203+
expect(await previewHtml(" \n\n\t\n ")).toBe("");
204+
});
205+
it("多行拼接**无分隔符**:相邻块首尾直接相接(空行只是空串,不产 <br>)", async () => {
206+
expect(await previewHtml("L1\n\nL2")).toBe(P("L1") + P("L2"));
207+
});
208+
it("极长单行(4000 字)完整落单个 <p>:不截断、不折成多段", async () => {
209+
const long = "Z" + "长".repeat(4000) + "Z";
210+
const out = await previewHtml(long);
211+
expect(out).toBe(P(long));
212+
expect(out.split("<p ").length).toBe(2);
213+
});
214+
});

0 commit comments

Comments
 (0)