521 lines
24 KiB
JavaScript
521 lines
24 KiB
JavaScript
// 任务粒度 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::text,text 变 → 新 key)
|
||
// 6) 容器 / TODAY 不参与 diff;COMPLETED / TRASH 按 originalCategory 入 key(v4)
|
||
// 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', '文本前后空白被 trim(H6 修复)');
|
||
|
||
// ── 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);
|
||
|
||
// onlyInA:A 里有 B 里没有的(A1)
|
||
eq(diff.onlyInA.length, 1, 'onlyInA 数量 = 1');
|
||
eq(diff.onlyInA[0].key, '2:工作::2:A1', 'onlyInA 是 A1');
|
||
|
||
// onlyInB:B 里有 A 里没有的(B1)
|
||
eq(diff.onlyInB.length, 1, 'onlyInB 数量 = 1');
|
||
eq(diff.onlyInB[0].key, '2:工作::2:B1', 'onlyInB 是 B1');
|
||
|
||
// modified:A2 文本相同但状态不同
|
||
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)');
|
||
|
||
// unchanged:shared 完全相同
|
||
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 入 key(v4 修复)');
|
||
// 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 key(v3.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 任务也参与 diff(v4 保留勾选动作)');
|
||
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=true,B 侧 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 应该 unchanged(key 用 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 勾选并迁到 COMPLETED(v3.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 在 TRASH(originalCategory='工作'),内存里用户把它恢复到工作
|
||
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 modified(disk 在 TRASH,memory 在 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 个 T1(trim 匹配,无重复副本)');
|
||
}
|
||
|
||
// ── 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 默认 = both(OR 合并,不自动选边丢标记)');
|
||
}
|
||
|
||
// ── 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 时强制置 false(M-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);
|
||
}
|