Commit Graph

11 Commits

Author SHA1 Message Date
c1465e09ab 跨端: 平板三处真 bug(返回回登录页 / 避让重复叠加 / 联系人卡被裁 10vp)
用户报的「在主页返回为什么会直接回到登陆页」是**原语用错**:
LoginPage 用 pushUrl 进 MainPage,路由栈成 [LoginPage, MainPage],
返回自然弹回登录页。而 Logout.ets 早就是 replaceUrl 并写了理由
(「退出后不该还能'返回到已登出的页'」)—— 同一个不变式、相反方向,
只改了一半。三处 pushUrl → replaceUrl,设备实测:返回直接退出 app
(前台变 com.huawei.hmos.browser),不再回登录页。

另两处平板(HUAWEI MatePad Pro, 2800x1840, ratio 1.52 ⇒ 宽屏):
· 内容列 `this.isWide ? Theme.surface : (bgActive ? 透明 : surface)`
  —— 宽屏分支把 bgActive 丢掉了 ⇒ 平板上壁纸永远被挡。
· `top: this.isWide ? paneGap : statusBar` —— 宽屏分支把状态栏避让丢掉
  ⇒ 页签字压在系统时钟下。
· 侧栏自己也加了一次 topInset,而父 Row 的 padding 已经含状态栏
  ⇒ 83+83=166px 空白(用户:「避让有点用力过猛」)。
· 联系人列表项硬写 .height(85)+clip,内容实际要 95vp ⇒ 写信/归档行被裁一半。

判据 harmony-nav 新增「登录/退出必须用同一个原语」并做变异验证
(改回 pushUrl 即红);注册数 18→19,全绿 19/19。

★ 途中发现的记账缺口:设备判据连续失败时走 noteBusySkip 计数,
  .tmp/harmony-busy-skips.json 累到 10 后拒绝再当'礼貌跳过'。
  清除账本 + 让 app 真在前台后立刻 19/19 —— 说明**不是代码问题**,
  是账本把'设备忙'当成了证据。这一点记进 DEBTS。
2026-09-19 23:38:35 +08:00
13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00
21132647bc 跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了

此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么",
不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。

新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` /
`hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。

**它当场证明了它的价值**:`cross-client-theme` 新增的设备判据
用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。

## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用

`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` ——
一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。

- 浅色下当白字用**碰巧对**(白字压蓝底)
- **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见**

设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**;
读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。

`Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在,
却**一处都没用**。

修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg`
(`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。
另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。

## 三、判据(两条,都做了变异验证)

1. **设备条**(`cross-client-theme`):读悬浮球像素 ——
   ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染);
   ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。
   把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。
   设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。

## 四、判据自身踩的三个坑(都写进注释了)

- **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。
  截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。
- **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`),
  实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。
  原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。
- **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页),
  而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。

## 五、欠账

