Files
MailUI4Agents/client/electron/test/harmony-widescreen.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

157 lines
9.0 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.

// 宽屏侧栏图标轨(WideSidebar)判据 —— 一比一复刻 WebUI 的 `Sidebar`(60px 图标轨)。
//
// WebUI 的 `Sidebar`(components/Sidebar.tsx)是 60px 宽的图标列:
// 品牌标(顶) / 通信·日历·联系人(中) / 设置(底)
// 选中态 = 图标+文字变色 + 品牌浅底,**不加背景块/指示条**。
//
// 鸿蒙侧原来是**没有**宽屏布局的(只有底部导航条),WideSidebar 补的就是
// "宽屏模式"这半:≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
// 这条判据钉的是 Sidebar 自己的形状:宽 60vp、三项导航 + 设置、选中态只换颜色。
import { code, prose, stripComments } from './lib/read.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const read = p => prose(join(HARMONY_ETS, p));
/** 取某个成员/方法正文(按行切到下一个成员声明) */
function memberBody(src, signature) {
const lines = src.split('\n');
const start = lines.findIndex(l => l.includes(signature));
assert.ok(start > 0, `要能找到 ${signature}`);
let stop = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^ (@Builder|build\(|NavItemBuilder\()/.test(lines[i])) { stop = i; break; }
}
return lines.slice(start, stop).join('\n');
}
test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字)', () => {
const sidebar = read('pages/WideSidebar.ets');
const code_ = stripComments(sidebar);
assert.match(code_, /export const SIDEBAR_WIDTH: number = 60\s*;/, '要导出 60 这个常量');
const main = read('pages/MainPage.ets');
assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar');
});
test('② 三项导航 + 设置按钮:内容与 WebUI Sidebar 对齐', () => {
const sidebar = read('pages/WideSidebar.ets');
// NAV_CONTENT_ITEMS 遍历(**只内容窗格** —— 设置已单列在下面;若遍历 NAV_ITEMS
// 会因第 4 项「我的」出现两个 person 图标)
assert.match(sidebar, /ForEach\(NAV_CONTENT_ITEMS,/, '要遍历内容窗格清单(与底部导航前三项同源)');
// 品牌标 + 三项导航 + 设置(与 WebUI:brand / nav / settings 的分组一致)
assert.match(sidebar, /品牌标/, '要有品牌标(注释里的分组依据)');
assert.match(sidebar, /iconName: 'person'/, '设置按钮要用 person 图标(WebUI 侧沿用的同一枚)');
assert.match(sidebar, /onSettings/, '设置按钮要回调 onSettings(点击后由 MainPage 路由)');
});
test('③ 选中态只换颜色(与底部导航同一套纪律):纯图标、无文字', () => {
const sidebar = read('pages/WideSidebar.ets');
const code_ = stripComments(sidebar);
/*
* 2026-09-16 用户:「底部导航栏不允许有文字」⇒ 侧栏同样走纯图标。
* WebUI 的 `Sidebar.tsx` 本就是 60px 纯图标轨(无 label 文字)。
*/
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
const hits = code_.match(active) || [];
assert.equal(hits.length, 1,
`侧栏导航项只应有 1 处选中三元式(现在 ${hits.length} 处)— 纯图标,无文字`);
// 无文字(label 的 Text)
assert.ok(!/Text\(label\)/.test(code_),
'侧栏是纯图标轨:不允许有 label 文字(WebUI Sidebar 无文字)');
// 图标不能太小(26vp)
assert.match(code_, /iconSize: 26/,
'侧栏导航图标要 26vp(WebUI 是 24,鸿蒙放大一档 —— 用户嫌 22 太小)');
// 不加背景块 / 指示条
assert.ok(!/currentIndex[^{;]{0,40}backgroundColor/.test(code_),
'选中态不许改背景色(与底部导航同一纪律:变色即可)');
});
test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768', () => {
const main = read('pages/MainPage.ets');
const code_ = stripComments(main);
// 宽屏分支:isWide 时才渲染 WideSidebar
assert.match(main, /if \(this\.isWide\) \{/, '侧栏要包在 isWide 分支里');
assert.match(code_, /WideSidebar\(\{/, '侧栏要真挂在 build 里(剥注释后仍存在)');
// 窄屏 x 宽屏:底部条只要窄屏有 —— 两半都是条件式的,不是同时出现
assert.match(main, /if \(!this\.isWide\) \{/, '底部导航条要包在 !isWide 分支里');
const both = /if \(this\.isWide\) \{[\s\S]*?if \(!this\.isWide\)/.test(main);
assert.ok(both, 'isWide 与 !isWide 两分支要在同一个结构里(互斥)');
// 断点检测
assert.match(code_, /onAreaChange/, '要用 onAreaChange 实时检测窗口宽度');
assert.match(code_, />=\s*768/s, '断点是 768(与 WebUI 的 lg 断点同一档)');
// let为0(宽屏没有底部条,内容 padding 归零)
/*
* ★ 2026-09-17:让位方式改了 —— 宽屏不再靠"内容 padding 归零",
* 而是宽屏时 navReserve 本身变成 0(没有底部条)。
* 内容窗格必须**满高**,否则内容滑不到条底下、玻璃就没东西可糊。
*
* ★ 2026-09-18:窄屏那一支多加了 `windowInsets.navIndicator`(全屏后要让开系统
* 手势条,见 `harmony-window.test.mjs`)。所以断言改为:**宽屏一定是 0**,
* 窄屏是 `NAV_CONTENT_RESERVE` **加上避让**(不是写死那个字面表达式)。
* 写死的话,"加一个正当的避让"与"宽屏忘了归零"会红得一模一样 ——
* 而这条判据要守的是后者。
*/
const reserve = /this\.navReserve = this\.isWide \? 0 : ([^;]+);/.exec(code_);
assert.ok(reserve, '要按 `isWide ? 0 : <窄屏值>` 的开关式写 navReserve');
const narrowExpr = reserve[1].trim();
assert.notEqual(narrowExpr, '0', '窄屏不能归零(窄屏有底部条,列表要让位)');
assert.match(narrowExpr, /NAV_CONTENT_RESERVE/,
'窄屏要让条高那么大的位(至少含 NAV_CONTENT_RESERVE)');
});
test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row 分栏', () => {
const main = read('pages/MainPage.ets');
const code_ = stripComments(main);
/*
* phone / tablet / 2in1 的列表→详情行为交给系统:
* - 窄宽度自动 Stack(详情覆盖列表,可返回);
* - 宽窗口自动 Split(列表 + 详情并排)。
* 断点由 navBarWidthRange + minContentWidth 共同决定,不复制一套 onAreaChange。
*/
assert.match(code_, /private navPathStack: NavPathStack = new NavPathStack\(\)/,
'通信页必须持有 NavPathStack');
assert.match(code_, /Navigation\(this\.navPathStack\)/,
'通信页列表/详情必须由原生 Navigation 承载');
assert.match(code_, /\.navDestination\(this\.DestinationBuilder\)/,
'Navigation 必须注册邮件详情目标页 builder');
assert.match(code_, /\.mode\(NavigationMode\.Auto\)/,
'必须用 NavigationMode.Auto,让系统自动选择 Stack / Split');
assert.match(code_, /\.navBarWidth\(320\)/,
'宽屏列表栏应与 WebUI MailList 的 320px 同档');
assert.match(code_, /\.navBarWidthRange\(\[280, 360\]\)/,
'列表栏应允许系统在 280–360vp 内适配,而不是固定死布局');
assert.match(code_, /\.minContentWidth\(360\)/,
'详情栏最小宽度参与 Auto 断点计算');
assert.match(code_, /this\.navPathStack\.pushPath\(\{ name: MAIL_DETAIL_ROUTE, param: params \}\)/,
'选中邮件必须进入 NavPathStack,而不是绕回 router.pushUrl');
});
test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比一复刻的骨架)', () => {
const main = read('pages/MainPage.ets');
const code_ = stripComments(main);
/*
* WebUI 的 `.app-shell`(index.css):
* padding: var(--pane-gap) → 10px
* gap: var(--pane-gap) → 10px
* > * { border-radius: var(--radius-card) } → 14px
* 宽屏面板之间留缝(壁纸从缝隙露出)、各面板圆角 14。窄屏贴合全屏(无 padding / 无圆角)。
*/
// 令牌值要和 WebUI 同值(14/10)
const theme = read('common/Theme.ets');
assert.match(theme, /static readonly glassRadius: number = 14/, 'glassRadius = 14(与 WebUI --radius-card 同值)');
assert.match(theme, /static readonly paneGap: number = 10/, 'paneGap = 10(与 WebUI --pane-gap 同值)');
// Row 用 space=paneGap(gap)
assert.match(code_, /Row\(\{ space: Theme\.paneGap \}\)/, '宽屏 Row 用 space=paneGap(与 WebUI gap 同值)');
// 内容面板圆角只在宽屏启用(窄屏贴合全屏)
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/, '内容面板圆角宽屏 14 / 窄屏 0');
assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)');
// 容器 padding 宽屏 paneGap / 窄屏 0(壁纸从缝隙露出)
assert.match(code_, /left: this\.isWide \? Theme\.paneGap : 0/, '宽屏容器左右 padding paneGap(窄屏 0 贴合全屏)');
});