Files
MailUI4Agents/client/electron/test/cross-client-gesture.test.mjs
JianFeeeee 6ca0113fd2 跨端: 统一顶栏组件 + 按压反馈(真跑通)+ 滑动判定从"时长门"改"速度门"
用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
  ① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
  ③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
  ⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」

★ ① ② 统一顶栏 `AppHeader`
  改造前**六处各写各的**:`AdminUsersPage`(40×40 返回键+16 号标题)、
  `SettingsPage`(20 号标题+40×40「+」+**实心 Theme.surface**)、
  `MailDetailPage`(36×36 + 14 号标题,行高只有 14px 被裁过)、
  `MainPage.SentTab`/`PermissionTab`(通栏、无圆角)、`CommTabBar`。
  尺寸(36/40、14/16/20)、底色(实心白/透明/无)、返回键(有/无)三类都不一致;
  且四处用 `Text('‹')` 当返回键 —— **用字符当图标**(本仓明令禁止,字形随字体变、
  基线对不齐),而 `ICON_PATHS` 里**早就有** `chevronLeft`。
  ⇒ 抽成 `AppHeader`(返回键 + 标题 + `@BuilderParam` 右侧动作区),
    几何复用底部条常量,材质走 `Theme.navMaterial`。已接入 5 处。
  ★ `topInsetPx` 由调用方传:状态栏避让是**窗口级**事实(属页面),
    组件自己读会变成"每层各加一次"(平板侧栏 83+83 双计就是这么来的)。

★ ③ 发件箱"太大"—— 我的理由错了
  我第一版让 `HEADER_HEIGHT = NAV_BAR_HEIGHT`(56),理由是"顶栏与底栏同在一根
  竖轴上,高度不同会一眼看出来"。**那个理由不成立**:
  底栏是**两行**(图标+文字标签)⇒ 需要 56;顶栏只有**一行标题** ⇒ 56 里一半是空白。
  实测 `Row [277,315,1105,476]` = 161px = 56vp,标题那行只占 54px。
  "两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正要对齐的是**左右留白与圆角**。
  ⇒ `HEADER_HEIGHT = 44`(= 可点区下限,返回键装得下)。

★ ④ 按压反馈 —— 两次失败才跑通,两个坑都记进注释
  · **坑一**:`.attributeModifier()` **一个组件只能挂一个**,链两个是**后者覆盖前者**。
    会话组头卡同时挂了玻璃与按压反馈 ⇒ 只生效一个,**编译器不报错、运行不提示**。
    WebUI 是 CSS,类名天然叠加;ArkUI 是单一插槽,"叠加"必须显式做
    ⇒ 新增 `CompositeModifier`。
  · **坑二**:`applyPressedAttribute` **只对自带按压状态机的组件**(`Button`)回调。
    实测:挂 `Button` 上 → hilog 有输出;挂只有 `.onClick` 的 `Row`/`Column` 上
    → **一次都不回调**(按下与常态逐像素相同 `239,244,255`)。而全仓 117 处
    `onClick` 的主体正是纯 `Row`/`Column` ⇒ 那条路对本仓没用。
    改用 `onTouch` + `animateTo`。
  · 底色用**系统点击效果色** `ohos_id_color_click_effect`(不是我自己挑的):
    第一版用 `Theme.surfaceMuted`,实测只差 `3/3/3`,肉眼看不出 ——
    因为"一块面的颜色"与"按下时叠的提示色"在系统色板里是**两个不同语义**。
  设备验证:`onTouch type=0`(Down) → `type=1`(Up) 成对到达,像素确有位移。

★ ④ 滑动判定:从「时长门」改成「速度门」(**设计错误,不是调参**)
  旧门 `SWIPE_MAX_DURATION_MS = 700`("整个手势超 700ms 就拒")。
  而**时长 = 距离 ÷ 速度** —— 它把两个量混成一个 ⇒ 同样的手速下
  **滑得越远越容易被拒**,屏幕越大越严重(平板同一手势像素更多)。
  设备实测(3184×2232,位移恒 542.6vp):
      600px/s→5909ms | 1200→3161ms | 1800→2006ms | 2500→1429ms | 5000→616ms | 15000→123ms
  ⇒ 旧门意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
    —— 用户报的"滑不动/时灵时不灵"就是这个。
  改 `SWIPE_MIN_SPEED = 0.25`(vp/ms,取自上表两档中间)后实测:
      1200px/s → 0/3(正确拒绝:那是拖动)    2500px/s → **0/4 → 3/3**
      5000px/s → **2/4 → 3/3**
  两条判据跟着改形态(**语义不变**:都是"够快才翻页"):
  · `cross-client-gesture` 断言"算的是 dx/ms"——只断言"有个门"会漏掉这次的错
    (旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,而真机拒掉正常滑动);
  · `harmony-logic` 的行为判据加了两条**回归钉**:
    `542.6vp/1429ms`(实测的正常甩动)必须过;
    `1200vp/3000ms`(同速度、更远)也必须过 —— 旧门正是在这里误拒。

