Files
MailUI4Agents/client/electron/test/harmony-contacts.test.mjs
JianFeeeee 4ff6b10260 跨端: 联系人页补齐「写信 / 归档」—— 顺带撞出三个只在跑起来才现形的 bug
用户:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
把两个客户端**同一个宽度**并排看之后,缺的很具体:WebUI 联系人卡片有
「写信 / 归档」,鸿蒙一个都没有。补的过程撞出三个缺陷,都不是"代码不合法"——
编译器与既有判据全绿:

## ① 归档打的是**服务端不存在**的路由(死函数)

`MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}`:

- `grep 'me/contacts' server/cmd/server/main.go` **零命中** ⇒ 按钮接上去就是 404;
- 全仓**没有任何调用方** ⇒ 它是个从没跑过的死函数,所以"没有归档按钮"这件事
  一直没暴露这个错。

服务端真实形状是 `POST /api/v1/contacts/archive`(`handler.ArchiveContact`),
body 二选一 `{session_id}` / `{address}`。**跟 WebUI 同口径用 session_id**:
address 会随别名变化,只有 session_id 是会话的身份。

## ② 用已存 token 恢复登录**永远进不去主界面**(最恶劣的一个)

`LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
(界面出现「登录成功:jianf」)却**从无跳转**。本文件另外两条成功路径
(`aboutToAppear` 快速路径、`doLogin` 末尾)都有跳转,唯独这条没有,
而它**恰恰是老用户最常走的那条**(重启时 token 还在,`doLogin` 根本不会被调用)。

症状最坏的地方是它**看起来是成功的**。实测(模拟器 + jianf 的永久 key):
日志只有 `→ GET /auth/me` 然后什么都没有。补齐 ① 跳转 ② 账号登记
③ SSE 连接(后两条是 `doLogin` 有而这里缺的,少了它们进主界面是个瘸的状态,
而且因为 `aboutToAppear` 的快速路径靠账号命中,下次启动还会重走这里 ⇒ 永远进不去)。

修后实测:启动即进主界面(可见文本变成「发件箱/授权/收件箱 · 2 组 · 50 封」)。

## ③ 时间戳原样印出来

卡片直接印 `last_activity` ⇒ 屏上是 `2026-09-18T02:50:35.49065Z`。
WebUI 是 `09/18 10:50`(`toLocaleString('zh-CN', {month,day,hour,minute})`,**本地**时区)。
加 `MailGrouping.shortTimeOf`(走 `Date` 取本地字段;**不许 `.slice()`** ——
那是拿 UTC 的月/日当本地时刻,UTC+8 的 09-15 00:30 会显示成 09-14 16:30)。
实测:`112 封 · 09/18 10:50`,与 WebUI 逐字一致。

## 补的界面(三折叠模拟器 3184px 展开态实测)

- 两种视图**各一处**「写信 / 归档」(WebUI 两视图同语义),WebUI 用 `.reveal`
  悬停显形,**鸿蒙不能照抄**:`index.css` 那段注释已经踩过这个坑
  (触摸设备没 hover ⇒ 按钮透明却仍可点,一个看不见却按得动的破坏性按钮更糟),
  WebUI 的修法是只在真支持悬停的设备上隐藏 ⇒ 鸿蒙这两个按钮**常显**。
- 归档先确认,**两视图共用同一个确认框**(WebUI 原话:换个视图就换套确认 UI
  只会让人对「自己点了什么」更没底);文案逐字一致。
- 列表视图的 meta 行原来放 `last_preview`,于是同一联系人在两视图里的关键信息
  不一致 ⇒ 改成与卡片视图同源(`N 封 · 时间`)。
- ★ 一处只有跑起来才会发现的坑:列表视图的 ListItem 钉死 `.height(85)` +
  `clip(true)`,而确认框比 85 高 ⇒ **按钮被裁掉、点不了也退不出**。
  确认态下高度交给内容自己定。

## 判据(新增 harmony-contacts,5 条;4 个变异方向都跑过)

判的都是"按下去会发生什么",不是"按钮在不在":
① 归档打的是服务端真有的路由(同时钉住**没有**再用那条不存在的 DELETE —— 只钉前者的话,
   加个平行实现也能全绿);② 两视图各一处动作且去向正确;③ 确认框只有 1 个定义、
   两视图都调它、文案逐字一致、确认态下点卡片不开会话;④ 时间戳走了格式化且
   函数本身不走 slice;⑤ **切出 `tryRestore` 的函数体**判它自己含跳转
   (只判全文件出现次数的话,另外两条路径里那两句就够让它变绿)。
2026-09-18 12:17:41 +08:00

174 lines
10 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.

/**
* 联系人页(工作列表 / 联系人)的判据 —— 与 WebUI `ContactPanel` 对齐。
*
* ── 这一整个文件的来历 ──
*
* 用户 2026-09-18:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
* 把两个客户端**同一个宽度**并排看之后,缺的东西很具体:联系人卡片上 WebUI 有
* 「写信 / 归档」,鸿蒙一个都没有。
*
* 补的时候撞出三个**只有跑起来才会现形**的问题,所以这个文件判的正是那三样
* (不是"按钮在不在",而是"按下去会发生什么"):
*
* ① `MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}` ——
* **服务端根本没有这条路由**,而且全仓没有调用方 ⇒ 它是个从没跑过的死函数。
* 按钮接上去的那一刻就是 404。服务端真实形状是 `POST /contacts/archive`
* (body `{session_id}` 或 `{address}`)。
*
* ② `LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
* (界面出现「登录成功:jianf」)却**从不跳转**。而这条恰恰是老用户最常走的
* 那条(重启时 token 还在,`doLogin` 根本不会被调用)。
*
* ③ 卡片把 `last_activity` **原样印出来** ⇒ 屏上是
* `2026-09-18T02:50:35.49065Z`,而 WebUI 是 `09/18 10:50`。
*
* ⚠️ 这个文件读的是**文本**。它不能替代设备验证:按钮真的画出来、点下去真的
* 发出那个请求、确认框真的换掉那一张卡片 —— 那三条我是在三折叠模拟器上
* 用 `dumpLayout` + `uitest uiInput click` 实测过的(见提交信息)。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
/** 去注释:判据要判**代码**,不是判我们自己在注释里写的那些话 */
function code(p) {
return readFileSync(p, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
const MAIN = join(ETS, 'pages', 'MainPage.ets');
const API = join(ETS, 'api', 'MailApi.ets');
const LOGIN = join(ETS, 'pages', 'LoginPage.ets');
const GROUP = join(ETS, 'model', 'MailGrouping.ts');
test('★ 归档打的是服务端**真有的**那条路由(POST /contacts/archive,不是不存在的 DELETE /me/contacts/…)', () => {
/*
* 这条判的是 ① 。原来那个实现不只是"过时"——它指向的路径
* 在 `server/cmd/server/main.go` 里**一条都没有**,所以按下去必然 404,
* 而按钮一旦出现在界面上,用户看到的就是"点了没反应/报错"。
*
* 判据形状:既钉住**用了** `POST /contacts/archive`,也钉住
* **没有**再用那条不存在的 DELETE 路径。只钉前者的话,加回一个
* 平行实现(两个都发)也能全绿 —— 那不是"修好了"。
*/
const src = code(API);
assert.match(src, /this\.client\.post<[^>]*>\(\s*'\/contacts\/archive'/,
'archiveContact 必须 POST /contacts/archive(服务端 handler.ArchiveContact 的真实路由)');
assert.doesNotMatch(src, /\/me\/contacts\//,
'不要再打 /me/contacts/… —— 服务端没有这条路由(这次修的就是它)');
/*
* body 必须带 session_id:服务端 archiveRequest 接受 session_id 或 address,
* 但 address 会随别名变化,只有 session_id 是会话的身份。
* WebUI 也用 session_id(contactStore.archive → api.archiveContact({session_id}))。
*/
assert.match(src, /session_id/, '归档请求体要带 session_id(address 会随别名变化,不是身份)');
});
test('★ 「写信 / 归档」两个动作在**两种视图**里都有(WebUI 两视图同语义)', () => {
/*
* WebUI `ContactPanel` 的卡片视图(`WorkCard`)与列表视图(`ContactRow`)
* 都带这两个动作,且**共用同一个确认框**(原话:归档是破坏性操作,
* 换个视图就换套确认 UI 只会让人对「自己点了点什么」更没底)。
* 鸿蒙两视图都要有 —— 只加一边的话,切到另一边就像"这个功能没了"。
*/
const src = code(MAIN);
const writes = [...src.matchAll(/this\.CardAction\('写信'[\s\S]{0,80}?composeTo\(c\)/g)].length;
const archives = [...src.matchAll(/this\.CardAction\('归档'[\s\S]{0,80}?requestArchive\(c\)/g)].length;
assert.equal(writes, 2, `「写信」要在卡片视图与列表视图各一处,实际 ${writes} 处`);
assert.equal(archives, 2, `「归档」要在卡片视图与列表视图各一处,实际 ${archives} 处`);
// 两个动作各自的去向也要对:写信 → composeTo(预填该地址),归档 → requestArchive(先确认)
assert.match(src, /composeTo\(c: Contact\)[\s\S]{0,400}?to: c\.address/,
'composeTo 要把该联系人的三维地址预填进 ComposeParams(WebUI: startCompose({to: c.address}))');
assert.match(src, /requestArchive\(c: Contact\)[\s\S]{0,200}?pendingArchiveId = c\.session_id/,
'requestArchive 只打开确认框、不发请求 —— 破坏性操作先确认');
});
test('★ 归档要**先确认**,且两种视图共用同一个确认框(不是各画一个)', () => {
/*
* 「共用」这条必须是机器可判的:如果两个视图各画一个确认框,
* 文案就会各自漂移,而漂移之后没人会同时看两边。
* 判据:确认框的 builder 只有一个定义,两个视图都调它。
*/
const src = code(MAIN);
const defs = [...src.matchAll(/@Builder\s+ArchiveConfirmCard\(/g)].length;
assert.equal(defs, 1, `ArchiveConfirmCard 只应有 1 个定义,实际 ${defs} 个`);
const calls = [...src.matchAll(/this\.ArchiveConfirmCard\(c\)/g)].length;
assert.equal(calls, 2, `两个视图都要调同一个确认框,实际 ${calls} 处调用`);
// 文案与 WebUI `ArchiveConfirm` 逐字一致(用户是在两个客户端之间来回看的)
assert.match(src, /'归档 ' \+ c\.address \+ '?'/, '确认框主句要与 WebUI 逐字一致:归档 <address>?');
assert.match(src, /'对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除'/,
'确认框副句要与 WebUI 逐字一致');
// 确认态下点卡片不许打开会话(那块区域已经是确认框了)
assert.match(src, /if \(this\.pendingArchiveId !== c\.session_id\) \{[\s\S]{0,80}?this\.openSession\(c\);/,
'确认态下卡片的点击不能再打开会话');
});
test('★ 时间戳要按 WebUI 的口径格式化(不是把 ISO 串原样印出来)', () => {
/*
* 这条判的是 ③ 。原样印 `last_activity` 得到的是
* `2026-09-18T02:50:35.49065Z` —— 秒级微秒的 UTC 串。
* WebUI 用 `toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})`
* ⇒ `09/18 10:50`(**本地**时区)。
*
* 两件事都要判:卡片/列表**用了**格式化函数,且那个函数走 `Date` 取本地字段
* —— 不许 `.slice()` 切字符串(那是拿 UTC 的月/日当本地时刻用,
* UTC+8 的 09-15 00:30 会显示成 09-14 16:30,而格子/表头全都正常)。
*/
const main = code(MAIN);
assert.doesNotMatch(main, /\+ c\.last_activity\b/,
'last_activity 不许原样拼接显示(会印出 ISO 串),要走 shortTimeOf');
assert.match(main, /shortTimeOf\(c\.last_activity\)/, '要用 shortTimeOf 格式化');
const g = code(GROUP);
assert.match(g, /export function shortTimeOf\(/, 'MailGrouping 要导出 shortTimeOf');
const fn = g.slice(g.indexOf('export function shortTimeOf('));
assert.match(fn, /new Date\(ms\)/, '必须走 Date 解析');
assert.match(fn, /getMonth\(\)/, '月份要取**本地**字段(getMonth),不是 UTC');
assert.match(fn, /getDate\(\)/, '日期要取本地字段(getDate)');
assert.doesNotMatch(fn.slice(0, 900), /slice\(/, '不许用 slice 切 ISO 字符串当日期');
// 自检:探测器要真能分辨"格式化过"与"原样印"
assert.match('shortTimeOf(c.last_activity)', /shortTimeOf\(c\.last_activity\)/);
assert.doesNotMatch("' 封 · ' + c.last_activity", /shortTimeOf\(c\.last_activity\)/);
});
test('★ 用已存 token 恢复登录也必须**真的进主界面**(不能只显示「登录成功」)', () => {
/*
* 这条判的是 ② ,也是这一轮里最恶劣的一个 —— 因为它**看起来是成功的**:
* 界面上出现「登录成功:jianf」,然后永远停在登录页。
* 实测证据:模拟器日志只有 `→ GET /auth/me` 然后什么都没有。
*
* 判据形状(不是"文件里有 pushUrl"这种能靠别处蒙混过去的写法):
* 把 `tryRestore` 的**函数体**切出来,要求它自己含跳转主界面那句。
* 只判全文件出现次数的话,另外两条路径里那两句就够让它变绿。
*/
const src = code(LOGIN);
const start = src.indexOf('async tryRestore(');
assert.ok(start > 0, 'LoginPage 要有 tryRestore');
// 从函数头到下一个同缩进的成员定义为止
const rest = src.slice(start);
const end = rest.indexOf('\n }\n');
const body = rest.slice(0, end > 0 ? end : 2000);
assert.match(body, /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
'tryRestore 成功后必须跳转主界面 —— 它原来只设 loggedIn=true 就结束了');
/*
* 还要登记账号 + 建 SSE:少了它们,"恢复登录"进主界面是个瘸的状态
* (设置页看不到账号;而 aboutToAppear 的快速路径正是靠账号命中的,
* 所以下次启动又会重走 tryRestore ⇒ 永远进不去)。
*/
assert.match(body, /addAccount\(/, 'tryRestore 要把账号写进多账号管理器(否则下次启动还会重走这里)');
assert.match(body, /connectForAccount\(/, 'tryRestore 要建 SSE(否则主界面收不到实时事件)');
// 自检:这条判据必须能抓到"只设 loggedIn 不跳转"那个原形状
const broken = "async tryRestore(t) {\n this.loggedIn = true;\n }\n";
const bStart = broken.indexOf('async tryRestore(');
const bRest = broken.slice(bStart);
const bEnd = bRest.indexOf('\n }\n');
assert.doesNotMatch(bRest.slice(0, bEnd > 0 ? bEnd : 2000), /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
'自检:原形状(只有 loggedIn=true)必须判红');
});