- `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了
  (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。
  如实记着**未验**的那点:预算条的**点击**没在设备上走通
  (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。
- `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 ——
  `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。
- `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`;
Go 侧 `./internal/repo/...` 通过。
2026-09-19 19:29:49 +08:00
1bf687f506 判据: 图片上传链的设备判据(用真实素材)+ 静态欠账从 6 减到 5
继续升级到期的静态判据。这一批做 `harmony-imageprep`(上传链),
并顺手把已完成的 `harmony-admin` 移出欠账名单。

## 一、图片上传链:用**真实素材**验压缩决策的输入

`run-all.mjs` 的 `STATIC_ONLY` 里那条登记写着
「上传链的设备侧:`@ohos.multimedia.image` + 相册要设备才能真跑」。
上面 30 条判的都是 `model/ImagePrep.ts` 的纯逻辑(阈值、单调性、边界…),
它们全绿时有一件事从未验过:**那些数字与设备上真实的图片对得上吗**。

新判据用**用户真上传过的那张壁纸**(`GET /me/appearance/image` 取回,
1402×1122 / 152570 字节 —— 不是合成图),断三个跨端事实:
① 设备上读到的像素尺寸 = 决策时用的尺寸;
② 真实体积在客户端上限之内;③ 该尺寸走 `planCompress` 首档**不缩小**
(长边 1402 < 2560)+ `judgePick` 放行。

★ **为什么不合成图**:纯色能压到几 KB、噪声几乎压不动,用它们验阈值
会得到"怎么都对"的假绿。真实照片的行为才是要验的那个。

★ **诚实标注了没做的那一半**(写在判据注释与 `DEBTS.json` 里):
本判据用的是**设备上的 `file`/`ls`** 这一独立来源读素材属性,
**没有**跑 `image.createImagePacker()`。跑它需要一个**用户选图**入口
(`DocumentViewPicker`,要人操作系统选择器),自动化里没有稳定路径;
而编一个"绕过选择器直接调 `packJpeg`"的测试专用入口,会是**只有测试在用的代码**
——那种代码不会被真实场景触到,验它等于验一个不存在的东西。

## 二、静态欠账 6 → 5(还完就划掉)

`harmony-admin` 那条**已升级为设备判据**(上一批做的),
所以它**不该再留在 `STATIC_ONLY` 里** —— 那个名单是给"还欠着的"记账的。
留着会让余额虚高,而这正是这个机制要防的(欠账不显形就等于没有)。

## 三、途中被两条"登记一致性"判据拦了两次(都按它们给的方向修)

1. `debt-visibility`:我在 `harmony-imageprep` 里新增了两处边界声明
   ("未覆盖/未验"这类词),而余额里没登记 ⇒ 红。
   **按它要求的顺序做**:先补 `docs/DEBTS.json`(`harmony-p4c-boundary-decls`
   那一笔的 note 里写明"只做了一半"),再把登记次数 4 → 6。
2. `commit-hygiene`:`DEBTS.json` 说 `static-criteria=6`,实测 5 ⇒ 红。
   —— 这条正是"可见的那个数字是副本,漂移了必须两边一起改"。
   改数字之外还在那一笔里写了**为什么减**(admin 升级并移出名单)。

★ 第三处被拦很有意思:`debt-visibility` 是**按词表数自己**的判据,
我第一次修时在那个文件里写了一句话里含"仍未覆盖",于是它把自己数多了 1 处
(12 → 13)。那一刻是**判据在正确地工作**("多一处即红")——
我写的其实是**引用**另一笔账,不是新的边界声明,所以改成了不带判定词的措辞。

## 四、判据

`run-all.mjs` → `files=32 ran=32 checks=507 pass=507 fail=0 skip=0
red=0 broken=0 unreported=0`。`harmony-imageprep` 30 → 31;
`harmony-admin` 移出静态名单(仍在套件里,28 条)。
`hvigorw assembleHap` 成功;前端重建。

**剩余到期未升级**:`harmony-appearance`(已有 2 条设备判据)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults` —— 逐条来。
2026-09-19 16:18:10 +08:00
a18014e1e3 判据: 管理页设备判据(到期静态判据的第一条升级)+ 修设备判据基建的三个真 bug
`run-all.mjs` 的 `STATIC_ONLY` 里登记着 6 条"只能静态验"的判据,前提是
「本工作区能装、能点设备」。设备现在可用 ⇒ 它们**到期**了(欠账当场变红)。
这一批升级第一条:`harmony-admin`(用户管理页)。

## 一、新设备判据:管理页真的能打开、列表真的渲染

上面 27 条静态判据判的都是逻辑与接线形态。它们全绿时,
"管理页能不能打开、列表能不能渲染"**一句都没验过** —— 而它最容易坏:
路由注册对了但入口没接上、接口回来了但列表没渲染。

判据自己搭现场:拉起应用 → 进「我的」→ 滚到底 → 点「管理」→
断言真的在管理页(出现「新建用户」)且**渲染出用户行**。

## 二、途中撞出三个**判据自身**的 bug(都已修,都写了教训)

1. **`tapText` 从来没成功点过任何东西**(`lib/harmony-device.mjs`)
   `findByText` 返回的是**数组**,我当单个节点用了 ⇒ `node.attributes` 恒
   `undefined` ⇒ 恒返回 `false`。症状极隐蔽:调用方以为"没找到那个文案",
   实际是帮手自己坏了。修:从数组里挑,且**优先挑可点的那个**
   (同名文案常常一个可点、一个不可点)。

2. **判据之间互相干扰**(新增 `backToMain`)
   每个设备判据都是写操作,会把前台留在它操作完的那一页。`harmony-admin`
   把人留在管理页 —— 那是 `pushUrl` 出去的独立 `@Entry` 页,**没有侧栏**
   ⇒ 后面的 `cross-client-gesture` / `harmony-appearance` 找不到侧栏、
   双双 skip(skip 原因写的是"宽屏侧栏找不到",看起来像功能没了)。
   `launchOurApp` 解决不了(`aa start` 只切前台,不弹栈)⇒ 新增 `backToMain`。