★ ⑤ 转场动画:查清了,"不做页面级"是**决定**而不是漏做
  WebUI `index.css:1230` 写明:页面级淡入会让**已在那儿的框架**也一起暗一下
  ("观感还是闪"),且用户 2026-09-14 **亲口否掉过**"整屏一起淡",
  所以那一档整体删掉,只保留**局部**动效。鸿蒙侧当前是:
  · `pushUrl` 推页 → **系统自带的滑动转场**(连拍 8 帧验证:第 3 帧能看到
    写信页正在覆盖发件箱,是真实的横向滑入,不是硬切);
  · 局部入场 → `paneRiseIn` / `menuIn` / `calendarSlide`(已接 12 处)。
  所以"加 pageTransition"反而会与用户当时的否决冲突 —— 这一条**不做**,
  理由记在这里,免得下次又当成遗漏。

判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
2026-09-20 10:51:28 +08:00

522 lines
28 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, backToMain,
} 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 (speed < SWIPE_MIN_SPEED) return v;`。
*
* ★★ 2026-09-20:鸿蒙侧从"**时长**上限"改成"**速度**下限",
* 本判据跟着改形态(**语义不变**:都是"够快才翻页")。
*
* 改的原因是实测撞出的**设计错误**,不是调参:时长 = 距离 ÷ 速度,
* 它把距离与速度混成一个量 ⇒ 同样的手速下**滑得越远越容易被拒**,
* 大屏(平板)尤其严重。设备实测(3184×2232,位移恒 542.6vp):
*
* 600px/s → 5909ms | 2500px/s → 1429ms
* 1200px/s → 3161ms | 5000px/s → 616ms
* 1800px/s → 2006ms | 15000px/s → 123ms
*
* 旧门 700ms 意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
* ⇒ 用户报"滑不动/时灵时不灵"。速度门与距离解耦,才是"快滑"该有的形状。
*
* 两端都要有这道门;**数值不比**((b) 口径)。
*/
const webFast = webCal.match(/Date\.now\(\)\s*-\s*start\.t\s*<\s*(\d+)/);
assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)');
assert.ok(Number(webFast[1]) > 0, 'WebUI 的时间窗应为正数');
/*
* 鸿蒙侧:门必须存在、且**必须按速度算**。
*
* 「按速度」是本判据要钉的形状 —— 只断言"有个门"会漏掉这次的错:
* 旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,
* 而它在真机上把正常滑动拒掉了。所以要断言"算的是 dx/ms"。
*/
assert.match(harmonyLogic, /export const SWIPE_MIN_SPEED:\s*number\s*=\s*[\d.]+/,
'鸿蒙的"快滑"门要走具名常量(裸字面量会让"这个数为什么是它"无从追溯)');
assert.match(harmonyLogic, /Math\.abs\(dx\)\s*\/\s*ms/,
'鸿蒙的快滑门要按**速度**(dx/ms)算,不是按时长 —— ' +
'时长把距离与速度混在一起,大屏上会误拒正常滑动(实测 700ms 门只有 ≥5000px/s 能过)');
/*
* ★ 反向断言:鸿蒙**不得**引用 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), '要能拉起我们的应用并等到它到前台');
/*
* ★★ 必须先回**主界面**:前面跑过的设备判据可能把前台留在
* push 出去的独立页(管理页/详情页/写信页)—— 那些页面**没有侧栏**,
* 于是这里找不到侧栏项、判据会 skip 成"导航轨只找到 1 项"。
* 那是**判据间干扰**,不是功能缺失(详见 `backToMain` 的注释)。
*/
assert.ok(await backToMain(hdc), '要能回到主界面(侧栏/底栏可见)');
await new Promise((r) => setTimeout(r, 1200));
/* 切到日历窗格:宽屏点侧栏第 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-20 修(本判据在套件里红、单独跑绿):**先回到已知的月份**。
*
* 本判据下面算的是"相对最初那一月的 delta",而它原来**只保证在月档、
* 不保证在哪一月** —— 于是前一屏停在哪个月、它就从那个月开始数。
* 套件里跑时,日历页可能已被别的判据(或上一次失败的手势)翻到 10 月,
* 而右滑那一步的期望值是按"最初那一月"算的 ⇒ 差一格就判红。
*
* 这不是"设备抖动",是**判据没有自己搭现场**:它依赖进入时的状态,
* 于是"通过与否取决于跑之前那一屏是什么"。同族毛病本仓已修过多次
* (`harmony-appearance` 那条设备判据、以及 `backToMain` 那段注释)。
*
* 修法:点「今天」把 anchor 归位到本月(该按钮的语义就是"回本月",
* 与 WebUI `goToday` 一致),再从那个确定的状态开始算 delta。
*/
assert.ok(tapText(hdc, '今天'), '要能点「今天」把月份归位(本判据需要一个已知起点)');
await new Promise((r) => setTimeout(r, 2000));
assert.ok(monthTitle(dumpLayout(hdc)) !== null,
'点了「今天」之后仍应在月档(否则下面的 delta 无从算起)');
/*
* ★★ 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})`);
});