Files
MailUI4Agents/client/electron/test/harmony-system-api.test.mjs
JianFeeeee a6b5e52f45 test(harmony): 判据按 pi 复核意见补强 —— 手写色登记表、玻璃叠用形状、按标题取小节、遮罩必须被用、废弃 API 清单从 SDK 生成
pi 逐行读了 `cross-client-theme.test.mjs` 与 `Theme.ets` 后指出五处(第一处是真缺口),
外加一条建议。全部处理,并且**每一处都用变异验证过**。

## 一(真缺口):枚举 11 个"必须是系统资源"的名字,挡不住第 12 个新写死的手写色

`static readonly brandSecondary: string = '#123456'` 这种新增**三条判据都碰不到**:
A(不在名单里)、B(六位、不是半透明)、裸色值那条(只管 `pages/`)。
原理与当初 14 处 Google 色逃出去是同一条 —— **枚举挡实例,类才挡漂移**,
只是这次枚举的单位是**名字**。补 `A2` 条:

- 枚举 `Theme.ets` 里所有 `static readonly X: string = '#……'`,未登记的 → 红;
- 名单里已不存在的名字 → 红(名单不能烂成化石);
- `Theme.ets` 里的「手写色登记表」段必须逐个列出这些名字(理由不能只存在于记忆里);
- 自检:把一个**新的**手写色塞进源码字符串,确认这条抓得到。
  `Theme.ets` 因此新增登记表(17 项,每项一行理由,按品牌 / 业务语义 / 档位胶囊分组)。

变异:Theme.ets 加 `brandSecondary` → 红。

## 二:C 的"玻璃只有一处"**说错了自己断言的东西**

`assert.equal(glassCalls.length, 1)` 断言的是"全仓共一处",**不是**"没有嵌套":
同页两个**并列**玻璃面(没问题)会让它红,真嵌套它没在判。P5 正是悬浮玻璃导航,
到时若把 1 改成 2 就等于不判。改成形状判据:

- 收集每个调用点所作用的**组件块**(按括号配对回溯;注意 ArkUI 修饰符是链式的,
  `X.blur(A).blur(B)` 前面是 `)` 不是 `}` —— 第一版只看一个字符,对这种写法**静默失效**,
  是判据自检抓出来的);
- 判两种叠法:同一组件上叠多次(同一块)、以及套在另一层玻璃的子树里;
- 每一处玻璃都要在 `GLASS_REGISTRY` 里登记(附一句为什么),名单里的位置若已不存在 → 红。

变异:链式叠两层 → 红;通信页多开一处未登记玻璃 → 红;导航条块内嵌玻璃 → 红。

## 三:文档小节用 `indexOf('有意差异')` 会**拿错段落**

别的段落正文里出现这四个字,切片就从那里开始,后面所有断言都在**别的段落**上判。
改成**按标题**定位(正则匹配 `^#{2,4}…有意差异…$`),并加了表头列名断言
(WebUI / 鸿蒙 / 为什么)—— 拿错段落时表头不会是这个形状,于是它自己会红。

顺带修掉一个**我自己的**同类毛病:品牌色那行原来用"关键词 + 80 字符窗口"判,
窗口宽度在赌表格单元格字符数(该行两格之和 > 80)。改成**按行取**那一行再断言。
另外把偏移算术的切片换成**按行**切片(偏移差一个字符就会把最后一行拦腰截断,
现象是"品牌色那行只剩 57 字符"这种看着像文案、其实像切片的怪事)。

变异:文件前面插入含「有意差异」的段落 → 仍绿(按标题定位生效);
再改坏真表里的品牌色行 → 红(确实读的是那一张表)。

## 四:`Theme.overlay` 只判了"存在",没判"被用"

没使用点的令牌是自证。补断言:它必须在 `Theme.ets` **之外**有真实使用点
(实测 `SettingsPage.ets` / `MailDetailPage.ets` 两处自绘弹层在用),
并把 `overlayColor`/`overlayAlpha` 消失的理由写进 §7.12 —— 否则下一个人会当成漏改补回来。

## 五:材质档次是这次替换里唯一"判据绿但可能观感错"的地方

`COMPONENT_THICK` 的依据(对 THIN / BACKGROUND_* / ULTRA_THICK 的取舍)写进 §7.12,
并把"材质档次在导航条上的实际观感"列为**模拟器起来后第一个要看的项**(间距是数字,材质是判断)。

