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,604 @@
// 验证「已完成任务」+「回收站」批量操作的真实数据流:
// 1) 已完成视图:批量「取消完成」→ 任务按 originalCategory 归位completed=false
// 2) 回收站视图:批量「恢复到分类…」选 工作 → uncompleted 任务回到「工作」
// 3) 回收站视图:批量恢复 → completed 任务被硬路由到「已完成任务」
// 4) 回收站视图:批量「标重要」→ 任务的 [⭐] 标记写入
//
// 用 Playwright 跑真实浏览器。stub window.api捕获 writeFile 调用,读回 markdown 反序列化校验。
import { chromium } from 'playwright-core';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const BASE = 'http://127.0.0.1:8765';
const tmpDir = mkdtempSync(join(tmpdir(), 'batch-rt-test-'));
// 简化版解析器 —— 复刻 src/markdown-parser.js 的核心规则
// 支持 [⭐]/[▶] 在行首或行尾,[原分类xxx] 始终在行尾
function parseMd(md) {
const lines = md.split('\n');
const result = { cats: [], trash: [], completed: [] };
let currentCat = null;
let mode = 'normal'; // 'normal' | 'completed' | 'trash'
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('# ')) {
const title = line.slice(2).trim();
if (title === '回收站') {
mode = 'trash';
currentCat = null;
} else if (title === '已完成任务') {
mode = 'completed';
currentCat = null;
} else {
// 容器或镜像段都忽略
mode = 'normal';
currentCat = null;
}
continue;
}
if (line.startsWith('## ')) {
const name = line.slice(3).trim();
currentCat = { name, tasks: [] };
mode = 'normal';
result.cats.push(currentCat);
continue;
}
const m = line.match(/^-\s+\[([ xX✓])\]\s*(.*)$/);
if (!m) continue;
const completed = m[1] === 'x' || m[1] === 'X' || m[1] === '✓';
let text = m[2];
// 抽 [原分类xxx] —— 始终在行尾
let originalCategory = null;
const origMatch = text.match(/\[原分类:([^\]]+)\]/);
if (origMatch) {
originalCategory = origMatch[1];
text = text.replace(/\[原分类:[^\]]+\]/g, '').trim();
}
// 行首 [⭐] / [▶]
let headImportant = false, headCurrent = false;
const headImp = text.match(/^\[⭐\]\s*/);
if (headImp) { headImportant = true; text = text.slice(headImp[0].length); }
const headCur = text.match(/^\[▶\]\s*/);
if (headCur) { headCurrent = true; text = text.slice(headCur[0].length); }
// 行尾 [⭐] / [▶]
const tailImp = text.match(/\[⭐\]$/);
if (tailImp) { headImportant = true; text = text.slice(0, text.length - tailImp[0].length).trim(); }
const tailCur = text.match(/\[▶\]$/);
if (tailCur) { headCurrent = true; text = text.slice(0, text.length - tailCur[0].length).trim(); }
const task = {
text: text.trim(),
completed,
important: headImportant,
current: headCurrent,
originalCategory
};
if (mode === 'trash') {
result.trash.push(task);
} else if (mode === 'completed') {
result.completed.push(task);
} else if (currentCat) {
currentCat.tasks.push(task);
}
}
return result;
}
function findTask(arr, predicate) {
return arr.find(predicate);
}
// 文档1 个完成的任务来自工作 + 1 个删到回收站 + 1 个删且完成的
const DOC = [
'# 当前任务', '',
'# 全部任务', '',
'## 工作', '',
'- [ ] 工作任务 A', '',
'## 学习', '',
'- [ ] 学习任务 A', '',
'# 已完成任务', '',
'- [✓] 工作任务 A', '',
'# 回收站', '',
'- [ ] 删除的 A', '',
'- [✓] 已删且已完成的', ''
].join('\n');
async function setupStub(page, md) {
// 注意addInitScript 会在每次 reload 时重新执行(闭包 md 会被覆盖回原值)。
// 所以「换 doc 后再 reload」必须重新调 setupStub(newDoc),否则 reload 会把
// window.__todoMd 重置回原 DOC —— 看起来测试通过了其实是测了不同的 doc。
await page.addInitScript((md) => {
const noop = () => {};
const noopUnsub = () => noop;
window.__writes = [];
window.__todoMd = md;
window.api = {
isDev: false,
saveFileDialog: async () => null,
readFile: async () => ({ ok: true, content: window.__todoMd }),
writeFile: async (path, content) => {
// 捕获写入:每次 store flush 都会调一次,把最新内容记下来
window.__writes.push({ path, content });
return { ok: true };
},
fileExists: async () => true,
createFileIfMissing: async () => ({ ok: true }),
createSnapshot: async () => ({ ok: true }),
getDataDir: async () => '/tmp',
getDefaultDataDir: async () => '/tmp',
getHomeDir: async () => '/tmp',
getVersion: async () => '0.0.0-test',
getElectronVersion: async () => '0.0.0-test',
openDataDir: async () => true,
getSettings: async () => ({}),
saveSettings: async () => true,
chooseDataDir: async () => null,
notifyDirtyChanged: noop,
notifyFlushDone: noop,
onFlushPendingSave: noopUnsub,
onMenuCommand: noopUnsub,
onFileExternalChange: noopUnsub,
setAlwaysOnTop: async () => true,
onAlwaysOnTopChanged: noopUnsub,
minimizeWindow: async () => true,
toggleMaximizeWindow: async () => false,
closeWindow: async () => true,
onMaximizeStateChanged: noopUnsub
};
}, md);
}
async function clickSidebarCategory(page, categoryName) {
const result = await page.evaluate((name) => {
const items = document.querySelectorAll('.category-item');
for (const el of items) {
if (el.textContent.trim().startsWith(name)) {
el.click();
return true;
}
}
return false;
}, categoryName);
if (!result) throw new Error(`未找到 sidebar 分类:${categoryName}`);
await page.waitForTimeout(300);
}
async function getMd(page) {
return await page.evaluate(() => {
// 找到最近一次写入
const writes = window.__writes || [];
return writes.length > 0 ? writes[writes.length - 1].content : null;
});
}
async function triggerBatch(page, action, opts = {}) {
// 进批量模式 + 选第一条 + 点对应按钮
await page.evaluate(() => {
const btn = document.querySelector('#btn-batch');
if (btn && !btn.hidden) btn.click();
});
await page.waitForTimeout(200);
await page.evaluate(() => {
const item = document.querySelector('.task-item');
if (item) item.click();
});
await page.waitForTimeout(150);
if (action === 'complete') {
// 直接点完成/取消完成按钮
await page.evaluate(() => {
const btn = document.querySelector('.batch-action[data-action="complete"]');
if (btn) btn.click();
});
} else if (action === 'important-set') {
await page.evaluate(() => {
const btn = document.querySelector('.batch-action[data-action="important-set"]');
if (btn) btn.click();
});
} else if (action === 'restore') {
// 点「恢复到分类…」→ 弹菜单 → 点目标
await page.evaluate(() => {
const btn = document.querySelector('.batch-action[data-action="move"]');
if (btn) btn.click();
});
await page.waitForTimeout(300);
await page.evaluate((target) => {
const items = document.querySelectorAll('#context-menu .context-menu-item');
for (const el of items) {
const actionAttr = el.dataset.action || '';
if (actionAttr === `batch-move:${target}`) {
el.click();
return true;
}
}
return false;
}, opts.target);
}
await page.waitForTimeout(400);
}
async function clearWrites(page) {
await page.evaluate(() => {
window.__writes = [];
});
}
async function getTaskCount(page) {
return await page.evaluate(() => {
return document.querySelectorAll('.task-item').length;
});
}
function check(name, cond, info) {
const tag = cond ? '✓' : '✗';
console.log(`${tag} ${name}${info ? ` (${info})` : ''}`);
return cond;
}
let pass = 0, fail = 0;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1024, height: 768 } });
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
page.on('console', (msg) => {
const t = msg.type();
if (t === 'error') errors.push(`console.error: ${msg.text()}`);
});
await setupStub(page, DOC);
await page.goto(BASE + '/index.html');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(800);
if (!(await page.evaluate(() => !!document.getElementById('app')))) {
await browser.close();
throw new Error('app 未渲染');
}
console.log('\n========== 场景 1已完成视图批量「取消完成」 ==========');
{
await clickSidebarCategory(page, '已完成任务');
// 已完成任务视图下应有 1 条
const initialCount = await getTaskCount(page);
if (check('已完成任务视图渲染 1 条任务', initialCount === 1, `actual=${initialCount}`)) pass++; else fail++;
// batch 按钮可见
const batchVisible = await page.evaluate(() => !document.querySelector('#btn-batch').hidden);
if (check('batch 按钮可见', batchVisible)) pass++; else fail++;
await clearWrites(page);
await triggerBatch(page, 'complete');
const md = await getMd(page);
if (check('批量「取消完成」后产生 writeFile 调用', md !== null)) pass++; else fail++;
if (md) {
const parsed = parseMd(md);
const workCat = parsed.cats.find(c => c.name === '工作');
if (check('「工作」分类存在', !!workCat)) pass++; else fail++;
if (check('「工作」里有 1 条任务(原 A 已回来)',
workCat && workCat.tasks.length === 1,
`tasks=${JSON.stringify(workCat?.tasks)}`)) pass++; else fail++;
if (check('回来的任务 completed=false',
workCat && workCat.tasks[0] && workCat.tasks[0].completed === false,
JSON.stringify(workCat?.tasks[0]))) pass++; else fail++;
if (check('已完成任务分类为空',
parsed.completed.length === 0,
`completed=${JSON.stringify(parsed.completed)}`)) pass++; else fail++;
}
}
console.log('\n========== 场景 2回收站批量恢复未完成 ==========');
{
await clickSidebarCategory(page, '回收站');
const initialCount = await getTaskCount(page);
if (check('回收站视图渲染 2 条任务', initialCount === 2, `actual=${initialCount}`)) pass++; else fail++;
// 选第一条(未完成的「删除的 A」→ 恢复到「工作」
// 先选中第一条再恢复
await clearWrites(page);
await page.evaluate(() => {
const btn = document.querySelector('#btn-batch');
if (btn && !btn.hidden) btn.click();
});
await page.waitForTimeout(200);
await page.evaluate(() => {
const items = document.querySelectorAll('.task-item');
// 选第一条(未完成的)
if (items.length > 0) items[0].click();
});
await page.waitForTimeout(150);
// 点「恢复到分类…」
await page.evaluate(() => {
const btn = document.querySelector('.batch-action[data-action="move"]');
if (btn) btn.click();
});
await page.waitForTimeout(300);
// 点「工作」目标
await page.evaluate(() => {
const items = document.querySelectorAll('#context-menu .context-menu-item');
for (const el of items) {
const a = el.dataset.action || '';
if (a === 'batch-move:工作') {
el.click();
return true;
}
}
return false;
});
await page.waitForTimeout(400);
const md = await getMd(page);
if (check('批量恢复后产生 writeFile 调用', md !== null)) pass++; else fail++;
if (md) {
const parsed = parseMd(md);
const workCat = parsed.cats.find(c => c.name === '工作');
if (check('「工作」分类存在', !!workCat)) pass++; else fail++;
// 工作原本有 A加上恢复回来的「删除的 A」= 2 条
if (check('「工作」里有 2 条任务(原 A + 恢复的 删除的 A',
workCat && workCat.tasks.length === 2,
`tasks=${JSON.stringify(workCat?.tasks?.map(t => t.text))}`)) pass++; else fail++;
const restoredTask = workCat && workCat.tasks.find(t => t.text === '删除的 A');
if (check('恢复回来的「删除的 A」completed=false',
restoredTask && restoredTask.completed === false,
JSON.stringify(restoredTask))) pass++; else fail++;
if (check('回收站还剩 1 条(已删且完成的)',
parsed.trash.length === 1,
`trash=${JSON.stringify(parsed.trash?.map(t => t.text))}`)) pass++; else fail++;
}
}
console.log('\n========== 场景 3回收站批量恢复completed → 已完成任务硬路由) ==========');
{
// 现在回收站里还剩 1 条「已删且已完成的」
// 选中这条 → 恢复到「工作」→ 应该被硬路由到「已完成任务」
// 重要:先确保退出场景 2 残留的 selectionMode_afterBatchComplete 不退批量)
// 否则点 batch 按钮会变「退出」语义,菜单根本打不开。
await page.evaluate(() => {
if (document.body.dataset.selectionMode === 'true') {
// 模拟 Esc 退出批量模式
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
}
});
await page.waitForTimeout(150);
await clickSidebarCategory(page, '回收站');
await clearWrites(page);
await page.evaluate(() => {
const btn = document.querySelector('#btn-batch');
if (btn && !btn.hidden) btn.click();
});
await page.waitForTimeout(200);
await page.evaluate(() => {
const items = document.querySelectorAll('.task-item');
// 选第一条(即「已删且已完成的」)
if (items.length > 0) items[0].click();
});
await page.waitForTimeout(150);
await page.evaluate(() => {
const btn = document.querySelector('.batch-action[data-action="move"]');
if (btn) btn.click();
});
await page.waitForTimeout(300);
// 候选列表里不应包含「已完成任务」(已被排除)
// 但可以选「学习」(仍然是普通分类)
const moveOptions = await page.evaluate(() => {
const items = document.querySelectorAll('#context-menu .context-menu-item');
const opts = [];
for (const el of items) {
const a = el.dataset.action || '';
if (a.startsWith('batch-move:')) opts.push(a.slice(11));
}
return opts;
});
if (check('回收站恢复菜单不含「已完成任务」',
!moveOptions.includes('已完成任务'),
`opts=${JSON.stringify(moveOptions)}`)) pass++; else fail++;
if (check('回收站恢复菜单不含「回收站」',
!moveOptions.includes('回收站'),
`opts=${JSON.stringify(moveOptions)}`)) pass++; else fail++;
if (check('回收站恢复菜单不含「全部任务」',
!moveOptions.includes('全部任务'),
`opts=${JSON.stringify(moveOptions)}`)) pass++; else fail++;
// 关键:必须在 menu 还开着时立刻点击学习 —— 把「点学习」放紧跟着 move button click
// 不穿插任何 page.evaluate避免异步间隙里 store change 触发 render 把菜单关掉。
const learnResult = await page.evaluate(async () => {
const items = document.querySelectorAll('#context-menu .context-menu-item');
let clicked = false;
for (const el of items) {
if (el.dataset.action === 'batch-move:学习') {
el.click();
clicked = true;
break;
}
}
await new Promise(r => setTimeout(r, 500));
return { clicked, writeCount: window.__writes.length };
});
if (!learnResult.clicked) {
throw new Error('场景 3找不到 batch-move:学习 菜单项,菜单状态异常');
}
const md = await getMd(page);
if (check('completed 任务恢复后产生 writeFile', md !== null)) pass++; else fail++;
if (md) {
const parsed = parseMd(md);
// completed 任务不应去「学习」,应去「已完成任务」
const studyCat = parsed.cats.find(c => c.name === '学习');
const restoredInStudy = studyCat?.tasks?.find(t => t.text === '已删且已完成的');
if (check('completed 任务不在「学习」(未被改投成功)',
!restoredInStudy,
`study=${JSON.stringify(studyCat?.tasks)}`)) pass++; else fail++;
// 应在「已完成任务」分类里
const inCompleted = parsed.completed.find(t => t.text === '已删且已完成的');
if (check('completed 任务被硬路由到「已完成任务」',
!!inCompleted,
`completed=${JSON.stringify(parsed.completed.map(t => t.text))}`)) pass++; else fail++;
if (check('硬路由后任务仍带 completed=true',
inCompleted && inCompleted.completed === true)) pass++; else fail++;
// 回收站应为空
if (check('回收站为空',
parsed.trash.length === 0,
`trash=${JSON.stringify(parsed.trash)}`)) pass++; else fail++;
}
}
console.log('\n========== 场景 4回收站批量「标重要」→ [⭐] 写入 ==========');
{
// 重新加载初始文档 —— 必须重新调 setupStub(DOC)addInitScript 在 reload 时
// 会用闭包里的 DOC 覆盖 window.__todoMd
await setupStub(page, DOC);
await page.evaluate(() => { window.__writes = []; });
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(800);
if (!(await page.evaluate(() => !!document.getElementById('app')))) {
await browser.close();
throw new Error('reload 后 app 未渲染');
}
await clickSidebarCategory(page, '回收站');
await clearWrites(page);
// 进批量模式,选全部 2 条,标重要
await page.evaluate(() => {
const btn = document.querySelector('#btn-batch');
if (btn && !btn.hidden) btn.click();
});
await page.waitForTimeout(200);
await page.evaluate(() => {
// 全选
// 找全选按钮(如果有),否则一条一条点
const allBtn = document.querySelector('.batch-select-all');
if (allBtn) {
allBtn.click();
} else {
const items = document.querySelectorAll('.task-item');
for (const it of items) it.click();
}
});
await page.waitForTimeout(150);
await page.evaluate(() => {
const btn = document.querySelector('.batch-action[data-action="important-set"]');
if (btn) btn.click();
});
await page.waitForTimeout(400);
const md = await getMd(page);
if (check('回收站批量标重要产生 writeFile', md !== null)) pass++; else fail++;
if (md) {
const parsed = parseMd(md);
const allTrashImportant = parsed.trash.length > 0 && parsed.trash.every(t => t.important);
if (check('回收站里 2 条任务都带 important=true',
allTrashImportant,
`trash=${JSON.stringify(parsed.trash.map(t => ({ text: t.text, imp: t.important })))}`)) pass++; else fail++;
// 回收站段应包含 [⭐]
if (check('回收站段 markdown 含 [⭐] 标记', /\[⭐\]/.test(md))) pass++; else fail++;
}
}
console.log('\n========== 场景 5切分类自动退出批量模式不残留 ==========');
{
// 再次 reload 回到初始文档
await setupStub(page, DOC);
await page.evaluate(() => { window.__writes = []; });
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(800);
if (!(await page.evaluate(() => !!document.getElementById('app')))) {
await browser.close();
throw new Error('reload 后 app 未渲染');
}
await clickSidebarCategory(page, '已完成任务');
// 进批量模式
await page.evaluate(() => {
const btn = document.querySelector('#btn-batch');
if (btn && !btn.hidden) btn.click();
});
await page.waitForTimeout(200);
const inSelectionBefore = await page.evaluate(() => document.body.dataset.selectionMode === 'true');
if (check('切到 已完成 视图后进入批量模式', inSelectionBefore)) pass++; else fail++;
// 切到「工作」分类(普通子分类)
await clickSidebarCategory(page, '工作');
const inSelectionAfter = await page.evaluate(() => document.body.dataset.selectionMode === 'true');
if (check('切到「工作」后批量模式自动退出', !inSelectionAfter)) pass++; else fail++;
const batchBarAfter = await page.evaluate(() => {
const bar = document.querySelector('.batch-action-bar');
return !bar || bar.hidden === true;
});
if (check('切分类后 batch 浮栏已隐藏', batchBarAfter)) pass++; else fail++;
// 切回「已完成任务」应能重新进入批量模式
await clickSidebarCategory(page, '已完成任务');
const batchVisibleAgain = await page.evaluate(() => !document.querySelector('#btn-batch').hidden);
if (check('重新进入 已完成 视图后 batch 按钮仍可见', batchVisibleAgain)) pass++; else fail++;
}
console.log('\n========== 场景 6空视图下 batch 按钮隐藏 ==========');
{
// 文档里把已完成任务和回收站都清空
const emptyDoc = [
'# 当前任务', '',
'# 全部任务', '',
'## 工作', '',
'- [ ] 工作任务 A', '',
'# 已完成任务', '',
'# 回收站', ''
].join('\n');
// 重新 setupStub 用空 doc —— 见 setupStub 注释addInitScript 闭包在 reload 时覆盖)
await setupStub(page, emptyDoc);
await page.evaluate(() => { window.__writes = []; });
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(800);
if (!(await page.evaluate(() => !!document.getElementById('app')))) {
await browser.close();
throw new Error('reload 后 app 未渲染');
}
// 已完成视图sidebar 里 smart-list item 一直渲染(即使为空),可直接点击
await clickSidebarCategory(page, '已完成任务');
const afterCompleted = await page.evaluate(() => ({
title: document.getElementById('category-title')?.textContent,
batchHidden: document.querySelector('#btn-batch').hidden,
taskCount: document.querySelectorAll('.task-item').length
}));
const completedBatchHidden = afterCompleted.batchHidden;
if (check('空 已完成 视图下 batch 按钮隐藏', completedBatchHidden, JSON.stringify(afterCompleted))) pass++; else fail++;
// 回收站视图sidebar 在 trash 为空时不渲染 item设计如此 —— 空回收站无入口)。
// 用户正常路径下到达空回收站的唯一方式:在回收站里批删所有任务 → _followTrashIfEmptied
// 会自动切走。所以这个边界场景无法在普通 UI 流里复现,跳过断言。
// (代码层面 hideBatch 在 visibleTasks.length===0 时一定为 true见 task-list.js:888-892)
}
console.log('\n========== 无 page error ==========');
if (check('无 page error', errors.length === 0, errors.join('; '))) pass++; else fail++;
await browser.close();
console.log(`\n==========\n通过 ${pass} / 失败 ${fail}\n==========`);
rmSync(tmpDir, { recursive: true, force: true });
process.exit(fail === 0 ? 0 : 1);