Files
MailUI4Agents/client/electron/test/harmony-system-api.test.mjs
JianFeeeee 4d226be056 test(harmony): 「用系统方案」的地基 —— 系统资源名与 BlurStyle 取值离线可校验(判据先于替换)
jianf 追加要求「鸿蒙要求用系统方案」(§二·五)。难点是**没有设备**:
`$r('sys.color.写错了')` 编译期不报、只有真机运行到那一行才炸 —— 这块会变成谁都验不了的区域。

SDK 里其实带着答案:`sdk/default/openharmony/toolchains/id_defined.json` 列了全部系统资源名
及其类型(本机 API 26:7826 条,color 1059 条);BlurStyle 取值在 `component/common.d.ts` 的
`declare enum BlurStyle` 里。两边都能离线查,所以"名字写对没有"可以变成构建期判据。

新增 `test/harmony-system-api.test.mjs`(4 条,已接进 run-all):

1. SDK 名表/组件声明找不到时**判红并给出替代做法**(会静默跳过的判据等于没有这条判据 ——
   本仓库已栽过四次同类问题);
2. 源码里每个 `$r('sys.<type>.<name>')` 必须存在且**类型相符**;
3. 每个 `BlurStyle.<MEMBER>` 必须在 SDK enum 里;
4. 替换计划要用的那批系统色**先核过再写代码**,并且钉住一条边界:名表里没有
   `brand`/`confirm`/`success` —— 权限三档、预算三档这些**业务语义色没有系统对应物**,
   继续用自定义令牌,不许"为了系统化"把 plan 档画成 warning 色(那是丢语义换形式)。

变异验证:拼错色名 `ohos_id_color_list_cad_bg` → 报「查无此名」并指出文件;
写错 `BlurStyle.COMPONENT_不存在` → 报出可用取值。顺带修掉解析精度问题:
早先的宽松正则把文档里的 `T`、`R` 也当成了枚举成员,现在精确到 `,`/`=` 分隔符。

验证:`npm test` 退出码 0(10 个判据文件全绿 + vitest 258/258)。
2026-09-14 13:56:43 +08:00

176 lines
8.2 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} 竟然存在了?那这条边界要重新核一遍`);
}
});