Files
MailUI4Agents/client/electron/test/cross-client-theme.test.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

665 lines
39 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-14jianf 要求「鸿蒙用系统方案」,与 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'
];
test('A2Theme.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 给的放行条件②),壁纸层整个不吃材质' }
];
/**
* 往回找"这次材质作用在哪个组件块上",返回块尾 `}` 的下标(找不到返回 -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 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)');
// 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它)
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_ → :rootDARK_ → .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'} 段不一致`);
}
});