/* 对话树 = 画布式真树(可拖动 / 可缩放 / 点节点看正文)的判据(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'); // 症状:「一缩放还崩溃」。 会让浏览器 // 按**内容包围盒**光栅化:205 节点缩放后包围盒可达 10^5 px,一次滚轮就抽干纹理内存。 assert.doesNotMatch(tv, /]*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\(\);/, '手动「适应」后没有清标记 ⇒ 之后再也不能自动重算'); });