Files
MailUI4Agents/client/electron/test/harmony-window.test.mjs
dsh 477479a370 fix(harmony): ★★ 三页 AppHeader 顶栏避让硬编码 0 ⇒ 顶栏压进系统状态栏(真机实测)
设备:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS NEXT,API 26,
      `hdc tconn 192.168.2.87:43679`(此前一直无真机,本条挂了 5 天)。

## 症状(用户报:「左上角全屏状态下不应该显示全屏,会与顶部系统顶栏冲突」)

截图像看是状态栏压住顶栏。**实测证伪了这个读法**:像素扫描 + `uitest dumpLayout`
显示顶栏文字在 y=134..166、系统状态栏止于 y=82,**两者不重叠**。
真正被切掉的是**列表第一封邮件的标题**(y=294..319,只剩一条细线)。

⇒ 两处独立问题,第二个(窗格头 y=222..320 与列表首行 y=294..319 重叠)
   **不是顶栏避让**造成的,本次未修,见下。

## 已修:AppHeader 那一半的避让确实是坏的

`EntryAbility` 早就把避让读到了(真机日志 `insets: statusBar=38.588235
navIndicator=27.764706 windowDecor=37`),`CommPage`/`MainPage` 内容层也用了
(`top: max(statusBar, windowDecor) + paneGap`)。**但 `AppHeader` 是另一个消费者,
三处都写死了 `topInsetPx: 0`**:

  · `SentTab`(发件箱)      MainPage.ets:1658
  · `SettingsPage`(我的)   SettingsPage.ets:687
  · `PermissionTab`(授权)  PermissionTab.ets:487

只有 `AdminUsersPage` 是对的(`topInset(this.windowInsets)`)—— 抄它。

## 为什么已有的判据没抓到(这才是关键)

`harmony-window.test.mjs` 接线⑤「每一个 @Entry 页都要消费避让」是**绿的**,
因为它的 `consumes()` 认两种形状,其中一种只要**文件里出现过**
`this.windowInsets.statusBar` 就算过 —— 而 `MainPage` 的**内容层**正好读了它。
⇒ 页面上有**两个**避让消费者,判据只问"页里有没有出现过那个字段",
  于是 `AppHeader` 里那个 0 被完全放过。

新判据(判据 10)改成**逐个消费者问**:任何传给 `AppHeader` 的 `topInsetPx`
不得是字面量 0。自检要求扫到 ≥4 处,防"遍历写错 ⇒ 永远绿"。

★ 这条判据自己先犯过一次同类错并当场被抓:第一版用 `prose()`(含注释),
  把**我自己写进注释里的**「原先 topInsetPx: 0」抓成了红 ——
  判据在判自己的注释。改用本文件已有的 `stripped()`(其注释原话:
  「判据的锚不能落在被守对象的自述上」)。红绿已验:把 SettingsPage 退回
  缺陷版 ⇒ 红;恢复 ⇒ 绿。

## 编译期抓到的一个坑

`MainPage.ets:1658` 的 AppHeader 在 **`SentTab`** 里,而 `windowInsets` 原本
只声明在 `CommPage` 上。ArkTS 报 `Property 'windowInsets' does not exist on
type 'SentTab'`。⇒ `@StorageLink` 是**每个组件各自**订阅 `AppStorage` 的,
父组件的不会自动传给子组件;直接各自订阅同一把键(比"父传子"少一层)。

## 真机验证

装机后重跑 dumpLayout:发件箱顶栏 `y=134..166`(状态栏底 y=82,间隙 52px),
截图确认「发件箱」完整显示、不再被压。

## 未修(诚实登记)

列表第一行标题被窗格头盖住(`List` 首项 y=294..319 落在窗格头 y=222..320 内),
与顶栏避让**无关**,本次未动。根因待查:`MailRow` 是 `.height(64)` 定高,
而 List 容器从 y=320 起算,首项被画到容器上方。
2026-10-01 18:49:06 +08:00

