Files
MailUI4Agents/client/electron/test/cross-client-theme.test.mjs
JianFeeeee b041ea51e4 fix(harmony): 令牌收尾 —— 裸色值清零、遮罩拆两段式、权限档位配色对齐 WebUI
接手核对时发现:「不许写死颜色」那条判据**只挡得住枚举的 8 个旧值** —— 判据全绿的
同期,pages/ 里还留着 14 处另一套写死的色(Google/Material:#E8F0FE、#E8F5E9、
#FFF3E0、#D93025、#777777×2、#555555、#444444、#cccccc、#aaaaaa、
遮罩 #80000000×2、透明 #00000000×2)。枚举挡不住漂移,只有"类"能挡。

## 改法

- 14 处全部换成令牌。新增 accentStrong / warnBg / warnFg(取值对齐 WebUI `:root`
  的 blue-700 / amber-50 / amber-700)与 `Theme.permBg/permFg` —— 权限档位徽标与
  WebUI 的 `PermissionChip.tsx` **同一映射**(plan=蓝 / workspace=绿 / full=琥珀);
  原先写成 `full ? 绿 : 橙`(Material 色),与 WebUI **反着来**。
- 遮罩拆成 `overlayColor` + `overlayAlpha`(照 WebUI 的 `--bg-scrim` + `--bg-dim`
  两段式):遮罩色要能随主题换向,色与透明度焊死成一个 `#AARRGGBB` 等于把枚举写回
  代码。ArkUI 只认单值,故由 `Theme.overlay()` 组装。
- 判据从"枚举旧值"改成"按类挡":pages/ 下**一个裸色值都不许有**(含 8 位
  `#AARRGGBB`),页面清单从硬编码 7 个文件名改成**扫目录** —— 旧写法下,
  接下来要加的发件箱/授权/日历会自动逃出判据。另加一条判据:权限档位配色与
  WebUI `:root` 变量逐一比对,防"看起来差不多"。

## 验证

- 变异测试:往 `InboxPage.ets` 塞一个 `#E8F0FE` → 判据红;撤回 → 绿。
- cross-client 判据 6 → 8 条全绿;`hvigorw assembleHap` BUILD SUCCESSFUL。
- **视觉未验**(模拟器在本机文件沙箱下起不来,见 `docs/HARMONY-ALIGN-PLAN.md` 5.4),
  未写成"已完成"。
2026-09-14 13:29:50 +08:00

139 lines
7.6 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.

/**
* 两个客户端必须用**同一份设计词表**。
*
* 用户下一步要求:「同步 ui 设计到客户端」。同步的第一件事不是把每个页面重画一遍,
* 而是两边共用同一套令牌(颜色/圆角/玻璃透明度)—— 否则每加一个页面就重抄一遍色值,
* 两个客户端会越走越远,而且这种漂移**没有任何判据会红**。
*
* 这里只断言"两边对同一件事的取值一致"不断言实现方式WebUI 用 CSS 变量、
* 鸿蒙用 ArkTS 常量,本来就该不同)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { 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 web = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
const harmony = readFileSync(
join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'),
'utf8'
);
const hex = h => h.toUpperCase();
test('品牌蓝一致', () => {
// WebUI: --c-blue-600 应该是 37 99 235 = #2563EB
const webBlue = web.match(/--c-blue-600:\s*(\d+)\s+(\d+)\s+(\d+)/);
assert.ok(webBlue, 'WebUI 要有 --c-blue-600');
const asHex = hex(
'#' +
[webBlue[1], webBlue[2], webBlue[3]]
.map(n => Number(n).toString(16).padStart(2, '0'))
.join('')
);
const harmonyBlue = harmony.match(/accent: string = '(#[0-9A-Fa-f]{6})'/);
assert.ok(harmonyBlue, '鸿蒙要有 accent');
assert.equal(hex(harmonyBlue[1]), asHex, `品牌蓝不一致WebUI ${asHex} vs 鸿蒙 ${harmonyBlue[1]}`);
});
test('卡片圆角一致WebUI 0.875rem = 14px', () => {
assert.match(web, /--radius-card:\s*0\.875rem/);
assert.match(harmony, /radiusCard: number = 14/);
});
test('导航玻璃不透明度一致(都是 0.72', () => {
assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/);
// 0.72 × 255 ≈ 184 = 0xB8鸿蒙用 #AARRGGBB顺序与 CSS 不同)
assert.match(harmony, /navBgLight: string = '#B8FFFFFF'/);
});
test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => {
const mutated = harmony.replace(/accent: string = '#2563EB'/, "accent: string = '#FF0000'");
const m = mutated.match(/accent: string = '(#[0-9A-Fa-f]{6})'/);
assert.notEqual(hex(m[1]), '#2563EB');
});
test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => {
assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/);
});
test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => {
/*
* 上一版这条判据只列了 8 个旧色值(#1A73E8 / #333333…于是判据全绿的
* 同时pages/ 里还留着 14 处**另一套**写死的色Google/Material 的
* #E8F0FE、#E8F5E9、#FFF3E0、#D93025 与 #777777/#555555/#444444/
* #cccccc/#aaaaaa、遮罩 #80000000、透明 #00000000。
* 枚举只能挡住"列出来的实例",挡不住漂移本身 —— 改成按类挡:
* pages/ 下一个裸色值都不许有(含 8 位 #AARRGGBB颜色只能来自 Theme.ets。
*
* 页面清单也一并改成扫目录:旧版硬编码 7 个文件名,**新加的页面会自动
* 逃出判据** —— 而发件箱/授权/日历正是接下来要加的页面。
*/
const dir = join(ROOT, 'client/harmony/entry/src/main/ets/pages');
const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort();
assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets判据大概扫错了目录`);
const literal = /#[0-9A-Fa-f]{6,8}\b/g;
for (const f of files) {
const hits = readFileSync(join(dir, f), 'utf8').match(literal);
assert.equal(hits, null, `${f} 里有裸色值 ${hits ? hits.join('、') : ''}(应改用 Theme 令牌)`);
}
// 反向对照:判据要真能抓到裸色值(否则写错了正则也是一片绿)
assert.deepEqual("fontColor('#E8F0FE')".match(literal), ['#E8F0FE']);
assert.deepEqual("color('#80000000')".match(literal), ['#80000000']);
// 令牌文件本身必须是**唯一**的颜色来源,否则上面那条会因为"哪儿都没有色值"而空转
assert.ok(/#[0-9A-Fa-f]{6,8}/.test(harmony), 'Theme.ets 里应该有真正的色值');
});
test('权限档位徽标两边同一套色plan=蓝 / workspace=绿 / full=琥珀)', () => {
// WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。
const chip = readFileSync(
join(ROOT, 'client/electron/src/components/PermissionChip.tsx'),
'utf8'
);
assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝');
assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀');
assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿');
// 鸿蒙侧取的是同一批值 —— 直接和 WebUI 的 :root 变量比,防的是"看起来差不多"
const cssVar = (name, src) => {
const m = src.match(new RegExp(`--${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
assert.ok(m, `WebUI 要有 --${name}`);
return hex('#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join(''));
};
const token = name => {
const m = harmony.match(new RegExp(`${name}: string = '(#[0-9A-Fa-f]{6})'`));
assert.ok(m, `鸿蒙要有令牌 ${name}`);
return hex(m[1]);
};
assert.equal(token('accentSoft'), cssVar('c-blue-50', web), 'plan 底色应与 blue-50 一致');
assert.equal(token('accentStrong'), cssVar('c-blue-700', web), 'plan 字色应与 blue-700 一致');
assert.equal(token('approveBg'), cssVar('c-green-50', web), 'workspace 底色应与 green-50 一致');
assert.equal(token('approveFg'), cssVar('c-green-700', web), 'workspace 字色应与 green-700 一致');
assert.equal(token('warnBg'), cssVar('c-amber-50', web), 'full 底色应与 amber-50 一致');
assert.equal(token('warnFg'), cssVar('c-amber-700', web), 'full 字色应与 amber-700 一致');
});
test('鸿蒙的遮罩是「色 + 透明度」两段式(照 WebUI 的 --bg-scrim + --bg-dim', () => {
/*
* WebUI 的遮罩是**两段**`--bg-scrim`(颜色:浅色=白、深色=黑)+
* `--bg-dim`(透明度),见 `index.css` 的 `.app-backdrop::after`。
* 理由:遮罩色要能**随主题换向** —— 浅色主题用白把图案洗淡,深色主题必须换黑,
* 否则浅色照片在深色界面里糊成一块亮斑、正文读不动WebUI 侧实测踩过)。
* 鸿蒙侧原先写成一个焊死的 `#80000000`,换向时只能再写一个常量 —— 又变成枚举。
*/
assert.match(harmony, /overlayColor: string = '#000000'/, '鸿蒙的遮罩**色**要单独成令牌');
assert.match(harmony, /overlayAlpha: number = 0\.5/, '鸿蒙的遮罩**透明度**要单独成令牌');
// 只查**代码**不查注释Theme.ets 的注释里正当地引用了旧值来解释为什么拆开
const harmonyCode = harmony.replace(/\/\*[\s\S]*?\*\//g, '');
assert.ok(!/#80000000/.test(harmonyCode), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值');
// WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立
assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色');
assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色');
assert.match(web, /--bg-dim:/, 'WebUI 的透明度是独立变量');
// 反向对照:判据要真能抓到"焊死单值"
assert.ok(/#80000000/.test("backgroundColor('#80000000')"), '自检:正则抓不到焊死的单值');
});