This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,521 @@
// 任务粒度 diff / merge 工具测试
//
// 覆盖 markdown-diff.js 的核心行为categoriesToMap、diffCategories、applyResolutions、
// defaultResolutions、mergeTaskStates、summarizeDiff。在纯函数层验证 —— 不需要 DOM / Electron。
//
// 关键覆盖场景:
// 1) 仅 A 侧有的任务 → onlyInA
// 2) 仅 B 侧有的任务 → onlyInB
// 3) 两边都有但状态不同 → modified
// 4) 两边完全相同 → unchanged
// 5) 文本被改 → 视为不同任务key 是 catName::texttext 变 → 新 key
// 6) 容器 / TODAY 不参与 diffCOMPLETED / TRASH 按 originalCategory 入 keyv4
// 7) applyResolutions 的每个选项a/b/both/skip
// 8) defaultResolutions 智能默认
// 9) summarizeDiff 计数
// 10) v4 H1 修复:移除 idxInCat 后,跨 idx 漂移(用户加任务)不产生重复
// 11) v4 H4 修复用户勾选NORMAL → COMPLETED默认合并保留动作
// 12) v4 H5 修复:用户回收 / 恢复 默认合并保留动作
// 13) v4 H3 修复current 仅一边 true 时 OR 保留,不丢 [▶] 标记
//
// 用法node scripts/check-conflict-merge.mjs
import {
categoriesToMap,
diffCategories,
applyResolutions,
defaultResolutions,
mergeTaskStates,
summarizeDiff,
makeTaskKey,
tasksStateEqual,
tasksTextuallyEqual,
} from '../src/utils/markdown-diff.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
function truthy(value, name) {
if (value) ok(name);
else bad(name, `expected truthy, got ${value}`);
}
// 直接 === 比较两个值(用 JSON.stringify 处理对象 / 数组)
function eq(actual, expected, name) {
if (JSON.stringify(actual) === JSON.stringify(expected)) ok(name);
else bad(name, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
// ── 测试数据 ──
function makeCat(name, kind = 'normal', parentOtherTasks = true, tasks = []) {
return { name, kind, isSpecial: false, meta: null, parentOtherTasks, tasks };
}
const container = { name: '全部任务', kind: 'other_tasks', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] };
const trash = { name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] };
const catsA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'A1', completed: false, important: true, current: false },
{ text: 'A2', completed: false, important: true, current: false }, // 与 B 文本同但 important 不同
]),
makeCat('学习', 'normal', true, [
{ text: 'shared', completed: false, important: false, current: false },
]),
trash,
];
const catsB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'B1', completed: false, important: false, current: false }, // 仅 B 有idx=0
{ text: 'A2', completed: false, important: false, current: false }, // 与 A 同 idx=1 但状态不同
]),
makeCat('学习', 'normal', true, [
{ text: 'shared', completed: false, important: false, current: false }, // 与 A 完全相同
]),
trash,
];
// ── 1) makeTaskKey ──
//
// 注v4 起 key 不含 idxInCat —— 详见 src/utils/markdown-diff.js 的历史注。
// 「同 cat 内位置漂移」用 text 区分text 变 = 不同任务idx 不进 key。
console.log('\n[makeTaskKey]');
eq(makeTaskKey('工作', 'A1'), '2:工作::2:A1', '基本键值');
eq(makeTaskKey('工作', 'A1', 2), '2:工作::2:A1', 'idxInCat 参数被忽略v4 设计key 不含 idx');
eq(makeTaskKey('工作', '特殊 | 字符'), '2:工作::7:特殊 | 字符', '特殊字符保留');
eq(makeTaskKey('工作', ' hello '), '2:工作::5:hello', '文本前后空白被 trimH6 修复)');
// ── 2) categoriesToMap ──
console.log('\n[categoriesToMap]');
{
const map = categoriesToMap(catsA);
truthy(map.has('2:工作::2:A1'), '工作::A1 存在');
truthy(map.has('2:工作::2:A2'), '工作::A2 存在');
truthy(map.has('2:学习::6:shared'), '学习::shared 存在');
truthy(!map.has('4:全部任务::'), '容器不参与');
truthy(!map.has('3:回收站::'), '回收站不参与');
}
// ── 3) tasksStateEqual ──
console.log('\n[tasksStateEqual]');
truthy(tasksStateEqual(
{ completed: true, important: false, current: false },
{ completed: true, important: false, current: false }
), '同状态 = true');
truthy(!tasksStateEqual(
{ completed: true, important: false, current: false },
{ completed: false, important: false, current: false }
), 'completed 不同 = false');
truthy(!tasksStateEqual(
{ completed: false, important: false, current: false },
{ completed: false, important: true, current: false }
), 'important 不同 = false');
// ── 4) tasksTextuallyEqual ──
console.log('\n[tasksTextuallyEqual]');
truthy(tasksTextuallyEqual({ text: 'hello' }, { text: 'hello' }), '相同文本');
truthy(!tasksTextuallyEqual({ text: 'hello' }, { text: 'world' }), '不同文本');
truthy(tasksTextuallyEqual({ text: ' hello ' }, { text: 'hello' }), '前后空白 trim 后相等');
// ── 5) diffCategories 核心 ──
console.log('\n[diffCategories]');
const diff = diffCategories(catsA, catsB);
// onlyInAA 里有 B 里没有的A1
eq(diff.onlyInA.length, 1, 'onlyInA 数量 = 1');
eq(diff.onlyInA[0].key, '2:工作::2:A1', 'onlyInA 是 A1');
// onlyInBB 里有 A 里没有的B1
eq(diff.onlyInB.length, 1, 'onlyInB 数量 = 1');
eq(diff.onlyInB[0].key, '2:工作::2:B1', 'onlyInB 是 B1');
// modifiedA2 文本相同但状态不同
eq(diff.modified.length, 1, 'modified 数量 = 1');
eq(diff.modified[0].key, '2:工作::2:A2', 'modified 是 A2');
truthy(diff.modified[0].stateOnly, 'A2 是 stateOnly modified');
truthy(!diff.modified[0].stateContainerMove, 'A2 不是 state container move同 cat 同 kind');
// unchangedshared 完全相同
eq(diff.unchanged.length, 1, 'unchanged 数量 = 1');
eq(diff.unchanged[0].key, '2:学习::6:shared', 'unchanged 是 shared');
// ── 6) TODAY 容器不参与 diff ──
console.log('\n[excluded categories]');
{
const withToday = [
container,
makeCat('我的一天', 'today', false, [
{ text: 'todayTask', completed: false, important: false, current: false },
]),
];
const m = categoriesToMap(withToday);
truthy(!m.has('4:我的一天::9:todayTask'), 'TODAY 分类任务不参与 diff');
// v4 修复COMPLETED 任务按 originalCategory 入 key —— 没 originalCategory 时
// 走 __anon_{catIdx}_{taskIdx} 复合 key避免同 cat 内多条 orphan 任务撞 key 被
// 静默吞掉v3.7+ HIGH data-loss vector见 markdown-diff.js 注释)。旧版用单一
// '__anon__' 字符串会让第二条同 text 的 orphan 任务被 `if (!map.has(key))` 丢掉。
const withCompleted = [
makeCat('已完成任务', 'completed', false, [
// 有 originalCategory 时按工作入 key —— 与 NORMAL 「工作」同文本 [ ] 任务撞 key
{ text: 'done1', completed: true, important: false, current: false, originalCategory: '工作' },
// 无 originalCategory 时落 '__anon_<catIdx>_<taskIdx>'orphan 兜底 + 唯一身份)
{ text: 'orphanDone', completed: true, important: false, current: false },
]),
makeCat('普通', 'normal', true, [
{ text: 'normal1', completed: false, important: false, current: false },
]),
];
const m2 = categoriesToMap(withCompleted);
truthy(m2.has('2:工作::5:done1'), 'COMPLETED 任务按 originalCategory 入 keyv4 修复)');
// orphanDone 是 COMPLETED cat 的第 2 条任务 → taskIdx=1在 filtered 数组里
// catIdx=0因为只有这条 COMPLETED cat 进第一遍。key 前缀 '__anon_0_1'。
truthy(m2.has('10:__anon_0_1::10:orphanDone'), '无 originalCategory 的 COMPLETED 任务走唯一 anon keyv3.7+ 修复 H 数据丢失)');
truthy(m2.has('2:普通::7:normal1'), '普通分类正常参与 diff');
// v4 起completed=true 任务也参与 diff —— 否则用户在内存里勾选的任务,磁盘
// 侧同文本 [ ] 版本会被当成「onlyInDisk」→ 默认 a → 用户的勾选被静默撤销。
// 这是 H4 历史 bug 的修复方向:勾选动作必须能在冲突 UI 里被保留(走 both/OR 合并)。
const withLeaked = [
makeCat('普通', 'normal', true, [
{ text: 'leaked', completed: true, important: false, current: false },
{ text: 'normal', completed: false, important: false, current: false },
]),
];
const m3 = categoriesToMap(withLeaked);
truthy(m3.has('2:普通::6:leaked'), 'completed=true 任务也参与 diffv4 保留勾选动作)');
truthy(m3.has('2:普通::6:normal'), '同 cat 内非完成态任务正常入 key');
}
// ── 7) applyResolutions —— 各种选项 ──
console.log('\n[applyResolutions]');
{
// 'a' (onlyInA) → 把 A1 加进去
const merged = applyResolutions(catsA, diff, { '2:工作::2:A1': 'a' });
const workCat = merged.find(c => c.name === '工作');
truthy(workCat.tasks.some(t => t.text === 'A1'), 'onlyInA 用 a → A1 进入合并结果');
// 'b' (onlyInB) → 把 B1 加进去
const merged2 = applyResolutions(catsA, diff, { '2:工作::2:B1': 'b' });
const workCat2 = merged2.find(c => c.name === '工作');
truthy(workCat2.tasks.some(t => t.text === 'B1'), 'onlyInB 用 b → B1 进入合并结果');
// 'a' (modified) → 用 A 侧的任务状态important=true
const merged3 = applyResolutions(catsA, diff, { '2:工作::2:A2': 'a' });
const workCat3 = merged3.find(c => c.name === '工作');
const a2 = workCat3.tasks.find(t => t.text === 'A2');
truthy(a2.important === true, 'modified 用 a → A 侧状态important=true');
// 'b' (modified) → 用 B 侧的任务状态important=false
const merged4 = applyResolutions(catsA, diff, { '2:工作::2:A2': 'b' });
const workCat4 = merged4.find(c => c.name === '工作');
const a2b = workCat4.tasks.find(t => t.text === 'A2');
truthy(a2b.important === false, 'modified 用 b → B 侧状态important=false');
// 'both' (modified) → OR 合并状态
// A 侧 important=trueB 侧 important=false → 合完后 important=true
const merged5 = applyResolutions(catsA, diff, { '2:工作::2:A2': 'both' });
const workCat5 = merged5.find(c => c.name === '工作');
const a2both = workCat5.tasks.find(t => t.text === 'A2');
truthy(a2both.important === true, 'modified 用 both → OR 合并A=true B=false → true');
// 'skip' (onlyInA) → 不写入
const merged6 = applyResolutions(catsA, diff, { '2:工作::2:A1': 'skip' });
const workCat6 = merged6.find(c => c.name === '工作');
const a1InBase = catsA.find(c => c.name === '工作').tasks.find(t => t.text === 'A1');
const a1InMerged = workCat6.tasks.find(t => t.text === 'A1');
truthy(a1InMerged === a1InBase, 'onlyInA 用 skip → A1 引用未被额外创建(保留 base 里的同一对象)');
}
// ── 7.5) v4 H1 修复:移除 idxInCat 后,跨 idx 漂移不产生重复 ──
console.log('\n[v4 H1 修复idx 漂移不重复]');
{
// 模拟「用户在 B 侧(新内存)于工作分类前端插入了新任务」——
// 同样两个老任务 idx 在 B 侧都比 A 侧 +1。
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: false, current: false },
{ text: 'U', completed: false, important: false, current: false },
]),
trash,
];
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'NEW', completed: false, important: false, current: false }, // 新插入
{ text: 'T', completed: false, important: false, current: false }, // idx 漂移
{ text: 'U', completed: false, important: false, current: false }, // idx 漂移
]),
trash,
];
const d = diffCategories(sideA, sideB);
// T 和 U 应该 unchangedkey 用 cat+text不受 idx 影响)
eq(d.unchanged.length, 2, 'idx 漂移后 T 和 U 仍判定为 unchanged');
eq(d.onlyInA.length, 0, 'idx 漂移不会让 A 侧误报 onlyInA');
eq(d.onlyInB.length, 1, '仅 NEW 是 onlyInB');
eq(d.modified.length, 0, 'idx 漂移不会触发 modified');
// 默认合并后不会出现 T/U 重复
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const tCount = workCat.tasks.filter(t => t.text === 'T').length;
const uCount = workCat.tasks.filter(t => t.text === 'U').length;
truthy(tCount === 1, 'T 只出现一次(无重复)');
truthy(uCount === 1, 'U 只出现一次(无重复)');
}
// ── 7.6) v4 H4 修复用户勾选NORMAL → COMPLETED默认合并保留动作 ──
console.log('\n[v4 H4 修复:勾选状态默认合并保留]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'X', completed: false, important: false, current: false }, // 磁盘 [ ]
]),
trash,
];
// 内存B 侧已把 X 勾选并迁到 COMPLETEDv3.4 行为);同时 NORMAL 里还残留旧副本migrate 过渡期)
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'X', completed: false, important: false, current: false }, // 残留 NORMAL 副本
]),
{ name: '已完成任务', kind: 'completed', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'X', completed: true, important: false, current: false, originalCategory: '工作' },
],
},
trash,
];
const d = diffCategories(sideA, sideB);
// 勾选场景X 在 disk NORMAL 和 memory COMPLETED按 effective 名匹配 → modified (stateContainerMove)
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'勾选产生 stateContainerMove modified');
// 默认 'both' 合并后X 在 COMPLETED 且 completed=true工作分类里不再有 X
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const completedCat = merged.find(c => c.name === '已完成任务');
truthy(!workCat.tasks.some(t => t.text === 'X'), '工作分类不再有 X用户已勾选');
const xInCompleted = completedCat && completedCat.tasks.find(t => t.text === 'X');
truthy(xInCompleted && xInCompleted.completed === true,
'COMPLETED 里有 X 且 completed=true用户勾选保留');
}
// ── 7.7) v4 H5 修复用户回收NORMAL → TRASH默认合并保留动作 ──
console.log('\n[v4 H5 修复:回收动作默认合并保留]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Y', completed: false, important: false, current: false },
]),
trash,
];
// 内存B 侧已把 Y 迁到回收站
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Y', completed: false, important: false, current: false }, // 残留 NORMAL 副本
]),
{ name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'Y', completed: false, important: false, current: false, originalCategory: '工作' },
],
},
];
const d = diffCategories(sideA, sideB);
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'回收产生 stateContainerMove modified');
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const trashCat = merged.find(c => c.name === '回收站');
truthy(!workCat.tasks.some(t => t.text === 'Y'), '工作分类不再有 Y用户已回收');
truthy(trashCat && trashCat.tasks.some(t => t.text === 'Y'),
'回收站里有 Y用户回收动作保留');
}
// ── 7.8) v4 H5 修复:从回收站恢复 ──
console.log('\n[v4 H5 修复:恢复动作默认合并保留]');
{
// 磁盘有 Z 在 TRASHoriginalCategory='工作'),内存里用户把它恢复到工作
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Z', completed: false, important: false, current: false }, // 工作分类里也有同名任务(用户已恢复)
]),
{ name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'Z', completed: false, important: false, current: false, originalCategory: '工作' },
],
},
];
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Z', completed: false, important: false, current: false },
]),
{ name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] },
];
const d = diffCategories(sideA, sideB);
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'恢复产生 stateContainerMove modifieddisk 在 TRASHmemory 在 NORMAL');
// 默认 'both' 应把 Z 放到 NORMAL用户当前位置
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const trashCat = merged.find(c => c.name === '回收站');
const zInWork = workCat.tasks.filter(t => t.text === 'Z').length;
truthy(zInWork === 1, '工作分类里有且仅有 1 个 Z恢复后');
truthy(!trashCat.tasks.some(t => t.text === 'Z'), '回收站里没有 Z用户已恢复');
}
// ── 7.9) v4 修复stateContainerMove 'a' 必须用 taskA 不应用 mergedTask ──
// 旧实现下,'a' 分支会用 mergeTaskStates(item.taskA, item.taskB) 把 taskB.completed
// 回写成 true等于「选 'a' = 保留文件版本」却把用户的勾选偷偷带回 —— 违反语义。
console.log('\n[v4 修复stateContainerMove choice=\'a\' 必须用 taskA 不应用 mergedTask]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Q', completed: false, important: false, current: false }, // 磁盘 [ ]
]),
];
const sideB = [
container,
{ name: '已完成任务', kind: 'completed', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'Q', completed: true, important: false, current: false, originalCategory: '工作' },
],
},
];
const d = diffCategories(sideA, sideB);
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'Q 在 disk NORMAL / memory COMPLETED → stateContainerMove modified');
// 用户选 'a' = 「保留磁盘 [ ]」 → 应该撤销勾选,回到工作分类且 completed=false
const merged = applyResolutions(sideA, d, { [d.modified[0].key]: 'a' });
const workCat = merged.find(c => c.name === '工作');
const completedCat = merged.find(c => c.name === '已完成任务');
const qInWork = workCat.tasks.filter(t => t.text.trim() === 'Q');
truthy(qInWork.length === 1, '工作分类里有 Q撤销勾选后');
truthy(qInWork[0].completed === false, 'Q 在工作分类里 completed=false撤销勾选');
truthy(!completedCat || !completedCat.tasks.some(t => t.text.trim() === 'Q'),
'COMPLETED 里没有 Q撤销勾选后');
}
// ── 7.10) v4 修复findOrCreateCat trim 两边 ──
// 旧实现只 trim c.name 不 trim catName。若 item.cat.name 带空白而 out 里的 cat 已 trim
// 匹配漏过 → 静默创建重复分类。
console.log('\n[v4 修复findOrCreateCat trim 两边防止重复 cat]');
{
const sideA = [
container,
makeCat('学习', 'normal', true, [
{ text: 'L1', completed: false, important: false, current: false },
]),
];
const sideB = [
container,
makeCat('学习', 'normal', true, [
{ text: 'L1', completed: false, important: false, current: false },
{ text: 'L2', completed: false, important: false, current: false },
]),
];
// 人为把 sideB 的 cat.name 加前后空白(模拟上游 cat.name 没 trim 透传过来)
sideB[1].name = ' 学习 ';
const d = diffCategories(sideA, sideB);
// 注入一个 onlyInB强制走 findOrCreateCat 分支
const merged = applyResolutions(sideA, d, {});
const learnCats = merged.filter(c => (c.name || '').trim() === '学习');
truthy(learnCats.length === 1, '只有一个「学习」分类(无重复 cat 创建)');
truthy(learnCats[0].name === '学习', '新建 cat 的 name 是 trim 后的版本(不留空白)');
}
// ── 7.11) v4 修复findIndex 用 trim 后比较(防止 text 前后空白产生重复副本)──
console.log('\n[v4 修复findIndex 用 trim 后比较,防止 text 空白导致重复副本]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'T1', completed: false, important: false, current: false },
]),
];
// B 侧同名任务 text 带前导/尾部空格 —— categoriesToMap 用 text.trim() 入 key
// 所以会与 A 侧匹配成 modified旧 findIndex 用 === text 找不到push 重复副本。
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: ' T1 ', completed: false, important: false, current: false },
]),
];
const d = diffCategories(sideA, sideB);
// 用 'a' 让 applyResolutions 走 findIndex 路径
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const t1Count = workCat.tasks.filter(t => t.text.trim() === 'T1').length;
truthy(t1Count === 1, '工作分类里有且仅有 1 个 T1trim 匹配,无重复副本)');
}
// ── 8) defaultResolutions ──
console.log('\n[defaultResolutions]');
{
const res = defaultResolutions(diff);
eq(res['2:工作::2:A1'], 'a', 'onlyInA 默认 = a');
eq(res['2:工作::2:B1'], 'b', 'onlyInB 默认 = b');
eq(res['2:工作::2:A2'], 'both', 'modified 默认 = bothOR 合并,不自动选边丢标记)');
}
// ── 9) mergeTaskStates ──
console.log('\n[mergeTaskStates]');
{
const a = { text: 'X', completed: true, important: false, current: false };
const b = { text: 'X', completed: false, important: true, current: false };
const merged = mergeTaskStates(a, b);
truthy(merged.completed === true, 'OR completed');
truthy(merged.important === true, 'OR important');
eq(merged.text, 'X', 'text 保留');
// v4 H3 修复current 仅一边 true → OR 保留(之前无条件置 false 会丢 [▶] 标记)
const ca = { text: 'Y', completed: false, important: false, current: true };
const cb = { text: 'Y', completed: false, important: false, current: false };
const mergedSingle = mergeTaskStates(ca, cb);
truthy(mergedSingle.current === true, 'current 单边 true → OR 保留H3 修复)');
// current 是单值指针 —— 两边都 true 时强制置 falseM-Diff2 历史兜底)
const cA = { text: 'Y2', completed: false, important: false, current: true };
const cB = { text: 'Y2', completed: false, important: false, current: true };
const mergedC = mergeTaskStates(cA, cB);
truthy(mergedC.current === false, 'current 两边都 true → 合并后强制 false');
// 字段白名单H9 — 不透传 id / _skipReload / createdAt
const withInternal = {
text: 'Z', completed: false, important: false, current: false,
id: 42, _skipReload: true, createdAt: '2024-01-01',
};
const mergedFields = mergeTaskStates(withInternal, withInternal);
truthy(mergedFields.id === undefined, '不泄漏 task.id');
truthy(mergedFields._skipReload === undefined, '不泄漏 _skipReload');
truthy(mergedFields.createdAt === undefined, '不泄漏 createdAt');
truthy('text' in mergedFields, 'text 字段保留');
truthy('important' in mergedFields, 'important 字段保留');
}
// ── 10) summarizeDiff ──
console.log('\n[summarizeDiff]');
{
const s = summarizeDiff(diff);
eq(s.total, 3, 'total = 3');
eq(s.addedByFile, 1, 'addedByFile = 1');
eq(s.addedByMine, 1, 'addedByMine = 1');
eq(s.conflicting, 1, 'conflicting = 1');
}
// ── 总结 ──
console.log('');
if (summary.fail === 0) {
console.log(`✓ 全部通过`);
process.exit(0);
} else {
console.log(`${summary.fail} 处失败`);
process.exit(1);
}