/* * 窗口全屏 + 避让区(消上下黑边)—— 判据。 * * ── 这条为什么必须存在(它挡的是一个**已经发生过三次**的退回)── * * 用户报过一次同一条:「底部那个黑条是啥意思」(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()); });