update
This commit is contained in:
275
scripts/verify-batch-move-menu.mjs
Normal file
275
scripts/verify-batch-move-menu.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
// 验证 showContextMenu / _showBatchMoveMenu 修复:
|
||||
// 1) 长分类名 → 菜单不应被右边切
|
||||
// 2) 很多分类 → 应可滚动 + 滚动条可见
|
||||
// 3) 窄窗口 → 应 clamp 到视口内
|
||||
// 4) 普通情况 → 仍能右对齐 batchBar
|
||||
//
|
||||
// 用 Playwright 跑实际浏览器,注入 stub window.api,直接走 task-list.js。
|
||||
|
||||
import { chromium } from 'playwright-core';
|
||||
import { writeFileSync, 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-move-test-'));
|
||||
|
||||
function buildTodo(longNames, count) {
|
||||
// 通用 tasks + 容器 + 任意条数的子分类(其中一些名字超长)
|
||||
const cats = [];
|
||||
// longNames 可能为 null(场景 2),此时全部用默认名生成
|
||||
const names = longNames || [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = names[i] || `分类 ${i}`;
|
||||
cats.push(`## ${name}\n\n- [ ] 长名字的分类测试任务 ${i}`);
|
||||
}
|
||||
return `# 全部任务\n\n${cats.join('\n\n')}\n\n# 已完成任务\n\n# 回收站\n`;
|
||||
}
|
||||
|
||||
async function setup(store, todoMd) {
|
||||
await store.evaluate((md) => {
|
||||
window.__initialMd = md;
|
||||
}, todoMd);
|
||||
}
|
||||
|
||||
async function runScenario(label, opts) {
|
||||
const { windowWidth, windowHeight, longNames, count, screenshot = false } = opts;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: windowWidth, height: windowHeight } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`console.error: ${msg.text()}`);
|
||||
});
|
||||
|
||||
// 在 goto 之前用 addInitScript 注册 stub —— addInitScript 注册的脚本
|
||||
// 会在页面每次重新加载时(含 location.reload())都执行,存活的 window 全局
|
||||
// 变量也会被 location.reload() 清掉,所以 stub 必须放在 addInitScript 里。
|
||||
const todoMd = buildTodo(longNames, count);
|
||||
await page.addInitScript((md) => {
|
||||
const noop = () => {};
|
||||
const noopUnsub = () => noop;
|
||||
// 完整 stub:与 preload.js 暴露的 api 字段对齐 —— 缺一个就 bootstrap 失败
|
||||
window.api = {
|
||||
isDev: false,
|
||||
// 文件操作
|
||||
saveFileDialog: async () => null,
|
||||
readFile: async () => ({ ok: true, content: md }),
|
||||
writeFile: async () => ({ 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
|
||||
};
|
||||
window.__todoMd = md;
|
||||
}, todoMd);
|
||||
|
||||
await page.goto(BASE + '/index.html');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
// 给 app 一点时间渲染
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 进入第一个子分类(普通视图)→ 触发批量模式 → 选中 1 条 → 移到分类
|
||||
// 选第一个非「未分类」、非特殊分类的子分类
|
||||
// 注意:用 data-category 判定,不要用 textContent —— 因为 DOM 里 .category-count
|
||||
// 徽章会和分类名拼在同一行(带换行和空格),trim 后仍是 "未分类\n 0"
|
||||
// 这种格式,与 '未分类' 不等,循环会跳过判断误把「未分类」当成可点击目标。
|
||||
const catClicked = await page.evaluate(() => {
|
||||
const items = document.querySelectorAll('.category-item.subcategory');
|
||||
// 跳过第一个(通常是「未分类」)
|
||||
for (const el of items) {
|
||||
const name = el.dataset.category;
|
||||
if (name && name !== '未分类') {
|
||||
el.click();
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 点批量按钮
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('#btn-batch');
|
||||
if (btn) btn.click();
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// 选中第一条任务
|
||||
await page.evaluate(() => {
|
||||
const item = document.querySelector('.task-item');
|
||||
if (item) item.click();
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// 点「移到分类」按钮
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('.batch-action[data-action="move"]');
|
||||
if (btn) btn.click();
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// 测量菜单的位置和视口关系
|
||||
const menuInfo = await page.evaluate(() => {
|
||||
const menu = document.getElementById('context-menu');
|
||||
if (!menu || menu.hidden) return null;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
visible: !menu.hidden,
|
||||
viewportW: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
itemCount: menu.querySelectorAll('.context-menu-item').length,
|
||||
// 滚动条是否可见(overflow-y: auto + 内容超 max-height)
|
||||
hasScroll: menu.scrollHeight > menu.clientHeight,
|
||||
// 哪些项被切(**视口外**,不是菜单外)—— 菜单本身 overflow-y:auto,
|
||||
// 滚到下方的项仍可访问,不算 bug。真实 bug 是菜单溢出 viewport 让用户看不到入口。
|
||||
itemsClipped: (() => {
|
||||
const items = [...menu.querySelectorAll('.context-menu-item')];
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const visible = items.filter(el => {
|
||||
const ir = el.getBoundingClientRect();
|
||||
// 只检查「被 viewport 切」—— 完全在视口外的项(包括菜单滚动到下方的项)
|
||||
// 不算 clipped,只要菜单本身在视口内且有滚动条就 OK
|
||||
return ir.right <= vw && ir.left >= 0 && ir.bottom <= vh && ir.top >= 0;
|
||||
});
|
||||
return items.length - visible.length;
|
||||
})()
|
||||
};
|
||||
});
|
||||
|
||||
if (screenshot) {
|
||||
await page.screenshot({ path: join(tmpDir, `${label}.png`), fullPage: false });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
return { label, catClicked, menuInfo, errors };
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
console.log('=== 场景 1:长分类名 ===');
|
||||
const r1 = await runScenario('long-names', {
|
||||
windowWidth: 1024,
|
||||
windowHeight: 768,
|
||||
longNames: [
|
||||
'这是一个非常非常长的子分类名字用来测试溢出',
|
||||
'工作',
|
||||
'学习',
|
||||
'生活',
|
||||
'个人事务与重要客户跟进'
|
||||
],
|
||||
count: 5
|
||||
});
|
||||
results.push(r1);
|
||||
console.log(JSON.stringify(r1, null, 2));
|
||||
|
||||
console.log('\n=== 场景 2:很多分类(应可滚动)===');
|
||||
const r2 = await runScenario('many-cats', {
|
||||
windowWidth: 1024,
|
||||
windowHeight: 600,
|
||||
longNames: null,
|
||||
count: 20
|
||||
});
|
||||
results.push(r2);
|
||||
console.log(JSON.stringify(r2, null, 2));
|
||||
|
||||
console.log('\n=== 场景 3:窄窗口 ===');
|
||||
const r3 = await runScenario('narrow-window', {
|
||||
windowWidth: 600,
|
||||
windowHeight: 500,
|
||||
longNames: null,
|
||||
count: 5
|
||||
});
|
||||
results.push(r3);
|
||||
console.log(JSON.stringify(r3, null, 2));
|
||||
|
||||
console.log('\n=== 场景 4:超窄窗口(菜单应 clamp)===');
|
||||
const r4 = await runScenario('very-narrow', {
|
||||
windowWidth: 380,
|
||||
windowHeight: 500,
|
||||
longNames: null,
|
||||
count: 5
|
||||
});
|
||||
results.push(r4);
|
||||
console.log(JSON.stringify(r4, null, 2));
|
||||
|
||||
// 验证
|
||||
function check(name, cond, info) {
|
||||
const tag = cond ? '✓' : '✗';
|
||||
console.log(`${tag} ${name}${info ? ` (${info})` : ''}`);
|
||||
return cond;
|
||||
}
|
||||
|
||||
console.log('\n========== 验证 ==========');
|
||||
let pass = 0, fail = 0;
|
||||
for (const r of results) {
|
||||
console.log(`\n[${r.label}]`);
|
||||
if (!r.menuInfo) {
|
||||
console.log(' ✗ 菜单未显示');
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
const m = r.menuInfo;
|
||||
// 1. 右边不超过视口
|
||||
if (check('菜单右边 ≤ 视口右边', m.right <= m.viewportW - 4, `right=${m.right}, vw=${m.viewportW}`)) pass++; else fail++;
|
||||
// 2. 左边不小于 4
|
||||
if (check('菜单左边 ≥ 4', m.left >= 4, `left=${m.left}`)) pass++; else fail++;
|
||||
// 3. 底边不超过视口
|
||||
if (check('菜单底边 ≤ 视口底边', m.bottom <= m.viewportH - 4, `bottom=${m.bottom}, vh=${m.viewportH}`)) pass++; else fail++;
|
||||
// 4. 顶边不小于 4
|
||||
if (check('菜单顶边 ≥ 4', m.top >= 4, `top=${m.top}`)) pass++; else fail++;
|
||||
// 5. 无项被切 —— 仅当菜单**没有**滚动时检查。
|
||||
// 菜单 overflow-y:auto 时,滚到下方的项虽然 DOM 存在但会被 viewport 切
|
||||
// (这是正常的滚动行为,用户主动 scrollTop 才能看到),不算 bug。
|
||||
// 真实 bug 是「菜单本身溢出 viewport」,由上面的 1/3 项覆盖。
|
||||
if (!m.hasScroll) {
|
||||
if (check('菜单内所有项可见(不被切)', m.itemsClipped === 0, `clipped=${m.itemsClipped}`)) pass++; else fail++;
|
||||
} else {
|
||||
if (check('菜单可滚动时跳过项可见性检查(滚动是正常的)', true)) pass++;
|
||||
}
|
||||
// 6. 有滚动条当内容超出
|
||||
if (m.itemCount > 15) {
|
||||
if (check('内容多时滚动条可见', m.hasScroll, `scrollH=${m.hasScroll}`)) pass++; else fail++;
|
||||
}
|
||||
// 7. 无 page error
|
||||
if (check('无 JS 错误', r.errors.length === 0, r.errors.join('; '))) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log(`\n==========\n通过 ${pass} / 失败 ${fail}\n==========`);
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user