/** * Tests for render.ts — 代码块去围栏的纯逻辑(行渲染 + 原型补丁)。 * * Run with: node --test clients/pi/extensions/fenceless-code-block/render.test.ts * * 被测模块不 import pi / pi-tui(量度、折行、Markdown 类都是注入的),所以这里喂假实现: * ASCII = 1 列、CJK 全角 = 2 列(口径与 pi-tui 的 visibleWidth 一致);折行是朴素的按列硬切 * (输入不含 ANSI,正好等价于 wrapTextWithAnsi 在"没有可断词"情形下的结果);"Markdown 类" * 只是个带 theme 字段和 renderToken 的假类,用来验证补丁的接线(转发、幂等、读实例上的主题)。 * 真实的端到端链路(pi 自己的加载器 → pi 自己的渲染组件)在 `index.test.ts`。 */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { installFencelessCodeBlocks, renderCodeBlockLines } from "./render.ts"; const ESC = "\u001b"; const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); /** CJK 全角区间(够这几条用例用;口径与 pi-tui 的 visibleWidth 一致)。 */ const WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/; const charWidth = (ch: string): number => (WIDE.test(ch) ? 2 : 1); const measure = (text: string): number => [...stripAnsi(text)].reduce((sum, ch) => sum + charWidth(ch), 0); /** 朴素折行:按列硬切。输入不含 ANSI,够用。 */ const wrap = (text: string, width: number): string[] => { if (measure(text) <= width) return [text]; const pieces: string[] = []; let current = ""; let used = 0; for (const ch of text) { const w = charWidth(ch); if (used > 0 && used + w > width) { pieces.push(current); current = ""; used = 0; } current += ch; used += w; } pieces.push(current); return pieces; }; describe("renderCodeBlockLines", () => { it("围栏不参与:正文按缩进铺开", () => { assert.deepEqual(renderCodeBlockLines("a\nbb", 10, { measure, wrap }), [" a", " bb"]); }); it("行尾不补白:补白交给 pi 的 Markdown.render(),这里只保证不超过 width", () => { const lines = renderCodeBlockLines("a", 10, { measure, wrap }); assert.deepEqual(lines, [" a"], "末尾不该有我们加的空格"); for (const line of lines) assert.ok(measure(line) <= 10); }); it("不注入任何自己的转义(没有底色、没有额外的 SGR)", () => { for (const line of renderCodeBlockLines("a\nbb", 10, { measure, wrap })) { assert.equal(line.includes(ESC), false, "没有高亮时返回的应该是纯文本"); } }); it("长行自己先折,每行都不超过 width(外层不会再有第二次折行)", () => { const lines = renderCodeBlockLines("xxxxxxxxxx", 6, { measure, wrap }); assert.deepEqual(lines, [" xxxx", " xxxx", " xx"]); for (const line of lines) assert.ok(measure(line) <= 6); }); it("缩进放不下时丢缩进,行宽仍然不超过 width", () => { const lines = renderCodeBlockLines("abcdef", 3, { measure, wrap, indent: " " }); assert.deepEqual(lines, ["abc", "def"]); for (const line of lines) assert.ok(measure(line) <= 3); }); it("空代码块 / 高亮返回空数组都至少留一行", () => { assert.deepEqual(renderCodeBlockLines("", 4, { measure, wrap }), [" "], "空代码块留一行,只带缩进"); assert.deepEqual( renderCodeBlockLines("a", 4, { measure, wrap, highlight: () => [] }), [" a"], "高亮没产出时退回纯文本,不能一行都不画", ); }); it("语言标签与正文一起交给高亮函数", () => { const seen: Array<[string, string | undefined]> = []; const lines = renderCodeBlockLines("const x = 1;", 40, { measure, wrap, lang: "js", highlight: (code, lang) => { seen.push([code, lang]); return [`${code}`]; }, }); assert.deepEqual(seen, [["const x = 1;", "js"]]); assert.deepEqual(lines, [" const x = 1;"], "着色行原样保留,不再包一层"); }); it("没有高亮函数时用兜底着色(等价官方渲染器的 else 分支)", () => { assert.deepEqual(renderCodeBlockLines("a", 8, { measure, wrap, fallbackStyle: (text) => `[${text}]` }), [" [a]"]); }); it("CJK 按两列计:折行按列,不按字符", () => { assert.deepEqual(renderCodeBlockLines("中文中文", 10, { measure, wrap }), [" 中文中文"]); // 可用宽度 6 列时装不下 8 列正文:切成 6 + 2。 assert.deepEqual(renderCodeBlockLines("中文中文", 8, { measure, wrap }), [" 中文中", " 文"]); }); }); interface FakeCalls { forwarded: unknown[][]; } /** 假 Markdown 类:renderToken 记下参数并返回可辨认的文本。 */ function makeFakeMarkdown(theme: Record = {}) { const calls: FakeCalls = { forwarded: [] }; class FakeMarkdown { theme: Record; constructor() { this.theme = theme; } renderToken(token: { type?: string }, width: number, nextType?: string, styleContext?: unknown): string[] { calls.forwarded.push([token, width, nextType, styleContext]); return [`<>`]; } } return { FakeMarkdown, calls }; } describe("installFencelessCodeBlocks", () => { it("只接管 code token:其它 token 连参数一起原样转给内置渲染器", () => { const { FakeMarkdown, calls } = makeFakeMarkdown(); assert.equal(installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }), true); const md = new FakeMarkdown(); const context = { marker: 1 }; assert.deepEqual(md.renderToken({ type: "paragraph" }, 12, "space", context), ["<>"]); assert.deepEqual(calls.forwarded, [[{ type: "paragraph" }, 12, "space", context]]); }); it("code token 渲染成无围栏的行,并把 lang 交给实例主题上的高亮", () => { const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " ", highlightCode: (code: string, lang?: string) => [`<${lang}>${code}`], }); installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }); assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "const x = 1;", lang: "js" }, 20), [ " const x = 1;", ]); }); it("采信实例 theme 上的 codeBlockIndent", () => { const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " " }); installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }); assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 6), [" a"]); }); it("装了两次只包一层(/reload 幂等)", () => { const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " " }); assert.equal(installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }), true); assert.equal(installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }), false); assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 4), [" a"]); }); it("nextType 不是 space 时补一个空行", () => { const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " " }); installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }); assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 3, "space"), [" a"]); assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 3, "paragraph"), [" a", ""]); }); it("原型没有 renderToken 时什么都不做、不抛", () => { const Markdown = { prototype: {} as Record }; assert.equal(installFencelessCodeBlocks({ Markdown, measure, wrap }), false); assert.equal(Object.keys(Markdown.prototype).length, 0); }); });