Files
MailUI4Agents/client/electron/test/cross-client-theme.test.mjs
JianFeeeee 21132647bc 跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了

此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么",
不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。

新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` /
`hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。

**它当场证明了它的价值**:`cross-client-theme` 新增的设备判据
用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。

## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用

`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` ——
一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。

- 浅色下当白字用**碰巧对**(白字压蓝底)
- **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见**

设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**;
读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。

`Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在,
却**一处都没用**。

修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg`
(`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。
另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。

## 三、判据(两条,都做了变异验证)

1. **设备条**(`cross-client-theme`):读悬浮球像素 ——
   ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染);
   ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。
   把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。
   设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。

## 四、判据自身踩的三个坑(都写进注释了)

- **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。
  截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。
- **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`),
  实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。
  原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。
- **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页),
  而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。

## 五、欠账

- `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了
  (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。
  如实记着**未验**的那点:预算条的**点击**没在设备上走通
  (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。
- `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 ——
  `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。
- `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`;
Go 侧 `./internal/repo/...` 通过。
2026-09-19 19:29:49 +08:00

1098 lines
63 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 { code, prose, stripComments } 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 } 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 web = code(join(ROOT, 'client/electron/src/index.css'));
const harmonyPath = join(HARMONY_ETS, 'common/Theme.ets');
/** 判「代码里有什么」用这个 */
const harmony = code(harmonyPath);
/** 判「注释/文档里写了什么」用这个 —— 两条判据各取所需,别混用 */
const harmonyDoc = prose(harmonyPath);
/**
* 裸色值的**类**(不只 `#RRGGBB`):四种写法一起扫 ——
* `#RRGGBB(AA)`、`rgba(...)`、`0xRRGGBBAA`(`Color(0x…)` 与渐变数组都用它)。
* 注释先剥掉:注释里引用旧写法是常有的事,而"诚实的注释"不该把判据判红。
*/
const RAW_COLOR = /#[0-9A-Fa-f]{6,8}\b|\brgba?\s*\(|\b0x[0-9A-Fa-f]{6,8}\b/g;
const rawColors = src => stripComments(src).match(RAW_COLOR) || [];
/**
* 文档里的「有意差异」表(§7.12)。
*
* 这是**弱判据**用的材料:它防的是"改了做法没改记录"(下一个人会当成漏改),
* 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上
* (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。
*/
const plan = prose(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'));
/**
* 取「有意差异」那一小节。
*
* ⚠️ **按标题定位,不按关键词**(pi 读出来的第三条):
* 原来写的是 `plan.indexOf('有意差异')` —— 只要别的段落正文里出现过这四个字,
* 切片就从**那一处**开始,后面的 `includes('圆角')`、行数断言全是在**别的段落**上判,
* 可能照样绿。这个坑在本仓库记过一次:`index.css` 的注释里写了深色选择器字面量,
* `theme.test.mjs` 的 `indexOf` 就被提前截断(那条注释现在还留着当教训)。
*/
const diffSection = () => {
const lines = plan.split('\n');
const start = lines.findIndex(l => /^#{2,4}\s/.test(l) && l.includes('有意差异'));
assert.ok(start >= 0, '文档里应有「有意差异」小节(§7.12),且要是一行标题');
const headLevel = (lines[start].match(/^#+/) || ['#'])[0].length;
/*
* 用**行**切,不用字符偏移。
*
* 第一版是算偏移的(`acc += 每行长度`,再 `slice`),而偏移算术一旦差一个字符
* 就会把最后一行**拦腰截断** —— 表现是"品牌色那一行只剩 57 个字符、找不到'不允许差异'"
* 这种看着像文案问题、其实是切片问题的怪现象。行切法没有这种自由度。
*/
const body = [];
for (let i = start + 1; i < lines.length; i++) {
const h = lines[i].match(/^(#{1,6}) /);
if (h && h[1].length <= headLevel) break;
body.push(lines[i]);
}
return body.join('\n');
};
/** 递归收集鸿蒙源码(.ets / .ts) */
const collectEts = (dir, acc = []) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) collectEts(full, acc);
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
}
return acc;
};
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 有它自己的令牌,鸿蒙跟随系统 —— 差异被记录(不再钉"取值相同")', () => {
/*
* 这一条**改过口径**(2026-09-14,jianf 要求「鸿蒙用系统方案」,与 pi 对齐后改)。
*
* 旧口径钉的是"两边都 14px"。改用系统资源后它必然红 —— 而这次红是**预期**的:
* 圆角交给系统就意味着它**允许**与 WebUI 不同(系统圆角会随设备/主题/无障碍设置变)。
* 所以口径从"取值相同"换成"**意图相同**":
* ① WebUI 侧仍须自己声明一个圆角令牌(它没有系统可跟随);
* ② 鸿蒙侧圆角来自系统(`sys.float.*`);
* ③ 这条差异被**记录**在文档的"有意差异"表里 —— 否则下一个人会以为是漏改。
* 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。
*/
assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌');
assert.match(stripComments(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统');
assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)');
});
test('材质:WebUI 声明透明度令牌,鸿蒙用系统材质 —— 差异被记录(不再钉 0.72)', () => {
// 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事
// 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。
assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌');
assert.match(stripComments(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统');
assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表');
});
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(harmonyDoc, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/, '这条判的是**注释里写的对应关系**,所以要读原文(prose),不是剥离版');
});
test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => {
/*
* 上一版这条判据只列了 8 个旧色值(#1A73E8 / #333333…),于是判据全绿的
* 同时,pages/ 里还留着 14 处**另一套**写死的色:Google/Material 的
* #E8F0FE、#E8F5E9、#FFF3E0、#D93025 与 #777777/#555555/#444444/
* #cccccc/#aaaaaa、遮罩 #80000000、透明 #00000000。
* 枚举只能挡住"列出来的实例",挡不住漂移本身 —— 改成按类挡。
*
* ## 2026-09-14:按类挡得**挡全**(pi 指出的洞)
*
* 原来只扫 `#RRGGBB(AA)` —— 抓不到 `rgba(...)`、`Color(0x…)`、`0xRRGGBBAA` 这几种写法,
* 而"改用系统材质/颜色"的过程恰恰最容易混进这几种形态(`0xB8FFFFFF` 就是手写玻璃)。
* 现在四种形态一起扫,并且**先剥注释**再扫:注释里正当地引用旧写法是常有的事
* (本文件上面两段注释里就各有一个 #B8FFFFFF),不剥的话判据会被"诚实的注释"判红。
*/
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,判据大概扫错了目录`);
for (const f of files) {
const hits = rawColors(prose(join(dir, f)));
assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`);
}
// 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿)
assert.deepEqual(rawColors("fontColor('#E8F0FE')"), ['#E8F0FE']);
assert.deepEqual(rawColors("color('#80000000')"), ['#80000000']);
assert.deepEqual(rawColors('linearGradient({ colors: [[0xB8FFFFFF, 0]] })'), ['0xB8FFFFFF']);
assert.deepEqual(rawColors("backgroundColor('rgba(255,255,255,0.72)')"), ['rgba(']);
// 注释里的旧写法不算(否则这条判据会逼着人删掉"为什么不能这么写"的解释)
assert.deepEqual(rawColors("// 旧写法是 '#B8FFFFFF'\ncolor(Theme.surface)"), []);
// 令牌文件本身是**唯一**的颜色来源,否则上面那条会因为"哪儿都没有色值"而空转
assert.ok(/#[0-9A-Fa-f]{6,8}/.test(harmony), 'Theme.ets 里应该有真正的色值');
});
// ─────────────── 「用系统方案」的意图判据(A/B/C + 品牌色防线) ───────────────
test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不是手抄的值)', () => {
/*
* 「一个维度只允许一个机制来源」。表面/文字/分隔/遮罩/圆角这些**系统有语义**的维度
* 只能来自 `$r('sys.*')` —— 于是它们自动跟随深色模式,也不会再和系统打架。
*
* 说明一处**与 pi 原话的有意差异**:他写的是"页面不再引用 Theme.pageBg/surface/…",
* 我保留了 `Theme.` 这层名字,但把它的**值**换成了系统资源。理由是"唯一来源"这条性质
* 靠这个也能拿到(值只有一处、且那一处指向系统),而 149 处调用点不用动 ——
* 少动 149 处就少 149 次改错的机会。真正的防线是下面这条判据本身:
* 只要有人把这些格子改回手写色值,它就红。
*/
const sysOwned = {
pageBg: 'color', surface: 'color', surfaceMuted: 'color', border: 'color',
textPrimary: 'color', textMuted: 'color', textSubtle: 'color',
overlay: 'color', navFg: 'color',
radiusCard: 'float', radiusControl: 'float'
};
for (const name of Object.keys(sysOwned)) {
const kind = sysOwned[name];
assert.match(
harmony,
new RegExp(`static readonly ${name}: Resource = \\$r\\('sys\\.${kind}\\.[A-Za-z0-9_]+'\\)`),
`${name} 必须来自系统资源($r('sys.${kind}.*')),而不是手写的值`
);
}
// 反向断言:这些格子不得退回 string/number 类型(退回 = 又开始自己定值了)
for (const name of Object.keys(sysOwned)) {
assert.ok(
!new RegExp(`${name}: (string|number) =`).test(stripComments(harmony)),
`${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号`
);
}
});
/**
* Theme.ets 里**允许自己写**的色值名单(与文件里的「手写色登记表」逐字一致)。
*
* pi 读出来的真缺口:A 条只枚举了"**必须**来自系统资源"的 11 个名字,
* 于是新加一个手写色(`static readonly brandSecondary: string = '#123456'`)
* **三条判据都碰不到它** —— A(不在名单里)、B(六位、不是半透明)、
* 裸色值那条(只管 `pages/` 目录)。
*
* 「枚举挡实例,类才挡漂移」:这次的枚举单位是**名字**,所以名单本身就是防线。
* 想加一个手写色 → 先登记在这里 + 在 `Theme.ets` 的登记表里写一行理由;
* 否则它本来就该走 `$r('sys.*')`。
*/
const SELF_OWNED_COLORS = [
// 品牌(跨客户端身份,必须与 WebUI 逐字一致的那一个 + 它的前景/浅底/深色变体)
'accent', 'accentFg', 'accentSoft', 'accentStrong',
// 业务语义色:系统没有对应物(同意 / 拒绝 / 警示)
'approve', 'danger', 'approveBg', 'approveFg', 'dangerBg', 'warnBg', 'warnFg',
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg',
/*
* ★★ 2026-09-19 补登记(对应用户「底栏数字为什么显示在图标下面?」与
* 用户「你写的app和webui大面积不符」那两轮改动)。
*
* 三个值都是**跨端身份色**,系统语义色里没有对应物:
* · `navActiveBg` #DBEAFE —— WebUI `index.css:1595-1606` 的 `--nav-active-bg`,
* 侧栏选中项的**浅蓝底块**(宽屏侧栏的选中线索就是它,不是文字变色)。
* · `navBrandFg` #475569 —— WebUI 的 `--nav-fg-muted`,侧栏未选中项的图标色。
* · `badgePlain` #475569 —— WebUI `bg-chrome-600`(`index.css:92`),
* "plain" 档徽标底色(联系人数)。**它必须是石板灰而不是红**:
* 三档被压成两档时,`'plain'` 会走 `danger`,看起来像"有未读"。
* `chrome-600` 刻意不透明(`index.css:1097` 有注释:15px 小控件叠透明度
* 会让数字掉到 4.46:1,低于 WCAG AA)。
*
* 三者的取值理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
*/
'navActiveBg', 'navBrandFg', 'badgePlain', 'accentSoftDark',
/*
* SSE 连接指示器的四个状态色(`WideSidebar.ets` 的 `sseColorOf`)。
* 逐档对齐 WebUI 的 Tailwind 类(`ConnectionIndicator.tsx:22-25`):
* connected `bg-green-500` / connecting `bg-yellow-400` /
* reconnecting `bg-orange-400` / disconnected `bg-red-400`。
*
* 为什么不复用 `warnFg`/`danger`:那两个是**文字色**,而状态点是 8px 实心圆,
* 用文字色会发脏、深色底上不够跳。WebUI 也是分开的两套(`text-*` vs `bg-*`)。
*/
'sseConnecting', 'sseConnected', 'sseReconnecting', 'sseDisconnected'
];
test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => {
const themeSrc = harmony; // 声明在代码里:读剥离版就够了
const declared = [...themeSrc.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]);
assert.ok(declared.length >= 10, `要从 Theme.ets 里读到那些手写色,实际读到 ${declared.length} 个`);
// ① 未登记的手写色 → 红(这是补上的那一枪)
const extra = declared.filter(n => !SELF_OWNED_COLORS.includes(n));
assert.deepEqual(extra, [],
`Theme.ets 里出现未登记的手写色:${extra.join('、')} —— ` +
'属于品牌/业务语义色就登记进 SELF_OWNED_COLORS 并在 Theme.ets 的登记表里写一行理由,否则该走 $r(\'sys.*\')');
// ② 名单不能烂成"曾经"的化石:登记了却已经不存在的名字 → 红
const stale = SELF_OWNED_COLORS.filter(n => !declared.includes(n));
assert.deepEqual(stale, [], `名单里这些名字在 Theme.ets 里已不存在(名单要跟着改):${stale.join('、')}`);
/*
* ③ 登记表本身要写在文件里(理由靠记忆是不可靠的,要靠登记):
* 每个登记项都要在那一段**注释**里出现 —— 所以这里读**原文**(`harmony`),
* 不是剥过注释的源码(剥注释会把登记表本身剥掉,第一版就踩了这个:
* `indexOf` 返回 -1,切片拿到一段不相干的东西)。
*/
const regStart = harmonyDoc.indexOf('手写色**登记表**');
assert.ok(regStart > 0, 'Theme.ets 里要有「手写色登记表」那一段');
const regEnd = harmonyDoc.indexOf('品牌色:跨客户端身份', regStart);
assert.ok(regEnd > regStart, '登记表那一段要有明确的结束边界(下一个分节标题)');
const registry = harmonyDoc.slice(regStart, regEnd);
assert.ok(registry.length > 200, `登记表太短,可能切错了段落(${registry.length} 字符)`);
for (const name of SELF_OWNED_COLORS) {
assert.ok(registry.includes(name), `登记表里要提到 ${name}(否则理由只存在于写它那个人的记忆里)`);
}
/*
* 自检:把一个**新的**手写色塞进去,确认①真的抓得到。
* 这条自检是"判据能判红"的证据(不依赖外部变异测试也能看出它有效)。
*/
const mutated = themeSrc.replace(
"static readonly accent: string = '#2563EB';",
"static readonly accent: string = '#2563EB';\n static readonly brandSecondary: string = '#123456';"
);
assert.notEqual(mutated, themeSrc, '自检:变异没打上');
const mutatedNames = [...mutated.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]);
const mutatedExtra = mutatedNames.filter(n => !SELF_OWNED_COLORS.includes(n));
assert.deepEqual(mutatedExtra, ['brandSecondary'], '自检:新加的手写色必须被判据抓到');
});
test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => {
const codeOnly = stripComments(harmony);
/*
* `navBgLight` / `navBgDark` 这两个名字本身就是罪证:浅色一个、深色一个手写玻璃,
* 等于"我们替系统猜了深色该怎么做"。pi 在 WebUI 侧撤掉 `.dark` 导航令牌、
* 我在这侧撤掉这两个常量,是**同一个判断**,只是答案相反:
* WebUI 没有深色主题所以不该猜,鸿蒙有系统主题所以**不该手写**。
*/
assert.ok(!/navBg(Light|Dark)/.test(codeOnly), 'navBgLight/navBgDark 不该再出现(玻璃交给系统材质)');
// 半透明色(8 位 #AARRGGBB)= 手写玻璃/手写遮罩那一类,一律不许
const translucent = rawColors(codeOnly).filter(h => h.startsWith('#') && h.length === 9);
assert.deepEqual(translucent, [], '源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责');
// 鸿蒙侧不写 CSS 式颜色函数
assert.ok(!/\brgba?\s*\(/.test(codeOnly), '鸿蒙源码里不该出现 rgb()/rgba()(那是 WebUI 的写法)');
// 圆角不得再与 WebUI 的 14/8 绑死
assert.ok(!/radiusCard: number = 14/.test(codeOnly), 'radiusCard 又变回写死的 14 了');
assert.ok(!/radiusControl: number = 8/.test(codeOnly), 'radiusControl 又变回写死的 8 了');
});
/**
* 允许出现玻璃的位置**名单**(改判据时一起改这里)。
*
* pi 读出来的第二条:原来那条判据写的是 `assert.equal(glassCalls.length, 1)`,
* 断言的是"全仓一共一处 `backgroundBlurStyle`" —— 它**不是**"没有嵌套":
* 同一页面上两个**并列**的玻璃面(不嵌套,没问题)会让它红,而真正的嵌套它没在判。
* 现在只有导航条一处,所以红得对;但 P5 正是"悬浮玻璃导航",很可能撞上第二处。
*
* 到那时要**改判定形状**,不是把 1 改成 2 —— 改成 2 这条就退化成"最多两处"(等于不判)。
* 所以现在就把形状改成它真正想说的两件事:
* ① **不许嵌套**(模糊叠模糊,视觉上互相打架、性能也白花);
* ② 每一处玻璃都要**登记**(新开一处玻璃面必须显式过一道,而不是悄悄多出来)。
*/
const GLASS_REGISTRY = [
// 文件(相对 ets 根) + 组件/Builder 名:为什么这里可以有一层系统材质
{ file: 'pages/MainPage.ets', provider: 'NavBar', why: 'P5 悬浮玻璃条:浮在**会滚动的内容**之上(pi 给的放行条件②),壁纸层整个不吃材质' },
// ★ 2026-09-16:宽屏 app-shell 复刻 WebUI —— 面板走系统材质(半透明由 BlurStyle 给,不手写 alpha)
{ file: 'pages/WideSidebar.ets', provider: 'SidebarItem', why: '宽屏侧栏(判据按最近的 @Builder 命名):浮在壁纸之上、背后是会滚动的导航内容(WebUI Sidebar 同形状),bgActive 才开。★ 2026-09-19 改名:原先登记的是 NavItemBuilder,但那是我上一版编造的“复刻”(参见 Theme.ets 里那段更正)—— 重写后按 WebUI 拆成导航轨 + 底部一簇,@Builder 相应改名,登记同步跟上(判据就是为此存在的)' }
];
/**
* 往回找"这次材质作用在哪个组件块上",返回块尾 `}` 的下标(找不到返回 -1)。
*
* ⚠️ 这里**不能只看前一个字符是不是 `}`**:ArkUI 的修饰符是链式的,
* `Row() { ... }.backgroundColor(x).backgroundBlurStyle(A)` 里 `backgroundBlurStyle` 前面是 `)`。
* 第一版就只看了一个字符,于是这种写法下"嵌套"检查**静默失效**(判 get 到 null 就放过去了)——
* 这正是"断言形状和它声称的东西不是一回事"那一类毛病,判据自检把它抓了出来。
* 现在往回扫时跳过成对的括号组(含修饰符参数),遇到 `}` 才算块尾。
*/
const blockEndBefore = (src, at) => {
let i = at - 1;
let depth = 0;
while (i >= 0) {
const c = src[i];
if (c === ')') { depth++; i--; continue; }
if (c === '(') { depth--; i--; continue; }
if (depth === 0) {
if (c === '}') return i;
if (c === '{' || c === ';') return -1; // 走到了别的结构:这次材质没作用在块上
}
i--;
}
return -1;
};
/** 用花括号配对取 span(剥过注释的源码上做;字符串里的花括号在这份代码里不出现) */
const braceSpans = (src) => {
const spans = [];
const stack = [];
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '{') stack.push(i);
else if (c === '}' && stack.length) spans.push([stack.pop(), i]);
}
return spans;
};
test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(形状判据,不是数数)', () => {
/*
* 「用系统方案」里最容易被写歪的一处:`#B8FFFFFF` 看着也能出玻璃效果,
* 但它不跟随深色模式、也不跟随系统的模糊半径。所以钉三件事:
* ① 材质档次来自 `BlurStyle`(系统枚举);② 该玻璃化的位置真的调了 `backgroundBlurStyle`;
* ③ **不许叠**(同一组件叠两次 / 套在另一层玻璃的子树里)+ 每一处都登记。
*
* ③ 的形状是 pi 读出来的第二条:原来写的是 `assert.equal(glassCalls.length, 1)` ——
* 那断言的是"全仓一共一处",**不是**"没有嵌套":同一页面两个**并列**玻璃面会让它红
* (并列本身没问题),而真正的嵌套它没在判。P5 正是"悬浮玻璃导航",很可能撞上第二处;
* 到那时若把 1 改成 2,这条就退化成"最多两处"(等于不判)。所以现在就把形状改对。
*/
const codeOnly = stripComments(harmony);
assert.match(codeOnly, /static readonly navMaterial: BlurStyle = BlurStyle\.[A-Z_]+/, '导航材质要声明成系统材质档次');
const navMaterial = codeOnly.match(/navMaterial: BlurStyle = BlurStyle\.([A-Z_]+)/)[1];
assert.notEqual(navMaterial, 'NONE', 'NONE 等于没有材质,"玻璃"就名存实亡');
const etsRoot = join(ROOT, 'client/harmony/entry/src/main/ets');
/** 每个调用点:哪一处(文件#组件)、调用下标、它作用的**组件块**(配对出来的) */
const found = [];
for (const f of collectEts(etsRoot)) {
const src = code(f);
const spans = braceSpans(src);
for (const m of src.matchAll(/backgroundBlurStyle\(/g)) {
const at = m.index;
const bEnd = blockEndBefore(src, at);
const span = bEnd >= 0 ? spans.find(sp => sp[1] === bEnd) : undefined;
const owner = [...src.slice(0, at).matchAll(/(?:struct|@Builder\s+)\s*(\w+)/g)].pop();
found.push({
key: `${f.slice(etsRoot.length + 1)}#${owner ? owner[1] : '(未识别)'}`,
at,
block: span ? { start: span[0], end: span[1] } : null
});
}
}
assert.ok(found.length > 0, '至少导航条要用系统材质(不是手写 alpha)');
/*
* 叠用判定(两种形态都判红):
* · 同一组件:两处调用解析到**同一个块**(`X.blur(A).blur(B)` 就是这种,ArkUI 的修饰符链
* 作用在同一个节点上 —— 注意它前面是 `)` 不是 `}`,所以按字符相邻判会漏);
* · 子树:一处的调用点落在另一处的块**内部**。
*/
const doubled = new Set();
const nested = new Set();
for (const c of found) {
for (const o of found) {
if (o === c) continue;
if (c.block && o.block && c.block.start === o.block.start && c.block.end === o.block.end) {
const pair = [c.at, o.at].sort((a, b) => a - b).join('-');
doubled.add(`${c.key}@${pair}`);
} else if (c.block && o.at > c.block.start && o.at < c.block.end) {
nested.add(`${c.key}(内含 ${o.key} 的玻璃)`);
}
}
}
assert.deepEqual([...doubled], [], `同一个组件上不该叠多层模糊:${[...doubled].join('、')}`);
assert.deepEqual([...nested], [], `玻璃不该套在另一层玻璃的子树里:${[...nested].join('、')}`);
// 每一处都要登记;名单里也不能有已经不存在的位置(否则名单会烂成化石)
const keys = found.map(c => c.key);
const registered = GLASS_REGISTRY.map(g => `${g.file}#${g.provider}`);
const unregistered = keys.filter(k => !registered.includes(k));
assert.deepEqual(unregistered, [],
`这些位置开了玻璃但没登记:${unregistered.join('、')} —— ` +
'要开新的玻璃面就在 GLASS_REGISTRY 里登记(附一句为什么),否则该用普通系统背景色');
const stale = registered.filter(k => !keys.includes(k));
assert.deepEqual(stale, [], `名单里这些位置已经没有玻璃了(名单要跟着改):${stale.join('、')}`);
for (const g of GLASS_REGISTRY) {
assert.ok(g.why && g.why.length >= 8, `GLASS_REGISTRY 里 ${g.file}#${g.provider} 要写一句为什么可以在这里开玻璃`);
}
// 导航条那一处必须真的还在(形状判定之外,位置本身也要在)
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)');
/*
* 自检:造一次**链式叠用**(`X.blur().blur()`),确认上面的判定抓得到。
* 这一枪放在判据里,是为了以后改这段扫描逻辑时它自己会被检验 ——
* 第一版只看"前一个字符是不是 }",对这种写法**静默失效**,正是这条自检抓出来的。
*/
// 样本要与**真实写法同形**(否则自检会变成"拿一段判据认不出来的代码去验判据")
const sample = 'Row() { Text("x") }\n .backgroundBlurStyle(Theme.navMaterial)\n' +
' .backgroundBlurStyle(Theme.navMaterial)';
const sampleEnds = [...sample.matchAll(/backgroundBlurStyle\(/g)].map(m => blockEndBefore(sample, m.index));
assert.ok(sampleEnds.every(e => e >= 0), '自检:链式写法要能解析出所作用的块');
assert.equal(new Set(sampleEnds).size, 1, '自检:同一组件的两处调用必须解析到同一个块(否则叠用判不出来)');
const sampleSpans = braceSpans(sample);
assert.ok(sampleSpans.some(sp => sp[1] === sampleEnds[0]), '自检:块的配对要能对上');
});
test('★ 品牌色防线:主操作色不得退化成系统强调色', () => {
/*
* 这是 pi 最担心的语义事故,我同意:改用系统方案时**顺手**把 `accent` 换成
* 系统的 emphasize 色,界面看着还挺协调 —— 但品牌蓝是**跨客户端身份**
* ("两个客户端是同一个产品"),系统强调色会随主题/厂商皮肤变,换过去这件事就靠不住了。
* 所以这条不是"取值好看",是钉住唯一必须与 WebUI 逐字一致的那一个值。
*/
assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值');
assert.ok(
!/accent: Resource/.test(stripComments(harmony)),
'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识'
);
// 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义)
const login = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'));
assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色');
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色');
});
test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥珀)', () => {
// WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。
const chip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
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 一致');
/*
* 往返预算条的三个档位(P2a 新加)。
*
* WebUI 的 `BudgetChip` 用的是 gray-100/gray-500(普通)、red-100/red-700(用尽)、
* orange-100/orange-700(将尽);鸿蒙的卡片视图要显示同一条预算,
* 就得取**同一批值** —— 而且这批值只在这条判据里和 WebUI 对齐,
* 否则"鸿蒙那边自己挑了个接近的红"没人会发现。
*
* ⚠️ 注意:index.css 里 `--c-gray-100` 等变量在 `.dark` 段里还有第二处定义,
* 上面的 cssVar 取的是**第一处**(`:root`,浅色主题)—— 与 `Theme.ets` 是浅色一套对应。
*/
assert.equal(token('chipNeutralBg'), cssVar('c-gray-100', web), '预算普通档底色应与 gray-100 一致');
assert.equal(token('chipNeutralFg'), cssVar('c-gray-500', web), '预算普通档字色应与 gray-500 一致');
assert.equal(token('chipSpentBg'), cssVar('c-red-100', web), '预算用尽底色应与 red-100 一致');
assert.equal(token('chipSpentFg'), cssVar('c-red-700', web), '预算用尽字色应与 red-700 一致');
assert.equal(token('chipWarnBg'), cssVar('c-orange-100', web), '预算将尽底色应与 orange-100 一致');
assert.equal(token('chipWarnFg'), cssVar('c-orange-700', web), '预算将尽字色应与 orange-700 一致');
// 反向对照:判据要真能抓到"自己挑了个接近的颜色"
const off = harmony.replace("chipSpentBg: string = '#FEE2E2'", "chipSpentBg: string = '#FEE3E3'");
assert.notEqual(
hex(off.match(/chipSpentBg: string = '(#[0-9A-Fa-f]{6})'/)[1]),
cssVar('c-red-100', web),
'自检:差一个色阶判据却还是绿的'
);
});
test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在由系统做)', () => {
/*
* 这一条也**改过口径**。旧口径要求鸿蒙照 WebUI 那样把遮罩拆成"色 + 透明度"两个令牌 ——
* 理由是遮罩色必须**随主题换向**:浅色主题用白把图案洗淡,深色主题必须换黑,
* 否则浅色照片在深色界面里糊成一块亮斑、正文读不动(WebUI 侧实测踩过)。
*
* 但"随主题换向"正是一个**系统语义色**能表达的东西:`ohos_id_color_mask_regular`
* 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。
* 所以口径改成"遮罩来自系统"(这条是硬的),WebUI 侧仍然两段式(它没有系统可跟随)。
*/
assert.match(stripComments(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色');
// 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事)
assert.ok(!/overlayColor: string/.test(stripComments(harmony)), '遮罩色不该再由我们自己定');
assert.ok(!/overlayAlpha: number/.test(stripComments(harmony)), '遮罩透明度不该再由我们自己定');
/*
* pi 读出来的第四条:判据只断言 `overlay` **存在**,没断言它**被用** ——
* 立一个没人用的令牌是自证(判据只能验"它还在",验不了它有用)。
* 所以这里补一条:它必须在 `Theme.ets` **之外**有真实使用点,
* 否则要么删掉、要么说明它为什么该留着。
*/
const etsRoot = join(ROOT, 'client/harmony/entry/src/main/ets');
const themePath = join(HARMONY_ETS, 'common/Theme.ets');
const overlayUsers = collectEts(etsRoot)
.filter(f => f !== themePath)
.filter(f => /Theme\.overlay\b/.test(code(f)))
.map(f => f.slice(etsRoot.length + 1));
assert.ok(overlayUsers.length > 0,
'Theme.overlay 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)');
/*
* ★ pi 2026-09-15 的第二刀:上面这条**只覆盖了 `overlay` 一个令牌**。
* 我当时把 `MainPage` 里的 `Theme.navMaterial` 换成了另一个表达式,
* `navMaterial` 就**再没有任何使用点**了 —— 而它上面那条判据
* ("declaration 存在且不是 NONE")**照样绿**:它守的是声明,
* 坏的是那条活的调用路径。**判据名替实现作证**,我们这一路反复在消的形状。
* ⇒ 把这条规则**铺到 Theme 的每一个令牌**上(同一个文件里早就写着正确的形状,
* 只是覆盖面只有一处)。令牌只在自己的文件里被别的方法读**不算**(那是内部实现细节,
* 由那个方法自己的使用点担保)。
*/
const themeSrc = code(themePath);
const declared = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
assert.ok(declared.length > 20, `要从 Theme.ets 里读到令牌清单(读到 ${declared.length} 个)`);
const others = collectEts(etsRoot).filter(f => f !== themePath);
const othersSrc = others.map(f => ({ f: f.slice(etsRoot.length + 1), src: code(f) }));
/*
* ⚠️ **量的是"外部引用数"**,这一点是踩出来的:第一版我数"任何引用",
* 结果是 `chipSpentBg`/`chipSpentFg` 被判死 —— 而它们**不是**死的:它们被
* `Theme.budgetBg()` 返回,而 `budgetBg()` 在 `MainPage` 里用着
* (`Theme.budgetBg(budgetState(...))`,那个 `budgetState` 本身有真值判据)。
* 也就是说"只在 Theme 内部被别的方法读"**不算死** —— 那是内部实现细节,
* 由那个方法的**外部**使用点担保。
* 但 `navMaterial` 必须仍然**被抓**:它当时唯一的消费者是我在 `MainPage` 里
* 写的一张**局部只读表**(`BLUR_STYLE_OF`),而那张表可以整体删掉/改写
* (我真删过一次)—— 它不提供任何"担保"。
* ⇒ 区分这两者的唯一办法是**量外部引用数**:页面/组件里引用 0 次,就记一笔,
* 连同它在 Theme 内部被哪些方法读(供人判断"那个方法自己有没有人用")。
*/
const deadTokens = [];
for (const name of declared) {
const re = new RegExp(`Theme\\.${name}\\b`);
const users = othersSrc.filter(o => re.test(o.src)).map(o => o.f);
if (users.length > 0) continue;
// 在 Theme.ets 内部找"读它的那个成员":看每个 `Theme.<name>` 出现处**前面最近**的成员声明
const internalUsers = [];
const re2 = new RegExp(`Theme\\.${name}\\b`, 'g');
let m2;
while ((m2 = re2.exec(themeSrc)) !== null) {
const before = themeSrc.slice(0, m2.index);
const owners = [...before.matchAll(/static\s+(?:readonly\s+)?(\w+)/g)];
if (owners.length === 0) continue;
const owner = owners[owners.length - 1][1];
if (owner !== name && !internalUsers.includes(`Theme.${owner}`)) internalUsers.push(`Theme.${owner}`);
}
/*
* 只被 Theme 内部的方法读 **且那个方法自己在外部有调用点** ⇒ 不算死
* (`chipSpentBg` ← `Theme.budgetBg()` ← `MainPage` 的 `Theme.budgetBg(budgetState(…))`)。
* 否则仍然算死:这正是 `navMaterial` 的形状 —— 它当时那个"内部消费者"是
* `MainPage` 里的一张**局部表**,不提供任何担保。
*/
if (internalUsers.length > 0) {
const ownersAlive = internalUsers.some((u) => {
const methodName = u.replace('Theme.', '').replace('()', '');
return othersSrc.some((o) => new RegExp(`Theme\\.${methodName}\\b`).test(o.src));
});
if (ownersAlive) continue;
deadTokens.push(`${name}(只在 ${internalUsers.join('、')} 内部被读,而那些方法**外部也没有调用点**)`);
continue;
}
deadTokens.push(`${name}(任何地方都没读)`);
}
assert.deepEqual(deadTokens, [],
`★ 这些 Theme 令牌在**页面/组件里一次都没被引用**:\n ${deadTokens.join('\n ')}\n` +
' 要么删掉,要么写出它的使用处;若只是被 Theme 内部的方法读,确认那个方法自己还有外部调用点。\n' +
' **这条要抓的形状**:把一根线接到别处,让某个令牌**意外变成孤儿** —— ' +
'`navMaterial` 就这么变成过孤儿(它当时唯一的消费者是 MainPage 里一张可以整体删掉的局部表),' +
'而只盯声明的判据("声明了且不是 NONE")**照样绿**。');
// 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它)
assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`);
assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #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.match(diffSection(), /遮罩/, '遮罩是"有意差异",要写进文档的差异表');
// 反向对照:判据要真能抓到"焊死单值"
assert.ok(rawColors("backgroundColor('#80000000')").includes('#80000000'), '自检:正则抓不到焊死的单值');
});
test('C|「有意差异」表列全了允许不同的维度,并点名品牌色**不在内**(弱判据,防遗忘)', () => {
/*
* pi 把这条定为**辅助**:文档表会过时,而过时的表照样能判绿 —— 所以它防的是
* "改了做法没改记录"(下一个人会当成漏改),不是"证明做法对"。
* 主体在代码上(A/B + 品牌色防线 + 材质位置),这张表是第三只手。
*/
const section = diffSection();
for (const dim of ['圆角', '材质', '动效', '遮罩']) {
assert.ok(section.includes(dim), `「有意差异」表里要列 ${dim}(它现在是"允许不同"的维度)`);
}
/*
* 品牌色那一行:**按行取**,不用"关键词 + 窗口"。
* 原来写的是 `/品牌色[\s\S]{0,80}不允许差异/` —— 窗口宽度是在赌表格单元格的字符数
* (品牌色行里"品牌色"与"不允许差异"隔着 WebUI/鸿蒙 两格,正好 > 80)。
* 窗口型断言和 `indexOf` 是同一类毛病:看着断言了,其实在赌排版。
*/
const brandRow = section.split('\n').find(l => /^\|\s*\**品牌色/.test(l));
assert.ok(brandRow, '「有意差异」表里应有品牌色一行(它要显式写明"不允许差异")');
assert.match(brandRow, /不允许差异/, '品牌色必须被点名"不允许差异",否则下一个人会以为它也在表内');
// 表头列名要对(这也让"拿错段落"自己红出来:拿错段落时表头不会是这个形状)
const header = section.split('\n').find(l => l.startsWith('|') && !l.includes('---'));
assert.ok(header, '差异表要有表头');
for (const col of ['WebUI', '鸿蒙', '为什么']) {
assert.ok(header.includes(col), `差异表表头要有「${col}」列,实际:${header}`);
}
// 反向对照:表里必须真的写了"为什么允许不同",不是只列个名字
const rows = section.split('\n').filter(l => l.startsWith('|') && !l.includes('---'));
assert.ok(rows.length >= 5, `差异表应有表头 + 至少 4 行,实际 ${rows.length} 行`);
assert.ok(rows.every(r => r.split('|').filter(c => c.trim()).length >= 4), '差异表每行要说清"WebUI / 鸿蒙 / 为什么"');
});
// ───────── 手写色清册**跨文件**(pi 2026-09-14:别一个文件一套枚举) ─────────
/** 从 index.css 的某个段(:root 或 .dark)里读出调色板变量 */
function cssPaletteOf(selector) {
const at = selector === ':root' ? web.indexOf(':root') : web.indexOf('.dark {');
assert.ok(at >= 0, `CSS 里要有 ${selector} 段`);
let depth = 0;
let end = at;
for (let i = web.indexOf('{', at); i < web.length; i++) {
if (web[i] === '{') depth++;
else if (web[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = web.slice(web.indexOf('{', at), end);
const read = (name) => {
const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(body);
assert.ok(m, `${selector} 里要有 ${name}`);
return '#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0').toUpperCase()).join('');
};
return {
gray100: read('--c-gray-100'), gray200: read('--c-gray-200'),
blue100: read('--c-blue-100'), blue200: read('--c-blue-200'),
green100: read('--c-green-100'), amber100: read('--c-amber-100'), orange100: read('--c-orange-100')
};
}
test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RRGGBB\'` 都要登记(不在某个文件里各搞一套枚举)', () => {
/*
* pi 的观察:A2 保护的是 `Theme.ets`,而 `Wallpaper.ts` 也有手写色(预设色板)——
* 那儿靠"从 CSS 读出来逐个对照"抓到了 `#BFDCFE`,手法对;
* 但如果那份对照是**按名字枚举**的,第 8 个预设色就会逃掉:
* 这正是 A2 要防的同一件事,只是换了个文件。
* 所以并成**一份清册、按类扫**:全树里每个手写色都得登记 ——
* 要么是 Theme 的品牌/业务语义色,要么是"预设色板(值由 CSS 两段比对负责)"。
* 这样"哪儿还能写死颜色"的答案是一处清册,而不是"看情况"。
*/
const tree = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (/\.(ets|ts)$/.test(e.name)) tree.push(full);
}
};
walk(HARMONY_ETS);
assert.ok(tree.length >= 20, `要扫整个 ets 树(至少 20 个文件),实际 ${tree.length}`);
const declared = [];
for (const f of tree) {
const rel = f.slice(HARMONY_ETS.length + 1);
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) {
declared.push({ file: rel, name: m[1], value: m[2] });
}
}
assert.ok(declared.length >= 20, `应当扫到一批手写色(Theme 17 + 预设 14),实际 ${declared.length}`);
const presetNames = new Set([
'LIGHT_GRAY_100', 'LIGHT_GRAY_200', 'LIGHT_BLUE_100', 'LIGHT_BLUE_200',
'LIGHT_GREEN_100', 'LIGHT_AMBER_100', 'LIGHT_ORANGE_100',
'DARK_GRAY_100', 'DARK_GRAY_200', 'DARK_BLUE_100', 'DARK_BLUE_200',
'DARK_GREEN_100', 'DARK_AMBER_100', 'DARK_ORANGE_100'
]);
const themeNames = new Set(SELF_OWNED_COLORS);
const unregistered = declared.filter(d =>
!(d.file === 'common/Theme.ets' && themeNames.has(d.name)) &&
!(d.file === 'model/Wallpaper.ts' && presetNames.has(d.name))
);
assert.deepEqual(unregistered.map(d => `${d.file}:${d.name}=${d.value}`), [],
'这些手写色不在任何清册里 —— 要么挂进 Theme(品牌/业务语义色),要么挂进预设色板');
const byName = new Map(declared.map(d => [d.name, d]));
for (const n of themeNames) assert.ok(byName.has(n), `清册里的 ${n} 已不存在(清册过期)`);
for (const n of presetNames) assert.ok(byName.has(n), `预设色板清册里的 ${n} 已不存在`);
// 预设色板的每个值都要由 CSS 的两段兜住(LIGHT_ → :root,DARK_ → .dark)
const lightCss = cssPaletteOf(':root');
const darkCss = cssPaletteOf('.dark');
const keyOf = { GRAY_100: 'gray100', GRAY_200: 'gray200', BLUE_100: 'blue100', BLUE_200: 'blue200', GREEN_100: 'green100', AMBER_100: 'amber100', ORANGE_100: 'orange100' };
for (const n of presetNames) {
const m = /^(LIGHT|DARK)_(.+)$/.exec(n);
assert.ok(m, `预设色板常量名要带 LIGHT_/DARK_ 前缀(否则判据不知道跟哪一段比):${n}`);
const css = m[1] === 'DARK' ? darkCss : lightCss;
assert.equal(byName.get(n).value, css[keyOf[m[2]]],
`${n} 与 CSS 的 ${m[1] === 'DARK' ? '.dark' : ':root'} 段不一致`);
}
});
test('★ 断点:两端各是多少、含义是什么、差异被登记(不是"两边必须一样")', () => {
/*
* 2026-09-19 由审计发现:**两端的宽屏断点不同,而且此前没有任何地方记录过**。
*
* WebUI: `useIsNarrow.ts` 的 `NARROW_QUERY = '(max-width: 1023px)'`
* 含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)
* 放不下就退化单栏」
* 鸿蒙: `MainPage.ets` 的 `isWide = width >= 768`
* 含义 = 「要不要显示**侧栏**」(鸿蒙的内容区是一个窗格,没有并排三栏)
*
* ★ 这条判据**不**要求两端取值相同 —— 那会是错的:它们判的本来就不是同一件事
* (一个是"三栏放不下",一个是"要不要侧栏")。这与手势阈值同一个口径:
* **语义各自成立时,数值不必强求一致**。
*
* 它要求的是三件事:
* ① 两端的取值能被**读出来**(而不是散落在魔法数字里);
* ② 两端的**含义**在注释里说清了(后人不必猜"为什么不一样");
* ③ 「两者不同」这个事实被**登记**(`docs/DEBTS.json`),
* 否则下一个人只会当成漏改 —— 这正是它被发现时的状态。
*/
const narrowHook = prose(join(ROOT, 'client/electron/src/hooks/useIsNarrow.ts'));
/*
* ★ 读**原文**(`prose`)而不是剥注释版:本条要判的正是"理由有没有写在代码旁",
* 而理由天然在注释里。用 `code()` 会把注释剥掉 ⇒ 永远红。
* (这与 `criteria-hygiene` 那条"判代码用 code、判理由用 prose"是同一条纪律,
* 我在本文件里又踩了一次。)
*/
const mainPage = prose(join(HARMONY_ETS, 'pages/MainPage.ets'));
/* ① 两端取值可读 */
const webMq = /NARROW_QUERY\s*=\s*'\(max-width:\s*(\d+)px\)'/.exec(narrowHook);
assert.ok(webMq, 'WebUI 的窄屏断点要能从 `NARROW_QUERY` 读出来');
const webMax = Number(webMq[1]);
const hWide = /this\.isWide\s*=\s*\(newValue\.width as number\)\s*>=\s*(\d+)/.exec(mainPage);
assert.ok(hWide, '鸿蒙的宽屏阈值要能从 `onAreaChange` 里读出来');
const hMin = Number(hWide[1]);
assert.ok(webMax > 0 && hMin > 0, '两端断点都应是正数');
/* ② 含义写清了(这是本条判据的主要价值:把"为什么不同"钉在代码旁) */
assert.match(narrowHook, /三栏|导航.*列表.*详情/,
'★ WebUI 侧要说明这个断点的**依据**(它是按"三栏放不下"定的)');
assert.match(mainPage, /侧栏|宽屏模式/,
'★ 鸿蒙侧要说明这个阈值判的是什么(「要不要显示侧栏」)');
/* ③ 差异被登记 */
const debts = prose(join(ROOT, 'docs/DEBTS.json'));
assert.match(debts, /wide-breakpoint-divergence/,
'★ 「两端断点不同」必须登记在 docs/DEBTS.json —— '
+ '它被发现时**没有任何地方记录**,下一个人只会当成漏改');
/* ④ 反向断言:万一以后有人"统一"了,登记不该变成化石 */
if (webMax - 1 === hMin || hMin === 1024) {
assert.match(debts, /wide-breakpoint-divergence[\s\S]{0,400}?(已统一|统一到)/,
'★ 两端断点看起来已经一致了 —— 那就该在登记里写明"已统一",'
+ '否则这条登记会变成没人在核的化石(登记也该跟着事实走)');
}
});
test('★ 品牌浅底必须有深色变体(深色下白底卡片 = 刺眼的 bug)', () => {
/*
* ★★ 2026-09-19 设备实测撞出来的真 bug:
* 深色主题下「多账号」里**选中**的那张卡片仍是接近纯白的浅蓝
* (`Theme.accentSoft = #EFF6FF` 是写死的),在深色页面上刺眼得像渲染错误。
*
* 根因:WebUI 靠 **CSS 变量在 `.dark` 段反转发**解决
* (`index.css:113` 的 `--c-blue-50: 239 246 255` → `:475` 的 `28 37 54`),
* 而 ArkTS 的 `static readonly` **没有那层机制** —— 一个常量一个值。
*
* 修法:显式提供深色取值 + 一个按当前主题选值的入口
* (`Theme.accentSoftFor(dark)`),**不让页面各自 `isDark ? a : b`**
* —— 那样每处都会各写一遍,迟早漏一处。
*
* 判据断三件事:
* ① 深色变体存在,且**取值来自 WebUI 的 `.dark` 段**(不是随手挑一个深色);
* ② 有按主题选值的入口(页面不该自己写三元);
* ③ 用到它的地方**真的走那个入口**(定义了不接 = 那处深色下照样刺眼)。
*/
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
const webCss = prose(join(ROOT, 'client/electron/src/index.css'));
/* ① WebUI 深色下的 blue-50 是权威值 */
const darkBlue50 = /\.dark\s*\{[\s\S]*?--c-blue-50:\s*(\d+)\s+(\d+)\s+(\d+)/.exec(webCss);
assert.ok(darkBlue50, 'WebUI `.dark` 段要有 `--c-blue-50`(品牌浅底的深色取值)');
const [r, g, b] = [darkBlue50[1], darkBlue50[2], darkBlue50[3]].map(Number);
const hex = '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0').toUpperCase()).join('');
const darkVar = new RegExp(`static readonly accentSoftDark: string = '${hex}'`, 'i');
assert.match(themeSrc, darkVar,
'★ 鸿蒙要有 accentSoftDark = ' + hex + '(对齐 WebUI 的 .dark --c-blue-50)—— ' +
'深色下用写死的浅底会让选中卡片在深色页上刺眼');
/* ② 有按主题选值的入口 */
assert.match(themeSrc, /static accentSoftFor\(dark: boolean\): string/,
'★ 要有 `accentSoftFor(dark)` 入口 —— 页面各自写 `isDark ? a : b` 迟早漏一处');
/* ③ 用到它的地方真的走入口(不是还在直接用浅色那个) */
const pages = readdirSync(HARMONY_ETS + '/pages').filter((f) => f.endsWith('.ets'));
const offenders = [];
for (const f of pages) {
const src = code(join(HARMONY_ETS, 'pages', f));
/*
* 找"把 accentSoft 当背景色用"的地方。用 accentSoftFor 的**不算**。
* 只看 backgroundColor(...),因为 accentSoft 也可以当文字色
* (那种场景下深浅色差异不刺眼,不在本条范围)。
*/
for (const m of src.matchAll(/backgroundColor\(([^)]*Theme\.accentSoft\b[^)]*)\)/g)) {
const line = src.slice(0, m.index).split('\n').length;
offenders.push(`${f}:${line}`);
}
}
assert.deepEqual(offenders, [],
'★ 这些地方还在直接把浅色 accentSoft 当背景(深色下会刺眼):\n ' +
offenders.join('\n ') +
'\n改成 Theme.accentSoftFor(this.isDarkNow)(页面要有一个 isDarkNow 状态)');
});
test('★ 设备:品牌色**真的画成那个色**(令牌写对了 ≠ 渲染对了)', async (t) => {
/*
* 补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口:
* 「跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐」
*
* 上面那些判据判的都是**声明**(`Theme.accent` 等于 `#2563EB`、
* 两端令牌同名…)。它们全绿时有一件事从未验过:
* **屏幕上真的画成那个色吗**。
*
* ★ 为什么这不是多余的(本仓的实证):
* · `Slider` 节点的 `text='56.000000'` 是**无障碍文本**,屏幕上根本没那串字
* —— 我为它追了很久,最后靠截图才发现。
* · 「多账号选中卡片是白底」那个 bug:令牌写对了(`accentSoft` 确实是浅蓝),
* 但深色下**渲染出来**是刺眼的白 —— 静态判据全绿。
* ⇒ 声明与渲染会分叉,而"观感类"结论只能靠**像素**。
*
* 判据形状:找一个**品牌色实心元素**(写意最明确的那个:悬浮球的 `Theme.accent`),
* 读它中心像素,与 `Theme.accent` 的取值比对。
*/
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住声明)');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
assert.ok(await D.backToMain(hdc), '要能回到主界面');
/*
* ★★ **自己导到列表页** —— 悬浮球只在通信页的列表窗格内。
* 第一版没做这一步,于是套件里它**永远跳过**(前面判据把前台留在管理页),
* 而那看起来像"功能没了"。这正是本仓那条纪律:
* **设备判据要自己搭现场**,不能等人摆好。
*/
const inListPane = () => [...D.walk(D.dumpLayout(hdc))]
.some((n) => (n.attributes?.text || '').includes('收件箱'));
if (!inListPane()) {
/* 通信是底栏/侧栏第一项 —— 用文案点(不写死坐标) */
if (!D.tapText(hdc, '通信')) {
return t.skip('点不到「通信」入口 —— 无法导到列表页');
}
let landed = false;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (inListPane()) { landed = true; break; }
}
assert.ok(landed, '要能导到通信页的列表窗格(悬浮球只在那一屏)');
}
await new Promise((r) => setTimeout(r, 1200));
/* 品牌色的**预期取值**(从 `Theme.ets` 读,不在判据里再写一份) */
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
const accentHex = /static readonly accent: string = '(#[0-9A-Fa-f]{6})'/.exec(themeSrc);
assert.ok(accentHex, '要能从 Theme.ets 读到 `accent` 的取值');
const want = D.hexToRgb(accentHex[1]);
/*
* 找那个品牌色实心元素。**用形状找**(不写死坐标):
* 悬浮球是"圆形 + 尺寸 56vp 左右 + 在屏幕右下"。实测(密度 2.875)
* 它是约 161×161px 的 `Button`。
*
* ★ 用形状而不是"找某个文案":球上没有文字(只有图标)——
* 按文案找会找不到,然后我会误以为"功能没了"。
*/
const root = D.dumpLayout(hdc);
let screenW = 0;
let screenH = 0;
for (const n of D.walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
screenW = Math.max(screenW, Number(m[1]));
screenH = Math.max(screenH, Number(m[2]));
}
}
/*
* ★★ 第二版(第一版假设错了):我按"右下角"找球(`x1 > 屏宽*0.6`),
* 实测它是 `Button [942,1997][1103,2158]` —— `x1=942`,
* 而 60% 屏宽是 1910 ⇒ **永远找不到**。
*
* 原因:通信页的列表窗格是**左栏**(宽屏下右栏是详情),
* 所以悬浮球在"左栏的右下角",不是"屏幕的右下角"。
* ⇒ 形状判据只该用**能站得住的那部分**:圆形(宽高相等)
* + 在屏幕**下半部**。左侧/右侧是布局决定的,不该写进形状。
*/
const fab = [...D.walk(root)].find((n) => {
const a = n.attributes || {};
const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [x1, y1, x2, y2] = m.slice(1).map(Number);
const w = x2 - x1;
const h = y2 - y1;
return Math.abs(w - h) <= 10 && w > 120 && w < 220
&& y1 > screenH * 0.6; // 只要求在下半部
});
if (!fab) {
return t.skip('找不到右下角的悬浮球(可能不在列表页)—— 本次不跑,但不假装通过');
}
const c = D.boundsCenter(fab.attributes.bounds);
assert.ok(c, '要能解析出悬浮球的坐标');
/* `boundsCenter` 只给圆心 —— 宽度自己从 bounds 里算(采样偏移要用它) */
const fb = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(fab.attributes.bounds).slice(1).map(Number);
const fabW = fb[2] - fb[0];
const png = D.screenshot(hdc, '/tmp/theme-accent-shot.png');
assert.ok(png, '要能截屏(观感类判据没有截屏就没有依据)');
/*
* 读**中心**像素。为什么不是别处:球的边缘是圆角/抗锯齿,
* 取边缘会读到背景混色。
*/
/*
* ★★ 采样点必须**避开图标**!
* 第一版我取球的正中心,实测读到 `rgb(32,34,36)`(深灰)——
* 差点当成"品牌色没渲染"。截图一看:球是**蓝的**,中心那个深色是
* **铅笔图标**(`compose`)。圆心正是图标所在。
*
* ⇒ 往中心**左侧**偏 50px(图标宽 24vp≈69px,偏 50px 仍在圆内、
* 但在图标之外)。半径 ~80px,所以 50px 是安全的。
* 这个偏移量要**小于半径、大于图标的半宽**:写死一个值不如
* 按半径算(下同)。
*/
const off = Math.max(30, Math.round(fabW * 0.3));
const got = D.pixelAt(png, c.cx - off, c.cy);
assert.ok(got, '要能读到像素');
assert.ok(D.closeColor(got, want),
`★ 悬浮球应是品牌色 ${accentHex[1]}(${JSON.stringify(want)}),` +
`实测 rgb(${got.r},${got.g},${got.b})。\n` +
' 两者不符说明**声明与渲染分叉了** —— 令牌写对了但没画成那个色' +
'(被父层覆盖 / 透明度抹掉 / 换了别的色)。这正是静态判据看不到的那一层。');
/*
* ★★ 第二半:图标**必须与底色可分辨**。
*
* 这是本轮真撞到的 bug:12 处把 `Theme.surface` 当**前景色**用
* (`sys.color.ohos_id_color_list_card_bg`,一个**会跟随系统主题翻转**的
* Resource)—— 浅色下是白的(碰巧对),**深色下变成近黑** ⇒
* 深色铅笔压在蓝球上,看起来像"图标消失了"。
*
* `Theme.accentFg`(#FFFFFF)就是为"品牌底上的文字/图标"存在的,
* 但没人用它。
*
* 判据形状:读**圆心**的像素(那里是图标),要求它与底色**可分辨**
* (对比度足够)。这条不指定图标必须是白 —— 只要"看得见"。
* 用相对亮度算 WCAG 对比度,阈值取 3:1(图形元素的下限)。
*/
const onIcon = D.pixelAt(png, c.cx, c.cy);
assert.ok(onIcon, '要能读到图标处的像素');
const lum = (c1) => {
const f = (v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(c1.r) + 0.7152 * f(c1.g) + 0.0722 * f(c1.b);
};
const l1 = lum(onIcon);
const l2 = lum(want);
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
/*
* 若圆心读到的**就是**底色,说明图标没画(或采样的不是图标位置)——
* 那两种都该问一句,但今天的现场是真有图标(截图可见),
* 所以这里只判"可分辨",并附上实测值。
*/
assert.ok(ratio >= 3,
`★ 悬浮球上的图标要对底色**可分辨**(WCAG 图形对比度 ≥3:1)。\n` +
` 实测:图标 rgb(${onIcon.r},${onIcon.g},${onIcon.b}),` +
`底色 rgb(${got.r},${got.g},${got.b}),对比度 **${ratio.toFixed(2)}:1**。\n` +
' 本轮撞到的真 bug:12 处把 `Theme.surface` 当**前景色**用 ——\n' +
' 它是 `sys.color.ohos_id_color_list_card_bg`,一个**跟随系统主题翻转**的 Resource:\n' +
' 浅色下是白的(碰巧对),**深色下变成近黑** ⇒ 深色图标压在品牌蓝上,' +
'看起来像图标没了。\n' +
' 正确的前景色是 `Theme.accentFg`(#FFFFFF,它的注释原话就是"品牌底上的文字")。');
});
test('C|`Theme.surface` 不得当代的前景色(它是会翻转的「面」)', () => {
/*
* ★★ 这是设备判据(上面那条)抓到的 bug 的**静态防线** ——
* 设备条只能看一处(悬浮球),而这个错法当时有 **12 处**。
*
* 错法:`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg`,
* **跟随系统主题翻转**(浅色近白 / 深色近黑)。把它当
* `fontColor` / `iconColor`("压在彩色底上的字"):
* · 浅色下碰巧对(白字压蓝底)
* · **深色下字变黑**,压在蓝/红/绿底上几乎看不见
*
* 正确:`Theme.accentFg`(#FFFFFF,注释原话就是"品牌底上的字/图标")。
*
* 判据形状:全局 grep「`fontColor(Theme.surface)` / `iconColor: Theme.surface`」,
* 一处都不许有。`backgroundColor(Theme.surface)` 是合法用法(那才是"面")。
*/
const files = [];
const walkDir = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walkDir(p);
else if (e.name.endsWith('.ets')) files.push(p);
}
};
walkDir(HARMONY_ETS);
const bad = [];
for (const f of files) {
const src = prose(f); // 保留注释:注释里举例不算错
const lines = src.split('\n');
lines.forEach((ln, i) => {
if (/\.fontColor\(Theme\.surface\)/.test(ln) || /iconColor:\s*Theme\.surface/.test(ln)) {
bad.push(`${f.replace(HARMONY_ETS, '')}:${i + 1} ${ln.trim().slice(0, 90)}`);
}
});
}
assert.deepStrictEqual(bad, [],
'★ `Theme.surface` 是**会跟随系统主题翻转的面色**(浅色近白 / 深色近黑),' +
'只能当背景,不能当字/图标色。\n' +
' 压在彩色底上的前景要用 `Theme.accentFg`(#FFFFFF)。\n' +
' 深色下症状:字/图标几乎看不见(2026-09-19 真撞到 12 处)。\n' +
' 违规处:\n ' + bad.join('\n '));
});