Files
MailUI4Agents/client/electron/test/harmony-push.test.mjs
JianFeeeee ff75339baa fix(harmony): 图标按 px→vp 正确缩放 + 底栏四入口 + 卡片 10vp 缩进
图标过小的根因(实图硬证):`Path.commands` 的坐标按 **px** 解释,不是 vp。
- `Shape.viewPort` 只裁剪不缩放(官方样例"缩放成立"是因为它用 Rect/Circle
  自带 vp 宽高,不是裸 Path.commands);
- 改动前的 `Path.scale({iconSize/24})` 对 iconSize=24 等于 1.0,同样没缩放。
  症状:FAB 内 compose 只画 24px(应 24vp=84px),且偏在盒子左上角。

修法:`scale({vp2px(iconSize)/24})` 把 24 单位路径放大到 iconSize vp;
`scale` 会连带放大描边,故 `strokeWidth = strokeWeight / vp2px(1)` 抵消,
否则 1.8vp 描边会变成 ~7.3vp 的"实心块"(实图已验证)。
用 `this.getUIContext().vp2px()` 而非全局 `vp2px`——后者已废弃,
`harmony-system-api` 判据对全局调用默认判红。

验收(模拟器实图,密度 3.489):
- FAB 内 compose 图标 84px = 24vp ✓(原 24px)
- 底栏图标描边 7-8px ≈ 1.8/24×28vp=7.3px ✓(原为实心块)
- 四个图标均为清晰描边,与 WebUI icons.tsx 同几何

底栏四入口:新增"我的"(person → pages/SettingsPage),对齐 WebUI
通信/日历/联系人/我的;NAV_CONTENT_ITEMS 保持宽屏侧栏三项,
避免宽屏出现重复 person 图标。

卡片缩进:`width('100%') + margin({left:10,right:10})` 在 ArkUI 里不会
缩到 90%——margin 加在宽度外面,卡片顶出父容器(实测左缘只留 ~1px,
列表卡才有正确 10vp)。改为外层容器加 padding。验收:页头卡与邮件卡
左缘同为 x=35(10.0vp)、右缘 x=1219。

推送:reportToken 先申请通知权限再取 token(部分设备权限未开时
getToken 返回空 / 报 1600004),并补两条文件形状判据钉住
client_id 配置与调用顺序。

测试:198 passed / 0 failed。
2026-09-17 16:27:27 +08:00

190 lines
12 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.

