import { describe, it, expect } from 'vitest' import { computeDiff, type DiffRow } from './diffEngine' import { buildHtmlReport, buildTxtReport, buildMarkdownReport, buildReport, buildSegments, plainSummary, plainOptions, REPORT_EXT } from './report' const ctx = { leftName: 'a.txt', rightName: 'b.txt', leftEncoding: 'UTF-8', rightEncoding: 'UTF-8' } /** 便捷构造行集:u=unchanged, a=added, r=removed, m=modified */ function rowsOf(kinds: string[]): DiffRow[] { let n = 0 return kinds.map((k) => { const id = `t${n++}` const changed = k !== 'u' const left = { lineNo: k === 'a' ? null : n, text: k === 'a' ? null : `L${n}`, segs: null } const right = { lineNo: k === 'r' ? null : n, text: k === 'r' ? null : `R${n}`, segs: null } return { id, rowKind: (k === 'a' ? 'added' : k === 'r' ? 'removed' : k === 'm' ? 'modified' : 'unchanged') as DiffRow['rowKind'], isChanged: changed, left, right } }) } describe('buildSegments - 差异分块', () => { it('空行集返回空段', () => { expect(buildSegments([])).toEqual([]) }) it('无任何变更时单段全展示且无省略', () => { const segs = buildSegments(rowsOf(['u', 'u', 'u', 'u'])) expect(segs).toEqual([{ start: 0, end: 3, changes: 0, skippedBefore: 0, skippedAfter: 0 }]) }) it('单处居中变更保留前后 3 行上下文,其余省略', () => { const rows = rowsOf(['u', 'u', 'u', 'u', 'u', 'm', 'u', 'u', 'u', 'u', 'u']) const segs = buildSegments(rows) expect(segs).toEqual([{ start: 2, end: 8, changes: 1, skippedBefore: 2, skippedAfter: 2 }]) }) it('变更在开头/结尾时区间收敛到边界', () => { const rows = rowsOf(['m', 'u', 'u', 'u', 'u', 'm']) const segs = buildSegments(rows) expect(segs).toEqual([{ start: 0, end: 5, changes: 2, skippedBefore: 0, skippedAfter: 0 }]) }) it('相近变更(间距不超过 2×context+1)合并为同段', () => { const rows = rowsOf(['u', 'u', 'u', 'u', 'u', 'm', 'u', 'u', 'u', 'm', 'u', 'u', 'u', 'u']) // 变更 5 与 9:区间 [2,8] 与 [6,12] 重叠 → 单段 [2,12] const segs = buildSegments(rows) expect(segs).toHaveLength(1) expect(segs[0].start).toBe(2) expect(segs[0].end).toBe(12) expect(segs[0].changes).toBe(2) }) it('远距变更分为两段,段间省略数正确', () => { const kinds = Array.from({ length: 30 }, () => 'u') kinds[2] = 'm' kinds[20] = 'm' const segs = buildSegments(rowsOf(kinds)) expect(segs).toHaveLength(2) expect(segs[0]).toMatchObject({ start: 0, end: 5, changes: 1, skippedBefore: 0 }) expect(segs[1]).toMatchObject({ start: 17, end: 23, changes: 1, skippedBefore: 11, skippedAfter: 6 }) }) it('自定义 context=0 时仅展示变更行本身', () => { const rows = rowsOf(['u', 'u', 'u', 'm', 'u', 'u']) const segs = buildSegments(rows, 0) expect(segs).toEqual([{ start: 3, end: 3, changes: 1, skippedBefore: 3, skippedAfter: 2 }]) }) }) describe('plainSummary - 人话总结', () => { it('无差异时给出一致结论', () => { expect(plainSummary({ changedLines: 0, inserted: 0, deleted: 0, modified: 0 })).toBe( '两文件内容完全一致' ) }) it('为 0 的项被省略', () => { expect(plainSummary({ changedLines: 2, inserted: 2, deleted: 0, modified: 0 })).toBe( '共 2 处不同:新增 2 行' ) }) it('三类变更齐全时完整罗列', () => { expect(plainSummary({ changedLines: 9, inserted: 4, deleted: 3, modified: 2 })).toBe( '共 9 处不同:新增 4 行、删除 3 行、修改 2 行' ) }) }) describe('buildHtmlReport', () => { const { rows, summary } = computeDiff('line1\nline2', 'line1\nline3') it('输出合法 HTML 文档并包含核心信息', () => { const html = buildHtmlReport(rows, ctx, summary) expect(html).toContain('') expect(html).toContain('DiffLens 差异报告') expect(html).toContain('a.txt') expect(html).toContain('b.txt') }) it('Hero 区包含人话结论、文件卡与统计卡', () => { const html = buildHtmlReport(rows, ctx, summary) expect(html).toContain('共 1 处不同:修改 1 行') expect(html).toContain('class="verdict"') expect(html).toContain('file-card left') expect(html).toContain('file-card right') expect(html).toContain('stat ins') expect(html).toContain('stat rate') }) it('包含差异统计与类型高亮语义', () => { const html = buildHtmlReport(rows, ctx, summary) expect(html).toContain('modified') expect(html).toContain('hunk-head') }) it('内容完全一致时显示绿色一致结论', () => { const { rows, summary } = computeDiff('same', 'same') const html = buildHtmlReport(rows, ctx, summary) expect(html).toContain('两文件内容完全一致') expect(html).toContain('verdict ok') }) it('多差异块时渲染目录锚点,单块时不渲染目录', () => { const left = Array.from({ length: 20 }, (_, i) => `l${i}`).join('\n') const right = left.replace('l2', 'X').replace('l16', 'Y') const multi = computeDiff(left, right) const html = buildHtmlReport(multi.rows, ctx, multi.summary) expect(html).toContain('toc-list') expect(html).toContain('href="#seg-2"') const single = computeDiff('a', 'b') const singleHtml = buildHtmlReport(single.rows, ctx, single.summary) expect(singleHtml).not.toContain('href="#seg-') }) it('大段相同内容以折叠行呈现且保留全量数据', () => { const left = Array.from({ length: 40 }, (_, i) => `l${i}`).join('\n') const right = left.replace('l20', 'X') const { rows, summary } = computeDiff(left, right) const html = buildHtmlReport(rows, ctx, summary) expect(html).toContain('
⋯ 相同内容') // 折叠段内仍包含被省略行的数据(信息零丢失) expect(html).toContain('l0') expect(html).toContain('l39') }) it('两侧均为空时给出空内容提示', () => { const { rows, summary } = computeDiff('', '') const html = buildHtmlReport(rows, ctx, summary) expect(html).toContain('没有可对比的行') }) }) describe('plainOptions - 比较选项描述', () => { it('未传或空选项时说明为严格对比', () => { expect(plainOptions(undefined)).toBe('无(严格逐字符对比)') expect(plainOptions({})).toBe('无(严格逐字符对比)') }) it('列出全部生效选项', () => { expect( plainOptions({ ignoreAllWhitespace: true, ignoreCase: true, ignoreBlankLines: true }) ).toBe('忽略所有空白、忽略大小写、忽略空行') expect(plainOptions({ trimWhitespace: true, ignoreCase: true })).toBe( '忽略行首尾空白、忽略大小写' ) }) it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => { expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白') }) it('字符级对比单独描述并遮蔽全部空白类选项', () => { expect( plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true }) ).toBe('字符级对比(忽略全部空白与换行)') }) it('字符级对比与忽略大小写组合列出', () => { expect(plainOptions({ charMode: true, ignoreCase: true })).toBe( '字符级对比(忽略全部空白与换行)、忽略大小写' ) }) }) describe('报告头部比较选项说明', () => { const { rows, summary } = computeDiff('a b', 'ab', { ignoreAllWhitespace: true }) const optCtx = { ...ctx, options: { ignoreAllWhitespace: true, ignoreCase: true } } it('纯文本头部包含比较选项行', () => { const txt = buildTxtReport(rows, optCtx, summary) expect(txt).toContain('比较选项:忽略所有空白、忽略大小写') }) it('Markdown 头部包含比较选项行(引用块内)', () => { const md = buildMarkdownReport(rows, optCtx, summary) expect(md).toContain('> 比较选项:忽略所有空白、忽略大小写') }) it('HTML Hero 区包含比较选项徽标', () => { const html = buildHtmlReport(rows, optCtx, summary) expect(html).toContain('hero-opts') expect(html).toContain('比较选项:忽略所有空白、忽略大小写') }) it('未传选项时头部说明为严格对比', () => { const txt = buildTxtReport(rows, ctx, summary) expect(txt).toContain('比较选项:无(严格逐字符对比)') }) }) describe('buildTxtReport', () => { it('输出带增删改标记与人话结论的行式文本', () => { const { rows, summary } = computeDiff('a', 'a\nb') const txt = buildTxtReport(rows, ctx, summary) expect(txt).toContain('DiffLens') expect(txt).toContain('+') expect(txt).toContain('结论:共 1 处不同:新增 1 行') }) it('行号按最大位数右对齐填充', () => { const left = Array.from({ length: 12 }, (_, i) => `l${i}`).join('\n') const right = left.replace('l1', 'changed') const { rows, summary } = computeDiff(left, right) const txt = buildTxtReport(rows, ctx, summary) // 最大行号 12 → 宽度 2:行号 2 左补空格,行号 12 原样(修改行左右行号同样填充) expect(txt).toContain('~ 2 | l1 ==> 2 | changed') expect(txt).toContain(' 12 | l11') }) it('行号位数一致时无额外填充', () => { const { rows, summary } = computeDiff('a\nc', 'a\nb') const txt = buildTxtReport(rows, ctx, summary) expect(txt).toContain('~ 2 | c ==> 2 | b') }) }) describe('buildMarkdownReport', () => { it('输出表格化报告,标题独立成行不进引用块', () => { const { rows, summary } = computeDiff('a', 'b') const md = buildMarkdownReport(rows, ctx, summary) expect(md.startsWith('# DiffLens 差异报告')).toBe(true) expect(md).not.toContain('> # DiffLens 差异报告') expect(md).toContain('| 类型 |') expect(md).toContain('| 左行号 |') }) it('引用块包含人话结论', () => { const { rows, summary } = computeDiff('a', 'b') const md = buildMarkdownReport(rows, ctx, summary) expect(md).toContain('> 结论:共 1 处不同:修改 1 行') }) it('管道符被转义以保持表格结构', () => { const { rows, summary } = computeDiff('a|b', 'a') const md = buildMarkdownReport(rows, ctx, summary) expect(md).toContain('a\\|b') }) }) describe('buildReport / REPORT_EXT', () => { it('按格式分发', () => { const { rows, summary } = computeDiff('x', 'y') expect(buildReport(rows, ctx, summary, 'html')).toContain('') expect(buildReport(rows, ctx, summary, 'txt')).toContain('DiffLens') expect(buildReport(rows, ctx, summary, 'md')).toContain('# DiffLens 差异报告') }) it('扩展名映射正确', () => { expect(REPORT_EXT.html).toBe('html') expect(REPORT_EXT.txt).toBe('txt') expect(REPORT_EXT.md).toBe('md') }) })