Files
MailUI4Agents/client/electron/test/cross-client-gesture.test.mjs
JianFeeeee f811c9887a 跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据
这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
2026-09-19 15:03:40 +08:00

473 lines
24 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.

/**
* 左右滑动翻页的**语义契约**:两端逐项相同,数值各自定。
*
* 这条判据是 `docs/DEBTS.json` 的 `gesture-semantics` 那条债的还款 ——
* 它的原文是:
*
* 「P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建
* (此前建 = 只有一端存在的假判据)」
*
* 也就是**先有手势、再钉语义**。2026-09-19 鸿蒙侧真的加了手势
* (`CalendarPage.ets` 的 `PanGesture` + `model/Calendar.ts` 的 `judgeSwipe`),
* 所以现在建。
*
* ★ 为什么钉语义而不是钉数值
*
* `docs/HARMONY-ALIGN-PLAN.md:118-128` 显式选了 (b) 口径,理由是原话:
* 「手势的物理量本来就不该强求同值。40px 的位移阈值、600ms 的"快滑"判据,
* 在触摸屏与鼠标上、在手机与大屏上,人体工学的合理值天然不同;
* 强行同值会得到一个两边都不舒服的数。该对齐的是**语义层**。」
*
* 所以本文件**从两端源码里各自读一遍语义**(不是读常量名、不是读注释),
* 再逐项比对:
*
* | 语义 | WebUI 侧证据 | 鸿蒙侧证据 |
* |------------|-------------------------------------------|-------------------------|
* | 左滑=下一段 | `shift(dx < 0 ? 1 : -1)` | `dx < 0 ? 1 : -1` |
* | 右滑=上一段 | 同上 | 同上 |
* | 纵向优先 | `Math.abs(dx) < Math.abs(dy) * 1.5` | `Math.abs(dx) < Math.abs(dy) * X` |
* | 慢拖不翻页 | `Date.now() - start.t < 600` | `ms > Y` 时 return |
* | 无边界回弹 | `addMonths` 无钳制(可任意前后) | 同(`addMonths`) |
* | 手势复用按钮逻辑 | 手势与按钮都调 `shift()` | 手势与按钮都调 `shiftRange()` |
*
* 数值(`40` / `1.5` / `600`)**只做形状校验**(是数字、是正数),
* 断言它们**相等**才是错的 —— 那会把 (b) 口径推翻。
*/
import { code, prose } from './lib/read.mjs';
/*
* 设备侧工具:`findHdc/hasTarget/foregroundBundle/ourBundle/dumpLayout/walk`
* 出自 `lib/harmony-device.mjs`(唯一权威处 —— 包名也从那里读,
* 不在这里写第二份,见那条「包名漂移」的教训)。
*/
import {
findHdc, hasTarget, dumpLayout, walk, swipe, tapText,
launchOurApp, boundsCenter, tap,
} from './lib/harmony-device.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
/** WebUI 的手势实现在日历组件里 */
const webCal = code(join(ROOT, 'client/electron/src/components/CalendarView.tsx'));
/** 鸿蒙的判定逻辑在纯逻辑层(可被 node 直接执行,另有行为判据跑它) */
const harmonyLogic = code(join(HARMONY_ETS, 'model/Calendar.ts'));
/** 鸿蒙的接线(手势挂在哪个容器上) */
const harmonyPage = code(join(HARMONY_ETS, 'pages/CalendarPage.ets'));
/* ────────────────────────── ① 两端都真的存在手势 ────────────────────────── */
test('两端都有左右滑动翻页的代码(不是只有一端)', () => {
/*
* 这是本文件存在的前提。`docs/HARMONY-ALIGN-PLAN.md:136-140` 记过:
* 鸿蒙侧当时**没有日历页、也没有任何手势代码**,此时"两端逐项相同"
* 只有一端存在 ⇒ 任何断言都是空真(`∀x∈∅`)。所以第一条就把这个前提钉住。
*/
assert.match(webCal, /onTouchStart/,
'WebUI 侧要有触摸起点(`CalendarView.tsx` 的 onTouchStart)');
assert.match(webCal, /onTouchEnd/,
'WebUI 侧要有触摸终点(判定位移与时长的地方)');
assert.match(harmonyPage, /PanGesture\s*\(/,
'鸿蒙侧要有手势(`PanGesture`)—— 没有它本判据是空真,正是那条债警告的事');
assert.match(harmonyLogic, /export function judgeSwipe\s*\(/,
'鸿蒙侧的判定要落在**纯逻辑层**(`model/Calendar.ts` 的 judgeSwipe)—— ' +
'写在 .ets 里就跑不了 node 判据,语义没法被单测钉住');
});
/* ────────────────────── ② 语义逐项相同(不比数值) ────────────────────── */
test('★ 语义:左滑 = 下一段,右滑 = 上一段(两端同)', () => {
/*
* 这是最容易写反、也最容易被"看起来在动"掩盖的一条:
* 方向反了功能完全正常,只是与另一端的体感相反 —— 用户跨端用会觉得很怪。
*
* 两端的写法都必须是 `dx < 0 ? +1 : -1` 这个**同一个形状**:
* 左滑(dx 为负)= 前进(+1);右滑(dx 为正)= 后退(-1)。
*/
const webDir = webCal.match(/shift\(dx\s*<\s*0\s*\?\s*(\d+)\s*:\s*(-?\d+)\)/);
assert.ok(webDir,
'WebUI 的方向映射要能读出来(形如 `shift(dx < 0 ? 1 : -1)`)');
assert.equal(webDir[1], '1', 'WebUI:左滑(dx<0)应映射为 +1(下一段)');
assert.equal(webDir[2], '-1', 'WebUI:右滑应映射为 -1(上一段)');
const hDir = harmonyLogic.match(/delta\s*=\s*dx\s*<\s*0\s*\?\s*(\d+)\s*:\s*(-?\d+)/);
assert.ok(hDir,
'鸿蒙的方向映射要能读出来(形如 `v.delta = dx < 0 ? 1 : -1`)');
assert.equal(hDir[1], webDir[1],
'★ 左滑在两端的映射必须相同(这里**该**比数值,因为它是语义不是物理量)');
assert.equal(hDir[2], webDir[2],
'★ 右滑同理');
});
test('★ 语义:纵向优先 —— 横向位移必须明显大于纵向,两端都有这道门', () => {
/*
* 移动端最容易犯的手势错误:用户在纵向列表上滚,手指天然带一点横向偏移,
* 不卡这条就会"滚着滚着翻页了"。
*
* 两端都要有这道门,且形状必须是「横向 < 纵向 * 倍数 ⇒ 放弃」。
* 倍数是多少**不比**((b) 口径),但必须是个正数。
*/
const webAxis = webCal.match(/Math\.abs\(dx\)\s*<\s*Math\.abs\(dy\)\s*\*\s*([\d.]+)/);
assert.ok(webAxis, 'WebUI 要有"横向 vs 纵向"这道门');
assert.ok(Number(webAxis[1]) > 1,
`WebUI 的倍数应 > 1(实测 ${webAxis[1]})—— 等于 1 时斜滑就翻页,判据等于没有`);
const hAxis = harmonyLogic.match(/Math\.abs\(dx\)\s*<\s*Math\.abs\(dy\)\s*\*\s*([A-Z_]+|[\d.]+)/);
assert.ok(hAxis, '鸿蒙要有"横向 vs 纵向"这道门(漏了它 = 纵向滚动会误翻页)');
/* 数值不同是**允许的**((b)),但取值来源要说清是常量而不是裸字面量 */
assert.match(harmonyLogic, /export const SWIPE_AXIS_RATIO:\s*number\s*=\s*[\d.]+/,
'鸿蒙的倍数要走具名常量(裸字面量会让"这个数为什么是它"无从追溯)');
});
test('★ 语义:慢拖不翻页("快滑"是感知档,不是同一个毫秒数)', () => {
/*
* WebUI:`const fast = Date.now() - start.t < 600` + `!fast → return`。
* 鸿蒙:`if (ms > SWIPE_MAX_DURATION_MS) return v;`。
*
* 两端都要有这道门;**毫秒数不比**。
*/
const webFast = webCal.match(/Date\.now\(\)\s*-\s*start\.t\s*<\s*(\d+)/);
assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)');
assert.ok(Number(webFast[1]) > 0, 'WebUI 的时间窗应为正数');
const hFast = harmonyLogic.match(/ms\s*>\s*SWIPE_MAX_DURATION_MS/);
assert.ok(hFast, '鸿蒙要有"快滑"时间窗这道门');
assert.match(harmonyLogic, /export const SWIPE_MAX_DURATION_MS:\s*number\s*=\s*\d+/,
'鸿蒙的时间窗要走具名常量');
/*
* ★ 反向断言:鸿蒙**不得**引用 WebUI 的那三个数。
* 这正是 (b) 口径的落点 —— 一旦有人"抄过去省事",两端就被锁在一起,
* 以后为大屏调阈值会同时改坏手机端。计划文档明写「鸿蒙**不得引用** WebUI 的三个数」。
*/
for (const forbidden of ['40', '600', '1.5']) {
const asStandalone = new RegExp(`SWIPE_[A-Z_]*:\\s*number\\s*=\\s*${forbidden.replace('.', '\\.')}\\b`);
assert.ok(!asStandalone.test(harmonyLogic),
`★ 鸿蒙的阈值常量不得直接取 WebUI 的值 ${forbidden} —— ` +
'(b) 口径要求数值各自定(见 HARMONY-ALIGN-PLAN.md:118-128)');
}
});
test('★ 语义:手势与翻页按钮复用**同一个**翻页函数(两端同)', () => {
/*
* WebUI 的原话:「复用 shift() —— 手势与「上一月/下一月」按钮必须是同一套翻页逻辑,
* 各写一遍的话阈值、边界、"周/日/月"三种刻度的行为迟早分叉。」
*
* 鸿蒙对应的函数是 `shiftRange()`(`shiftMonth` 只是它的旧名包装)。
* 断言方式:页面里出现的手势判定结果必须交给 shiftRange,
* 而不是自己算日期/自己改 year/month。
*/
assert.match(harmonyPage, /if\s*\(v\.turned\)\s*\{[\s\S]{0,60}this\.shiftRange\(v\.delta\)/,
'★ 手势判定通过后必须调 `shiftRange(v.delta)` —— 不能自己改 year/month,' +
'那会出现"手势翻 1 天、按钮翻 7 天"的分叉');
/* 手势那一支里不得直接写 year/month(那是 shiftRange 的职责) */
const gestureBlocks = harmonyPage.match(/\.onActionEnd\([\s\S]{0,400}?\}\)/g) ?? [];
assert.ok(gestureBlocks.length >= 1, '要能找到手势的 onActionEnd 回调');
for (const b of gestureBlocks) {
assert.ok(!/this\.(year|month)\s*=/.test(b),
'★ 手势回调里不得直接改 `year`/`month` —— 锚点是唯一真相,' +
'年月只能由 `shiftRange` → `syncYearMonthFrom` 派生');
}
});
test('★ 语义:翻页无边界回弹(两端一致)', () => {
/*
* 语义表里那一行是「翻页边界是否回弹」。两端的答案都是"没有边界":
* `addMonths(year, month, delta)` 直接把 month 折进 year(`total % 12`),
* 不钳制范围 ⇒ 可以一直往前/往后翻,不存在"到头了"的状态,
* 因此**没有回弹动画可看**(不是"忘了做回弹")。
*
* 这条判据防的是"哪天有人加了钳制":加了钳制就得同时定义回弹语义,
* 而那是一个两端都要改的契约变更,不能只改一边。
*/
const webAddMonths = webCal.includes('addMonths');
assert.ok(webAddMonths, 'WebUI 翻页走 addMonths(无钳制)');
/*
* 锚点要短:`addMonths` 只有两行,中间隔不了多少字符。
* (第一版写 `[\s\S]{0,140}?total\s*%\s*12` 反而没匹上 —— 我把它想长了。)
*/
assert.match(harmonyLogic, /export function addMonths\([\s\S]*?total % 12/,
'★ 鸿蒙的 addMonths 必须仍是取模折年(有边界就不对称了);' +
'若哪天要加钳制,回弹语义必须两端一起定');
});
/* ─────────────────────── ③ 有意差异被记录(不是漏做) ─────────────────────── */
test('有意差异:周档的"横滚冲突让位"在鸿蒙侧不存在 —— 但必须被记录', () => {
/*
* WebUI 在**周/日档**额外检查「触点是否落在可横向滚动的区域里」,
* 是则把手势让给滚动条。用户 2026-09-14 亲口报过这个冲突:
* 「横向滚动条会与切换视图的手势冲突,我觉得周视图需要卡严条件」。
*
* 鸿蒙侧**没有这个冲突**:周档是"一行 7 格、按 layoutWeight 等分",不横滚。
* 所以这一条**不适用**,不是漏做。
*
* 判据两半(缺一不可):
* ① 鸿蒙的周档确实不横滚(否则就该补卡严条件,而它没补 = 真漏);
* ② 这件事在代码里被**记录下来**(下一个人看到 WebUI 有、鸿蒙没有时,
* 要能查到这是有意的,而不是当成待办)。
*/
const strictWeb = /startsInHorizontalScroller/.test(webCal);
assert.ok(strictWeb, 'WebUI 侧应有"落在横滚区域就让位"的检查');
assert.ok(!/Scroll\s*\(\s*\)\s*\{[\s\S]{0,200}?calScale\s*===\s*'week'/.test(harmonyPage),
'鸿蒙周档目前不横滚(若改成横滚,必须同时补上这条卡严条件)');
/*
* ★ 读**注释**要用 `prose`(不剥注释),不能用 `code`:
* 这条断言判的正是「理由有没有被写下来」,而理由天然在注释里。
* 我第一版用了 code(),它把注释剥掉了 ⇒ 永远红。
*/
const harmonyLogicProse = prose(join(HARMONY_ETS, 'model/Calendar.ts'));
assert.match(harmonyLogicProse, /有意\*\*未对齐\*\*的一条|有意未对齐/,
'★ 鸿蒙侧要把"这条为什么不适用"写进代码注释 —— ' +
'两端行为不同的地方不留记录,下一个人只会当成漏做(§7.12 的同一纪律)');
});
/* ─────────────────────────── ④ 判据自检 ─────────────────────────── */
test('★ 判据自检:方向写反必须判红', () => {
/*
* 没有自检的判据是"看起来在跑"的判据。这里模拟"有人把左滑写成上一段",
* 确认 ② 那条能红 —— 若不能红,说明它锚错了地方(读的是别的字符串)。
*/
const mutated = harmonyLogic.replace(/delta\s*=\s*dx\s*<\s*0\s*\?\s*1\s*:\s*-1/,
'delta = dx < 0 ? -1 : 1');
assert.notEqual(mutated, harmonyLogic, '变异要有实际效果(锚点必须命中)');
/* 变异后**不能**再匹配上「左滑=+1」那个形状 —— 这才是那条断言真正的咬法 */
const stillOk = /delta\s*=\s*dx\s*<\s*0\s*\?\s*1\s*:\s*-1/.test(mutated);
assert.equal(stillOk, false,
'★ 方向写反时,②「左滑=下一段」那条必须能判红');
});
/* ───────────────── ⑤ 设备实测:真的滑一下,标题真的变了 ───────────────── */
/*
* ★★ 为什么这条必须在**设备上**做(2026-09-19 实测撞出来的):
*
* 上面 ①~④ 判的全是"代码写对了没有" —— 而这一轮我第一次装上跑时,
* **代码全对、手势一次都没触发**。原因是 `PanGesture` 挂在网格列上,
* 而我滑动的位置(x=2600)落在**右栏**(日程面板
* `Column [2207,112][3184,2204]`)—— 事件根本没进网格列。
*
* 那一次如果只有静态判据,结论会是"手势已实现、判据全绿",
* 而用户真去滑时可能一动不动。所以必须有一条**真滑**的判据。
*
* 取证方式(我实测用的那套):滑动前后各 dump 一次布局,
* 断言**标题真的从 9 月变成 10 月**(而不是"日志里有 turned=true")——
* 日志只能证明"判定通过",标题才能证明"界面真的翻了"。
*/
test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', async (t) => {
const hdc = findHdc();
if (!hdc || !hasTarget(hdc)) {
return t.skip('设备不在 —— 手势本体本次不跑(上面 ①~④ 静态层仍把住契约)');
}
/*
* ★★ 2026-09-19 修:本判据原先要求"前台已经是我们的应用、且已经在日历月档",
* 不满足就 `t.skip` —— 于是**在套件里永远跳过**(run-all 跑到它时前台是别的),
* 只有我手动摆好现场才通过。那等于没有这条判据。
*
* 现在它**自己搭现场**:拉起应用 → 切到日历窗格 → 必要时切回月档。
* 这才是设备判据该有的样子(前置条件自己满足,而不是等人摆好)。
*/
assert.ok(await launchOurApp(hdc), '要能拉起我们的应用并等到它到前台');
await new Promise((r) => setTimeout(r, 1500));
/* 切到日历窗格:宽屏点侧栏第 2 项 / 窄屏点底栏「日历」 */
const rootA = dumpLayout(hdc);
const screenDims = (root) => {
let w = 0;
let h = 0;
for (const n of walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const dims = screenDims(rootA);
const wide = dims.w / dims.h > 1.2;
if (wide) {
/* 侧栏导航轨第 2 项(日历):贴顶、在左 1/6 内、高 > 屏高 4% */
/*
* ★ 必须**要求有文字标签** —— 品牌标(左上那个 40vp 方块,实测 `y1=174`)
* 也是"可点 + 在左 1/6 + 高度够",会被一起选进来。
* 我第一版没排除它,于是排序后 `rail[0]` 是品牌标、`rail[1]` 是**通信**
* ⇒ 想点日历却点到了通信,判据卡在"找不到月档"。
* (`harmony-nav` 的 `navItemsOf` 早就有这条过滤,我这里漏了 —— 同一形状。)
*/
const textsUnder = (n) => {
const out = [];
const sub = (x) => {
if (Array.isArray(x)) { x.forEach(sub); return; }
if (!x || typeof x !== 'object') return;
const t = (x.attributes?.text || '').trim();
if (t) out.push(t);
for (const c of (x.children || [])) sub(c);
};
sub(n);
return out;
};
const rail = [...walk(rootA)].filter((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= dims.w * 0.08 && y1 < dims.h * 0.5 && (y2 - y1) > dims.h * 0.04
&& textsUnder(n).length > 0; // ← 排除品牌标
}).sort((a, b) => {
const y = (n) => Number(/\[(-?\d+),(-?\d+)\]/.exec(n.attributes.bounds)[2]);
return y(a) - y(b);
});
if (rail.length < 2) {
return t.skip(`宽屏侧栏导航轨只找到 ${rail.length} 项 —— 无法切到日历`);
}
const c = boundsCenter(rail[1].attributes.bounds);
assert.ok(tap(hdc, c.cx, c.cy), '要能点到侧栏的「日历」');
} else {
assert.ok(tapText(hdc, '日历'), '要能点到「日历」');
}
await new Promise((r) => setTimeout(r, 2500));
/* 确保在**月档**(标题是 `YYYY年M月`;周/日档是范围式) */
const monthTitle = (root) => {
for (const n of walk(root)) {
const m = /^(\d{4})年(\d{1,2})月$/.exec((n.attributes?.text || '').trim());
if (m) return { year: Number(m[1]), month: Number(m[2]) };
}
return null;
};
if (monthTitle(dumpLayout(hdc)) === null) {
assert.ok(tapText(hdc, '月'), '要在日历里点到「月」档');
await new Promise((r) => setTimeout(r, 2000));
}
/*
* ★★ 2026-09-19:本判据第一版用「滑一次 + 固定等 2500ms + dump」。
* 结果**单独跑通过、接进 run-all 后失败**(同一台设备、同一份代码)。
* 原因不是手势坏了,是**设备繁忙时 2500ms 不够** —— 而 run-all 会把设备
* 留给前后其它判据,负载天然比单独跑时高。
*
* 固定等待是设备判据最常见的假红来源(我这条已经吃过一次)。
* 改成**轮询到状态稳定 + 滑动带重试**:宁可多花几秒,
* 也不要一条"时红时绿"的判据 —— 那种判据最后只会被绕过。
*/
const settle = async (ms = 600) => { await new Promise((r) => setTimeout(r, ms)); };
/** 等标题出现(最多 tries 次),返回读到的标题或 null */
const readTitle = async (tries = 6) => {
for (let i = 0; i < tries; i++) {
const t2 = monthTitle(dumpLayout(hdc));
if (t2 !== null) return t2;
await settle();
}
return null;
};
/* 先在月档(周/日档标题是范围式,这里判最简单的那一档) */
const before = monthTitle(dumpLayout(hdc));
if (before === null) {
return t.skip('月标题找不到(可能在周/日档,或日历页没打开)—— 本次不跑');
}
const beforeMonths0 = before.year * 12 + before.month;
/*
* 滑动区间必须落在**网格列**里,不能扫到右栏。
* 网格列实测约 `[229,112][2207,2204]`;取 y=中线、x 从 3/4 处滑到 1/4 处。
* velocity 取 5000(uitest 合法范围 200~40000)⇒ 约 560ms,
* 落在鸿蒙侧 `SWIPE_MAX_DURATION_MS`(700) 窗口内。
*/
const width = Math.max(...[...walk(dumpLayout(hdc))].map((n) => {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
return m ? Number(m[1]) : 0;
}));
/*
* ★★ 坐标不能用"到边界差一点"的比例(实测撞出来的)。
*
* 网格列实测 `[229,112][2207,2204]`。我第一版取 `width * 0.68` = **2165**,
* 距右边界只有 42px —— 而**手势一次都没触发**(8 秒内标题轨迹全是 9 月)。
* 同一台设备、同一份代码,起点改用 2000 就立刻生效(翻到 10 月)。
*
* 原因:起点贴着网格列边缘时,触摸点会被判到相邻的右栏(或落在边界判定区),
* 事件根本没进网格列。**这与我手动复现时踩的是同一个坑**(那次 x=2600
* 整个落在右栏里,也是"代码全对、一次都不触发")。
*
* ⇒ 取**网格列的中段**做滑动:起点 x 用 `width * 0.62`(≈1974,实测生效),
* 终点 `width * 0.13`(≈414)。两端都离列边界有几百 px 余量。
*/
const gridRight = Math.round(width * 0.62);
const gridLeft = Math.round(width * 0.13);
const y = 1300;
/*
* 滑动一次 + 轮询到标题变化。
*
* ★★ 这里**不能重试滑动** —— 我第一版写成了"发不生效就再发一次(最多 3 次)",
* 结果实测把日历一次翻了 3 格(标题跑到 2026年11月)。
* 滑动是**有副作用且不可撤销**的写操作:重试不是"再试一次",而是"再翻一页"。
* (只有幂等的操作才允许盲目重试 —— 滑动不属于。)
*
* 所以策略改成:**只发一次**,然后**等足够久**(设备繁忙时事件注入到 UI 更新
* 可能滞后一两秒)。等待期间反复 dump,一看到标题变了就返回。
*/
const swipeOnce = async (fromX, toX) => {
/*
* ★ 诊断入口:设备判据失败时,最耗时间的是"到底是坐标不对、
* 还是前台不对、还是手势没接上"。把这三个事实一次打出来,
* 比事后一遍遍手动复现快得多(`AGENTMAIL_GESTURE_DEBUG=1` 时才打,
* 免得正常跑的日志被噪声淹没)。
*/
if (process.env.AGENTMAIL_GESTURE_DEBUG) {
const fgNow = foregroundBundle(hdc);
console.log(`[gesture] 前台=${fgNow} 期望=${ourBundle()} ` +
`滑(${fromX},${y})→(${toX},${y}) velocity=5000 宽=${width}`);
}
assert.ok(swipe(hdc, fromX, y, toX, y, 5000), '滑动命令要被执行');
const seen = [];
for (let k = 0; k < 16; k++) { // 最多等 8 秒
await settle(500);
const now = monthTitle(dumpLayout(hdc));
if (now !== null) {
seen.push(`${now.year}-${now.month}`);
const delta = (now.year * 12 + now.month) - beforeMonths0;
if (delta !== 0) {
if (process.env.AGENTMAIL_GESTURE_DEBUG) console.log(`[gesture] 读到标题轨迹: ${seen.join(' → ')}`);
return { delta, now };
}
}
}
if (process.env.AGENTMAIL_GESTURE_DEBUG) {
/* 注意:右滑那一步"没变"是**正常的**(回到基准月,delta 应为 0) */
console.log(`[gesture] 标题轨迹(相对基准 delta 始终为 0): ${seen.join(' → ') || '(一个都没读到)'}`);
}
return { delta: 0, now: await readTitle() };
};
const fwd = await swipeOnce(gridRight, gridLeft);
assert.equal(fwd.delta, 1,
`★ 左滑应前进**恰好一段**(月档 = 1 月):${before.year}年${before.month}月 → ` +
`${fwd.now?.year}年${fwd.now?.month}月。` +
(fwd.delta === 0
? ' 一格没动 ⇒ 手势没生效(检查挂载点是否真在滑动的那个容器上)。'
: ' 跳了不止一格 ⇒ 一次手势触发了多次翻页。'));
/* 右滑回原处 —— 顺带验证反方向,并让设备回到初始状态(判据不该留下副作用) */
/*
* 右滑:`swipeOnce` 的基准是**最初**的 `beforeMonths0`,右滑一页后
* delta 应回到 0(即回到出发时那一月)。
*/
const back = await swipeOnce(gridLeft, gridRight);
assert.equal(back.delta, 0,
`★ 右滑应退回上一段(回到 ${before.year}年${before.month}月)—— ` +
`实际落在 ${back.now?.year}年${back.now?.month}月(delta=${back.delta})`);
});