Files
MailUI4Agents/client/electron/test/harmony-logic.test.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

573 lines
36 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);
const C = await import(pathToFileURL(COMM_TS).href);
const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
/**
* 断言一律读**剥掉注释的源码**。
*
* 起因是一次变异测试:我把 `promptAction.showToast(` 注释掉,判据**照样绿** ——
* 因为它在注释里也能被正则匹配到。注释里出现某个调用,恰恰说明不了那个调用存在
* (而注释里正当地引用旧写法又是常有的事)。这条纪律在 `cross-client-theme` 里
* 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。
*/
const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
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(pageCode, /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(pageCode, /partialLoadNotice\(/, '页面要用这条提示');
});
test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => {
// 原先底部写的是「共 N 封」,而那个 N 是 /me/mail/inbox 的 total= CountUnread
// 于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。
assert.ok(
!/共 ' \+ this\.total \+ ' 封/.test(pageCode),
'页面里还有「共 N 封」—— 服务端 total 是未读数,不是总封数'
);
assert.match(pageCode, /已加载 ' \+ this\.loaded \+ ' 封/, '应如实说"已加载了多少封"');
});
// ───────────────────────── 预算(与 WebUI BudgetChip 同判据) ─────────────────────────
test('往返预算档位与 WebUI 的 BudgetChip 完全一致', () => {
// WebUI 的判据WorkCard.tsxmax<=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('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用"', () => {
assert.match(pageCode, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑');
// 加载后要折叠。变量是**筛过权限邮件之后**的那一批(权限邮件归授权栏,
// 收件箱里不该出现它们 —— 见"权限邮件不进收件箱"那条)。
assert.match(pageCode, /this\.groups = groupMailsBySession\(inboxMails\)/, '加载后要折叠(筛过权限邮件的那批)');
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\)/, '卡片视图要真的渲染出来');
});
// ───────────────────────── 撤掉平级「会话」tabP2a 收尾) ─────────────────────────
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'));
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`);
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-strikesplan 与 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.showToastAPI 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'));
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
assert.match(sendCode, /CommPage\(\{ bgActive: 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 都要真的存在(少一个就是空页签)
// 三个 pane 都要真的存在(少一个就是空页签),且都要收到背景开关
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
assert.ok(sendCode.includes(`${pane}({ bgActive: this.bgActive })`),
`通信页要渲染 ${pane}(并把背景开关传下去)`);
}
// 悬浮加号在通信页这一层(三个栏都要能新建),形状是圆形
const commPage = sendCode.slice(sendCode.indexOf('struct CommPage'), sendCode.indexOf('struct ContactsTab'));
assert.match(commPage, /Text\('\+'\)[\s\S]{0,220}?borderRadius\(28\)/, '悬浮加号要是圆形');
assert.match(commPage, /this\.openCompose\(\)/, '加号要真的能进写信页');
// 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的)
// 注意切到那一行**之后**再截断:原来截到 indexOf 处,正好把要断言的那一行切掉,
// 判据于是报"再折叠筛过的那些"失败 —— 判据自己的切片边界错了(变异测试式的自省)
const gStart = sendCode.indexOf('this.groups = groupMailsBySession');
const inboxLoad = sendCode.slice(sendCode.indexOf('async loadData'), gStart + 60);
assert.match(inboxLoad, /splitByPermission\(mergedMails\)/, '收件箱要先分家');
assert.match(inboxLoad, /groupMailsBySession\(inboxMails\)/, '再折叠筛过的那些');
});
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}(后端按这三个字段解析)`);
}
// 备注必须真的送出:拒绝路径要传 noteTextWebUI 踩过"界面能填、其实没发出去"的坑)
const permTab = sendCode.slice(sendCode.indexOf('struct PermissionTab'));
assert.match(permTab, /this\.decide\(req, 'deny', this\.noteText\)/, '拒绝要把备注送出去');
assert.match(permTab, /this\.noteText = v;/, '备注框要真的收集输入');
// 过期必须当场说清:审批不会让那次调用继续
assert.match(permTab, /resp\.expired/, '过期分支要处理');
assert.match(permTab, /不会让那次调用继续/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
});
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), '自检:默认页签变异没生效');
});