用户:「不是没显示,而是顶部与三键和状态栏重合,我觉得只应该在 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 个文件在真机出现后被判到期,本轮未处理)。
828 lines
45 KiB
JavaScript
828 lines
45 KiB
JavaScript
/*
|
||
* 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 同一来源)。');
|
||
});
|