// 验证「已完成任务」+「回收站」批量操作的真实数据流: // 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);