Files
MailUI4Agents/client/electron/test/calendar-slide.test.mjs
JianFeeeee 1f517eca4b fix(webui)★★: 对话树改成画布式真树 + 修两个线上 bug;日历翻月卡死
移植自本地 main(含 3536689..e670f6f 的最终状态)。
基于 origin/main 而非 rebase 本地 —— 本地那条链上的 6 个提交
(0a9287b/d52be3f/74aacc3/646cb3f/6e198d2/010c8ec)是远端已推内容的等价改写,
重复移植只会制造冲突;远端另有一条线上的成果(main.cjs/preload.cjs/
index.css/main.tsx/TitleBar.tsx/run-all.mjs 与 4 份 docs)本地没有,
**不能覆盖**,故从 origin/main 起只取本次真正改动的 5 个文件。

## 对话树:换成画布式真树

用户连续三轮反馈「这不还是老版的对话树」,最后明确要求:
「直接用你之前写的那个**可拖动缩放的对话树界面**替换现在这个对话树界面」。

我前两轮改错了方向 —— 一直在**缩进列表**上打补丁(改排序 → 改竖线 →
整份重写),而缩进列表**表达不了分支**:实测最深的线索 205 封、59 层,
多个兄弟追问并排时缩进线断成一片;画布上每条边都是独立的一条线。

现在:可拖动平移 / 滚轮缩放(20%–250%,锚定光标)/ 点节点看正文 /
分层 Reingold–Tilford 布局(不用力导向:位置会变、点不准)。

## 两个线上 bug(用户实测报告,我第一轮没看出来)

① **页面没撑满**:打开后 zoom=140%、pan.x=-9393,内容包围盒 11340×7193,
   屏幕上只看得到左上角。而手动点「适应」是对的(0.1217)——
   同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
   逐层实测揪出三个连环坑:`nodes` 是**根数组**(只 1 个根)、
   自动 fit 的 effect 没在数据到达后重跑、`fittedFor` 守卫置位太早把自己挡掉。
   另:`MailView` 的 `threadOf` 分支裸返回 `<ThreadView/>`,缺
   `flex-1 min-w-0 min-h-0` ⇒ 高度链断。

② **一缩放就崩**:`onWheel` 直接解引用 `e.currentTarget`,
   组件卸载后滚轮事件仍在飞 ⇒ `TypeError: Cannot read properties of null`。
   实测放大 20 次后面板消失。改成先取可空引用 + 空值守卫。

真机复测:200 节点 / 55 层,打开即 fit 到 12%,放大 20 次 + 缩小 25 次
全部 0 崩溃 0 错误。

## 日历:翻月卡死

用户实测「点击切换月份的按钮,直接从10月跳到八月卡死」。
CDP 复现:

    第1跳起:opacity="0",transform="translateX(-94.8px)",元素 x = -15
    右侧工具栏(导入/导出/新建)整块消失

根因:注释写着「用**声明式** key + 类」,实现却是两个 ref ——
**ref 改动不触发重渲染** ⇒ slideClass 只能用上一次的方向,且 class 名永不变
⇒ 动画只播一次不再重放。而 `animation-fill-mode: both` 把元素留在帧之间,
动画一旦没跑到终点就永久停在 `translateX(-12%) + opacity:0`。

修:ref → `useState<{dir,seq}>`;`seq` 进 key 让动画真重放;
加 260ms 自清理兜底(最坏只是没动画,不会卡死)。
复测:12 次连点,每次都是「class 已摘掉 OK」,侧栏/网格/工具栏全好。

## 判据

`thread-view-tree.test.mjs` 15 格 + `calendar-slide.test.mjs` 6 格,
每个 bug 都有对应的**可复现变异**(退回 ref / 去掉清理 / 去掉 seq …),
实测全部转红。

## 教训

两轮都只跑 `tsc` + 判据就宣布完成,然后请用户去看 ——
**「类型没错」不等于「页面能用」**。日历那一轮我更是盲巡检了十几轮
(还把「看起来被切」误判成 bug,最后自己量出 scrollHeight==clientHeight 才撤回)。
**先问清楚,比继续猜便宜得多。**
2026-10-05 09:09:36 +08:00

62 lines
3.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Electron 日历页的判据(2026-10-04)。
★ 起因:用户实测报告「点击切换月份的按钮,直接从10月跳到八月卡死」。
我用 CDP 真机复现时看到的是:
animation-play-state: running,opacity: 0,
transform: translateX(-94.8px),元素 x = -15(跑出视口)
⇒ 日历整块滑出屏幕且**永不回来**。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { code } from './lib/read.mjs';
const src = p => code(`src/${p}`);
test('★ 翻页动画状态必须是 state,不能是 ref', () => {
const cal = src('components/CalendarView.tsx');
// ★ 核心:注释写着「用**声明式** key + 类」,实现却是命令式 ref。
// ref 改动不触发重渲染 ⇒ slideClass 只能用上一次的方向,
// 且 class 名永不变 ⇒ 动画只播一次就停在起始帧 ⇒ 观感「卡死」。
assert.match(cal, /const \[slide, setSlide\] = useState/,
'★ 翻页动画状态还在用 ref —— ref 改动不触发重渲染,动画会卡在起始帧');
assert.doesNotMatch(cal, /navigated\.current|slideDir\.current/,
'★ 还在用 navigated/slideDir ref(命令式)—— 与注释宣称的「声明式」矛盾');
});
test('★ 每次翻页都要重放一次动画(key 含 seq)', () => {
const cal = src('components/CalendarView.tsx');
assert.match(cal, /seq: \(s\?\.seq \?\? 0\) \+ 1/, 'seq 没有递增');
assert.match(cal, /key=\{`\$\{anchor\.getTime\(\)\}-\$\{scale\}-\$\{slide\?\.seq \?\? 0\}`\}/,
'★ key 不含 seq —— 同方向连续翻页时动画可能不重放');
});
test('★ 动画结束必须摘掉 class(fill-mode:both 的兜底)', () => {
const cal = src('components/CalendarView.tsx');
// ★ animation-fill-mode: both 会把元素留在帧之间。动画一旦没跑到终点
// (连点 / 切后台 / reduced-motion 切换),元素就永久停在
// translateX(-12%) + opacity:0 —— 实测元素 x=-15,整块日历跑出视口。
assert.match(cal, /setTimeout\(\(\) => setSlide\(null\)/,
'★ 动画结束后没有清掉 class —— 元素会永久停在动画帧上(用户实测「卡死」)');
});
test('翻页方向只有一个来源(按钮与手势共用 shift)', () => {
const cal = src('components/CalendarView.tsx');
assert.match(cal, /function shift\(dir: 1 \| -1\)/, '没有统一的 shift()');
const n = (cal.match(/shift\(/g) || []).length;
assert.ok(n >= 3, `shift 只有 ${n} 处引用,按钮/手势可能各写一套`);
});
test('翻页用函数式更新(连点不丢更新)', () => {
const cal = src('components/CalendarView.tsx');
assert.match(cal, /setAnchor\(a => addMonths\(a, dir\)\)/,
'★ setAnchor 没用函数式更新 —— 连点时会基于同一个旧 anchor 算,跳月会丢步');
assert.match(cal, /addDays\(a, dir \* 7\)/, '周视图没有按 7 天翻页');
});
test('addMonths 先归到 1 号(setMonth 会溢出)', () => {
const lib = src('lib/calendar.ts');
assert.match(lib, /new Date\(d\.getFullYear\(\), d\.getMonth\(\) \+ n, 1\)/,
'★ addMonths 没有先归到 1 号 —— 1月31日加一月会溢出到 3 月');
});