// 用最小 DOM mock 模拟 Sidebar + TaskList 在「外部修改 → loadFromContent」后的渲染行为
//
// 核心目的:捕获「change 事件触发后,listEl.innerHTML 是否真的反映了新 categories」
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { readFileSync } from 'node:fs';
import { findTodoFile } from './_lib/find-todo-path.mjs';
// ====== Minimal DOM ======
class MiniElement {
constructor(tag) {
this.tagName = (tag || 'div').toUpperCase();
this.children = [];
this.parent = null;
this._innerHTML = '';
this._textContent = '';
this.classList = {
_classes: new Set(),
add(c) { this._classes.add(c); },
remove(c) { this._classes.delete(c); },
toggle(c, force) {
if (force === undefined) {
if (this._classes.has(c)) this._classes.delete(c);
else this._classes.add(c);
} else if (force) this._classes.add(c);
else this._classes.delete(c);
},
contains(c) { return this._classes.has(c); }
};
this.dataset = {};
this.style = {};
this.hidden = false;
this.title = '';
this.placeholder = '';
this.disabled = false;
this.value = '';
this.checked = false;
this.eventListeners = {};
this._attrs = {};
}
setAttribute(k, v) { this._attrs[k] = v; }
getAttribute(k) { return this._attrs[k]; }
set innerHTML(html) {
this._innerHTML = html;
// 简单模拟:把 innerHTML 当作文本存储,统计
出现次数作为任务行数
this._renderedHtml = html;
}
get innerHTML() { return this._innerHTML; }
set textContent(t) { this._textContent = t; this._innerHTML = ''; }
get textContent() { return this._textContent || ''; }
appendChild(c) { this.children.push(c); c.parent = this; return c; }
removeChild(c) { this.children = this.children.filter(x => x !== c); c.parent = null; }
remove() { if (this.parent) this.parent.removeChild(this); }
addEventListener(ev, fn) {
(this.eventListeners[ev] = this.eventListeners[ev] || []).push(fn);
}
removeEventListener(ev, fn) {
if (this.eventListeners[ev]) {
this.eventListeners[ev] = this.eventListeners[ev].filter(f => f !== fn);
}
}
closest(sel) {
// 简化:返回自身(仅用于「找带 dataset.id 的元素」场景的兼容)
return this;
}
querySelector() { return null; }
querySelectorAll() { return []; }
setPointerCapture() {}
releasePointerCapture() {}
contains() { return false; }
getBoundingClientRect() { return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }; }
focus() {}
click() {}
blur() {}
scrollIntoView() {}
}
// 注册 DOM 元素
const elements = {};
function makeEl(id, tag = 'div') {
const el = new MiniElement(tag);
el.id = id;
elements[id] = el;
return el;
}
const sidebar = makeEl('sidebar');
const categoryList = makeEl('category-list');
sidebar.appendChild(categoryList);
const ctxMenu = makeEl('category-context-menu');
sidebar.appendChild(ctxMenu);
const taskListEl = makeEl('task-list');
const titleEl = makeEl('category-title');
const titleProgressEl = makeEl('task-progress-text');
const inputEl = makeEl('task-input', 'input');
const submitBtn = makeEl('btn-submit-task', 'button');
const emptyEl = makeEl('empty-state');
const listEl = makeEl('task-list', 'ul'); // task-list 也是 ul(覆盖 taskListEl)
const filterBtn = makeEl('btn-filter', 'button');
const filterChipEl = makeEl('filter-chip');
const sortBtn = makeEl('btn-sort', 'button');
const sortChipEl = makeEl('sort-chip');
const importBtn = makeEl('btn-import', 'button');
const batchBtn = makeEl('btn-batch', 'button');
const chipText = makeEl('chip-text', 'span');
batchBtn.appendChild(chipText);
const statusMeta = makeEl('status-meta');
const statusText = makeEl('status-text');
const searchInput = makeEl('search-input', 'input');
const btnAddCategory = makeEl('btn-add-category', 'button');
const submitTaskBtn = submitBtn;
taskListEl.appendChild(titleEl);
taskListEl.appendChild(titleProgressEl);
taskListEl.appendChild(inputEl);
taskListEl.appendChild(submitBtn);
taskListEl.appendChild(emptyEl);
taskListEl.appendChild(listEl);
taskListEl.appendChild(filterBtn);
taskListEl.appendChild(sortBtn);
taskListEl.appendChild(importBtn);
taskListEl.appendChild(batchBtn);
taskListEl.appendChild(searchInput);
taskListEl.appendChild(btnAddCategory);
// document mock
globalThis.document = {
getElementById: (id) => elements[id] || null,
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
removeEventListener: () => {},
body: new MiniElement('body'),
activeElement: null,
createElement: (tag) => new MiniElement(tag),
createDocumentFragment: () => new MiniElement('fragment'),
};
globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} };
globalThis.HTMLElement = MiniElement;
globalThis.Node = { ELEMENT_NODE: 1, TEXT_NODE: 3 };
globalThis.Event = class { constructor(type) { this.type = type; } };
// ====== 加载真实模块 ======
const { TaskStore, Filter, SortBy } = await import('../src/task-store.js');
const { Sidebar } = await import('../src/ui/sidebar.js');
const { TaskList } = await import('../src/ui/task-list.js');
// 优先用用户的真实 todo.md 做端到端验证,缺文件则用内置样例
// 路径解析统一走 scripts/_lib/find-todo-path.mjs —— 不能硬编码用户路径
const found = findTodoFile();
const userTodoPath = found ? found.path : null;
// v3.6 起 todo.md 不再含顶部「# 当前任务 / # 重要任务」镜像段:[▶]/[⭐] 标识直接
// 写在子分类里的源任务上即可,智能视图由 store 按标识聚合。
const SAMPLE_MD = `# 全部任务
## 工作打发打发的法大师傅
- [✓] [⭐] 通过 17 / 失败 0 (check-trash-fuzz)
- [ ] 通过 14 / 失败 0 (check-settings)
- [✓] 通过 15 / 失败 0 (check-status-met
- [✓] [▶] [⭐] 的28刚刚给烦烦烦
- [✓] [▶] [⭐] 2222111阿斯顿111
- [ ] [▶] [⭐] 通过 19 / 失败 0 (check-trash)1112222aaa撒旦发射点发
## 学习
- [✓] [▶] [⭐] 学习111111
- [✓] [⭐] 学习55
## 未分类
- [ ] 生活1
# 回收站
- [✓] [▶] [⭐] 生活2
`;
let original, fwdPath;
if (found) {
original = found.content;
fwdPath = userTodoPath;
console.log(`[check-render-flow] 使用用户文件 ${fwdPath}(${original.length} 字符)`);
} else {
original = SAMPLE_MD;
// 兜底路径 —— 跟用户路径一样参与 pathsEqual 等字符串比对,仅作为虚拟占位
fwdPath = `${process.platform === 'win32' ? 'C:\\TodoList' : '/tmp'}/todo.md`;
console.log('[check-render-flow] 找不到用户 todo.md,回退到内置样例(不写回磁盘)');
}
console.log('\n=== 完整流程:Sidebar + TaskList 订阅 change → 外部修改 → loadFromContent ===\n');
// 创建 store + 选一个有任务的子分类(兼容用户文件 + 内置样例)
// 不能硬取 [0] —— 用户可能把所有任务都从「未分类」清空了,导致初始任务数 = 0,
// 后续「初始渲染包含任务行」的断言无法成立。改取「第一个有任务的子分类」。
const store = new TaskStore();
store.loadFromContent(original, fwdPath);
// 优先选第一个有任务的子分类;若整个文件都没有子分类含任务,回退到第一个子分类
// (不是死路径 —— 子分类存在但空是合法状态,但「初始渲染包含任务行」断言依赖有任务可渲)。
let firstSub = store.getSubCategories().find(c => c.tasks.length > 0)
|| store.getSubCategories()[0];
if (!firstSub) throw new Error('未找到任何子分类');
// 用户文件里所有子分类都空(只有「未分类」「学习」空段 + 已完成任务真实分类)时,
// 后续断言「初始渲染包含任务行」永远过不去 —— 这种结构对侧边栏渲染测试不友好,
// 切回内置 SAMPLE_MD 跑 —— SAMPLE 里有「工作」「学习」「未分类」三个有任务的子分类。
// 注意:必须把 `original` 一起切到 SAMPLE_MD —— 下面的「模拟外部修改」用 original
// 找插入点;如果只切 store 不切 original,insertion 跑在用户文件上、load 进去
// 又是 SAMPLE 分类,initial 和 reloaded 两边来自不同文件,断言 +2 永远对不上。
// 也必须重新算 firstSub —— loadFromContent(SAMPLE) 会跑迁移,把「工作」清空、
// 「学习」变成首个有任务的子分类,旧的 firstSub ref 就废了。
if (original !== SAMPLE_MD && firstSub.tasks.length === 0) {
console.log('[setup] 用户文件所有子分类都空,切回 SAMPLE_MD 以保留测试信号');
original = SAMPLE_MD;
store.loadFromContent(SAMPLE_MD, fwdPath);
firstSub = store.getSubCategories().find(c => c.tasks.length > 0)
|| store.getSubCategories()[0];
}
if (!firstSub) throw new Error('未找到任何子分类');
store.selectCategory(firstSub.name);
const initial = store.getSelectedCategory();
if (!initial) throw new Error(`selectCategory(${firstSub.name}) 后 getSelectedCategory() 为 null`);
console.log(`初始: 「${initial.name}」任务数 = ${initial.tasks.length}`);
// 实例化 UI 组件(订阅 change 事件,触发初始 render)
const sidebarUI = new Sidebar(store);
const taskListUI = new TaskList(store);
const initHtml = listEl._renderedHtml || '';
const initTaskLines = (initHtml.match(/ 0);
// 渲染层会在中西文边界插入空的 (盘古之白,见
// check-cjk-autospace.mjs),所以混排任务文本在 HTML 里是被间隙元素切开的。
// 下面断言的是「任务文本有没有渲染出来」,不是 HTML 的字节形态 —— 先把间隙元素
// 抹掉再做子串匹配,否则这里会跟着排版逻辑一起假红。
const stripGaps = (h) => h.split('').join('');
// 用一段渲染层不会重排的标识串断言 —— 渲染层会把多空格压成单空格,
// 所以选不含连续空格的固定标识。「check-settings」是 SAMPLE_MD 工作分类里
// 仅有的两条 [ ] 待办之一,迁移完成后不会被搬到「# 已完成任务」,渲染层可见。
// 注意:不能用 `[✓]` 任务的标识(如 check-trash-fuzz)—— 迁移逻辑会把已
// 完成项从子分类搬到「# 已完成任务」,原位置就找不到这段文本了。
if (original === SAMPLE_MD) {
check('初始渲染包含原始任务文本', stripGaps(initHtml).includes('check-settings'));
}
// 模拟外部修改:插入新任务 —— 用第一个真实子分类的位置(兼容用户文件 + 内置样例)
// 旧实现硬编码 `## 工作` 在用户机器上没有就直接挂;改用 firstSub.name 动态适配。
// v3.4:插入 [✓] 任务会触发「# 已完成任务」迁移,搬出当前子分类 —— 渲染流测试
// 不应耦合迁移逻辑,所以两条都插入 [ ];[✓] 任务的搬迁路径在 check-trash 覆盖。
const h2Work = original.indexOf(`## ${firstSub.name}`);
// 段终点取「下一个 h2」或「下一个 h1」(哪个先到用哪个)——
// SAMPLE 里「未分类」是最后一个 h2,紧跟 `# 回收站`;只找 h2 会贴到 EOF,
// 新任务会被误归到回收站。verify-reload-ui.mjs 早就这么做了,这里补齐。
let insertPoint;
if (h2Work < 0) {
insertPoint = original.length;
} else {
const nextH2 = original.indexOf('\n## ', h2Work);
const nextH1 = original.indexOf('\n# ', h2Work);
if (nextH2 > 0 && nextH1 > 0) insertPoint = Math.min(nextH2, nextH1);
else if (nextH2 > 0) insertPoint = nextH2;
else if (nextH1 > 0) insertPoint = nextH1;
else insertPoint = original.length;
}
const modified = original.slice(0, insertPoint) +
'\n- [ ] 外部添加的新任务A1\n' +
'- [ ] 外部添加的新任务B2 [⭐]\n' +
original.slice(insertPoint);
// 通过「外部修改检测路径」调用 loadFromContent
store.loadFromContent(modified, fwdPath);
const reloaded = store.getSelectedCategory();
console.log(`重载后: 「${reloaded.name}」任务数 = ${reloaded.tasks.length}`);
const reloadedHtml = listEl._renderedHtml || '';
const reloadedTaskLines = (reloadedHtml.match(/(盘古之白,见
// check-cjk-autospace.mjs),所以「新任务A1」在 HTML 里是被间隙元素切开的。
// 断言的是「任务文本有没有渲染出来」,不是 HTML 的字节形态。
const reloadedText = stripGaps(reloadedHtml);
check('重载后 store 任务数增加',
reloaded.tasks.length === initial.tasks.length + 2,
`${initial.tasks.length} → ${reloaded.tasks.length}`);
check('重载后 DOM 包含 A1',
reloadedText.includes('外部添加的新任务A1'),
'A1 不在 listEl._renderedHtml 中');
check('重载后 DOM 包含 B2',
reloadedText.includes('外部添加的新任务B2'),
'B2 不在 listEl._renderedHtml 中');
check('重载后 DOM 行数增加',
reloadedTaskLines > initTaskLines,
`${initTaskLines} → ${reloadedTaskLines}`);
// ====== Sidebar 也要更新 ======
const sidebarHtml = categoryList._renderedHtml || '';
console.log(`\n Sidebar 渲染 HTML 长度: ${sidebarHtml.length}`);
// 检查 sidebar 是否包含第一个子分类 —— 用 firstSub.name 而不是硬编码「工作」,
// 让测试在用户机器上也能跑(用户的子分类名不一定是「工作」)。
const hasSubcategory = sidebarHtml.includes('subcategory') && sidebarHtml.includes(firstSub.name);
check(`Sidebar 渲染包含「${firstSub.name}」子分类`, hasSubcategory);
// 「学习」是 SAMPLE_MD 里的固定名 —— 仅在用样例时校验
if (original === SAMPLE_MD) {
check('Sidebar 渲染包含「学习」子分类', sidebarHtml.includes('学习'));
}
check('Sidebar 渲染包含智能列表', sidebarHtml.includes('当前任务') && sidebarHtml.includes('重要任务'));
// ====== 关键检查:listEl.innerHTML 的实际值 ======
console.log('\n实际 listEl.innerHTML:');
console.log(reloadedHtml.slice(0, 500));
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);