From b6d7cfa28e5c890cf5ee32c7f1a6ebe0c1e1c1e9 Mon Sep 17 00:00:00 2001 From: FinleyGe Date: Thu, 6 Nov 2025 16:50:10 +0800 Subject: [PATCH 1/5] feat(docDiff): new tool doc diff --- .claude/CLAUDE.md | 9 +- modules/tool/packages/docDiff/DESIGN.md | 12 + modules/tool/packages/docDiff/README.md | 122 +++ modules/tool/packages/docDiff/bun.lock | 34 + modules/tool/packages/docDiff/config.ts | 61 ++ modules/tool/packages/docDiff/index.ts | 10 + modules/tool/packages/docDiff/logo.svg | 20 + modules/tool/packages/docDiff/package.json | 17 + modules/tool/packages/docDiff/src/index.ts | 703 ++++++++++++++++++ .../tool/packages/docDiff/test/index.test.ts | 425 +++++++++++ 10 files changed, 1412 insertions(+), 1 deletion(-) create mode 100644 modules/tool/packages/docDiff/DESIGN.md create mode 100644 modules/tool/packages/docDiff/README.md create mode 100644 modules/tool/packages/docDiff/bun.lock create mode 100644 modules/tool/packages/docDiff/config.ts create mode 100644 modules/tool/packages/docDiff/index.ts create mode 100644 modules/tool/packages/docDiff/logo.svg create mode 100644 modules/tool/packages/docDiff/package.json create mode 100644 modules/tool/packages/docDiff/src/index.ts create mode 100644 modules/tool/packages/docDiff/test/index.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index cd7fcf3e..ba441f79 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -180,8 +180,11 @@ bun run install:plugins bun run build:pkg ``` -### 3. 生产部署 +### 3. 测试和部署 ```bash +# 运行测试 +bun run test + # 构建生产版本 bun run build:runtime @@ -189,6 +192,10 @@ bun run build:runtime bun run start ``` +**⚠️ 重要**: +- 测试命令使用 `bun run test` 而不是 `bun test` +- 测试环境使用 Vitest 运行器,支持 mock 和覆盖率 + ## 快速适配指南 ### 1. 工具适配步骤 diff --git a/modules/tool/packages/docDiff/DESIGN.md b/modules/tool/packages/docDiff/DESIGN.md new file mode 100644 index 00000000..f08a22e2 --- /dev/null +++ b/modules/tool/packages/docDiff/DESIGN.md @@ -0,0 +1,12 @@ +# DocDiff 系统工具 + +输入:两个 markdown 格式的纯文本内容 +输出:一个 html 文件 + +提供一个 html 模版文件, 对两个文本内容进行对比,逐段对比,分析其中的差异,高亮显示差异。 + +描述工具集名称,以及子工具,子工具需要包含 ID,名字,哪些输入输出,例如: + +--- + +下面由 AI 生成完整的设计文档 diff --git a/modules/tool/packages/docDiff/README.md b/modules/tool/packages/docDiff/README.md new file mode 100644 index 00000000..453f2f4c --- /dev/null +++ b/modules/tool/packages/docDiff/README.md @@ -0,0 +1,122 @@ +# DocDiff 文档对比工具 + +一个用于对比两个 Markdown 文档差异的 FastGPT 工具,生成可视化的 HTML 对比报告。 + +## 功能特性 + +- 📝 **段落级对比**: 逐段分析文档差异 +- 🎨 **可视化报告**: 生成美观的 HTML 对比报告 +- 📊 **统计信息**: 显示新增、删除、修改、未修改的段落数量 +- 🔒 **安全防护**: 自动转义 HTML 字符,防止 XSS 攻击 +- 📱 **响应式设计**: 支持移动端浏览 +- ⚡ **高性能**: 基于相似度算法的智能对比 + +## 使用方法 + +### 输入参数 + +- **originalText** (必需): 原始的 Markdown 文档内容 +- **modifiedText** (必需): 修改后的 Markdown 文档内容 +- **title** (可选): 对比报告的标题,默认为"文档对比报告" + +### 输出 + +返回一个完整的 HTML 文件,包含: +- 文档标题和生成时间 +- 变更统计信息 +- 逐段对比详情 +- 美观的样式设计 + +## 对比算法 + +工具使用智能的相似度算法来识别文档变化: + +1. **段落分割**: 将文档按空行分割为段落 +2. **相似度计算**: 基于字符级别的相似度匹配 +3. **变更分类**: + - 未修改 (>80% 相似度) + - 修改 (30%-80% 相似度) + - 新增 (仅在修改后文档中存在) + - 删除 (仅在原始文档中存在) +4. **最佳匹配**: 寻找最相似的段落配对 + +## 使用示例 + +```typescript +const result = await tool({ + originalText: `# 项目文档 + +## 功能介绍 +这是一个测试功能。 + +## 安装步骤 +1. 下载代码 +2. 运行安装命令`, + + modifiedText: `# 项目文档 + +## 功能介绍 +这是一个更新的测试功能。 + +## 安装步骤 +1. 下载代码 +2. 运行安装命令 +3. 配置环境变量`, + + title: "项目文档变更记录" +}); + +console.log(result.html); // 输出 HTML 对比报告 +``` + +## HTML 报告特性 + +- 🎨 **现代化设计**: 渐变背景、卡片布局、阴影效果 +- 📈 **统计仪表板**: 直观显示各类变更数量 +- 🏷️ **状态标签**: 不同颜色标识变更类型 +- 📝 **代码高亮**: 使用等宽字体显示内容 +- 🔄 **对比视图**: 并排显示原始和修改内容 + +## 技术实现 + +- **TypeScript**: 类型安全的实现 +- **Zod 验证**: 输入参数验证 +- **纯函数设计**: 无副作用的处理逻辑 +- **HTML 安全**: 自动转义特殊字符 + +## 适用场景 + +- 📄 **文档版本对比**: 比较文档的不同版本 +- 📝 **内容审核**: 查看内容修改记录 +- 🔄 **变更追踪**: 跟踪文档变化 +- 📊 **差异分析**: 分析文档变更模式 + +## 开发和测试 + +```bash +# 安装依赖 +bun install + +# 运行测试 +bun test + +# 类型检查 +bun run tsc --noEmit + +# 构建 +bun run build +``` + +## 测试覆盖 + +- ✅ 输入验证测试 +- ✅ 文档对比逻辑测试 +- ✅ HTML 输出质量测试 +- ✅ 边界情况测试 +- ✅ 安全性测试 + +## 版本信息 + +- **当前版本**: 1.0.0 +- **FastGPT 兼容性**: 完全兼容 +- **依赖**: 仅依赖 Zod,无外部 API 调用 \ No newline at end of file diff --git a/modules/tool/packages/docDiff/bun.lock b/modules/tool/packages/docDiff/bun.lock new file mode 100644 index 00000000..ecfad29b --- /dev/null +++ b/modules/tool/packages/docDiff/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@fastgpt-plugins/tool-doc-diff", + "dependencies": { + "zod": "^3.25.76", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + } +} diff --git a/modules/tool/packages/docDiff/config.ts b/modules/tool/packages/docDiff/config.ts new file mode 100644 index 00000000..0f61ecdc --- /dev/null +++ b/modules/tool/packages/docDiff/config.ts @@ -0,0 +1,61 @@ +import { defineTool } from '@tool/type'; +import { FlowNodeInputTypeEnum, WorkflowIOValueTypeEnum } from '@tool/type/fastgpt'; +import { ToolTagEnum } from '@tool/type/tags'; + +export default defineTool({ + name: { + 'zh-CN': '文档对比工具', + en: 'DocDiff' + }, + tags: [ToolTagEnum.enum.tools], + description: { + 'zh-CN': '对比两个 Markdown 文档的差异,生成可视化的 HTML 对比报告', + en: 'Compare differences between two Markdown documents and generate visual HTML comparison report' + }, + toolDescription: + 'A tool that compares two markdown documents and generates a visual HTML diff report showing differences section by section', + + versionList: [ + { + value: '1.0.0', + description: 'Initial version', + inputs: [ + { + key: 'originalText', + label: '原始文档', + description: '原始的 Markdown 格式文档内容', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], + toolDescription: 'The original markdown document content to compare' + }, + { + key: 'modifiedText', + label: '修改后文档', + description: '修改后的 Markdown 格式文档内容', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], + toolDescription: 'The modified markdown document content to compare' + }, + { + key: 'title', + label: '对比报告标题', + description: '生成的 HTML 对比报告的标题', + required: false, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + defaultValue: '文档对比报告' + } + ], + outputs: [ + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'htmlUrl', + label: 'HTML 对比报告连接', + description: '生成的 HTML 对比报告的访问连接' + } + ] + } + ] +}); diff --git a/modules/tool/packages/docDiff/index.ts b/modules/tool/packages/docDiff/index.ts new file mode 100644 index 00000000..d698ed48 --- /dev/null +++ b/modules/tool/packages/docDiff/index.ts @@ -0,0 +1,10 @@ +import config from './config'; +import { InputType, OutputType, tool as toolCb } from './src'; +import { exportTool } from '@tool/utils/tool'; + +export default exportTool({ + toolCb, + InputType, + OutputType, + config +}); diff --git a/modules/tool/packages/docDiff/logo.svg b/modules/tool/packages/docDiff/logo.svg new file mode 100644 index 00000000..1e4b9a3d --- /dev/null +++ b/modules/tool/packages/docDiff/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/modules/tool/packages/docDiff/package.json b/modules/tool/packages/docDiff/package.json new file mode 100644 index 00000000..c8e25eea --- /dev/null +++ b/modules/tool/packages/docDiff/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fastgpt-plugins/tool-doc-diff", + "module": "index.ts", + "type": "module", + "scripts": { + "build": "bun ../../../../scripts/build.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "zod": "^3.25.76" + } +} diff --git a/modules/tool/packages/docDiff/src/index.ts b/modules/tool/packages/docDiff/src/index.ts new file mode 100644 index 00000000..45f4fedb --- /dev/null +++ b/modules/tool/packages/docDiff/src/index.ts @@ -0,0 +1,703 @@ +import { uploadFile } from '@tool/utils/uploadFile'; +import { z } from 'zod'; + +export const InputType = z.object({ + originalText: z.string().min(1, '原始文档内容不能为空'), + modifiedText: z.string().min(1, '修改后文档内容不能为空'), + title: z.string().optional().default('文档对比报告') +}); + +export const OutputType = z.object({ + htmlUrl: z.string() +}); + +// 输入类型:title 是可选的 +export type InputType = { + originalText: string; + modifiedText: string; + title?: string; +}; + +// 输出类型 +export type OutputType = { + htmlUrl: string; +}; + +// 定义段落差异类型 +type DiffType = 'unchanged' | 'added' | 'removed' | 'modified'; + +interface ParagraphDiff { + type: DiffType; + original?: string; + modified?: string; + lineNumber?: number; +} + +// 分割文档为行 +function splitIntoLines(text: string): string[] { + return text.split('\n'); +} + +// 计算两个段的相似度 +function calculateSimilarity(text1: string, text2: string): number { + // 如果两行都为空,则完全相同 + if (!text1.trim() && !text2.trim()) return 1.0; + if (!text1.trim() || !text2.trim()) return 0.0; + + const chars1 = text1.replace(/\s/g, '').toLowerCase(); + const chars2 = text2.replace(/\s/g, '').toLowerCase(); + + const longer = chars1.length > chars2.length ? chars1 : chars2; + const shorter = chars1.length > chars2.length ? chars2 : chars1; + + if (longer.length === 0) return 1.0; + + const matches = Array.from(longer).filter( + (char, index) => index < shorter.length && char === shorter[index] + ).length; + + return matches / longer.length; +} + +// 对比两个文档 +function compareDocuments(originalText: string, modifiedText: string): ParagraphDiff[] { + const originalLines = splitIntoLines(originalText); + const modifiedLines = splitIntoLines(modifiedText); + + const diffs: ParagraphDiff[] = []; + let origIndex = 0; + let modIndex = 0; + + while (origIndex < originalLines.length || modIndex < modifiedLines.length) { + const originalLine = originalLines[origIndex]; + const modifiedLine = modifiedLines[modIndex]; + + // 如果其中一个文档已经处理完毕 + if (origIndex >= originalLines.length) { + diffs.push({ + type: 'added', + modified: modifiedLine, + lineNumber: modIndex + 1 + }); + modIndex++; + continue; + } + + if (modIndex >= modifiedLines.length) { + diffs.push({ + type: 'removed', + original: originalLine, + lineNumber: origIndex + 1 + }); + origIndex++; + continue; + } + + // 计算行相似度 + const similarity = calculateSimilarity(originalLine, modifiedLine); + + if (similarity > 0.8) { + // 基本相同的行 + diffs.push({ + type: 'unchanged', + original: originalLine, + modified: modifiedLine, + lineNumber: origIndex + 1 + }); + origIndex++; + modIndex++; + } else if (similarity > 0.1) { + // 修改的行 - 降低阈值,让更多变化被识别为修改 + diffs.push({ + type: 'modified', + original: originalLine, + modified: modifiedLine, + lineNumber: origIndex + 1 + }); + origIndex++; + modIndex++; + } else { + // 寻找最佳匹配 + let bestMatchIndex = -1; + let bestSimilarity = 0; + + for (let i = 0; i < Math.min(3, modifiedLines.length - modIndex); i++) { + const candidateSimilarity = calculateSimilarity(originalLine, modifiedLines[modIndex + i]); + if (candidateSimilarity > bestSimilarity) { + bestSimilarity = candidateSimilarity; + bestMatchIndex = i; + } + } + + if (bestSimilarity > 0.5) { + // 找到匹配,插入添加的行 + for (let i = 0; i < bestMatchIndex; i++) { + diffs.push({ + type: 'added', + modified: modifiedLines[modIndex + i], + lineNumber: origIndex + 1 + }); + } + diffs.push({ + type: 'modified', + original: originalLine, + modified: modifiedLines[modIndex + bestMatchIndex], + lineNumber: origIndex + 1 + }); + modIndex += bestMatchIndex + 1; + origIndex++; + } else { + // 没有找到匹配,可能是删除或添加 + diffs.push({ + type: 'removed', + original: originalLine, + lineNumber: origIndex + 1 + }); + origIndex++; + } + } + } + + return diffs; +} + +// 生成 HTML 报告 +function generateHtmlReport(diffs: ParagraphDiff[], title: string): string { + const timestamp = new Date().toLocaleString('zh-CN'); + + const css = ` + + `; + + const js = ` + + `; + + // 计算统计信息 + const stats = diffs.reduce( + (acc, diff) => { + acc[diff.type]++; + return acc; + }, + { unchanged: 0, added: 0, removed: 0, modified: 0 } + ); + + // 生成左侧原始内容 + const originalContent = diffs + .map((diff, index) => { + let content = ''; + const typeClass = diff.type; + + if (diff.type === 'added') { + // 新增的内容在左侧显示为空占位符 + content = '
'; + } else if (diff.type === 'removed' || diff.type === 'modified') { + content = `
${escapeHtml(diff.original || '')}
`; + } else { + content = `
${escapeHtml(diff.original || '')}
`; + } + + return ` +
+ ${diff.type !== 'unchanged' ? `${diff.type === 'added' ? '新增' : diff.type === 'removed' ? '删除' : '修改'}` : ''} + ${content} +
+ `; + }) + .join(''); + + // 生成右侧修改后内容 + const modifiedContent = diffs + .map((diff, index) => { + let content = ''; + const typeClass = diff.type; + + if (diff.type === 'removed') { + // 删除的内容在右侧显示为空占位符 + content = '
'; + } else if (diff.type === 'added' || diff.type === 'modified') { + content = `
${escapeHtml(diff.modified || '')}
`; + } else { + content = `
${escapeHtml(diff.modified || '')}
`; + } + + return ` +
+ ${diff.type !== 'unchanged' ? `${diff.type === 'added' ? '新增' : diff.type === 'removed' ? '删除' : '修改'}` : ''} + ${content} +
+ `; + }) + .join(''); + + const html = ` + + + + + ${title} + ${css} + + +
+

