跨端: 三个真 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% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
This commit is contained in:
@ -263,3 +263,100 @@ test('★ 预设:鸿蒙 PRESET_IDS 与 WebUI PRESETS 的 id 与顺序逐项一
|
||||
`两端的预设清单不一致。\n 鸿蒙:${harmonyIds.join('、')}\n WebUI:${webIds.join('、')}\n` +
|
||||
'顺序也是契约:顺序不同会让两端的选择界面看起来"选错了"。');
|
||||
});
|
||||
|
||||
/* ═══════════ 三个设备实测撞出来的真 bug 的**回锚**判据 ═══════════ */
|
||||
|
||||
/*
|
||||
* 这三条都是 2026-09-19 在真机上撞出来的,且**静态判据全都绿**:
|
||||
* 静态层判的是"字段对不对""逻辑算得对不对",而它们坏在
|
||||
* 「数据对、界面不动」「请求被服务端拒收」「内容被顶出屏幕」这三种形状上。
|
||||
*
|
||||
* 每条都锚在**能复现该 bug 的那个具体形状**上,不是为了凑数。
|
||||
*/
|
||||
|
||||
test('★ 回锚:PUT 上去的 payload 不得带服务端不认识的字段(响应类型 ≠ 请求类型)', () => {
|
||||
/*
|
||||
* 真 bug:`payloadFromLocal` 原先返回 `AppearanceResponse` —— 那个类型是 **GET 的响应**,
|
||||
* 带着 `has_image` / `image_bytes` / `saved` 三个字段。而服务端的 `Decode()`
|
||||
* 是 `DisallowUnknownFields()`(严格,有意为之)⇒ **每一次外观保存都 400**。
|
||||
*
|
||||
* 症状极隐蔽:本地 @State 立刻变 ⇒ 肉眼看着像成功了;
|
||||
* 只有看 hilog 的 HTTP 状态码才发现 400。(修好后服务端访问日志是 200。)
|
||||
*
|
||||
* 判据形状:请求类型里**不许**出现任何只属于响应的字段。
|
||||
* ★ 为什么不是"断言有 AppearancePayload 这个类"那种弱判据:
|
||||
* 那样只要类名还在就绿,而"payload 又用回响应类型"照样漏。
|
||||
*/
|
||||
const src = code(join(ETS, 'model/Appearance.ts'));
|
||||
const payloadBlock = src.match(/export class AppearancePayload \{([\s\S]*?)\n\}/);
|
||||
assert.ok(payloadBlock, '要有 `AppearancePayload`(PUT 请求体专用类型)');
|
||||
const fields = [...payloadBlock[1].matchAll(/^\s{2}(\w+):/gm)].map(m => m[1]);
|
||||
assert.deepEqual(fields.sort(), ['bg_blur', 'bg_dim', 'bg_kind', 'bg_preset_id', 'theme'],
|
||||
'★ PUT 的字段集必须**恰好**是服务端 `models.Appearance` 认识的那五个 —— ' +
|
||||
'多一个就被 DisallowUnknownFields 拒收(400)。' +
|
||||
`实际:${fields.join('、')}`);
|
||||
|
||||
/* 反向:那三个响应专属字段不许回到 payload 里 */
|
||||
for (const bad of ['has_image', 'image_bytes', 'saved']) {
|
||||
assert.ok(!new RegExp(`AppearancePayload[\\s\\S]{0,300}?\\b${bad}\\b`).test(src),
|
||||
`★ \`${bad}\` 是 GET 响应字段,绝不能出现在 PUT 的 payload 类型里(会导致 400)`);
|
||||
}
|
||||
|
||||
/* 函数签名也要跟上(返回类型写错同样会漏字段) */
|
||||
assert.match(src, /export function payloadFromLocal\([^)]*\): AppearancePayload/,
|
||||
'★ `payloadFromLocal` 必须返回 `AppearancePayload`(不是 `AppearanceResponse`)');
|
||||
});
|
||||
|
||||
test('★ 回锚:改了外观必须**发布变更事件**(否则 MainPage 的画布不重算)', () => {
|
||||
/*
|
||||
* 真 bug:在「我的」页切档位 → 色板出现、状态显示「已同步」、服务端也真存了,
|
||||
* 而**页面背景一点没变**。因为 `MainPage` 的 `bgPlan` 只在启动时算一次。
|
||||
*
|
||||
* 修法:`SettingsPage` 用 `AppStorage` 发布 revision,`MainPage` 用
|
||||
* `@StorageProp + @Watch` 接住并重算。
|
||||
*
|
||||
* 判据断**两端都接上了**(只写发布方或只写订阅方,功能都是死的):
|
||||
*/
|
||||
const settings = code(join(ETS, 'pages/SettingsPage.ets'));
|
||||
const main = code(join(ETS, 'pages/MainPage.ets'));
|
||||
const KEY = 'agentmail.appearance.revision';
|
||||
|
||||
assert.ok(settings.includes(KEY) && /AppStorage\.setOrCreate/.test(settings),
|
||||
'★ 改外观后要**发布**变更(`SettingsPage` 写 AppStorage)—— 不发布则 MainPage 永远不知道');
|
||||
assert.ok(main.includes(KEY) && /@StorageProp\([^)]*\)\s*@Watch\(/.test(main),
|
||||
'★ `MainPage` 要**订阅**该变更并带 `@Watch` —— 只发布不订阅等于没做');
|
||||
|
||||
/* 计数而不是布尔:连改两次也要各触发一次(布尔 true→true 不发通知) */
|
||||
assert.match(settings, /prev\s*\+\s*1|appearanceRev\s*\+\s*1/,
|
||||
'★ 必须是**计数器**(每次 +1):布尔从 true 再设 true 不产生变化通知,' +
|
||||
'那正是"改了没反应"的经典形状');
|
||||
});
|
||||
|
||||
test('★ 回锚:`@State` 持有对象时,改内部字段必须另加原始类型触发器', () => {
|
||||
/*
|
||||
* 真 bug(最隐蔽的一个):`bgPlan` 是 `@State BackgroundPlan`,但 ArkTS 的
|
||||
* `@State` **观察不到类内部字段**的变化 —— 而 `WallpaperLayer()` 读的正是
|
||||
* `this.bgPlan.kind` / `.layers`。于是 `applyAppearance()` 赋了新 plan 之后,
|
||||
* **壁纸层不重渲染**,屏幕一直停在最初的 `kind=none`。
|
||||
*
|
||||
* 实测证据(hilog,同一次启动相差 100ms):
|
||||
* `Appearance: sync: bgKind=preset … hasImg=true` ← 数据是对的
|
||||
* `Wallpaper: kind=none layers=0 active=false` ← 渲染读到的还是旧值
|
||||
*
|
||||
* 修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,
|
||||
* 并在 `WallpaperLayer()` 的**条件表达式里消费它**。
|
||||
*
|
||||
* ★ 判据必须同时断两半 —— 这是本条的全部价值所在:
|
||||
* 只加计数器而渲染不读它 ⇒ 仍然不重渲染(我第一版就差点这样);
|
||||
* 只读它而没人 +1 ⇒ 永远同一值,也白搭。
|
||||
*/
|
||||
const main = code(join(ETS, 'pages/MainPage.ets'));
|
||||
assert.match(main, /@State bgContentRev: number/,
|
||||
'要有原始类型的触发器(`@State bgContentRev: number`)—— ' +
|
||||
'ArkTS 的 @State 观察不到对象内部字段的变化');
|
||||
assert.match(main, /this\.bgContentRev = this\.bgContentRev \+ 1/,
|
||||
'★ 每次算完壁纸 plan 必须 +1(只声明不加,触发器永远不变)');
|
||||
assert.match(main, /this\.bgPlan\.kind === 'preset'[^)]*&&[^)]*bgContentRev/,
|
||||
'★ **渲染处必须读它**(写在条件表达式里)—— 不读则 Builder 不会重跑,' +
|
||||
'加了计数器也没用。ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染。');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user