## 撤 tab(按 pi 的顺序:先补视图与折叠,再撤入口)
底部只剩 **收件箱 / 联系人**。「会话」不是第三个地方,而是同一批数据的两种看法:
收件箱那栏按会话折叠(组头就是会话),联系人那栏的卡片视图是会话的进度视角。
依据是"信息没丢",并且把它做成了判据:**卡片字段集与 WebUI `WorkCard` 完全相等**
(多一个少一个都红)—— 其中 `status`(active/archived) 与 `from_agent` 参考实现也不显示;
哪天 WebUI 补上,这条会红,提醒跟着补,而不是悄悄少一块。
## 权限"强制力"上界面(WebUI 有、鸿蒙原先没有)
只显示档位会让人以为 plan 档真的管住了对方。WebUI 把说明放在 `title`(悬停提示),
**手指没有悬停** —— 所以鸿蒙拆两步:标记形状当场可辨(● 平台强制 / ◉ 覆盖不完整 /
○ 仅提示),点徽标用 toast 说完整那句话。三条纪律落进判据:
1. 档位/强制力标签与 WebUI 的 `MODE_LABEL` / `ENFORCEMENT_LABEL` **逐字一致**;
2. **说明文案从 WebUI 源码抽出字符串逐字比对**(3 档 × 3 强制力全覆盖)——
两个客户端对同一个任务不能给两种保证;
3. 认不出的强制力归一到 `advisory`(保守方向),空/未知必须说"仅提示"。
收件箱每封邮件里没有 `permission_enforcement`(会话级字段),故那里只写中文档位 ——
凭空画一个强制力标记等于编一个"平台做到了什么"。
## 判据自己不可信的两个坑(变异测试逼出来的,各修一次)
- **断言一律读剥掉注释的源码**:把 `showToast` 注释掉,正则照样匹配 ——
注释里有某个调用证明不了它存在。
- **"在回调里"不能靠正则窗口**:`onClick` 体掏空、或把 toast 挪到相邻的 `onHover`,
窗口式正则都会放过。改成**括号配对**取那个 `onClick` 的 `{...}` 体,只在里面找。
两次变异现在都判红。
`harmony-logic.test.mjs` 19 条(原 14);变异验证:改文案 / 改档位标签 / 页签改回「会话」/
注释掉 toast / toast 挪出 onClick / toast 写死文案 → 各判红。
## 文档
§7.9 记本轮;§7.10 记 jianf 追加的「鸿蒙要求用系统方案」:同意该理解,并补上**可离线校验**的
做法 —— SDK 自带系统资源名表 `sdk/default/openharmony/toolchains/id_defined.json`(7826 条),
其中正好有 `ohos_id_color_list_card_bg`(每项一张卡的底色)、`_list_separator`、
`_text_primary/secondary/tertiary`、`_emphasize`、`_warning`、`_alert`、`_mask_*`、
`ohos_id_blur_style_component_*_color`。**没有设备**,`$r('sys.*')` 写错在运行前发现不了,
所以先立一条判据:源码里的每个 `sys.*` 名字都必须在该表里查得到,再逐处替换。
`cross-client-theme` 的三个取值钉改"意图相同"**先与 pi 对齐、两侧一起改**(他已明确要求)。
## 验证
`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 9 个判据文件(background 42 /
cross-client 8 / harmony-logic 19 / nav-merge 8 / build-stamp 4 / packaging 3 …)+ vitest 258。
视觉与点击仍未验(无设备,模拟器需人在命令行启动)。
352 lines
21 KiB
JavaScript
352 lines
21 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 { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } 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 H = await import(pathToFileURL(MODULE_TS).href);
|
||
|
||
const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8');
|
||
/**
|
||
* 断言一律读**剥掉注释的源码**。
|
||
*
|
||
* 起因是一次变异测试:我把 `promptAction.showToast(` 注释掉,判据**照样绿** ——
|
||
* 因为它在注释里也能被正则匹配到。注释里出现某个调用,恰恰说明不了那个调用存在
|
||
* (而注释里正当地引用旧写法又是常有的事)。这条纪律在 `cross-client-theme` 里
|
||
* 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。
|
||
*/
|
||
const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8');
|
||
const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8');
|
||
|
||
/** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 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',
|
||
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.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\(mergedMails\)/, '加载后要折叠');
|
||
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 这些只在会话列表里出现的信息就没地方看了。
|
||
*/
|
||
const tabLabels = [...pageCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]);
|
||
assert.deepEqual(tabLabels, ['收件箱', '联系人'], `平级页签应只剩两个,实际:${tabLabels.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 = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8');
|
||
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 = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8');
|
||
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);
|
||
assert.match(chipClickBody, /promptAction\.showToast\(/, '徽标的 onClick 里要弹说明(不是页面别处的 toast)');
|
||
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,画强制力标记等于编一个"平台做到了什么"'
|
||
);
|
||
});
|