Files
MailUI4Agents/client/electron/test/harmony-2in1.test.mjs
dsh 7d08109583 fix(harmony): ★★ 顶部文案用 windowDecor 判 2in1 ⇒ 平板上浮在系统状态栏上(真机实测)
用户:「不是没显示,而是顶部与三键和状态栏重合,我觉得只应该在 2in1」。

## 根因:一个**假信号**

顶部那行文案(一言 + 签名,`TopbarStore`)的渲染条件写的是

    if (this.topbarTexts().length > 0 && this.windowInsets.windowDecor > 0)

本意"有装饰带才显示",在模拟器(真 2in1)上一直成立。**真机平板把它证伪了**:

    实测 insets: statusBar=38.588235 navIndicator=27.764706 windowDecor=37
    param get const.product.devicetype = tablet

`getWindowDecorHeight()` 在平板上**照样返回 37vp** —— 官方给的是"全屏悬浮态
固定 37vp"这个下限值,它衡量的是"系统浮层厚度",**不是"有没有三键区"**。
平板没有三键区,但 windowDecor 恒为 37 ⇒ 条件成立 ⇒ 文案渲染出来,
位置又按"三键在右边"算(`.height(this.windowInsets.windowDecor)`),
于是整行浮在**系统状态栏**上,与时钟/电量重叠。

## 改法

条件换成形态真值:

    if (this.topbarTexts().length > 0 && deviceInfo.deviceType === '2in1')

`deviceInfo` 取自 `@kit.BasicServicesKit`,与 `EntryAbility.ets:310` 判 2in1
用的是同一个来源 —— 形态判据全仓只此一处口径,不让两处各判一套。

`.height(this.windowInsets.windowDecor)` 保留不动:2in1 上它是对的,
非 2in1 上整块不渲染、根本走不到(`harmony-2in1` 那条仍钉着它)。

## 判据

`harmony-2in1.test.mjs` 新增一条:顶部文案必须挂在 `deviceType === '2in1'` 上,
且**不许再出现 `windowInsets.windowDecor` 与 0 的比较**(那是假信号)。
红绿已验:退回 `windowDecor > 0` ⇒ 红;恢复 ⇒ 绿。

★ 这条判据第一版也犯了同类错:用 `prose`(含注释)会把**我自己写进注释里的**
  旧写法 `windowDecor > 0` 判红。改用 `lib/read.mjs` 的 `code()`(读时剥注释)。
  与上一条(`harmony-window` 判据 10)同形——同一天犯两次,已成惯例性陷阱。

## 环境

`devecocli` 的 npm 包在本会话中途被卸载(`/usr/bin/devecocli` 成断链),
hvigor 直跑缺它注入的 SDK 环境 ⇒ 三个 SDK 路径都报 "SDK component missing"。
已 `npm i -g @deveco/deveco-cli` 装回(6s,252 包),`devecocli build` 恢复可用。

## 真机验证

装 `entry-default-signed.hap`(26.0.0 Beta2,debug 签名)后截图:
顶部只剩系统状态栏(18:34 / 浏览器 / 信号 / 电量),其下是干净的壁纸带,
再下方才是页签条「收件箱 20 / 发件箱 / 授权1」—— 重叠消失。

## 回归

`run-all.mjs`:files=35 checks=584 pass=580 fail=2 skip=2 red=4。
与本改动前的基线(checks=583 fail=2 red=3)比,**fail/red 未增加**:
那 2 个 fail 是设备判据并行争用(单独跑全绿),3 个 red 是「静态判据到期」
的既有债务(5 个文件在真机出现后被判到期,本轮未处理)。
2026-10-01 20:37:19 +08:00

828 lines
45 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.

