/** * 代码块内媒体标签检测 — 单元测试 * * 覆盖 isInsideCodeBlock / hasMediaTags / findFirstClosedMediaTag / splitByMediaTags * 确保围栏代码块(```)内的媒体标签不会被误识别。 * * 运行方式: npx tsx tests/code-block-media-tag.test.ts */ import { isInsideCodeBlock, hasMediaTags, findFirstClosedMediaTag, splitByMediaTags, } from "../src/utils/media-send.js"; import assert from "node:assert"; let passed = 0; let failed = 0; const failedTests: string[] = []; // ============ 辅助 ============ function group(title: string) { console.log(`\n=== ${title} ===`); } /** isInsideCodeBlock 断言 */ function testInside(name: string, text: string, position: number, expected: boolean) { try { const result = isInsideCodeBlock(text, position); assert.strictEqual(result, expected); console.log(` ✅ ${name}`); passed++; } catch (e: any) { console.log(` ❌ ${name}`); console.log(` 文本: ${JSON.stringify(text)}`); console.log(` 位置: ${position}`); console.log(` 期望: ${expected}`); console.log(` 实际: ${!expected}`); failed++; failedTests.push(name); } } /** hasMediaTags 断言 */ function testHas(name: string, text: string, expected: boolean) { try { const result = hasMediaTags(text); assert.strictEqual(result, expected); console.log(` ✅ ${name}`); passed++; } catch (e: any) { console.log(` ❌ ${name}`); console.log(` 文本: ${JSON.stringify(text)}`); console.log(` 期望: ${expected}`); console.log(` 实际: ${!expected}`); failed++; failedTests.push(name); } } /** findFirstClosedMediaTag 断言(简化:检查返回值是否为 null,以及如果非 null 检查 mediaPath) */ function testFind( name: string, text: string, expected: { found: false } | { found: true; mediaPath: string; tagName: string }, ) { try { const result = findFirstClosedMediaTag(text); if (!expected.found) { assert.strictEqual(result, null, `期望返回 null`); } else { assert.notStrictEqual(result, null, `期望找到标签`); assert.strictEqual(result!.mediaPath, expected.mediaPath, `mediaPath 不匹配`); assert.strictEqual(result!.tagName, expected.tagName, `tagName 不匹配`); } console.log(` ✅ ${name}`); passed++; } catch (e: any) { console.log(` ❌ ${name}`); console.log(` 文本: ${JSON.stringify(text)}`); console.log(` 期望: ${JSON.stringify(expected)}`); console.log(` 实际: ${JSON.stringify(findFirstClosedMediaTag(text))}`); failed++; failedTests.push(name); } } /** splitByMediaTags 断言 */ function testSplit( name: string, text: string, expectedHasMedia: boolean, expectedQueueLength?: number, ) { try { const result = splitByMediaTags(text); assert.strictEqual(result.hasMediaTags, expectedHasMedia, `hasMediaTags 不匹配`); if (expectedQueueLength !== undefined) { assert.strictEqual(result.mediaQueue.length, expectedQueueLength, `mediaQueue 长度不匹配`); } console.log(` ✅ ${name}`); passed++; } catch (e: any) { console.log(` ❌ ${name}`); console.log(` 文本: ${JSON.stringify(text)}`); console.log(` 期望: hasMedia=${expectedHasMedia}, queueLen=${expectedQueueLength}`); const r = splitByMediaTags(text); console.log(` 实际: hasMedia=${r.hasMediaTags}, queueLen=${r.mediaQueue.length}`); failed++; failedTests.push(name); } } // ====================================================================== // Part 1: isInsideCodeBlock — 围栏代码块 // ====================================================================== group("1.1 围栏代码块 — 基本场景"); { const text = "前文\n```\n/a.png\n```\n后文"; const tagPos = text.indexOf(""); testInside("围栏内的标签应返回 true", text, tagPos, true); testInside("围栏前的文本应返回 false", text, 0, false); testInside("围栏后的文本应返回 false", text, text.indexOf("后文"), false); } group("1.2 围栏代码块 — 带语言标识"); { const text = "说明\n```html\n/path/to/img.png\n```\n结束"; const tagPos = text.indexOf(""); testInside("```html 围栏内的标签应返回 true", text, tagPos, true); } { const text = "说明\n```xml\n/path/to/voice.mp3\n```\n结束"; const tagPos = text.indexOf(""); testInside("```xml 围栏内的标签应返回 true", text, tagPos, true); } { const text = "说明\n```javascript\nconsole.log('/x.png');\n```\n结束"; const tagPos = text.indexOf(""); testInside("```javascript 围栏内的标签应返回 true", text, tagPos, true); } group("1.3 围栏代码块 — 多个围栏"); { const text = "前\n```\ncode1\n```\n中间\n```\n/b.png\n```\n后"; const tagPos = text.indexOf(""); const midPos = text.indexOf("中间"); testInside("第二个围栏内的标签应返回 true", text, tagPos, true); testInside("两个围栏之间的文本应返回 false", text, midPos, false); } group("1.4 围栏代码块 — 未闭合围栏"); { const text = "前文\n```\n/c.png\n剩余内容"; const tagPos = text.indexOf(""); testInside("未闭合围栏内应返回 true", text, tagPos, true); testInside("未闭合围栏后面的内容也应返回 true", text, text.indexOf("剩余内容"), true); testInside("未闭合围栏前的文本应返回 false", text, 0, false); } group("1.5 围栏代码块 — 四反引号围栏"); { const text = "前文\n````\n/d.png\n````\n后文"; const tagPos = text.indexOf(""); testInside("四反引号围栏内标签应返回 true", text, tagPos, true); testInside("四反引号围栏后文本应返回 false", text, text.indexOf("后文"), false); } group("1.6 围栏代码块 — 四反引号包裹三反引号"); { const text = '前文\n````\n```\n/e.png\n```\n````\n后文'; const tagPos = text.indexOf(""); testInside("四反引号内嵌三反引号,标签应在外层围栏内返回 true", text, tagPos, true); testInside("四反引号围栏后文本应返回 false", text, text.indexOf("后文"), false); } group("1.7 围栏代码块 — 围栏开始/结束位置边界"); { const text = "```\ncontent\n```"; // ``` 本身的第一个字符 testInside("围栏开头 ` 处应返回 true", text, 0, true); // ``` 内容区 testInside("围栏内容 content 应返回 true", text, text.indexOf("content"), true); // 闭合 ``` 的位置(仍在围栏范围内) testInside("闭合 ``` 位置仍在围栏范围内应返回 true", text, text.lastIndexOf("```"), true); } // ====================================================================== // Part 2: isInsideCodeBlock — 特殊/边界场景 // ====================================================================== group("2.1 空文本"); testInside("空文本位置0应返回 false", "", 0, false); group("2.2 纯代码块无媒体标签"); { const text = "```\nconst x = 1;\n```"; testInside("代码块内普通代码应返回 true", text, text.indexOf("const"), true); } group("2.3 代码块前后紧邻标签"); { const text = "/before.png\n```\ncode\n```\n/after.png"; const beforePos = text.indexOf("/before.png"); const afterPos = text.indexOf("/after.png"); testInside("围栏前的标签应返回 false", text, beforePos, false); testInside("围栏后的标签应返回 false", text, afterPos, false); } group("2.4 连续多个围栏代码块"); { const text = "```\na\n```\n```\nb\n```\n```\n/c.png\n```"; const tagPos = text.indexOf(""); testInside("第三个围栏内标签应返回 true", text, tagPos, true); } group("2.5 围栏内含反引号"); { const text = "```\n这里有 `反引号` 和 /x.png\n```"; const tagPos = text.indexOf(""); testInside("围栏内的反引号不影响判断,标签应返回 true", text, tagPos, true); } group("2.6 空围栏代码块"); { const text = "前\n```\n```\n/a.png"; const tagPos = text.indexOf(""); testInside("空围栏后的标签应返回 false", text, tagPos, false); } group("2.7 围栏代码块 — 只有开始标记,文本结尾"); { const text = "```"; testInside("只有 ``` 的文本,位置0应返回 true", text, 0, true); } // ====================================================================== // Part 4: hasMediaTags — 代码块过滤 // ====================================================================== group("4.1 hasMediaTags — 纯文本无标签"); testHas("纯文本应返回 false", "这只是普通文本", false); testHas("空字符串应返回 false", "", false); group("4.2 hasMediaTags — 代码块外标签"); testHas("普通 qqimg 标签", "/path/img.png", true); testHas("普通 qqvoice 标签", "/path/voice.mp3", true); testHas("普通 qqvideo 标签", "/path/video.mp4", true); testHas("普通 qqfile 标签", "/path/file.txt", true); testHas("普通 qqmedia 标签", "/path/media.bin", true); group("4.3 hasMediaTags — 围栏代码块内标签"); testHas( "围栏内 qqimg 应返回 false", "前文\n```\n/path/img.png\n```\n后文", false, ); testHas( "围栏内 qqvoice 应返回 false", "示例代码:\n```\n/tmp/voice.mp3\n```", false, ); testHas( "```html 围栏内标签应返回 false", "代码示例\n```html\n/path/img.png\n```", false, ); testHas( "未闭合围栏内标签应返回 false", "代码:\n```\n/path/img.png", false, ); group("4.4 hasMediaTags — 混合场景(代码块内+代码块外)"); testHas( "代码块内+代码块外各一个标签应返回 true", "```\n/in-code.png\n```\n/outside.png", true, ); group("4.5 hasMediaTags — 多个标签全部在代码块内"); testHas( "多个标签全在围栏内应返回 false", "```\n/a.png\n/b.mp3\n/c.txt\n```", false, ); group("4.6 hasMediaTags — 多个标签全部在代码块外"); testHas( "多个标签全在外面应返回 true", "看这个 /a.png/b.mp3", true, ); // ====================================================================== // Part 5: findFirstClosedMediaTag — 代码块过滤 // ====================================================================== group("5.1 findFirstClosedMediaTag — 无标签"); testFind("纯文本无标签", "普通文本", { found: false }); testFind("空字符串", "", { found: false }); group("5.2 findFirstClosedMediaTag — 代码块外标签"); testFind( "普通 qqimg 标签", "前文 /path/img.png 后文", { found: true, mediaPath: "/path/img.png", tagName: "qqimg" }, ); testFind( "qqvoice 标签", "/voice.mp3", { found: true, mediaPath: "/voice.mp3", tagName: "qqvoice" }, ); testFind( "qqvideo 标签", "/video.mp4", { found: true, mediaPath: "/video.mp4", tagName: "qqvideo" }, ); testFind( "qqfile 标签", "/file.txt", { found: true, mediaPath: "/file.txt", tagName: "qqfile" }, ); testFind( "qqmedia 标签", "/media.bin", { found: true, mediaPath: "/media.bin", tagName: "qqmedia" }, ); group("5.3 findFirstClosedMediaTag — 围栏代码块内标签"); testFind( "围栏内标签应返回 null", "前文\n```\n/in-code.png\n```\n后文", { found: false }, ); testFind( "未闭合围栏内标签应返回 null", "代码\n```\n/path.png", { found: false }, ); testFind( "带语言标识的围栏内标签应返回 null", "```html\n/img.png\n```", { found: false }, ); group("5.4 findFirstClosedMediaTag — 跳过代码块内,找到代码块外的"); testFind( "第一个在围栏内,第二个在围栏外", "```\n/inside.png\n```\n/outside.png", { found: true, mediaPath: "/outside.png", tagName: "qqimg" }, ); testFind( "多个围栏代码块内标签后接一个外部标签", "```\n/a.png\n/b.mp3\n```\n/d.mp4", { found: true, mediaPath: "/d.mp4", tagName: "qqvideo" }, ); group("5.5 findFirstClosedMediaTag — 所有标签都在代码块内"); testFind( "围栏全包裹", "```\n/a.png\n```", { found: false }, ); group("5.6 findFirstClosedMediaTag — textBefore 正确性"); { const text = "前面一些文字\n```\n/skip.png\n```\n中间文字 /real.png 后"; const result = findFirstClosedMediaTag(text); try { assert.notStrictEqual(result, null); assert.strictEqual(result!.tagName, "qqimg"); assert.strictEqual(result!.mediaPath, "/real.png"); // textBefore 应该包含围栏代码块在内的所有前面的文本 assert.ok(result!.textBefore.includes("前面一些文字")); assert.ok(result!.textBefore.includes("```")); assert.ok(result!.textBefore.includes("中间文字")); console.log(` ✅ textBefore 包含代码块和中间文字`); passed++; } catch (e: any) { console.log(` ❌ textBefore 包含代码块和中间文字`); console.log(` 实际 textBefore: ${JSON.stringify(result?.textBefore)}`); failed++; failedTests.push("textBefore 包含代码块和中间文字"); } } // ====================================================================== // Part 6: splitByMediaTags — 代码块过滤 // ====================================================================== group("6.1 splitByMediaTags — 无标签"); testSplit("纯文本", "没有标签的文本", false, 0); testSplit("空字符串", "", false, 0); group("6.2 splitByMediaTags — 代码块外标签"); testSplit("一个外部标签", "/a.png", true, 1); testSplit( "两个外部标签(queue含标签间文本)", "/a.png 中间 /b.mp3", true, 3, // 2个媒体 + 1个中间文本 ); group("6.3 splitByMediaTags — 全部在代码块内"); testSplit( "围栏内一个标签", "前文\n```\n/a.png\n```\n后文", false, 0, ); testSplit( "围栏内多个标签", "```\n/a.png\n/b.mp3\n```", false, 0, ); group("6.4 splitByMediaTags — 混合:部分在代码块内,部分在外"); testSplit( "围栏内1个+外面1个", "```\n/inside.png\n```\n/outside.png", true, 1, ); group("6.5 splitByMediaTags — textBeforeFirstTag / textAfterLastTag"); { const text = "前面文字\n```\n/skip.png\n```\n中间 /real.png 后面"; const result = splitByMediaTags(text); try { assert.strictEqual(result.hasMediaTags, true); assert.strictEqual(result.mediaQueue.length, 1); assert.ok(result.textBeforeFirstTag.includes("前面文字"), "textBeforeFirstTag 应包含前面文字"); assert.ok(result.textBeforeFirstTag.includes("中间"), "textBeforeFirstTag 应包含中间文字"); assert.strictEqual(result.textAfterLastTag, "后面"); console.log(` ✅ split 的 textBefore/After 在代码块过滤后正确`); passed++; } catch (e: any) { console.log(` ❌ split 的 textBefore/After 在代码块过滤后正确`); console.log(` before: ${JSON.stringify(result.textBeforeFirstTag)}`); console.log(` after: ${JSON.stringify(result.textAfterLastTag)}`); console.log(` queue: ${JSON.stringify(result.mediaQueue)}`); failed++; failedTests.push("split 的 textBefore/After 在代码块过滤后正确"); } } // ====================================================================== // Part 7: 综合 / 真实场景测试 // ====================================================================== group("7.1 LLM 输出中示范用法(围栏包裹)"); { const text = `你好!下面是使用方法: \`\`\` /path/to/image.png \`\`\` 以上就是示例。`; testHas("LLM示范用法,围栏内不应视为标签", text, false); testFind("LLM示范用法find应返回null", text, { found: false }); testSplit("LLM示范用法split应无标签", text, false, 0); } group("7.2 LLM 输出中先示范再真正发送"); { const text = `你好!用法如下: \`\`\` /example/demo.png \`\`\` 下面我发送真正的图片: /real/photo.jpg`; testHas("示范+真实发送,应有标签", text, true); testFind( "示范+真实发送,find应跳过示范找到真实标签", text, { found: true, mediaPath: "/real/photo.jpg", tagName: "qqimg" }, ); testSplit("示范+真实发送,split只含1个媒体", text, true, 1); } group("7.3 多种标签混合在围栏内"); { const text = `示例代码: \`\`\` /img.png /voice.mp3 /video.mp4 /file.txt /media.bin \`\`\` 以上是所有支持的标签类型。`; testHas("所有标签类型在围栏内应返回 false", text, false); testFind("所有标签类型在围栏内find应返回 null", text, { found: false }); } group("7.4 围栏内标签夹杂普通HTML标签"); { const text = "```html\n
/img.png
\n```"; testHas("围栏内HTML混合媒体标签应返回 false", text, false); } group("7.5 代码块内标签有额外空白/换行"); { const text = "```\n /path/img.png \n```"; testHas("围栏内带空白的标签应返回 false", text, false); } group("7.6 连续围栏,每个都有标签"); { const text = "```\n/a.png\n```\n\n```\n/b.mp3\n```\n\n```\n/c.txt\n```"; testHas("多个围栏各有标签都应返回 false", text, false); testFind("多个围栏各有标签find应返回 null", text, { found: false }); testSplit("多个围栏各有标签split无标签", text, false, 0); } group("7.7 围栏代码块紧邻无换行"); { // 标准 Markdown 围栏需要在行首,这里测试紧凑格式 const text = "文本```\n/a.png\n```结尾"; // 注意:如果 ``` 不在行首,不算围栏,标签应该能被检测到 // 但我们的正则是 /^(`{3,})[^\n]*$/gm,^ 匹配行首,所以 "文本```" 不算围栏开始 testHas("```不在行首,标签应正常检测", text, true); } group("7.8 Windows 风格换行 CRLF"); { const text = "前文\r\n```\r\n/a.png\r\n```\r\n后文"; const tagPos = text.indexOf(""); // CRLF 情况下 ``` 是否在行首取决于正则的 m 标志 // /^(`{3,})[^\n]*$/gm 中 ^ 在 m 模式下匹配 \n 后,\r\n 中 \r 可能影响 // 但 ```\r 可能匹配为 `{3,}[^\n]*$ → ``` 后跟 \r testInside("CRLF围栏内标签检测", text, tagPos, true); } group("7.9 标签跨行(不闭合)在围栏外"); { // 媒体标签正则不匹配跨行标签,所以不会匹配到 const text = "\n/path/img.png\n"; testHas("跨行标签会被 normalize 压缩为单行,应返回 true", text, true); } group("7.10 大小写混合标签"); { const text = "```\n/a.png\n```"; testHas("围栏内大小写混合标签应返回 false", text, false); } { const text = "/a.png"; testHas("外部大小写混合标签应返回 true", text, true); } group("7.11 代码块后紧接多个外部标签"); { const text = "```\n示例\n```\n/a.png/b.mp3/c.txt"; testHas("代码块后多个连续标签应返回 true", text, true); testFind( "代码块后多个连续标签find应返回第一个", text, { found: true, mediaPath: "/a.png", tagName: "qqimg" }, ); testSplit("代码块后多个连续标签split应有3个", text, true, 3); } group("7.12 img 标签(别名)"); { const text = "```\n/a.png\n```"; testHas("围栏内 img 标签应返回 false", text, false); } { const text = "/a.png"; testHas("外部 img 标签应返回 true", text, true); testFind("外部 img 标签find应找到", text, { found: true, mediaPath: "/a.png", tagName: "img" }); } group("7.13 围栏内标签路径含特殊字符"); { const text = "```\n/路径/中文 图片(1).png\n```"; testHas("围栏内含中文路径的标签应返回 false", text, false); } // ====================================================================== // 结果汇总 // ====================================================================== console.log("\n" + "=".repeat(50)); console.log(`测试结果: ${passed} 通过, ${failed} 失败, 共 ${passed + failed} 个`); if (failedTests.length > 0) { console.log(`\n失败的测试用例:`); for (const name of failedTests) { console.log(` - ${name}`); } } console.log("=".repeat(50)); process.exit(failed > 0 ? 1 : 0);