3. **"找不到元素"要先分清是功能缺失还是判据没摆好现场**
   本判据连栽四种形态,每一种都伪装成"功能缺失":
   · 锚点文案错(入口是「管理」,我按「用户管理」找 —— 后者只是说明的一部分)
   · 元素在滚动下方(dumpLayout 只报可见节点)
   · 文字节点不可点(`.onClick` 在包住它的容器上,ArkUI 的常态)
   · **滚动步长跨过了它**(诊断打印现场才发现:停在了「系统通知」那一带,
     而「管理」只占 ~0.04 屏,一次 0.2 屏的滑动必然越过)
   ⇒ 最后改成"**先滚到底、再小步回扫**"(只管往前找在不均匀列表上必漏),
     并加了 `AGENTMAIL_ADMIN_DEBUG=1` 的诊断入口把现场打出来。

## 三、判据

`run-all.mjs` → `files=32 ran=32 checks=505 pass=505 fail=0 skip=0
red=0 broken=0 unreported=0`(连跑两次稳定)。
`harmony-admin` 27 → 28 条。`hvigorw assembleHap` 成功;前端重建。

**设备实测**:管理页从「我的」页打开,显示「管理 / 用户管理 3 / 新建用户」
与三行用户(jianf 管理员 / gui-lab 用户 / test 用户)。