/*
* 2in1(平板/PC 形态)键盘可达性判据 —— 用户 2026-09-21:
* · 「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
* · 「上下键切换发信目标」
* · 「回车展开输入框等」
*
* # 为什么单独一个文件
*
* 这三条属于**同一条能力线**(键盘可达),而它们各自跨了两个文件
* (根组件 + 条件挂载的子组件 / 纯逻辑 + 界面接线)。混进 `harmony-nav`
* (管悬浮玻璃导航条)或 `harmony-logic`(管纯函数)都会让那个文件的
* "这一期在钉什么"变得含糊。
*
* # 判据分寸:钉"接上了"和"边界对",不钉"好不好用"
*
* 键盘交互在模拟器上**没法端到端验**(没有真实键盘事件的注入通道,
* `uitest uiInput` 只有 click/longClick/swipe/fling,没有 key)。
* ⇒ 所以分两层:
* · **可执行层**:纯逻辑用真跑(`model/AddressSuggest.ts` 已被
* `cross-client-logic.test.mjs` 与 electron 逐例比对,这里不重复跑,
* 只钉"界面接线确实调了它");
* · **接线层**:钉"快捷键绑在根上、意图有接收方、键位不与系统冲突"。
*
* # 每个断言都要在**改坏时变红**(本仓纪律)
*
* 下面每条都写了"改什么会红"。写不出这句话的断言就是装饰。
*/
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { code, prose } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const MAIN = code(join(ETS, 'pages/MainPage.ets'));
const COMPOSE = code(join(ETS, 'pages/ComposePage.ets'));
const INTENT = code(join(ETS, 'common/ComposeIntent.ets'));
const SUGGEST_UI = code(join(ETS, 'model/AddressSuggest.ts'));
const PROSE_INTENT = prose(join(ETS, 'common/ComposeIntent.ets'));
const mainProse = prose(join(ETS, 'pages/MainPage.ets'));
const composeProse = prose(join(ETS, 'pages/ComposePage.ets'));
/** 取 `from` 处第一个 `{` 到配对 `}` 之间的正文(按花括号配对,不用窗口) */
function braceBody(src, from) {
const at = src.indexOf('{', src.indexOf(from));
assert.ok(at > 0, `要能找到 ${from} 后面的 {`);
let depth = 0;
for (let i = at; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); }
}
assert.fail(`${from} 的花括号没有闭合`);
}
/* ────────────────────────────────────────────────────────────────
* ① 打开发信页的快捷键
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|Ctrl+N 绑在**根**组件树上(不是某个会卸载的分支)', () => {
/*
* 官方 `keyboardShortcut` 文档:「即使组件未获焦或是在所在页面未展示,
* 只要已经挂载到**获焦窗口**的组件树上就会响应自定义组合键」。
* ⇒ 只有绑在窗口组件树的根上,"窗口在就生效"才成立。
*
* 改坏会红:把 `.keyboardShortcut(...)` 挪到 FAB 上(FAB 在 `if` 分支里、
* 窄屏/宽屏位置也不同)—— 换个 tab 就失效,而那时用户按 Ctrl+N 什么都没发生。
*/
const at = MAIN.indexOf(".keyboardShortcut('n'");
assert.ok(at > 0, 'MainPage 里要有 Ctrl+N 绑定');
const structAt = MAIN.lastIndexOf('struct MainPage', at);
const prevStruct = MAIN.lastIndexOf('\nstruct ', at);
assert.ok(structAt > 0, 'Ctrl+N 绑定的位置要在 MainPage 之前有 struct 声明');
assert.ok(
structAt > prevStruct,
`Ctrl+N 必须绑在 MainPage(根)里,而不是更早的 ${MAIN.slice(prevStruct + 1, prevStruct + 40).split('\n')[0]}`
);
});
test('2in1 快捷键|键位不与官方禁止绑定的系统组合冲突', () => {
/*
* 官方文档「禁止绑定的系统快捷键」列了五个:Alt+F4、Alt+Shift+F4、
* Alt+TAB、Alt+Shift+TAB、Ctrl+Shift+ESC。
*
* 这五个绑上去**不生效**(且没有报错)—— 表现为"按了没反应",
* 极容易被误判成代码接错了。
*
* 改坏会红:把 Ctrl+N 改成 Alt+Tab 之类。
*/
const at = MAIN.indexOf('.keyboardShortcut(');
assert.ok(at > 0);
const call = MAIN.slice(at, MAIN.indexOf(')', MAIN.indexOf('[', at)) + 1);
const banned = [
["Alt", "F4"], ["Alt", "TAB"], ["Ctrl", "Shift", "ESC"]
];
for (const combo of banned) {
const all = combo.every(k => call.includes(`ModifierKey.${k.toUpperCase()}`));
assert.ok(!all, `不得绑定被系统占用的 ${combo.join('+')}`);
}
});
test('2in1 快捷键|组合键用 ModifierKey 枚举,且热键是单字符', () => {
/*
* 官方约束(「快捷键使用注意事项」表):
* · 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」
* · 「value 有多个字符时**不绑定**组合键」(静默失败)
* · 「keys 有重复的控制键时**不绑定**」(静默失败)
*
* 改坏会红:写成 `.keyboardShortcut('open', ...)`(多字符)或
* `[ModifierKey.CTRL, ModifierKey.CTRL]`(重复)。
*/
const m = MAIN.match(/\.keyboardShortcut\(\s*'([^']*)'\s*,\s*\[([^\]]*)\]/);
assert.ok(m, '要能解析出 keyboardShortcut 的 value 与 keys');
const [, value, keysRaw] = m;
assert.equal(value.length, 1, `热键必须是单个字符,实际是 '${value}'`);
const keys = keysRaw.split(',').map(s => s.trim()).filter(Boolean);
assert.ok(keys.length > 0, 'keys 不能为空');
assert.equal(new Set(keys).size, keys.length, `keys 不得重复:${keysRaw}`);
for (const k of keys) {
assert.match(k, /^ModifierKey\.(CTRL|SHIFT|ALT)$/, `keys 只允许 ModifierKey.CTRL/SHIFT/ALT,实际 ${k}`);
}
});
test('2in1 快捷键|同一组合只允许绑一处(浅的赢,第二处等于失效)', () => {
/*
* 官方文档:「多个不同组件设置相同组合键 ⇒ 只响应节点树上的
* **深度最浅**的组件,其它组件不响应快捷键」。
*
* ⇒ 再给别处的"写信"按钮补一个 Ctrl+N,不是"多一个入口",
* 而是让后来那处**永远收不到**。这类"多写一份反而坏掉"的坑
* 必须由判据挡住 —— 它不会报错,只会静默失效。
*
* 改坏会红:在 ComposePage / 任何别处再加一个 `.keyboardShortcut('n', [CTRL])`。
*/
const hits = [];
for (const [name, src] of [['MainPage', MAIN], ['ComposePage', COMPOSE]]) {
const re = /keyboardShortcut\(\s*'n'\s*,\s*\[\s*ModifierKey\.CTRL\s*\]/g;
const n = (src.match(re) ?? []).length;
if (n > 0) hits.push(`${name}×${n}`);
}
assert.deepEqual(hits, ['MainPage×1'], `Ctrl+N 只能绑一处,实际:${hits.join(' ')}`);
});
/* ────────────────────────────────────────────────────────────────
* ② 意图的"两半"必须都在(缺一半 = 按键静默失效)
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|意图有"存住"与"当场交付"两半,且发起方先切到通信页', () => {
/*
* 根(MainPage)够不着 `openCompose()` —— 它住在**条件挂载**的 CommPage 上
* (`if (this.currentIndex === 0)`)。这正是 `PushService` 处理"点通知跳转"
* 时踩过的同一个坑,解法在它的注释里:**两半都要有**。
*
* · 只有"存住" ⇒ 用户此刻就在通信页、页面早挂载完了,
* `aboutToAppear` 不重跑 ⇒ **按了没反应**;
* · 只有"当场交付" ⇒ 用户此刻在日历页、CommPage 还没实例化、
* 没有监听者 ⇒ **同样没反应**。
*
* 改坏会红:删掉 `ComposeIntent.setListener(...)`(通信页内按无效);
* 删掉 `ComposeIntent.consume()`(跨页按无效);
* 删掉发起方的 `this.currentIndex = 0`(在日历页按了,意图存着却没人挂载它)。
*/
assert.match(INTENT, /static pending: boolean/, '要有"存住"的格子');
assert.match(INTENT, /static setListener\(/, '要有登记监听的入口');
assert.match(INTENT, /static consume\(\): boolean/, '要有取走待处理的入口');
/* 发起方:先切通信页,再提意图 */
const callAt = MAIN.indexOf('ComposeIntent.request()');
assert.ok(callAt > 0, '根上要提意图');
const before = MAIN.slice(Math.max(0, callAt - 400), callAt);
assert.match(
before,
/this\.currentIndex\s*=\s*0/,
'提意图之前必须先把 currentIndex 拨到 0(否则 CommPage 可能还没挂载)'
);
/* 接手方:两半都接上 */
const commAt = MAIN.indexOf('struct CommPage');
const commEnd = MAIN.indexOf('\nstruct ', commAt + 1);
const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length);
assert.match(commBody, /ComposeIntent\.setListener\(/, 'CommPage 要登记监听');
assert.match(commBody, /ComposeIntent\.consume\(\)/, 'CommPage 挂载后要取走积压的请求');
assert.match(commBody, /ComposeIntent\.clearListener\(\)/, 'CommPage 卸载时要摘掉监听');
});
test('2in1 快捷键|摘监听发生在 aboutToDisappear(否则唤醒已销毁组件)', () => {
/*
* `clearListener` 若不在 `aboutToDisappear` 里,用户切走之后
* 键盘事件仍会调到那个已卸载组件的 `openCompose()` ——
* 轻则不响应,重则对已释放的 `navPathStack` 推路由。
*
* 改坏会红:把 `clearListener()` 挪到 `aboutToAppear`,或整个删掉。
*/
const commAt = MAIN.indexOf('struct CommPage');
const commEnd = MAIN.indexOf('\nstruct ', commAt + 1);
const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length);
const disAt = commBody.indexOf('aboutToDisappear');
assert.ok(disAt > 0, 'CommPage 要有 aboutToDisappear');
const disBody = braceBody(commBody, 'aboutToDisappear');
assert.match(disBody, /ComposeIntent\.clearListener\(\)/, '摘监听要在 aboutToDisappear 里');
});
/* ────────────────────────────────────────────────────────────────
* ③ 收件人补全:上下键 / 回车 —— 接线用的必须是那份**已比对过**的纯逻辑
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|收件人键盘处理走纯逻辑(接循环下标,不自己写 %)', () => {
/*
* `nextActiveIndex` 里那个 `+ n) % n` 是负下标陷阱的解药
* (JS 的 `%` 对负数返回负数,`-1 % 5 === -1`;而负下标在数组访问里
* **不报错**,只表现为"按上键后没有任何一项高亮")。
* 该函数已被 `cross-client-logic.test.mjs` 与 electron 逐例比对。
*
* ⇒ 界面必须**调它**,不能就地写 `(i - 1) % n` —— 后者看起来等价、
* 实际在边界上会静默坏掉,而这样的坏不会让任何判据变红。
*
* 改坏会红:把 `nextActiveIndex(...)` 换回 `(this.suggestActive + 1) % len`。
*/
assert.match(COMPOSE, /nextActiveIndex\(/, '收件人键盘处理要调 nextActiveIndex');
assert.ok(
!/suggestActive\s*[-+]\s*1\)\s*%/.test(COMPOSE),
'不得自己就地写 %(负下标陷阱)'
);
assert.match(SUGGEST_UI, /\(\(current \+ delta\) % count \+ count\) % count/, '纯逻辑里才是那条公式');
});
test('2in1 快捷键|↑↓ 换候选、Enter/Tab 选中、Esc 收起 —— 四个键都在', () => {
/*
* 对齐 WebUI `AddressInput.tsx:143-155`:
* ArrowDown → active+1 ;ArrowUp → active-1
* Enter | Tab → 选中当前项 ;Escape → 收起
*
* 改坏会红:删掉任一个 `else if` 分支 —— 用户按那个键就毫无反应。
*/
const at = COMPOSE.indexOf('onToKey');
assert.ok(at > 0, '要有 onToKey');
const body = braceBody(COMPOSE, 'private onToKey');
assert.match(body, /KEYCODE_DPAD_DOWN/, '↓ 要有');
assert.match(body, /KEYCODE_DPAD_UP/, '↑ 要有');
assert.match(body, /KEYCODE_ENTER/, 'Enter 要有');
assert.match(body, /KEYCODE_TAB/, 'Tab 要有');
assert.match(body, /KEYCODE_ESCAPE/, 'Esc 要有');
});
test('2in1 快捷键|只响应 KeyType.Down(Down/Up 都处理会让一次按键走两步)', () => {
/*
* `KeyEvent` 对一次按键会派发 Down 与 Up 两个事件。两个都处理 ⇒
* 按一次 ↓ 下标移动 **2** 格,用户看到的是"跳着走"。
*
* 改坏会红:删掉 `if (e.type !== KeyType.Down) return;`。
*/
const body = braceBody(COMPOSE, 'private onToKey');
assert.match(body, /e\.type\s*!==\s*KeyType\.Down/, '要先挡掉非 Down 的按键事件');
});
test('2in1 快捷键|地址候选列表内联渲染,不用 bindPopup/bindMenu', () => {
/*
* 这两者各有自己的焦点体系 —— 用户的按键会先被它们吃掉,
* "↑↓ 切换候选"就落不到 `onToKey` 上(菜单收不到、输入框也收不到)。
*
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
*
* ★★ 2026-09-21 改:原来这两条断言扫的是**整个文件**
* (`!/\.bindMenu\(/.test(COMPOSE)`)—— 那把**不属于候选列表**的菜单
* 也一并禁了。我在账号选择器上用了官方 `bindMenu`(那是正确的:
* 它是个"点开选一个"的菜单,没有 ↑↓ 候选导航),结果被判据误报。
*
* 两者性质完全不同:
* · 地址候选(`suggestOpen`):**要 ↑↓/Enter 导航** ⇒ 必须内联,
* 这是无障碍与键盘可达的硬要求;
* · 账号选择器(`showAccountPicker`):**没有键盘导航**,就是个下拉
* ⇒ 官方 `bindMenu` 正合适(还自带点外部关闭/边缘避让)。
*
* ⇒ 断言改为**只看候选那一段**(从 `suggestOpen` 条件挂载处取到下一段结束)。
* 这样它仍然拦得住"把候选改成 bindMenu"(真正要防的那件事)。
*/
const suggestAt = COMPOSE.indexOf('if (this.suggestOpen && this.suggestItems.length > 0)');
assert.ok(suggestAt > 0, '要能找到地址候选的挂载点(它必须内联渲染)');
/*
* ★ 窗口要**从候选的开头往前多取一段**,不能从 `if` 那个位置开始。
*
* 实测(变异验证时发现的):我第一版从 `suggestAt` 开始切,
* 而"把候选改成 `bindMenu`"最自然的写法是把 `.bindMenu(...)` 写在
* **那个 `if` 之前**(作为一个链式修饰符)—— 于是它落在窗口之外,
* 变异**测不出来**(实测确实绿)。
*
* 判据自己对变异不敏感 = 它没在守卫那件事。
* ⇒ 往前取 600 字符(够包住同一节点上的修饰符链),往后到该块结束。
*/
const from = Math.max(0, suggestAt - 600);
const tail = COMPOSE.slice(suggestAt);
const cut = tail.indexOf('Divider()');
const suggestBlock = COMPOSE.slice(from, suggestAt + (cut > 0 ? cut : 3000));
assert.ok(!/\.bindPopup\(/.test(suggestBlock), '地址候选不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(suggestBlock), '地址候选不得用 bindMenu(会吃掉按键)');
assert.match(COMPOSE, /if \(this\.suggestOpen && this\.suggestItems\.length > 0\)/, '候选要在布局里内联挂载');
});
test('2in1 快捷键|候选标题读 title 时守住 omitempty 缺键', () => {
/*
* `SessionCandidate.Title` 带 `json:"title,omitempty"` ⇒ **整个键可能不存在**。
* ArkTS 的裸 cast(`JSON.parse(raw) as T`)在缺键时给 `undefined`,
* **不会**应用类里那个 `= ''` 默认值。
*
* 本仓已踩过同一个坑的另一个实例:`MailDetail.normalize()` 之前直接读
* `m.to.trim()`,服务端 omit 时就 `Cannot read property trim of undefined`
* ⇒ **整页白屏**。
*
* ★★ 2026-09-21 改:守卫从那三个字段的**内联读取**搬进了
* `model/AddressSuggest.ts` 的 `normalizeCandidates`
* (因为转发条、日历事件也要同一套,内联三份必然漂移)。
*
* 本判据原来钉的是**内联那行的字面文本**
* (`/typeof raw === 'string' ? raw : ''/`)——
* 那是判据的**实现细节**,不是它的**意图**。搬了家就假红。
*
* ⇒ 改钉两件真的事:
* ① 守卫本身在(三个字段各一道);
* ② **调用点真的走那道守卫**(而不是绕回去自己读 `.title`)。
* 只钉①会被"守卫在库里、调用点旁路"绕过;
* 只钉②会被"调用了却传未守卫的原始值"绕过。两件都要。
*/
const MODEL = code(join(ETS, 'model/AddressSuggest.ts'));
assert.match(MODEL, /typeof t === 'string' \? t : ''/, 'title 缺键要守成空串');
assert.match(MODEL, /typeof s === 'string' \? s : ''/, 'source 缺键要守成空串');
assert.match(MODEL, /typeof u === 'number' \? u : 0/, 'unread 缺键要守成 0');
/* 调用点必须经过 `normalizeCandidates`,不得自己读 `c.title` 当字符串用 */
assert.match(COMPOSE, /normalizeCandidates\(/, '写信页要经共享归一化,不得内联重写一份守卫');
assert.ok(
!/\.suggestTitles\s*=\s*[^;]*\.title\b/.test(COMPOSE),
'不得把候选的 title 直接赋给 suggestTitles(绕过守卫)'
);
/* 而且模型里的字段名要跟服务端一致(服务端给的是 alias/title/source) */
const M = code(join(ETS, 'model/Models.ets'));
const cls = M.slice(M.indexOf('export class AddressSuggestion'));
assert.match(cls, /alias: string/, 'AddressSuggestion 要有 alias(服务端字段名)');
assert.ok(!/value: string/.test(cls.slice(0, 600)), '不得保留服务端从不返回的 value 字段');
});
/* ────────────────────────────────────────────────────────────────
* ④ 文档化:为什么用 keyboardShortcut 而不是 onKeyEvent
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|选 keyboardShortcut 的理由写在源码里(否则后人会"顺手改成 onKeyEvent")', () => {
/*
* 这条判据钉的不是代码,是**理由**。
*
* `onKeyEvent` 看着更"底层可控",但官方文档写明:它要求**组件获焦**才触发
* (「按键事件是指组件与键盘、遥控器等按键设备交互时触发的事件,
* 适用于所有可获焦组件」)。而邮件列表里焦点落在哪是不确定的(点一下就换),
* 用 onKeyEvent 做全局快捷键会时灵时不灵。
*
* 而 `keyboardShortcut` 是「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」。
*
* 改坏会红:把这段理由删了(后人看不到就会"顺手改成 onKeyEvent")。
*/
assert.match(mainProse, /keyboardShortcut/, '注释里要写明用的是 keyboardShortcut');
assert.match(mainProse, /未获焦|获焦窗口/, '要写明"不依赖焦点"这条理由');
assert.match(PROSE_INTENT, /两半|当初|坑/, 'ComposeIntent 要留下"为什么需要中间层"的由来');
});
/* ────────────────────────────────────────────────────────────────
* ⑤ 键盘可达的**其余三个页面**(2026-09-24 用户逐条点名)
*
* 用户原话:「还有快捷键,比如主页回车打开写信。邮件详情页回车打开回复。
* esc返回上一级」+「你在登陆页是不是没有做 enter 等键的监听」。
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|三个页面都用同一套键判定(不许各写一遍 KeyCode 比较)', () => {
/*
* 钉的是**单一出处**这条纪律。
*
* 三处(主页 / 详情 / 登录)都要判"是不是回车、是不是按下"。
* 各写一遍 `e.keyCode === KeyCode.KEYCODE_ENTER` 的后果早就见过:
* 本仓 `SentRow` 与 `MailRow` 那对就是"同一件事两处各写一遍、然后慢慢分叉"。
* 所以规则集中在 `model/KeyboardShortcuts.ts`,三个页面只 import。
*
* 改坏会红:任一页面改成自己比较 KeyCode(而不是走 isEnterKey)。
*/
const LOGIN = code(join(ETS, 'pages/LoginPage.ets'));
const DETAIL = code(join(ETS, 'pages/MailDetailPage.ets'));
const SHORTCUTS = code(join(ETS, 'model/KeyboardShortcuts.ts'));
assert.match(SHORTCUTS, /export function isEnterKey/, '键判定要有一个集中出处');
assert.match(SHORTCUTS, /export function isKeyDown/, '按下/抬起的判定同样要集中');
for (const [name, src] of [['主页', MAIN], ['详情页', DETAIL], ['登录页', LOGIN]]) {
assert.match(src, /from '\.\.\/model\/KeyboardShortcuts'/,
`${name} 要从 KeyboardShortcuts 引键判定(不是自己写 KeyCode 比较)`);
}
/*
* 页面级的键处理不许自己比较 ENTER —— 那是分叉的起点。
*
* ★ 只查**页面级 onKeyEvent 那一处**,不查全文件:
* 详情页里还有**输入框内的候选导航**(`onFwdKey`/`onToKey`,
* 与 ComposePage 同款),那里的 `KEYCODE_ENTER` 是"选中候选",
* 与"页面级回车=打开回复"是**两件事**,必须分开判。
* (第一版我查了全文件、判红了自己 —— 判据比事实严格也是错的。)
*/
for (const [name, src] of [['主页', MAIN], ['详情页', DETAIL], ['登录页', LOGIN]]) {
const at = src.indexOf('.onKeyEvent((e: KeyEvent)');
assert.ok(at > 0, `${name} 要有页面级 onKeyEvent`);
/* 取到该回调结束(下一个 `})` 收口)—— 页面级处理的范围 */
const body = src.slice(at, src.indexOf('})', at) + 2);
assert.ok(!/KEYCODE_ENTER/.test(body),
`${name} 的页面级键处理不许自己比较 KEYCODE_ENTER(要经 isEnterKey)`);
}
});
test('2in1 快捷键|登录页要有回车提交(WebUI 靠 <form> 天然有,我们漏了)', () => {
/*
* ── 这条钉的是一个**真实漏掉的差异** ──
* WebUI 登录页是 `<form onSubmit={submit}>`(`LoginPage.tsx:92`)——
* 浏览器里在输入框按回车就会提交表单。
* 而鸿蒙登录页**一行键盘监听都没有**(2026-09-24 用户点出来的)。
*
* 改坏会红:删掉登录页的 `.onKeyEvent`。
*/
const LOGIN = code(join(ETS, 'pages/LoginPage.ets'));
assert.match(LOGIN, /\.onKeyEvent\(/, '登录页要挂键盘监听(回车直接登录)');
assert.match(LOGIN, /this\.doLogin\(\)/,
'回车要走**已有的** doLogin(不另写一条登录路 —— 那会与按钮的条件分叉)');
assert.match(LOGIN, /isEnterKey/, '用集中的键判定,不自己比 KeyCode');
});
test('2in1 快捷键|详情页 Enter=回复 / Esc=返回,且弹层开着时 Esc 先关弹层', () => {
/*
* 用户:「邮件详情页回车打开回复。esc返回上一级」。
*
* ★ Esc 那条有个容易漏的分寸:回复条/转发条/对话树开着时,
* Esc 应当**先关弹层**,再按一次才返回 ——
* 否则用户想关掉回复框,结果被直接踢回列表,输入到一半的内容全没了。
*
* 改坏会红:把 showReplyBox/showForwardBox/showThread 那几道先关弹层的门删掉。
*/
const DETAIL = code(join(ETS, 'pages/MailDetailPage.ets'));
assert.match(DETAIL, /\.onKeyEvent\(/, '详情页要挂键盘监听');
assert.match(DETAIL, /isEscapeKey/, '要用 Esc 判定');
assert.match(DETAIL, /isEnterKey/, '要用回车判定');
assert.match(DETAIL, /openReplyWithMorph/,
'回车要走**已有的** openReplyWithMorph(与回复球同一个入口,连动画都不另开一条)');
/*
* 先关弹层:三个 @State 都要在 Esc 分支里出现。
*
* ★ 切片起点要取**页面级 onKeyEvent 内部**那个 `isEscapeKey`
* (DETAIL 里 `isEscapeKey` 先在 import 行出现 —— 从那里切会取到注释,
* 第一版就是这么判红的)。
*/
const evAt = DETAIL.indexOf('.onKeyEvent((e: KeyEvent)');
assert.ok(evAt > 0, '要有页面级 onKeyEvent');
const escAt = DETAIL.indexOf('isEscapeKey', evAt);
assert.ok(escAt > 0, '要有 Esc 判定');
const escBody = DETAIL.slice(escAt, escAt + 900);
for (const state of ['showThread', 'showReplyBox', 'showForwardBox']) {
assert.match(escBody, new RegExp(state),
`Esc 分支要先关 ${state}(弹层开着时 Esc 先关它,再按才返回)`);
}
});
test('2in1|2in1/PC 上窗口装饰必须隐掉(否则标题栏下多一条"假顶栏"=不沉浸)', () => {
/*
* ── 用户报的问题 ──
* 「app 顶栏为什么不沉浸」。实测(2in1 3120×2080 截图硬证):
* 窗口标题栏(`AgentMailHarmony`)下面**还有一条白条**,内容从第二条下面才开始。
* 对比同一台机器上的系统通知面板:标题栏与内容融为一体。
*
* ── 根因 ──
* 从未调过装饰接口 ⇒ 用系统默认(PC 形态带标题栏)。
* `setWindowLayoutFullScreen(true)` 管的是"内容铺到**屏幕**四边",
* **不包含"窗口自己的标题栏是否隐藏"** —— 两件不同的事。
*
* 改坏会红:删掉 `setWindowDecorVisible(false)`。
*/
const ABILITY = code(join(ETS, 'entryability/EntryAbility.ets'));
/*
* ★ 匹配**调用形态** `win.setWindowDecorVisible(false)`,不是裸方法名。
* 第一版写的是 `/setWindowDecorVisible\(false\)/` —— 而紧邻的两行日志
* ('setWindowDecorVisible(false) ok')也含这个串 ⇒ 删掉真正的调用后
* 判据照样绿(变异实测:没红)。这正是"判据在骗自己"的形状:
* 它匹配到的是**关于这件事的文字**,不是**这件事**。
*/
assert.match(ABILITY, /win\.setWindowDecorVisible\(false\)/,
'要显式隐掉窗口装饰(不调就是系统默认带标题栏)');
assert.match(ABILITY, /win\.setWindowLayoutFullScreen\(true\)/,
'两条是**互补**的:这条管屏幕四边、装饰那条管窗口标题栏,删任一条都会露馅');
});
/* ────────────────────────────────────────────────────────────────
* ⑥ 右键菜单(2026-09-24 用户选「右键菜单」)
*
* ★ 这不是对齐项:WebUI **没有**右键菜单(`grep onContextMenu` 全仓为空)——
* 所以它是 2in1/PC 的新增桌面能力。写明,免得日后被当成缺失项去"补"。
* ──────────────────────────────────────────────────────────────── */
test('2in1|邮件行要有右键菜单,且用系统的 RightClick(不是自己接鼠标事件)', () => {
/*
* 钉两条:
* ① 菜单挂上了(`bindContextMenu`)—— 删掉它菜单就没了;
* ② 类型是 `ResponseType.RightClick` —— 官方枚举只有
* `RightClick` / `LongPress` 两项,用错就变成"长按出菜单",
* 而长按在触屏上是另一套语义(且左键单击已被"打开邮件"占用)。
*
* 改坏会红:删 bindContextMenu、或把 RightClick 换成 LongPress。
*/
assert.match(MAIN, /\.bindContextMenu\(/,
'邮件行要挂右键菜单(bindContextMenu)');
assert.match(MAIN, /ResponseType\.RightClick/,
'必须是鼠标右键(RightClick)—— LongPress 是触屏长按,语义不同');
});
test('2in1|右键菜单项只用**列表层能当场完成**的动作(不放回复/转发)', () => {
/*
* ★ 这条钉的是一条**判断**,不是实现细节:
* 菜单项应当"点了当场就有结果"。
*
* 回复/转发需要详情页的表单(收件人、正文、附件)——
* 在列表行上做只能"先跳详情再操作",那还不如直接单击那一行。
* 凑一个"点了等于跳转"的菜单项比没有更坏(用户以为在这里能完成)。
*
* 所以菜单里只有三个**当场能完成**的:
* · 标记已读(`MailApi.markRead`)
* · 归档会话(`MailApi.archiveContact`)
* · 复制主题(`pasteboard`,纯本地)
*
* 改坏会红:往菜单里加"回复"/"转发"项。
*/
const at = MAIN.indexOf('MailContextMenu(mail: MailLike)');
assert.ok(at > 0, '菜单 Builder 要存在');
const body = MAIN.slice(at, MAIN.indexOf('@Builder', at + 10));
assert.match(body, /标记已读/, '要有「标记已读」(markRead 在列表层可独立完成)');
assert.match(body, /归档会话/, '要有「归档会话」(archiveContact 同上)');
assert.match(body, /复制主题/, '要有「复制主题」(纯本地)');
assert.ok(!/回复|转发/.test(body),
'菜单里**不放**回复/转发 —— 那两个要详情页的表单,在这里做只能"先跳详情",' +
'那不是菜单项该有的语义(点了当场就该有结果)');
});
test('2in1|右键菜单的破坏性动作(归档)必须先确认', () => {
/*
* 归档是**破坏性**的(Agent 侧会话归档 + 邮箱界面同时移除),
* 而右键菜单是"点一下就发生了"—— 误触代价太大。
* 联系人页那边的归档一直有确认(`ArchiveConfirm` 弹层 / `confirmArchive`),
* 列表行这条也必须有,否则两处对同一件事的分寸不一致。
*
* 改坏会红:把 showAlertDialog 那层确认删掉、直接发请求。
*/
const at = MAIN.indexOf('private archiveRow(mail: MailLike)');
assert.ok(at > 0, 'archiveRow 要存在');
const body = MAIN.slice(at, at + 1200);
assert.match(body, /showAlertDialog|AlertDialog/,
'归档要先弹确认(与联系人页同一分寸)');
/* 真正的请求在确认回调之后(另一个方法里)——确保不是"点了就发" */
assert.match(body, /doArchiveRow/, '确认的回调里才发请求');
});
/* ────────────────────────────────────────────────────────────────
* ⑦ 顶栏文案(摘要 / 一言 / 签名轮播)—— 2026-09-25
*
* 用户四条:
* · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
* · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
* · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
* · 「我要的效果是在退出,最大化,最小化三个按键的左边」
* ──────────────────────────────────────────────────────────────── */
test('2in1|顶栏文案在三键左边,且不破坏窗口沉浸', () => {
/*
* ★ 这条钉的是**两个约束同时成立**,因为它们会互相顶:
* 要"在三键左边"最省事的做法是官方 `setWindowTitle`(实测确实能显示),
* 但那个**必须保持窗口装饰可见** —— 于是标题栏那条浅色横带回来,
* 与用户先前要的「顶栏沉浸」直接冲突(那是刚修好的)。
*
* 所以正确解法是:**装饰仍隐藏**(沉浸保留)+ 文字自绘在装饰带原位。
* 判据必须同时看到这两件事,只看一件就会放过错误的那版。
*
* 改坏会红:
* · 去掉 setWindowDecorVisible(false) ⇒ 第一段红(沉浸丢了)
* · 去掉自绘的 position/padding ⇒ 第二段红(文案不在三键左边)
*/
const ABILITY = code(join(ETS, 'entryability/EntryAbility.ets'));
const MAIN = code(join(ETS, 'pages/MainPage.ets'));
/* ① 沉浸必须保留(装饰隐藏) */
assert.match(ABILITY, /win\.setWindowDecorVisible\(false\)/,
'窗口装饰要保持隐藏 —— 否则标题栏横带回来,与「顶栏沉浸」冲突');
/*
* ② 文案自绘,且在**左边**(与三键同一行、左右相称)。
*
* ★★ 这里**改过一次**:第一版我断言的是"贴住三键左缘"
* (右对齐 + TOPBAR_TRIPLE_BTN_WIDTH),用户纠正:
* 「我要求的是与三键同行,但是在左边啊」
* ⇒ 断言随之改成靠左。判据要跟事实走,不是给错版背书。
*/
assert.match(MAIN, /\.justifyContent\(FlexAlign\.Start\)/,
'文案要**靠左**(与右边三键同一行、左右相称)');
assert.match(MAIN, /\.position\(\{ x: 0, y: 0 \}\)/,
'文案要绝对定位到窗口顶部(整窗根 Stack 的最上层)');
assert.match(MAIN, /padding\(\{ left: TOPBAR_STRIP_LEFT \}\)/,
'左侧留出与窗口边缘的距离');
/*
* ★ 垂直对齐要**用装饰带自己的高度**,不许写死 y 偏移。
*
* 我第一版写 `.position({ y: 8 })`(凭感觉给的 8vp),
* 实测文案中心 309.5、三键中心 316.5 —— **差 7px**(用户看出「行没有对齐」)。
* 改成 `height(windowInsets.windowDecor)` + `VerticalAlign.Center` 后
* 实测中心差 **0.0px**。
*
* ⇒ 判据钉"高度取自 windowDecor"这条做法,而不是某个具体数值:
* 它同时保证"带高变了也不跑偏"。
*/
assert.match(MAIN, /\.height\(this\.windowInsets\.windowDecor\)/,
'垂直对齐要用装饰带自己的高度(写死 y 偏移会差几个 px —— 实测过)');
assert.match(MAIN, /\.alignItems\(VerticalAlign\.Center\)/,
'带内垂直居中');
});
test('2in1|顶栏文案必须是纯文字(不加底、不吃事件)', () => {
/*
* 用户原话:「同时注意,是纯文字不要加底」。
*
* ★ 为什么这条要单独钉:给它加个底是最容易的"看起来更清楚"的冲动,
* 而那条会在沉浸顶栏上再压一块色 —— 正是用户点名不要的。
* 同理不能加玻璃材质(那也会形成一块可见的面)。
*
* 改坏会红:给那个 Row 加 backgroundColor / backgroundBlurStyle。
*/
const MAIN = code(join(ETS, 'pages/MainPage.ets'));
/*
* ★ 切片锚点取**使用点**(`.position({ x: 0, y: TOPBAR_STRIP_VPAD })`),
* 不是那个常量的**名字**。
*
* 我第一版就用了名字 —— 而它在**文件顶部的常量区**先出现一次,
* 从那里往后切会一路包进 `InboxTab`(那里有 `backgroundColor`),
* 于是判据报"文案加了底色",报的是**别人的代码**。
* 本仓纪律:判据匹配到的东西必须就是它声称的那个。
*/
const at = MAIN.indexOf('.position({ x: 0, y: 0 })');
assert.ok(at > 0, '顶栏文案块要存在(找不到定位语句)');
/* 往前取到该块的起点、往后取到它的收口,覆盖整块 */
const start = Math.max(0, at - 1200);
const body = MAIN.slice(start, at + 900);
assert.ok(body.length > 0, '要能切出顶栏文案块');
assert.ok(!/\.backgroundColor\(/.test(body),
'顶栏文案不许加底色(用户:「是纯文字不要加底」)');
assert.ok(!/\.backgroundBlurStyle\(/.test(body),
'顶栏文案不许加玻璃材质(同样是"加了一层可见的面")');
assert.match(body, /hitTestBehavior\(HitTestMode\.None\)/,
'文案是装饰性的,不许吃掉底下的点击(三键就在它右边)');
});
test('2in1|顶栏摘要"零"时也要有文案(否则整块消失)', () => {
/*
* ★ 这条钉的是一个**实测撞出来的逻辑漏洞**。
*
* 我第一版写的是"三项计数都是 0 就返回空串"——想当然的"有信息才显示"。
* 实测:那封邮件已读 ⇒ 三项全 0 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**,
* 顶栏右上什么都没有。
*
* 而"没有未读"恰恰是**常态**(收件箱清干净了)。
*
* ★★ 文案改过两轮,判据跟着改(用户裁定):
* 第 0 版 空串 → 整块消失(顶栏右侧空着、与三键失衡)
* 第 1 版 「暂无待办」→ 补上了"零也是信息",但这句话**信息量为 0 且会误导**:
* 它读起来像"没事可做",实际只表示"三个计数是 0"。
* 第 2 版 「AgentMail」(品牌名)—— 不带任何判断。
*
* ⇒ 判据钉**两条**:① 兜底必须存在(别退回空串让整块消失);
* ② 兜底文案**不含判断词**("暂无/没有/无"开头都是在替用户下结论)。
*
* 改坏会红:把 `TOPBAR_FALLBACK` 改回 `''`,或改回 `'暂无待办'`。
*/
assert.match(MAIN, /const TOPBAR_FALLBACK: string = '[^']+';/,
'兜底文案要存在(退回空串会让整块消失、顶栏右侧空着)');
const fallback = MAIN.match(/const TOPBAR_FALLBACK: string = '([^']+)';/);
assert.ok(fallback, '要能取到兜底文案');
assert.doesNotMatch(fallback[1], /^暂无|^没有|^无/,
'兜底文案不许下判断("暂无X"是在替用户下结论,而触发条件只是计数为零)');
/*
* ③ 兜底**不占轮播位** —— 它只做"唯一候选"。
*
* 实测:四张连拍(3s 一张)得到 `一言 → 兜底 → 兜底 → 一言`,
* 轮播位有一半被废话吃掉(那时一言只有 2 条)。
* ⇒ 摘要有真计数才配占位;兜底只在**三类都空**时才作为唯一候选出现。
*
* 结构上是两半,都要钉住(少一半就退回旧行为):
* a) 摘要入口有 `summaryIsReal` 守卫(假摘要不进数组)
* b) 数组为空时才 push 兜底(不是无条件 push)
*
* 改坏会红:删掉任一半。
*/
assert.match(MAIN, /const summaryIsReal: boolean = this\.navUnread > 0 \|\| this\.navPending > 0 \|\| this\.navContacts > 0;/,
'摘要要有"是否为真计数"的判断');
assert.match(MAIN, /if \(summaryIsReal\) \{\s*\n\s*out\.push\(summary\);\s*\n\s*\}/,
'假摘要(兜底)不许进候选数组 —— 否则它会被轮播到、占掉一半时长');
assert.match(MAIN, /if \(out\.length === 0\) \{\s*\n\s*out\.push\(TOPBAR_FALLBACK\);\s*\n\s*\}/,
'兜底只在数组为空时作为唯一候选进入');
/*
* ④ 一言**只显示句子**,不带出处(用户裁定)。
*
* 原形状 `quote + ' —— ' + source` 的问题:
* 出处占近 1/3 宽度、把句子本身挤到省略号;
* 且 hitokoto 的出处格式杂(动漫角色/诗词/网名),窄带上排起来脏。
* ⇒ 与签名对齐:两者都是"当前状态的一句话",都不带出处。
*
* 判据钉**两半**:① 拼装里不含 `——` + source;
* ② `topQuoteSource` 这个状态整个不存在了(留着就是只写不读的死字段)。
*
* 改坏会红:把拼接改回去,或把 `topQuoteSource` 状态加回来。
*/
/*
* ★ 这里我第一版写漏了,记下来:
* 正则写成 `/this\.topQuote \+ ' —+ ' \+ this\.topQuoteSource/` ——
* **只匹配单引号**。而变异时我把拼接写成双引号 `" —— "`,
* 正则没命中 ⇒ 判据**假绿**(变异验证的意义就在这:它当场抓到了)。
*
* ⇒ 改成**正向断言**:必须存在"把 topQuote 原样推进数组"这一句。
* 正向比反向窄,且不依赖引号风格。
*/
assert.match(MAIN, /@State topQuotes: string\[\] = \[\];/,
'一言要存**整批**(单个字符串装不下一批,见下条)');
assert.match(MAIN, /this\.topQuotes\.push\(q\)|out\.push\(q\);/,
'一言要**逐句**进候选数组');
assert.doesNotMatch(MAIN, /this\.topQuote\b(?!s)/,
'不许再有单句形态的 topQuote(它是"只取第一句"那个 bug 的载体)');
assert.doesNotMatch(MAIN, /this\.topQuoteSource/,
'出处不许出现在代码里(只写不读的死字段也要删)');
/*
* ⑤ **整批都要存下来** —— 只取第一句会让轮播静默失效。
*
* 这是本轮最值钱的一条判据,因为那个 bug **不报错、不崩溃**:
* `this.topQuote = content.quotes[0].text` ⇒ 本地只有 1 句 ⇒
* `startTopbarRotation` 的 `texts.length <= 1` 守卫直接 return ⇒ 不轮播。
* 我连拍 6 张(18s)全是同一句才发现。
*
* ★ 更坏的是它是**假象**:先前看着"在转",是因为兜底文案占了位
* (一言 ↔ 暂无待办 交替);等兜底改成不占轮播位,转的其实是兜底。
* ⇒ 判据必须钉"服务端给的那一批**全都进**本地状态"。
*
* 改坏会红:把赋值改回 `quotes[0]`。
*/
assert.match(MAIN, /for \(let i = 0; i < content\.quotes\.length; i\+\+\)/,
'要遍历服务端返回的整批(只取 [0] 会让轮播静默失效)');
assert.match(MAIN, /this\.topQuotes = qs;/,
'整批赋给本地状态');
assert.doesNotMatch(MAIN, /@State topQuoteSource/,
'出处状态要删干净,不留只写不读的死字段');});
test('2in1|一言与签名要在客户端缓存(账号级),离线也能轮播', () => {
/*
* 用户:「同时 app 本地缓存一部分」。
*
* ★ 缓存键必须**账号级**(`kind + accountId`)—— 本仓纪律。
* 共用一份会让换个账号看到上一个人的签名;`user_appearance` 当初就是
* 全局键,多账号互相覆盖。
*
* ★ 还要验"拉失败不致命":装饰性内容不该成为失败点
* (服务端那半也有对称的一条判据)。
*
* 改坏会红:去掉 prefKey 的 accountId 拼接、或让 refresh 把异常抛出去。
*/
const STORE = code(join(ETS, 'common/TopbarStore.ets'));
assert.match(STORE, /private prefKey\(accountId: string\)/,
'缓存键要按账号拼(共用一份会让多账号串台)');
assert.match(STORE, /KEY_PREFIX \+ accountId/,
'键的实际拼法要含 accountId');
assert.match(STORE, /catch \(e\)/,
'拉取/缓存失败要吞掉 —— 顶栏是装饰性的,不该把异常抛给调用方');
assert.match(STORE, /'\/me\/topbar'/,
'要打服务端那个端点(/me/topbar)');
});
/*
* ★★ 2026-10-01 真机实测抓到的假判据(MatePad Pro / MRDI-W00,HarmonyOS NEXT API 26)。
*
* 顶部那行文案(一言 + 签名,`TopbarStore`)的渲染条件原来写的是
* `this.windowInsets.windowDecor > 0`
* 本意"有装饰带才显示",在模拟器(真 2in1)上一直成立。
*
* ★ 真机平板把它证伪了 —— 实测日志:
* `insets: statusBar=38.588235 navIndicator=27.764706 windowDecor=37`
* `param get const.product.devicetype` = **tablet**
*
* `getWindowDecorHeight()` 在平板上**照样返回 37vp**(官方给的是"全屏悬浮态
* 固定 37vp"这个下限值),但平板**根本没有三键区** ⇒ 条件成立、文案渲染,
* 位置却按"三键在右边"算,于是浮在**系统状态栏**上(用户:与时钟/电量重叠)。
*
* ⇒ `windowDecor > 0` 在平板上是个**恒为真的假信号**,不能当形态判据用。
* 形态的真值是 `deviceInfo.deviceType === '2in1'`。
*
* 本条钉的就是这个:**不许再用 windowDecor 的高度/正负当"是不是 2in1"。**
*/
test('★★ 形态判据必须用 deviceInfo.deviceType,不许拿 windowDecor 当 2in1 的信号', () => {
// ★ 用 `code()`(读时剥注释)而不是 `prose`:判据的锚要落在**代码**上,
// 不是落在代码对自己的描述上 —— 否则我写进注释里的旧写法会被自己判红。
const code_ = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets'));
// 那行文案必须挂在 2in1 判据上。
const guard = /if \(this\.topbarTexts\(\)\.length > 0 && deviceInfo\.deviceType === '2in1'\)/.test(code_);
assert.ok(guard,
'顶部文案必须只在 2in1 出现:`if (topbarTexts().length > 0 && deviceInfo.deviceType === \'2in1\')`\n' +
'真机实测(MatePad Pro,API 26):windowDecor=37(**非 0**)但 devicetype=tablet,\n' +
'旧条件 windowDecor > 0 在平板上恒成立 ⇒ 文案浮在系统状态栏上。');
// 反向:条件里不得再用 windowDecor 的正负/高度判形态。
const bad = /windowInsets\.windowDecor\s*(>|!=|===|!==|==)\s*0/.exec(code_);
assert.equal(bad, null,
`别再用 ${bad ? bad[0] : 'windowDecor 与 0 比较'} 判「是不是 2in1」——\n` +
'真机平板上它恒为 37(官方"全屏悬浮态固定值"),不代表有三键区。\n' +
'用 deviceInfo.deviceType === \'2in1\'(与 EntryAbility 同一来源)。');
});