/** * 两个客户端必须用**同一份设计词表**。 * * 用户下一步要求:「同步 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'} 段不一致`); } });