跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了 此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么", 不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。 新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` / `hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。 **它当场证明了它的价值**:`cross-client-theme` 新增的设备判据 用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。 ## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用 `Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` —— 一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。 - 浅色下当白字用**碰巧对**(白字压蓝底) - **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见** 设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**; 读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。 `Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在, 却**一处都没用**。 修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg` (`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。 另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。 ## 三、判据(两条,都做了变异验证) 1. **设备条**(`cross-client-theme`):读悬浮球像素 —— ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染); ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。 把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。 2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。 设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。 ## 四、判据自身踩的三个坑(都写进注释了) - **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。 截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。 - **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`), 实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。 原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。 - **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页), 而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。 ## 五、欠账 - `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了 (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。 如实记着**未验**的那点:预算条的**点击**没在设备上走通 (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。 - `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 —— `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。 - `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。 `run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`; Go 侧 `./internal/repo/...` 通过。
This commit is contained in:
@ -17,9 +17,13 @@
|
||||
// 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你
|
||||
// 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, statSync, unlinkSync } from 'node:fs';
|
||||
|
||||
import { tmpdir } from 'node:os';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bytes } from './read.mjs';
|
||||
// 读盘走 test/lib 的具名入口(本仓纪律:判据目录里不许裸 readFileSync)。
|
||||
// 这里要的是**原文**(账本是个 JSON 文本,读出来自己解析)⇒ `prose()` 正是那个入口。
|
||||
// ★ 实测教训:我第一版在这儿裸用了 readFileSync,`criteria-hygiene` 立刻红了
|
||||
@ -472,3 +476,79 @@ export function shellOn(hdc, cmd, timeout = 20000) {
|
||||
if (!hdc) return { stdout: '', stderr: 'no hdc', status: -1 };
|
||||
return sh(hdc, ['shell', cmd], timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 截屏并把 PNG 拉到本地,返回本地路径(失败返回 null)。
|
||||
*
|
||||
* ★ 为什么需要它(2026-09-19 加):
|
||||
* 本目录此前**只能**通过 `dumpLayout` 看界面 —— 而 dump 是**结构化描述**,
|
||||
* 它报的是"组件声明了什么属性",不是"屏幕上真的画成什么样"。
|
||||
* 两者会分叉的地方恰恰是最要紧的:
|
||||
* · `Slider` 节点的 `text='56.000000'` 是**无障碍文本**,屏幕上根本没这串字
|
||||
* (我为它追了很久,最后靠截图才发现真相);
|
||||
* · 颜色令牌写对了,但渲染时被父层覆盖 / 被透明度抹掉;
|
||||
* · 元素在屏幕外(dump 里仍报它的 bounds)。
|
||||
* ⇒ "观感类"判据必须有截屏这一层。
|
||||
*
|
||||
* **这是写操作**(会占用设备屏幕一小会儿)。调用方按本模块的边界①
|
||||
* 先确认前台是自己的应用。
|
||||
*/
|
||||
export function screenshot(hdc, localPath = '/tmp/hm-shot.png') {
|
||||
if (!hdc) return null;
|
||||
const remote = '/data/local/tmp/__hm_shot.png';
|
||||
sh(hdc, ['shell', 'uitest', 'screenCap', '-p', remote], 30000);
|
||||
const r = sh(hdc, ['file', 'recv', remote, localPath], 30000);
|
||||
if (!existsSync(localPath)) return null;
|
||||
/* 再确认一次不是空文件(recv 失败时会留下 0 字节或旧文件) */
|
||||
try {
|
||||
return statSync(localPath).size > 100 ? localPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一张 PNG 上某个点的像素(返回 `{ r, g, b }`,失败返回 null)。
|
||||
*
|
||||
* 实现用 `ffmpeg` 把它转成 1×1 的原始 RGB 再读三个字节 ——
|
||||
* 不引 PNG 解码依赖(本仓的判据目录不装 node_modules)。
|
||||
*
|
||||
* ★ 坐标是**屏幕像素**(与 `dumpLayout` 给的一致),不是 vp。
|
||||
*/
|
||||
export function pixelAt(pngPath, x, y) {
|
||||
if (!existsSync(pngPath)) return null;
|
||||
const out = join(tmpdir(), `hm-px-${process.pid}-${x}-${y}.raw`);
|
||||
const r = spawnSync('ffmpeg', [
|
||||
'-loglevel', 'error', '-y', '-i', pngPath,
|
||||
'-vf', `crop=1:1:${x}:${y}`,
|
||||
'-f', 'rawvideo', '-pix_fmt', 'rgb24', out,
|
||||
], { encoding: 'utf8', timeout: 20000 });
|
||||
if (r.error || !existsSync(out)) return null;
|
||||
try {
|
||||
/*
|
||||
* ★ 走 `lib/read.mjs` 的 `bytes()`,不裸用 `readFileSync` ——
|
||||
* `criteria-hygiene` 盯着这条(它是对的:本仓有两套读法,
|
||||
* `code()` 剥注释、`prose()` 保注释,裸读会让"判代码"退化成"判文本")。
|
||||
* 二进制只有 `bytes()` 这一个合法入口。
|
||||
*/
|
||||
const buf = bytes(out);
|
||||
unlinkSync(out);
|
||||
if (buf.length < 3) return null;
|
||||
return { r: buf[0], g: buf[1], b: buf[2] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 `#RRGGBB` 解析成 `{r,g,b}`(判据里常拿它跟 `pixelAt` 的结果比)。 */
|
||||
export function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
if (!m) return null;
|
||||
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
|
||||
}
|
||||
|
||||
/** 两个颜色的通道差是否都在容差内(渲染有抗锯齿/取整,不能用全等比)。 */
|
||||
export function closeColor(a, b, tol = 12) {
|
||||
if (!a || !b) return false;
|
||||
return Math.abs(a.r - b.r) <= tol && Math.abs(a.g - b.g) <= tol && Math.abs(a.b - b.b) <= tol;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user