跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据

这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
This commit is contained in:
2026-09-19 15:03:40 +08:00
parent 4a5318ca28
commit f811c9887a
9 changed files with 736 additions and 20 deletions

View File

@ -915,3 +915,378 @@ test('★ 碑文:`blurStyleFor` 不许回来 + 理由必须留在原处(旧
assert.match(prose(WALL_TS), /blurStyleFor[\s\S]{0,200}?已随/,
'碑文仍在原处:删除理由必须写在它被删掉的地方(否则下一个人看不出这里曾经有过什么)');
});
/* ═══════════════ 设备层:壁纸/外观**真的画在屏幕上** ═══════════════ */
/*
* 上面全部判据的最后一层都停在"数据/接线",从没验过 **屏幕上真的出现了那个东西**。
*
* ★ 这不是可有可无的一层 —— 2026-09-19 我在手势上刚吃过一模一样的教训:
* `PanGesture` 的代码全对、静态判据全绿,而真机上**一次都没触发**
* (起点坐标落在相邻的右栏里)。静态判据证明不了"用户点/看得见"。
*
* 这一条验的是 P4 的那句原始要求:**「壁纸开关打开后,屏幕上真的有壁纸」**。
* 做法:进「我的」页 → 打开背景开关 → 对比开关前后**顶层结构**里
* 壁纸层的存在(而不是截图比像素 —— 那太脆且依赖具体图片)。
*/
test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只看数据流)', async (t) => {
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, 1200));
/*
* 进「我的」窗格。★ **两种布局的入口不同**,必须分别处理 ——
* 这是我第一版直接红掉的地方(写 `tapText(hdc,'我的')`,而宽屏下根本没有底栏):
*
* 窄屏:底栏 4 项,第 4 项文字是「我的」(WebUI `NarrowNav.tsx` 同形)
* 宽屏:**没有底栏**,侧栏只有 3 项导航轨,「我的」是**底部那个头像按钮**
* (WebUI `Sidebar.tsx:186` 的 `setViewMode('account')`,二者同构)
*
* 判定方式与 `harmony-nav` 一致:用**宽高比 > 1.2** 区分形态
* (不是写死 vp 阈值,也不是拿屏幕 px 去比源码里的 vp —— 密度是第二个真相)。
*/
const screenOf = (root) => {
let w = 0;
let h = 0;
for (const n of D.walk(root)) {
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 root0 = D.dumpLayout(hdc);
const { w: scrW } = screenOf(root0);
const wide = scrW / screenOf(root0).h > 1.2;
if (wide) {
/*
* 宽屏:点侧栏底部**头像**(无文字、贴屏底)。用"左 1/6 内 + 在屏幕下半部 +
* 可点的方块"定位它 —— 与 `harmony-nav` 的 `navRailItemsOf` 同一套形状判法。
*/
const screenH = screenOf(root0).h;
const avatar = [...D.walk(root0)].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 [, x1, y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 && (x2 - x1) > 80 && (y2 - y1) > 80;
});
assert.ok(avatar,
'宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了');
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」窗格(底栏第 4 项)');
}
/*
* ★ 等页面真的切过去(**轮询**,不是睡固定时长)。
* 我第一版睡 1800ms 后断言,实测红 —— 而**手动点同一个坐标是立刻切过去的**。
* 原因就是固定等待不够稳(同一条纪律:手势判据也栽在固定 2500ms 上)。
* 现在等"「我的」页的标志性字段出现",最多 ~10 秒。
*/
const inMePane = (root) => [...D.walk(root)].some((n) => {
const t = (n.attributes?.text || '').trim();
/* 「我的」页独有:这几样在别的窗格里不会同时出现 */
return t.includes('用户名') || t.includes('连接密钥') || t.includes('主题由系统');
});
let landed = false;
/*
* ★★ 2026-09-19 修(套件里红、单独跑绿):轮询成功后必须**用新的 dump**,
* 不能继续用切页之前的 `root0`。
*
* 这个错法很隐蔽:`root0` 是"进「我的」之前"那一屏(套件里常常是日历页),
* 于是后面找「背景」时读的是**日历页的文字表** ⇒ 假红。
* 单独跑时之所以绿,是因为我手动摆现场时前台**恰好**已经在「我的」页 ——
* 也就是说:判据通过与否取决于跑之前那一屏是什么,而不是取决于代码对不对。
* (这正是"设备判据必须自己搭现场"的另一个理由。)
*/
let meRoot = null;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
const snap = D.dumpLayout(hdc);
if (inMePane(snap)) { landed = true; meRoot = snap; break; }
}
assert.ok(landed && meRoot !== null,
'点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' +
'只断言"点到了坐标"证明不了页面切过去了');
/* 「我的」页里找背景开关:WebUI 那边叫「背景」,鸿蒙侧同名 */
const texts0 = [...D.walk(meRoot)]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean);
assert.ok(texts0.some((x) => x.includes('背景') || x.includes('壁纸')),
'「我的」页里要能找到背景/壁纸那一节(这是本判据的定位锚点)——' +
`实际读到:${texts0.slice(0, 40).join(' | ')}`);
/*
* 记下"壁纸层是否存在"的判据:`MainPage.WallpaperLayer()` 在有壁纸时会挂
* 一个铺满屏幕的图片/渐变层。它在 dump 里表现为一个**铺满全屏的 Image 或
* 带渐变背景的 Column**,且 `MainPage` 的 `bgActive` 为真时页面底的
* 背景色会变成 Transparent。
*
* ★ 判据断的是**结构**(有铺满全屏的壁纸容器)而不是像素颜色 ——
* 后者要与主题/图片内容耦合,会在换壁纸时假红。
*/
/*
* ★★ 判定"壁纸层在不在"的**正确形状**(我前两版都写错了):
*
* 第一版:找"铺满屏幕的 Image/Stack" ⇒ **恒为 true**(外壳本身就有铺满的结构)。
* 第二版:找"全屏 Column 的渐变属性" ⇒ dump 里根本没有渐变字段(不报)。
*
* 有效的信号是**几何**:壁纸开启时 `bgActive=true` ⇒ 各内容面板的底色变
* `Transparent`,于是**面板与屏幕边缘之间的缝隙**会露出壁纸;关闭时那里是
* 面板自己的不透明底色。
*
* 实测(密度 2.875、屏 3184px):
* 预设档:侧栏区 `(60,1200)` = **#F4F5F7**、缝隙 `(215,1200)` = **#E0E2E4**
* 不设档:两处都是 **#FFFFFF**(纯白)
*
* 判据读的是 `dumpLayout` 里对应节点的 `backgroundColor` —— 比截图比像素稳
* (不依赖具体预设色的取值,只看"是不是纯白/是否透明让位")。
*/
const panelSlotColor = (root) => {
const all = [...D.walk(root)];
let screenW = 0;
let screenH = 0;
for (const n of all) {
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]));
}
}
if (screenW === 0) return null;
/*
* 找侧栏(左 1/6 内、纵贯大部分屏幕)与内容面板(在侧栏右侧、宽度很大),
* 取它们各自最外层容器的背景色。
*/
let sidebarBg = null;
let contentBg = null;
for (const n of all) {
const a = n.attributes || {};
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m || typeof a.backgroundColor !== 'string') continue;
const [, x1, y1, x2, y2] = m.map(Number);
const w = x2 - x1;
const h = y2 - y1;
if (h < screenH * 0.8) continue; // 要纵贯屏幕的容器
if (x2 <= screenW * 0.08 && sidebarBg === null) sidebarBg = a.backgroundColor;
if (x1 > screenW * 0.07 && x1 < screenW * 0.2 && w > screenW * 0.5 && contentBg === null) {
contentBg = a.backgroundColor;
}
}
return { sidebarBg, contentBg };
};
/**
* 壁纸开着 ⇔ **档位选中态不是"不设"**。
*
* ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事):
* ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有)
* ② 找"面板底色是否透明" ⇒ 恒 false(ArkUI 报的实色与我的正则对不上)
* 两版都没真正问出"壁纸开了吗"。
*
* 而这个问题**有一个确定答案**:段式选择器的选中态。
* 实测:选中 = `#FF2563EB`(品牌蓝)、未选中 = `#FFF1F3F5`(浅灰)。
* `bg_kind` 与选中态是一一对应的(同一个 @State),所以问它就等于问数据库。
*
* ★ 这一版为什么不脆:它读的是**控件自己的状态**,不依赖渲染管线的实现细节
* (材质/透明色如何上报、渐变节点是否出现在 dump 里)。
*/
const SELECTED_BG = '#FF2563EB';
const isSelected = (root, label) => chipState(root, label) === SELECTED_BG;
const chipState = (root, label) => {
const node = [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return (a.text || '').trim() === label && typeof a.backgroundColor === 'string';
});
return node ? (node.attributes.backgroundColor || '').toUpperCase() : null;
};
const currentKind = (root) => {
for (const [label, kind] of [['不设', 'none'], ['预设', 'preset'], ['自定义图片', 'image']]) {
if (isSelected(root, label)) return kind;
}
return null;
};
const before = currentKind(meRoot) !== 'none';
/*
* 切换背景开关。**幂等性处理**:开关当前是什么状态未知(上一次跑可能留下了),
* 所以先读当前状态、只在需要时点它 —— 盲目点会把"已开"点成"关"。
* 判据断的是**切换后状态真的变了**,不是"点了就成功"。
*/
/*
* ★ 开关的形态是**三选一的段式选择**:「不设 / 预设 / 自定义图片」——
* 不是 `Toggle`(我第一版按 Toggle 找,于是**永远 skip**)。
* 「背景」本身只是那一节的**标签文本**(`clickable=false`),点它没用。
*
* 这里用「切换到一个与当前不同的档位」的方式做幂等切换:
* 当前是「不设」就点「预设」,否则点「不设」。
* 这样跑第二遍时方向相反,但结论一致(判据必须幂等)。
*/
const clickableText = (root, want) => [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return a.clickable === 'true' && (a.text || '').trim() === want;
});
/*
* ★★ 第二版修正(第一版又错了,这次是**判"当前档"的方式错**):
*
* 我第一版用「能不能找到『不设』那个按钮」来判"当前是不设档" ——
* 但**三个按钮永远都在**(它是段式选择器,不是"点了才出现")。
* 于是它每次都去点「预设」,而壁纸本来就开着 ⇒ 切换前后无变化 ⇒ 假红。
*
* 正确判法是读**选中态**。实测(dump 的属性):
* 选中 `backgroundColor = #FF2563EB`(品牌蓝)
* 未选中 `backgroundColor = #FFF1F3F5`(浅灰)
* 这与 WebUI 的段式选择器同形(选中项是品牌底)。
*/
/*
* ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事):
* ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有)
* ② 找"面板底色是否透明" ⇒ 恒 false(dump 里的色值与我的正则对不上)
* 两版都没真正问出"壁纸开了吗"。
*
* 而这个问题**有一个确定答案**:段式选择器的选中态(`chipState`,下面定义)。
* 选中的 `bg_kind` 与 `@State` 一一对应 ⇒ 问它就等于问状态。
* 它读的是**控件自己的状态**,不依赖渲染管线细节(材质/透明色如何上报、
* 渐变节点是否出现在 dump 里)—— 这正是前两版脆的原因。
*/
const isNone = isSelected(meRoot, '不设');
const targetLabel = isNone ? '预设' : '不设';
const sw = clickableText(meRoot, targetLabel);
if (!sw) {
return t.skip(`找不到背景档位「${targetLabel}」(可能在图片档或文案变了)—— 本次不跑,但不假装通过`);
}
const c = D.boundsCenter(sw.attributes?.bounds);
assert.ok(c, '背景档位按钮要有可解析的边界');
assert.ok(D.tap(hdc, c.cx, c.cy), `要能点到背景档位「${targetLabel}」`);
await new Promise((r) => setTimeout(r, 2500)); // 切换 + 推服务端 + 重绘
const after = currentKind(D.dumpLayout(hdc)) !== 'none';
/*
* ★ 断"**状态变了**"而不是断"变成 true":开关初始状态取决于是上一次跑留下的,
* 而判据必须**幂等**(跑两遍结论相同)。所以两种切换方向都算通过,
* 真正要防的是"点了却什么都没发生"(那才是接线断了)。
*/
assert.notEqual(after, before,
'★ 切换背景开关后,壁纸层的存在性必须发生变化 —— ' +
`切换前=${before}、切换后=${after}。两者相同说明**开关点了没反应**:` +
'要么开关没接上状态,要么壁纸层没跟着重绘(这正是静态判据看不到的那一层)。');
/* 复位:点回**原来那一档**(不是"再点一次当前那个"——档位是单选,再点不变) */
const backLabel = isNone ? '不设' : '预设';
const backBtn = clickableText(D.dumpLayout(hdc), backLabel);
assert.ok(backBtn, `复位时还能找到「${backLabel}」`);
const bc = D.boundsCenter(backBtn.attributes.bounds);
assert.ok(D.tap(hdc, bc.cx, bc.cy), '要能点回去');
await new Promise((r) => setTimeout(r, 2000));
assert.equal(currentKind(D.dumpLayout(hdc)) !== 'none', before,
'复位后应回到判据开始时的状态(判据必须幂等,不留副作用)');
});
test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值曾被顶出屏外)', async (t) => {
/*
* ★★ 这条来自一个追了很久的真 bug(2026-09-19)。
*
* 症状:用户/我反复看到「我的」页压暗滑杆旁边印着 `56.000000`。
* 真相有两层,两层都值得记下来:
*
* ① `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
* —— 屏幕上根本没有这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
* ② 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,
* 而屏幕宽 3184 ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。
*
* 根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,而内容列写的是
* `.width('100%')` —— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp
* **相加** ⇒ 必然溢出 60vp。修法:内容列改 `.layoutWeight(1)`(吃剩余空间)。
*
* 为什么值得一条判据:**同一处错误在不同 pane 上表现不同**(日历页自己算宽度,
* 就没露出来),所以很容易被当成"某一页的样式问题"去调,而不是回到布局本身。
* 这条判据检查的是**所有窗格**,任何一个新 pane 犯了同样的错都会红。
*/
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), '要能拉起应用');
/*
* 逐个窗格检查。宽屏下「我的」用侧栏头像进,窄屏用底栏 —— 这里只跑宽屏那份
* (窄屏的几何不同,若要覆盖应单独一条,不要在一个判据里塞两种形态)。
*/
const root0 = D.dumpLayout(hdc);
const screenOf = (root) => {
let w = 0;
let h = 0;
for (const n of D.walk(root)) {
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 { w: scrW, h: scrH } = screenOf(root0);
/*
* 越界判定:右边缘超出屏宽、或左边缘为负。
* **排除系统组件**(状态栏那一条 `Stack` 实测 x2=3230 > 3184,那是系统的,
* 不归我们管)—— 按"是否属于我们应用的组件树"排除不现实,改用
* "越界幅度 + 节点类型":系统状态栏整条横贯顶部(y 很小且高度小)。
*/
const overflows = (root) => {
const bad = [];
for (const n of D.walk(root)) {
const a = n.attributes || {};
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) continue;
const [, x1, y1, x2, y2] = m.map(Number);
if (y1 < scrH * 0.05 && (y2 - y1) < scrH * 0.06) continue; // 状态栏,跳过
if (x1 < -2) bad.push(`${a.type} 左越界 x1=${x1}`);
else if (x2 > scrW + 2) bad.push(`${a.type} 右越界 x2=${x2}(屏宽 ${scrW})`);
}
return bad;
};
assert.deepEqual(overflows(root0), [],
`★ 「通信」窗格有内容超出屏幕 —— 超出 = 用户看不见(不是"偏了一点")`);
/* 进「我的」窗格(宽屏走侧栏头像;找不到就跳过,不假装通过) */
const avatar = [...D.walk(root0)].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 ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像进「我的」');
await new Promise((r) => setTimeout(r, 2500));
const rootMe = D.dumpLayout(hdc);
assert.deepEqual(overflows(rootMe), [],
'★ 「我的」窗格有内容超出屏幕 —— 这正是那个 `56%` 被顶出屏外的形状。' +
'常见原因:内容列写了 `width(\'100%\')` 而不是 `layoutWeight(1)`(与侧栏相加必溢出)');
});