线上事故(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。
665 lines
39 KiB
JavaScript
665 lines
39 KiB
JavaScript
/**
|
||
* 两个客户端必须用**同一份设计词表**。
|
||
*
|
||
* 用户下一步要求:「同步 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'
|
||
];
|
||
|
||
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 给的放行条件②),壁纸层整个不吃材质' }
|
||
];
|
||
|
||
/**
|
||
* 往回找"这次材质作用在哪个组件块上",返回块尾 `}` 的下标(找不到返回 -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_ → :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'} 段不一致`);
|
||
}
|
||
});
|