/** * 鸿蒙侧的**可执行判据** —— 跑的是客户端真正会跑的那份逻辑。 * * 为什么要有这个文件: * * 移交信里交代的头号纪律是「判据必须点用户真正会点的那一层」—— 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.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')); 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-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(/(? { /* * 页签键是**状态机的字母表**:两边不一致时,深链/恢复上次页签的行为会悄悄不同 * (用户上次停在"授权",鸿蒙这边认不出这个键,就退回收件箱了)。 * 所以从 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, />暂无邮件= 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\('\/me\/mail\/sent'\)/, '发件箱接口'); assert.match(api, /get\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)'); assert.match(api, /post\('\/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), '自检:默认页签变异没生效'); });