Files
MailUI4Agents/client/electron/test/thread-view-tree.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

202 lines
12 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.

/*
对话树 = 画布式真树(可拖动 / 可缩放 / 点节点看正文)的判据(2026-10-04)。
★ 演进(用户连续几轮反馈「还是老版」):
① 原实现是缩进列表 → 我改排序/竖线/缩进上限,仍像平铺(缩进列表
**表达不了分支**:59 层里多个兄弟并排时线断成一片)
② 整份重写为缩进列表的改良版 → 仍不是用户要的东西
③ **本轮**:用户明确「直接用你之前写的那个可拖动缩放的对话树界面替换
现在这个对话树界面」⇒ ThreadView 改成画布树。
★ 本文件盯画布树的三个能力 + 一条禁止退回的守卫。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { code, prose } from './lib/read.mjs';
const src = p => code(`src/${p}`);
test('会话视图保持平铺(范围纪律,用户 2026-10-04)', () => {
const view = src('components/MailView.tsx');
assert.match(view, /currentSessionMails\.map\(/, '会话视图不该变成树');
});
test('★ 可拖动:指针拖拽改平移,且绑在容器上', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /const onPointerDown = useCallback/, '没有 onPointerDown 实现');
// ★ 必须查**绑定**而不只是字面量:变异把 JSX 上的 onPointerDown 换成注释时,
// 早期版本判据全绿(函数还在,只是没接上)。
assert.match(tv, /onPointerDown=\{onPointerDown\}/, 'onPointerDown 没绑到容器 ⇒ 拖不动');
assert.match(tv, /onPointerMove=\{onPointerMove\}/, 'onPointerMove 没绑 ⇒ 拖不动');
assert.match(tv, /setPointerCapture/, '未捕获指针 —— 拖出元素会中断');
assert.match(tv, /cursor-grab/, '缺拖动光标提示');
// 点在节点上时不启动平移(否则点一下会把整棵树拖走)
assert.match(tv, /closest\('\[data-node\]'\)/, '点节点也会启动平移');
});
test('★ 可缩放:滚轮缩放且锚定光标', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /const onWheel = useCallback/, '没有 onWheel 实现');
assert.match(tv, /onWheel=\{onWheel\}/, '★ onWheel 没绑 ⇒ 滚轮不缩放');
// ★ 必须断言**结果里真的用上了光标坐标**,而不只是「代码里出现过它」。
// 变异「把 setPan 改成原样返回(坐标照算但不用)」时,
// `clientX - rect.left` 仍在源码里 ⇒ 只查字面量会假绿。
assert.match(tv, /setPan\(p =>\s*\(\{\s*\n?\s*x: cx -/,
'★ 缩放结果没有按光标锚定 —— 缩放时内容会从光标下「跳」走');
assert.match(tv, /MIN_ZOOM/, '没有缩放下限');
assert.match(tv, /适应/, '没有「适应」按钮(60% 缩放下 205 封的树没法看)');
});
test('★ 可点击看正文', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /onClick=\{\(\) => pick\(n\)\}/, '节点不可点击');
assert.match(tv, /setDetail\(n\)/, '点了没有正文');
assert.match(tv, /body_preview \|\| detail\.body/, '正文没有渲染');
assert.match(tv, /openMailByID\(detail\.mail_id\)/, '没有「打开完整正文」的出口');
});
test('★ 布局是分层画布,不是缩进列表', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /function layout\(nodes: ThreadNode\[\]\): Placed\[\]/, '没有画布布局');
assert.match(tv, /x: number/, '节点没有坐标 ⇒ 不是画布');
assert.match(tv, /nodes - Math\.max|calc\(c\)/, '没有按子树宽度分配列');
// ⚠️ 力导向:位置每次变 ⇒ 点不准、两次打开画成不同样
assert.doesNotMatch(tv, /simulation|velocity|forceLayout/,
'用了力导向 —— 节点位置不稳定,点不准');
// ★ 禁止退回缩进列表
assert.doesNotMatch(tv, /paddingLeft: indent|Math\.min\(Math\.max\(node\.depth/,
'★ 又退回缩进列表了 —— 那表达不了分支,正是「看起来还是平铺」的原因');
});
test('数据源是邮件线索(不是会话列表)', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /getMailThread/, '没有读邮件线索');
assert.doesNotMatch(tv, /listSessionTree|listContacts/,
'用了会话级数据 —— 那是 198 个会话,不是这条线索的 205 封邮件');
const m = prose('src/components/ThreadView.tsx').match(/PAGE_SIZE = (\d+)/);
assert.ok(Number(m[1]) >= 200,
`首屏 ${m[1]} 封 —— 实测最深的线索有 205 封,60 会把后半截藏起来`);
});
test('parent_mail_id 外部可写 ⇒ 防环 + 不丢信', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /onPath\.has\(n\.mail_id\)/,
'无环保护 —— 成环会递归不死(205 封的面板直接白屏)');
assert.match(tv, /p\.mail_id !== n\.mail_id/, '没排除自引用');
});
test('★ 打开就 fit(否则 200 节点只露左上角 = 没撑满)', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /const run = \(\) => \{ fitTo\(\); setFittedFor\(mailID\); \};/,
'没有自动 fit 的入口');
// ★ 实测两条死路:load 里直调 → 首帧 rect 未完成 ⇒ 算出 1.4 上限值;
// load 里套 rAF → 仍被初始 state 的重渲染盖掉。
// 正确位置是「nodes 落地后、且只做一次」的 effect。
assert.match(tv, /if \(!nodes\.length \|\| fittedFor === mailID\) return;/,
'★ 自动 fit 没有只做一次的守卫 —— ResizeObserver 会反复重置用户拖好的视口');
assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/,
'★ 没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的');
// ★ 关键回归:首帧 wrapRef 还是 null,量不到尺寸就 return ⇒ fit 永远不跑
assert.match(tv, /if \(!rect \|\| !rect\.width \|\| !rect\.height\) return;/,
'量不到尺寸时没有安全返回');
assert.match(tv, /new ResizeObserver/, '没有容器尺寸变化时的重算');
assert.match(tv, /new ResizeObserver/, '没有监听容器尺寸变化');
assert.match(tv, /ro\.observe\(wrapRef\.current\)/, 'ResizeObserver 没有观察画布容器');
assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/,
'没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的');
// fit 要同时按宽高算,只看宽会被 59 层(高 7000+)拖成极小
const m = tv.match(/rect\.height - 48\) \/ Math\.max\(maxY, 1\)/);
assert.ok(m, 'fit 只按宽度算 ⇒ 59 层的树被压成一条');
});
test('★ 滚轮不崩:不能解引用可能为 null 的元素', () => {
const tv = src('components/ThreadView.tsx');
// 用户实测:放大后 TypeError: Cannot read properties of null
assert.match(tv, /const el = e\.currentTarget as HTMLElement \| null;/,
'没有先取一次可空引用');
assert.match(tv, /if \(!el\) return;/, '★ 没做空值守卫 —— 卸载后滚轮会抛异常');
assert.doesNotMatch(tv, /\(e\.currentTarget as HTMLElement\)\.getBoundingClientRect\(\)/,
'★ 直接解引用 currentTarget —— 卸载后即为 null');
});
/*
★ 两个线上症状的判据(用户 2026-10-04 实测报告,我第一轮没看出来)。
两轮都只跑了 tsc + 判据就宣布完成 —— 「类型没错」不等于「页面能用」。
*/
test('★ 撑满:ThreadView 必须被 flex 容器包住', () => {
const view = src('components/MailView.tsx');
// 症状:「页面没有撑满」。threadOf 分支是**裸返回**,
// 不像正常分支那样带 flex-1 min-w-0 min-h-0 ⇒ 高度链断、内容塌陷。
const m = view.match(/if \(threadOf\)\s*\{[\s\S]{0,400}?return \(([\s\S]{0,400}?)\);/);
assert.ok(m, '找不到 threadOf 分支');
assert.match(m[1], /flex-1/,
'★ threadOf 分支裸返回 ThreadView,没有 flex-1 ⇒ 面板不撑满(用户实测「页面没有撑满」)');
assert.match(m[1], /min-h-0/, '没有 min-h-0 ⇒ 高度链断');
assert.match(m[1], /min-w-0/, '没有 min-w-0 ⇒ 宽屏下被内容撑出横向滚动');
});
test('★ 缩放不崩:边不能画在 width={1} 的 overflow-visible svg 里', () => {
const tv = src('components/ThreadView.tsx');
// 症状:「一缩放还崩溃」。<svg width={1} height={1} overflow-visible> 会让浏览器
// 按**内容包围盒**光栅化:205 节点缩放后包围盒可达 10^5 px,一次滚轮就抽干纹理内存。
assert.doesNotMatch(tv, /<svg[^>]*overflow-visible/,
'★ 用了 overflow-visible 的溢出 svg —— 缩放时渲染进程会崩(用户实测)');
assert.doesNotMatch(tv, /className="absolute overflow-visible[^"]*"\s*width=\{1\}/,
'★ 1×1 的溢出 svg(同上)');
// 每条边要有自己的包围盒,svg 才能拿到真实尺寸
assert.match(tv, /viewBox=\{`0 0 /, '边没有独立包围盒 ⇒ svg 尺寸仍是 1×1');
assert.match(tv, /dx: number; dy: number;/, 'Edge 没有包围盒字段');
});
test('画布有真实尺寸(绝对定位的子元素需要参照)', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /const bounds = useMemo\(\(\) => boundsOf\(flat\)/,
'没有算内容包围盒');
assert.match(tv, /position: 'absolute',[\s\S]{0,200}?width: bounds\.w/,
'画布没设尺寸 —— 绝对定位的节点没有参照物');
});
/*
★ 这一格盯的是本轮最贵的两个坑(用户 2026-10-04 实测发现「页面没撑满」)。
实测现象:200 节点 / 55 层的树,打开后 zoom=140%、pan.x=-9393,
内容包围盒 11340x7193 —— 屏幕上只看得到左上角一小块。
而**手动点「适应」按钮算出来的却是正确的 0.1217**。
同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
*/
test('★ fit 必须读**扁平**节点,不是根节点数组', () => {
const tv = src('components/ThreadView.tsx');
// ★ nodes 是「根节点数组」(这棵树上只有 1 个根),
// 直接喂给 fit ⇒ span 260 ⇒ z 顶到上限 1.4 ⇒ 只露左上角。
assert.match(tv, /nodesRef\.current = flat;/,
'★ ref 存的是 nodes(根数组)而不是 flat —— fit 只看得到根,缩放算错');
const m = tv.match(/const flat = useMemo\(\(\) => flatten\(nodes\), \[nodes\]\);[\s\S]{0,200}?nodesRef\.current = flat;/);
assert.ok(m, 'nodesRef.current = flat 不在 flat 定义之后 —— 顺序反了会拿到上一轮的值');
// fitTo 不接受参数,只读 ref ⇒ 闭包不会捕获旧数组
assert.match(tv, /const fitTo = useCallback\(\(\) => \{/, 'fitTo 又开始接收参数了');
assert.match(tv, /const flat = nodesRef\.current;/, 'fitTo 没有读 ref');
});
test('★ 自动 fit 的 effect 必须依赖 nodes(数据到了要重跑)', () => {
const tv = src('components/ThreadView.tsx');
// 实测:effect 只在 nodes=0 时跑了一次就 return,之后数据到了不再触发
// ⇒ 永远停在初始 zoom。setFittedFor('') 与 setNodes 同批次会被 React bail out,
// 所以依赖里必须有 nodes。
const m = tv.match(/useEffect\(\(\) => \{\s*\n?\s*if \(!nodes\.length \|\| fittedFor === mailID\) return;[\s\S]*?\}, \[([^\]]*)\]\);/);
assert.ok(m, '找不到自动 fit 的 effect');
assert.match(m[1], /nodes/, '★ effect 依赖里没有 nodes —— 数据到达后不会重跑(实测卡在初始 zoom)');
});
test('用户拖/缩过之后不再自动重置视口', () => {
const tv = src('components/ThreadView.tsx');
assert.match(tv, /const touched = useRef\(false\)/, '没有「用户动过」标记');
assert.match(tv, /if \(touched\.current\) return;/,
'ResizeObserver 会把用户拖好的视口反复重置');
// 手动「适应」要清标记
assert.match(tv, /touched\.current = false;\s*\n\s*fitTo\(\);/,
'手动「适应」后没有清标记 ⇒ 之后再也不能自动重算');
});