fix: v0.5.1 字符级同行多块变更重复输出修复 - 字符换位专项测试暴露并修复回映射缺陷:同行内多处变更(ne-eq-ne 同行)不再重复输出多个 modified、回拉跨界判定与对齐行判定锚定同一 eq 块避免把全 common 行误判为 added、新增字符换位专项测试组固化对齐任意性下的不变量

This commit is contained in:
2026-08-18 11:14:10 +08:00
parent 74afeaeb84
commit bac885a4a0
6 changed files with 157 additions and 25 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.5.0 - 版本:0.5.1
--- ---
+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.5.0`** 当前基线版本:**`0.5.1`**
--- ---
@@ -70,7 +70,8 @@ z = 补丁版本号(Patch
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.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
0.5.0 ← 字符级对比模式(当前):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含) 0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
0.5.1 ← 字符级同行多块变更重复输出修复(当前):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化)
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) 0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
+1 -1
View File
@@ -78,7 +78,7 @@ src/renderer/src/
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null` - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
- 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines``ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 - 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines``ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志) - 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志)
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
- 行切分:结尾换行不产生多余空行 - 行切分:结尾换行不产生多余空行
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.5.0", "version": "0.5.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+119 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { computeDiff, CHAR_DIFF_MAX_CHARS } from './diffEngine' import { computeDiff, CHAR_DIFF_MAX_CHARS, type Seg } from './diffEngine'
/** 拼接 segs 文本(信息无损校验:拼接结果必须等于原始行文本) */
const joinSegs = (segs: Seg[] | null): string => (segs ?? []).map((s) => s.text).join('')
describe('computeDiff - 空文本', () => { describe('computeDiff - 空文本', () => {
it('空 vs 空 不产生任何行', () => { it('空 vs 空 不产生任何行', () => {
@@ -468,3 +471,118 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
expect(charModeDowngraded).toBeUndefined() expect(charModeDowngraded).toBeUndefined()
}) })
}) })
describe('computeDiff - 字符级对比(字符换位专项)', () => {
/**
* 已知取舍的固化测试:换位场景(如 ab ↔ ba)下 jsdiff 的 Myers 对齐具有任意性
* (LCS 长度 1,哪个字符被选为 common 不确定),个别 common 字符无法在同行视觉对齐。
* 本组用例不锚定具体对齐结果,只断言对齐无关的安全不变量:
* 1. 两侧均有变更字符的单行必然配对 modified(增删改计数稳定)
* 2. segs 拼接无损(等于原始行文本,信息零丢失)
* 3. 高亮不越侧(左侧无 insert 段、右侧无 delete 段)
* 4. 展示文本始终为原文,行号序列单调
*/
it('最小换位 ab↔ba:配对 modifiedsegs 拼接无损且高亮不越侧', () => {
const { rows, summary } = computeDiff('ab', 'ba', { charMode: true })
expect(summary.changedLines).toBe(1)
expect(summary.modified).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(m.left.lineNo).toBe(1)
expect(m.right.lineNo).toBe(1)
// 不变量 2:拼接无损(对齐任意但原文可完整重建)
expect(joinSegs(m.left.segs)).toBe('ab')
expect(joinSegs(m.right.segs)).toBe('ba')
// 不变量 3:不越侧
expect(m.left.segs!.every((s) => s.kind !== 'insert')).toBe(true)
expect(m.right.segs!.every((s) => s.kind !== 'delete')).toBe(true)
// 不变量 4:展示为原文
expect(m.left.text).toBe('ab')
expect(m.right.text).toBe('ba')
})
it('长串换位 abc↔cba:不变量同样成立', () => {
const { rows, summary } = computeDiff('abc', 'cba', { charMode: true })
expect(summary.modified).toBe(1)
expect(summary.changedLines).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(joinSegs(m.left.segs)).toBe('abc')
expect(joinSegs(m.right.segs)).toBe('cba')
expect(m.left.segs!.every((s) => s.kind !== 'insert')).toBe(true)
expect(m.right.segs!.every((s) => s.kind !== 'delete')).toBe(true)
})
it('中文换位 你好↔好你:码点级处理,不变量成立', () => {
const { rows, summary } = computeDiff('你好', '好你', { charMode: true })
expect(summary.modified).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(joinSegs(m.left.segs)).toBe('你好')
expect(joinSegs(m.right.segs)).toBe('好你')
// 变更段按码点切分(不含半个代理对):删除段与新增段的码点数一致为 1
const lDel = m.left.segs!.filter((s) => s.kind === 'delete').map((s) => s.text).join('')
const rIns = m.right.segs!.filter((s) => s.kind === 'insert').map((s) => s.text).join('')
expect([...lDel]).toHaveLength(1)
expect([...rIns]).toHaveLength(1)
})
it('多行局部换位:换位行配 modified,未换位行锚定 unchanged', () => {
const { rows, summary } = computeDiff('ab\ncd', 'ba\ncd', { charMode: true })
expect(summary.changedLines).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(m.left.lineNo).toBe(1)
expect(m.right.lineNo).toBe(1)
expect(joinSegs(m.left.segs)).toBe('ab')
expect(joinSegs(m.right.segs)).toBe('ba')
// 未换位行锚定:两侧第 2 行原样 unchanged
const anchor = rows.filter((r) => r.rowKind === 'unchanged')
expect(anchor).toHaveLength(1)
expect(anchor[0].left.lineNo).toBe(2)
expect(anchor[0].right.lineNo).toBe(2)
expect(anchor[0].left.text).toBe('cd')
expect(anchor[0].right.text).toBe('cd')
})
it('同行两处替换(ne-eq-ne 同行):仅输出一个 modified,不重复行', () => {
// 与换位同源的重复输出缺陷触发场景:aXbYc → aPbQc
// 两处替换被中间的 common 'b' 隔开为两个 ne 块且落在同一行
const { rows, summary } = computeDiff('aXbYc', 'aPbQc', { charMode: true })
expect(summary.changedLines).toBe(1)
expect(summary.modified).toBe(1)
const mods = rows.filter((r) => r.rowKind === 'modified')
expect(mods).toHaveLength(1)
expect(mods[0].left.lineNo).toBe(1)
expect(mods[0].right.lineNo).toBe(1)
// 单个 modified 内两处差异都可见(信息无损)
expect(joinSegs(mods[0].left.segs)).toBe('aXbYc')
expect(joinSegs(mods[0].right.segs)).toBe('aPbQc')
expect(mods[0].left.segs!.some((s) => s.kind === 'delete' && s.text.includes('X'))).toBe(true)
expect(mods[0].left.segs!.some((s) => s.kind === 'delete' && s.text.includes('Y'))).toBe(true)
expect(mods[0].right.segs!.some((s) => s.kind === 'insert' && s.text.includes('P'))).toBe(true)
expect(mods[0].right.segs!.some((s) => s.kind === 'insert' && s.text.includes('Q'))).toBe(true)
})
it('换位伴随跨行重组:对齐任意但增删改计数与行号单调恒成立', () => {
// ab → b\na:换位同时换行位置变化。jsdiff 可能选 b 为 common(输出 modified(1,1)+added(右2)
// 也可能选 a 为 common(输出 modified(1,2)+added(右1)),配对行号随对齐漂移——
// 断言只锚定不变量,禁锢"信息无损、无越侧、单调"三条安全底线
const { rows, summary } = computeDiff('ab', 'b\na', { charMode: true })
expect(summary.changedLines).toBe(2)
expect(summary.modified).toBe(1)
expect(summary.inserted).toBe(1)
expect(summary.deleted).toBe(0)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(joinSegs(m.left.segs)).toBe('ab')
expect(joinSegs(m.right.segs)).not.toBe('')
expect(m.left.segs!.every((s) => s.kind !== 'insert')).toBe(true)
expect(m.right.segs!.every((s) => s.kind !== 'delete')).toBe(true)
// 新增行:另一侧为空槽,文本取自右侧原文
const add = rows.find((r) => r.rowKind === 'added')!
expect(add.left.lineNo).toBeNull()
expect(add.right.text).not.toBe('')
// 不变量 4:双侧行号序列各自单调不减
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
})
+32 -19
View File
@@ -443,19 +443,20 @@ function computeCharDiff(
// 变更行与相邻 eq 块同行(跨界延续,如行内删除 abcXYZdef → abcdef)时, // 变更行与相邻 eq 块同行(跨界延续,如行内删除 abcXYZdef → abcdef)时,
// 把对侧对齐行拉入变更组配对,使行内增删显示为 modified(字符高亮)而非整行 removed/added // 把对侧对齐行拉入变更组配对,使行内增删显示为 modified(字符高亮)而非整行 removed/added
// 跨行整行增删(变更行与相邻 eq 块不同行)不回拉,维持 removed/added 语义。 // 跨行整行增删(变更行与相邻 eq 块不同行)不回拉,维持 removed/added 语义。
// 约束:跨界判定与对齐行判定必须锚定同一个 eq 块(pe 或 nx),
// 否则会拼接出跨越两个变更簇的假配对(多行换位场景曾把全 common 行误判为 added)。
for (let bi = 0; bi < blocks.length; bi++) { for (let bi = 0; bi < blocks.length; bi++) {
const b = blocks[bi] const b = blocks[bi]
if (b.eq) continue if (b.eq) continue
const pe = bi > 0 && blocks[bi - 1].eq ? blocks[bi - 1] : undefined const pe = bi > 0 && blocks[bi - 1].eq ? blocks[bi - 1] : undefined
const nx = bi + 1 < blocks.length && blocks[bi + 1].eq ? blocks[bi + 1] : undefined const nx = bi + 1 < blocks.length && blocks[bi + 1].eq ? blocks[bi + 1] : undefined
if (b.l0 < b.l1 && b.r0 === b.r1) { if (b.l0 < b.l1 && b.r0 === b.r1) {
// removed-only:左侧变更行跨界时回拉右侧对齐行 // removed-only:左侧变更行跨界时回拉右侧对齐行pe 优先,两处判定同块)
const firstL = left.lineNos[b.l0] const firstL = left.lineNos[b.l0]
const lastL = left.lineNos[b.l1 - 1] const lastL = left.lineNos[b.l1 - 1]
const crossL = const viaPe = pe !== undefined && firstL === left.lineNos[pe.l1 - 1]
(pe !== undefined && firstL === left.lineNos[pe.l1 - 1]) || const viaNx = nx !== undefined && lastL === left.lineNos[nx.l0]
(nx !== undefined && lastL === left.lineNos[nx.l0]) if (!viaPe && !viaNx) continue
if (!crossL) continue
const rn = const rn =
b.r0 < right.lineNos.length b.r0 < right.lineNos.length
? right.lineNos[b.r0] ? right.lineNos[b.r0]
@@ -464,20 +465,19 @@ function computeCharDiff(
: 0 : 0
if (rn < 1) continue if (rn < 1) continue
const crossR = const crossR =
(pe !== undefined && rn === right.lineNos[pe.r1 - 1]) || (viaPe && rn === right.lineNos[pe!.r1 - 1]) ||
(nx !== undefined && rn === right.lineNos[nx.r0]) (!viaPe && viaNx && rn === right.lineNos[nx!.r0])
if (crossR) { if (crossR) {
b.pullR = rn b.pullR = rn
pairedR.add(rn) pairedR.add(rn)
} }
} else if (b.r0 < b.r1 && b.l0 === b.l1) { } else if (b.r0 < b.r1 && b.l0 === b.l1) {
// added-only:右侧变更行跨界时回拉左侧对齐行 // added-only:右侧变更行跨界时回拉左侧对齐行pe 优先,两处判定同块)
const firstR = right.lineNos[b.r0] const firstR = right.lineNos[b.r0]
const lastR = right.lineNos[b.r1 - 1] const lastR = right.lineNos[b.r1 - 1]
const crossR = const viaPe = pe !== undefined && firstR === right.lineNos[pe.r1 - 1]
(pe !== undefined && firstR === right.lineNos[pe.r1 - 1]) || const viaNx = nx !== undefined && lastR === right.lineNos[nx.r0]
(nx !== undefined && lastR === right.lineNos[nx.r0]) if (!viaPe && !viaNx) continue
if (!crossR) continue
const ln = const ln =
b.l0 < left.lineNos.length b.l0 < left.lineNos.length
? left.lineNos[b.l0] ? left.lineNos[b.l0]
@@ -486,8 +486,8 @@ function computeCharDiff(
: 0 : 0
if (ln < 1) continue if (ln < 1) continue
const crossL = const crossL =
(pe !== undefined && ln === left.lineNos[pe.l1 - 1]) || (viaPe && ln === left.lineNos[pe!.l1 - 1]) ||
(nx !== undefined && ln === left.lineNos[nx.l0]) (!viaPe && viaNx && ln === left.lineNos[nx!.l0])
if (crossL) { if (crossL) {
b.pullL = ln b.pullL = ln
pairedL.add(ln) pairedL.add(ln)
@@ -524,6 +524,10 @@ function computeCharDiff(
const rows: DiffRow[] = [] const rows: DiffRow[] = []
let pendingL: number[] = [] let pendingL: number[] = []
let pendingR: number[] = [] let pendingR: number[] = []
// 已输出进变更组的行:同行内多处变更(ne-eq-ne 落在同一行,如换位、同行两处替换)
// 时,第二处收集必须跳过已消费的行,否则同一行会重复输出为多个 modified
const doneL = new Set<number>()
const doneR = new Set<number>()
/** 变更行组配对输出(与行级 commitModified 同构:等长 modified / 多余降级 removed·added */ /** 变更行组配对输出(与行级 commitModified 同构:等长 modified / 多余降级 removed·added */
const flushChange = (): void => { const flushChange = (): void => {
@@ -547,6 +551,8 @@ function computeCharDiff(
segs: charSegs(rightOrig[rn - 1], statByLineR.get(rn)) segs: charSegs(rightOrig[rn - 1], statByLineR.get(rn))
} }
}) })
doneL.add(ln)
doneR.add(rn)
} else if (ln !== undefined) { } else if (ln !== undefined) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rowSeq++}`,
@@ -555,6 +561,7 @@ function computeCharDiff(
left: { lineNo: ln, text: leftOrig[ln - 1], segs: null }, left: { lineNo: ln, text: leftOrig[ln - 1], segs: null },
right: emptyLine() right: emptyLine()
}) })
doneL.add(ln)
} else if (rn !== undefined) { } else if (rn !== undefined) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rowSeq++}`,
@@ -563,6 +570,7 @@ function computeCharDiff(
left: emptyLine(), left: emptyLine(),
right: { lineNo: rn, text: rightOrig[rn - 1], segs: null } right: { lineNo: rn, text: rightOrig[rn - 1], segs: null }
}) })
doneR.add(rn)
} }
} }
pendingL = [] pendingL = []
@@ -605,17 +613,22 @@ function computeCharDiff(
lCursor = lEnd + 1 lCursor = lEnd + 1
rCursor = rEnd + 1 rCursor = rEnd + 1
} else { } else {
// 变更行收集(ne 块内行号单调不减,尾去重即可);单侧变更块带上回拉的对侧配对行 // 变更行收集(ne 块内行号单调不减,尾去重即可;跳过已输出的行防同行重复);
// 单侧变更块带上回拉的对侧配对行(回拉行已输出时同样跳过)
for (let i = b.l0; i < b.l1; i++) { for (let i = b.l0; i < b.l1; i++) {
const ln = left.lineNos[i] const ln = left.lineNos[i]
if (pendingL[pendingL.length - 1] !== ln) pendingL.push(ln) if (pendingL[pendingL.length - 1] !== ln && !doneL.has(ln)) pendingL.push(ln)
} }
for (let i = b.r0; i < b.r1; i++) { for (let i = b.r0; i < b.r1; i++) {
const rn = right.lineNos[i] const rn = right.lineNos[i]
if (pendingR[pendingR.length - 1] !== rn) pendingR.push(rn) if (pendingR[pendingR.length - 1] !== rn && !doneR.has(rn)) pendingR.push(rn)
}
if (b.pullR !== undefined && pendingR.length === 0 && !doneR.has(b.pullR)) {
pendingR.push(b.pullR)
}
if (b.pullL !== undefined && pendingL.length === 0 && !doneL.has(b.pullL)) {
pendingL.push(b.pullL)
} }
if (b.pullR !== undefined && pendingR.length === 0) pendingR.push(b.pullR)
if (b.pullL !== undefined && pendingL.length === 0) pendingL.push(b.pullL)
} }
} }
flushChange() flushChange()