判据: 管理页设备判据(到期静态判据的第一条升级)+ 修设备判据基建的三个真 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

@ -41,7 +41,7 @@ import { code, prose } from './lib/read.mjs';
*/
import {
findHdc, hasTarget, dumpLayout, walk, swipe, tapText,
launchOurApp, boundsCenter, tap,
launchOurApp, boundsCenter, tap, backToMain,
} from './lib/harmony-device.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@ -278,7 +278,14 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
* 这才是设备判据该有的样子(前置条件自己满足,而不是等人摆好)。
*/
assert.ok(await launchOurApp(hdc), '要能拉起我们的应用并等到它到前台');
await new Promise((r) => setTimeout(r, 1500));
/*
* ★★ 必须先回**主界面**:前面跑过的设备判据可能把前台留在
* push 出去的独立页(管理页/详情页/写信页)—— 那些页面**没有侧栏**,
* 于是这里找不到侧栏项、判据会 skip 成"导航轨只找到 1 项"。
* 那是**判据间干扰**,不是功能缺失(详见 `backToMain` 的注释)。
*/
assert.ok(await backToMain(hdc), '要能回到主界面(侧栏/底栏可见)');
await new Promise((r) => setTimeout(r, 1200));
/* 切到日历窗格:宽屏点侧栏第 2 项 / 窄屏点底栏「日历」 */
const rootA = dumpLayout(hdc);

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(' | ')}`);
});

View File

@ -1226,6 +1226,8 @@ test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值
return t.skip('设备不在 —— 本条的设备半边本次不跑');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
/* 回主界面(前面判据可能把前台留在 push 出去的页上 —— 那些页没有侧栏) */
assert.ok(await D.backToMain(hdc), '要能回到主界面');
/*
* 逐个窗格检查。宽屏下「我的」用侧栏头像进,窄屏用底栏 —— 这里只跑宽屏那份

View File

@ -367,9 +367,92 @@ export async function launchOurApp(hdc, { settle = 400, tries = 40 } = {}) {
export function tapText(hdc, text) {
if (!hdc) return false;
const root = dumpLayout(hdc);
const node = findByText(root, text);
if (node === null) return false;
const c = boundsCenter(node.attributes?.bounds);
/*
* ★★ 2026-09-19 修(真 bug,而且是我自己写的帮手):
* `findByText` 返回的是**数组**(同名节点可能多个),而我当单个节点用了
* ⇒ `node.attributes` 恒为 `undefined` ⇒ 这个帮手**从来没成功点过任何东西**。
*
* 它造成的症状极其隐蔽:`tapText(...)` 返回 `false`,调用方以为
* "没找到那个文案"——而实际是**帮手自己坏了**。用它的判据(appearance/admin)
* 都会表现为"找不到入口"或"点不到",看起来像功能缺失。
*
* 修两处:① 从数组里挑;② **优先挑可点的那个** —— 同名文案常常一个可点
* (入口按钮)、一个不可点(说明文字),随便挑会点到说明文字上(点了没反应)。
*/
const nodes = findByText(root, text);
if (nodes.length === 0) return false;
const target = nodes.find((n) => n.attributes?.clickable === 'true') || nodes[0];
/*
* ★★ 2026-09-19 补第三层:文字节点本身常常**不可点**,点击挂在它**祖先**上。
*
* 实测(「我的」页的管理入口):`Text('管理')` 的 `clickable=false`,
* 而 `.onClick` 写在包住它的那层 `Row` 上 —— 这是 ArkUI 的常态
* (文字只是内容,可点的是容器)。直接点文字节点的中心**也能触到**
* (命中测试会冒泡到祖先),但那样判据就依赖"冒泡行为"而不是"我点到了那个入口"。
*
* 所以这里显式用**祖先的可点容器**的边界:找不到可点祖先时才退回文字节点自己。
* 当前实现从目标节点的 bounds 出发做一次"就近扩展":
* 若目标不可点,就用**同文本的可点兄弟/祖先链**(dump 树里 clickable 的那一层
* 一般把文字整个包住,所以用它的 bounds 最稳)。
*/
const clickableAncestor = (() => {
if (target.attributes?.clickable === 'true') return target;
/* 往上找:dump 树的节点带 children,父链要从根重新走一遍才能拿到 */
let found = null;
const visit = (n) => {
if (found) return;
const kids = n.children || [];
for (const k of kids) visit(k);
if (found) return;
const self = n.attributes || {};
if (self.clickable === 'true'
&& String(self.bounds || '') === String(target.attributes?.bounds || '')
&& kids.length > 0) {
found = n;
}
};
visit(root);
return found || target;
})();
const c = boundsCenter(clickableAncestor.attributes?.bounds);
if (c === null) return false;
return tap(hdc, c.cx, c.cy);
}
/**
* 确保回到**主界面**(`MainPage`),而不是某个 push 出去的页
* (管理页 / 详情页 / 写信页 —— 它们没有侧栏与底栏)。
*
* ★★ 为什么需要它(2026-09-19 实测撞出来的**判据间干扰**):
* 每个设备判据都是"写操作",会把前台留在它操作完的那一页上。
* 于是**前一个判据留下的位置**决定了后一个判据能不能跑:
* `harmony-admin` 把人留在管理页(那是 `pushUrl` 出去的独立 @Entry 页,
* **没有侧栏**)⇒ 后面的 `cross-client-gesture` / `harmony-appearance`
* 找不到宽屏侧栏 ⇒ 双双 skip。而它们的 skip 原因写的是
* "宽屏侧栏找不到",看起来像功能没了。
*
* `launchOurApp` 解决不了这个:`aa start` 只把应用切到前台,
* 不会把已经 push 的页面弹栈。
*
* 做法:**反复按返回键**直到侧栏/底栏出现(那是主界面的标志)。
* 上限兜底,避免在没有可弹页面时空按。
*/
export async function backToMain(hdc, { tries = 6, settle = 700 } = {}) {
if (!hdc) return false;
const atMain = () => {
const root = dumpLayout(hdc);
for (const n of walk(root)) {
const t = (n.attributes?.text || '').trim();
/* 侧栏导航轨(宽屏)或底栏(窄屏)上的项 —— 只有 MainPage 有 */
if (t === '通信' || t === '联系') return true;
}
return false;
};
for (let i = 0; i < tries; i++) {
if (atMain()) return true;
sh(hdc, ['shell', 'uitest', 'uiInput', 'keyEvent', 'Back'], 10000);
await new Promise((r) => setTimeout(r, settle));
}
return atMain();
}

View File

@ -115,7 +115,7 @@ const SUITE = [
['test/criteria-hygiene.test.mjs', [], 6],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],