**仍到期未升级**:`harmony-appearance`(已加 2 条设备判据,但登记里其余部分仍静态)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults`、`harmony-imageprep`
—— 按"每条缺什么设备侧验证"逐条来,不为了消数字而凑。
2026-09-19 15:53:34 +08:00
f811c9887a 跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据
这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
2026-09-19 15:03:40 +08:00
4a5318ca28 跨端: 手势补设备判据(真滑、标题真变)+ 修三处"设备判据假红"的典型错法
上一提交把滑动翻页做完了,但只验到"代码形态对"。**真装上跑时一次都没触发** ——
这一条补上设备实测,并把途中撞出来的三类错法记进判据注释。

## 一、为什么必须有设备判据

第一次装上跑:**代码全对、手势一次都没触发**。原因是起点 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 —— 按判据要求重建 + 重打包,没有改记录迁就。)
2026-09-19 14:16:30 +08:00
f2cddf41d2 修复: 包名从 com.agentmail.harmony 改成 com.jianf.agentmail 后,有一条行为判据**永远走"设备忙"跳过**
被发现的方式值得记:那条判据从没红过(它从不执行断言),是**"跳过也要有界"**
那条闹钟把 42 轮连续跳过顶成红,才露出来的。这正是设界要抓的形状 ——
判据既不算红也不算绿 ⇒ 永远不必被升级。

## 根因:包名有四处字面量,改名只改了三处

AGC 拒绝 `com.agentmail.harmony`(`harmony` 是包名保留字),于是改成
`com.jianf.agentmail`(见 `docs/ALIGN-REFS.json` 的 `agc.packageName` 与
`align-refs.test` 那条一致性判据)。但那个字面量在判据里是**各自抄的**:

  · `AppScope/app.json5`                ← 唯一权威
  · `harmony-deviceprobe.test.mjs:24`   ← 改了(它判 AGC 匹配)
  · `harmony-nav.test.mjs:810`          ← **没改** ⇒ 前台判定永不成立
  · `lib/harmony-device.mjs:280` 注释   ← 没改(只是注释,但同样在传播旧名)

于是 `foregroundBundle(hdc) === 'com.agentmail.harmony'` 恒假,行为判据每次
都记一次"设备忙"。账本 `.tmp/harmony-busy-skips.json` 实测数到 **45**。

## 修法

1. `lib/harmony-device.mjs` 新增 `ourBundle()` —— 从 `AppScope/app.json5`
   **唯一权威处**读包名(惰性、读不到返回 null,由调用方决定)。
2. `harmony-nav.test.mjs` 的比较改成 `fg !== ourBundle()`。
3. 清除 `.tmp/harmony-busy-skips.json` 里那 45 轮**假忙计数** ——
   它们不是设备争用,是字面量漂移,留着会污染"跳过余额"的含义。

## 判据(防止这一类漂移再发生)

`harmony-deviceprobe.test.mjs` 新增 3 条(8 → 11,已同步 run-all 棘轮):
  · 判据目录里**不许再出现包名字面量**(除 lib/ 唯一读取处与本文件——它要拿
    真机样本 `fixtures/aa-dump-l-*.txt` 里的包名做**逐字**对照,属具名例外)
  · 判据自检:ourBundle() 读得出值、且不带 `harmony`(AGC 保留字)
  · 变异自检:把一条消费方改回硬编码 ⇒ 必须判红(已验证)
    且注释里的历史说明**不许误红**(`align-refs` 正靠注释解释为什么换名)

验证:harmony-deviceprobe 11/0、harmony-nav 18/0(行为那条**真跑过**、
不再是跳过)、criteria-hygiene 6/0。
2026-09-18 08:39:21 +08:00
0822993e76 修复: 我上一笔升级留下的**更糟的灰区** —— 跳过要**具名**、"移出 STATIC_ONLY"要**要求行为层真跑过**(pi 的变异证明我复现了)
pi 2026-09-18 报的是**我上一笔(b84880f)自己的缺陷**,我按两处都实测复现,没有一条靠信。

## 一、缺陷:升级后有一条真实回归**零痕迹**

形状是**两件事同时发生**:
1. `harmony-nav` 移出 `STATIC_ONLY`(7→6)⇒ 到期闸**不再点名它**;
2. 行为条在设备不在时 `t.skip` ⇒ **不跑,也不算红**。

⇒ 这条判据有了一个"既不红、也不算没升级"的状态。升级前它在 `STATIC_ONLY` 里,每轮都被
点名(红但**看得见、可行动**);升级后**不被点名**,而行为条可以**永远不跑**。

**我的复现**(不是转述):注入 `assert.ok(false,'MUTANT: 真实回归')`,同一棵树两种设备态:

| | RESULT | 红清单里有 harmony-nav |
|---|---|---|
| 设备在 | `checks=459 pass=454 fail=5 skip=0 red=10` | **有** |
| 设备不在 | `checks=459 pass=454 fail=4 skip=1 red=9` | **没有** —— 与未变异基线**逐条一致** |

文件级同样:设备在 `fail 1`;设备不在 `fail 0 / skipped 1`(变异**够不着**)。
★ 还有一层 pi 点出的:两次 `verdict` 都是 `red`,是**被别的红兜住的** ——
等那批红清掉,这条回归就能让套件在"绿"的状态下藏着。

## 二、闸 (i):跳过必须**具名**(与红清单同级)

原来 `skip=N` 只在余额里**数得出来**,但**看不出是谁** —— 于是和"设备恰好不在、
什么都没坏"不可区分。node:test 其实**已经**把原因打出来了(`ok 16 - … # SKIP 设备不在 —— …`),
是解析只取了计数、把名字和原因丢了。⇒ 取回来,并在汇总里按**与红清单并列**的格式打印
(文件 + 判据名 + 原因)。实测(设备不在):

```
跳过的判据(1 条,分布在 1/29 个文件)—— **不是通过**,也不等于没问题:
  - test/harmony-nav.test.mjs(跳过 1 条)
      「★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)」:设备不在 —— 行为部分本次不跑
```

## 三、闸 (ii):移出 `STATIC_ONLY` 必须**出示行为层绿跑记录**

"7→6"原来是纯**记账动作**:从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求
行为条真的执行过**。⇒ 行为条**断言全过之后**留一条本机记录(`noteBehavioralRan`),
`run-all` 拿它当结算前提;没有记录就**点名**(回到被看见的状态)。

★ 方向要紧:宁可**多报**(说你还没验过),不许**漏报**(把没验过的当成验过了)。

★ 我自己在这里补了一层 pi 没提的收口:**只在到期前提成立时才追这条账**。否则没设备
的机器上行为层**不可能**留记录,那条红就是**天天假红且无法行动**(没人能在那台机器上
把它做绿)—— 正是探针三值设计要避免的。前提不成立时,可见性交给闸 (i)(具名 + 连续
轮数 + 超 K 自红)。四态实测:

| 场景 | 闸(ii) | 闸(i) |
|---|---|---|
| 设备在 + 有记录 | 静默 | 静默 |
| 设备在 + 记录被删(真跑不了) | **红** | **点名** |
| 探针说不(无设备机器) | 静默(避免清不掉的假红) | **点名** |
| 设备在但"忙"(前台是别人的) | **红** | **点名** |

