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,178 @@
// 回归:拖拽任务刚结束立刻点击 checkbox / ⭐ / ▶ / 编辑 / 删除按钮应该生效,
// 而不是被静默吞掉(间歇性「按钮没反应」现象)。
//
// 历史 bug
// _dragJustEnded 标志位在 pointerup 后被设为 true目的是吞掉浏览器随后派发
// 的合成 click/dblclick避免在松手位置触发意外选择或编辑。但标志位的「清
// 除」只发生在**非 wrapper 区**的 pointerdown 里 —— checkbox / ⭐ / ▶ / 编辑
// / 删除按钮的 pointerdown 都被 early-return 挡在 wrapper 检查之后,标志位
// 永远没机会重置。结果:拖完任务再点这些按钮,点一次没反应(被吞),点第二
// 次pointerdown 重置标志位)才生效。表现为「间歇性按钮无响应」。
//
// 修复:在 pointerdown handler 的最顶端无条件重置 _dragJustEnded同时加一个
// 250ms 兜底定时器(防止任何未来代码路径让标志位永久卡住)。
//
// 测项:
// 1) pointerdown handler 在 wrapper early-return 之前无条件重置 _dragJustEnded
// 2) pointerup handler 在设置 _dragJustEnded = true 后再启一个兜底定时器
// 3) 兜底定时器有 clear 路径destroy + 新一轮 pointerdown
// 4) _setupDrag 初始化时声明 _dragJustEndedTimer 字段
import { readFileSync } from 'node:fs';
import { check, ok, bad, printSummary } from './_lib/check.mjs';
const SRC = readFileSync('src/ui/task-list.js', 'utf8');
let pass = 0, fail = 0;
function expect(name, cond, info) {
if (cond) { ok(name); pass++; } else { bad(name, info); fail++; }
}
// ── 1. 核心修复点 ─────────────────────────────────────────────────────────
// pointerdown handler 最开始(任何 early-return 之前)必须无条件重置 _dragJustEnded。
// 用 [\s\S]{0,1500} 抓取整个 handler body确保检测的是同一个 handler 内部的相对位置。
//
// 回归签名:
// - 必须出现 `this._dragJustEnded = false;` 在 pointerdown handler 内部
// - 该赋值必须早于 `.task-checkbox-wrapper` early-return
// - 不能落在 early-return 之后
// 抓取 pointerdown handler 全部 body
const pointerDownMatch = SRC.match(
/this\.listEl\.addEventListener\(['"]pointerdown['"],\s*\(e\)\s*=>\s*\{([\s\S]*?)\}\);/
);
expect(
'pointerdown handler 可被静态解析',
!!pointerDownMatch,
'没找到 pointerdown handler'
);
if (pointerDownMatch) {
const body = pointerDownMatch[1];
const resetIdx = body.indexOf('this._dragJustEnded = false');
const wrapperEarlyReturnIdx = body.indexOf('.task-checkbox-wrapper');
const buttonEarlyReturnIdx = body.indexOf('.task-action-btn');
expect(
'pointerdown body 包含 `_dragJustEnded = false` 重置',
resetIdx >= 0,
'没找到 _dragJustEnded = false'
);
expect(
'pointerdown body 包含 .task-checkbox-wrapper 早退分支',
wrapperEarlyReturnIdx >= 0,
'没找到 .task-checkbox-wrapper 早退'
);
expect(
'_dragJustEnded 重置位于 wrapper 早退之前(防 wrapper-点击卡死)',
resetIdx >= 0 && wrapperEarlyReturnIdx >= 0 && resetIdx < wrapperEarlyReturnIdx,
`_dragJustEnded 重置在位置 ${resetIdx}wrapper 早退在 ${wrapperEarlyReturnIdx} —— 必须前者在前`
);
// 关键reset 必须在 button early-return 之前(按钮也是 wrapper 的一种)
expect(
'_dragJustEnded 重置位于 .task-action-btn 早退之前(防编辑/删除卡死)',
resetIdx >= 0 && buttonEarlyReturnIdx >= 0 && resetIdx < buttonEarlyReturnIdx,
`重置在 ${resetIdx}action 早退在 ${buttonEarlyReturnIdx}`
);
}
// ── 2. 兜底定时器 ─────────────────────────────────────────────────────────
// pointerup 在设 _dragJustEnded = true 之后必须再启一个 setTimeout 兜底。
// 250ms 上限:浏览器合成 click 一般 < 50ms250ms 已是绝对够用的上限。
//
// 注意pointerup handler 在源码里是命名函数 `const onPointerUp = (e) => { ... }`
// 然后 document.addEventListener('pointerup', onPointerUp) 把它挂上去 ——
// 不是 inline arrow。所以匹配的是 const onPointerUp = ... 整段。
const onPointerUpMatch = SRC.match(
/const\s+onPointerUp\s*=\s*\(e\)\s*=>\s*\{([\s\S]*?)\n\s{4}\};/
);
expect(
'onPointerUp handler 可被静态解析',
!!onPointerUpMatch,
'没找到 onPointerUp 命名函数'
);
if (onPointerUpMatch) {
const body = onPointerUpMatch[1];
const setTrueIdx = body.indexOf('this._dragJustEnded = true');
const timerIdx = body.search(/setTimeout\([\s\S]{0,200}?_dragJustEnded\s*=\s*false/);
expect(
'onPointerUp body 包含 `_dragJustEnded = true`',
setTrueIdx >= 0,
'没找到 _dragJustEnded = true'
);
expect(
'onPointerUp body 包含 250ms 兜底 setTimeout 重置 _dragJustEnded',
timerIdx >= 0,
'没找到 setTimeout(() => { this._dragJustEnded = false }, ...) 兜底'
);
expect(
'兜底 setTimeout 在 _dragJustEnded = true 之后',
setTrueIdx >= 0 && timerIdx >= 0 && timerIdx > setTrueIdx,
`setTimeout 在 ${timerIdx}setTrue 在 ${setTrueIdx}`
);
}
// ── 3. destroy 中清理定时器 ────────────────────────────────────────────────
const destroyMatch = SRC.match(/destroy\(\)\s*\{([\s\S]*?)\n \}/);
expect(
'destroy() 可被静态解析',
!!destroyMatch,
'没找到 destroy() 方法'
);
if (destroyMatch) {
const body = destroyMatch[1];
const clearsTimer = body.includes('_dragJustEndedTimer') &&
body.includes('clearTimeout');
expect(
'destroy() 清理 _dragJustEndedTimer防止组件销毁后定时器还在跑',
clearsTimer,
'destroy() 里没有清理 _dragJustEndedTimer'
);
}
// ── 4. _setupDrag 初始化时声明 _dragJustEndedTimer ─────────────────────────
const setupDragMatch = SRC.match(/_setupDrag\(\)\s*\{([\s\S]*?)\n \}/);
expect(
'_setupDrag() 可被静态解析',
!!setupDragMatch,
'没找到 _setupDrag() 方法'
);
if (setupDragMatch) {
const body = setupDragMatch[1];
// 期望在 _setupDrag 顶部声明 _dragJustEndedTimer = null与 _dragJustEnded = false 同行/相邻)
const declIdx = body.indexOf('this._dragJustEndedTimer');
expect(
'_setupDrag 内声明 _dragJustEndedTimer 字段',
declIdx >= 0,
'没找到 this._dragJustEndedTimer 初始化'
);
}
// ── 5. 选择模式下 ⭐/▶ 真正使用 .task-readonly-indicator 类 ─────────────────
// 防止「CSS 定义了但 JS 永不应用」的死代码状态。
const renderStarMatch = SRC.match(/class="task-star-btn[^"]*"/);
const renderCurMatch = SRC.match(/class="task-current-btn[^"]*"/);
expect(
'⭐ 按钮渲染带条件 class="task-readonly-indicator"',
!!renderStarMatch && /task-readonly-indicator/.test(renderStarMatch[0]),
'class="task-star-btn" 字符串里没有 task-readonly-indicator —— 选择模式下 CSS 仍是死代码'
);
expect(
'▶ 按钮渲染带条件 class="task-readonly-indicator"',
!!renderCurMatch && /task-readonly-indicator/.test(renderCurMatch[0]),
'class="task-current-btn" 字符串里没有 task-readonly-indicator'
);
printSummary('check-click-after-drag', true);