用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:
① 「邮件点进去自动已读的能力不正常」
根因:鸿蒙只有「标记已读」按钮(doMarkRead,对照 WebUI MailView.tsx:522
那个手动按钮),缺 WebUI 的**自动**路径(MailView.tsx:55-65 的 useEffect:
可见且 unread 就 markRead)。⇒ 点开邮件不变已读,必须再点按钮。
修:MailDetailPage.loadMail 成功尾端按 `status==='unread'` 触发 doMarkRead
(复用按钮那条路,因而天然带上「就地改 status」+「失败弹 toast」)。
★ 加 autoReadMailId 守卫:WebUI 靠 useEffect 依赖数组天然只跑一次,
鸿蒙 loadMail 是显式调用的(下拉刷新会重跑),不守会重复打接口。
② 「接收邮件的能力也有点不正常」
三个独立缺口,每个都会单独造成"收不到":
a) **SSE 监听寿命**:原挂在 InboxTab.aboutToAppear,而三个 tab 是
if/else 条件挂载的 ⇒ 切到发件箱/授权时 InboxTab 被销毁、监听跟着
移除 ⇒ 在那两栏时收不到任何新邮件。搬到 MainPage(@Entry,全程在)。
b) **只处理 new_mail**:WebUI 监听 5 种事件(sse.ts:7-13 的 EVENTS);
服务端权限决策后发的是 session_update(permission.go:387)⇒
"授权栏里处理过的申请,收件箱还是旧的样子"。补齐四种。
c) **UTF-8 解码错**:arrayBufferToString 逐字节 String.fromCharCode
(Latin-1 语义)⇒ 中文解成乱码。改用 util.TextDecoder + stream:true,
且**每连接一个实例**(stream 会把半截汉字存在解码器内部,
共享实例会把两个账号的半个字拼在一起)。
③ 「发件箱内容点不开」(第二轮;5483140 补了 onClick 仍点不开)
根因:发件箱对单封组**既画组头又画行**——
this.SentGroupHeader(g); if (isFlatGroup(g)) { this.SentRow(...) }
组头那半张卡没有任何点击处理 ⇒ 点卡片上半部完全无反应。
而 isFlatGroup 自己的注释写着「单封不成组:套一个可折叠的组头只是
多一次点击」—— 实现与注释**直接相反**。收件箱一直是正确形状
(if (isFlatGroup) { MailRow } else { 组头 + 子行 })。
修:与收件箱取同形,单封组只画 SentRow。
④ 「通信页面我觉得没有 webui 那么通透」
这是我自己上一轮改错的:把 WebUI 的「页签条无背景」实现成了
`backgroundColor(Theme.surface)` 实心白。WebUI 的真实层叠是
.comm-pane 是玻璃卡、CommTabs 在它内部且**自己无背景**(透出卡的白)。
铺实心白 = 把玻璃卡换成横条白 ⇒ 壁纸再也透不过来。
⇒ 改回玻璃族(Theme.navMaterial,与底部导航条同档)+ 通栏 +
只左上圆角(右上 0,与窗格那道弧重合)+ 底边线。
判据 harmony-nav.test.mjs:732 在我改错时当场判红,是它先抓到的。
判据(变异验证:还原 bug → 必须判红)
- 新增「单封组不许既画组头又画行」:两栏的 (header, row) 对必须在
else/三目里二选一。
★ 第一版写弱了(用 isFlatGroup(g) 作锚点往后切片,组头在切片之前
⇒ 变异测不红)。改成以**行调用**作锚点往两边开窗后,删掉修复
即判红(实测已验)。
- 新增「列表行必须把 onClick 挂在自己身上」(MailRow/SentRow)。
- harmony-logic 34→37、harmony-nav 22(新增后仍绿)、
harmony-appearance 27→28、animation-audit 12→15 的登记数同步。
设备复验(全部有实测凭据,不是推断)
- 自动已读:点开前 unread → 点开 4s 后服务端 read(连验两封)。
列表组头 4→2、侧栏徽标 4→2,三处数字一致。
- 实时收信:App 保持前台不重启,从 gateway 发信 ⇒ 8s 内自动出现
(新卡片 + 侧栏 2→3 + 页签 2→3 + 3 组 8 封)。
- 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。
- 页签条:截图确认为玻璃通透(不再是实心白横条)。
1199 lines
73 KiB
JavaScript
1199 lines
73 KiB
JavaScript
/**
|
||
* 鸿蒙侧的**可执行判据** —— 跑的是客户端真正会跑的那份逻辑。
|
||
*
|
||
* 为什么要有这个文件:
|
||
*
|
||
* 移交信里交代的头号纪律是「判据必须点用户真正会点的那一层」—— 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)');
|
||
const viaVar = /const\s+\w+:\s*MailLike\[\]\s*=\s*split\.normal[\s\S]{0,120}?groupMailsBySession\(\w+\)/.exec(inboxBody);
|
||
const groupAt = directAt >= 0 ? directAt : (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} 要自己吞异常 —— 缓存坏了不能把取数一起拖红`);
|
||
}
|
||
});
|