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