Files
MailUI4Agents/client/electron/test/harmony-logic.test.mjs
JianFeeeee 6a8e868d31 fix(harmony)★★: MailStore 收/发共用一份快照 + 壁纸 PixelMap 泄漏 —— 两处 HIGH
★★ 前置事实修正:报告写的「本机无 hvigorw / 无 SDK / HarmonyOS 侧没有编译过」
   **已不成立** —— `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用,
   `hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。
   ⇒ 本次两条 HIGH 都是**真编译过**的,不是静态推断。这是本次最大的认知变化:
   「改 ArkTS 无法验证 ⇒ 只能推给下一个人」这个前提可以取消了。

## ① MailStore:收件箱与发件箱共用同一份快照(HIGH)

`loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,而两者是两个独立
`@Component`(`InboxTab`/`SentTab`),各自 `await` 完才 `applyStoreSnapshot(...)`
⇒ 切页签 / SSE 交错时**发件箱那次把收件箱那份覆盖掉**:`unread = 0`、
`mails` 换成发件箱的、`loading` 互相关闭 ⇒ 症状是「收件箱未读被清零 /
列表短暂空白 / 转圈停了但列表是空的」。

★ 为什么此前没人动(旧报告标 CRITICAL 却长期未修):它当时的理由是
  「Navigation 分栏下两个 pane 同时在屏」这个**必然场景**;`commTab` 后来改成
  同一时刻只 mount 一个 ⇒ 那个"必然"没了 ⇒ 从"每次都坏"降级成"交错时坏"。
  **这是判据缺失导致缺陷降级**——本条判据就是为了不让它再降级。

**修法(两份快照 + 代号守卫,各挡一半,都要有)**:
· 新增 `sentSnapshot`,`loadSent`/`paintSentFromCache` 只写它;
  `SentTab` 读 `store.sentSnapshot`(收件箱侧零改动)。
· `inboxGen` / `sentGen` 代号:同一栏**自己**的两次 load 也可能乱序到达
  (SSE 叫醒一次、切页签又触发一次)⇒ 旧的**后**到会盖掉新的。
  ⚠️ 守卫**只拦写快照**,`loading = false` 仍要执行 —— 否则新请求的 spinner 被挂住。
· `clear()` 清两份,**并把两个代号都 +1 作废**:否则**正在飞**的旧请求回来时
  `myGen` 仍等于旧值 ⇒ 会把清空后的快照重新填上旧数据(登出/切账号正是此时)。

★★ 顺带修一个**我差点引入的回归**:拆开之前两份共用一份 ⇒ 归档一个会话会
同时抹掉两边的它。拆开后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已删)。
⇒ `dropSession` 拆成 `dropSessionFromInbox` / `dropSessionFromSent`,
**两份都要剔**;且发件箱那份**不能用** `splitByPermission(...).normal`
(发件箱不做权限分流,那一筛会抹掉「我发出的授权请求」——09-20 修过的
「发件箱一片空白」同一族)。

## ② AppearanceStore:壁纸 PixelMap 泄漏 + 全尺寸解码(HIGH)

`loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
· `PixelMap` 是**原生内存**,每次同步/每次切账号重新解码一张,全部不释放;
  而它是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**(无任何清理入口)。