import test from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { code } from './lib/read.mjs';
/*
推送客户端契约层(pi 邮件 `1f9ff3b4`)。四条不变量里**三条是纯逻辑**,所以不需要设备就能钉:
静默失败、`enabled:false` 是正常态、按 `provider+tail` 比、通知按 `mail_id` 去重。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const HARMONY = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'model');
const C = await import(pathToFileURL(join(HARMONY, 'PushContract.ts')).href);
test('★ 上报决策:空 token 不上报;同账号同 token 不上报;变了才上报', () => {
assert.equal(C.shouldReportToken('', 'acct1', ''), false, '取不到 token 就不许上报(静默跳过)');
const m = C.reportMarker('acct1', 'abc');
assert.equal(C.shouldReportToken(m, 'acct1', 'abc'), false, '没变就不许重复上报(否则每次启动打一次接口)');
assert.equal(C.shouldReportToken(m, 'acct1', 'xyz'), true, 'token 变了要上报');
assert.equal(C.shouldReportToken('', 'acct1', 'xyz'), true, '第一次拿到要上报');
});
/*
★ 账号必须绑进标记(pi 邮件 `2518e1a3` 的服务端事实):同一个 token **换账号登录是"转移"不是并存**,
所以"我登记过没有"的答案**随账号而变**。只按 token 存标记 ⇒ 换账号后**错误跳过上报**,
而那个账号其实没登记过。这条判据钉的是"换账号必然重新上报"这个性质。
*/
test('★ 换账号后必然重新上报(token 转移不是并存 —— 别把登记状态缓存成长期结论)', () => {
const token = 'AAAABBBBCCCC123456';
const m1 = C.reportMarker('acct1', token);
assert.equal(C.shouldReportToken(m1, 'acct1', token), false, '同账号同 token:不重复上报');
assert.equal(C.shouldReportToken(m1, 'acct2', token), true,
'换账号 + 同一个 token ⇒ 必须重新上报(服务端是转移,acct2 其实没登记过)');
assert.notEqual(m1, C.reportMarker('acct2', token), '标记的形状就保证账号变了必然不同(不靠人记得 reset)');
assert.equal(C.shouldReportToken(C.reportMarker('acct2', token), 'acct1', token), true, '切回来也要重新上报');
});
test('★ "登记过没有"只按 provider + tail 比 —— 比全文是"看起来更严、其实永远为假"的写法', () => {
const token = 'AAAABBBBCCCC123456';
const list = [{ provider: 'hms', token_tail: '123456' }];
assert.equal(C.tokenTail(token), '123456', 'tail 取尾 6 位(与服务端同口径)');
assert.equal(C.isRegistered(list, 'hms', token), true, '尾 6 位相同就算登记过');
// 反证:为什么不能比全文 —— 服务端只回 tail,全文永远不等于 tail
assert.notEqual(token, list[0].token_tail, 'GET 只回尾 6 位');
assert.equal(C.isRegistered(list, 'apns', token), false, 'provider 维度必须参与比较');
assert.equal(C.isRegistered(list, 'hms', 'ZZZZZZZZZZZZ123456'), true,
'同尾 6 位即视为同一条 —— 这是服务端给的信息量的上界,不是我们的选择');
assert.equal(C.tokenTail('abc'), 'abc', '短于 6 位时取全文(不补零、不截空)');
});
test('★ 上报结果分类:三种里没有一种是"提示失败"(enabled:false 是正常态)', () => {
assert.equal(C.classifyRegister(true, false), 'ok-enabled');
assert.equal(C.classifyRegister(false, false), 'ok-disabled', 'enabled:false 是正常态 ⇒ 不重试、不提示');
assert.equal(C.classifyRegister(true, true), 'silent-skip', '失败归 silent-skip,即使服务端 enabled=true');
assert.equal(C.classifyRegister(false, true), 'silent-skip');
});
test('★ 通知 data:不满足约定形状就静默忽略(不"尽力打开某个页面")', () => {
const ok = C.parseNotificationData(JSON.stringify({ type: 'new_mail', mail_id: 'm1', session_id: 's1', action: 'open_mail' }));
assert.equal(ok.mail_id, 'm1');
assert.equal(ok.session_id, 's1');
assert.equal(C.parseNotificationData(''), undefined, '空串');
assert.equal(C.parseNotificationData('{不是 json'), undefined, '坏 JSON 不许抛(推送是可选通道)');
assert.equal(C.parseNotificationData(JSON.stringify({ action: 'open_mail' })), undefined, '缺 mail_id');
assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: 'm1', action: 'other' })), undefined, '动作不是 open_mail');
assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: '', action: 'open_mail' })), undefined, '空 mail_id');
});
test('★ 去重台账:服务端无幂等键 ⇒ 重复保护落客户端;且有界', () => {
const led = new C.NotificationLedger(3);
assert.equal(led.shouldHandle('m1'), true, '第一次该处理');
assert.equal(led.shouldHandle('m1'), false, '重复必须丢弃(服务端至多一次、无幂等键)');
assert.equal(led.shouldHandle('m2'), true);
assert.equal(led.shouldHandle(''), false, '空 id 不处理');
assert.equal(led.size(), 2);
led.shouldHandle('m3'); led.shouldHandle('m4');
assert.equal(led.size(), 3, '有界(常驻 pane 不许无限长)');
assert.equal(led.shouldHandle('m1'), true, '最旧的被挤出后可再处理(有界台账的代价,明写在这里)');
});
test('★ 契约层必须保持"无 @ohos 依赖"(否则这些判据跑不了,会退化成必须上设备)', () => {
// 扫 code()(去注释):第一次跑这条时它咬到了**解释这条规则的那行注释** —— 与扫描口径同族的现成例子
const src = code(join(HARMONY, 'PushContract.ts'));
assert.ok(!/@ohos|@kit\./.test(src),
'PushContract.ts 里出现了 @ohos/@kit 依赖 ⇒ 判据将无法用 node 直接跑。' +
'**正确修法**:把平台调用留在 PushService.ets,纯决策留在这里(与 Calendar/Wallpaper 同模式)。' +
'**最常见的错误修法**:把这条断言删掉,让契约层的判据跟着一起失效。');
});
/*
★ 线上形状(pi 邮件 `004983bb`,从 handler/push.go 读的):请求体字段名必须逐字一致
(**未知字段直接 400**,不是静默忽略),provider 只做形状校验、**没有白名单**,
错误是 `{"error"}` 不是 `{"message"}`。这些都能在纯逻辑侧判。
*/
test('★ 请求体只放已知键(未知字段服务端直接 400,拼错会立刻可见)', () => {
const b = C.buildTokenBody('hms', 'tok123', '我的手机', 's1');
assert.deepEqual(Object.keys(b).sort(), ['device_name', 'provider', 'session_id', 'token']);
for (const k of Object.keys(b)) assert.ok(C.PUSH_BODY_KEYS.includes(k), `不许出现未登记的键:${k}`);
const minimal = C.buildTokenBody('hms', 'tok123', '', '');
assert.deepEqual(Object.keys(minimal).sort(), ['provider', 'token'], '可选字段为空就不放(空串虽合法,但不放更不容易踩校验)');
});
test('★ provider 只做形状校验、没有白名单 —— 不许硬编码"只有 hms 合法"', () => {
assert.equal(C.isValidProvider('hms'), true);
assert.equal(C.isValidProvider('apns'), true, '服务端没实现的通道也不该变成客户端的 400');
assert.equal(C.isValidProvider('fcm'), true);
assert.equal(C.isValidProvider('HMS'), false, '大写非法(形状规则是小写字母/数字/下划线/连字符)');
assert.equal(C.isValidProvider(''), false, '空非法');
assert.equal(C.isValidProvider('a'.repeat(33)), false, '最长 32');
assert.equal(C.isValidProvider('a'.repeat(32)), true);
assert.equal(C.isValidProvider('has space'), false);
});
test('★ token 形状:空非法、512 上限;形状不合法就不去打注定 400 的请求', () => {
assert.equal(C.isValidToken(''), false);
assert.equal(C.isValidToken('x'.repeat(512)), true);
assert.equal(C.isValidToken('x'.repeat(513)), false);
assert.equal(C.buildTokenBody('hms', '', '', ''), undefined, '空 token ⇒ 组不出请求体(调用侧静默跳过)');
assert.equal(C.buildTokenBody('HMS', 'tok', '', ''), undefined, 'provider 形状非法 ⇒ 同样组不出');
});
test('★ 错误体是 {"error"} 不是 {"message"}(形状不对就不当错误消息用)', () => {
assert.equal(C.parseErrorBody('{"error":"token 非法"}'), 'token 非法');
assert.equal(C.parseErrorBody('{"message":"x"}'), undefined, '{"message"} 是另一种形状 —— 不能当错误消息');
assert.equal(C.parseErrorBody('not json'), undefined);
assert.equal(C.parseErrorBody(''), undefined);
assert.equal(C.parseErrorBody('{"error":""}'), undefined, '空消息等于没有消息');
});
test('★ 注销:deleted:false(本来没登记)不是失败;deleted 不参与分类', () => {
assert.equal(C.classifyUnregister(false, false), 'ok-disabled', '没配凭证 + 没登记过 ⇒ 正常态');
assert.equal(C.classifyUnregister(true, false), 'ok-enabled');
assert.equal(C.classifyUnregister(false, true), 'silent-skip', '真失败才归 silent-skip');
assert.equal(C.classifyUnregister.length, 2, 'deleted 不影响结果 ⇒ 它不该是入参(否则读代码的人会以为它影响结果)');
});
test('★ DELETE 也带 JSON body(不是 query / 不是 path 参数)—— ApiClient 必须支持', () => {
const api = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'ApiClient.ets'));
assert.match(api, /async del<T>\(path: string, bodyObj\?: Object\)/,
'DELETE 端点要 JSON body,而`del`只有 path ⇒ 推送的注销调用会 400/无效。' +
'**正确修法**:给 del 加可选 body(向后兼容)。**最常见的错误修法**:把 token 拼进 URL(那不是线上形状)。');
});
/*
* ★ 取 token 的两个硬前提(2026-09-17 实测根因,pi 邮件线上链路):
*
* 症状:鸿蒙 App 连上了、登录了(服务端收到 GET /me/devices/push-token 200),
* 但**从来没有 POST** —— getToken() 返回空串,reportToken 直接 return。
*
* 根因 ①:module.json5 的 module.metadata **没有配 client_id**。
* Push Kit 必须先读到这个 client_id 才能向华为推送服务器申请 token ——
* 缺了它 getToken() 永远返回空(且不报错,静默)。
* 业界示例(dev.to 的 HarmonyOS Next PushKit 集成)与之互证:
* "获取 token 前需在 module.json5 的 module.metadata 中配置 client_id(来自 AGC)"。
*
* 根因 ②:通知权限申请必须在取 token **之前**。
* 部分设备上通知权限没开时 getToken 会返回空 / 报 1600004。
*
* 这两条都不需要设备就能钉(文件形状),且是最容易悄悄回归的地方。
*/
test('★ 取 token 前提①:module.json5 的 module.metadata 必须配 client_id(缺了 getToken 恒空)', () => {
const mod = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'module.json5'));
// module 级 metadata(不是 abilities/extensionAbilities 里的)
assert.match(mod, /"metadata":\s*\[\s*\{\s*"name":\s*"client_id"\s*,\s*"value":\s*"[0-9]+"\s*\}/,
'module.metadata 里必须有 {"name":"client_id","value":"<数字>"}。' +
'这是 Push Kit 申请 token 的硬前提;缺了它 getToken() 静默返回空串 —— ' +
'症状是"App 能连服务端、能 GET,但永远不 POST token"。');
// client_id 的值要跟 agconnect-services.json 里的一致(不是随手编一个数)
const agc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'agconnect-services.json'));
const m = agc.match(/"client_id"\s*:\s*"(\d+)"/);
assert.ok(m, 'agconnect-services.json 里要能找到 client_id');
assert.ok(mod.includes(`"value": "${m[1]}"`),
`module.json5 的 client_id 必须等于 agconnect-services.json 里的 ${m[1]}(两处不一致时 Push Kit 认不出应用)`);
});
test('★ 取 token 前提②:reportToken 里 requestEnableNotification 在 getToken 之前', () => {
const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets'));
const lines = svc.split('\n');
const start = lines.findIndex(l => /async reportToken\(/.test(l));
assert.ok(start > 0, '要能找到 reportToken');
const body = lines.slice(start, start + 60).join('\n');
const permAt = body.indexOf('requestEnableNotification');
const tokenAt = body.indexOf('this.getToken()');
assert.ok(permAt > 0, 'reportToken 里要有 requestEnableNotification');
assert.ok(tokenAt > 0, 'reportToken 里要有 getToken');
assert.ok(permAt < tokenAt,
'通知权限申请必须在 getToken **之前** —— 部分设备上权限未开时 getToken 返回空 / 报 1600004(2026-09-17 实测)');
});