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/lib/s3/controller.ts b/lib/s3/controller.ts index 86cf5d06..2aafce72 100644 --- a/lib/s3/controller.ts +++ b/lib/s3/controller.ts @@ -249,11 +249,11 @@ export class S3Service { // const fileId = this.generateFileId(); const prefix = input.prefix - ? input.prefix?.endsWith('/') - ? input.prefix - : input.prefix + '/' + ? !input.prefix?.endsWith('/') + ? input.prefix + '/' + : input.prefix : PluginBaseS3Prefix; - const objectName = `${prefix}${input.keepRawFilename ? '' : this.generateFileId() + '-'}${originalFilename}`; + const objectName = `${prefix}/${input.keepRawFilename ? '' : this.generateFileId() + '-'}${originalFilename}`; if (input.expireMins) { await MongoS3TTL.create({ bucketName: this.config.bucket, 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..8d4e68a2 --- /dev/null +++ b/modules/tool/packages/docDiff/README.md @@ -0,0 +1,127 @@ +# DocDiff 文档对比工具 + +一个用于对比两个 Markdown 文档差异的 FastGPT 工具,生成可视化的 HTML 对比报告。 + +## 功能特性 + +- 📝 **行级对比**: 逐行分析文档差异,精确识别变更 +- 🎨 **可视化报告**: 生成美观的双栏 HTML 对比报告 +- 🔄 **导航功能**: 支持按钮和键盘快捷键在变更间跳转 +- 📊 **统计信息**: 显示新增、删除、修改、未修改的行数 +- 🔒 **安全防护**: 自动转义 HTML 字符,防止 XSS 攻击 +- 📱 **响应式设计**: 支持移动端浏览 +- ⚡ **高性能**: 基于相似度算法的智能对比 + +## 使用方法 + +### 输入参数 + +- **originalText** (必需): 原始的 Markdown 文档内容 +- **modifiedText** (必需): 修改后的 Markdown 文档内容 +- **title** (可选): 对比报告的标题,默认为"文档对比报告" + +### 输出 + +返回 HTML 文件访问 URL,包含: +- 文档标题和生成时间 +- 变更统计信息 +- 双栏对比视图(左为原始文档,右为修改后文档) +- 导航控制按钮 +- 响应式设计支持 + +## 对比算法 + +工具使用智能的相似度算法来识别文档变化: + +1. **行级分割**: 将文档按换行符分割为行 +2. **相似度计算**: 基于字符级别的相似度匹配 +3. **变更分类**: + - 未修改 (>80% 相似度) + - 修改 (10%-80% 相似度) + - 新增 (仅在修改后文档中存在) + - 删除 (仅在原始文档中存在) +4. **最佳匹配**: 寻找最相似的行配对 + +## 使用示例 + +```typescript +const result = await tool({ + originalText: `# 项目文档 + +## 功能介绍 +这是一个测试功能。 + +## 安装步骤 +1. 下载代码 +2. 运行安装命令`, + + modifiedText: `# 项目文档 + +## 功能介绍 +这是一个更新的测试功能。 + +## 安装步骤 +1. 下载代码 +2. 运行安装命令 +3. 配置环境变量`, + + title: "项目文档变更记录" +}); + +console.log(result.htmlUrl); // 输出 HTML 对比报告 URL +``` + +## HTML 报告特性 + +- 🎨 **现代化设计**: 渐变背景、卡片布局、阴影效果 +- 📈 **统计仪表板**: 直观显示各类变更数量 +- 🔄 **双栏对比**: 左右并排显示原始和修改内容 +- 🎯 **导航功能**: 点击按钮或使用键盘快捷键在变更间跳转 +- 🏷️ **状态标签**: 不同颜色标识变更类型 +- ✨ **高亮效果**: 柔和的高亮提示,自动消失 +- 📝 **代码显示**: 使用等宽字体保持格式 +- 📱 **响应式布局**: 自适应不同屏幕尺寸 + +## 技术实现 + +- **TypeScript**: 类型安全的实现 +- **Zod 验证**: 输入参数验证 +- **纯函数设计**: 无副作用的处理逻辑 +- **HTML 安全**: 自动转义特殊字符 + +## 适用场景 + +- 📄 **文档版本对比**: 比较文档的不同版本 +- 📝 **内容审核**: 查看内容修改记录 +- 🔄 **变更追踪**: 跟踪文档变化 +- 📊 **差异分析**: 分析文档变更模式 + +## 开发和测试 + +```bash +# 安装依赖 +bun install + +# 运行测试 +bun test + +# 类型检查 +bun run tsc --noEmit + +# 构建 +bun run build +``` + +## 测试覆盖 + +- ✅ 输入验证测试 +- ✅ 文档对比逻辑测试 +- ✅ HTML 输出质量测试 +- ✅ 边界情况测试 +- ✅ 安全性测试 + +## 版本信息 + +- **当前版本**: 1.0.0 +- **FastGPT 兼容性**: 完全兼容 +- **依赖**: 仅依赖 Zod,无外部 API 调用 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..e6c6374b --- /dev/null +++ b/modules/tool/packages/docDiff/config.ts @@ -0,0 +1,67 @@ +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 对比报告的访问连接' + }, + { + valueType: WorkflowIOValueTypeEnum.arrayObject, + key: 'diffs', + label: '差异结果数组', + description: '过滤后的文档差异数组,包含新增、删除、修改的变更' + } + ] + } + ] +}); 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..8621c8aa --- /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..40f1d40d --- /dev/null +++ b/modules/tool/packages/docDiff/src/index.ts @@ -0,0 +1,1119 @@ +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(), + diffs: z.array( + z.object({ + type: z.enum(['added', 'removed', 'modified']), + original: z.string().optional(), + modified: z.string().optional(), + lineNumber: z.number() + }) + ) +}); + +// 输入类型:title 是可选的 +export type InputType = { + originalText: string; + modifiedText: string; + title?: string; +}; + +// 输出类型 +export type OutputType = { + htmlUrl: string; + diffs: { + type: 'added' | 'removed' | 'modified'; + original?: string; + modified?: string; + lineNumber: number; + }[]; +}; + +// 定义段落差异类型 +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 { + // 移除首尾空白字符 + const clean1 = text1.trim(); + const clean2 = text2.trim(); + + // 如果两行都为空,则完全相同 + if (!clean1 && !clean2) return 1.0; + if (!clean1 || !clean2) return 0.0; + + // 如果内容完全相同,直接返回1.0 + if (clean1 === clean2) return 1.0; + + // 移除所有空白字符并转换为小写进行比较 + const chars1 = clean1.replace(/\s+/g, '').toLowerCase(); + const chars2 = clean2.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; + let currentLineNumber = 1; // 使用连续的行号 + + while (origIndex < originalLines.length || modIndex < modifiedLines.length) { + const originalLine = originalLines[origIndex] || ''; + const modifiedLine = modifiedLines[modIndex] || ''; + + // 如果其中一个文档已经处理完毕 + if (origIndex >= originalLines.length) { + // 只有修改后的文档有内容,这是新增行 + if (modifiedLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'added', + modified: modifiedLine, + lineNumber: currentLineNumber++ + }); + } + modIndex++; + continue; + } + + if (modIndex >= modifiedLines.length) { + // 只有原始文档有内容,这是删除行 + if (originalLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'removed', + original: originalLine, + lineNumber: currentLineNumber++ + }); + } + origIndex++; + continue; + } + + // 如果两行都是空的,跳过 + if (!originalLine.trim() && !modifiedLine.trim()) { + origIndex++; + modIndex++; + continue; + } + + // 计算行相似度 + const similarity = calculateSimilarity(originalLine, modifiedLine); + + if (similarity > 0.9) { + // 完全相同的行,标记为unchanged + diffs.push({ + type: 'unchanged', + original: originalLine, + modified: modifiedLine, + lineNumber: currentLineNumber++ + }); + origIndex++; + modIndex++; + } else if (similarity > 0.8) { + // 修改的行 + diffs.push({ + type: 'modified', + original: originalLine, + modified: modifiedLine, + lineNumber: currentLineNumber++ + }); + 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.6) { + // 找到匹配,先添加新增的行 + for (let i = 0; i < bestMatchIndex; i++) { + const addedLine = modifiedLines[modIndex + i]; + if (addedLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'added', + modified: addedLine, + lineNumber: currentLineNumber++ + }); + } + } + + // 添加修改的行 + diffs.push({ + type: 'modified', + original: originalLine, + modified: modifiedLines[modIndex + bestMatchIndex], + lineNumber: currentLineNumber++ + }); + modIndex += bestMatchIndex + 1; + origIndex++; + } else { + // 没有找到匹配,可能是删除 + if (originalLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'removed', + original: originalLine, + lineNumber: currentLineNumber++ + }); + } + 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 = ''; + let badge = ''; + const typeClass = diff.type; + + if (diff.type === 'added') { + // 新增的内容在左侧显示为空占位符 + content = '
'; + } else if (diff.type === 'removed') { + content = `
${escapeHtml(diff.original || '')}
`; + badge = '删除'; + } else if (diff.type === 'modified') { + content = `
${escapeHtml(diff.original || '')}
`; + badge = '修改'; + } else { + content = `
${escapeHtml(diff.original || '')}
`; + } + + return ` +
+ ${badge} + ${content} +
+ `; + }) + .join(''); + + // 生成右侧修改后内容 + const modifiedContent = diffs + .map((diff, index) => { + let content = ''; + let badge = ''; + const typeClass = diff.type; + + if (diff.type === 'removed') { + // 删除的内容在右侧显示为空占位符 + content = '
'; + } else if (diff.type === 'added') { + content = `
${escapeHtml(diff.modified || '')}
`; + badge = '新增'; + } else if (diff.type === 'modified') { + content = `
${escapeHtml(diff.modified || '')}
`; + badge = '修改'; + } else { + content = `
${escapeHtml(diff.modified || '')}
`; + } + + return ` +
+ ${badge} + ${content} +
+ `; + }) + .join(''); + + const html = ` + + + + + ${title} + ${css} + + +
+
+
+

${title}

+
生成时间: ${timestamp}
+ +
+
+
${stats.added}
+
新增
+
+
+
${stats.removed}
+
删除
+
+
+
${stats.modified}
+
修改
+
+
+ + +
+
+ +
+
+
+ 原始文档 + +
+ ${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: z.infer) { + const diffs = compareDocuments(input.originalText, input.modifiedText); + const html = generateHtmlReport(diffs, input.title); + + const { accessUrl } = await uploadFile({ + buffer: Buffer.from(html, 'utf-8'), + defaultFilename: 'docdiff_report.html', + contentType: 'text/html' + }); + + // 过滤掉unchanged类型,只返回有变更的内容 + const filteredDiffs = diffs.filter((diff) => diff.type !== 'unchanged'); + + return { + htmlUrl: accessUrl, + diffs: filteredDiffs + }; +} 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..a66aad6d --- /dev/null +++ b/modules/tool/packages/docDiff/test/index.test.ts @@ -0,0 +1,438 @@ +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'; + +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', + title: '' + }) + ).rejects.toThrow('原始文档内容不能为空'); + }); + + it('should reject empty modified text', async () => { + await expect( + tool({ + originalText: 'Some content', + modifiedText: '', + title: '' + }) + ).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.', + title: 'Test Report' + }); + + 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, + title: 'Test Report' + }); + + 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, + title: 'Test Report' + }); + + 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, + title: 'Test Report' + }); + + 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, + title: 'Test Report' + }); + + 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.', + title: 'Test Report' + }); + + 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.', + title: 'Test Report' + }); + + 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.', + title: 'Test Report' + }); + + 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('