From bac885a4a07e810d894f68b48a81241b4ae3ad97 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 18 Aug 2026 11:14:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.5.1=20=E5=AD=97=E7=AC=A6=E7=BA=A7?= =?UTF-8?q?=E5=90=8C=E8=A1=8C=E5=A4=9A=E5=9D=97=E5=8F=98=E6=9B=B4=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=BE=93=E5=87=BA=E4=BF=AE=E5=A4=8D=20-=20=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E6=8D=A2=E4=BD=8D=E4=B8=93=E9=A1=B9=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=9B=9E=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E7=BC=BA=E9=99=B7=EF=BC=9A=E5=90=8C=E8=A1=8C=E5=86=85?= =?UTF-8?q?=E5=A4=9A=E5=A4=84=E5=8F=98=E6=9B=B4=EF=BC=88ne-eq-ne=20?= =?UTF-8?q?=E5=90=8C=E8=A1=8C=EF=BC=89=E4=B8=8D=E5=86=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=A4=9A=E4=B8=AA=20modified=E3=80=81?= =?UTF-8?q?=E5=9B=9E=E6=8B=89=E8=B7=A8=E7=95=8C=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E8=A1=8C=E5=88=A4=E5=AE=9A=E9=94=9A=E5=AE=9A?= =?UTF-8?q?=E5=90=8C=E4=B8=80=20eq=20=E5=9D=97=E9=81=BF=E5=85=8D=E6=8A=8A?= =?UTF-8?q?=E5=85=A8=20common=20=E8=A1=8C=E8=AF=AF=E5=88=A4=E4=B8=BA=20add?= =?UTF-8?q?ed=E3=80=81=E6=96=B0=E5=A2=9E=E5=AD=97=E7=AC=A6=E6=8D=A2?= =?UTF-8?q?=E4=BD=8D=E4=B8=93=E9=A1=B9=E6=B5=8B=E8=AF=95=E7=BB=84=E5=9B=BA?= =?UTF-8?q?=E5=8C=96=E5=AF=B9=E9=BD=90=E4=BB=BB=E6=84=8F=E6=80=A7=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E4=B8=8D=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/应用开发与版本迭代规范.md | 5 +- docs/测试策略与方案.md | 2 +- package.json | 2 +- src/renderer/src/diff/diffEngine.test.ts | 120 ++++++++++++++++++++++- src/renderer/src/diff/diffEngine.ts | 51 ++++++---- 6 files changed, 157 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index bd2093c..b321aa2 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.5.0 +- 版本:0.5.1 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 45a700c..c010a3d 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.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.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论 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.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index a5811d6..e3ec59d 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -78,7 +78,7 @@ src/renderer/src/ - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 忽略选项:`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 - 行切分:结尾换行不产生多余空行 diff --git a/package.json b/package.json index 8a5f164..340a97f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.5.0", + "version": "0.5.1", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index b1abe0d..7a50b82 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -1,5 +1,8 @@ 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 - 空文本', () => { it('空 vs 空 不产生任何行', () => { @@ -467,4 +470,119 @@ describe('computeDiff - 字符级对比(超限降级)', () => { const { charModeDowngraded } = computeDiff('a', 'b', {}) expect(charModeDowngraded).toBeUndefined() }) +}) + +describe('computeDiff - 字符级对比(字符换位专项)', () => { + /** + * 已知取舍的固化测试:换位场景(如 ab ↔ ba)下 jsdiff 的 Myers 对齐具有任意性 + * (LCS 长度 1,哪个字符被选为 common 不确定),个别 common 字符无法在同行视觉对齐。 + * 本组用例不锚定具体对齐结果,只断言对齐无关的安全不变量: + * 1. 两侧均有变更字符的单行必然配对 modified(增删改计数稳定) + * 2. segs 拼接无损(等于原始行文本,信息零丢失) + * 3. 高亮不越侧(左侧无 insert 段、右侧无 delete 段) + * 4. 展示文本始终为原文,行号序列单调 + */ + + it('最小换位 ab↔ba:配对 modified,segs 拼接无损且高亮不越侧', () => { + 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) + }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index 74b8512..9283072 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -443,19 +443,20 @@ function computeCharDiff( // 变更行与相邻 eq 块同行(跨界延续,如行内删除 abcXYZdef → abcdef)时, // 把对侧对齐行拉入变更组配对,使行内增删显示为 modified(字符高亮)而非整行 removed/added; // 跨行整行增删(变更行与相邻 eq 块不同行)不回拉,维持 removed/added 语义。 + // 约束:跨界判定与对齐行判定必须锚定同一个 eq 块(pe 或 nx), + // 否则会拼接出跨越两个变更簇的假配对(多行换位场景曾把全 common 行误判为 added)。 for (let bi = 0; bi < blocks.length; bi++) { const b = blocks[bi] if (b.eq) continue 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 if (b.l0 < b.l1 && b.r0 === b.r1) { - // removed-only:左侧变更行跨界时回拉右侧对齐行 + // removed-only:左侧变更行跨界时回拉右侧对齐行(pe 优先,两处判定同块) const firstL = left.lineNos[b.l0] const lastL = left.lineNos[b.l1 - 1] - const crossL = - (pe !== undefined && firstL === left.lineNos[pe.l1 - 1]) || - (nx !== undefined && lastL === left.lineNos[nx.l0]) - if (!crossL) continue + const viaPe = pe !== undefined && firstL === left.lineNos[pe.l1 - 1] + const viaNx = nx !== undefined && lastL === left.lineNos[nx.l0] + if (!viaPe && !viaNx) continue const rn = b.r0 < right.lineNos.length ? right.lineNos[b.r0] @@ -464,20 +465,19 @@ function computeCharDiff( : 0 if (rn < 1) continue const crossR = - (pe !== undefined && rn === right.lineNos[pe.r1 - 1]) || - (nx !== undefined && rn === right.lineNos[nx.r0]) + (viaPe && rn === right.lineNos[pe!.r1 - 1]) || + (!viaPe && viaNx && rn === right.lineNos[nx!.r0]) if (crossR) { b.pullR = rn pairedR.add(rn) } } else if (b.r0 < b.r1 && b.l0 === b.l1) { - // added-only:右侧变更行跨界时回拉左侧对齐行 + // added-only:右侧变更行跨界时回拉左侧对齐行(pe 优先,两处判定同块) const firstR = right.lineNos[b.r0] const lastR = right.lineNos[b.r1 - 1] - const crossR = - (pe !== undefined && firstR === right.lineNos[pe.r1 - 1]) || - (nx !== undefined && lastR === right.lineNos[nx.r0]) - if (!crossR) continue + const viaPe = pe !== undefined && firstR === right.lineNos[pe.r1 - 1] + const viaNx = nx !== undefined && lastR === right.lineNos[nx.r0] + if (!viaPe && !viaNx) continue const ln = b.l0 < left.lineNos.length ? left.lineNos[b.l0] @@ -486,8 +486,8 @@ function computeCharDiff( : 0 if (ln < 1) continue const crossL = - (pe !== undefined && ln === left.lineNos[pe.l1 - 1]) || - (nx !== undefined && ln === left.lineNos[nx.l0]) + (viaPe && ln === left.lineNos[pe!.l1 - 1]) || + (!viaPe && viaNx && ln === left.lineNos[nx!.l0]) if (crossL) { b.pullL = ln pairedL.add(ln) @@ -524,6 +524,10 @@ function computeCharDiff( const rows: DiffRow[] = [] let pendingL: number[] = [] let pendingR: number[] = [] + // 已输出进变更组的行:同行内多处变更(ne-eq-ne 落在同一行,如换位、同行两处替换) + // 时,第二处收集必须跳过已消费的行,否则同一行会重复输出为多个 modified + const doneL = new Set() + const doneR = new Set() /** 变更行组配对输出(与行级 commitModified 同构:等长 modified / 多余降级 removed·added) */ const flushChange = (): void => { @@ -547,6 +551,8 @@ function computeCharDiff( segs: charSegs(rightOrig[rn - 1], statByLineR.get(rn)) } }) + doneL.add(ln) + doneR.add(rn) } else if (ln !== undefined) { rows.push({ id: `r${rowSeq++}`, @@ -555,6 +561,7 @@ function computeCharDiff( left: { lineNo: ln, text: leftOrig[ln - 1], segs: null }, right: emptyLine() }) + doneL.add(ln) } else if (rn !== undefined) { rows.push({ id: `r${rowSeq++}`, @@ -563,6 +570,7 @@ function computeCharDiff( left: emptyLine(), right: { lineNo: rn, text: rightOrig[rn - 1], segs: null } }) + doneR.add(rn) } } pendingL = [] @@ -605,17 +613,22 @@ function computeCharDiff( lCursor = lEnd + 1 rCursor = rEnd + 1 } else { - // 变更行收集(ne 块内行号单调不减,尾去重即可);单侧变更块带上回拉的对侧配对行 + // 变更行收集(ne 块内行号单调不减,尾去重即可;跳过已输出的行防同行重复); + // 单侧变更块带上回拉的对侧配对行(回拉行已输出时同样跳过) for (let i = b.l0; i < b.l1; 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++) { 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()