## 四、我在实现过程中自己写错的(照实记)

1. **自检抄了一份副本**:我把 `# SKIP` 正则**复制**进 `--skip-selftest`
   (当时的想法是"自检不该依赖被测对象")—— 那样自检验的是**副本**,生产那份改坏了
   自检照样绿。是变异时 `AssertionError: 命中 2 处` 把它暴露出来的。
   ⇒ 抽成唯一实现 `parseSkips`,自检**直接调它**。再变异**生产那份**(去掉破折号剥离)
   ⇒ 自检红(`why: "- 设备忙"` vs `"设备忙"`)、exit 1,**这次验的是真身**。
   这正是本仓反复消的"同一个事实多份实现"(`blurStyleFor`、`stripStrings` 兄弟副本同款)。
2. **自检案例我写错了**:第一条拿的是"不带 ` # SKIP` 标记的行"却期望解析出 1 条 ——
   自检当场红。真形状一定带标记;不带标记的行正是另一条要钉的"不许被当成跳过"。
   (顺带:自检连**我写测试时的错**都抓到了,方向对。)
3. **账本路径两处各拼一次**:`run-all` 与 `lib/harmony-device.mjs` 各按自己的位置算
   `.tmp/` 路径。已注释说明"写的那边是唯一权威、这边只读,路径若漂移会**多报**不会漏报"。

## 验证

· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 与改动前**同样 9 条**
  (并发会话的"自报>清单" + 4 条 exit 1),两条新闸在正常路径上**都静默**。
· 变异:M7 自检②、M8 自检③、M12 自检正则、M14 **生产**解析器,全部按预期红并已还原。
· `--skip-selftest` 5/5 绿;它与既有 `--probe-selftest` **同形状**(都先跑套件再判定),
  不是我引入的新形状。
2026-09-18 05:05:38 +08:00
4f386199cf 修复: 集合指纹**不是集合的函数**(顺序敏感 + 只含 name:size);"读不到"曾静默报成"全 0 且指纹正常";"设备忙"的跳过**加界**;criteria-hygiene 两处按字面量裁射程
pi 2026-09-18 报的两个洞我**都当场复现了**(不是我信了,是跑出来了),修完都验了阳性对照。

## 1. 集合指纹必须是集合的函数(pi 洞 1,两个都复现)

原来 `'\n'.join(f'{f}:{getsize(f)}' for f in listed)`。实测:

· **顺序敏感**:只把 `jobs.manifest.json` 反序(集合/内容/计数全不变)
  ⇒ `sha=02502771` → `3439e049`。于是"集合变了 ⇒ 一眼看得出"失效,
  **每次清单整理都假变**。