## 六(建议):废弃 API 判据**类化** —— 清单从 SDK 生成

原来是手写"不得再用全局 `promptAction.showToast`"(只挡已踩过的那个)。
现在从 SDK 生成:顶层(花括号深度 0)被标 `@deprecated` 的 `declare function`
—— 实测 68 个名字(含 `animateTo` / `getContext` / `px2vp`),配一份**空**的 allow-list。
深度判定是必要的:`declare namespace fileIo { declare function open() }` 里的 `open`
是命名空间成员,算进来会造一堆假红。只算全局调用(排除 `x.name(`)。

变异:调 `px2vp(10)` → 红;`this.px2vp(10)`(成员调用)→ 绿(假阳性自检)。

## 验证

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(11 个判据文件全绿 + vitest 258/258)。cross-client-theme 13 → 14 条,harmony-system-api 4 → 5 条。
2026-09-14 14:26:45 +08:00

281 lines
13 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.

/**
* 「用系统方案」的可验证部分 —— 系统资源名与系统材质取值,**不需要设备**。
*
* 背景jianf 追加要求「鸿蒙也同步,但是鸿蒙要求用系统方案」(详见
* `docs/HARMONY-ALIGN-PLAN.md` §二·五 / §7.10)。方向很清楚:能交给系统的就交给系统 ——
* `backgroundBlurStyle(BlurStyle.*)` 取代手写模糊、`$r('sys.color.*')` 语义色取代自切变量。
*
* 难点在于**这台机器上没有设备**(模拟器需要人在命令行启动,本沙箱起不来):
* `$r('sys.color.写错了')` 这种错**编译期不报**,只有真机运行到那一行才炸。
* 于是"用系统方案"会变成一块谁都验不了的区域 —— 那正是本仓库一直在防的东西。
*
* 解决办法是 SDK 自带的那张表:`sdk/default/openharmony/toolchains/id_defined.json`
* 列了**全部系统资源名及其类型**(本机 API 267826 条,其中 color 1059 条)。
* BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。
* 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS_DIR = join(ROOT, 'client/harmony/entry/src/main/ets');
/** DevEco CLI 的 SDK 根。可用 HARMONY_CLT 覆盖(换机器时不用改判据)。 */
const CLT = process.env.HARMONY_CLT || '/opt/huawei/command-line-tools';
const ID_TABLE = process.env.HARMONY_ID_TABLE
|| join(CLT, 'sdk/default/openharmony/toolchains/id_defined.json');
const COMMON_DTS = join(CLT, 'sdk/default/openharmony/ets/component/common.d.ts');
/**
* SDK 名表不在就没法验 —— 这时**必须判红**,不能"跳过"。
* 一条会自己静默跳过的判据,等于没有这条判据(本仓库已经栽过四次同类问题)。
*/
const missingSdk = [];
if (!existsSync(ID_TABLE)) missingSdk.push(ID_TABLE);
if (!existsSync(COMMON_DTS)) missingSdk.push(COMMON_DTS);
test('SDK 的系统资源名表与组件声明要在本机找得到(找不到就明说,不许静默跳过)', () => {
assert.deepEqual(
missingSdk,
[],
`找不到 SDK 文件,这条判据什么都验不了:\n ${missingSdk.join('\n ')}\n`
+ '若 SDK 换了位置,用 HARMONY_CLT 或 HARMONY_ID_TABLE 指向它,'
+ '不要在没核过的情况下把这条判据删掉。'
);
});
/** 收集鸿蒙源码(.ets / .ts */
const collectSources = (dir, acc = []) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) collectSources(full, acc);
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
}
return acc;
};
/** SDK 名表name → type */
const loadIdTable = () => {
const recs = JSON.parse(readFileSync(ID_TABLE, 'utf8')).record;
const byName = new Map();
for (const r of recs) byName.set(r.name, r.type);
return byName;
};
/** SDK 的 BlurStyle 成员 */
const loadBlurStyleMembers = () => {
const src = readFileSync(COMMON_DTS, 'utf8');
const at = src.indexOf('declare enum BlurStyle');
assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle');
// 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,`
// (早先的写法把文档里的 `T`、`R` 也算成了成员 —— 名字解析必须精确到分隔符)
const block = src.slice(at, src.indexOf('\n}', at));
return new Set(
[...block.matchAll(/^ {2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1])
);
};
/** 扫描 `$r('sys.<type>.<name>')` 用法 → [{file, type, name}] */
const scanSysResources = (files) => {
const found = [];
for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
for (const m of src.matchAll(/\$r\(\s*'sys\.([a-z]+)\.([A-Za-z0-9_]+)'/g)) {
found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] });
}
}
return found;
};
/** 扫描 `BlurStyle.<MEMBER>` 用法 → [{file, member}](注释已剥离) */
const scanBlurStyles = (files) => {
const found = [];
for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
for (const m of src.matchAll(/BlurStyle\.([A-Z][A-Z_0-9]*)/g)) {
found.push({ file: f.slice(ROOT.length + 1), member: m[1] });
}
}
return found;
};
test('源码里引用的系统资源名都必须在 SDK 名表里且类型相符(否则真机才炸)', () => {
const files = collectSources(ETS_DIR);
assert.ok(files.length >= 10, `应扫到至少 10 个源文件,实际 ${files.length}`);
const table = loadIdTable();
assert.ok(table.size > 1000, `SDK 名表应有一千条以上,实际 ${table.size}`);
const used = scanSysResources(files);
const bad = used.filter(u => table.get(u.name) !== u.type);
assert.deepEqual(
bad.map(u => `${u.file}: sys.${u.type}.${u.name}(表里是 ${table.get(u.name) ?? '查无此名'}`),
[],
'系统资源名写错/类型不匹配 —— 这类错误编译期不报,只有真机运行到那一行才炸'
);
// 反向对照:判据要真能抓到一个不存在的名字(否则它只是"跑过了"
const ghost = 'backgroundColor($r(\'sys.color.ohos_id_color_这个词不存在\'))';
const ghostName = ghost.match(/sys\.color\.([A-Za-z0-9_]+)/)[1];
assert.notEqual(table.get(ghostName), 'color', '自检:判据抓不到不存在的系统色名');
});
test('BlurStyle 的取值必须在 SDK 的 enum 里(系统材质的名字同样不能凭记忆写)', () => {
const members = loadBlurStyleMembers();
assert.ok(members.size >= 8, `BlurStyle 应有 8 个以上成员,实际 ${members.size}`);
const used = scanBlurStyles(collectSources(ETS_DIR));
const bad = used.filter(u => !members.has(u.member));
assert.deepEqual(
bad.map(u => `${u.file}: BlurStyle.${u.member}`),
[],
`这些 BlurStyle 取值在 SDK 里不存在,可用:${[...members].join(' / ')}`
);
assert.ok(members.has('COMPONENT_REGULAR'), '自检SDK 里应有 COMPONENT_REGULAR');
});
test('替换成系统方案时要用的那批系统色,先在这里核过(计划先于代码)', () => {
/*
* 这张清单是"WebUI 做法 → 鸿蒙系统能力"的对应表(文档 §二·五)落到**具体资源名**上。
* 先在这里核一遍再动代码:名字我核过了,替换时就不会一边写一边猜。
*
* ⚠️ 注意表里**没有** brand / confirm / success 这类名字 —— 系统只给"情绪色"
* warning / alert。所以权限三档与预算三档这些**业务语义色没有系统对应物**
* 必须继续用自定义令牌:为了"用系统色"把 plan 档画成 warning 色,是把语义丢了换形式。
*/
const table = loadIdTable();
const intent = {
color: [
'ohos_id_color_list_card_bg', // 列表项 = 每项一张卡(底色)
'ohos_id_color_list_separator', // 列表分隔线
'ohos_id_color_background', // 页面底
'ohos_id_color_sub_background', // 次级表面
'ohos_id_color_text_primary',
'ohos_id_color_text_secondary',
'ohos_id_color_text_tertiary',
'ohos_id_color_emphasize', // 强调(品牌蓝的落点:具体取值仍须与 WebUI 对齐)
'ohos_id_color_warning',
'ohos_id_color_alert',
'ohos_id_color_mask_regular' // 遮罩
]
};
for (const [type, names] of Object.entries(intent)) {
for (const n of names) {
assert.equal(table.get(n), type, `计划要用的 sys.${type}.${n} 在 SDK 名表里不是 ${type} 类型`);
}
}
// 边界:业务语义色没有系统对应物,这一点也钉住 —— 免得以后有人"为了系统化"去硬套
for (const ghost of ['ohos_id_color_brand', 'ohos_id_color_confirm', 'ohos_id_color_success']) {
assert.equal(table.get(ghost), undefined, `${ghost} 竟然存在了?那这条边界要重新核一遍`);
}
});
/*
* ─────────── 废弃 API**清单从 SDK 生成**,不手写 ───────────
*
* pi 的建议2026-09-14与其一条条手写"不得再用全局 `promptAction.showToast`"
* 不如从 SDK 的 `@deprecated` 标记**生成**一份清单,再配一份人工 allow-list ——
* 新增一条默认判红,除非有人显式放行。这与 `$r('sys.*')` 名字表的做法是同一个套路。
*
* 手写清单的死法是"只挡已经踩过的那一个":这次修了 23 处,下一个人写个新的废弃 API
* 照样全绿 —— 而 SDK 自己知道哪些废弃了,问它就行。
*/
/** 递归收集 SDK 的 .d.ts含 api/ 与 component/ */
function sdkDtsFiles() {
const roots = [
join(CLT, 'sdk/default/openharmony/ets/component'),
join(CLT, 'sdk/default/openharmony/ets/api')
];
const out = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (e.name.endsWith('.d.ts')) out.push(full);
}
};
for (const r of roots) if (existsSync(r)) walk(r);
return out;
}
/**
* 顶层(不在 namespace/class 里)被标 `@deprecated` 的 `declare function` 名字。
*
* "顶层"这件事必须判:`declare namespace fileIo { declare function open(...) }` 里的
* `open` 是**命名空间成员**,全局调 `open(...)` 并不会走到那个废弃实现 ——
* 把它算进来会造出一堆假红(这一版就是按花括号深度筛出来的)。
*/
function deprecatedGlobalFunctions() {
const names = new Set();
for (const f of sdkDtsFiles()) {
const src = readFileSync(f, 'utf8');
// 先切出 depth == 0 的片段
let depth = 0;
let segStart = 0;
const segs = [];
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '{') {
if (depth === 0) segs.push([segStart, i]);
depth++;
} else if (c === '}') {
depth--;
if (depth === 0) segStart = i + 1;
}
}
if (depth === 0) segs.push([segStart, src.length]);
const top = segs.map(([a, b]) => src.slice(a, b)).join('\n');
for (const m of top.matchAll(/\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*declare function (\w+)\s*\(/g)) {
if (m[1].includes('@deprecated')) names.add(m[2]);
}
}
return names;
}
test('废弃 API清单**从 SDK 生成**,源码里不得调用(新增一条默认判红)', () => {
if (missingSdk.length) return; // 没有 SDK这一条无从判起由文件顶部统一报
const deprecated = deprecatedGlobalFunctions();
assert.ok(deprecated.size > 20, `要从 SDK 读到一批废弃的全局函数,实际 ${deprecated.size}`);
// 清单真的抓到了我们关心的东西(否则"生成"这件事本身没生效)
for (const must of ['animateTo', 'getContext', 'px2vp']) {
assert.ok(deprecated.has(must), `清单里应该有 ${must}(它已被 SDK 标记废弃)`);
}
// 全局 showToast 不是 `declare function`(它是命名空间成员),由 harmony-logic 那条单钉
/**
* 允许的例外:**每条都要写理由**。
* 空名单就是"一处都不许"—— 想加就得在这里写明为什么非用不可。
*/
const ALLOW = [];
const walk = (dir, acc = []) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full, acc);
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
}
return acc;
};
const files = walk(ETS_DIR);
const hits = [];
for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const name of deprecated) {
// 只算**全局调用**:排除成员调用 `x.name(``.animateTo(` 是我们要求的新写法)
const re = new RegExp(`(?<![.\\w])${name}\\s*\\(`, 'g');
if (re.test(src)) hits.push(`${f.slice(ETS_DIR.length + 1)}: ${name}`);
}
}
const allowed = hits.filter(h => ALLOW.some(a => h.includes(a.name)));
const bad = hits.filter(h => !ALLOW.some(a => h.includes(a.name)));
assert.deepEqual(bad, [],
`这些地方调了 SDK 已标记废弃的**全局**函数:${bad.join('、')} —— ` +
'换成新写法(如 UIContext 上的同名方法),或把它登记进 ALLOW 并写明理由');
assert.ok(allowed.length === ALLOW.length || allowed.length >= 0, 'allow-list 命中统计');
});