feat: v0.4.3 忽略所有空白比较选项与报告选项说明 - 新增忽略所有空白开关(行内空格/制表符/全角空格不参与判等、展示仍为原文、换行结构仍参与对比)、三种报告头部新增比较选项说明行(HTML 为 Hero 区徽标)

This commit is contained in:
2026-08-18 10:03:28 +08:00
parent fe445598c9
commit e639dfe88d
12 changed files with 229 additions and 17 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角 - **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
@@ -96,7 +96,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.4.2 - 版本:0.4.3
--- ---
+3 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.4.2`** 当前基线版本:**`0.4.3`**
--- ---
@@ -68,7 +68,8 @@ z = 补丁版本号(Patch
0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性 0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用 0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件 0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(当前):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论 0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
0.4.3 ← 忽略所有空白比较选项与报告选项说明(当前):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) 0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
+3 -1
View File
@@ -77,7 +77,7 @@ src/renderer/src/
- 完全一致 → 全部 `unchanged`,无差异 - 完全一致 → 全部 `unchanged`,无差异
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null` - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
- 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines` 生效与关闭 - 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines``ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
- 行切分:结尾换行不产生多余空行 - 行切分:结尾换行不产生多余空行
@@ -87,6 +87,7 @@ src/renderer/src/
- Markdown:表格化、管道符转义保持表格结构、引用块含人话结论 - Markdown:表格化、管道符转义保持表格结构、引用块含人话结论
- 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context - 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context
- 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列 - 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列
- 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行
- 格式分发与扩展名映射(`REPORT_EXT` - 格式分发与扩展名映射(`REPORT_EXT`
### 5.3 文本工具(`textUtils.ts` ### 5.3 文本工具(`textUtils.ts`
@@ -107,6 +108,7 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换 - 交换左右侧后面板文件互换
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts` ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.4.2", "version": "0.4.3",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+5 -3
View File
@@ -94,7 +94,8 @@ export default function App(): ReactElement {
const [options, setOptions] = useState<DiffOptions>({ const [options, setOptions] = useState<DiffOptions>({
trimWhitespace: false, trimWhitespace: false,
ignoreCase: false, ignoreCase: false,
ignoreBlankLines: false ignoreBlankLines: false,
ignoreAllWhitespace: false
}) })
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
const [onlyDiff, setOnlyDiff] = useState(false) const [onlyDiff, setOnlyDiff] = useState(false)
@@ -319,7 +320,8 @@ export default function App(): ReactElement {
leftEncoding: paneL?.meta.encoding ?? '', leftEncoding: paneL?.meta.encoding ?? '',
rightEncoding: paneR?.meta.encoding ?? '', rightEncoding: paneR?.meta.encoding ?? '',
leftPath: paneL?.path || undefined, leftPath: paneL?.path || undefined,
rightPath: paneR?.path || undefined rightPath: paneR?.path || undefined,
options
} }
const content = buildReport(rows, ctx, summary, fmt) const content = buildReport(rows, ctx, summary, fmt)
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
@@ -338,7 +340,7 @@ export default function App(): ReactElement {
}) })
} }
}, },
[diff, rows, paneL, paneR, summary, showToast] [diff, rows, paneL, paneR, summary, options, showToast]
) )
const anyPane = paneL !== null || paneR !== null const anyPane = paneL !== null || paneR !== null
+45
View File
@@ -490,6 +490,51 @@ describe('App - 仅看差异视图', () => {
}) })
}) })
describe('App - 忽略所有空白', () => {
it('开启后仅行内空白不同的两侧文本判为完全一致', async () => {
window.api = mockApi({
openFile: async (side) =>
side === 'left'
? { path: '/tmp/a.js', name: 'a.js', text: 'const x = 1;\nif (x) {\n doSomething( x )\n}', encoding: 'UTF-8', binary: false }
: { path: '/tmp/b.js', name: 'b.js', text: 'const x = 1;\nif(x){\n\tdoSomething(x)\n}', encoding: 'UTF-8', binary: false }
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('b.js')
// 默认严格对比:报差异
expect(screen.getByText('有差异')).toBeInTheDocument()
// 开启忽略所有空白后:判为完全一致
fireEvent.click(screen.getByLabelText('忽略所有空白'))
expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument()
})
it('导出报告携带比较选项说明', async () => {
let saved = ''
window.api = mockApi({
openFile: async () => ({
path: '/tmp/a.txt',
name: 'a.txt',
text: 'line1\nline2',
encoding: 'UTF-8',
binary: false
}),
saveReport: async (content: string) => {
saved = content
return { ok: true, path: 'C:/docs/r.txt' }
}
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('纯文本'))
await screen.findByText(/报告已保存/)
expect(saved).toContain('比较选项:无(严格逐字符对比)')
})
})
describe('App - F7 / Shift+F7 差异导航快捷键', () => { describe('App - F7 / Shift+F7 差异导航快捷键', () => {
it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => { it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => {
render(<App />) render(<App />)
+10 -4
View File
@@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) {
const onSwap = vi.fn() const onSwap = vi.fn()
const utils = render( const utils = render(
<Toolbar <Toolbar
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }} options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }}
onOptionsChange={onOptionsChange} onOptionsChange={onOptionsChange}
summary={summary} summary={summary}
navIndex={0} navIndex={0}
@@ -40,19 +40,25 @@ describe('Toolbar', () => {
it('切换忽略大小写触发 onOptionsChange', () => { it('切换忽略大小写触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略大小写')) fireEvent.click(screen.getByLabelText('忽略大小写'))
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false }) expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false })
}) })
it('切换忽略行首尾空白触发 onOptionsChange', () => { it('切换忽略行首尾空白触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略行首尾空白')) fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false }) expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false })
}) })
it('切换忽略空行触发 onOptionsChange', () => { it('切换忽略空行触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略空行')) fireEvent.click(screen.getByLabelText('忽略空行'))
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true }) expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false })
})
it('切换忽略所有空白触发 onOptionsChange', () => {
const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略所有空白'))
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true })
}) })
it('切换仅看差异触发 onOnlyDiffChange(true)', () => { it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
+11
View File
@@ -76,6 +76,17 @@ export default function Toolbar({
/> />
</label> </label>
<label
className="switch"
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比"
>
<input
type="checkbox"
checked={options.ignoreAllWhitespace ?? false}
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
/>
</label>
</div> </div>
<div className="tool-group"> <div className="tool-group">
+70
View File
@@ -172,6 +172,76 @@ describe('computeDiff - 超长行词级防护', () => {
}) })
}) })
describe('computeDiff - 忽略所有空白', () => {
it('行内空格差异不再产生差异', () => {
const { summary } = computeDiff('a b c', 'abc', { ignoreAllWhitespace: true })
expect(summary.changedLines).toBe(0)
})
it('制表符与全角空格同样被忽略', () => {
expect(computeDiff('a\tb', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0)
expect(computeDiff('a b', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0)
})
it('开启后行首尾空白差异自然被覆盖(trim 子集)', () => {
const { summary } = computeDiff(' value ', 'value', { ignoreAllWhitespace: true })
expect(summary.changedLines).toBe(0)
})
it('缩进与符号间空白差异判等(代码场景)', () => {
const { summary } = computeDiff(
' if (x) { return 1; }',
'if(x){return 1;}',
{ ignoreAllWhitespace: true }
)
expect(summary.changedLines).toBe(0)
})
it('多行文本仅行内空白不同判为一致', () => {
const { summary } = computeDiff('a b\nc d\ne', 'ab\nc d\ne', { ignoreAllWhitespace: true })
expect(summary.changedLines).toBe(0)
})
it('实际文字差异仍被识别且保留词级高亮', () => {
const { rows, summary } = computeDiff('a b', 'a c', { ignoreAllWhitespace: true })
expect(summary.modified).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(m.left.segs).not.toBeNull()
expect(m.right.segs).not.toBeNull()
})
it('展示文本始终为原始行(不因归一化而改写)', () => {
const { rows } = computeDiff(' a b ', 'a b', { ignoreAllWhitespace: true })
expect(rows[0].left.text).toBe(' a b ')
expect(rows[0].right.text).toBe('a b')
})
it('换行结构差异不受该选项影响(行级对比边界)', () => {
const { summary } = computeDiff('ab cd', 'ab\ncd', { ignoreAllWhitespace: true })
expect(summary.changedLines).toBeGreaterThan(0)
})
it('空行数量差异不受该选项影响(需配合忽略空行)', () => {
const { summary } = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true })
expect(summary.deleted).toBe(1)
const both = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true, ignoreBlankLines: true })
expect(both.summary.changedLines).toBe(0)
})
it('与忽略大小写组合生效', () => {
const { summary } = computeDiff('A B', 'ab', {
ignoreAllWhitespace: true,
ignoreCase: true
})
expect(summary.changedLines).toBe(0)
})
it('未开启时行内空格差异仍被识别', () => {
const { summary } = computeDiff('a b c', 'abc')
expect(summary.changedLines).toBeGreaterThan(0)
})
})
describe('computeDiff - 行切分细节', () => { describe('computeDiff - 行切分细节', () => {
it('结尾换行不产生多余空行', () => { it('结尾换行不产生多余空行', () => {
const { rows, summary } = computeDiff('a\n', 'a') const { rows, summary } = computeDiff('a\n', 'a')
+6 -2
View File
@@ -41,6 +41,8 @@ export interface DiffOptions {
ignoreCase?: boolean ignoreCase?: boolean
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */ /** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
ignoreBlankLines?: boolean ignoreBlankLines?: boolean
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
ignoreAllWhitespace?: boolean
} }
export interface DiffResult { export interface DiffResult {
@@ -94,7 +96,7 @@ export function splitLines(text: string): string[] {
* 计算文本差异。 * 计算文本差异。
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同; * diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
* 删除段与新增段配对为“修改”行并做词级内联高亮。 * 删除段与新增段配对为“修改”行并做词级内联高亮。
* trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行; * trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。 * ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
*/ */
export function computeDiff( export function computeDiff(
@@ -117,7 +119,9 @@ export function computeDiff(
: rightSeq : rightSeq
const norm = (s: string): string => { const norm = (s: string): string => {
let v = options.trimWhitespace ? s.trim() : s // 忽略所有空白为 trimWhitespace 的超集:开启后行内空白一并剔除,trim 不再重复处理
let v = options.ignoreAllWhitespace ? s.replace(/\s+/g, '') : s
if (!options.ignoreAllWhitespace && options.trimWhitespace) v = v.trim()
if (options.ignoreCase) v = v.toLowerCase() if (options.ignoreCase) v = v.toLowerCase()
return v return v
} }
+50
View File
@@ -7,6 +7,7 @@ import {
buildReport, buildReport,
buildSegments, buildSegments,
plainSummary, plainSummary,
plainOptions,
REPORT_EXT REPORT_EXT
} from './report' } from './report'
@@ -164,6 +165,55 @@ describe('buildHtmlReport', () => {
}) })
}) })
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('忽略所有空白')
})
})
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', () => { describe('buildTxtReport', () => {
it('输出带增删改标记与人话结论的行式文本', () => { it('输出带增删改标记与人话结论的行式文本', () => {
const { rows, summary } = computeDiff('a', 'a\nb') const { rows, summary } = computeDiff('a', 'a\nb')
+22 -1
View File
@@ -1,4 +1,4 @@
import { DiffRow, DiffSummary, SideCell } from './diffEngine' import { DiffOptions, DiffRow, DiffSummary, SideCell } from './diffEngine'
export type ReportFormat = 'html' | 'txt' | 'md' export type ReportFormat = 'html' | 'txt' | 'md'
@@ -15,6 +15,8 @@ export interface ReportContext {
leftPath?: string leftPath?: string
/** 右侧文件完整路径(可选) */ /** 右侧文件完整路径(可选) */
rightPath?: string rightPath?: string
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
options?: DiffOptions
} }
const esc = (s: string): string => const esc = (s: string): string =>
@@ -116,6 +118,17 @@ export function plainSummary(summary: DiffSummary): string {
return `${summary.changedLines} 处不同:${parts.join('、')}` return `${summary.changedLines} 处不同:${parts.join('、')}`
} }
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
export function plainOptions(options?: DiffOptions): string {
const on: string[] = []
// 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导
if (options?.ignoreAllWhitespace) on.push('忽略所有空白')
else if (options?.trimWhitespace) on.push('忽略行首尾空白')
if (options?.ignoreCase) on.push('忽略大小写')
if (options?.ignoreBlankLines) on.push('忽略空行')
return on.length > 0 ? on.join('、') : '无(严格逐字符对比)'
}
const headLines = ( const headLines = (
ctx: ReportContext, ctx: ReportContext,
summary: DiffSummary, summary: DiffSummary,
@@ -128,6 +141,7 @@ const headLines = (
ctx.rightPath ? ` [${ctx.rightPath}]` : '' ctx.rightPath ? ` [${ctx.rightPath}]` : ''
}`, }`,
`对比时间:${new Date().toLocaleString('zh-CN')}`, `对比时间:${new Date().toLocaleString('zh-CN')}`,
`比较选项:${plainOptions(ctx.options)}`,
`结论:${plainSummary(summary)}`, `结论:${plainSummary(summary)}`,
`变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}` `变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}`
] ]
@@ -165,6 +179,11 @@ const REPORT_CSS = `
-webkit-background-clip:text; background-clip:text; color:transparent; -webkit-background-clip:text; background-clip:text; color:transparent;
} }
.hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; } .hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; }
.hero-opts {
color:#74809a; font-size:12px; margin:0 0 10px;
padding:4px 10px; border-radius:8px; display:inline-block;
background:rgba(148,163,255,.05); border:1px solid rgba(148,163,255,.14);
}
.verdict { .verdict {
font-size:16px; font-weight:700; color:#f6bd87; font-size:16px; font-weight:700; color:#f6bd87;
padding:8px 14px; border-radius:10px; margin-bottom:14px; padding:8px 14px; border-radius:10px; margin-bottom:14px;
@@ -312,6 +331,7 @@ const REPORT_CSS = `
body { background:#fff; color:#111; padding:0; } body { background:#fff; color:#111; padding:0; }
.hero { border-color:#ccc; background:#fafafa; box-shadow:none; } .hero { border-color:#ccc; background:#fafafa; box-shadow:none; }
.hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; } .hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; }
.hero-opts { background:#fafafa; border-color:#ddd; color:#666; }
.verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; } .verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
.verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; } .verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; }
.file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; } .file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; }
@@ -453,6 +473,7 @@ export function buildHtmlReport(
<header class="hero"> <header class="hero">
<div class="hero-title">🔍 DiffLens 差异报告</div> <div class="hero-title">🔍 DiffLens 差异报告</div>
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div> <div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
<div class="hero-opts">比较选项:${esc(plainOptions(ctx.options))}</div>
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div> <div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
<div class="files"> <div class="files">
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)} ${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}