${title}

+
生成时间: ${timestamp}
+ +
+
+
${stats.added}
+
新增
+
+
+
${stats.removed}
+
删除
+
+
+
${stats.modified}
+
修改
+
+
+
${stats.unchanged}
+
未修改
+
+
+ + +
+ +
+
+
📄 原始文档
+ ${originalContent} +
+
+
📝 修改后文档
+ ${modifiedContent} +
+
+ + ${js} + + + `; + + return html; +} + +// HTML 转义函数 +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + + return text.replace(/[&<>"']/g, (m) => map[m]); +} + +export async function tool(input: InputType): Promise { + // Zod 验证 + const validated = InputType.parse(input); + + const diffs = compareDocuments(validated.originalText, validated.modifiedText); + const html = generateHtmlReport(diffs, validated.title); + + const { accessUrl } = await uploadFile({ + buffer: Buffer.from(html, 'utf-8'), + defaultFilename: 'docdiff_report.html', + contentType: 'text/html' + }); + + return { + htmlUrl: accessUrl + }; +} diff --git a/modules/tool/packages/docDiff/test/index.test.ts b/modules/tool/packages/docDiff/test/index.test.ts new file mode 100644 index 00000000..0080d9fe --- /dev/null +++ b/modules/tool/packages/docDiff/test/index.test.ts @@ -0,0 +1,425 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { tool } from '../src'; + +// Mock the uploadFile function +vi.mock('@tool/utils/uploadFile', () => ({ + uploadFile: vi.fn() +})); + +import { uploadFile } from '@tool/utils/uploadFile'; + +const mockUploadFile = vi.mocked(uploadFile); + +describe('DocDiff Tool Tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Input Validation', () => { + it('should reject empty original text', async () => { + await expect( + tool({ + originalText: '', + modifiedText: 'Some content' + }) + ).rejects.toThrow('原始文档内容不能为空'); + }); + + it('should reject empty modified text', async () => { + await expect( + tool({ + originalText: 'Some content', + modifiedText: '' + }) + ).rejects.toThrow('修改后文档内容不能为空'); + }); + + it('should accept valid inputs and return HTML URL', async () => { + const mockUrl = 'https://example.com/test-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + const result = await tool({ + originalText: '# Test Document\n\nThis is a test.', + modifiedText: '# Test Document\n\nThis is a modified test.' + }); + + expect(result).toHaveProperty('htmlUrl'); + expect(typeof result.htmlUrl).toBe('string'); + expect(result.htmlUrl).toBe(mockUrl); + expect(uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + buffer: expect.any(Object), + defaultFilename: 'docdiff_report.html', + contentType: 'text/html' + }) + ); + }); + + it('should accept valid inputs with custom title', async () => { + const mockUrl = 'https://example.com/custom-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + const result = await tool({ + originalText: '# Test Document\n\nThis is a test.', + modifiedText: '# Test Document\n\nThis is a modified test.', + title: '自定义对比报告' + }); + + expect(result.htmlUrl).toBe(mockUrl); + expect(uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + defaultFilename: 'docdiff_report.html' + }) + ); + }); + }); + + describe('Document Comparison Logic', () => { + it('should handle identical documents', async () => { + const mockUrl = 'https://example.com/identical-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + const content = '# Test Document\n\nThis is a test.\n\nAnother paragraph.'; + const result = await tool({ + originalText: content, + modifiedText: content + }); + + expect(result.htmlUrl).toBe(mockUrl); + + // Verify the uploaded HTML contains expected content + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain('未修改'); + expect(htmlContent).toContain('📄 原始文档'); + expect(htmlContent).toContain('📝 修改后文档'); + expect(htmlContent).toContain('3'); // unchanged count + }); + + it('should detect added paragraphs', async () => { + const mockUrl = 'https://example.com/added-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + const original = '# Original Document\n\nFirst paragraph.'; + const modified = + '# Original Document\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph.'; + const result = await tool({ + originalText: original, + modifiedText: modified + }); + + expect(result.htmlUrl).toBe(mockUrl); + + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain('新增'); + expect(htmlContent).toContain('Second paragraph.'); + expect(htmlContent).toContain('Third paragraph.'); + }); + + it('should detect removed paragraphs', async () => { + const mockUrl = 'https://example.com/removed-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + const original = + '# Original Document\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph.'; + const modified = '# Original Document\n\nFirst paragraph.'; + const result = await tool({ + originalText: original, + modifiedText: modified + }); + + expect(result.htmlUrl).toBe(mockUrl); + + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain('删除'); + expect(htmlContent).toContain('Second paragraph.'); + expect(htmlContent).toContain('Third paragraph.'); + }); + + it('should detect modified paragraphs', async () => { + const mockUrl = 'https://example.com/modified-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + const original = '# Test Document\n\nThis is the original text.'; + const modified = '# Test Document\n\nThis is the modified text.'; + const result = await tool({ + originalText: original, + modifiedText: modified + }); + + expect(result.htmlUrl).toBe(mockUrl); + + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain('修改'); + expect(htmlContent).toContain('original text'); + expect(htmlContent).toContain('modified text'); + }); + }); + + describe('HTML Structure and Features', () => { + it('should generate two-column layout HTML', async () => { + const mockUrl = 'https://example.com/column-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + await tool({ + originalText: '# Test\n\nContent.', + modifiedText: '# Test\n\nModified content.' + }); + + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain(''); + expect(htmlContent).toContain('📄 原始文档'); + expect(htmlContent).toContain('📝 修改后文档'); + expect(htmlContent).toContain('content-container'); + expect(htmlContent).toContain('column'); + }); + + it('should include navigation controls', async () => { + const mockUrl = 'https://example.com/nav-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + await tool({ + originalText: '# Test\n\nContent.', + modifiedText: '# Test\n\nModified content.' + }); + + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain('navigation'); + expect(htmlContent).toContain('上一处'); + expect(htmlContent).toContain('下一处'); + expect(htmlContent).toContain('previousChange'); + expect(htmlContent).toContain('nextChange'); + }); + + it('should include JavaScript for navigation functionality', async () => { + const mockUrl = 'https://example.com/js-report.html'; + vi.mocked(uploadFile).mockResolvedValue({ + accessUrl: mockUrl, + originalFilename: 'docdiff_report.html', + contentType: 'text/html', + size: 1000, + uploadTime: new Date(), + objectName: 'test-object' + }); + + await tool({ + originalText: '# Test\n\nContent.', + modifiedText: '# Test\n\nModified content.' + }); + + const uploadCall = vi.mocked(uploadFile).mock.calls[0][0]; + const htmlContent = Buffer.isBuffer(uploadCall.buffer) + ? uploadCall.buffer.toString('utf-8') + : Buffer.from(uploadCall.buffer || '').toString('utf-8'); + + expect(htmlContent).toContain('