用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」
★ ① ② 统一顶栏 `AppHeader`
改造前**六处各写各的**:`AdminUsersPage`(40×40 返回键+16 号标题)、
`SettingsPage`(20 号标题+40×40「+」+**实心 Theme.surface**)、
`MailDetailPage`(36×36 + 14 号标题,行高只有 14px 被裁过)、
`MainPage.SentTab`/`PermissionTab`(通栏、无圆角)、`CommTabBar`。
尺寸(36/40、14/16/20)、底色(实心白/透明/无)、返回键(有/无)三类都不一致;
且四处用 `Text('‹')` 当返回键 —— **用字符当图标**(本仓明令禁止,字形随字体变、
基线对不齐),而 `ICON_PATHS` 里**早就有** `chevronLeft`。
⇒ 抽成 `AppHeader`(返回键 + 标题 + `@BuilderParam` 右侧动作区),
几何复用底部条常量,材质走 `Theme.navMaterial`。已接入 5 处。
★ `topInsetPx` 由调用方传:状态栏避让是**窗口级**事实(属页面),
组件自己读会变成"每层各加一次"(平板侧栏 83+83 双计就是这么来的)。
★ ③ 发件箱"太大"—— 我的理由错了
我第一版让 `HEADER_HEIGHT = NAV_BAR_HEIGHT`(56),理由是"顶栏与底栏同在一根
竖轴上,高度不同会一眼看出来"。**那个理由不成立**:
底栏是**两行**(图标+文字标签)⇒ 需要 56;顶栏只有**一行标题** ⇒ 56 里一半是空白。
实测 `Row [277,315,1105,476]` = 161px = 56vp,标题那行只占 54px。
"两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正要对齐的是**左右留白与圆角**。
⇒ `HEADER_HEIGHT = 44`(= 可点区下限,返回键装得下)。
★ ④ 按压反馈 —— 两次失败才跑通,两个坑都记进注释
· **坑一**:`.attributeModifier()` **一个组件只能挂一个**,链两个是**后者覆盖前者**。
会话组头卡同时挂了玻璃与按压反馈 ⇒ 只生效一个,**编译器不报错、运行不提示**。
WebUI 是 CSS,类名天然叠加;ArkUI 是单一插槽,"叠加"必须显式做
⇒ 新增 `CompositeModifier`。
· **坑二**:`applyPressedAttribute` **只对自带按压状态机的组件**(`Button`)回调。
实测:挂 `Button` 上 → hilog 有输出;挂只有 `.onClick` 的 `Row`/`Column` 上
→ **一次都不回调**(按下与常态逐像素相同 `239,244,255`)。而全仓 117 处
`onClick` 的主体正是纯 `Row`/`Column` ⇒ 那条路对本仓没用。
改用 `onTouch` + `animateTo`。
· 底色用**系统点击效果色** `ohos_id_color_click_effect`(不是我自己挑的):
第一版用 `Theme.surfaceMuted`,实测只差 `3/3/3`,肉眼看不出 ——
因为"一块面的颜色"与"按下时叠的提示色"在系统色板里是**两个不同语义**。
设备验证:`onTouch type=0`(Down) → `type=1`(Up) 成对到达,像素确有位移。
★ ④ 滑动判定:从「时长门」改成「速度门」(**设计错误,不是调参**)
旧门 `SWIPE_MAX_DURATION_MS = 700`("整个手势超 700ms 就拒")。
而**时长 = 距离 ÷ 速度** —— 它把两个量混成一个 ⇒ 同样的手速下
**滑得越远越容易被拒**,屏幕越大越严重(平板同一手势像素更多)。
设备实测(3184×2232,位移恒 542.6vp):
600px/s→5909ms | 1200→3161ms | 1800→2006ms | 2500→1429ms | 5000→616ms | 15000→123ms
⇒ 旧门意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
—— 用户报的"滑不动/时灵时不灵"就是这个。
改 `SWIPE_MIN_SPEED = 0.25`(vp/ms,取自上表两档中间)后实测:
1200px/s → 0/3(正确拒绝:那是拖动) 2500px/s → **0/4 → 3/3**
5000px/s → **2/4 → 3/3**
两条判据跟着改形态(**语义不变**:都是"够快才翻页"):
· `cross-client-gesture` 断言"算的是 dx/ms"——只断言"有个门"会漏掉这次的错
(旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,而真机拒掉正常滑动);
· `harmony-logic` 的行为判据加了两条**回归钉**:
`542.6vp/1429ms`(实测的正常甩动)必须过;
`1200vp/3000ms`(同速度、更远)也必须过 —— 旧门正是在这里误拒。
★ ⑤ 转场动画:查清了,"不做页面级"是**决定**而不是漏做
WebUI `index.css:1230` 写明:页面级淡入会让**已在那儿的框架**也一起暗一下
("观感还是闪"),且用户 2026-09-14 **亲口否掉过**"整屏一起淡",
所以那一档整体删掉,只保留**局部**动效。鸿蒙侧当前是:
· `pushUrl` 推页 → **系统自带的滑动转场**(连拍 8 帧验证:第 3 帧能看到
写信页正在覆盖发件箱,是真实的横向滑入,不是硬切);
· 局部入场 → `paneRiseIn` / `menuIn` / `calendarSlide`(已接 12 处)。
所以"加 pageTransition"反而会与用户当时的否决冲突 —— 这一条**不做**,
理由记在这里,免得下次又当成遗漏。
判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
522 lines
28 KiB
JavaScript
522 lines
28 KiB
JavaScript
/**
|
||
* 左右滑动翻页的**语义契约**:两端逐项相同,数值各自定。
|
||
*
|
||
* 这条判据是 `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 (speed < SWIPE_MIN_SPEED) return v;`。
|
||
*
|
||
* ★★ 2026-09-20:鸿蒙侧从"**时长**上限"改成"**速度**下限",
|
||
* 本判据跟着改形态(**语义不变**:都是"够快才翻页")。
|
||
*
|
||
* 改的原因是实测撞出的**设计错误**,不是调参:时长 = 距离 ÷ 速度,
|
||
* 它把距离与速度混成一个量 ⇒ 同样的手速下**滑得越远越容易被拒**,
|
||
* 大屏(平板)尤其严重。设备实测(3184×2232,位移恒 542.6vp):
|
||
*
|
||
* 600px/s → 5909ms | 2500px/s → 1429ms
|
||
* 1200px/s → 3161ms | 5000px/s → 616ms
|
||
* 1800px/s → 2006ms | 15000px/s → 123ms
|
||
*
|
||
* 旧门 700ms 意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
|
||
* ⇒ 用户报"滑不动/时灵时不灵"。速度门与距离解耦,才是"快滑"该有的形状。
|
||
*
|
||
* 两端都要有这道门;**数值不比**((b) 口径)。
|
||
*/
|
||
const webFast = webCal.match(/Date\.now\(\)\s*-\s*start\.t\s*<\s*(\d+)/);
|
||
assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)');
|
||
assert.ok(Number(webFast[1]) > 0, 'WebUI 的时间窗应为正数');
|
||
|
||
/*
|
||
* 鸿蒙侧:门必须存在、且**必须按速度算**。
|
||
*
|
||
* 「按速度」是本判据要钉的形状 —— 只断言"有个门"会漏掉这次的错:
|
||
* 旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,
|
||
* 而它在真机上把正常滑动拒掉了。所以要断言"算的是 dx/ms"。
|
||
*/
|
||
assert.match(harmonyLogic, /export const SWIPE_MIN_SPEED:\s*number\s*=\s*[\d.]+/,
|
||
'鸿蒙的"快滑"门要走具名常量(裸字面量会让"这个数为什么是它"无从追溯)');
|
||
assert.match(harmonyLogic, /Math\.abs\(dx\)\s*\/\s*ms/,
|
||
'鸿蒙的快滑门要按**速度**(dx/ms)算,不是按时长 —— ' +
|
||
'时长把距离与速度混在一起,大屏上会误拒正常滑动(实测 700ms 门只有 ≥5000px/s 能过)');
|
||
|
||
/*
|
||
* ★ 反向断言:鸿蒙**不得**引用 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-20 修(本判据在套件里红、单独跑绿):**先回到已知的月份**。
|
||
*
|
||
* 本判据下面算的是"相对最初那一月的 delta",而它原来**只保证在月档、
|
||
* 不保证在哪一月** —— 于是前一屏停在哪个月、它就从那个月开始数。
|
||
* 套件里跑时,日历页可能已被别的判据(或上一次失败的手势)翻到 10 月,
|
||
* 而右滑那一步的期望值是按"最初那一月"算的 ⇒ 差一格就判红。
|
||
*
|
||
* 这不是"设备抖动",是**判据没有自己搭现场**:它依赖进入时的状态,
|
||
* 于是"通过与否取决于跑之前那一屏是什么"。同族毛病本仓已修过多次
|
||
* (`harmony-appearance` 那条设备判据、以及 `backToMain` 那段注释)。
|
||
*
|
||
* 修法:点「今天」把 anchor 归位到本月(该按钮的语义就是"回本月",
|
||
* 与 WebUI `goToday` 一致),再从那个确定的状态开始算 delta。
|
||
*/
|
||
assert.ok(tapText(hdc, '今天'), '要能点「今天」把月份归位(本判据需要一个已知起点)');
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
assert.ok(monthTitle(dumpLayout(hdc)) !== null,
|
||
'点了「今天」之后仍应在月档(否则下面的 delta 无从算起)');
|
||
|
||
/*
|
||
* ★★ 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})`);
|
||
});
|