From 4a5318ca28e2e1fbe1076845b7e4ac0567132ef0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 14:16:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E6=89=8B=E5=8A=BF?= =?UTF-8?q?=E8=A1=A5=E8=AE=BE=E5=A4=87=E5=88=A4=E6=8D=AE=EF=BC=88=E7=9C=9F?= =?UTF-8?q?=E6=BB=91=E3=80=81=E6=A0=87=E9=A2=98=E7=9C=9F=E5=8F=98=EF=BC=89?= =?UTF-8?q?+=20=E4=BF=AE=E4=B8=89=E5=A4=84"=E8=AE=BE=E5=A4=87=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E5=81=87=E7=BA=A2"=E7=9A=84=E5=85=B8=E5=9E=8B?= =?UTF-8?q?=E9=94=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交把滑动翻页做完了,但只验到"代码形态对"。**真装上跑时一次都没触发** —— 这一条补上设备实测,并把途中撞出来的三类错法记进判据注释。 ## 一、为什么必须有设备判据 第一次装上跑:**代码全对、手势一次都没触发**。原因是起点 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 —— 按判据要求重建 + 重打包,没有改记录迁就。) --- .../test/cross-client-gesture.test.mjs | 161 ++++++++++++++++++ client/electron/test/lib/harmony-device.mjs | 19 +++ client/electron/test/run-all.mjs | 2 +- 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/client/electron/test/cross-client-gesture.test.mjs b/client/electron/test/cross-client-gesture.test.mjs index 4c5a8c4..9204add 100644 --- a/client/electron/test/cross-client-gesture.test.mjs +++ b/client/electron/test/cross-client-gesture.test.mjs @@ -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})`); +}); diff --git a/client/electron/test/lib/harmony-device.mjs b/client/electron/test/lib/harmony-device.mjs index c7a06af..00efbab 100644 --- a/client/electron/test/lib/harmony-device.mjs +++ b/client/electron/test/lib/harmony-device.mjs @@ -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'); +} diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 8215126..9ad7db8 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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],