判据: 管理页设备判据(到期静态判据的第一条升级)+ 修设备判据基建的三个真 bug

`run-all.mjs` 的 `STATIC_ONLY` 里登记着 6 条"只能静态验"的判据,前提是
「本工作区能装、能点设备」。设备现在可用 ⇒ 它们**到期**了(欠账当场变红)。
这一批升级第一条:`harmony-admin`(用户管理页)。

## 一、新设备判据:管理页真的能打开、列表真的渲染

上面 27 条静态判据判的都是逻辑与接线形态。它们全绿时,
"管理页能不能打开、列表能不能渲染"**一句都没验过** —— 而它最容易坏:
路由注册对了但入口没接上、接口回来了但列表没渲染。

判据自己搭现场:拉起应用 → 进「我的」→ 滚到底 → 点「管理」→
断言真的在管理页(出现「新建用户」)且**渲染出用户行**。

## 二、途中撞出三个**判据自身**的 bug(都已修,都写了教训)

1. **`tapText` 从来没成功点过任何东西**(`lib/harmony-device.mjs`)
   `findByText` 返回的是**数组**,我当单个节点用了 ⇒ `node.attributes` 恒
   `undefined` ⇒ 恒返回 `false`。症状极隐蔽:调用方以为"没找到那个文案",
   实际是帮手自己坏了。修:从数组里挑,且**优先挑可点的那个**
   (同名文案常常一个可点、一个不可点)。

2. **判据之间互相干扰**(新增 `backToMain`)
   每个设备判据都是写操作,会把前台留在它操作完的那一页。`harmony-admin`
   把人留在管理页 —— 那是 `pushUrl` 出去的独立 `@Entry` 页,**没有侧栏**
   ⇒ 后面的 `cross-client-gesture` / `harmony-appearance` 找不到侧栏、
   双双 skip(skip 原因写的是"宽屏侧栏找不到",看起来像功能没了)。
   `launchOurApp` 解决不了(`aa start` 只切前台,不弹栈)⇒ 新增 `backToMain`。

3. **"找不到元素"要先分清是功能缺失还是判据没摆好现场**
   本判据连栽四种形态,每一种都伪装成"功能缺失":
   · 锚点文案错(入口是「管理」,我按「用户管理」找 —— 后者只是说明的一部分)
   · 元素在滚动下方(dumpLayout 只报可见节点)
   · 文字节点不可点(`.onClick` 在包住它的容器上,ArkUI 的常态)
   · **滚动步长跨过了它**(诊断打印现场才发现:停在了「系统通知」那一带,
     而「管理」只占 ~0.04 屏,一次 0.2 屏的滑动必然越过)
   ⇒ 最后改成"**先滚到底、再小步回扫**"(只管往前找在不均匀列表上必漏),
     并加了 `AGENTMAIL_ADMIN_DEBUG=1` 的诊断入口把现场打出来。

## 三、判据

`run-all.mjs` → `files=32 ran=32 checks=505 pass=505 fail=0 skip=0
red=0 broken=0 unreported=0`(连跑两次稳定)。
`harmony-admin` 27 → 28 条。`hvigorw assembleHap` 成功;前端重建。

**设备实测**:管理页从「我的」页打开,显示「管理 / 用户管理 3 / 新建用户」
与三行用户(jianf 管理员 / gui-lab 用户 / test 用户)。

