跨端: 全屏是**窗口级**的 —— 光给 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 断言,
    都改成剥注释后验**形状与不变量**,而不是写死字面表达式。改完变异仍咬得住。
This commit is contained in:
2026-09-18 10:34:00 +08:00
parent cac026e9e2
commit a87a88ea2a
8 changed files with 218 additions and 8 deletions

View File

@ -30,6 +30,7 @@
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';
@ -165,6 +166,118 @@ test('★ 接线④:`.ets` 不许 import `WindowInsets` 的 SDK 类型(判
'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)');
});
/* ───────────────────────── ③ 设备行为(有设备才跑)───────────────────────── */
/*

View File

@ -82,7 +82,7 @@ const SUITE = [
// `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);
// 只做后半 ⇒ 黑边照旧。**两半缺哪一半都要判红**,所以变异自检两个方向都跑过。
['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8],
['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 4],
['test/build-stamp.test.mjs', [], 7],