Files
MailUI4Agents/client/electron/test/harmony-window.test.mjs
JianFeeeee a87a88ea2a 跨端: 全屏是**窗口级**的 —— 光给 MainPage 让位,等于把黑边换成顶栏压字
上一提交(cac026e)把黑边消掉了,但**只给 `MainPage` 加了避让**。
实测截图硬证:写邮件页的「取消」与时钟「09:49」重叠、「发送」与 wifi/电量图标重叠。

## 根因:`setWindowLayoutFullScreen(true)` 不只作用于当前页

它是**窗口级**的:一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页
(写邮件/会话/收件箱/邮件详情/用户管理)都从 y=0 开始画。

所以上次那个错与更早那次(6861934 只删全屏不留避让)**同源**:
都是"同一件事只做了一半"。上次少的是**步骤**,这次少的是**页面**。

## 改法:把"消费避让"变成每个 @Entry 页都得做的事

- `model/WindowInsets.ts` 加 `topInset(insets)`:取 0 时(未全屏/取不到)
  表达式的值与旧代码**逐字相同** ⇒ 没全屏的环境行为不变,不会把谁顶下去。
- 五个页各按自己的形状让位:
  - 固定 56vp 顶栏(写邮件/会话/收件箱/用户管理):
    `height(56 + topInset(...))` **与** `padding(… top: topInset(...))` 一起加 ——
    只加 padding 会把固定的 56 切掉 39(按钮压扁),只加 height 则内容仍贴 y=0。
  - 满高容器(邮件详情):`padding({ top })` 加在 `@Entry` 包装层。
    ★ **不能加在 `MailDetailView` 里面**:它同时被 `MainPage` 的 Navigation 内嵌复用,
      而那层已经让过位了 —— 加在里面就变成让两次(39vp 变 78vp)。
      这类"同一组件两种入口"的坑与"悬浮加号要放在 Navigation 内部"同源:
      **让位的量取决于它被挂在哪一层**。

## 判据(harmony-window 8 → 9 条)

接线⑤ 枚举**所有** `@Entry` 页并要求它们消费避让 —— 口径是
"有人在窗口上开了全屏 ⇒ 每个 @Entry 页都得让",而不是"检查 MainPage 做了没有"。
后者在新增一个推上来的页时会静默逃掉,而"新增一个页"正是最常发生的事。

豁免要带**可机器复核**的理由(不再是"这个页先不管"):
- `Index.ets`:DevEco 模板欢迎页,不在 `main_pages.json` 流程里;
- `LoginPage.ets`:根容器 `.align(Alignment.Center)` ⇒ 结构上碰不到 y=0。
  但"居中"是可能被改掉的性质 ⇒ 判据**断言那个居中写法仍然存在**,
  谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。

**变异自检两个方向都跑过**:
- 把 ComposePage 避让整个拿掉(重演"只给 MainPage 加")⇒ 红 ✓
- 只加 padding 不加 height(会压扁按钮)⇒ 红 ✓

★ 期间还修掉两处"判据锚在当时的字符串上"(不是放宽,是它把"加一个正当的避让"
  与"真犯那个错"判得一模一样):
  - `harmony-nav` ④ 的留白断言、`harmony-widescreen` ④ 的 navReserve 断言,
    都改成剥注释后验**形状与不变量**,而不是写死字面表达式。改完变异仍咬得住。
2026-09-18 10:34:00 +08:00

307 lines
17 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());
});