**仍到期未升级**:`harmony-appearance`(已加 2 条设备判据,但登记里其余部分仍静态)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults`、`harmony-imageprep`
—— 按"每条缺什么设备侧验证"逐条来,不为了消数字而凑。
This commit is contained in:
2026-09-19 15:53:34 +08:00
parent ff172ed2c1
commit a18014e1e3
5 changed files with 297 additions and 6 deletions

View File

@ -526,3 +526,202 @@ test('★ 「我的」页的数据都来自服务端真实字段(不是写死/
assert.ok(key.includes('key_token'),
'UserKey 要有 key_token —— 密钥全文只在创建响应里出现一次,没有它就没法展示');
});
/* ═══════════════ 设备侧:管理页真的能打开、能操作 ═══════════════ */
test('★ 设备:管理页能从「我的」页打开,且列表真的渲染出用户', async (t) => {
/*
* 这条补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口:
* 「用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点(本机两者都没有)」
*
* 上面 27 条判的都是**逻辑与接线形态**。它们全绿时,"管理页能不能打开、
* 列表能不能渲染"这件事**一句都没验过** —— 而它偏偏最容易坏:
* 路由注册对了但入口没接上、接口回来了但列表没渲染、非管理员也能看见入口。
*
* ★★ 写这条判据时连撞三个坑,每个都是**判据自己的**错(记下来,它们是同类):
* ① 锚点文案写错:入口标题是「管理」,我却按「用户管理」找
* (后者只是它下面那行说明的一部分)⇒ **永远跳过**。
* 而那个跳过看起来像"非管理员、行为正确"—— 最危险的那种静默。
* ② 元素在**滚动下方**:dumpLayout 只报可见节点 ⇒ 不滚到底根本搜不到。
* ③ 文字节点不可点:`Text('管理')` 的 `clickable=false`,
* 点击挂在包住它的容器上(ArkUI 的常态)。
*
* ⇒ 结论:设备判据里"找不到元素"必须先分清是**功能缺失**还是**判据没摆好现场**。
* 前两个坑都是后者,而它们伪装成前者。
*/
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), '要能拉起应用并等到它到前台');
await new Promise((r) => setTimeout(r, 2000));
const dims = () => {
let w = 0;
let h = 0;
for (const n of D.walk(D.dumpLayout(hdc))) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const hasText = (needle) => [...D.walk(D.dumpLayout(hdc))]
.some((n) => (n.attributes?.text || '').includes(needle));
/* 已经在管理页了?那直接用(判据不该强求"必须从某处点进来") */
if (!hasText('新建用户')) {
const { w: scrW, h: scrH } = dims();
const wide = scrW / scrH > 1.2;
/*
* ① 进「我的」
*
* ★★ 「在「我的」页」**不能用单个文案判**(第一版用「用户名」⇒ 假红):
* 本页有 8 个分段、可滚动,而**判据里的滚动会把「用户名」滚出屏幕**
* —— 同一个判据第二次跑到这里时(或前面判据把它留在这页且已滚动),
* `hasText('用户名')` 为假,于是它以为"不在「我的」页"、又去点一次导航,
* 结果页面被滚到别处、后面全乱。
*
* 改用**分段标记**判定:这些文案分布在本页的不同段落,
* 滚动到任何位置都至少能看见一个。
*/
const inMePane = () => ['用户名', '多账号', '外观', '客户端连接密钥', '修改密码']
.some((marker) => hasText(marker));
if (!inMePane()) {
if (wide) {
const avatar = [...D.walk(D.dumpLayout(hdc))].find((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
});
if (!avatar) return t.skip('宽屏侧栏头像找不到 —— 无法进「我的」');
const c = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, c.cx, c.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」');
}
let landed = false;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (inMePane()) { landed = true; break; }
}
assert.ok(landed, '要能进「我的」页');
}
/*
* ② 找「管理」入口(在 8 个分段之后,屏幕外)。
*
* ★★ 滚动必须**小步 + 每步检查**(我前两版都栽在这里):
* 大步滑(`0.8h → 0.25h`)会**跳过**那个入口 —— 实测:滚 1~2 次时它在屏外,
* 第 3 次才出现(`y=1994`,屏高 2232,贴着底)。而"滑到底再看"
* 会因为惯性/回弹停在别处,于是"找不到"——看起来像功能缺失。
*
* 现在每步只滑 ~1/4 屏,滚动后立刻查一次。
*/
/*
* ★★ 第四版(诊断逼出来的真相):小步滚也会**越过**它。
* 实测现场:滚到「系统通知」那一带(`03:50` 附近)时,「管理」**已经在上面**了
* —— 它在「客户端连接密钥」之后、「系统通知」之前,而我的步长一次跨过了整个
* 区间(每次 ~0.2 屏,而它只占 ~0.04 屏)。
*
* ⇒ 正确策略是**先滚到底,再从下往上找**(到底之后它一定在屏幕内或其上方,
* 再按小步回滚就必然扫过它)。"只管往前找"在不均匀列表上必然漏。
*/
const scrollDown = async (steps) => {
for (let i = 0; i < steps; i++) {
D.swipe(hdc, Math.round(scrW * 0.5), Math.round(scrH * 0.7),
Math.round(scrW * 0.5), Math.round(scrH * 0.3), 2500);
await new Promise((r) => setTimeout(r, 600));
}
};
/* ① 一口气滚到底(多滚几次确保到底) */
await scrollDown(8);
/* ② 若「管理」已经在屏上就好了;否则小步**回滚**(内容往下走)扫回来 */
let found = hasText('管理');
for (let i = 0; i < 8 && !found; i++) {
D.swipe(hdc, Math.round(scrW * 0.5), Math.round(scrH * 0.35),
Math.round(scrW * 0.5), Math.round(scrH * 0.6), 1500);
await new Promise((r) => setTimeout(r, 650));
found = hasText('管理');
}
if (!found) {
const seen = [...D.walk(D.dumpLayout(hdc))]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean).slice(-20).join(' | ');
return t.skip(`滚到底后仍找不到「管理」入口(非管理员时这是正确行为)—— 末尾读到:${seen}`);
}
/*
* ③ 点它。文字节点不可点(`.onClick` 在包住它的容器上),所以:
* 先让 `tapText` 试(它会挑同名的**可点**节点),失败再退回点文字中心
* (命中测试会冒泡到祖先的 onClick)。
*
* ★ 兜底那一步要**重新 dump 并当场找**,不能复用上面 `found` 那次的快照 ——
* 实测(套件里红、单独跑绿):`found` 为真说明"那一瞬间有",
* 而下一步再 dump 时可能正在重绘/滚动的惯性里 ⇒ 快照里没有 ⇒ 假红。
* 设备上的两次 dump 之间窗口状态是可以变的,别假设它们一致。
*/
/*
* ★★ 第三版:**直接按文字节点的矩形中心点**,不走 `tapText`。
*
* 为什么 `tapText` 在这里不可靠(实测:单独跑绿、套件里红):
* 它先找"同名且**可点**"的节点;这个入口的 `.onClick` 挂在
* **外面那层 `Column`** 上(`SettingsPage.ets:1144`),而那层的
* bounds 比文字大一圈、且**没有文字** ⇒ `findByText` 找不到它,
* `tapText` 退回点文字中心;但文字节点的中心在**卡片内部**,
* 命中测试是否冒泡到那层 Column 取决于层级细节 —— 不稳定。
*
* 而实测(手动点 `(1592, 2017)`,即文字所在行的中心)**每次都进管理页**。
* ⇒ 用文字节点的**矩形中心**直接点,这条路径最接近"人点那一行"。
*/
/* 诊断入口:`AGENTMAIL_ADMIN_DEBUG=1` 时把现场打出来(排查设备判据专用) */
const dbg = process.env.AGENTMAIL_ADMIN_DEBUG
? (m) => console.log(`[admin] ${m}`) : () => {};
const snap = D.dumpLayout(hdc);
const node = [...D.walk(snap)]
.find((n) => (n.attributes?.text || '').trim() === '管理');
if (!node) {
const seen = [...D.walk(snap)]
.map((n) => (n.attributes?.text || '').trim()).filter(Boolean).slice(-18).join(' | ');
dbg(`找不到「管理」节点;末尾文本:${seen}`);
}
assert.ok(node, '滚到之后「管理」文字节点应在 dump 里');
const c = D.boundsCenter(node.attributes.bounds);
assert.ok(c, '要能解析出「管理」的坐标');
/*
* 点之前**再确认一次前台是我们的应用**:套件里前面跑过的判据可能把前台
* 切走(那是"判据间干扰"的另一种形态)。切走了就不是"点不到入口",
* 而是"点在了别人的界面上"—— 后者必须显式失败,不能算通过。
*/
assert.equal(D.foregroundBundle(hdc), D.ourBundle(),
'点之前前台必须还是我们的应用(前面判据若切走了前台,这里点的是别人的界面)');
const ok = D.tap(hdc, c.cx, c.cy);
dbg(`点 (${c.cx},${c.cy}) ⇒ ${ok}`);
assert.ok(ok, `要能点到「管理」入口(坐标 ${c.cx},${c.cy})`);
}
/* ④ 等页面真的切过去 */
let onAdmin = false;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (hasText('新建用户')) { onAdmin = true; break; }
}
assert.ok(onAdmin,
'★ 点「管理」之后要真的在**管理页**(标志:出现「新建用户」按钮)—— ' +
'只断言"点到了入口"证明不了页面切过去了(路由没注册时点击静默无效)');
/* ⑤ 列表要真的渲染出用户行(抓"接口回来了但列表没接上") */
const root = D.dumpLayout(hdc);
const texts = [...D.walk(root)].map((n) => (n.attributes?.text || '').trim()).filter(Boolean);
const userRows = texts.filter((x) => /用户管理|启用|管理员|用户$/.test(x));
assert.ok(userRows.length > 0,
'★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' +
`只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`);
});