|
| 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("<script>alert(1)</script>")); |
| 130 | + expect(out).not.toContain("<script"); |
| 131 | + }); |
| 132 | + it("`<img onerror>` 载荷同样只落文本(事件属性注入面为零)", async () => { |
| 133 | + expect(await previewHtml("<img src=x onerror=alert(1)>")).toBe(P("<img src=x onerror=alert(1)>")); |
| 134 | + }); |
| 135 | + it("`&` 被转义、引号在文本节点里字面量往返(DOM 串口径;属性口径见下方图片行)", async () => { |
| 136 | + expect(await previewHtml(`a & b " c ' d`)).toBe(P(`a & 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("- ", { 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("- ", { 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("- \n- "); |
| 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("- ", { baseUrl: "/base" })).toBe(IMG("", "a")); |
| 173 | + expect(await previewHtml("- ", { dataDir: "/data" })).toBe(IMG("", "b")); |
| 174 | + expect(cfArgs()).toEqual([]); |
| 175 | + }); |
| 176 | + it("多图行:每张本地图各 1 次 convertFileSrc、外链 0 次(调用数与图数对齐)", async () => { |
| 177 | + const md = "- \n- \n- "; |
| 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(" - ", { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", "a")); |
| 184 | + expect(await previewHtml("\t- ", { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", "a")); |
| 185 | + }); |
| 186 | + it("🔴 图片行尾多一个空格 ⇒ 整行匹配失败、退化成列表行(T17 最易做坏的退化形态)", async () => { |
| 187 | + const out = await previewHtml("-  ", { baseUrl: "/base" }); |
| 188 | + expect(out).toBe(LI(" ")); |
| 189 | + expect(cfArgs()).toEqual([]); |
| 190 | + }); |
| 191 | + it("图片正则的两个非匹配边界:src 含 `)`(`[^)]*` 硬边界)与 `- !x` ⇒ 都退化成列表行", async () => { |
| 192 | + expect(await previewHtml("- .png)", { baseUrl: "/base" })).toBe(LI(".png)")); |
| 193 | + expect(await previewHtml("- !x")).toBe(LI("!x")); |
| 194 | + }); |
| 195 | + it("alt/src 属性按不可信输入转义(`\" onerror=\"` 逃不出属性);空 alt 仍产出 alt=\"\"", async () => { |
| 196 | + expect(await previewHtml(`- `, { baseUrl: "/base" })).toBe(IMG("CF(/base/q.png)", "a" onerror="x")); |
| 197 | + expect(await previewHtml(`- `, { baseUrl: "/base" })).toBe(IMG('CF(/base/q"onerror="x.png)', "")); |
| 198 | + expect(await previewHtml("- ", { 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