Files
MailUI4Agents/client/electron/test/harmony-window.test.mjs
JianFeeeee cac026e9e2 跨端: 上下黑边真的消了 —— 全屏 + 避让是"同一套东西的两半",上次只删了一半
用户第三次报同一条:「你再看看页面底部,那么大的黑色,你看从头到尾都没
修好,你能不能好好看看我给你的示例工程怎么处理上下黑边的」。

## 根因:上一次把"两半"当成了"一件事",删掉一半就以为修好了

`6861934` 的注释白纸黑字写着「★ **刻意不用** `setWindowLayoutFullScreen(true)`」,
理由是「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏
被时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)」。

那次实测**是真的**,结论**下错了**:被盖住不是"不该全屏",而是
**只做了全屏、没做避让**。示例工程里这两件事本来就是**同一套东西的两半**:

    common/.../util/WindowUtil.ets        → setWindowLayoutFullScreen(true)
                                          + getWindowAvoidArea(TYPE_SYSTEM /
                                            TYPE_NAVIGATION_INDICATOR)
    features/mine/.../view/MineView.ets:251 → .margin({ top: statusBarHeight + …,
                                                        bottom: naviIndicatorHeight })

只做前半 ⇒ 内容跑到状态栏底下没人让(那次退回的原因);
只做后半 ⇒ 黑边照旧(这三次报修的原因)。退回的代价是**黑边留了三天**。

## 实测(模拟器 1256x2760,四页一致)

    修前:顶部纯黑 136px、底部纯黑 60px + 手势条 20px
    修后:四页**纯黑段均为 0**;y=0..135 是壁纸(时钟浮在上面,正是示例工程的效果)
          y=2662+ 壁纸铺到底、底栏浮在手势区之上

## 改了什么

- `entryability/EntryAbility.ets`:拆出 `setupFullScreenWindow()`,
  在 `loadContent` **回调里**调(与示例工程同一位置 —— `px2vp` 要用 `getUIContext()`,
  那要有已加载内容才拿得到)。全屏 + 读两个避让区 + 订 `avoidAreaChange`。
  `setWindowSystemBarEnable(['status'])` 保留(状态栏要看得见),但**不再靠它**消黑边。
- `model/WindowInsets.ts`(新):纯逻辑 `insetsFromAvoidArea()`,不 import SDK ——
  判据才能在 node 里直接喂样本验换算。形参叫 `toVp` 而**不是** `px2vp`:
  后者是 SDK 已废弃的全局函数名,`harmony-system-api` 按名字扫,同名形参会误报。
- `pages/MainPage.ets`:`@StorageLink(KEY_WINDOW_INSETS)` 订阅;状态栏高度当
  **内容层**的 `padding-top`(**不是**根 Stack —— 壁纸必须铺到屏幕四边,根上加
  padding 会把壁纸一起缩进去,黑边只是换个地方出现);底栏与内容末尾让开手势条。
  reserve 收成**一个** `recomputeNavReserve()`,宽度变化与避让变化两个触发点共用
  (转屏只改避让不改宽度,各写一份迟早漏一个)。

## 判据:`test/harmony-window.test.mjs`(8 条)

钉的是"两半必须同时存在"——**只钉一半的话,"退回某一半"照样能全绿通过**,
而那次退回恰恰就是删了一半。纯逻辑 3 条(换算/取不到就是 0 不猜/键名是常量)
+ 接线 4 条(全屏在、避让在、布局真消费了值、纯逻辑模块不许 import SDK)
+ 设备行为 1 条(全屏没把应用搞成白屏)。

**变异自检两个方向都跑过**(这是本轮最该记的一步):
- 删掉 `setWindowLayoutFullScreen`(重演 6861934)⇒ 2 条红 ✓
- 删掉 `getWindowAvoidArea`(只全屏不让位)⇒ 1 条红 ✓

★ 第一版判据**锚错了**:正则直接扫全文,而注释里正好有 `setWindowLayoutFullScreen(true)`
  这个串 —— 把真正的调用删掉后判据**仍然全绿**。锚落在"代码对自己的描述"上了。
  加 `stripped()` 剥注释后,变异才咬得住。这条与仓里那条"判据的锚不能落在
  被守对象的自述上"是同一件事,这次是它的实例。

## 顺带修的两条既有判据(不是放宽,是它们把"当时的字符串"当成了"要守的坑")

- `harmony-nav` ④:留白断言写死了 `bottom: NAV_BAR_BOTTOM` 那个字面串。
  它本来要守的是"留白靠 padding 不靠 margin"(margin 在 ArkUI 里加在宽度外面,
  100% + margin 会顶出父容器)—— 那是另一件事。改成剥注释后验三段在不在、
  离底是否**从** `NAV_BAR_BOTTOM` **起**。变异(padding→margin)仍判红 ✓
- `harmony-widescreen` ④:同上,写死了 `? 0 : NAV_CONTENT_RESERVE`。
  改成"宽屏必为 0、窄屏含 NAV_CONTENT_RESERVE(可再加避让)"。

两处都是"加一个正当的避让"与"真犯那个错"会红得一模一样 —— 那就不再是守坑,
是守字符串。

另:`align-refs` / `build-stamp` / `packaging` 三条 broken 是前端 `99a2d7a`
(另一个人改的 `CalendarView.tsx`)带来的,与本轮无关,留给他。
2026-09-18 09:47:22 +08:00

194 lines
11 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 { 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 依赖),否则判据连编都编不过');
});
/* ───────────────────────── ③ 设备行为(有设备才跑)───────────────────────── */
/*
* 上面四条都是**静电扫源码** —— 它们证不了"屏幕上真的没有黑边"。
* 真正的证据是设备上读数。没设备/前台不是我们 ⇒ **跳过**(不是绿、不是红),
* 与本仓 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());
});