跨端: 手势补设备判据(真滑、标题真变)+ 修三处"设备判据假红"的典型错法

上一提交把滑动翻页做完了,但只验到"代码形态对"。**真装上跑时一次都没触发** ——
这一条补上设备实测,并把途中撞出来的三类错法记进判据注释。

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

第一次装上跑:**代码全对、手势一次都没触发**。原因是起点 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 —— 按判据要求重建 + 重打包,没有改记录迁就。)
This commit is contained in:
2026-09-19 14:16:30 +08:00
parent 28e3da76d4
commit 4a5318ca28
3 changed files with 181 additions and 1 deletions

View File

@ -34,6 +34,12 @@
* 断言它们**相等**才是错的 —— 那会把 (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';
@ -237,3 +243,158 @@ test('★ 判据自检:方向写反必须判红', () => {
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})`);
});

View File

@ -313,3 +313,22 @@ export function tap(hdc, x, y) {
const r = sh(hdc, ['shell', 'uitest', 'uiInput', 'click', String(x), String(y)], 15000);
return (r.stdout || '').includes('No Error');
}
/**
* 从 (x1,y1) 滑到 (x2,y2),`velocity` 单位 px/s(`uitest` 合法范围 200~40000)。
* 返回 true 表示 "No Error"。
*
* ★ `velocity` **必须落在合法范围内**:我第一版传了 150(想表达"慢一点"),
* `uitest` 只回一句 `The swipe velocity out of range, the default value will be used.`
* —— 它**不报错、不改退出码**,只是默默换成默认 600,于是"慢滑"变成"更慢的滑",
* 看起来像手势没生效。这类"参数被静默替换"的坑,只能靠**传合法值 + 读回执**避免。
*
* 同样**这是写操作**(改变前台应用状态):调用方先确认前台是自己的应用再滑。
*/
export function swipe(hdc, x1, y1, x2, y2, velocity = 5000) {
if (!hdc) return false;
const v = Math.min(40000, Math.max(200, Math.round(velocity)));
const r = sh(hdc, ['shell', 'uitest', 'uiInput', 'swipe',
String(x1), String(y1), String(x2), String(y2), String(v)], 20000);
return (r.stdout || '').includes('No Error');
}

View File

@ -74,7 +74,7 @@ const SUITE = [
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。
// 2026-09-19 鸿蒙侧加了手势 ⇒ 现在建。8 条:两端都真有手势 / 方向映射 /
// 纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
['test/cross-client-gesture.test.mjs', [], 8],
['test/cross-client-gesture.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],