fix: v0.5.1 字符级同行多块变更重复输出修复 - 字符换位专项测试暴露并修复回映射缺陷:同行内多处变更(ne-eq-ne 同行)不再重复输出多个 modified、回拉跨界判定与对齐行判定锚定同一 eq 块避免把全 common 行误判为 added、新增字符换位专项测试组固化对齐任意性下的不变量
This commit is contained in:
@@ -97,7 +97,7 @@ src/
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.5.0
|
||||
- 版本:0.5.1
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
- 行切分:结尾换行不产生多余空行
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<number>()
|
||||
const doneR = new Set<number>()
|
||||
|
||||
/** 变更行组配对输出(与行级 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()
|
||||
|
||||
Reference in New Issue
Block a user