· **只含 name:size**:字节级等长改写(`jobs-one.json` 的 `name: 'image'` → `'imoge'`,
  265 字节不变)⇒ **指纹仍是 02502771**。更尖锐的是 file 路径改成等长的
  `ApiClienX.ets`(指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变而
  **指纹不变** —— **"集合没变而数变了"恰恰是它声称要抓的情况,它抓不到。**

⇒ `sorted()` + 内容哈希。实测:原序/反序/排序三种**同一哈希**(`33ff3bea`);
等长改内容 ⇒ 变(`89b2aa41`)。

## 2. "读不到" ≠ "不存在"(pi 洞 2,复现 + 比 pi 说的更糟)

隔离副本 + `runuser -u nobody` + `chmod 644 jobs/` 实测:
```
nobody: mutants=0 ran=0 skipped=0 … 原始条目 0   sha=e3b0c442   rc=0
```
`e3b0c442` = **空字符串的 sha256**;而且退出码 **0**。根因:`exists()` 对
**不可进入目录里的文件**返回 **False**(实测),12 个全被跳过;而 `glob` 那一半
照样列得出 12 个 ⇒ `unlisted`/`ghosts` 全空 ⇒ **"清单与磁盘不一致"那条警告一声不响**。
**同一份权限,两个半边给出互相矛盾的结论。**

⚠️ 比 pi 说的更糟的一点:那行**同时**印着 `清单 12 个 job 文件` 和 `sha=e3b0c442`
—— 一句话里说"12 个"却一个都没读到。

⇒ ① 判目录可进入性(`access(R_OK|X_OK)`),读不到就 `✗✗` 明说数字**全部无效**、
并点明"这不是空集合";② `open` 接住 `OSError`,把读不到的文件**攒起来一次报全**
(原来会死在第一个文件上抛 `PermissionError`,让人以为"就这一个");
③ 退出码 **2**(本仓约定:2=环境)—— 读不到就是没读数,而**没读数不是成功**。
④ `unreadable` 与 `ghosts` **分开报**:修法完全不同(修权限 vs 删条目)。

★ 这里我先写了个**不可达的分支**:`unreadable` 先探后读,而真读时 `PermissionError`
会提前抛出 ⇒ 那段报告永远走不到("判据在,但走不到",这次长在报告分支上)。
自己查出来并改成"读的时候接住",才让它成为可达的真分支。

## 3. "不抢前台"的跳过**加界**(pi 2026-09-18 §3,我接受)

只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红不算绿 ⇒ 永远不必被升级
—— 到期机制要防的正是这个,只是入口换成了"设备忙"。⇒ 连续 K(默认 3)轮没跑成,
**跳过自己变红**并给出接管路径(K 轮内是礼貌,K 轮外是闹钟)。

边界:**只对"设备在、前台不是我们的"计数**;**设备不在不计数也不变红** ——
那是 `PROBES.device` 的既有裁定(没装 SDK 的机器不该天天假红),超出本模块职责。

账本 `.tmp/harmony-busy-skips.json`(已 gitignore):判"**这台机器上**连续多少轮没验成",
换机器不继承。实测 1→2→3 轮 skip、第 4 轮起 FAIL;跑成一次即清零、再从 1 重新数。
自检(不连设备)把 ①K 轮内礼貌 ②超了必红 ③跑成清零 ④上限可覆盖 都钉住,
变异验证 M7(over 恒 false)⇒自检②红、M8(noteRan 不清零)⇒自检③红。

## 4. 顺带修掉 criteria-hygiene 自己两处"按字面量裁射程"

我按纪律把账本读取从裸 `readFileSync` 改成 `prose()`(`criteria-hygiene` 立刻红,
**那条判据是对的、我错了**),接着暴露出该判据自身两个洞:

· **import 按写法硬匹配**:只认 `'./lib/read.mjs'`/`'../lib/read.mjs'`,
  而 `harmony-device.mjs` **就在 `lib/` 里**、按惯例写 `'./read.mjs'` ⇒
  假红"根本没 import"(其实 import 了、运行时完全正常)。改成**解析说明符后与
  `SELF` 比**(本仓已用"解析后比较"解决过同一族:`abspath`、`join(HERE,…)`)。
· **用 `prose()`(原文)判"用没用"** ⇒ **注释里**写 `prose(…)` 就算用了
  (实测该文件命中 3 处、只有 1 处是真调用)。改成 `code()`。这与它上面那条
  "不许裸用 readFileSync"踩过的是同一个坑,我在那条上写了理由、**这条漏了**。

两处修法都做了承重验证:删真 import ⇒ 红;import 换成 `code as prose2`(别名)⇒ 红;
只在注释里写 `prose(` ⇒ **绿**(对照旧实现:同一份样本 ⇒ **红**)。

## 验证

· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 红的仍是同样 9 条(都是并发
  会话的"自报 > 清单"与 4 条 exit 1),我中途引入的两条(裸 readFileSync、import 假红)
  已消。`mutants=48` 不变。
· 所有 chmod/清单反序/等长改写**都已还原**,`git status` 只剩本次 5 个文件。
2026-09-18 04:54:59 +08:00
b84880f14b test(判据): 到期闸第一条按 (a) 升级 —— harmony-nav 加**行为层**(真 dumpLayout),并把"跳过"变成可数余额
pi 2026-09-18 报"静态判据到期闸开了"是真阳性:探针的前提"本工作区能装、能点
设备"现在成立(我实测:签名 HAP 装上 install bundle successfully、aa start
start ability successfully、uitest uiInput click 返回 No Error、uitest dumpLayout
出真 UI 树)。闸门要求 (a) 改行为判据 或 (b) 改换更准的前提 —— (b) 救不了:
它建议拆的"目标存在 ↔ 能装能点"两层我两层都实测为真,拆开照样红。所以走 (a)。

## 1. 新增行为层(harmony-nav,17 条)

