Files
MailUI4Agents/client/electron/test/harmony-contacts.test.mjs
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

194 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.

/**
* 联系人页(工作列表 / 联系人)的判据 —— 与 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 { code, prose } from './lib/read.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
/*
* ★ 2026-09-19:本文件原先**自造**了一个 `code()`(自己写去注释正则)。
* `criteria-hygiene` 判据拦的正是这个 —— 判据目录有一条"裸用 readFileSync"的
* 禁令,理由不是洁癖:`code`/`prose` 的选择决定"判的是代码还是注释",
* 各文件自造一份,就等于每个文件对同一件事有各自的说法。
* 改用 `test/lib/read.mjs` 的共享入口。
*/
/*
* ★★ 2026-09-23 修:`ContactsTab` 已从 `MainPage.ets` 抽出
* (`pages/ContactsTab.ets`)。下面三个判据原来只在 `MAIN` 上找,
* 抽出后落在 `ContactsTab` 的代码全部失踪 ⇒ 静默变绿(判了空串)。
* 改成把两边并起来 —— 与 `harmony-logic`/`harmony-nav` 的 `PAGE_SOURCES`
* 同一个做法(显式名单,不扫整个目录,避免作用域过宽)。
*/
const MAIN = join(ETS, 'pages', 'MainPage.ets');
const CT = join(ETS, 'pages', 'ContactsTab.ets');
const ctAndMain = (src) => src; // 占位,下面直接用 code(MAIN)+code(CT)
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) + '\n' + code(CT);
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) + '\n' + code(CT);
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) + '\n' + code(CT);
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);
/*
* ★ 2026-09-19 放宽:`pushUrl` → `(pushUrl|replaceUrl)`。
*
* 本条判的是**"有没有跳转"**,不是"用哪个原语"。原先写死 `pushUrl`,
* 于是在修 LoginPage 的返回栈 bug 时(三处 `pushUrl` → `replaceUrl`,
* 见 `harmony-nav` 那条"登录/退出必须用同一个原语")它误报了。
* 原语该用哪个由 harmony-nav 那条专门判;这里只管"跳了没有"。
*/
assert.match(body, /(?:pushUrl|replaceUrl)\(\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|replaceUrl)\(\s*\{\s*url:\s*'pages\/MainPage'/,
'自检:原形状(只有 loggedIn=true)必须判红');
});