362 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 窗口全屏 + 避让区(消上下黑边)—— 判据。
*
* ── 这条为什么必须存在(它挡的是一个**已经发生过三次**的退回)──
*
* 用户报过一次同一条:「底部那个黑条是啥意思」(2026-09-17)、
* 「你再看看页面底部,那么大的黑色,你看从头到尾都没修好,
* 你能不能好好看看我给你的示例工程怎么处理上下黑边的」(2026-09-18)。
*
* 中间那次的处理是:只调 `setWindowLayoutFullScreen(true)`,发现页签被时钟盖住
* ⇒ 把整个全屏**退回了**,代码里留下一句「★ 刻意不用 setWindowLayoutFullScreen(true)」。
*
* 那个结论错在:被盖住不是"不该全屏",而是**只做了全屏、没做避让**。
* 示例工程(`/tmp/harmonyos-samples-reference`)里两件事是**同一套东西的两半**:
* `common/src/main/ets/util/WindowUtil.ets:registerBreakpoint`
* → `getWindowAvoidArea(TYPE_SYSTEM / TYPE_NAVIGATION_INDICATOR)`
* `features/mine/src/main/ets/view/MineView.ets:251`
* → `.margin({ top: statusBarHeight + …, bottom: naviIndicatorHeight })`
*
* 只做前半 ⇒ 内容跑到状态栏底下没人让(那次的症状);
* 只做后半 ⇒ 黑边照旧(这三次的症状)。**两半缺哪一半都是错的**,
* 所以判据必须同时钉住两半 —— 只钉一半的话,"退回某一半"这个动作照样能全绿通过。
*
* ── 判据的层次 ──
* ① 纯逻辑(`insetsFromAvoidArea`):能跑,喂样本进去验换算对不对;
* ② 接线(`.ets` 源码):全屏调用在、避让读在、布局真的消费了值。
* 静电扫源码验证不了"屏幕上没有黑边",所以另有一条**设备行为判据**
* (见文件末尾,有设备时真读 insets)。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { prose } from './lib/read.mjs';
import { findHdc, hasTarget, foregroundBundle, ourBundle } from './lib/harmony-device.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const HARMONY = join(HERE, '..', '..', 'harmony');
const MODEL = join(HARMONY, 'entry', 'src', 'main', 'ets', 'model');
const PAGES = join(HARMONY, 'entry', 'src', 'main', 'ets', 'pages');
const ABILITY = join(HARMONY, 'entry', 'src', 'main', 'ets', 'entryability', 'EntryAbility.ets');
const W = await import(pathToFileURL(join(MODEL, 'WindowInsets.ts')).href);
/** 造一个 `window.AvoidArea` 形状的样本(只带我们用到的两个字段)。 */
function area(topPx, bottomPx) {
return {
topRect: { height: topPx },
bottomRect: { height: bottomPx }
};
}
/* ───────────────────────── ① 纯逻辑 ───────────────────────── */
test('★ 避让换算:px → vp(设备 3.5 密度,真机实测 136px/20px ⇒ 39vp/6vp)', () => {
const px2vp = (px) => px / 3.5;
const got = W.insetsFromAvoidArea(area(136, 20), area(0, 20), px2vp);
/*
* 136px / 3.5 = 38.857…,20/3.5 = 5.714…
* 断言的是"换算真的走了 px2vp",不是某个具体舍入 —— 所以用近似比较。
*/
assert.ok(Math.abs(got.statusBar - 136 / 3.5) < 0.001, `状态栏应是 39vp,实际 ${got.statusBar}`);
assert.ok(Math.abs(got.navIndicator - 20 / 3.5) < 0.001, `手势条应是 5.7vp,实际 ${got.navIndicator}`);
});
test('★ 取不到避让区 ⇒ 0(**不是**猜一个 39vp)', () => {
const px2vp = (px) => px / 3.5;
const none = W.insetsFromAvoidArea(undefined, undefined, px2vp);
assert.equal(none.statusBar, 0);
assert.equal(none.navIndicator, 0);
/*
* 为什么是 0 而不是"兜底一个经验值":
* 0 的表现 = 黑边照旧(与修之前一模一样,能被截图判据看出来);
* 猜一个的表现 = 按猜的值让位、错得**看不出来**(比如让多了留白、让少了压字)。
* 两害相权取**能暴露的那个** —— 与本仓「缺证据 ≠ 没有那个现象」同一条纪律。
*/
const partial = W.insetsFromAvoidArea({ topRect: { height: 136 } }, undefined, px2vp);
assert.ok(partial.statusBar > 0, '只给一半时,给了的那一半照样算出来');
assert.equal(partial.navIndicator, 0, '没给的那一半是 0,不猜');
});
test('★ 避让键名是个常量(`@StorageLink` 拼错不报错、只会恒为 0 —— 正好是本轮病症)', () => {
assert.equal(typeof W.KEY_WINDOW_INSETS, 'string');
assert.ok(W.KEY_WINDOW_INSETS.length > 0);
/*
* 这条防的是"用会静默失效的机制修静默失效的病":
* ArkUI 的 `@StorageLink('拼错了')` **不报错**,只是永远等于初值(0),
* 表现就是"避让永远 0 ⇒ 黑边照旧" —— 与要修的病症长得一模一样。
* 所以键名必须是一处常量、且**消费方不许写第二份字面量**(下面那条接线判据钉这个)。
*/
const pages = prose(join(PAGES, 'MainPage.ets'));
const literals = pages.match(/@StorageLink\(\s*'([^']+)'\s*\)/g) ?? [];
for (const l of literals) {
assert.ok(!/window|inset/i.test(l) || l.includes('KEY_WINDOW_INSETS'),
`窗口避让的 StorageLink 不许写字符串字面量(要用 KEY_WINDOW_INSETS):${l}`);
}
});
/*
* 把注释剔掉再断言。
*
* ★ 这一层不是可选项 —— 本文件第一版就是栽在这里:
* 正则 `/setWindowLayoutFullScreen\(\s*true\s*\)/` 直接扫全文,
* 而**注释里就有这个串**(那句解释「这就是消黑边的动作」)。
* 于是把真正的调用删掉(重演上一次的退回)后,判据**仍然全绿** ——
* 因为它锚在**代码对自己的描述**上,而不是代码本身。
* 变异自检(删掉调用 ⇒ 必须红)当场抓住了这一点。
*
* 与本仓那条纪律一致:**判据的锚不能落在被守对象的自述上**。
* 注释里可以有这个串(而且要写清楚),但断言必须只看代码。
*/
function stripped(src) {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
/* ───────────────────────── ② 接线 ───────────────────────── */
test('★ 接线①:EntryAbility 必须调 `setWindowLayoutFullScreen(true)`(消黑边的那动作)', () => {
const src = stripped(prose(ABILITY));
assert.match(src, /setWindowLayoutFullScreen\(\s*true\s*\)/,
'没有全屏调用 ⇒ 窗口在状态栏/导航条处留黑 ⇒ 上下黑边(这正是三次报修的病症)');
});
test('★ 接线②:全屏旁边必须**同时**有避让读取(只有一半就是上一次的退回)', () => {
const src = stripped(prose(ABILITY));
assert.match(src, /getWindowAvoidArea\(\s*window\.AvoidAreaType\.TYPE_SYSTEM\s*\)/,
'要读状态栏避让区(TYPE_SYSTEM)');
assert.match(src, /getWindowAvoidArea\(\s*window\.AvoidAreaType\.TYPE_NAVIGATION_INDICATOR\s*\)/,
'要读手势条避让区(TYPE_NAVIGATION_INDICATOR)');
/*
* ★ 这条是全文件最重要的:它钉的是"两半必须同时存在"。
* 上一次退回时,`getWindowAvoidArea` **一次都没出现**(实测 grep 为空)——
* 只有全屏、没有避让,于是页签被时钟盖住,于是把全屏也删了。
* 如果判据只钉"有全屏"或只钉"有避让",那次退回在判据里就是全绿的。
*/
const hasFullScreen = /setWindowLayoutFullScreen\(\s*true\s*\)/.test(src);
const hasAvoid = /getWindowAvoidArea/.test(src);
assert.ok(hasFullScreen && hasAvoid,
'全屏与避让是同一套东西的两半:缺哪一半都会坏(缺前半=黑边,缺后半=被时钟盖住)');
});
test('★ 接线③:避让高度必须真的被**布局消费**(读了不用 = 没读)', () => {
const page = stripped(prose(join(PAGES, 'MainPage.ets')));
assert.match(page, /@StorageLink\(\s*KEY_WINDOW_INSETS\s*\)/,
'MainPage 要订阅避让值(键走常量,不写字面量)');
assert.match(page, /windowInsets\.statusBar/,
'★ 读了 `statusBar` 却没人用 ⇒ 内容照样跑到时钟底下(上一次的病症就是这么来的)');
assert.match(page, /windowInsets\.navIndicator/,
'★ 手势条高度要让底栏用上,否则自绘玻璃条会压在手势区上');
});
test('★ 接线④:`.ets` 不许 import `WindowInsets` 的 SDK 类型(判据要在 node 里 import 它)', () => {
const model = prose(join(MODEL, 'WindowInsets.ts'));
/*
* 这个纯逻辑模块必须**无 `@ohos`/`@kit` 依赖** —— 判据用 `--experimental-strip-types`
* 在 node 里直接 import 它验换算,`@kit.ArkUI` 在 node 里不存在 ⇒ 一旦 import 就跑不起来。
* 这是本仓反复用的办法(`DeviceProbe.ts` / `MailGrouping.ts` / `NavItems.ts` 同一形状)。
*
* ⚠️ 只看**代码**,不看注释:本文件的注释里**故意**写了 `@kit.ArkUI` 来解释为什么
* 不能 import 它 —— 把注释也算进去的话,连"说清楚理由"都会被判红(我第一版就踩了)。
*/
const code = stripped(model);
assert.ok(!/@kit\.|@ohos\./.test(code),
'WindowInsets.ts 必须保持纯净(无 SDK 依赖),否则判据连编都编不过');
});
test('★ 接线⑤:**每一个 `@Entry` 页**都要消费避让(全屏是窗口级的,不止 MainPage)', () => {
/*
* ── 这条为什么是全文件第二重要(2026-09-18 实测踩到)──
*
* `setWindowLayoutFullScreen(true)` 是**窗口级**的:一旦设上,窗口里**所有**
* 用 `router.pushUrl` 推上来的页都从 y=0 开始画,而不只是 `MainPage`。
*
* 我第一版只给 `MainPage` 加了避让 ⇒ 写邮件页实拍:「取消」与时钟「09:49」重叠、
* 「发送」与 wifi/电量图标重叠。**把黑边换成了另一种坏** —— 而且两个错同源:
* 都是"同一件事只做了一半"(上次少了避让,这次少了页面)。
*
* ★ 所以口径是「**有人在窗口上开了全屏 ⇒ 每个 `@Entry` 页都得让**」,
* 而不是「检查 MainPage 做了没有」。后者下新增一个推上来的页会静默逃掉 ——
* 而"新增一个页"正是最常发生的事。
*/
const pageDir = join(HARMONY, 'entry', 'src', 'main', 'ets', 'pages');
const entries = readdirSync(pageDir)
.filter(f => f.endsWith('.ets'))
.filter(f => /^@Entry\b/m.test(prose(join(pageDir, f))));
/*
* 自检:扫到的 `@Entry` 页数要 ≥ 5(`main_pages.json` 里登记 7 个)。
* 防的是"目录改名/遍历写错 ⇒ 这条变成空判据而依然全绿"——
* 与 `harmony-system-api` 里那条"扫到的文件数 ≥ N"同形。
*/
assert.ok(entries.length >= 5, `要扫到全部 @Entry 页(实际 ${entries.length}:${entries.join('、')})`);
/*
* 两种合法的"消费"写法:
* · `topInset(this.windowInsets)` —— 直白的(顶栏页用这个);
* · 直接读 `this.windowInsets.statusBar` —— MainPage 要的,它得把避让嵌进
* `isWide ? paneGap : statusBar` 这个条件表达式里,套不进 `topInset()`。
* 两者都算"把值用起来了";形状不同而要求相同(与 `harmony-nav` 那条同口径:
* 判的是"读到的东西有没有落到布局上",不是"用的哪个函数")。
*/
const consumes = (src) => /topInset\(\s*this\.windowInsets\s*\)/.test(src)
|| /this\.windowInsets\.statusBar/.test(src);
/*
* 豁免名单:每条都必须是**可机器复核的理由**,不是"这个页先不管"。
*
* `LoginPage` 的根容器是 `.align(Alignment.Center)` 的满高容器 —— 内容是**居中**的,
* 最顶上的那个像素也远在状态栏之下,结构上碰不到 y=0。所以它不需要避让。
*
* ★ 但"居中"是个可能被改掉的性质(哪天有人给它加个贴顶的标题栏,理由就没了)。
* 所以这里不只看一次理由字符串 —— 下面**断言那个居中写法仍然存在**:
* 谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。
* 这正是本仓那条"豁免得带可复核的理由"(`harmony-system-api` 的 ALLOW 同形)。
*/
const EXEMPT = new Map([
['Index.ets', { why: 'DevEco 模板欢迎页,不在 main_pages.json 的流程里,从不被路由到', check: null }],
['LoginPage.ets', {
why: '根容器 .align(Alignment.Center):内容居中,结构上碰不到 y=0',
check: (src) => /\.align\(Alignment\.Center\)/.test(stripped(src))
&& !/Alignment\.Top/.test(stripped(src))
}]
]);
const missing = [];
for (const f of entries) {
const src = prose(join(pageDir, f));
const exempt = EXEMPT.get(f);
if (exempt) {
if (exempt.check) {
assert.ok(exempt.check(src),
`${f} 被豁免的理由已不存在(原理由:${exempt.why})—— ` +
'要么恢复那个形状,要么把它当普通页消费避让,不要默默留着豁免');
}
continue;
}
if (!consumes(src)) missing.push(f);
}
assert.deepEqual(missing, [],
`这些 @Entry 页没有消费窗口避让:${missing.join('、')} —— ` +
'全屏是窗口级的,漏一个页就是一处「顶栏被时钟压住」(实测过:写邮件页的取消/发送压着 09:49 与电量)');
/*
* `\u2605` 顶栏那对"高度 + padding-top"只对**固定高度**的顶栏成立(`height(56)`)。
*
* `MailDetailPage` 是另一种形状:它的 `@Entry` 包装层是 `height('100%')` 的**满高列**,
* 避让用 `padding({ top: … })` 加在满高容器上(内容自然往下,没有"从 56 里切"的问题)——
* 这是满高容器的正确写法,不该被要求写成 `56 + topInset(...)`。
*
* 所以这里只对**真的写了固定 height(56) 顶栏**的页判那对同现,
* 其余页(满高容器)只要求"避让落到了布局上"(上面已经判过)。
*/
const halfDone = [];
for (const f of entries) {
if (EXEMPT.has(f)) continue;
const code_ = stripped(prose(join(pageDir, f)));
if (!/topInset\(\s*this\.windowInsets\s*\)/.test(code_)) continue; // 不是顶栏形状(MainPage)
/*
* 只看**同一个修饰符链**:包含 `padding(top: topInset(...))` 的那一段往前找它的 `.height(...)`。
*
* 不能满文件搜 `height(56)` —— `MailDetailPage` 里的悬浮回复球是 `.width(56).height(56)`,
* 与顶栏毫无关系(我第一版就是这么误报的:把一个 56×56 的圆球当成了"固定 56 顶栏")。
* 判据要锚在"这一段的形状"上,而不是"文件里有没有出现过这个数"。
*/
const pad = /\.padding\(\{[^}]*top:\s*topInset\(\s*this\.windowInsets\s*\)[^}]*\}\)/.exec(code_);
if (!pad) continue;
const chain = code_.slice(Math.max(0, pad.index - 300), pad.index + pad[0].length);
const hasFixed56 = /\.height\(\s*56\s*\)/.test(chain);
if (!hasFixed56) continue; // 满高容器形状(MailDetailPage):没有从 56 里切的问题
const heightGrew = /\.height\(56 \+ topInset\(\s*this\.windowInsets\s*\)\)/.test(chain);
if (!heightGrew) {
halfDone.push(f);
}
}
assert.deepEqual(halfDone, [],
`这些页的顶栏没把"高度 + padding-top"一起加:${halfDone.join('、')} —— ` +
'只加一个会把固定 56vp 切掉一截(按钮压扁)或让位没生效(内容仍贴 y=0)');
});
/* ───────────────────────── ③ 设备行为(有设备才跑)───────────────────────── */
/*
* 上面四条都是**静电扫源码** —— 它们证不了"屏幕上真的没有黑边"。
* 真正的证据是设备上读数。没设备/前台不是我们 ⇒ **跳过**(不是绿、不是红),
* 与本仓 harmony-nav 同一套边界:不下断言、也不许静默算验过。
*/
test('★ 行为(设备):全屏后窗口的避让区读得到(真机口径)', (t) => {
const hdc = findHdc();
if (!hdc || !hasTarget(hdc)) {
t.skip('无设备/无目标 —— 跳过(不算验过)');
return;
}
const fg = foregroundBundle(hdc);
if (fg !== ourBundle()) {
t.skip(`前台是 ${fg}(不是我们的 ${ourBundle()})—— 跳过,不下断言`);
return;
}
/*
* ⚠️ 这里**不**去解析设备的 avoid area(那要 hdc 上的 hidumper + 解析,脆)。
* 这条只钉一件事:**全屏之后我们的界面仍然在前台且能读树** ——
* 即"全屏没有导致应用起不来/白屏"(全屏配错最坏的后果)。
* 屏幕上的黑边由截图判据(人看 + pngread 逐像素)负责,不混进这条。
*/
assert.equal(fg, ourBundle());
});
/*
* ★★ 2026-10-01 真机实测抓到的漏洞(MatePad Pro / MRDI-W00,HarmonyOS NEXT API 26):
*
* 上一条判据("每个 `@Entry` 页都要消费 windowInsets")在 `MainPage` 上是**绿的**,
* 而真机上顶栏被系统状态栏压住了。**为什么绿**:
*
* `consumes()` 认两种形状 —— `topInset(this.windowInsets)` 或
* `this.windowInsets.statusBar`。`MainPage` 的**内容层**确实读了
* `this.windowInsets.statusBar`(`top: max(statusBar, windowDecor) + paneGap`),
* 于是整页被判为"已消费" ⇒ 绿。
*
* ★ 但那说的是**内容层**,而 `AppHeader({ topInsetPx: 0 })` 是**另一个消费者**,
* 它硬编码 0。页面上有两处顶栏避让的消费者,判据只问"页里有没有出现过那个字段"。
*
* ⇒ 修法:判据改成**逐个消费者问**,而不是逐页问一次。
* 任何传给 `AppHeader` 的 `topInsetPx` 都不得是字面量 0 ——
* 要么走 `topInset(this.windowInsets)`,要么显式说明为什么可以给 0。
*/
test('★★ AppHeader 的 topInsetPx 不得硬编码 0(真机:顶栏被系统状态栏压住)', () => {
const pageDir = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages');
const files = readdirSync(pageDir).filter(f => f.endsWith('.ets'));
// 自检:这条必须真的扫到东西,否则改名后它会变成"永远绿"的空判据。
let seenAppHeader = 0;
const bad = [];
for (const f of files) {
// ★ 必须用 `stripped` 而不是 `prose`:判据要锚在**代码**上,
// 不是锚在代码对自己的描述上(见本文件 `stripped` 的注释)。
// 我第一版用了 `prose`,结果把**我刚写进注释里的**「原先 topInsetPx: 0」
// 抓成了红 —— 判据在判自己的注释。(同一类错:注释里那个串不是代码。)
const src = stripped(prose(join(pageDir, f)));
// 抓所有 `topInsetPx: <表达式>`,逐个看表达式是不是字面量 0。
const re = /topInsetPx:\s*([^,\n}]+)/g;
let m;
while ((m = re.exec(src)) !== null) {
seenAppHeader++;
const expr = m[1].trim();
if (/^0$/.test(expr)) {
bad.push(`${f}: topInsetPx: 0 —— 顶栏不避让,会被系统状态栏压住`);
}
}
}
assert.ok(seenAppHeader >= 4,
`自检:要扫到至少 4 处 topInsetPx(实际 ${seenAppHeader} 处)——` +
'扫不到说明遍历写错了,这条会变成永远绿的空判据');
assert.deepEqual(bad, [],
'这些页把 AppHeader 的状态栏避让硬编码成 0:\n · ' + bad.join('\n · ') +
'\n\n真机实测(MatePad Pro,API 26):EntryAbility 已读到 insets: statusBar=38.588235,' +
'\n但 MainPage 的 AppHeader 收到的是 0 ⇒ 顶栏「收件箱/发件箱/授权」压进状态栏。' +
'\n修法:传 topInset(this.windowInsets)(见 AdminUsersPage 的写法)。');
});