Files
MailUI4Agents/client/electron/test/cross-client-gesture.test.mjs
JianFeeeee 4a5318ca28 跨端: 手势补设备判据(真滑、标题真变)+ 修三处"设备判据假红"的典型错法
上一提交把滑动翻页做完了,但只验到"代码形态对"。**真装上跑时一次都没触发** ——
这一条补上设备实测,并把途中撞出来的三类错法记进判据注释。

## 一、为什么必须有设备判据

第一次装上跑:**代码全对、手势一次都没触发**。原因是起点 x=2600 落在
**右栏**(日程面板 `Column [2207,112][3184,2204]`)—— 事件根本没进网格列。
只有静态判据的话,结论会是"手势已实现、判据全绿",而用户真去滑时一动不动。

所以判据断的是**界面真的翻了**(滑动前后各 dump 一次,断言月标题变一格),
不是断"日志里有 turned=true" —— 后者只证明判定通过、证明不了有人会动。

## 二、途中撞出来的三类错法(都写进注释了)

1. **`uitest` 的 velocity 越界会被静默替换**
   我传 150(想表达"慢一点"),它只回一句
   `The swipe velocity out of range, the default value will be used.`
   —— 不报错、不改退出码,只是默默换成默认 600。于是"慢滑"变成"更慢的滑",
   看起来像手势没生效。**传合法值(200~40000)+ 读回执**才能避免。
   新增 `lib/harmony-device.mjs` 的 `swipe()` 帮手(与 `tap()` 同族,
   内部 clamp,并在注释里写了这个坑)。

2. **滑动是有副作用且不可撤销的写操作 ⇒ 不能盲目重试**
   第一版写"不生效就再发一次(最多 3 次)",结果实测**把日历一次翻了 3 格**
   (标题跑到 2026年11月)。重试不是"再试一次",是"再翻一页"。
   只有幂等操作才允许盲目重试。改成:**只发一次 + 等足够久**(最多 8 秒轮询)。

3. **坐标不能用"到边界差一点"的比例**
   取 `width * 0.68` = 2165,距网格列右边界 2207 只有 42px ⇒ **一次都不触发**;
   同一台设备、同一份代码,起点改 2000 立刻生效。
   起点贴边时触摸点会被判到相邻的右栏。改成取**列中段**
   (`0.62` / `0.13`,两端各留几百 px 余量)。

另:本判据第一版用"滑一次 + 固定等 2500ms + dump",**单独跑通过、接进
run-all 后失败**(设备繁忙时不够)。固定等待是设备判据最常见的假红来源 ——
改成轮询到标题变化。诊断留了 `AGENTMAIL_GESTURE_DEBUG=1` 门控,
失败时能一次看到"前台/坐标/轨迹",不用事后手动复现。

## 三、设备实测结果(HATriple 3184×2232)

· 月档:左滑 9月 → **10月**(`dx=-556.5 dy=0 ms=624 turned=true`);
  右滑回 **9月**(`dx=+556.5`)。
· 周档:`2026年9月14–20日` → 左滑 → **`9月21–27日`**(正好 +7 天,
  证明步长走的是 `stepDaysOf` 而不是写死 1)。
· 斜滑(dx=400 dy=800):`PanGesture({direction: Horizontal})` 在系统层
  就没识别 ⇒ 比鸿蒙侧的 `SWIPE_AXIS_RATIO` 更早拦住(正确行为)。

**仍未验**:56vp / 1.4× / 700ms 这三个数**手感是否合适**,只能真人滑过才知道。
我验的是"判定逻辑 + 接线 + 真能翻页"。

## 四、验证

`run-all.mjs` → `files=32 ran=32 checks=498 pass=498 fail=0 skip=0
red=0 broken=0 unreported=0`。
(含新设备判据:cross-client-gesture 9 条,其中第 9 条是真滑。
 `build-stamp` 7/7、`packaging` 5/5 —— 按判据要求重建 + 重打包,没有改记录迁就。)
2026-09-19 14:16:30 +08:00

401 lines
21 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, foregroundBundle, ourBundle, dumpLayout, walk, swipe } 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('设备不在 —— 手势本体本次不跑(上面 ①~④ 静态层仍把住契约)');
}
const fg = foregroundBundle(hdc);
if (fg !== ourBundle()) {
return t.skip(`前台不是我们的应用(${fg})—— 不拿别人的界面断言`);
}
const titleOf = (root) => {
for (const n of walk(root)) {
const txt = n.attributes?.text || '';
const m = /^(\d{4})年(\d{1,2})月$/.exec(txt.trim());
if (m) return { year: Number(m[1]), month: Number(m[2]) };
}
return null;
};
/*
* ★★ 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 = titleOf(dumpLayout(hdc));
if (t2 !== null) return t2;
await settle();
}
return null;
};
/* 先在月档(周/日档标题是范围式,这里判最简单的那一档) */
const before = await readTitle();
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 = titleOf(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})`);
});