Files
MailUI4Agents/client/electron/test/harmony-contacts.test.mjs
JianFeeeee 28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00

176 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);
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)必须判红');
});