Files
MailUI4Agents/client/electron/test/cross-client-gesture.test.mjs
JianFeeeee 28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00

240 lines
13 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';
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,
'★ 方向写反时,②「左滑=下一段」那条必须能判红');
});