Files
MailUI4Agents/client/electron/test/session-tree-list.test.mjs
JianFeeeee 7b0207b621 feat(线索树)★★: 卡片视图接线 + C 层 admin 全量邮件
## ① 卡片视图(WorkCard)

上一轮只接了列表视图 —— 但用户原话是「形成/展示为树结构」,
只在一个视图里成立不算:**切一下视图,线索树就没了**。

缩进比列表更紧(8px/级、上限 3 级):卡片本来就有三块内容
(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
挤掉的是**摘要本身** —— 摘要没了,卡片就只剩一个标题。

判据加一格专门盯「两个视图都接了」,否则这格缺口会一直没人看。

## ② C 层:admin 全量邮件(scope=all)

    GET /api/v1/me/mail/inbox?scope=all    admin 才有效
    GET /api/v1/me/mail/inbox              默认,仍是自己收件箱

三条边界,两个变异都转红:

| | 行为 | 变异后 |
|---|---|---|
| 非 admin 要 scope=all | **403** | 静默降级 200 ⇒ 转红 |
| 默认(无 scope) | 自己的,一封不多 | 身份隐式全给 ⇒ 转红 |
| 非法 scope | 400 | — |

**静默降级是最危险的那个**:调用方会以为拿到了全量(实际没有)——
「看起来能用的错答案」,比报错难查得多。

**默认不给 admin 全量**:全看必须显式要求,不能靠身份隐式获得。

### 为什么单独写 ListAllMails,没给 ListInboxScoped 加参数

`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
  ① SQL 里的 reader 过滤
  ② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
两者都必须有值 —— `requireReader` 就是为此存在。

若把「全量」做成「reader 传空」,那个非法状态看起来就合法了;
一旦放进去,**status 会静默变成未读** —— 一个没人会注意到、
却让「已读/未读」全面失真的坑。

同理,admin 全量视图里 `status=unread` **明确报错**而不是返回全部 ——
后者会让前端把整箱染成"未读"。admin 不是任何一封信的读者。

### scanMailRows 提取(repo.go +16/-0,纯新增)

第二份手写扫描副本的第一个分叉点必然是「admin 视图少算一个派生字段」,
而那在前端表现为某个徽标不见了,极难归因。⇒ 两处共用一份,
`ListInboxScoped` 行为一行未改。

## ★ 这不触碰 Agent 侧的收窄

`ListInboxScoped` 里 `to_name = reader OR cc` 那条是 **AgentAuth** 用的,
是 15e4fe9 / 095213b 修出来的越权防护。`scope=all` 是**人类登录态**下
admin 的显式全量视图,两条通道互不相干 —— 语义不同,不要混谈。

## 判据自己错了一次

`TestAdminScopeAllSeesEverything` 红在 500,报
`Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
其实是判据自己的数据不对(`mail_id` 是 UUID,我塞了 `"m-admin"`)。
**判据数据错了会伪装成被测代码坏了。**

14 包全绿;Electron 带改动 fail 9 / 基线 fail 12(减少 3 红、无新红,
剩下的是其他会话 harmony 设备判据的红)。
2026-10-04 12:03:58 +08:00

88 lines
4.4 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)。
★ 这批盯的是**接线的正确性**,不是「有没有缩进」:
1. depth 来源正确 —— 必须来自 /sessions/tree,且**按 session_id 连接**;
绝不能把树的节点直接当列表渲染(那是「我可见的全部会话」,
而列表是「我参与过的」—— 两者量级差两个数量级)。
2. 树不可用时**退化成平铺**,不报错也不空白。
3. 换账号/登出必须清 depthBySession —— session_id 不含账号维度,
不清就是把 A 账号的层级套到 B 账号的列表上(越界,不是显示瑕疵)。
4. 缩进有上限 —— 320px 侧栏里无限缩进会把地址 truncate 到读不出来。
源码每次现读(不模块级缓存):判据跑的是**当前文件**,
否则改了不重读会给出假绿。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const src = p => readFileSync(join(here, '..', 'src', p), 'utf8');
/*
★ 两个视图都要接缩进 —— 否则「切一下视图,线索树就没了」。
用户 2026-10-04 的原话是「形成/展示为树结构」,只在一个视图里成立不算。
*/
test('列表与卡片两个视图都接了缩进', () => {
const panel = src('components/ContactPanel.tsx');
const card = src('components/WorkCard.tsx');
assert.match(panel, /<ContactRow[\s\S]{0,240}?depth=\{depthBySession\[/,
'ContactRow 没收到 depth');
assert.match(panel, /<WorkCard[\s\S]{0,240}?depth=\{depthBySession\[/,
'★ WorkCard 没收到 depth —— 切到卡片视图线索树就消失了');
assert.match(card, /depth\?: number/, 'WorkCard 的 props 里没有 depth');
assert.match(card, /Math\.min\(Math\.max\(depth, 0\), 3\)/,
'WorkCard 缩进缺上限或阈值不对');
assert.match(card, /border-l border-b/, 'WorkCard 缺左框连接线');
});
test('depth 来自独立的树端点,不复用 /contacts', () => {
const client = src('api/client.ts');
assert.match(client, /export async function listSessionTree/, '缺 listSessionTree');
assert.match(client, /'\/sessions\/tree'/, '树端点没接上');
// 树与 contacts 必须各自请求,不得把树塞进 listContacts 的返回
assert.match(client, /listContacts[\s\S]{0,200}?\/contacts\?archived=/,
'listContacts 的口径被改了');
});
test('ContactPanel 按 depth 缩进且有上限', () => {
const panel = src('components/ContactPanel.tsx');
assert.match(panel, /Math\.min\(Math\.max\(depth, 0\), (\d+)\)/,
'ContactRow 没按 depth 缩进,或没有上限');
const m = panel.match(/Math\.min\(Math\.max\(depth, 0\), (\d+)\)/);
assert.ok(Number(m[1]) <= 4,
`缩进上限 ${m[1]} 太大:320px 侧栏里会把地址 truncate 到读不出层级`);
// 连接线:与 ThreadView 同一套视觉语言
assert.match(panel, /border-l border-b/, '缺左框连接线,与 ThreadView 不一致');
});
test('树不可用时退化为平铺,不让列表崩', () => {
const store = src('stores/contactStore.ts');
// 树失败必须被吞掉,且不影响 contacts 已 set 的结果
assert.match(store, /catch\s*\{\s*set\(\{\s*treeAvailable:\s*false\s*\}\)/,
'树拉取失败未降级 —— 一次树服务故障不该让整个联系人列表报错');
// contacts 的 set 必须在树的 try 之前、且提前 return
const idxContacts = store.indexOf('set({ contacts: contacts || [], loading: false })');
const idxTree = store.indexOf('api.listSessionTree()');
assert.ok(idxContacts > 0 && idxTree > idxContacts,
'树拉取必须排在联系人之后 —— 否则树慢 200ms 会让整个列表多等 200ms');
});
test('换账号必须清 depthBySession(跨账号越界)', () => {
const store = src('stores/contactStore.ts');
// reset / clear 分支
const clears = store.match(/depthBySession:\s*\{\}/g) || [];
assert.ok(clears.length >= 2,
`depthBySession 只清了 ${clears.length} 处(应在初始 state 与 reset 各一处)` +
' —— session_id 不含账号维度,不清就会把 A 账号的层级套到 B 账号');
// 初始 state 也必须有
const initIdx = store.indexOf('archivedContacts: [],');
assert.ok(initIdx > 0 && store.indexOf('depthBySession: {}', initIdx) > initIdx,
'初始 state 里没有 depthBySession');
});