Files
MailUI4Agents/client/electron/test/harmony-contacts.test.mjs
JianFeeeee 5103e0aee3 跨端: 抽出基础面/玻璃组件(Motion + Surface)+ 三处硬弹的弹层补过渡
用户两条要求,各自都指向"决策被复制到各处"这个病根:
  ① 「应该定义一个基础玻璃容器给各个组件引用」
  ② 「很多页面还是不够精致……封装为基础组件供所有页面使用。基础组件包含动画」

★ 玻璃不透明的真根因(前几轮都没找到,这次是像素证据定的)
  实测模拟器 3184×2232、壁纸 aurora + 压暗 37:
      Navigation [229,140,3156,2204]   ← 255,255,255(整块内容区)
      y=2210(Navigation 之外那条缝)    ← 226,225,235(壁纸清楚可见)
  那条缝是决定性的:**壁纸层本身是好的**,白是因为上面盖了不透明的壳。
  链路上一共三层,逐层修:
    · `Navigation` 外壳:不设背景 ⇒ 系统默认不透明白,把里面已透明的窗格整片盖住;
    · navBar 内容层(列表栏根容器):同样没有背景;
    · **卡片**:铺的是 `Theme.surface`(实体系统色)。
  修后:缝隙 203,213,228 / 卡片 191,204,220 —— 壁纸透得出来。

★ 新增基础组件(common/)
  · `Surface.ets`  —— `GlassPane`(正文面)/ `GlassCard`(嵌套面)/ `PageHeader` / `Pressable`
  · `Motion.ets`   —— `Motion.dur()`:接系统「减弱动态效果」
    (`accessibility.isAnimationReduceEnabledSync()`,API 23 正好够用)。
    WebUI 侧有 `prefers-reduced-motion` 硬约束(index.css:1266),
    鸿蒙这边**改造前一次都没调过** —— 系统里关了动画,我们照样动。这是无障碍义务。
  · `Theme.cardMaterial` —— 卡片材质令牌。不是拍脑袋选的:
    `COMPONENT_THIN` 实测卡片 252,254,254(吃掉 94% 壁纸,就是用户看到的"白卡");
    `BACKGROUND_THIN` → 185,190,201,壁纸透得出来且文字对比度仍够。

★ 判定形状按"玻璃从例外变成默认"重写(判据 C 条)
  原来是"允许出现玻璃的位置"白名单,逐处登记。那个形状在"玻璃是少数例外"时成立,
  但用户要的是**全部玻璃化** ⇒ 白名单退化成"把每处抄一遍",且挡不住新写的裸枚举。
  改成**规则**:材质档次只能来自 `Theme.navMaterial` / `Theme.cardMaterial`
  (或 `BlurStyle.NONE`),页面里出现裸 `BlurStyle.XXX` 即红;
  辅助方法(`this.materialOf()`)体内也查,否则等于开了后门。两条变异都验证咬得住。

★ 判据 C 条自身的两个 bug(都被本次触发)
  · 嵌套判定**没比文件**:`c.at`/`o.at` 是各文件自己的字符下标,直接比大小
    于是判出"MainPage 内含 SettingsPage 的玻璃"这种物理上不可能的红。
    假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
  · 参数抽取用 `[^)]*`:`this.materialOf()` 里含一层 `()`,在内层括号处截断,
    把合法写法读成 `this.materialOf(` 判红。改成配对括号抽取。

★ 出现/消失动画:用户点名的三处硬弹
  这些挂在 `if (cond)` 上,条件一变整块出现/消失,原来一帧过渡都没有 ——
  而代码上"看着像挂了动画"(外层页面有 transition),所以最容易漏。
  · `MainPage` 账号选择器下拉 → `menuIn()`(从上往下落的浮层档)
  · `AdminUsersPage` 新建用户表单 → `paneRiseIn()`(就地展开档)
    ★ 过渡必须挂在**调用点的 Column**:`@Builder` 返回 void,链不上修饰符。
      第一版写进了 `CreateForm()` 内部,是新判据抓出来的。
  · `SettingsPage` 新增账号弹层 → `paneRiseIn()`(与回复/转发弹层同一挂法)
  新增判据「出现/消失的那类元素真的挂了过渡」:**枚举所有条件挂载的浮层/展开块**
  (不是数数 —— 数数挡不住"新增第四处又漏了"),变异验证过。

★ 顺手修的两处真嵌套玻璃
  `SettingsPage` 的账号列表与密钥列表都是"卡里面的一段列表",两层都铺材质 =
  WebUI 用 `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的嵌套
  (alpha 相乘 0.82×0.82=0.97 把壁纸吃光)。去掉内层材质,玻璃只留一层。

★ 判据放宽一处(不是放水)
  `harmony-contacts` 那句写死 `pushUrl`,判的是"tryRestore 有没有跳转",
  与用哪个原语无关。修 LoginPage 返回栈时改成 `replaceUrl` 后它误报了;
  改成 `(pushUrl|replaceUrl)`,原语该用哪个由 `harmony-nav` 那条专门判。

判据:files=32 ran=32 checks=518 pass=518 fail=0 red=0;
baseline 7/7✓(第 6 次重算,两个文件逐个 `git diff --quiet HEAD` 取证为有意编辑)。
2026-09-20 07:37:16 +08:00

185 lines
11 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` 的共享入口。
*/
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);
/*
* ★ 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)必须判红');
});