Files
MailUI4Agents/client/electron/test/cross-client-gesture.test.mjs
JianFeeeee a18014e1e3 判据: 管理页设备判据(到期静态判据的第一条升级)+ 修设备判据基建的三个真 bug
`run-all.mjs` 的 `STATIC_ONLY` 里登记着 6 条"只能静态验"的判据,前提是
「本工作区能装、能点设备」。设备现在可用 ⇒ 它们**到期**了(欠账当场变红)。
这一批升级第一条:`harmony-admin`(用户管理页)。

## 一、新设备判据:管理页真的能打开、列表真的渲染

上面 27 条静态判据判的都是逻辑与接线形态。它们全绿时,
"管理页能不能打开、列表能不能渲染"**一句都没验过** —— 而它最容易坏:
路由注册对了但入口没接上、接口回来了但列表没渲染。

判据自己搭现场:拉起应用 → 进「我的」→ 滚到底 → 点「管理」→
断言真的在管理页(出现「新建用户」)且**渲染出用户行**。

## 二、途中撞出三个**判据自身**的 bug(都已修,都写了教训)

1. **`tapText` 从来没成功点过任何东西**(`lib/harmony-device.mjs`)
   `findByText` 返回的是**数组**,我当单个节点用了 ⇒ `node.attributes` 恒
   `undefined` ⇒ 恒返回 `false`。症状极隐蔽:调用方以为"没找到那个文案",
   实际是帮手自己坏了。修:从数组里挑,且**优先挑可点的那个**
   (同名文案常常一个可点、一个不可点)。

2. **判据之间互相干扰**(新增 `backToMain`)
   每个设备判据都是写操作,会把前台留在它操作完的那一页。`harmony-admin`
   把人留在管理页 —— 那是 `pushUrl` 出去的独立 `@Entry` 页,**没有侧栏**
   ⇒ 后面的 `cross-client-gesture` / `harmony-appearance` 找不到侧栏、
   双双 skip(skip 原因写的是"宽屏侧栏找不到",看起来像功能没了)。
   `launchOurApp` 解决不了(`aa start` 只切前台,不弹栈)⇒ 新增 `backToMain`。

3. **"找不到元素"要先分清是功能缺失还是判据没摆好现场**
   本判据连栽四种形态,每一种都伪装成"功能缺失":
   · 锚点文案错(入口是「管理」,我按「用户管理」找 —— 后者只是说明的一部分)
   · 元素在滚动下方(dumpLayout 只报可见节点)
   · 文字节点不可点(`.onClick` 在包住它的容器上,ArkUI 的常态)
   · **滚动步长跨过了它**(诊断打印现场才发现:停在了「系统通知」那一带,
     而「管理」只占 ~0.04 屏,一次 0.2 屏的滑动必然越过)
   ⇒ 最后改成"**先滚到底、再小步回扫**"(只管往前找在不均匀列表上必漏),
     并加了 `AGENTMAIL_ADMIN_DEBUG=1` 的诊断入口把现场打出来。

## 三、判据

`run-all.mjs` → `files=32 ran=32 checks=505 pass=505 fail=0 skip=0
red=0 broken=0 unreported=0`(连跑两次稳定)。
`harmony-admin` 27 → 28 条。`hvigorw assembleHap` 成功;前端重建。

**设备实测**:管理页从「我的」页打开,显示「管理 / 用户管理 3 / 新建用户」
与三行用户(jianf 管理员 / gui-lab 用户 / test 用户)。

**仍到期未升级**:`harmony-appearance`(已加 2 条设备判据,但登记里其余部分仍静态)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults`、`harmony-imageprep`
—— 按"每条缺什么设备侧验证"逐条来,不为了消数字而凑。
2026-09-19 15:53:34 +08:00

480 lines
25 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 (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), '要能拉起我们的应用并等到它到前台');
/*
* ★★ 必须先回**主界面**:前面跑过的设备判据可能把前台留在
* 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-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})`);
});