## 一、这是"我自己推的理由"被实测推翻 上一条我把 `textSubtleFor()` 写成**只改深色**,理由是: > 「浅色下这些令牌本来就是为浅底配的,量它们没有信息量」 **那个理由是错的。** 切到浅色主题复扫,立刻抓到 12 处: ``` "用户名 / 显示名 / 角色 / 状态 / 创建时间 / 最后登录" 2.85:1 "已同步" / "37%" / "3px"(外观区) 2.85:1 "这一天没有日程" 2.85:1 ``` `ink rgb(153,153,153)` 压白底 —— 全是 `textSubtle`。 对照 WebUI 浅色(`index.css` `:root`): - `gray-400` = `107 114 128` = **4.83:1** - `gray-500` = `90 98 112` = **6.15:1** ⇒ 系统三级色**两个主题下都不够**。它的语义只是"比二级更淡", **不保证任何可读性下限**;WebUI 那边两套主题都做过适配,这边一档都没有。 修:`textSubtleFor()` 两个主题都返回 `textMuted` (系统二级色,语义就是"次要文字",且**跟随主题** —— 不必再维护两个手写常量、不必登记)。 ## 二、判据从"只验深色"改成"两个主题都验" 原来那条设备判据**在浅色下直接 skip**,skip 的理由正是上面那句错话。 现在两个主题都扫,断言消息里带上主题名(`深色主题下…` / `浅色主题下…`)。 ★ 纪律:**色令牌的可读性是两个主题各自的事,不能只验一半。** "另一半没问题"若没量过,就只是推测。 **变异验证**:把 `textSubtleFor()` 改回"两主题都用 textSubtle" (此时设备正在浅色主题)⇒ **判红**;还原 ⇒ 绿。 ## 三、设备复扫(浅色主题) ``` [通信] 扫 41 段,低对比 0 [日历] 扫 84 段,低对比 0 [联系人] 扫 50 段,低对比 0 [我的] 扫 40 段,低对比 0 ``` **215 段文字全部 ≥3:1。** 两个主题合起来:深色 219 段 + 浅色 215 段,全绿。 ## 四、顺手改掉的 测试名从「深色下短文本必须都读得动」改成「**当前主题**下…」 —— 名字得跟着它实际判的东西走(否则下一个人会以为它只管深色)。 `run-all.mjs` → `checks=515 pass=515 fail=0 skip=0 red=0 broken=0 unreported=0`。 **当前设备主题**:浅色(本轮为验另一半切过去的;服务端 `theme=light`)。
1484 lines
84 KiB
JavaScript
1484 lines
84 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',
|
||
/*
|
||
* ★★ 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)。
|
||
*
|
||
* · `accentDark` #80AFF9 —— WebUI 的 `.dark --c-blue-600`(`index.css:481`)。
|
||
* **这是"前景专用"的品牌深色档**,不是 `accent` 的替代:
|
||
* WebUI 那边是双通道(`--s-blue-*` 实心按钮底两模式同值 /
|
||
* `--c-blue-*` 内容用蓝在 `.dark` 段提亮),这里跟着分。
|
||
* 不提亮的话深色下品牌蓝字在近黑底上只有 **2.61:1**(设备实测),
|
||
* 低于 WCAG 图形下限 3:1。使用入口是 `Theme.accentFor()`。
|
||
*
|
||
* 各条取值的理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
|
||
*/
|
||
'navActiveBg', 'navBrandFg', 'badgePlain', 'accentSoftDark', 'accentDark',
|
||
/*
|
||
* 语义色的深色档(2026-09-19 加)—— 与 `accentDark` 同一个问题、同一批证据:
|
||
* WebUI 的语义色也是**双通道**,`.dark` 段把 red/green/amber-700 换成浅色,
|
||
* 否则深底上的红字/绿字读不动(设备实测「归档」按钮 2.47:1)。
|
||
* · `dangerDark` #F8A4A4 ← WebUI `.dark --c-red-700`(`248 164 164`)
|
||
* · `approveDark` #76DB9B ← WebUI `.dark --c-green-700`(`118 219 155`)
|
||
* · `warnFgDark` #F9C368 ← WebUI `.dark --c-amber-700`(`249 195 104`)
|
||
* 入口是 `Theme.dangerFor()` / `approveFor()` / `warnFgFor()`。
|
||
*/
|
||
'dangerDark', 'approveDark', 'warnFgDark',
|
||
/*
|
||
* 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` 里的一张**局部表**,不提供任何担保。
|
||
*/
|
||
/*
|
||
* ★★ 2026-09-19:改成**走整条链**,不只一跳。
|
||
*
|
||
* 原来的写法只问"读它的那个方法在外部有没有调用点"。加了 `KEY_IS_DARK`
|
||
* 之后立刻假红:它的链是
|
||
* KEY_IS_DARK ← Theme.isDarkNow() ← Theme.accentFor() ← 24 处页面
|
||
* `isDarkNow` 自己**只被 Theme 内部的两个 `For` 方法读**,外部一处都没有
|
||
* ⇒ 一跳就判成孤儿。而它显然不是孤儿(24 处页面在用)。
|
||
*
|
||
* ★ 这个坑值得记:**"有没有人用"是可达性问题,不是邻接问题。**
|
||
* 只查一跳的判据在"中间加了一层"时必然假红 —— 而加中间层
|
||
* (抽个 `For()` 入口)恰恰是我们鼓励的写法。
|
||
*/
|
||
if (internalUsers.length > 0) {
|
||
const alive = new Set();
|
||
const isUsedExternally = (name) =>
|
||
othersSrc.some((o) => new RegExp(`Theme\\.${name}\\b`).test(o.src));
|
||
const queue = internalUsers.map((u) => u.replace('Theme.', '').replace('()', ''));
|
||
let hops = 0;
|
||
while (queue.length > 0 && hops < 12) { // 环/自引用保护
|
||
hops++;
|
||
const cur = queue.shift();
|
||
if (alive.has(cur)) continue;
|
||
alive.add(cur);
|
||
if (isUsedExternally(cur)) {
|
||
alive.add('__externally_reached__');
|
||
continue;
|
||
}
|
||
/*
|
||
* 它自己没人从外面调 —— 继续往上找"谁在 Theme.ets 内部读它",
|
||
* 直到某个环节在外部有调用点(或链条断掉)。
|
||
*/
|
||
const re3 = new RegExp(`Theme\\.${cur}\\b`, 'g');
|
||
let m3;
|
||
while ((m3 = re3.exec(themeSrc)) !== null) {
|
||
const before = themeSrc.slice(0, m3.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 !== cur && !alive.has(owner)) queue.push(owner);
|
||
}
|
||
}
|
||
if (alive.has('__externally_reached__')) 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)—— ' +
|
||
'深色下用写死的浅底会让选中卡片在深色页上刺眼');
|
||
|
||
/* ② 有按主题选值的入口 */
|
||
/*
|
||
* ★ 2026-09-19:参数从 `dark: boolean` 改成 **`dark?: boolean`**(可选)——
|
||
* 因为 `Theme.isDarkNow()` 现在能直接读 `AppStorage` 的全局键,
|
||
* 调用方不必再各自把"当前是不是深色"转述一遍(那个约定已经漏过 8 处)。
|
||
* 判据跟着放宽成"参数可选",但**入口必须存在**这一点不变。
|
||
*/
|
||
assert.match(themeSrc, /static accentSoftFor\(dark\?: boolean\): string/,
|
||
'★ 要有 `accentSoftFor()` 入口 —— 页面各自写 `isDark ? a : b` 迟早漏一处');
|
||
|
||
/*
|
||
* ②b 品牌**前景**色的深色入口 —— 与 ② 同一理由(2026-09-19 加)。
|
||
* `accent` 是两用令牌,但只有"当前景"那一半需要在深色下提亮;
|
||
* 当背景时保持饱和(WebUI 的 `--s-blue-*` 两模式同值)。
|
||
*/
|
||
assert.match(themeSrc, /static accentFor\(dark\?: boolean\): string/,
|
||
'★ 要有 `accentFor()` 入口 —— 深色下品牌蓝字/图标必须提亮,' +
|
||
'否则近黑底上只有 2.61:1(设备实测)。页面各自写 `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('C2|`*For()` 入口只能用于**前景**,不得当背景(我批量替换时真犯过)', () => {
|
||
/*
|
||
* ★★ 这条是给**我自己**写的 —— 2026-09-19 我批量把裸 `Theme.accent`
|
||
* 换成 `Theme.accentFor()` 时,把**6 处 `backgroundColor` 也一起换了**。
|
||
*
|
||
* 为什么那是 bug:`accentFor()` 在深色下返回**浅蓝** `#80AFF9`,
|
||
* 而它的搭档前景是 `accentFg`(白)——
|
||
* 白字压浅蓝底 ≈ 1.4:1,主按钮上的文字会**彻底看不见**。
|
||
*
|
||
* ★ 修法不是"下次小心点":批量替换一定会再犯。
|
||
* 在 `backgroundColor(Theme.XFor(...))` 这个形状上直接判红,
|
||
* 代价一行,收益是这类错误不可能再出现。
|
||
*
|
||
* 适用对象:所有 `*For()` 主题自适应入口 —— 它们的返回值都是
|
||
* **为前景调过亮的**(`accentFor` / `dangerFor` / `approveFor` / `warnFgFor`)。
|
||
* `accentSoftFor` 是例外:它本来就是**面**(浅底),当背景才是对的。
|
||
*/
|
||
const FG_ONLY = ['accentFor', 'dangerFor', 'approveFor', 'warnFgFor', 'textSubtleFor'];
|
||
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 rel = f.replace(HARMONY_ETS, '');
|
||
src.split('\n').forEach((ln, i) => {
|
||
for (const name of FG_ONLY) {
|
||
if (new RegExp(`backgroundColor\\(\\s*Theme\\.${name}\\(`).test(ln)) {
|
||
bad.push(`${rel}:${i + 1} ${ln.trim().slice(0, 90)}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
assert.deepStrictEqual(bad, [],
|
||
'★ 这些地方把**为前景调亮的** `*For()` 入口当成背景色用了:\n ' + bad.join('\n ') + '\n' +
|
||
' 它们返回的是"压在这类底上的字色"(深色下更亮),当**底**会把上面的文字吞掉\n' +
|
||
' (实测形状:`accentFor()` 深色给 #80AFF9,白字压上去 ≈1.4:1)。\n' +
|
||
' 当背景用**不带 For 的那个**:`Theme.accent` / `danger` / `approve` / `warnFg`\n' +
|
||
' (WebUI 的实心按钮底 `--s-*` 两个主题同值,跟着变会让主按钮失去视觉重量)。');
|
||
});
|
||
|
||
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,它的注释原话就是"品牌底上的文字")。');
|
||
});
|
||
|
||
|
||
/**
|
||
* 两边都用**且都合理**的令牌 —— 逐条给理由,别只列名字。
|
||
*
|
||
* ★ 目前**空**:这类令牌满足"会翻转的 Resource"且两边都用的组合,
|
||
* 本身就很可疑(那正是 `Theme.surface` 出问题的形状)。
|
||
*
|
||
* ★ 已审过、**不需要**列在这里的(说明为什么它们不该进这张表):
|
||
* · `accent` / `danger` / `approve` / `warnFg` —— 两边都用是正常设计
|
||
* (蓝底白字主按钮 + 白底蓝字返回箭头),而且它们是**字符串常量**,
|
||
* 不跟随主题翻转 ⇒ 不满足条件 A,判据本来就不会碰它们。
|
||
* · `textPrimary` / `textMuted` / `textSubtle` —— 只当字色,不满足条件 B。
|
||
*/
|
||
const ALLOW_BOTH_BG_AND_FG = new Set([]);
|
||
|
||
test('C|「会跟随主题翻转的 Resource」不得当前景色(含矛盾信号)', () => {
|
||
/*
|
||
* ★★ 这是设备判据(上面那条)抓到的 bug 的**静态防线** ——
|
||
* 设备条只能看一处(悬浮球),而这个错法当时有 **12 处**。
|
||
*
|
||
* 错法(真实现场):`Theme.surface` 是
|
||
* `sys.color.ohos_id_color_list_card_bg` —— 一个**跟随系统主题翻转**的
|
||
* Resource(浅色近白 / **深色近黑**)。它被当成"压在彩色底上的字/图标色"用:
|
||
* · 浅色下碰巧对(白字压蓝底)
|
||
* · **深色下字变黑**,压在品牌蓝/红/绿底上几乎看不见
|
||
* 正确的前景色是 `Theme.accentFg`(`'#FFFFFF'`,字符串常量,不翻转)。
|
||
*
|
||
* ★★ 判据形状的**三次迭代**(都写下来,因为每一次都错得很典型):
|
||
*
|
||
* ① grep `fontColor(Theme.surface)` —— **一个名字**。
|
||
* 错在这是死名单:以后加了新令牌它不会跟上(本仓已记录过多次)。
|
||
*
|
||
* ② 从 `Theme.ets` 读"所有 `sys.color.*` 的 Resource",一律不许当前景色。
|
||
* **太宽** —— 实测立刻报出 20+ 处"违规",全是
|
||
* `Theme.textMuted` / `textSubtle` / `textPrimary`。
|
||
* 那些**本来就该**当前景色(它们的语义就是文字色,跟随主题翻转是对的)。
|
||
* ⇒ 只按"是不是 Resource"判,把正确用法也判红 ⇒ 假红一片。
|
||
*
|
||
* ③(本版)判**矛盾**:同一令牌既当 `backgroundColor` 又当
|
||
* `fontColor/iconColor` ⇒ 它同时被当成"面"和"字",其中一种必然错。
|
||
* 这个信号从**实际用法**推出,不依赖任何名单。
|
||
*
|
||
* ★ 但④实测又发现它**太宽**:`accent` / `danger` / `approve` / `warnFg`
|
||
* 这些**饱和的字符串常量**色两边都用是正常设计(蓝底白字的主按钮 +
|
||
* 白底蓝字返回箭头)。它们和 `surface` 的**关键区别**是:
|
||
*
|
||
* · `surface`(危险):**中性面**(近白/近黑,"承载内容的底板")
|
||
* + **跟随主题翻转**。当字色 = 把底板当墨水 ⇒ 深色下看不见。
|
||
* · `accent`(正常):**饱和色**(品牌/语义身份)+ **字符串常量**
|
||
* (不翻转)。两边都站得住。
|
||
*
|
||
* ⇒ 最终判据 = **翻转的 Resource** ∩ **既当背景又当前景**。
|
||
* 两个条件都要,缺一个都会假红:
|
||
* · 只要"翻转" ⇒ 把 `textPrimary` 判红(假红)
|
||
* · 只要"矛盾" ⇒ 把 `accent` 判红(假红)
|
||
* 两个都要 ⇒ 恰好命中 `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);
|
||
|
||
/*
|
||
* 条件 A:从 `Theme.ets` 读出**会翻转的**令牌 —— 值是 `Resource`
|
||
* 且指向 `sys.color.*`(跟随系统主题)。
|
||
*/
|
||
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
|
||
const flip = new Set();
|
||
for (const m of themeSrc.matchAll(
|
||
/static readonly ([A-Za-z0-9_]+)\s*:\s*Resource\s*=\s*\$r\('(sys\.color\.[^']+)'/g)) {
|
||
flip.add(m[1]);
|
||
}
|
||
assert.ok(flip.size >= 5,
|
||
`要从 Theme.ets 读出「会翻转的令牌」名单(实测 ${flip.size} 个)—— ` +
|
||
'读出 0 个说明正则是错的,那样下面的检查恒绿。');
|
||
|
||
/* 条件 B:收集每个令牌"当背景 / 当前景"的首个出处 */
|
||
const asBg = new Map();
|
||
const asFg = new Map();
|
||
for (const f of files) {
|
||
const src = prose(f); // 保留注释:注释里举例不算错
|
||
const rel = f.replace(HARMONY_ETS, '');
|
||
src.split('\n').forEach((ln, i) => {
|
||
const where = `${rel}:${i + 1} ${ln.trim().slice(0, 80)}`;
|
||
for (const m of ln.matchAll(/backgroundColor\(Theme\.([A-Za-z0-9_]+)\)/g)) {
|
||
if (!asBg.has(m[1])) asBg.set(m[1], where);
|
||
}
|
||
/*
|
||
* ★★ 提取时**不能要求令牌是唯一实参** —— 这一版原来用
|
||
* `\.fontColor\(Theme\.(\w+)\)`,于是**三元里的令牌全被漏掉**:
|
||
*
|
||
* .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary)
|
||
*
|
||
* 实测代价:`SettingsPage.ets:834` 正是这个形状(`surface` 当字色压在
|
||
* `accent` 蓝底上),而判据**照样绿** —— 直到设备扫描报出
|
||
* `深色 2.93:1 ink rgb(32,34,36) bg rgb(34,96,228)` 才发现。
|
||
*
|
||
* ⇒ 改成"在 `fontColor(` 之后的**整段实参**里找所有 `Theme.X`"。
|
||
* 判据漏报的形状往往就是它**自己的正则太窄**,而不是被测代码太隐蔽。
|
||
*/
|
||
for (const m of ln.matchAll(/\.fontColor\(/g)) {
|
||
const rest = ln.slice(m.index + m[0].length);
|
||
/* 只取到本行结束(这些调用都在一行内闭合) */
|
||
for (const t of rest.matchAll(/Theme\.([A-Za-z0-9_]+)/g)) {
|
||
if (!asFg.has(t[1])) asFg.set(t[1], where);
|
||
}
|
||
}
|
||
/* `iconColor: X` 同理 —— 冒号后整段都可能有三元 */
|
||
for (const m of ln.matchAll(/iconColor:/g)) {
|
||
const rest = ln.slice(m.index + m[0].length);
|
||
for (const t of rest.matchAll(/Theme\.([A-Za-z0-9_]+)/g)) {
|
||
if (!asFg.has(t[1])) asFg.set(t[1], where);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
assert.ok(asBg.size >= 5 && asFg.size >= 5,
|
||
`要能从源码读出背景色/前景色两边的用法(实测 ${asBg.size} / ${asFg.size})—— ` +
|
||
'有一边读出 0 个说明正则是错的,那样下面的"交集"恒空、判据恒绿。');
|
||
|
||
/*
|
||
* 判决 = A ∩ B ∩(不在允许清单里)。
|
||
* 允许清单目前**空**:修完 `surface` 之后,没有任何"翻转的 Resource"
|
||
* 被当成前景色用过。留空表比留理由表更好读 —— 它直接说明"没有例外"。
|
||
*/
|
||
const conflicts = [];
|
||
for (const name of asFg.keys()) {
|
||
if (!flip.has(name)) continue; // 条件 A:必须是会翻转的 Resource
|
||
if (!asBg.has(name)) continue; // 条件 B:必须也被当背景用过(矛盾)
|
||
if (ALLOW_BOTH_BG_AND_FG.has(name)) continue;
|
||
conflicts.push({ name, bg: asBg.get(name), fg: asFg.get(name) });
|
||
}
|
||
|
||
assert.deepStrictEqual(conflicts, [],
|
||
'★ 这些令牌**同时**满足两个条件 ⇒ 是 `surface` 那一类错:\n' +
|
||
' A. 会跟随系统主题翻转的 `sys.color.*` Resource(浅色近白 / 深色近黑)\n' +
|
||
' B. 既当 `backgroundColor` 又当 `fontColor/iconColor` —— 被当成"面"也被当成"字"\n' +
|
||
' 浅色下碰巧对,**深色下字/图标几乎看不见**(2026-09-19 真撞到 12 处)。\n' +
|
||
' 压在彩色底上的前景请用 `Theme.accentFg`(#FFFFFF,字符串常量、不翻转)。\n' +
|
||
' 若某个令牌**确实**两边都该用(有具体理由),加进 `ALLOW_BOTH_BG_AND_FG` 并写明理由。\n' +
|
||
conflicts.map((c) => ` · Theme.${c.name}\n 当背景:${c.bg}\n 当前景:${c.fg}`).join('\n'));
|
||
});
|
||
|
||
|
||
test('★ 设备:当前主题下短文本必须都读得动(扫一屏,不只查一个元素)', async (t) => {
|
||
/*
|
||
* ★★ 这条是上面"品牌色真的画成那个色"的**一般化**。
|
||
*
|
||
* 那条只钉住悬浮球一个点(品牌色 + 图标可分辨)。但"深色下看不见"
|
||
* 这个 bug 类**不止一处** —— 2026-09-19 一天里撞到两批:
|
||
* · 12 处 `Theme.surface` 当前景色(会翻转的面色当墨水)
|
||
* · 24 处 `Theme.accent` 当字色而**深色下没跟着调亮**
|
||
* (WebUI `.dark --c-blue-600` 是 `128 175 249`,这边还是 `#2563EB`
|
||
* ⇒ 近黑底上 **2.61:1**,低于 WCAG 图形下限 3:1)
|
||
*
|
||
* 逐处写判据是追不上的(每加一个页面就漏一处)。**扫一屏**才追得上:
|
||
* 把当前页面所有"小段文字"都量一遍对比度。
|
||
*
|
||
* ## 三个关键实现细节(第一版全踩了)
|
||
*
|
||
* ① **不能只取文字中心一个像素**:中心多半落在**笔画之间**,
|
||
* 读到的是底色 ⇒ ratio=1.00 的假红一片(第一版 20 个里报了 12 个)。
|
||
* 正确做法是**框内网格扫描取极值**:最亮=底、最暗=墨。
|
||
* ② **整屏解码一次**:`pixelAt()` 每点 spawn 一次 ffmpeg,
|
||
* 扫 40 段文字要几百次进程(一条判据几十秒)。用 `readPixels()`。
|
||
* ③ **只判"有真实墨迹"的框**:`hi.L - lo.L < 0.02` 说明框里全是底色
|
||
* (文字被裁掉、或采样的不是文字),那种**跳过而不是判红** ——
|
||
* 否则会报一堆"看不见"而其实是我没采到。
|
||
*
|
||
* 阈值取 **3:1**:这是 WCAG 对图形/大字的下限。小正文该 4.5:1,
|
||
* 但设备上有一堆 10–12px 的辅助文字(时间戳、计数),那档本身就在
|
||
* 4.5 附近晃 —— 先按 3:1 收住"真的看不见"这一类,别把噪声一次全报出来。
|
||
*/
|
||
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), '要能回到主界面');
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
|
||
/*
|
||
* ★★ 两个主题**都要判**(第一版只判深色,理由写的是"浅色下这些令牌本来就是
|
||
* 为浅底配的,量它们没有信息量" —— **那个理由是错的**)。
|
||
*
|
||
* 切到浅色复扫才发现的实例:浅色 `textSubtle` = `rgb(153,153,153)` 压白底
|
||
* = **2.85:1**,而 WebUI 浅色的 gray-400 是 4.83:1。
|
||
* ⇒ "浅色下没问题"是我自己推的,不是量出来的。
|
||
*
|
||
* ★ 纪律:**色令牌的可读性是两个主题各自的事**,不能只验一半。
|
||
* 现在把当前主题**记下来**,两个主题都扫;判据只看"有没有低对比",
|
||
* 不关心是哪个主题(两种都是错)。
|
||
*/
|
||
const img0 = D.readPixels(await D.screenshot(hdc, '/tmp/theme-legibility.png'));
|
||
assert.ok(img0, '要能截屏并解码');
|
||
const corner = img0.at(4, 4);
|
||
const isDark = corner && corner.r < 128 && corner.g < 128 && corner.b < 128;
|
||
const themeName = isDark ? '深色' : '浅色';
|
||
|
||
const lum = (c) => {
|
||
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(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
|
||
};
|
||
|
||
const root = D.dumpLayout(hdc);
|
||
let scanned = 0;
|
||
const bad = [];
|
||
for (const n of D.walk(root)) {
|
||
const a = n.attributes || {};
|
||
const txt = (a.text || '').trim();
|
||
if (!txt || txt.length > 14) continue;
|
||
const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(a.bounds || '');
|
||
if (!m) continue;
|
||
const [x1, y1, x2, y2] = m.slice(1).map(Number);
|
||
const w = x2 - x1;
|
||
const h = y2 - y1;
|
||
/* 只要"小段文字"的量级:太小的采不到笔画,太大的多半是容器 */
|
||
if (w < 20 || h < 14 || w > 700 || h > 130) continue;
|
||
if (x2 > img0.w - 4 || y2 > img0.h - 4) continue;
|
||
|
||
/*
|
||
* ★★ 第三版取样法(前两版都错,记下来):
|
||
*
|
||
* ① 只取**中心一个像素** ⇒ 多半落在笔画之间,读到的是底色 ⇒
|
||
* ratio≈1.00 的假红一片。
|
||
* ② **全局最暗/最亮**(我上一版)⇒ 会采到**两个不同东西**:
|
||
* 细字的抗锯齿**中间色**(既非墨也非底)。
|
||
* 实测假红:「日」按钮报 ink `rgb(32,34,35)` / bg `rgb(98,100,101)`
|
||
* 2.69:1 —— 而截图显示那个按钮要么是蓝底白字、要么是深底浅字,
|
||
* 两种都远高于 3:1。`rgb(98,100,101)` 是白字压在深色上的**边缘混色**。
|
||
* ★ 反复核对才确认是**判据自己的错**,不是界面的错 ——
|
||
* 这正是"假红比漏报更费时间"的又一例。
|
||
* ③(本版)**众数 = 底色**,再找离它最远的颜色当墨。
|
||
* 理由:一个文字框里**面积最大的一定是底**(笔画只占少数像素),
|
||
* 而"离底色最远"就是笔画最实的那部分 —— 不会再被边缘混色骗。
|
||
* 这一版对"细字 + 粗采样"稳,且不依赖步长调得多细。
|
||
*/
|
||
/*
|
||
* ★★ 第四版:**逐像素**(不再抽步长)。
|
||
*
|
||
* 第三版虽然避免了"取中心/取全局极值",但仍然**步长采样** ——
|
||
* 于是第三次假红:周表头的「一」报 2.03:1,而逐像素一看,
|
||
* 真正的墨是 `rgb(166,167,167)`(**6.6:1,完全没问题**),
|
||
* 我的稀疏网格**整个跳过了笔画**,只采到抗锯齿的中间色。
|
||
*
|
||
* ⇒ 教训:**小字 + 稀疏采样 = 采不到字**。笔画宽度可能只有 2–3px,
|
||
* 而步长 9 会稳定地跨过去。这不是调参能修好的(字越小越糟),
|
||
* 只能逐像素。
|
||
*
|
||
* ★ 逐像素现在是**负担得起的**:整屏已经解码在内存里(`readPixels`),
|
||
* 一个框最大 700×130=9.1 万像素,几十个框也就几百万次数组下标读取 ——
|
||
* 毫秒级。**不要**为了省这点开销再退回抽样(前两版都因此假红)。
|
||
*/
|
||
const counts = new Map();
|
||
for (let y = y1 + 1; y < y2; y++) {
|
||
for (let x = x1 + 1; x < x2; x++) {
|
||
const p = img0.at(x, y);
|
||
if (!p) continue;
|
||
/* 量化到 8 级,避免抗锯齿把众数切碎(同色系会被合成一档) */
|
||
const key = `${p.r >> 5},${p.g >> 5},${p.b >> 5}`;
|
||
const e = counts.get(key);
|
||
if (e) { e.n++; }
|
||
else { counts.set(key, { n: 1, r: p.r, g: p.g, b: p.b }); }
|
||
}
|
||
}
|
||
if (counts.size < 2) continue;
|
||
const sorted = [...counts.values()].sort((a, b) => b.n - a.n);
|
||
const bg = sorted[0];
|
||
const bgL = lum(bg);
|
||
/* 离底色最远的那个(按对比度算,而不是按亮度差 —— 亮度差会偏向同向的色) */
|
||
let ink = null;
|
||
let best = 0;
|
||
for (const c of sorted.slice(1)) {
|
||
const L = lum(c);
|
||
const r = (Math.max(L, bgL) + 0.05) / (Math.min(L, bgL) + 0.05);
|
||
if (r > best) { best = r; ink = { L, p: c }; }
|
||
}
|
||
/* 没有真实墨迹对比的框跳过(见上面 ③) */
|
||
if (!ink || best < 1.2) continue;
|
||
scanned++;
|
||
const ratio = best;
|
||
const lo = { L: Math.min(ink.L, bgL), p: ink.L < bgL ? ink.p : bg };
|
||
const hi = { L: Math.max(ink.L, bgL), p: ink.L < bgL ? bg : ink.p };
|
||
if (ratio < 3) {
|
||
bad.push(`「${txt}」 对比度 ${ratio.toFixed(2)}:1 ` +
|
||
`墨 rgb(${lo.p.r},${lo.p.g},${lo.p.b}) / 底 rgb(${hi.p.r},${hi.p.g},${hi.p.b}) ${a.bounds}`);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* ★ 自检:扫到的段数不能太少 —— 太少说明遍历/尺寸过滤写错了,
|
||
* 那样下面那条断言**恒绿**(判据静默失效的典型形状)。
|
||
*/
|
||
assert.ok(scanned >= 5,
|
||
`要扫到足够多的小段文字(实测 ${scanned} 段)—— ` +
|
||
'太少说明遍历或尺寸过滤写错了,那样下面的检查恒绿。');
|
||
|
||
assert.deepStrictEqual(bad, [],
|
||
`★ ${themeName}主题下这些文字读不动(对比度 <3:1):\n ${bad.join('\n ')}\n` +
|
||
' 深色下品牌色/面色都要换深色变体:\n' +
|
||
' · 文字/图标用 `Theme.accentFor()`(深色自动给 `#80AFF9`,' +
|
||
'对齐 WebUI 的 `.dark --c-blue-600`)\n' +
|
||
' · 品牌浅底用 `Theme.accentSoftFor()`\n' +
|
||
' · **别**把 `Theme.surface` 这类"会翻转的面"当字色(用 `accentFg`)\n' +
|
||
' 2026-09-19 真撞到的两批:12 处 surface 当字色 + 24 处 accent 深色没调亮。');
|
||
});
|