跨端: 手势补设备判据(真滑、标题真变)+ 修三处"设备判据假红"的典型错法
上一提交把滑动翻页做完了,但只验到"代码形态对"。**真装上跑时一次都没触发** ——
这一条补上设备实测,并把途中撞出来的三类错法记进判据注释。
## 一、为什么必须有设备判据
第一次装上跑:**代码全对、手势一次都没触发**。原因是起点 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:
@ -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})`);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
Reference in New Issue
Block a user