跨端: 左右滑动翻页(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 从「✅(滑动翻页除外)」改为 ✅。
This commit is contained in:
2026-09-19 14:01:21 +08:00
parent e8b260dd70
commit 28e3da76d4
16 changed files with 677 additions and 63 deletions

View File

@ -3,7 +3,7 @@
// ★★ 2026-09-19 重写本文件的头部说明(上面那一版是错的,与它判的东西矛盾):
// 旧文写的是「60px 图标轨 / 通信·日历·联系人(中)/ 设置(底)/
// 选中态只换颜色 + 品牌浅底,**不加背景块**」—— 那个描述是**编的**:
// · `Sidebar.tsx:26-44` 是**三项**(通信/日历/**联系**)——「我的」不在导航轨里,
// · `Sidebar.tsx` 第 26-44 行是**三项**(通信/日历/「**联系**」)——「我的」不在导航轨里,
// 而是 **底部头像按钮**(`Sidebar.tsx:186`);
// · `Sidebar.tsx:110` 有 `<span className="text-3xs">{short}</span>`(有文字);
// · `index.css:1590` 的 `.nav-item[data-active='true']` **有底色块**,
@ -13,9 +13,7 @@
//
// 鸿蒙侧原来**没有**宽屏布局(只有底部导航条),WideSidebar 补的就是"宽屏模式"这半:
// ≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
import { code, prose, stripComments } from './lib/read.mjs';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
@ -28,8 +26,12 @@ const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const WEBUI_SRC = join(ROOT, 'client/electron/src');
const read = p => prose(join(HARMONY_ETS, p));
/** 读 WebUI 的源文件(**不去注释**:注释里就有真实理由) */
const readWeb = p => readFileSync(join(WEBUI_SRC, p), 'utf8');
/*
* 读 WebUI 的源文件(**不去注释**:注释里就有真实理由)。
* ★ 用共享入口 `prose` 而不是裸 `readFileSync`:判据目录有一条 hygiene 判据
* 专门拦裸用(理由是那些用法会把"读哪个文件、剥不剥注释"变成每个文件各说各话)。
*/
const readWeb = p => prose(join(WEBUI_SRC, p));
/** 取某个成员/方法正文(按行切到下一个成员声明) */
function memberBody(src, signature) {
@ -121,10 +123,31 @@ test('② 侧栏**三项**(通信/日历/联系)+ 底部一簇 —— 与底
/* 头像右下角要带 SSE 连接状态点(WebUI 的 `<ConnectionIndicator/>`) */
assert.match(sidebar, /sseColorOf\(/, '头像要带连接状态点(颜色由 SSE 状态决定)');
const webInd = readWeb('components/ConnectionIndicator.tsx');
for (const [status, hex] of [['connected', '#22C55E'], ['connecting', '#FACC15'], ['reconnecting', '#FB923C'], ['disconnected', '#F87171']]) {
/*
* ★★ 2026-09-19 修:色值已从 `WideSidebar.ets` 抽到 `Theme.ets` 的令牌
* (`Theme.sseConnected` 等)—— 这是 `cross-client-theme` 那条
* 「鸿蒙页面里不得出现任何裸色值」要求的方向。
*
* 所以本断言相应改成:这里要有**四个令牌名**与 WebUI 四档的**逐档对应**。
* 分两半断(缺一不可):
* ① 令牌在 `Theme.ets` 里定义,且取值就是 WebUI 对应的那个色(# 值真的一样);
* ② 侧栏真的用了这些令牌(不是定义了不接)。
* 只断①会得到“有令牌但没人用”的假绿(`navMaterial` 就当过孤儿);
* 只断②会得到“接了但颜色抄错”的假绿。
*/
const themeSrc = read('common/Theme.ets');
for (const [status, hex, token] of [
['connected', '#22C55E', 'sseConnected'],
['connecting', '#FACC15', 'sseConnecting'],
['reconnecting', '#FB923C', 'sseReconnecting'],
['disconnected', '#F87171', 'sseDisconnected']
]) {
/* WebUI 用的是 tailwind 类名,这里判的是"四档都有"这个结构,不是具体色值 */
assert.ok(new RegExp(`${status}:`).test(webInd), `WebUI 的连接指示器要有 ${status} 档`);
assert.ok(sidebar.includes(hex), `鸿蒙的连接点要有 ${status} 对应的色值 ${hex}`);
assert.ok(new RegExp(`static readonly ${token}: string = '${hex}'`).test(themeSrc),
`① Theme.ets 里要有 ${token} = ${hex}(与 WebUI 的 ${status} 档同值)`);
assert.ok(sidebar.includes(`Theme.${token}`),
`② 侧栏要真的用 Theme.${token} —— 定义了不接 = 那档颜色永远画不出来`);
}
/* 「我的」由**头像**进入(不是导航轨里的一项) */
assert.match(sidebar, /this\.onSelect\(ME_PANE_INDEX\)/,
@ -150,13 +173,12 @@ test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是
const code_ = stripComments(sidebar);
// 从 WebUI 的真实源码里取侧栏选中底色(不写死,改了两边一起动)
const css = readFileSync(
join(ROOT, 'client', 'electron', 'src', 'index.css'), 'utf8');
const css = prose(join(ROOT, 'client', 'electron', 'src', 'index.css'));
const m = css.match(/--nav-active-bg:\s*(\d+ \d+ \d+)/);
assert.ok(m, 'WebUI index.css 要有 --nav-active-bg(侧栏选中底色)');
const [r, g, b] = m[1].split(' ').map(Number);
const hex = '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
const themeSrc = prose(join(HARMONY_ETS, 'common', 'Theme.ets'));
assert.match(themeSrc, new RegExp(`navActiveBg: string = '${hex}'`, 'i'),
`Theme.navActiveBg 要等于 WebUI 的 --nav-active-bg(${hex})`);
@ -357,7 +379,7 @@ test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑
assert.ok(chromeM, 'WebUI index.css 要有 --c-chrome-600(中性徽标底色)');
const [cr, cg, cb] = chromeM[1].split(' ').map(Number);
const plainHex = '#' + [cr, cg, cb].map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
const themeSrc = prose(join(HARMONY_ETS, 'common', 'Theme.ets'));
assert.match(themeSrc, new RegExp(`badgePlain: string = '${plainHex}'`, 'i'),
`Theme.badgePlain 要等于 WebUI 的 --c-chrome-600(${plainHex})`);