Files
MailUI4Agents/client/electron/test/harmony-logic.test.mjs
JianFeeeee c2f35d1023 跨端: 会话改名建议的接口层(详情页缺的第二块,服务端三个端点齐)
审计发现详情页缺三块功能之二:**Agent 的改名建议条**(WebUI `MailView.tsx:324`
的 `RenameProposalBar`)。这一批做**接口层**,UI 接线下一批。

## 为什么这件事不只是"少个提示条"

WebUI 那段的注释写得很清楚:

> Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
> 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。

也就是**寻址稳定性**的设计 —— 别名是人的寻址入口,Agent 只能**提议**。

## 做了什么

1. `model/SessionRename.ets` —— `RenameProposal`(`alias` / `reason`,
   字段名与服务端 json tag **逐字对齐**)
2. `api/SessionApi.ets` —— 三个动作:
   · `getRenameProposal(sessionId)` → `{"proposal": {...}}` 或 `{"proposal": null}`
   · `acceptRename(id, alias)` → **`PUT /sessions/{id}/alias`**
   · `dismissRename(id)` → `POST /sessions/{id}/rename-proposal/dismiss`
3. 判据(`harmony-logic`,+1 条):字段名、响应外壳容忍 `null`、
   接受走别名端点、驳回走专用端点、方法名与 WebUI store 同名、+ 变异自检。

★ **接受为什么不另开端点**(服务端注释原话):
「那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方」。
所以"接受建议"与"人手改别名"是**同一条路**,而"驳回"是另一条
(它不是改别名,是"别再问了"——服务端记下被驳回的别名,
否则每次打开会话都要重新点一次「忽略」)。

## 端到端验证(真数据,不是形态检查)

造了一封带标记的真邮件投进一个真会话:

    POST /mail/send {reply_to: …,
      body: "内容正文。<!-- agentmail:rename-session alias=\"rename-verify\" reason=\"验证改名建议链路\" -->"}

    → 200 {"rename_proposed":"rename-verify", …}

然后:

    GET /sessions/{id}/rename-proposal
    → {"proposal":{"alias":"rename-verify","reason":"验证改名建议链路"}}   ✓ 形状与我的一致
    SELECT body FROM mails …
    → "内容正文。"                                                        ✓ 标记被剥掉

两件事都验到了:**服务端识别建议**,且**标记从人读的正文里剥离**
(HTML 注释在 Markdown 渲染器里会变成可见文本,所以必须剥,不能指望渲染器吞掉)。

## 判据

`run-all.mjs` → `checks=510 pass=510 fail=0 skip=0 red=0 broken=0 unreported=0`。
`harmony-logic` 30 → 31。`hvigorw assembleHap` 成功;前端重建 + 重打包。

**未做**:UI 接线(详情页的提示条)。接口层已完成并验证,
但"页面上真的显示建议条并能点接受/驳回"要下一批。
2026-09-19 17:12:40 +08:00

732 lines
46 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 是未读数,不是总封数'
);
/*
* 页头文案已按 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('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")', () => {
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\)/, '卡片视图要真的渲染出来');
});
// ───────────────────────── 撤掉平级「会话」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 处,正好把要断言的那一行切掉,
// 判据于是报"再折叠筛过的那些"失败 —— 判据自己的切片边界错了(变异测试式的自省)
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}(后端按这三个字段解析)`);
}
// 备注必须真的送出:拒绝路径要传 noteText(WebUI 踩过"界面能填、其实没发出去"的坑)
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), '自检:默认页签变异没生效');
});
/* ─────────────────── 左右滑动翻页:行为(不是"代码里有") ─────────────────── */
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_MAX_DURATION_MS } = 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, '纯纵向滚动不得翻页');
// ④ 慢拖不翻页
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS + 1).turned, false,
'超过时间窗 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS).turned, true,
'正好在时间窗内 ⇒ 应翻页');
// ⑤ 阈值必须是正数(0 会让所有手势都翻页)
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MAX_DURATION_MS > 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 处理只该有一处)');
assert.match(api, /session_alias: alias/,
'★ 别名端点的请求体字段是 `session_alias`(服务端 json 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),
'★ 路径写错时上面那条断言必须能判红');
});