Files
Todo-List/scripts/check-conflict-merge.mjs
2026-09-12 13:55:57 +08:00

521 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 任务粒度 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);
}