· 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
  `MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻,
  而它永远被合成器降采样着全屏画。

**修法**(照 `BackgroundPicker` 既有形状,不另创一套):
· `desiredSize` 取 `display.getDefaultDisplaySync()` 的 width/height,
  不写死魔数(随屏变)。★ 它**会抛**(SDK 注释 1400001)⇒ 必须 catch,
  取不到就退回"不限制尺寸",而不是把壁纸整条路断掉。
· 所有权:先 `this.wallpaper = next` 再释放**旧的**,并把局部变量置空 ——
  ★ 若在 `finally` 里释放 `this.wallpaper`,会把**刚装上的那张**释放掉,
  这是最容易写反的一处。
· 新增 `releaseWallpaper()`,并在 `Logout.ets` 里与 `MailStore.clear()` 并排调用
  —— 后者是该方法**唯一**的调用点,没有它它就只是"写好了没人用"。

## 判据(harmony-arkts 8 → 10;harmony-logic 补一格)

新两条按**括号配对**取方法体(`balanced()`,不用 `\{[\s\S]{0,N}` 窗口)。
**变异测试 5 个全部抓住**:loadSent 写回 snapshot / SentTab 读错快照 /
归档不剔发件箱 / 释放的是新图而非旧的 / 去掉 desiredSize。

★ 顺带修一条**HEAD 上就在红的判据**(不是本次引入):`harmony-logic` 那条
  「先分家、后折叠」只认字面量 `groupMailsBySession(split.normal)`,
  而源码**本来就是** `const inboxMails: MailLike[] = split.normal;` 后
  `groupMailsBySession(inboxMails)` ⇒ 恒红。
  ⇒ 补上"经中间变量"这一支;变异验证:把 `inboxMails` 换成 `mergedMails`
  (权限邮件混进收件箱)仍**照红** ✓ —— 放宽的是写法假设,不是判定。

## 编译期硬规则(ArkTS 不接受对象字面量当类型)

`targetSize()` 第一版返回 `{ width: number, height: number }` ⇒ 编译报
`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`(连
**返回的对象字面量**也要能对应到**具名**类型)。⇒ 必须先声明 interface,
且每个字面量先赋给**显式类型的局部变量**再 return。
★ 这条**编译期硬规则**在本仓无判据覆盖(harmony-arkts 判的是 import 位置那一类)
⇒ 只能靠真编译抓;而它能抓,再次证明「ArkTS 本机无法验证」已不成立。

## 边界 / 未做

· **本条判据证明不了运行时症状**:切页签交错那条要设备才能造,本仓无那种探针。
  静态层只把住「两栏各写各的」。设备那半**仍未验**。
· `releaseWallpaper` 的**实际内存回收**未在真机验证(memory profiler)。
· 离屏解码(`@Concurrent`/taskpool)**本轮不做** —— 全仓 0 先例,
  单独引入会扩大风险面。
2026-10-03 12:11:25 +08:00

1213 lines
74 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 侧就是因为
* 只验了结构与样式、**一次都没点过**,漏掉了"点日历/联系人不翻页"的 bug 一路到线上。
* 而鸿蒙侧现在**没有设备**(`hdc list targets` 为空、模拟器在本机沙箱下起不来),
* 于是"点一下"这件事在鸿蒙上暂时无法自动验。
*
* 应对办法不是编个假判据,而是把**会点的那一层的内核**抽出来:
* `client/harmony/entry/src/main/ets/model/MailGrouping.ts` 是纯逻辑、无 UI 依赖,
* 本文件用 node 的 `--experimental-strip-types` **直接执行它**,断言的是行为
* (折叠后组头是不是最新一封、单封是不是不成组、预算剩 1 个来回是什么档)。
* 页面那一层再用源码判据钉住"确实调了这些函数"—— 两层合起来,
* 「逻辑对」+「页面接上了」都有判据;剩下的"手感/观感"如实标注未验。
*
* 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup)、
* `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。
*/
import { code, prose } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const MODULE_TS = join(HARMONY_ETS, 'model/MailGrouping.ts');
const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts');
/** 被测对象:鸿蒙客户端真正引用的那份逻辑(不是复制品) */
const H = await import(pathToFileURL(MODULE_TS).href);
/*
* 附件格式化(`model/Attachment.ts`,同样是纯逻辑、无 SDK 依赖)。
* ★ 直接执行鸿蒙那一份代码 —— 与 `H`/`C` 同一手法,验的是**行为**。
*/
const ATT_TS = join(HARMONY_ETS, 'model/Attachment.ts');
const ATT = await import(pathToFileURL(ATT_TS).href);
const C = await import(pathToFileURL(COMM_TS).href);
/*
* ★★ 2026-09-23 修:把**从 MainPage 抽出去的组件文件**也并进来。
*
* 用户要求「把鸿蒙的组件按页面封装,以便与 WebUI 一一对应」,
* 于是 `PermissionTab` / `ContactsTab` / `NavDestinations` / `NavShared`
* 都从 `MainPage.ets` 抽成了独立文件。而本文件的 `pageCode` 只看
* `MainPage.ets` ⇒ 那些断言("卡片上要显示预算"、"切换按钮要走这条判据"…)
* 全部落空变红 —— 它们问的是**渲染层有没有这个调用**,
* 而那个渲染层已经搬家了。
*
* ⇒ 判据该问的是"这个应用里有没有",不是"它在哪个文件里"。
* 把抽出去的那几个一起读进来。
*
* ★ 有意**不**做成"扫描 pages/ 下所有文件":那会让断言变得过宽
* (任何文件里出现过一次就算数),而本仓反复在消的正是"作用域比声称的宽"。
* 这份名单是显式的 —— 组件再搬家就一起改,与 `harmony-appearance` 里
* 那份 `PANE_SOURCES` 同一个做法。
*/
const PAGE_SOURCES = [
'pages/MainPage.ets',
/* ★ 2026-09-23 抽出的组件(与 WebUI 的 ContactPanel / PermissionList 一一对应) */
'pages/ContactsTab.ets',
'pages/PermissionTab.ets',
'pages/NavDestinations.ets',
'pages/NavShared.ets'
];
const page = PAGE_SOURCES.map((f) => code(join(HARMONY_ETS, f))).join('\n');
/**
* 断言一律读**剥掉注释的源码**。
*
* 起因是一次变异测试:我把 `promptAction.showToast(` 注释掉,判据**照样绿** ——
* 因为它在注释里也能被正则匹配到。注释里出现某个调用,恰恰说明不了那个调用存在
* (而注释里正当地引用旧写法又是常有的事)。这条纪律在 `cross-client-theme` 里
* 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。
*/
const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
/*
* ★★ 2026-09-20:再加一份「取数/组装那层」的源码。
*
* 用户裁定做 A(harmony 补 store 层)之后,**收件箱的取数与组装
* (`sumUnreadTotals` / `partialLoadNotice` / `splitByPermission` /
* `groupMailsBySession` 的调用)从 `MainPage.ets` 搬进了
* `common/MailStore.ets`**。
*
* 于是本文件里几条"页面要用这个纯逻辑"的判据**锚错了文件** —— 它们读
* `pageCode`,而那些调用现在在 store 里。这不是判据发现了 bug,是判据
* 锚在了**实现位置**而不是**它声称的不变量**("这条纯逻辑真的被用上了")。
*
* 所以这里引入 `compositionCode`:**两处合起来**看。
* 不变量是"这个纯函数在数据流里被用了",用它的人搬去哪一层不该让判据红。
*/
const compositionCode = pageCode + '\n' + code(join(HARMONY_ETS, 'common/MailStore.ets'));
const webGroups = code(join(ROOT, 'client/electron/src/lib/mailGroups.ts'));
const webCard = code(join(ROOT, 'client/electron/src/components/WorkCard.tsx'));
/** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */
const mail = (over) => Object.assign(
{
mail_id: 'm-' + Math.random().toString(36).slice(2, 8),
session_id: 's1',
session_alias: '',
from_name: 'pi',
subject: '主题',
body_preview: '预览',
created_at: '2026-09-14T10:00:00Z',
status: 'read',
mail_type: 'normal',
permission_result: '',
to_name: 'pi',
permission_mode: '',
source_account_id: 'acct-1',
source_account_name: '工作邮箱'
},
over
);
// ───────────────────────── 收件箱折叠 ─────────────────────────
test('折叠:同一个会话的信合成一组,组头取**最新一封**', () => {
const older = mail({ mail_id: 'm-a', session_id: 's1', created_at: '2026-09-14T09:00:00Z', subject: '旧主题', session_alias: '旧别名' });
const newer = mail({ mail_id: 'm-b', session_id: 's1', created_at: '2026-09-14T11:00:00Z', subject: '新主题', session_alias: 'fix-x', status: 'unread' });
const groups = H.groupMailsBySession([older, newer]);
assert.equal(groups.length, 1, '同一个 session_id 应该只有一组');
assert.equal(groups[0].alias, 'fix-x', '组头别名应取最新一封');
assert.equal(groups[0].subject, '新主题', '组头主题应取最新一封(会话主题会随任务推进被改写)');
assert.equal(groups[0].latest.mail_id, 'm-b');
assert.equal(groups[0].unreadCount, 1, '组内未读数');
assert.deepEqual(groups[0].mails.map(m => m.mail_id), ['m-b', 'm-a'], '组内按时间倒序');
});
test('★ 判据自检:把组头当成"第一封"而不是"最新一封"必须判红', () => {
// 故意按"旧 → 新"传入:实现里少了 sort 的话,mails[0] 就是旧的,组头会写错
const older = mail({ mail_id: 'm-a', session_id: 's1', created_at: '2026-09-14T09:00:00Z', subject: '旧主题' });
const newer = mail({ mail_id: 'm-b', session_id: 's1', created_at: '2026-09-14T11:00:00Z', subject: '新主题' });
const groups = H.groupMailsBySession([older, newer]);
assert.notEqual(groups[0].subject, '旧主题', '组头取到了旧的那封 —— 折叠没有排序');
});
test('单封的组不算「组」(与 WebUI isFlatGroup 同一结论)', () => {
const one = H.groupMailsBySession([mail({ session_id: 's1' })]);
const two = H.groupMailsBySession([mail({ session_id: 's1' }), mail({ session_id: 's1', created_at: '2026-09-14T11:00:00Z' })]);
assert.equal(H.isFlatGroup(one[0]), true);
assert.equal(H.isFlatGroup(two[0]), false);
// WebUI 侧同一条规则仍在(哪边改了口径,这条会红)
assert.match(webGroups, /export function isFlatGroup\(g: MailGroup\): boolean \{\s*return g\.mails\.length === 1;/, 'WebUI 的 isFlatGroup 口径变了');
});
test('组间按最新一封倒序;时间相同时用 mail_id 倒序兜底', () => {
const a = mail({ mail_id: 'm-1', session_id: 'sa', created_at: '2026-09-14T09:00:00Z' });
const b = mail({ mail_id: 'm-2', session_id: 'sb', created_at: '2026-09-14T12:00:00Z' });
const groups = H.groupMailsBySession([a, b]);
assert.deepEqual(groups.map(g => g.session_id), ['sb', 'sa']);
// 同一时刻:mail_id 倒序(与后端 ORDER BY created_at DESC, mail_id DESC 一致)
const t = '2026-09-14T10:00:00Z';
const x = mail({ mail_id: 'm-aaa', session_id: 'sx', created_at: t });
const y = mail({ mail_id: 'm-zzz', session_id: 'sy', created_at: t });
assert.deepEqual(H.groupMailsBySession([x, y]).map(g => g.session_id), ['sy', 'sx']);
});
test('时间解析失败不抛错、也不把顺序交给入参(NaN 参与比较恒为 false 的坑)', () => {
const bad = mail({ mail_id: 'm-bad', session_id: 'sbad', created_at: '不是时间' });
const good = mail({ mail_id: 'm-ok', session_id: 'sok', created_at: '2026-09-14T10:00:00Z' });
const forward = H.groupMailsBySession([bad, good]).map(g => g.session_id);
const backward = H.groupMailsBySession([good, bad]).map(g => g.session_id);
assert.deepEqual(forward, backward, '入参顺序换了,结果就变 —— 排序依赖了 NaN 比较');
assert.deepEqual(forward, ['sok', 'sbad']);
});
test('鸿蒙特有:多账号收件箱里,同一 session_id 出现在两个账号是两件事', () => {
const fromA = mail({ mail_id: 'm-a', session_id: 'shared', source_account_id: 'acct-1' });
const fromB = mail({ mail_id: 'm-b', session_id: 'shared', source_account_id: 'acct-2', created_at: '2026-09-14T11:00:00Z' });
const groups = H.groupMailsBySession([fromA, fromB]);
assert.equal(groups.length, 2, '两个账号的同名会话被折叠成了一组 —— 键里少了账号');
});
test('session_id 缺失的信各自成组(一条脏数据不该让整栏空白)', () => {
const a = mail({ mail_id: 'm-1', session_id: '' });
const b = mail({ mail_id: 'm-2', session_id: '' });
const groups = H.groupMailsBySession([a, b]);
assert.equal(groups.length, 2);
assert.equal(H.isFlatGroup(groups[0]), true);
});
// ───────────────────────── 未读数与"这一页可能不全" ─────────────────────────
test('未读数用服务端 total 相加(它是 CountUnread,权威),负数/0 忽略', () => {
assert.equal(H.sumUnreadTotals([3, 4]), 7);
assert.equal(H.sumUnreadTotals([0, -1, 2]), 2);
assert.equal(H.sumUnreadTotals([]), 0);
assert.match(compositionCode, /sumUnreadTotals\(/, '数据流里要用服务端未读数,而不是数这一页');
});
test('只有"取满了这一页"才提示可能还有更多(服务端 total 是未读数,不是总封数)', () => {
assert.equal(H.partialLoadNotice(12, 50), '', '没取满就别吓人');
assert.equal(H.partialLoadNotice(50, 50), '已加载 50 封(本页上限 50,可能还有更多)');
assert.equal(H.partialLoadNotice(0, 0), '', 'limit 不合法时不提示');
assert.match(compositionCode, /partialLoadNotice\(/, '数据流里要用这条提示');
});
test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => {
// 原先底部写的是「共 N 封」,而那个 N 是 /me/mail/inbox 的 total(= CountUnread),
// 于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。
assert.ok(
!/共 ' \+ this\.total \+ ' 封/.test(pageCode),
'页面里还有「共 N 封」—— 服务端 total 是未读数,不是总封数'
);
/*
* 页头文案已按 WebUI 的列表头重写为「N 组 · N 封」(parity 那一轮),
* 不再是「已加载 N 封」—— 但这条判据的本意没变:显示的必须是**本地数到的东西**
* (groups / loaded),不能是服务端那个未读 total。
*/
assert.match(pageCode, /this\.loaded \+ ' 封'/, '应如实说"本地取到多少封"(而不是服务端 total)');
assert.match(pageCode, /this\.groups\.length \+ ' 组/, '列表头要报告本地分组数(WebUI 的「N 组 · N 封」形状)');
});
// ───────────────────────── 预算(与 WebUI BudgetChip 同判据) ─────────────────────────
test('往返预算档位与 WebUI 的 BudgetChip 完全一致', () => {
// WebUI 的判据(WorkCard.tsx):max<=0 不显示;剩 0 = 用尽;剩 ≤1 = 将尽;其余普通
assert.match(webCard, /if \(!max \|\| max <= 0\) return null;/, 'WebUI 的"不限不显示"口径变了');
assert.match(webCard, /remaining === 0/, 'WebUI 的"用尽"判据变了');
assert.match(webCard, /remaining <= 1/, 'WebUI 的"将尽"判据变了');
// 鸿蒙侧同一批输入必须给出同样的档位
assert.equal(H.budgetState(0, 0), 'none', '上限 0 = 不限,不显示');
assert.equal(H.budgetState(5, 5), 'spent', '剩 0 = 用尽');
assert.equal(H.budgetState(5, 4), 'warn', '剩 1 = 将尽(快跑满的任务要人介入)');
assert.equal(H.budgetState(5, 3), 'ok');
assert.equal(H.budgetState(5, 9), 'spent', '用超了也是用尽,不能算成还剩负数');
assert.equal(H.budgetLabel(5, 4), '1/5');
assert.equal(H.budgetLabel(0, 0), '', '不限时徽标文字是空串(页面据此不渲染)');
assert.match(pageCode, /budgetLabel\(c\.max_rounds, c\.used_rounds\)/, '卡片上要显示预算');
assert.match(pageCode, /budgetState\(c\.max_rounds, c\.used_rounds\)/, '卡片上要用同一档位判据');
});
// ───────────────────────── 联系人页两种视图(为撤掉平级「会话」tab 做准备) ─────────────────────────
test('联系人页的列表/卡片切换:点一次换一次,标题用 WebUI 那套词', () => {
assert.equal(H.nextContactView('list'), 'card');
assert.equal(H.nextContactView('card'), 'list');
assert.equal(H.contactViewTitle('card'), '工作列表', '卡片视图的标题应与 WebUI 一致');
assert.equal(H.contactViewTitle('list'), '联系人');
assert.match(pageCode, /this\.contactView = nextContactView\(this\.contactView\)/, '切换按钮要走这条判据');
assert.match(pageCode, /contactViewTitle\(this\.contactView\)/, '标题要走这条判据');
});
test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接手)', () => {
assert.equal(H.lastFromIsHuman('pi', 'jianf'), true);
assert.equal(H.lastFromIsHuman('pi', 'pi'), false);
assert.match(pageCode, /lastFromIsHuman\(c\.agent_name, c\.last_from\)/, '卡片要用这条判据选图标');
// WebUI 侧同一判据仍在
assert.match(webCard, /const fromHuman = c\.last_from !== c\.agent_name;/, 'WebUI 的 fromHuman 口径变了');
});
// ───────────────────────── 两层的接合:页面确实调了被测逻辑 ─────────────────────────
test('折叠逻辑真正接上了(不是"逻辑写好了没人用")—— 取数侧 + 渲染侧都要在', () => {
/*
* ★★ 2026-09-20 拆成两半(用户裁定做 A:补 store 层之后本判据红了)。
*
* 它原来把两件事混在一条里,都断言在 `pageCode`(`MainPage.ets`)上:
* · **取数侧**:"加载后要折叠"—— `this.groups = groupMailsBySession(inboxMails)`
* · **渲染侧**:"单封平铺 / 多封按展开态 / 组头可点"
* 而 A 步把取数侧搬进了 `common/MailStore.ets`。于是判据红的是**取数侧那一条**,
* 而它声称的不变量("折叠逻辑真的被用上了")其实**仍然成立**。
*
* 这正是本仓反复出现的形状:**判据锚在实现位置上,而不是它声称的东西上**。
* 一处实现搬家(哪怕搬得更对)就会假红 —— 而假红比漏报更坏,
* 它会让人去改本来对的代码。
*
* 拆法按**归属**分:
* · 取数/组装 → `compositionCode`(页面 + store 合起来看)
* · 渲染 → 仍钉 `pageCode`(那是页面该管的事,不该被搬走)
*/
assert.match(compositionCode,
/import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/,
'数据流那一层要 import 折叠逻辑');
// 加载后要折叠。变量是**筛过权限邮件之后**的那一批(权限邮件归授权栏,
// 收件箱里不该出现它们 —— 见"权限邮件不进收件箱"那条)。
assert.match(compositionCode, /groupMailsBySession\(split\.normal\)/,
'加载后要折叠(筛过权限邮件的那批)—— A 步之后这行在 MailStore 里');
/* 渲染侧:下面三条仍必须落在页面上 —— 它们描述的是"用户看到什么" */
assert.match(pageCode, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)');
assert.match(pageCode, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染');
assert.match(pageCode, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)');
assert.match(pageCode, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来');
});
// ───────────────────────── 撤掉平级「会话」tab(P2a 收尾) ─────────────────────────
test('底部是 通信 / 日历 / 联系人 三个平级页签,「会话」不再是入口', () => {
/*
* pi 的判断:WebUI 里「会话」从来不是一个入口,它是**两处已有视图**(联系人页的卡片视图
* + 收件箱的会话折叠)。鸿蒙原来把它单列成 tab,等于把"卡片视图"放错了位置。
* 顺序也按他说的:**先补视图与折叠,再撤 tab** —— 撤早了,
* 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。
*/
// 第一项 2026-09-14 从「收件箱」变成「通信」:收件箱/发件箱/授权现在是**通信页内部**的三栏
// (用户:「收件发件授权改为一个导航项,通过内部导航区分」),标签必须跟着改 ——
// 否则底部写着"收件箱"、点进去却有发件箱和授权,比没有更让人困惑。
/*
* P5 起底栏换成自绘的浮动玻璃条(`NavBar` + `NavItems.ts`),不再是系统 `Tabs` 的
* `TabBarBuilder('标签')` 调用 —— 所以标签从**清单**里读,判的仍是同一件事:
* 平级项只剩两个、顺序不变。
*/
const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length;
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts'));
/*
* ★★ 2026-09-19 修:原先这里把 `NavItems.ts` 里**所有** `label:` 一网打尽
* ⇒ 得到 7 个(底栏 4 项 **加上** 侧栏 3 项)—— 因为这两份清单现在是**分开的**:
* · `NAV_ITEMS`(底栏)= 通信/日历/联系人/我的(对齐 WebUI `NarrowNav.tsx:37-40`+143)
* · `NAV_SIDEBAR_ITEMS`(侧栏)= 通信/日历/**联系**(对齐 WebUI `Sidebar.tsx:26-44`,
* 「我的」是底部头像,不在这份清单里)
* 把它们混着数,会得到一个既不是底栏、也不是侧栏的“第三份清单”,
* 于是任何一边改对了这一条都会红。⇒ 改成**分别从各自的数组里取**。
*/
const itemsOf = (name) => {
const m = navSource.match(new RegExp(`export const ${name}: NavItem\\[\\] = \\[([\\s\\S]*?)\\];`));
assert.ok(m, `NavItems.ts 要有 ${name}`);
return [...m[1].matchAll(/label:\s*'([^']+)'/g)].map(x => x[1]);
};
assert.deepEqual(itemsOf('NAV_ITEMS'), ['通信', '日历', '联系人', '我的'],
'底栏四项(对齐 WebUI NarrowNav:收件/发件/授权合并为「通信」,末项「我的」)');
assert.deepEqual(itemsOf('NAV_SIDEBAR_ITEMS'), ['通信', '日历', '联系'],
'侧栏三项(对齐 WebUI Sidebar.tsx 的 navItems:「我的」是底部头像,不在导航轨里)');
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)');
});
test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息没丢"的依据)', () => {
/*
* 撤掉会话列表的前提是"它独有的信息都还在"。这条判据把这个前提变成可执行的:
* 参考实现(WebUI `WorkCard.tsx`)显示哪些字段,鸿蒙卡片就得显示哪些 ——
* 少一个说明撤 tab 丢了东西;哪天 WebUI 补了新字段,这条也会红,提醒跟着补。
*/
const fields = src => new Set([...src.matchAll(/\bc\.([a-z_]+)/g)].map(m => m[1]));
const web = fields(webCard);
// 鸿蒙卡片 Builder 的正文(从 `WorkCard(c: Contact)` 到下一个 @Builder 之前)
const cardStart = pageCode.indexOf('WorkCard(c: Contact)');
assert.ok(cardStart > 0, '找不到鸿蒙的卡片 Builder');
const cardBody = pageCode.slice(cardStart, pageCode.indexOf('@Builder', cardStart));
const harmony = fields(cardBody);
const missing = [...web].filter(f => !harmony.has(f));
const extra = [...harmony].filter(f => !web.has(f));
assert.deepEqual(missing, [], `鸿蒙卡片少了 WebUI 卡片有的字段:${missing.join('、')}`);
assert.deepEqual(extra, [], `鸿蒙卡片多了 WebUI 没有的字段(要么补进 WebUI,要么说明理由):${extra.join('、')}`);
});
// ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ─────────────────────────
test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => {
const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
const labelOf = (name, src) => {
const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`)));
return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]]));
};
const webModes = labelOf('MODE_LABEL', webChip);
const webEnf = labelOf('ENFORCEMENT_LABEL', webChip);
for (const [mode, label] of webModes) {
assert.equal(H.permissionLabel(mode), label, `${mode} 档的中文标签两边必须一致`);
}
assert.equal(H.permissionLabel(''), '', '空档位不显示徽标(人→人的信、旧会话)');
for (const [e, label] of webEnf) {
assert.equal(H.enforcementLabel(e), label, `${e} 的强制力标签两边必须一致`);
}
// 认不出的值按 advisory(保守方向:绝不当成"平台拦得住")
assert.equal(H.enforcementKey('未知'), 'advisory');
assert.equal(H.enforcementKey(''), 'advisory');
assert.match(webChip, /e === 'native' \|\| e === 'partial' \|\| e === 'advisory' \? e : 'advisory'/, 'WebUI 的保守默认值变了');
// 三种强制力必须有**三种**标记(WebUI 用实心/靶心/空心三种点,鸿蒙用三个字形)
const glyphs = ['native', 'partial', 'advisory'].map(H.enforcementGlyph);
assert.equal(new Set(glyphs).size, 3, `三种强制力必须形状可辨,实际:${glyphs.join('')}`);
assert.equal(H.permissionChipText('plan', 'partial'), '只读 ◉');
assert.equal(H.permissionChipText('', 'native'), '', '空档位连标记都不该有');
assert.equal(H.enforcementLabel(''), '仅提示', '空/未知强制力必须说"仅提示",不能默认成平台强制');
});
test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边不能给两种保证)', () => {
/*
* 鸿蒙跑不了 TSX,文案只能 port 一份;**可执行的比对是它的替代品**:
* 从 WebUI 源码里抽出 permissionModeHint 的所有 return 字符串,
* 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话
* 都在那个集合里 —— 任一边改了口径就红。
*/
const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
const fn = webChip.slice(webChip.indexOf('export function permissionModeHint'));
const body = fn.slice(0, fn.indexOf('\n}'));
const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m =>
[...m[1].matchAll(/'((?:[^'\\]|\\.)*)'/g)].map(x => x[1]).join('').replace(/\\'/g, "'")
);
assert.ok(webHints.length >= 7, `WebUI 的说明文案应该至少 7 条,实际 ${webHints.length}`);
const modes = ['plan', 'workspace', 'full', ''];
const ens = ['native', 'partial', 'advisory', '未知', ''];
for (const mode of modes) {
const said = new Set();
for (const e of ens) {
const hint = H.permissionHint(mode, e);
assert.ok(hint.length > 0, `${mode}/${e} 必须给出说明`);
assert.ok(webHints.includes(hint), `${mode}/${e} 的文案与 WebUI 不一致:${hint}`);
said.add(hint);
}
// 认不出的强制力要落到保守档(与 advisory 同一句话),不能各自发明措辞
assert.equal(new Set([...said]).size <= 3, true, `${mode} 档的说明不该超过三种措辞`);
}
// three-strikes:plan 与 workspace 的三种强制力必须给出三种话(WebUI 的硬要求)
for (const mode of ['plan', 'workspace']) {
const three = new Set(ens.slice(0, 3).map(e => H.permissionHint(mode, e)));
assert.equal(three.size, 3, `${mode} 档的三种强制力必须给出三种话`);
}
});
test('徽标真的挂在界面上,且点它能看到那句说明(触屏没有悬停)', () => {
assert.match(pageCode, /permissionChipText\(c\.permission_mode, c\.permission_enforcement\)/, '卡片要用"档位+强制力"的徽标');
assert.match(pageCode, /permissionHint\(c\.permission_mode, c\.permission_enforcement\)/, '点徽标要弹出说明');
/*
* 说明必须挂在这颗徽标**自己的 onClick 里**。
*
* 这条原先只查"页面里出现过 showToast",两次变异都躲过去了:
* ① 把 onClick 体掏空(showToast 还在页面别处);
* ② 把 toast 挪到相邻的另一个回调(`onHover`)里。
* 正则窗口分不清"在回调里"和"在回调后面",所以这里做**括号配对**,
* 只在那个 onClick 的 `{...}` 里面找。这是"只验结构不算数"的又一个小例子。
*/
const onClickBodyOf = (code, fromIdx) => {
const open = code.indexOf('{', fromIdx);
let depth = 0;
for (let i = open; i < code.length; i++) {
if (code[i] === '{') depth++;
else if (code[i] === '}') {
depth--;
if (depth === 0) return code.slice(open, i + 1);
}
}
return code.slice(open);
};
const chipIdx = pageCode.indexOf('permissionChipText(c.permission_mode');
const clickIdx = pageCode.indexOf('.onClick(', chipIdx);
assert.ok(chipIdx > 0 && clickIdx > chipIdx && clickIdx - chipIdx < 600, '徽标上要有自己的 onClick');
const chipClickBody = onClickBodyOf(pageCode, clickIdx);
// 注意用**非废弃**的写法:全局 `promptAction.showToast` 自 API 18 起废弃
// (SDK `@ohos.promptAction.d.ts` 的 `@deprecated since 18`),
// 要的是 UIContext 上的那个 —— 这条断言顺带把废弃写法挡在门外。
assert.match(chipClickBody, /\.getPromptAction\(\)\.showToast\(/, '徽标的 onClick 里要弹说明(UIContext 的非废弃写法)');
assert.match(chipClickBody, /permissionHint\(/, '弹出来的必须是那句说明');
assert.match(pageCode, /enforcementLabel\(c\.permission_enforcement\)/, '说明里要带强制力标签');
assert.match(pageCode, /permissionLabel\(mail\.permission_mode\)/, '收件箱行要用中文档位');
// 收件箱列表接口没有 enforcement 字段,那里不许凭空画强制力标记
const mailItem = pageCode.slice(pageCode.indexOf('MailItem(mail: MailLike)'));
assert.ok(
!/permissionChipText\(mail\./.test(mailItem),
'收件箱每封邮件里没有 permission_enforcement,画强制力标记等于编一个"平台做到了什么"'
);
});
test('★ 不得再用废弃的全局 promptAction.showToast(API 18 起废弃,要走 UIContext)', () => {
/*
* SDK 里写得很清楚:`@ohos.promptAction.d.ts` 的全局 `showToast` 标着
* `@deprecated since 18`,替代品是 `UIContext.getPromptAction()`。
* 本仓库原先有 **23 处**这种调用(不是我写的,是历史)—— 既然这一轮在按
* "用系统方案"整理鸿蒙侧,就顺手一次扫干净,并用判据挡住回潮:
* 新增页面照抄旧写法是最常见的回退路径,而它**编译照样通过**。
*/
const dir = join(ROOT, 'client/harmony/entry/src/main/ets');
const files = [];
const walk = d => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const full = join(d, e.name);
if (e.isDirectory()) walk(full);
else if (/\.(ets|ts)$/.test(e.name)) files.push(full);
}
};
walk(dir);
assert.ok(files.length >= 10, `应扫到至少 10 个源文件,实际 ${files.length}`);
const bad = [];
for (const f of files) {
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
// `getPromptAction().showToast(` 不算违规:它前面必须有 `get`
for (const m of src.matchAll(/(?<!get)promptAction\.showToast\(/g)) bad.push(f.slice(ROOT.length + 1));
}
assert.deepEqual(bad, [], `这些文件还在用废弃的全局 promptAction.showToast:${bad.join('、')}`);
// 反向对照:自检正则要真能认出旧写法、且不误伤新写法
assert.ok(/(?<!get)promptAction\.showToast\(/.test('promptAction.showToast({ message: 1 })'), '自检:认不出旧写法');
assert.ok(!/(?<!get)promptAction\.showToast\(/.test('this.getUIContext().getPromptAction().showToast({})'), '自检:误伤了新写法');
});
// ───────────────────── 通信页:内部页签 / 徽标 / 三栏分家(P2b) ─────────────────────
const webUiStore = code(join(ROOT, 'client/electron/src/stores/uiStore.ts'));
const webCommTabs = code(join(ROOT, 'client/electron/src/components/CommTabs.tsx'));
const webMailList = code(join(ROOT, 'client/electron/src/components/MailList.tsx'));
const sendCode = code(join(HARMONY_ETS, 'pages/MainPage.ets'))
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
test('通信页的页签键与顺序,与 WebUI 的 CommTab / TABS 完全一致', () => {
/*
* 页签键是**状态机的字母表**:两边不一致时,深链/恢复上次页签的行为会悄悄不同
* (用户上次停在"授权",鸿蒙这边认不出这个键,就退回收件箱了)。
* 所以从 WebUI 源码里把键抽出来比,而不是在判据里再抄一遍。
*/
const union = webUiStore.match(/export type CommTab = ([^;]+);/);
assert.ok(union, 'WebUI 要有 CommTab 联合类型');
const webKeys = [...union[1].matchAll(/'([a-z]+)'/g)].map(m => m[1]);
assert.deepEqual(C.COMM_TABS, webKeys, '页签键与顺序必须与 WebUI 一致');
// 标签也要一致(用户看到的就是这两个字)
const tabBlock = webCommTabs.slice(webCommTabs.indexOf('const TABS'), webCommTabs.indexOf('];'));
const webLabels = [...tabBlock.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(C.COMM_TABS.map(C.commTabLabel), webLabels, '页签标签与 WebUI 一致');
assert.deepEqual(webLabels, ['收件箱', '发件箱', '授权']);
});
test('★ 页签状态机:认不出的键与越界下标都落回收件箱(不落在空 pane 上)', () => {
assert.equal(C.COMM_TAB_DEFAULT, 'inbox', '默认页签是收件箱(WebUI 的 uiStore 初值也是它)');
// 三个键各自往返:键 ↔ 下标
C.COMM_TABS.forEach((key, i) => {
assert.equal(C.commTabIndex(key), i, `${key} 的下标应是 ${i}`);
assert.equal(C.commTabFromIndex(i), key, `下标 ${i} 应是 ${key}`);
assert.equal(C.normalizeCommTab(key), key, `${key} 是合法键,不该被改写`);
});
// 脏输入:旧数据、拼错的键、名字改过之后的残留
for (const junk of ['', 'inboxx', 'SENT', '会话', 'unknown']) {
assert.equal(C.normalizeCommTab(junk), 'inbox', `认不出的键 ${JSON.stringify(junk)} 应落回收件箱`);
assert.equal(C.commTabIndex(junk), 0, `${junk} 的页签栏下标应是默认页签`);
}
for (const bad of [-1, 3, 99, 1.5]) {
assert.equal(C.commTabFromIndex(bad), 'inbox', `越界下标 ${bad} 应落回收件箱`);
}
});
test('徽标:未读红 / 待决策橙 / 发件箱无,且 99 以上写 99+(与 CommTabs.tsx 同一套规则)', () => {
// 数字来源:收件箱看未读、授权看**待决策**、发件箱没有徽标
assert.equal(C.badgeCount('inbox', 3, 5), 3);
assert.equal(C.badgeCount('permissions', 3, 5), 5, '授权徽标看**待决策**,不是权限邮件总数');
assert.equal(C.badgeCount('sent', 3, 5), 0, '发件箱不该有徽标');
assert.equal(C.badgeCount('不认识', 3, 5), 3, '认不出的键按默认页签(收件箱)算');
// 脏数字(负数/NaN 来源)不该显示成负数
assert.equal(C.badgeCount('inbox', -1, 0), 0);
assert.equal(C.badgeCount('permissions', 0, -3), 0);
// 文字与上限:WebUI 写的是 `badge > 99 ? '99+' : badge`
assert.equal(C.badgeText(0), '', '0 不显示徽标');
assert.equal(C.badgeText(-1), '', '负数不显示');
assert.equal(C.badgeText(1), '1');
assert.equal(C.badgeText(99), '99');
assert.equal(C.badgeText(100), '99+');
assert.match(webCommTabs, /badge > 99 \? '99\+' : badge/, 'WebUI 的上限写法变了,这条判据要跟着核');
// 色调:红=要读、橙=有人被卡住。色值来自 Theme,页面不自己挑颜色
assert.equal(C.badgeTone('inbox'), 'danger');
assert.equal(C.badgeTone('permissions'), 'warn');
assert.equal(C.badgeTone('sent'), 'none');
assert.match(webCommTabs, /key === 'permissions' \? 'bg-orange-700 text-white' : 'bg-red-600 text-white'/,
'WebUI 的徽标配色变了(授权橙、收件箱红),这条判据要跟着核');
});
test('★ 权限邮件不进收件箱:它进授权栏,且**决策过的**不再算待决策', () => {
const asks = mail({ mail_id: 'perm-1', mail_type: 'permission_request', permission_result: '', status: 'unread', subject: '是否允许删除' });
const settled = mail({ mail_id: 'perm-2', mail_type: 'permission_request', permission_result: 'allow', status: 'unread' });
const letter = mail({ mail_id: 'plain-1', mail_type: 'normal', status: 'unread' });
const split = H.splitByPermission([asks, settled, letter]);
assert.deepEqual(split.normal.map(m => m.mail_id), ['plain-1'], '收件箱只放要读的');
assert.deepEqual(split.permissions.map(m => m.mail_id), ['perm-1', 'perm-2'], '授权栏放全部权限邮件(含已决策的)');
assert.equal(H.isPendingPermission(asks), true, '没有 permission_result = 还在等人点头');
assert.equal(H.isPendingPermission(settled), false, '已决策的不该再喊人');
// 后端用 COALESCE 归一成空串,空串与 null 同义 —— 判据要按"假值"而不是"等于空串"来判
assert.equal(H.isPendingPermission(mail({ mail_type: 'permission_request', permission_result: null })), true);
assert.equal(H.countPendingPermissions([asks, settled, letter]), 1);
// 收件箱未读不能把授权栏的未读算进去(否则头显示"7 未读"、列表里一封都没有)
const heads = [mail({ status: 'unread', mail_type: 'normal' }), mail({ status: 'unread', mail_type: 'permission_request' })];
const normalUnread = H.splitByPermission(heads).normal.filter(m => m.status === 'unread').length;
assert.equal(normalUnread, 1);
});
test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐字一致', () => {
// WebUI 的空态就是这一句(MailList.tsx)—— 两边对同一件事说同一句话
assert.match(webMailList, />暂无邮件</, 'WebUI 的空态文案变了,这条判据要跟着核');
for (const tab of C.COMM_TABS) {
assert.equal(C.emptyTitle(tab), '暂无邮件', `${tab} 的主句应与 WebUI 一致`);
assert.ok(C.emptyHint(tab).length >= 6, `${tab} 要有"为什么是空的"那一句(只说暂无邮件说不清)`);
}
// 空态说明要**分得开**:三栏各说各的,否则等于没说明
assert.equal(new Set(C.COMM_TABS.map(C.emptyHint)).size, 3, '三栏的空态说明不该是同一句话');
assert.match(C.emptyHint('sent'), /发出/, '发件箱空态要说清这是"你发出的信"的地方');
assert.match(C.emptyHint('permissions'), /待决策/, '授权空态要说清"没有人被卡住"');
});
test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => {
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
// P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数
const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
/*
* ★★ 2026-09-19 修(同文件另一处同一个错):从 `NAV_ITEMS`(**底栏那份**)里取 label,
* 不能把全文件的 `label:` 一网打尽 —— 那样会把 `NAV_SIDEBAR_ITEMS`(三项)也数进来,
* 得到 7 个。两份清单现在是分开的(底栏 4 / 侧栏 3),见 `harmony-widescreen` ②。
*/
const navItemsBlock = navSource2.match(/export const NAV_ITEMS: NavItem\[\] = \[([\s\S]*?)\];/);
assert.ok(navItemsBlock, 'NavItems.ts 要有 NAV_ITEMS(底栏那份清单)');
const tabLabels = [...navItemsBlock[1].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
// 2026-09-14:日历页(P6 第 1 步)做完后入口上架 —— 三项且顺序固定(通信/日历/联系人)
// 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」也是内容窗格(currentIndex === 3)
assert.deepEqual(tabLabels, ['通信', '日历', '联系人', '我的'], `底部应为通信/日历/联系人/我的四项,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
{
const at = sendCode.indexOf('CommPage({');
assert.ok(at >= 0, '第一项要渲染通信页');
const call = sendCode.slice(at, sendCode.indexOf('})', at) + 2);
assert.match(call, /bgActive:\s*this\.bgActive/, '要把背景开关传下去');
}
// 内部页签:三栏由 COMM_TABS 驱动,点击切到 normalizeCommTab 的**同一个函数**
assert.match(sendCode, /ForEach\(COMM_TABS, \(key: string\)/, '页签栏要按页签清单渲染');
assert.match(sendCode, /commTabLabel\(key\)/, '页签文字走同一份标签');
assert.match(sendCode, /badgeText\(badgeCount\(key, this\.unreadCount, this\.pendingCount\)\)/, '徽标数字走同一份规则');
assert.match(sendCode, /this\.commTab = normalizeCommTab\(key\)/, '点击要过状态机的归一化,不能直接赋值');
// 三个 pane 都要真的存在(少一个就是空页签),且都要收到背景开关
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
const at = sendCode.indexOf(`${pane}({`);
assert.ok(at >= 0, `通信页要渲染 ${pane}`);
const call = sendCode.slice(at, sendCode.indexOf('})', at) + 2);
assert.match(call, /bgActive:\s*this\.bgActive/, `通信页要把 bgActive 传给 ${pane}`);
}
// 悬浮加号在通信页这一层(三个栏都要能新建),形状是圆形 + compose 图标(与 WebUI 同几何)
const commPage = sendCode.slice(sendCode.indexOf('struct CommPage'), sendCode.indexOf('struct ContactsTab'));
assert.match(commPage, /AmIcon\(\{[\s\S]{0,80}?iconName: 'compose'[^}]*}\)[\s\S]{0,220}?borderRadius\(28\)/, '悬浮加号要是圆形且用 compose 图标(不是 Text \'+\')');
assert.match(commPage, /this\.openCompose\(\)/, '加号要真的能进写信页');
// 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的)
// 注意切到那一行**之后**再截断:原来截到 indexOf 处,正好把要断言的那一行切掉,
// 判据于是报"再折叠筛过的那些"失败 —— 判据自己的切片边界错了(变异测试式的自省)
/*
* ★★ 2026-09-20:这两条改读 store(A 步之后取数/組装在 `MailStore.ets`)。
*
* 原来按 `sendCode`(MainPage)从 `async loadData` 切到
* `this.groups = groupMailsBySession` 之间取片段 —— 那段现在在 store 里,
* 页面里的 `loadData` 只剩三行调用。判据于是假红(它声称的"先分家再折叠"
* 仍然成立,只是实现搬了家)。
*
* 仍按**顺序**判(先 split 再 group)——那是这条不变量真正的形状,
* 与在哪个文件里无关。
*/
const storeCode = code(join(HARMONY_ETS, 'common/MailStore.ets'));
/*
* ★★ 只查"两行都在"是不够的 —— 我第一版就是这么写的,而**变异测试没咬住**:
* 把顺序反过来(先 `groupMailsBySession(全部)` 再 `splitByPermission`)之后
* 两行仍然都在、`indexOf` 也仍然一大一小(因为第二行的字面量变了,
* 而我当时匹配的是 `groupMailsBySession(split.normal)` ⇒ 它直接找不到,
* 却因为我把断言写成"两个都 >=0 且 splitAt < groupAt",反过来之后
* `groupAt` 变成 -1 ⇒ 那次是**另一个断言**红的,不是顺序这条)。
*
* 真正要钉的是:**`splitByPermission` 的结果被喂给 `groupMailsBySession`**。
* 那就直接断言那个**数据流**形状 —— 而不是两行的相对位置。
*/
assert.match(storeCode, /snap\.groups = groupMailsBySession\(split\.normal\)/,
'收件箱那一栏要折叠**分家之后**的普通邮件(`split.normal`)—— ' +
'喂 `mergedMails`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');
assert.match(storeCode, /const split: MailSplit = splitByPermission\(mergedMails\)/,
'分家要在合并后的全量上做一次(`mergedMails`),而不是在某一账号的局部');
/*
* ★★ 2026-09-24 改:把范围收到 **`loadInbox` 函数体内**,且卵**数据流**而非字面量。
*
* 两处回因(都是“判据锚在了会漂的位置/写法上”):
* ① 本轮加了缓存(`paintFromCache`),它里面**也**会写
* `groupMailsBySession(split.normal)`;而 `indexOf` 取的是整个文件
* **第一次出现** —— 那次落在 `paintFromCache` 里、早于
* `splitByPermission(mergedMails)` ⇒ 顺序断言假红。
* ② 同一次改动把收件箱那句改成了先赋给中间变量
* (`const inboxMails = split.normal` → `groupMailsBySession(inboxMails)`),
* 于是原来的字面量 `groupMailsBySession(split.normal)` **根本不再出现**。
*
* 要卵的不变量自始至终是同一件:**折叠吃的是分家之后的普通邮件**。
* 所以允许两种写法(直接 / 经中间变量),但两者都必须出现在 `loadInbox` 里、
* 且分家在前。
*/
const inboxAt = storeCode.indexOf('async loadInbox(');
assert.ok(inboxAt > 0, '要有 loadInbox');
/* 边界:`loadInbox` 之后最近的一处“缩进两格的成员定义”(不靠 `\n }` —— 那个会被内层 catch 命中) */
const memberRe = /\n (?:async |private |public )?[a-zA-Z]+\(/g;
memberRe.lastIndex = inboxAt + 20;
const nextMember = memberRe.exec(storeCode);
const inboxBody = storeCode.slice(inboxAt, nextMember ? nextMember.index : storeCode.length);
const splitAt = inboxBody.indexOf('splitByPermission(mergedMails)');
/* 折叠的对象必须是 `split.normal`(允许经中间变量转发) */
const directAt = inboxBody.indexOf('groupMailsBySession(split.normal)');
/*
* ★ 2026-10-03 补一个真实的合法写法:`const inboxMails: MailLike[] = split.normal;`
* 然后 `groupMailsBySession(inboxMails)`。
* 源码里**本来就是**这么写的(`MailStore.loadInbox:595/609`,HEAD 上也是),
* 而本判据只认 `split.normal` 字面量 ⇒ **恒红**。
* ⚠️ 这不是代码回归,是判据的**写法假设**比实现的自由度窄 ——
* 两条断言(分家在前、后折叠)本意都在,只是"后折叠"那半没覆盖中间变量。
* 形状上与上面的 `viaVar` 同族,统一按「`split.normal` 赋给一个变量,
* 后面拿那个变量折叠」来判。
*/
const aliasDecl = /const\s+(\w+)\s*:\s*MailLike\[\]\s*=\s*split\.normal/.exec(inboxBody);
const aliasAt = aliasDecl
? inboxBody.indexOf(`groupMailsBySession(${aliasDecl[1]})`)
: -1;
const viaVar = /const\s+\w+:\s*MailLike\[\]\s*=\s*split\.normal[\s\S]{0,120}?groupMailsBySession\(\w+\)/.exec(inboxBody);
const groupAt = directAt >= 0 ? directAt : (aliasAt >= 0 ? aliasAt : (viaVar ? inboxBody.indexOf(viaVar[0]) : -1));
assert.ok(splitAt >= 0 && groupAt >= 0,
'收件箱要“先分家(`split.normal`)、后折叠” —— ' +
'喂 `mergedMails`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');
assert.ok(splitAt < groupAt,
'★ 先分家、后折叠(顺序反了会先折叠进权限邮件,再想筛也晚了)');
});
test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => {
const api = code(join(HARMONY_ETS, 'api/MailApi.ets'))
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.match(api, /get<SentResponse>\('\/me\/mail\/sent'\)/, '发件箱接口');
assert.match(api, /get<PendingResponse>\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)');
assert.match(api, /post<DecideResponse>\('\/permission\/decide'/, '决策接口');
// 决策体三个字段名必须与后端/WebUI 一致
const decide = api.slice(api.indexOf('async decidePermission'), api.indexOf('async decidePermission') + 420);
for (const field of ['mail_id', 'decision', 'note']) {
assert.ok(new RegExp(`payload\\.${field} =`).test(decide), `决策体要带 ${field}(后端按这三个字段解析)`);
}
// 备注必须真的送出:拒绝路径要传 noteText(WebUI 踩过"界面能填、其实没发出去"的坑)
/*
* ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件**
* (`pages/PermissionTab.ets`)。
*
* 原来这里写的是 `sendCode.slice(sendCode.indexOf('struct PermissionTab'))`
* —— 而 `sendCode` 是 `MainPage.ets` 的源码。抽出之后 `indexOf` 返回 -1,
* `slice(-1)` 是**末一个字符**,于是下面四条断言全部落在空串上 ⇒
* **整条判据静默失效**(不是红,是变成永远绿的空断言)。
*
* ★ 这正是本仓反复在消的形状:**判据盯着一个会移动的位置**。
* 组件一旦搬家,它不会红,只会不再检查任何东西。
* ⇒ 改成读**那个组件自己的文件**,并把"搬家"这件事也钉住
* (下面先断言它在新位置,否则给一句能看懂的失败)。
*/
const permTabSrc = code(join(HARMONY_ETS, 'pages/PermissionTab.ets'));
assert.ok(permTabSrc.includes('struct PermissionTab'),
'PermissionTab 应在 pages/PermissionTab.ets —— 它若再次搬家,本条要一起改');
/*
* ★★★ 2026-09-23 重大修:`navigator_only` 后,**决策不在授权栏了**。
*
* 用户裁定授权栏走 WebUI 架构(纯导航器):点一条 → 详情页决策。
* 于是原来断言在 `PermissionTab` 里的「拒绝要送备注 / 备注框收集输入 /
* 过期要说清后果」全部搬到了详情页的 `PermissionPanel`(新组件)。
*
* ⇒ 判据对象从 `PermissionTab` 换成 `PermissionPanel`。
* 断言**没变宽**:还是那三件必须如实做的事(备注送出、输入收集、过期说清)。
* 只是位置跟组件一起搬了 —— 这正是本文件上面那条注释反复在消的形状。
*/
const panelSrc = code(join(HARMONY_ETS, 'pages/PermissionPanel.ets'));
assert.ok(panelSrc.includes('struct PermissionPanel'),
'PermissionPanel 应在 pages/PermissionPanel.ets —— 决策面板搬到详情页了');
assert.match(panelSrc, /this\.submit\(decision, noteText\)|this\.submit\(label, this\.note\)/,
'决策要带着备注一起送出(拒绝往往要说明理由,理由要真的到 Agent 手里)');
assert.match(panelSrc, /this\.note = v;/, '备注框要真的收集输入');
// 过期必须当场说清:审批不会让那次调用继续
assert.match(panelSrc, /resp\.expired/, '过期分支要处理');
assert.match(panelSrc, /staleWarning|已超过等待窗口/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
/*
* ★ `navigator_only` 的正形状:授权栏**不再有**内联决策,只导航。
* 漏删一个按钮/备注框就是"一半改了"(用户裁定最恨的形状)。
*/
assert.ok(!/Button\('同意'\)/.test(permTabSrc),
'授权栏不许再有内联「同意」按钮 —— 决策在详情页(navigator_only)');
assert.ok(!/this\.decide\(/.test(permTabSrc),
'授权栏不许再有 decide() 调用 —— 那是内联决策时代的残留');
assert.match(permTabSrc, /this\.onOpenMail\(req\.mail_id, req\.source_account_id\)/,
'卡片要能把点按变成导航(onOpenMail 带 mail_id + 来源账号)—— navigator_only 的形状');
});
test('★ 判据自检:把状态机的默认页签改错必须判红', () => {
// 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红")
const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;");
assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效');
const defaultMutated = prose(COMM_TS).replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';");
assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效');
});
/* ─────────────────── 左右滑动翻页:行为(不是"代码里有") ─────────────────── */
test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,不看字符串)', async () => {
/*
* 上一条跨端判据钉的是"两端语义相同",这里钉的是"这道门真的会拦住"。
* 两条都要有:只钉语义的话,一个 `return` 写漏了照样全绿。
*/
const CAL = await import(pathToFileURL(join(HARMONY_ETS, 'model/Calendar.ts')).href);
const { judgeSwipe, SWIPE_MIN_DISTANCE, SWIPE_AXIS_RATIO, SWIPE_MIN_SPEED } = CAL;
// ① 正常快滑:左滑 = 下一段
const next = judgeSwipe(-200, 10, 120);
assert.equal(next.turned, true, '横向位移足够大 + 够快 ⇒ 应判为翻页');
assert.equal(next.delta, 1, '★ 左滑(dx<0)必须是 +1(下一段)');
// 右滑 = 上一段
const prev = judgeSwipe(200, 10, 120);
assert.equal(prev.delta, -1, '★ 右滑(dx>0)必须是 -1(上一段)');
// ② 位移不够 —— 轻点/微抖不该翻页
assert.equal(judgeSwipe(-(SWIPE_MIN_DISTANCE - 1), 0, 100).turned, false,
'位移小于阈值 ⇒ 不翻页(否则点一下就会翻)');
assert.equal(judgeSwipe(SWIPE_MIN_DISTANCE + 1, 0, 100).turned, true,
'刚过阈值 ⇒ 应翻页(阈值不能写大到正常滑动都不触发)');
// ③ 纵向优先 —— 这是移动端最容易犯的错
assert.equal(judgeSwipe(-100, 90, 100).turned, false,
'横向只比纵向大一点 ⇒ 不翻页(用户在纵向滚动)');
assert.equal(judgeSwipe(-100, 90 / SWIPE_AXIS_RATIO, 100).turned, true,
'横向达到倍数要求 ⇒ 应翻页');
/* 纯纵向必然不翻 */
assert.equal(judgeSwipe(5, -300, 100).turned, false, '纯纵向滚动不得翻页');
/*
* ④ 慢拖不翻页 —— **按速度判,不是按时长**。
*
* ★★ 2026-09-20 改:原来是 `judgeSwipe(-300, 0, MAX_DURATION + 1)`,
* 即"时长超窗就拒"。那道门把**距离与速度混成一个量**(时长 = 距离 ÷ 速度),
* 于是同样的手速下滑得越远越容易被拒 —— 大屏上会误拒正常滑动。
* 设备实测(3184×2232、位移恒 542.6vp):旧门 700ms 意味着**只有 ≥5000px/s 才过**,
* 而用户正常甩动大约 2500px/s ⇒ "滑不动/时灵时不灵"。
*
* 现在按速度判,且要看**两侧**:慢的一定拒、快的一定过。
*/
assert.equal(judgeSwipe(-300, 0, 5000).turned, false,
'速度 0.06 vp/ms(300/5000)远低于阈值 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
/* ★ 这条是本次的**回归钉**:2500px/s 档在新门之前是 0/4 成功 */
assert.equal(judgeSwipe(-542.6, 0, 1429).turned, true,
'实测的正常甩动(542.6vp / 1429ms ≈ 0.38 vp/ms)**必须过** —— 旧门在这里判红过');
/* 同一距离、更快 ⇒ 更该过(证明判的是速度而不是距离) */
assert.equal(judgeSwipe(-300, 0, 600).turned, true, '同样 300vp、600ms(0.5 vp/ms)⇒ 应翻页');
/* 同样速度、更远 ⇒ 也该过(旧门会在这里误拒) */
assert.equal(judgeSwipe(-1200, 0, 3000).turned, true,
'同样 0.4 vp/ms 但滑得更远(1200vp)⇒ 仍应翻页(旧门会因时长超窗误拒)');
/* 时长为 0 不能除零,也不能被当成"慢" */
assert.equal(judgeSwipe(-300, 0, 0).turned, true, '时长为 0(同一毫秒)不得除零,应按极快处理');
// ⑤ 阈值必须是正数(0 会让所有手势都翻页)
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MIN_SPEED > 0,
'三个阈值都要是正数;倍数还必须 > 1(=1 时斜滑就翻页)');
// ⑥ 不翻页时 delta 必须是 0(否则调用方会拿 0 去翻页)
for (const v of [judgeSwipe(1, 0, 100), judgeSwipe(-100, 500, 100), judgeSwipe(-300, 0, 99999)]) {
assert.equal(v.turned, false);
assert.equal(v.delta, 0, '未翻页时 delta 应为 0(不是 -1/1)');
}
});
test('★ 行为:手势触发的翻页与按钮触发的翻页走同一个函数(步长不会分叉)', () => {
/*
* 这条是"结构"判据但断的是**行为等价性**:手势与 ‹ › 按钮都必须进 shiftRange。
* 各写一套的话,周档"手势翻 1 天、按钮翻 7 天"这种分叉不会报错、只会静静地不对。
*/
const page = code(join(HARMONY_ETS, 'pages/CalendarPage.ets'));
const gestureCalls = [...page.matchAll(/this\.shiftRange\(v\.delta\)/g)].length;
assert.ok(gestureCalls >= 2,
`★ 宽屏与窄屏两处手势都要调 shiftRange(实际 ${gestureCalls} 处)—— ` +
'只挂一屏会让另一屏没有手势,而那是"看起来已经做了"的最隐蔽形式');
/* 按钮那一支 */
assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(-1\)\)/, '上一页按钮');
assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(1\)\)/, '下一页按钮');
});
/* ───────── 会话改名建议:契约形状与两端行为一致(纯逻辑层) ───────── */
test('★ 改名建议:字段名与两端契约一致,接受/驳回走对端点(一个走别名、一个走专用端点)', () => {
/*
* 2026-09-19 审计发现:鸿蒙详情页**没有**改名建议条(WebUI 有,
* `MailView.tsx:324` 的 `RenameProposalBar`)。那件事对**寻址稳定性**很重要
* (WebUI 注释原话:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。
*
* 这一批先把**接口层**做完(`api/SessionApi.ets` + `model/SessionRename.ets`),
* 这条判据钉住它与服务端的契约 —— 形状错了 UI 接上也读不出东西。
*
* 服务端契约(`server/internal/handler/sessions.go:222`):
* GET → {"proposal": {"alias": "...", "reason": "..."}} 或 {"proposal": null}
* POST /rename-proposal/dismiss ← 驳回
* PUT /sessions/{id}/alias ← 接受(**与"人手改别名"同一个端点**)
*
* ★ 为什么"接受"不另开端点(服务端注释原话):
* 「那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方」。
*/
const model = code(join(HARMONY_ETS, 'model/SessionRename.ets'));
const api = code(join(HARMONY_ETS, 'api/SessionApi.ets'));
/* ① 字段名逐字对齐(服务端是 alias / reason,不是 newAlias / why) */
assert.match(model, /alias: string = ''/,
'★ 模型字段要叫 `alias`(与服务端 `RenameProposal.Alias` 的 json tag 一致)');
assert.match(model, /reason: string = ''/,
'★ 模型字段要叫 `reason`(服务端 json tag 是 `reason,omitempty`)');
assert.ok(!/newAlias|why:/.test(model),
'★ 不许自造字段名 —— 服务端不认识的名字会被静默忽略(读出来永远是空)');
/* ② 响应外壳:服务端把 proposal 包在一层里(且用 `null` 表示"没有建议") */
assert.match(api, /proposal: RenameProposal \| null = null/,
'★ 响应外壳必须容忍 `null` —— 服务端在有/无建议时分别返回对象与 `null`,' +
'而不是 404("没有建议"是正常状态)');
/* ③ 接受走**别名端点**(与手改别名同一条路) */
assert.match(api, /\/sessions\/' \+ sessionId \+ '\/alias'/,
'★ 接受建议要走 `PUT /sessions/{id}/alias` —— ' +
'服务端特意不另开端点(唯一性校验与 409 处理只该有一处)');
/*
* ★★ 这里我**第一版判据自己写错了**(写成了 `session_alias`)——
* 服务端 `updateAliasRequest` 的 tag 是 **`alias`**(`sessions.go:95-97`),
* 而它的 `Decode()` 是 `DisallowUnknownFields()` ⇒ 传错名字**直接 400**。
* 判据跟着实现一起错,等于把这处错误**锁进两处**。
* (WebUI 侧也是 `{ alias }`,见 `client.ts:537` —— 两端都该以它为准。)
*/
assert.match(api, /this\.alias = alias;/,
'★ 别名端点的请求体字段必须是 `alias`(服务端 `updateAliasRequest` 的 json tag)—— ' +
'写成 `session_alias` 会被 DisallowUnknownFields 拒收(400)');
assert.ok(!/session_alias: alias/.test(api),
'★ 不许用 `session_alias` 当请求体字段名 —— 那不是服务端认识的 tag');
/* ④ 驳回走**专用端点**(它不是改别名,是"别再问了") */
assert.match(api, /rename-proposal\/dismiss/,
'★ 驳回要走专用端点 `/rename-proposal/dismiss` —— ' +
'服务端记下被驳回的别名,否则每次打开会话都要重新点一次「忽略」');
/* ⑤ 方法名与语义一致(accept 不是 update、dismiss 不是 delete) */
assert.match(api, /async acceptRename\(/, '要有 `acceptRename`(与 WebUI store 同名)');
assert.match(api, /async dismissRename\(/, '要有 `dismissRename`(与 WebUI store 同名)');
/* ⑥ 锚点自检:把服务端路径改错必须能被抓到 */
const mutated = api.replace("/sessions/' + sessionId + '/alias'", "/sessions/' + sessionId + '/alias-wrong'");
assert.notEqual(mutated, api, '变异要有实际效果(锚点必须命中)');
assert.ok(!/\/sessions\/' \+ sessionId \+ '\/alias'/.test(mutated),
'★ 路径写错时上面那条断言必须能判红');
});
test('★ 详情页折叠头部的展开区要有足够高度(14px 的横条点不中)', () => {
/*
* ★★ 2026-09-19 真 bug,设备实测撞出来的:
*
* 详情页折叠态的头部只有一行标题,那一行的 `Row` **没有高度** ⇒
* 高度就是内容(14px 字号)。实测 dump 的可点区:
*
* Row [1277,112][3122,126] ← 只有 14px 高
*
* 叠上"全屏后状态栏也在 y=112 那个区间"这个事实 ⇒ 反复点它
* **触发系统手势(下拉通知)而不是展开头部**。我为此试了七八次,
* 每次都回到桌面。
*
* 而收起态头部里藏着**这个会话的全部旋钮**(收件人/时间/抄送/权限档/预算),
* 点不中就等于那些东西都看不到。
*
* 修法:给那一行 `height(36)`(与左边返回键同高)—— 实测可点区
* 从 14px 变成 43px。
*
* 判据形状:找"`layoutWeight(1)` + `onClick` 且**没有 height**"的头部行。
* 这类行都是"点它展开/切换"的可点区,太薄就点不中。
*/
const src = code(join(HARMONY_ETS, "pages/MailDetailPage.ets"));
/*
* 定位折叠头部的展开行:它的标志是「`.layoutWeight(1)` 之后紧跟
* `.onClick(() => { this.headerOpen = !this.headerOpen; })`」。
*/
/*
* ★ 锚点要**跳过注释**:那一段里有一大段解释(讲这个 bug 本身),
* 而我第一版的正则只允许空白,于是匹配不上(判据假红)。
* 用 `code()` 读的是**剥注释版**,所以这里直接在剥注释后的文本上找 ——
* 拿它当锚点最稳。
*/
/*
* ⚠️ 用 `[\s\S]{0,300}?` 而不是 `\s*`:`code()` 剥注释后会在原处留下**大量空行**
* (实测 `.layoutWeight(1)` 与 `.height(36)` 之间隔了 15 个空行)——
* 而且那些空行里可能有缩进空格。所以两侧都用"任意字符、尽量少"。
*/
const m = /\.layoutWeight\(1\)[\s\S]{0,300}?\.height\((\d+)\)[\s\S]{0,300}?\.onClick\(\(\)\s*=>\s*\{\s*this\.headerOpen/.exec(src);
assert.ok(m,
'要能找到折叠头部的展开行 —— 形状是「`.layoutWeight(1)` → `.height(N)` → `.onClick(headerOpen 切换)`」。' +
'若它变成别的顺序,更新这条正则(并确认那个顺序下可点区仍然够大)');
const h = [null, m[1]];
assert.ok(Number(h[1]) >= 32,
`★ 展开行的高度应 ≥32vp(实测 14px 太薄、点不中;修成 36 后实测可点区 43px)。实际 ${h[1]}`);
});
test('★ 附件区:字节数格式化与 WebUI 逐字一致(三档 + 保留一位小数)', () => {
/*
* ★★ 2026-09-20 加。鸿蒙原来**完全没有附件区** ——
* `MailDetail.attachments` 一直在模型里、服务端也在返回,界面一个都没画。
* WebUI `MailView.tsx:148/692` 两处都调 `AttachmentList`。
*
* 这里验的是格式化:期望值照 WebUI `api/client.ts:390 formatSize` **逐字**写,
* 不是"大概像就行" —— 两端同一个文件名旁边显示 `512 B` 与 `512.0 B`
* 会显得是两个不同的软件。
*
* ★ 边界各取一个,而不是只测"正常值":
* · `0` —— 0 字节文件真实存在(空文件),不能显示成空串
* · `1023` —— 最后一个 B 档(`< 1024`,不加小数)
* · `1024` —— 第一个 KB 档(**跨越点**,最容易差一位)
* · `1048575`—— 最后一个 KB 档
* · `1048576`—— 第一个 MB 档(第二个跨越点)
*/
assert.equal(ATT.formatSize(0), '0 B', '0 字节要显示成 "0 B",不能是空串');
assert.equal(ATT.formatSize(512), '512 B', 'B 档是整数、不加小数(与 WebUI 同)');
assert.equal(ATT.formatSize(1023), '1023 B', '1023 是最后一个 B 档');
assert.equal(ATT.formatSize(1024), '1.0 KB', '1024 跨到 KB 档,且保留一位小数');
assert.equal(ATT.formatSize(1536), '1.5 KB', 'KB 档保留一位小数');
assert.equal(ATT.formatSize(1048575), '1024.0 KB', '最后一个 KB 档(WebUI 也是 1024.0 KB,不提前进位)');
assert.equal(ATT.formatSize(1048576), '1.0 MB', '1048576 跨到 MB 档');
assert.equal(ATT.formatSize(3 * 1024 * 1024), '3.0 MB', 'MB 档保留一位小数');
});
test('★ 附件区:空文件名要显式占位,不能留一条看起来坏掉的空行', () => {
/*
* 服务端理论上不会给空文件名,但真出现时界面上会是一条**看不出来是错的**
* 空行(比显式占位更难诊断)。占位符不是文案偏好,是**可诊断性**。
*/
const ok = ATT.attachmentLabel('report.pdf', 2048);
assert.equal(ok.filename, 'report.pdf');
assert.equal(ok.size, '2.0 KB');
const empty = ATT.attachmentLabel('', 100);
assert.notEqual(empty.filename, '', '空文件名要有占位符,不能是空串');
assert.equal(empty.size, '100 B', '空文件名不影响大小显示');
});
/**
* ★★ 2026-09-24 新增(用户:「发件箱内容也点不开」)。
*
* ── 实测复现 ──
* 点发件箱里「致 pi」那一行:
* · 日志里**没有** `GET /mail/{id}`(收件箱同样操作是会发的);
* · 右栏仍是占位(「选择一封邮件查看…」)。
*
* ── 根因 ──
* 点击挂在**外层** `Column` 上,而 `SentRow` 自己**一个 `.onClick` 都没有**。
* 更要命的是:`isFlatGroup(g)` 那条分支(单封不成组)直接调 `SentRow`,
* **外层那层点击根本不经过** ⇒ 用户的数据恰好是 1 封不成组 ⇒ 完全点不动。
*
* 收件箱的 `MailRow` 是挂在行自己身上的,所以它两条分支都通。
* 这就是本仓反复出现的形状:**同一件事两处各写一遍,然后慢慢分叉**。
*
* 判据钉的是**不变式**(“列表行自带点击”),而不是某个函数的写法:
* 每个能展开邮件的列表行 `@Builder`,它的属性链里必须有自己的 `.onClick`。
*/
test('★ 列表行必须把 onClick 挂在自己身上(发件箱就漏在这里)', () => {
/*
* 行 Builder 的名单:两栏各自的邮件行。
* ★ 不扫全部 `@Builder`(那样会把组头、空态、徽标都算进来),
* 只钉**真正代表“一封邮件”的那两个** —— 与 `PAGE_SOURCES` 同一条纪律:
* 名单显式,不把作用域开得比声称的宽。
*/
const ROWS = ['MailRow', 'SentRow'];
for (const name of ROWS) {
const at = pageCode.indexOf(`${name}(mail: MailLike)`);
assert.ok(at > 0, `${name} 要存在(列表行)`);
/*
* 取到下一个 `@Builder` 为止(下一个 Builder 就是下一个方法的开始)。
* 比“取 3000 字符”可靠:方法长短会变,而 `@Builder` 是结构边界。
*/
const nextAt = pageCode.indexOf('@Builder', at);
const body = pageCode.slice(at, nextAt > at ? nextAt : at + 4000);
assert.match(body, /\.onClick\(/, [
`${name} 的属性链里必须有自己的 \`.onClick\``,
'—— 点击挂在外层容器时,扁平组分支(isFlatGroup)**不经过它**,',
'那一栏就完全点不动(2026-09-24 发件箱实测就是这个形状)。',
].join(''));
assert.match(body, /openMail\(/, `${name} 的 onClick 要调到 openMail(而不是只写个空壳)`);
}
});
/**
* ★★ 2026-09-24 新增(用户第二次报:「发件箱内容也点不开」)。
*
* ── 上一条判据为何没抓住 ──
* 上一条只钉了「行 Builder 自带 `.onClick`」—— 那是**必要条件,不是充分条件**。
* `SentRow` 确实带了 `.onClick`,判据绿了;而 bug 仍在:
* 发件箱对单封组**同时渲染了组头 + 行**,卡的上半部(组头)没有点击处理 ⇒
* 实测点 y=630(组头区)完全无反应。
* ★ 这就是“判据比 bug 弱”的典型形状:它钉的代理量(有没有 onClick)
* 与真正的不变式(**整张卡处处可点**)不一致。
*
* ── 真正的不变式 ──
* 两栏对单封组的处理必须**同形**:只渲染行,不渲染组头。
* · 收件箱(对的那一边):`if (isFlatGroup(g)) { this.MailRow(...) }`
* · 发件箱(错的那一边):无条件 `SentGroupHeader(g)` + 再叠一个 `SentRow`
* `isFlatGroup` 自己的注释早写着
* 「单封不成组:套一个可折叠的组头只是多一次点击」
* —— 发件箱的实现与它**直接相反**,而没有任何东西在看这件事。
*
* 判据取的是**结构关系**(“在两个互斥分支里二选一”),不是某一行字符串:
* 无论怎么写(`if/else`、两个 `if`、三目),只要“组头”与“行”
* 对同一个 flat 组**都会渲染**,就不通过。
*/
test('★ 单封组(isFlatGroup)不许既画组头又画行 —— 否则组头那半张卡点不动', () => {
/*
* ── 为何不用 `isFlatGroup(g)` 作锚点(我第一版就是这么写的,变异测不红)──
* bug 形状是:
* this.SentGroupHeader(g) ← 组头在**前面**
* if (isFlatGroup(g)) { this.SentRow(g.mails[0]) }
* 从 `isFlatGroup(g)` 往后切片时,组头**落在切片之前** ⇒ 两个名字里只看到一个,
* 直接 `continue` 跳过了。
* ⇒ 改用**行调用**(`Row(g.mails[0])`)作锚点、往**两边**开窗:
* 无论组头写在前还是在后,两个名字都在窗里。
*/
const ROWS = [
{ row: 'this.MailRow(g.mails[0])', header: 'this.GroupHeader(' },
{ row: 'this.SentRow(g.mails[0])', header: 'this.SentGroupHeader(' },
];
const WINDOW = 600;
for (const { row, header } of ROWS) {
const rAt = pageCode.indexOf(row);
assert.ok(rAt > 0, `要能找到单封组的行渲染 \`${row}\``);
const from = Math.max(0, rAt - WINDOW);
const to = Math.min(pageCode.length, rAt + WINDOW);
const win = pageCode.slice(from, to);
const relR = rAt - from;
const relH = win.indexOf(header);
assert.ok(relH >= 0, `${row} 同一张卡里要有组头 \`${header}\`(两栏结构应一致)`);
const lo = Math.min(relH, relR);
const hi = Math.max(relH, relR);
const between = win.slice(lo, hi);
assert.match(between, /\belse\b|\?/, [
`单封组的“${header}”与“${row}”必须在 \`else\` 或三目里**二选一**。`,
'两者都渲染时,组头那半张卡没有点击处理 —— 用户点上去没反应。',
'(2026-09-24 实测:发件箱点组头区完全无反应,就是这一条。)',
'收件箱的正确形状是 `if (isFlatGroup(g)) { Row } else { Header + 子行 }`。',
].join(''));
}
});
/**
* ★★ 2026-09-24 新增(用户:「鸿蒙是 app 啊,缓存邮件多正常,还可以加快同步速度」)。
*
* ── 为什么 App 该缓存、WebUI 不缓存是对的 ──
* WebUI 的 `mailStore` 没有 persist(逐个数过:只有 account/appearance/background/
* contact/theme 五个 store 有)—— 那是浏览器环境的合理取舍。
* 而 App 的本地存储是**能力**(preferences 单值上限 16MB),
* 「启动先出缓存、再拉服务端覆盖」是原生应用的常规做法。
*
* ── 判据卵的是**不变式**,不是某个函数名 ──
* ① 两栏(收件箱/发件箱)**都要**先读缓存:只给一栏做,另一栏就仍然是空等;
* ② 缓存键**必须带账号** —— `AppearanceStore` 里已经记过这条教训
* (WebUI 侧因为多账号共用一份全局常量键,切账号互相覆盖);
* ③ 缓存**不得代替取数**:读缓存那一句必须在发请求之前,且两者都在。
* 把缓存写成"有缓存就 return"就变成离线库了 —— 而邮件会变,
* 不刷新比不缓存更坏。
*/
test('★ 邮件本地缓存:两栏都先读后拉,且键带账号', () => {
const store = code(join(HARMONY_ETS, 'common/MailStore.ets'));
/* ① 两栏都要接上缓存 */
assert.match(store, /KEY_INBOX: string = 'inbox\.'/, '收件箱缓存的键要有独立前缀');
assert.match(store, /KEY_SENT: string = 'sent\.'/, '发件箱缓存要有独立前缀(不能与收件箱共键)');
for (const [fn, kind, paint] of [
['loadInbox', 'KEY_INBOX', 'paintFromCache'],
['loadSent', 'KEY_SENT', 'paintSentFromCache'],
]) {
const at = store.indexOf(`async ${fn}(`);
assert.ok(at > 0, `${fn} 要存在`);
const body = store.slice(at, store.indexOf('\n }', at));
assert.match(body, new RegExp(`${paint}\\(ctx, accountFilter\\)`),
`${fn} 要先读缓存(${paint})`);
assert.match(body, new RegExp(`persistByAccount\\(ctx, ${kind}`),
`${fn} 取数成功后要回写缓存(${kind})—— 只读不写的话缓存永远是空的`);
}
/* ② 读缓存在发请求之前(缓存不代替取数) */
const inboxAt = store.indexOf('async loadInbox(');
const inboxBody = store.slice(inboxAt, store.indexOf('\n }', inboxAt));
const paintIdx = inboxBody.indexOf('paintFromCache(');
const fetchIdx = inboxBody.indexOf('new MailApi(accountClient).inbox(');
assert.ok(paintIdx > 0 && fetchIdx > paintIdx,
'读缓存必须在发请求**之前**(先出内容再覆盖),且请求仍在 —— ' +
'把缓存写成"有就 return"=离线库,邮件会变,不刷新比不缓存更坏');
/* ③ 键必须拼上账号 id(不是全局常量键)—— **读写两处都要** */
/*
* ⚠ 只查"文件里出现过 `kind + accountId`"**不够**:
* 变异实测(把 `putSync` 那处改成裸 `kind`、`getSync` 保留)仍然**绿** ——
* 因为另一处还在。而真实后果是写入用全局键、读取用带账号键 ⇒
* 缓存永远读不回来(写了个空)。
* ⇒ 读写两处各自断言。
*/
const putAt = store.indexOf('putSync(');
const getAt = store.indexOf('getSync(');
assert.ok(putAt > 0 && getAt > 0, '要有 putSync / getSync');
assert.match(store.slice(putAt, putAt + 120), /putSync\(kind \+ accountId/,
'缓存**写**的键要带 accountId —— 多账号共用一份键会互相覆盖(AppearanceStore 记过这条)');
assert.match(store.slice(getAt, getAt + 120), /getSync\(kind \+ accountId/,
'缓存**读**的键也要带 accountId,且与写入同形 —— ' +
'读写键不一致的后果是缓存永远读不回来(写了等于白写)');
/* ④ 缓存失败不得影响取数(全程 try/catch 吞掉) */
const wcAt = store.indexOf('private writeCache(');
assert.ok(wcAt > 0, '要有 writeCache');
const rcAt = store.indexOf('private readCache(');
assert.ok(rcAt > 0, '要有 readCache');
for (const [name, at] of [['writeCache', wcAt], ['readCache', rcAt]]) {
const body = store.slice(at, store.indexOf('\n }', at));
assert.match(body, /catch \(e\) \{/, `${name} 要自己吞异常 —— 缓存坏了不能把取数一起拖红`);
}
});