★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
而是 `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 从「✅(滑动翻页除外)」改为 ✅。
176 lines
11 KiB
JavaScript
176 lines
11 KiB
JavaScript
/**
|
||
* 联系人页(工作列表 / 联系人)的判据 —— 与 WebUI `ContactPanel` 对齐。
|
||
*
|
||
* ── 这一整个文件的来历 ──
|
||
*
|
||
* 用户 2026-09-18:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
|
||
* 把两个客户端**同一个宽度**并排看之后,缺的东西很具体:联系人卡片上 WebUI 有
|
||
* 「写信 / 归档」,鸿蒙一个都没有。
|
||
*
|
||
* 补的时候撞出三个**只有跑起来才会现形**的问题,所以这个文件判的正是那三样
|
||
* (不是"按钮在不在",而是"按下去会发生什么"):
|
||
*
|
||
* ① `MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}` ——
|
||
* **服务端根本没有这条路由**,而且全仓没有调用方 ⇒ 它是个从没跑过的死函数。
|
||
* 按钮接上去的那一刻就是 404。服务端真实形状是 `POST /contacts/archive`
|
||
* (body `{session_id}` 或 `{address}`)。
|
||
*
|
||
* ② `LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
|
||
* (界面出现「登录成功:jianf」)却**从不跳转**。而这条恰恰是老用户最常走的
|
||
* 那条(重启时 token 还在,`doLogin` 根本不会被调用)。
|
||
*
|
||
* ③ 卡片把 `last_activity` **原样印出来** ⇒ 屏上是
|
||
* `2026-09-18T02:50:35.49065Z`,而 WebUI 是 `09/18 10:50`。
|
||
*
|
||
* ⚠️ 这个文件读的是**文本**。它不能替代设备验证:按钮真的画出来、点下去真的
|
||
* 发出那个请求、确认框真的换掉那一张卡片 —— 那三条我是在三折叠模拟器上
|
||
* 用 `dumpLayout` + `uitest uiInput click` 实测过的(见提交信息)。
|
||
*/
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { code, prose } from './lib/read.mjs';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
|
||
|
||
/*
|
||
* ★ 2026-09-19:本文件原先**自造**了一个 `code()`(自己写去注释正则)。
|
||
* `criteria-hygiene` 判据拦的正是这个 —— 判据目录有一条"裸用 readFileSync"的
|
||
* 禁令,理由不是洁癖:`code`/`prose` 的选择决定"判的是代码还是注释",
|
||
* 各文件自造一份,就等于每个文件对同一件事有各自的说法。
|
||
* 改用 `test/lib/read.mjs` 的共享入口。
|
||
*/
|
||
|
||
|
||
const MAIN = join(ETS, 'pages', 'MainPage.ets');
|
||
const API = join(ETS, 'api', 'MailApi.ets');
|
||
const LOGIN = join(ETS, 'pages', 'LoginPage.ets');
|
||
const GROUP = join(ETS, 'model', 'MailGrouping.ts');
|
||
|
||
test('★ 归档打的是服务端**真有的**那条路由(POST /contacts/archive,不是不存在的 DELETE /me/contacts/…)', () => {
|
||
/*
|
||
* 这条判的是 ① 。原来那个实现不只是"过时"——它指向的路径
|
||
* 在 `server/cmd/server/main.go` 里**一条都没有**,所以按下去必然 404,
|
||
* 而按钮一旦出现在界面上,用户看到的就是"点了没反应/报错"。
|
||
*
|
||
* 判据形状:既钉住**用了** `POST /contacts/archive`,也钉住
|
||
* **没有**再用那条不存在的 DELETE 路径。只钉前者的话,加回一个
|
||
* 平行实现(两个都发)也能全绿 —— 那不是"修好了"。
|
||
*/
|
||
const src = code(API);
|
||
assert.match(src, /this\.client\.post<[^>]*>\(\s*'\/contacts\/archive'/,
|
||
'archiveContact 必须 POST /contacts/archive(服务端 handler.ArchiveContact 的真实路由)');
|
||
assert.doesNotMatch(src, /\/me\/contacts\//,
|
||
'不要再打 /me/contacts/… —— 服务端没有这条路由(这次修的就是它)');
|
||
/*
|
||
* body 必须带 session_id:服务端 archiveRequest 接受 session_id 或 address,
|
||
* 但 address 会随别名变化,只有 session_id 是会话的身份。
|
||
* WebUI 也用 session_id(contactStore.archive → api.archiveContact({session_id}))。
|
||
*/
|
||
assert.match(src, /session_id/, '归档请求体要带 session_id(address 会随别名变化,不是身份)');
|
||
});
|
||
|
||
test('★ 「写信 / 归档」两个动作在**两种视图**里都有(WebUI 两视图同语义)', () => {
|
||
/*
|
||
* WebUI `ContactPanel` 的卡片视图(`WorkCard`)与列表视图(`ContactRow`)
|
||
* 都带这两个动作,且**共用同一个确认框**(原话:归档是破坏性操作,
|
||
* 换个视图就换套确认 UI 只会让人对「自己点了点什么」更没底)。
|
||
* 鸿蒙两视图都要有 —— 只加一边的话,切到另一边就像"这个功能没了"。
|
||
*/
|
||
const src = code(MAIN);
|
||
const writes = [...src.matchAll(/this\.CardAction\('写信'[\s\S]{0,80}?composeTo\(c\)/g)].length;
|
||
const archives = [...src.matchAll(/this\.CardAction\('归档'[\s\S]{0,80}?requestArchive\(c\)/g)].length;
|
||
assert.equal(writes, 2, `「写信」要在卡片视图与列表视图各一处,实际 ${writes} 处`);
|
||
assert.equal(archives, 2, `「归档」要在卡片视图与列表视图各一处,实际 ${archives} 处`);
|
||
// 两个动作各自的去向也要对:写信 → composeTo(预填该地址),归档 → requestArchive(先确认)
|
||
assert.match(src, /composeTo\(c: Contact\)[\s\S]{0,400}?to: c\.address/,
|
||
'composeTo 要把该联系人的三维地址预填进 ComposeParams(WebUI: startCompose({to: c.address}))');
|
||
assert.match(src, /requestArchive\(c: Contact\)[\s\S]{0,200}?pendingArchiveId = c\.session_id/,
|
||
'requestArchive 只打开确认框、不发请求 —— 破坏性操作先确认');
|
||
});
|
||
|
||
test('★ 归档要**先确认**,且两种视图共用同一个确认框(不是各画一个)', () => {
|
||
/*
|
||
* 「共用」这条必须是机器可判的:如果两个视图各画一个确认框,
|
||
* 文案就会各自漂移,而漂移之后没人会同时看两边。
|
||
* 判据:确认框的 builder 只有一个定义,两个视图都调它。
|
||
*/
|
||
const src = code(MAIN);
|
||
const defs = [...src.matchAll(/@Builder\s+ArchiveConfirmCard\(/g)].length;
|
||
assert.equal(defs, 1, `ArchiveConfirmCard 只应有 1 个定义,实际 ${defs} 个`);
|
||
const calls = [...src.matchAll(/this\.ArchiveConfirmCard\(c\)/g)].length;
|
||
assert.equal(calls, 2, `两个视图都要调同一个确认框,实际 ${calls} 处调用`);
|
||
// 文案与 WebUI `ArchiveConfirm` 逐字一致(用户是在两个客户端之间来回看的)
|
||
assert.match(src, /'归档 ' \+ c\.address \+ '?'/, '确认框主句要与 WebUI 逐字一致:归档 <address>?');
|
||
assert.match(src, /'对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除'/,
|
||
'确认框副句要与 WebUI 逐字一致');
|
||
// 确认态下点卡片不许打开会话(那块区域已经是确认框了)
|
||
assert.match(src, /if \(this\.pendingArchiveId !== c\.session_id\) \{[\s\S]{0,80}?this\.openSession\(c\);/,
|
||
'确认态下卡片的点击不能再打开会话');
|
||
});
|
||
|
||
test('★ 时间戳要按 WebUI 的口径格式化(不是把 ISO 串原样印出来)', () => {
|
||
/*
|
||
* 这条判的是 ③ 。原样印 `last_activity` 得到的是
|
||
* `2026-09-18T02:50:35.49065Z` —— 秒级微秒的 UTC 串。
|
||
* WebUI 用 `toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})`
|
||
* ⇒ `09/18 10:50`(**本地**时区)。
|
||
*
|
||
* 两件事都要判:卡片/列表**用了**格式化函数,且那个函数走 `Date` 取本地字段
|
||
* —— 不许 `.slice()` 切字符串(那是拿 UTC 的月/日当本地时刻用,
|
||
* UTC+8 的 09-15 00:30 会显示成 09-14 16:30,而格子/表头全都正常)。
|
||
*/
|
||
const main = code(MAIN);
|
||
assert.doesNotMatch(main, /\+ c\.last_activity\b/,
|
||
'last_activity 不许原样拼接显示(会印出 ISO 串),要走 shortTimeOf');
|
||
assert.match(main, /shortTimeOf\(c\.last_activity\)/, '要用 shortTimeOf 格式化');
|
||
|
||
const g = code(GROUP);
|
||
assert.match(g, /export function shortTimeOf\(/, 'MailGrouping 要导出 shortTimeOf');
|
||
const fn = g.slice(g.indexOf('export function shortTimeOf('));
|
||
assert.match(fn, /new Date\(ms\)/, '必须走 Date 解析');
|
||
assert.match(fn, /getMonth\(\)/, '月份要取**本地**字段(getMonth),不是 UTC');
|
||
assert.match(fn, /getDate\(\)/, '日期要取本地字段(getDate)');
|
||
assert.doesNotMatch(fn.slice(0, 900), /slice\(/, '不许用 slice 切 ISO 字符串当日期');
|
||
// 自检:探测器要真能分辨"格式化过"与"原样印"
|
||
assert.match('shortTimeOf(c.last_activity)', /shortTimeOf\(c\.last_activity\)/);
|
||
assert.doesNotMatch("' 封 · ' + c.last_activity", /shortTimeOf\(c\.last_activity\)/);
|
||
});
|
||
|
||
test('★ 用已存 token 恢复登录也必须**真的进主界面**(不能只显示「登录成功」)', () => {
|
||
/*
|
||
* 这条判的是 ② ,也是这一轮里最恶劣的一个 —— 因为它**看起来是成功的**:
|
||
* 界面上出现「登录成功:jianf」,然后永远停在登录页。
|
||
* 实测证据:模拟器日志只有 `→ GET /auth/me` 然后什么都没有。
|
||
*
|
||
* 判据形状(不是"文件里有 pushUrl"这种能靠别处蒙混过去的写法):
|
||
* 把 `tryRestore` 的**函数体**切出来,要求它自己含跳转主界面那句。
|
||
* 只判全文件出现次数的话,另外两条路径里那两句就够让它变绿。
|
||
*/
|
||
const src = code(LOGIN);
|
||
const start = src.indexOf('async tryRestore(');
|
||
assert.ok(start > 0, 'LoginPage 要有 tryRestore');
|
||
// 从函数头到下一个同缩进的成员定义为止
|
||
const rest = src.slice(start);
|
||
const end = rest.indexOf('\n }\n');
|
||
const body = rest.slice(0, end > 0 ? end : 2000);
|
||
assert.match(body, /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
|
||
'tryRestore 成功后必须跳转主界面 —— 它原来只设 loggedIn=true 就结束了');
|
||
/*
|
||
* 还要登记账号 + 建 SSE:少了它们,"恢复登录"进主界面是个瘸的状态
|
||
* (设置页看不到账号;而 aboutToAppear 的快速路径正是靠账号命中的,
|
||
* 所以下次启动又会重走 tryRestore ⇒ 永远进不去)。
|
||
*/
|
||
assert.match(body, /addAccount\(/, 'tryRestore 要把账号写进多账号管理器(否则下次启动还会重走这里)');
|
||
assert.match(body, /connectForAccount\(/, 'tryRestore 要建 SSE(否则主界面收不到实时事件)');
|
||
// 自检:这条判据必须能抓到"只设 loggedIn 不跳转"那个原形状
|
||
const broken = "async tryRestore(t) {\n this.loggedIn = true;\n }\n";
|
||
const bStart = broken.indexOf('async tryRestore(');
|
||
const bRest = broken.slice(bStart);
|
||
const bEnd = bRest.indexOf('\n }\n');
|
||
assert.doesNotMatch(bRest.slice(0, bEnd > 0 ? bEnd : 2000), /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
|
||
'自检:原形状(只有 loggedIn=true)必须判红');
|
||
});
|