`lib/harmony-device.mjs`:设备侧 harness(findHdc / hasTarget / foregroundBundle /
dumpLayout / walk / findByText / boundsAt)。**只读不抢** —— 模拟器是共享的,
应用不在前台就跳过,不启动、不点。

harmony-nav 新增两条:
· 「★ 行为(设备):底栏真渲染了可点的导航项(live ⊆ source)」——
  真 dumpLayout,断"屏幕下 1/4 里真画出了可点的项、每项至少亮一个
  源码 NAV_ITEMS 定义过的标签"。**版本无关**:已安装构建可能比 HEAD 旧
  (实测前台那份是 3 项,HEAD 源码是 4 项),所以断"live ⊆ source"而不是
  "相等"——"四项齐不齐"仍由静态层把。
· 「★ 判据自检:底栏取值逻辑」——纯函数 `navItemsOf` 上的合成树断言。

它**不替**静态那批(点击配对/挂载映射/命中区 ≥44vp/让位派生)——那些读源码更准。
它补的是源码读不到的那半:真渲染出来了吗、真可点吗、标签对吗。

## 2. 把"跳过"变成可数余额(run-all.mjs)

⚠️ 这是本轮**我先写错、再查出来**的地方,记在这里:升级之前套件里**没有任何
in-file skip**(全仓 grep 零命中)。加了第一条之后,`# tests N` 把跳过的**算进总数**,
而 `pass = checks - fail` 又把它读成**通过** —— 实测设备在 / 设备不在两次运行的
`RESULT files=…` **一字不差**(都 `checks=457 pass=453 fail=4`,而文件自己报
`# tests 16 / # pass 15 / # skipped 1`)。**"看不到 ⇒ 绿"长在总数行上。**

修法照 `fail` 那一格的先例(pi 2026-09-15 指出缺 `red` 时的同一形状):
· 取 `# skipped K` 成一格;`records` 带 `skip`;投影加 `totalSkip`(第 5 个);
· `pass` 改成 `checks - fail - skip`;汇总行补 `skip=N`(并在单位说明里写清它是
  "本次没跑",既不是通过也不是失败);
· 自检 5 补 `checks ≥ skip`(对**每个**按文件累加的计数器都成立的上界 —— 照
  pi 那条"只给其中两个判上界,第三个就永远没人管")。
实测:设备在 `… pass=454 fail=4 skip=0`,设备不在 `… pass=453 fail=4 skip=1`,
`pass+fail+skip == checks` 在两个方向都成立。

## 3. 到期债务结算一笔:7 → 6

STATIC_ONLY 去掉 harmony-nav;`docs/DEBTS.json` 的 static-criteria 7→6(含 where
清单同步);SUITE 登记数 11→17(原登记 11 早已与文件里的 15 条不符 —— 并发会话
加了日历/我的那批测试没改登记,这条红一并消掉)。

## 变异验证(都做了阳性对照,还原后 sha256 一致)

· M1 `sourceLabels` 取空集 → 行为条 not ok,报出真实文案 `✉️、通信`;
· M2 底栏阈值 0.75→2.0 → 报"实际 0,一个都没有";
· M3 `AGENTMAIL_HARMONY_DEVICE=none` → `# skipped 1`(且**改 run-all 之前**
  汇总读不出来 —— 这正是上面第 2 条要修的实证);
· M4 删掉 `if (a.type === 'Text') return false;` → **第一次没红**(自检④拿的是叶子
  FAB,它的 textsUnder 是空、被下一条内容条件滤掉,没测到那行)⇒ 换成"带子文本的
  可点 Text"后**才**红。自检本身也会空跑,这是同一族病的又一个实例;
· M5 阈值 0.75→0.10 → 自检②红;M6 `clickable` 判定取反 → 自检①红。

## 说明与遗留

· 已在运行的构建比 HEAD 旧(前台那份 3 项),行为条按"live ⊆ source"设计,
  所以它现在**绿**且**没有**把"我的缺一项"误报成红 —— 那是 build-stamp 的活。
· 模拟器是**别人会话的**:我为验证装/启动/点过(pi 明确没动它,我动了,如实记);
  harness 因此按"不抢前台"写:不在前台就 skip 并计数。
· 余下 6 条到期判据(appearance/logic/cross-client-theme/defaults/admin/imageprep)
  未动,仍留在 STATIC_ONLY 里红着 —— 到期机制该干的事。harness 已就位,可按
  可观测性逐条升级。
2026-09-18 04:42:03 +08:00