跨端: 三个真 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:
@ -39,7 +39,10 @@ import { code, prose } from './lib/read.mjs';
|
||||
* 出自 `lib/harmony-device.mjs`(唯一权威处 —— 包名也从那里读,
|
||||
* 不在这里写第二份,见那条「包名漂移」的教训)。
|
||||
*/
|
||||
import { findHdc, hasTarget, foregroundBundle, ourBundle, dumpLayout, walk, swipe } from './lib/harmony-device.mjs';
|
||||
import {
|
||||
findHdc, hasTarget, dumpLayout, walk, swipe, tapText,
|
||||
launchOurApp, boundsCenter, tap,
|
||||
} from './lib/harmony-device.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { dirname, join } from 'node:path';
|
||||
@ -266,19 +269,88 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
|
||||
if (!hdc || !hasTarget(hdc)) {
|
||||
return t.skip('设备不在 —— 手势本体本次不跑(上面 ①~④ 静态层仍把住契约)');
|
||||
}
|
||||
const fg = foregroundBundle(hdc);
|
||||
if (fg !== ourBundle()) {
|
||||
return t.skip(`前台不是我们的应用(${fg})—— 不拿别人的界面断言`);
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-19 修:本判据原先要求"前台已经是我们的应用、且已经在日历月档",
|
||||
* 不满足就 `t.skip` —— 于是**在套件里永远跳过**(run-all 跑到它时前台是别的),
|
||||
* 只有我手动摆好现场才通过。那等于没有这条判据。
|
||||
*
|
||||
* 现在它**自己搭现场**:拉起应用 → 切到日历窗格 → 必要时切回月档。
|
||||
* 这才是设备判据该有的样子(前置条件自己满足,而不是等人摆好)。
|
||||
*/
|
||||
assert.ok(await launchOurApp(hdc), '要能拉起我们的应用并等到它到前台');
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
const titleOf = (root) => {
|
||||
/* 切到日历窗格:宽屏点侧栏第 2 项 / 窄屏点底栏「日历」 */
|
||||
const rootA = dumpLayout(hdc);
|
||||
const screenDims = (root) => {
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
for (const n of walk(root)) {
|
||||
const txt = n.attributes?.text || '';
|
||||
const m = /^(\d{4})年(\d{1,2})月$/.exec(txt.trim());
|
||||
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
|
||||
if (m) {
|
||||
w = Math.max(w, Number(m[1]));
|
||||
h = Math.max(h, Number(m[2]));
|
||||
}
|
||||
}
|
||||
return { w, h };
|
||||
};
|
||||
const dims = screenDims(rootA);
|
||||
const wide = dims.w / dims.h > 1.2;
|
||||
if (wide) {
|
||||
/* 侧栏导航轨第 2 项(日历):贴顶、在左 1/6 内、高 > 屏高 4% */
|
||||
/*
|
||||
* ★ 必须**要求有文字标签** —— 品牌标(左上那个 40vp 方块,实测 `y1=174`)
|
||||
* 也是"可点 + 在左 1/6 + 高度够",会被一起选进来。
|
||||
* 我第一版没排除它,于是排序后 `rail[0]` 是品牌标、`rail[1]` 是**通信**
|
||||
* ⇒ 想点日历却点到了通信,判据卡在"找不到月档"。
|
||||
* (`harmony-nav` 的 `navItemsOf` 早就有这条过滤,我这里漏了 —— 同一形状。)
|
||||
*/
|
||||
const textsUnder = (n) => {
|
||||
const out = [];
|
||||
const sub = (x) => {
|
||||
if (Array.isArray(x)) { x.forEach(sub); return; }
|
||||
if (!x || typeof x !== 'object') return;
|
||||
const t = (x.attributes?.text || '').trim();
|
||||
if (t) out.push(t);
|
||||
for (const c of (x.children || [])) sub(c);
|
||||
};
|
||||
sub(n);
|
||||
return out;
|
||||
};
|
||||
const rail = [...walk(rootA)].filter((n) => {
|
||||
const a = n.attributes || {};
|
||||
if (a.clickable !== 'true') return false;
|
||||
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
|
||||
if (!m) return false;
|
||||
const [, , y1, x2, y2] = m.map(Number);
|
||||
return x2 <= dims.w * 0.08 && y1 < dims.h * 0.5 && (y2 - y1) > dims.h * 0.04
|
||||
&& textsUnder(n).length > 0; // ← 排除品牌标
|
||||
}).sort((a, b) => {
|
||||
const y = (n) => Number(/\[(-?\d+),(-?\d+)\]/.exec(n.attributes.bounds)[2]);
|
||||
return y(a) - y(b);
|
||||
});
|
||||
if (rail.length < 2) {
|
||||
return t.skip(`宽屏侧栏导航轨只找到 ${rail.length} 项 —— 无法切到日历`);
|
||||
}
|
||||
const c = boundsCenter(rail[1].attributes.bounds);
|
||||
assert.ok(tap(hdc, c.cx, c.cy), '要能点到侧栏的「日历」');
|
||||
} else {
|
||||
assert.ok(tapText(hdc, '日历'), '要能点到「日历」');
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
/* 确保在**月档**(标题是 `YYYY年M月`;周/日档是范围式) */
|
||||
const monthTitle = (root) => {
|
||||
for (const n of walk(root)) {
|
||||
const m = /^(\d{4})年(\d{1,2})月$/.exec((n.attributes?.text || '').trim());
|
||||
if (m) return { year: Number(m[1]), month: Number(m[2]) };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (monthTitle(dumpLayout(hdc)) === null) {
|
||||
assert.ok(tapText(hdc, '月'), '要在日历里点到「月」档');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
/*
|
||||
* ★★ 2026-09-19:本判据第一版用「滑一次 + 固定等 2500ms + dump」。
|
||||
@ -294,7 +366,7 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
|
||||
/** 等标题出现(最多 tries 次),返回读到的标题或 null */
|
||||
const readTitle = async (tries = 6) => {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const t2 = titleOf(dumpLayout(hdc));
|
||||
const t2 = monthTitle(dumpLayout(hdc));
|
||||
if (t2 !== null) return t2;
|
||||
await settle();
|
||||
}
|
||||
@ -302,7 +374,7 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
|
||||
};
|
||||
|
||||
/* 先在月档(周/日档标题是范围式,这里判最简单的那一档) */
|
||||
const before = await readTitle();
|
||||
const before = monthTitle(dumpLayout(hdc));
|
||||
if (before === null) {
|
||||
return t.skip('月标题找不到(可能在周/日档,或日历页没打开)—— 本次不跑');
|
||||
}
|
||||
@ -363,7 +435,7 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
|
||||
const seen = [];
|
||||
for (let k = 0; k < 16; k++) { // 最多等 8 秒
|
||||
await settle(500);
|
||||
const now = titleOf(dumpLayout(hdc));
|
||||
const now = monthTitle(dumpLayout(hdc));
|
||||
if (now !== null) {
|
||||
seen.push(`${now.year}-${now.month}`);
|
||||
const delta = (now.year * 12 + now.month) - beforeMonths0;
|
||||
|
||||
Reference in New Issue
Block a user