跨端: harmony 管理页(用户管理)+ P4c 壁纸上传入口 —— 「功能做全再交付」的两块

pi 的交付清单里缺的两块(`docs/GUI-PLAN-HARMONY.md` 原先把管理后台划在首版之外,
用户明确要求「功能做全再给我」之后收进来)。

标 `跨端:` 是因为本次的判据落在 `client/electron/test/`(鸿蒙的判据目录一向量在那里),
代码本体全在 `client/harmony/`。

## 管理页(用户管理)

- `pages/AdminUsersPage.ets`:新建 / 编辑(显示名·角色·白名单)/ 启停 / 重置密码。
  排布照 `AdminUsersPage.tsx`,包括「受限」徽标的口径(普通用户且白名单非空才显示)、
  最后登录缺席与空串都显示「从未登录」、管理员对白名单两项忽略。
- 入口在设置页底部,**仅管理员可见**(`role === 'admin'` 严格相等,与 `App.tsx` 同口径)。
  读不到身份时**不**显示也不报错(乐观放行会让每个普通用户看到一个点进去 403 的入口)。
- `api/AdminApi.ets` + `model/AdminUsers.ts`(纯逻辑,零 import ⇒ 判据能真跑)。
- 启停**只发 status 一个字段** —— 服务端是部分更新,多发字段会把显示名与白名单一起改掉。
- `model/Models.ets` 补管理端 DTO;`main_pages.json` 注册路由。

## P4c 壁纸上传

- `model/ImagePrep.ts`:阈值与两档策略(2560/0.85 → 1280/0.78,入口 20MB,压后上限 3.5MiB)。
  **一处有意不对齐 WebUI** 并写明理由:WebUI 卡 data-URL 长度(含 base64 膨胀),
  鸿蒙内存直传 ArrayBuffer,卡的是字节数。
- `common/BackgroundPicker.ets`:不设 / 预设 / 自定义图片 + 浓度与模糊滑杆。
  上传链:picker → 判可不可以 → 逐档按 desiredSize 解码压缩 → 上传 → **请页面以服务端为准重新同步**。
  失败**必带原因**(服务端 415/413 文案原样透出)。
- `ApiClient.uploadBytes`:MultiFormData.data 收 ArrayBuffer(核了 SDK,since 11;本工程 23)
  ⇒ 内存直传,不需要 base64、也不需要临时文件。
- 用户取消选图**不算失败**,什么都不说。

## 顺带修掉的两处真问题(都是变异测试逼出来的)

1. **压缩循环的第二档此前是死代码**:循环里的 break 与循环外那句 shouldRetryWithActual
   互相抵消 —— 把循环里那处改成 `if (true)`(永远只压一档)整套判据照样全绿。
   收成一处判定(overLimit),循环外只读结论。
2. **壁纸的模糊档一直是「只写不读」**(计划文档 §7.12 登记过):滑杆能拖、值能存、
   blurStyleFor 也写了,就是**没有调用点**,壁纸一点没糊。本次补上调用点
   (壁纸层 .blur(px) = 图片内容模糊;导航条材质由 blurStyleFor 映射)。
   同时按 §7.12 的原承诺更新了那一行。

## 一并修正的旧判据(都是"太宽/太窄",不是放宽标准)

- 「模糊归属」:原文「壁纸层不许有**任何**模糊调用」把**图片内容模糊**与**面板材质**
  混为一谈(WebUI 侧核实:.app-backdrop 的 filter 与它之上那层的 backdrop-filter
  是两个不同的量)⇒ 改成按两种模糊分别钉。
- 「bgBlur 只写不读,消费侧必须为 0」:值不再成立,**形状保留**(逐文件登记 + 计数 + 理由),
  标题与断言里的假话一并改掉。
- isDarkMode 那条 `/dark\s*\)/` 断的是**参数顺序**(加一个入参就误红)⇒ 改成"dark 在实参里"。
- 导航材质三处断言原本钉 `Theme.navMaterial` 字面量 ⇒ 改成钉新的映射写法。

## 判据

新增 `harmony-admin.test.mjs`(22 条)、`harmony-imageprep.test.mjs`(29 条);
`harmony-presets.test.mjs` 加 1 条(模糊档搬运与归一,含 -0 那个洞)。
全量 203 条:**201 通过**,2 条失败为**改动前就红**的既有项
(BUILD_INFO 比对、词表↔余额)—— 用 stash 对照验证过。

两个新判据文件上跑了 **48 个变异体,全部被抓**(含"接线"类:删掉「受限」徽标、
组件自己宣布成功、release 不 await、按原图尺寸解码…),
其中 2 个变异体**红不了**,因此又补了 5 条判据(纯逻辑接线、退档判定只有一处、
两档都超限必拒、解码尺寸用的是目标尺寸而非原图尺寸、模糊档搬运)。
(数字口径:按 runner 的真实条件"锚点恰好命中 1 次才算跑过"统计;
另有 4 条锚点不命中、根本没跑,不算在这 48 里。我第一次写的是"40"——
凭记忆累加的,错了,已更正。)

**未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的
实际档位观感)。代码齐 ≠ 真机验过。
This commit is contained in:
2026-09-15 11:01:09 +08:00
parent b7dc9e90e6
commit 474cadaf54
20 changed files with 2781 additions and 22 deletions

View File

@ -419,14 +419,17 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
// 导航条那一处必须真的还在(形状判定之外,位置本身也要在)
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)');
assert.match(main, /\.backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/,
'导航条要用系统材质(不是手写 alpha),且档位由用户偏好经 blurStyleFor 映射而来');
/*
* 自检:造一次**链式叠用**(`X.blur().blur()`),确认上面的判定抓得到。
* 这一枪放在判据里,是为了以后改这段扫描逻辑时它自己会被检验 ——
* 第一版只看"前一个字符是不是 }",对这种写法**静默失效**,正是这条自检抓出来的。
*/
const sample = 'Row() { Text("x") }\n .backgroundBlurStyle(Theme.navMaterial)\n .backgroundBlurStyle(Theme.navMaterial)';
// 样本要与**真实写法同形**(否则自检会变成"拿一段判据认不出来的代码去验判据")
const sample = 'Row() { Text("x") }\n .backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(x)] ?? BlurStyle.NONE)\n' +
' .backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(x)] ?? BlurStyle.NONE)';
const sampleEnds = [...sample.matchAll(/backgroundBlurStyle\(/g)].map(m => blockEndBefore(sample, m.index));
assert.ok(sampleEnds.every(e => e >= 0), '自检:链式写法要能解析出所作用的块');
assert.equal(new Set(sampleEnds).size, 1, '自检:同一组件的两处调用必须解析到同一个块(否则叠用判不出来)');

View File

@ -0,0 +1,308 @@
/*
* 管理页(用户管理)的判据 —— **行为**判据(纯逻辑真跑)+ **形态**判据(`.ets` 只读源码)。
*
* ── 为什么值得有 ──
*
* 这一页是本次「功能做全」新加的那一块,而它**本机跑不起来**(无设备/无模拟器)。
* 所以能钉住的每一条都要钉住,并且**分清哪一条是哪种**:
*
* · **真跑**(`model/AdminUsers.ts`,无 `@ohos` 依赖 ⇒ node strip-types 直接执行):
* 勾选、最后登录文案、角色判定、受限徽标、异常兜底文案。
* · **读源码**(`pages/AdminUsersPage.ets` / `pages/SettingsPage.ets` /
* `api/AdminApi.ets` / `main_pages.json`):路由注册、服务端调用都对应上、
* 没有写死色值、列表项每项一张卡、服务端文案不被吞。
* 这类**只能证明"代码里是这么写的"**,证不了"真机上长这样" —— 见文件末的未验清单。
*
* ── 判代码一律用 `code()`(剥注释)──
*
* 本文件里判的标识符(`listUsers`/`#` 色值/`radiusCard`…)在解释性注释里大量出现,
* 用原文读会产生假绿。这是 `lib/read.mjs` 存在的理由,也是我踩过两次的坑。
*/
import { join } from 'node:path';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { code, prose } from './lib/read.mjs';
const ROOT = '/home/program/agentmail';
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const ADMIN_TS = join(ETS, 'model/AdminUsers.ts');
const ADMIN_PAGE = join(ETS, 'pages/AdminUsersPage.ets');
const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets');
const ADMIN_API = join(ETS, 'api/AdminApi.ets');
const PAGES_JSON = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile/main_pages.json');
// 真跑纯逻辑(有 `@ohos` 依赖的文件不能这样跑;AdminUsers.ts 刻意零 import)
const A = await import(pathToFileURL(ADMIN_TS).href);
/* ────────────────────── ① 真跑:勾选白名单 ────────────────────── */
test('勾选:加进去、再去掉,而且**不就地改入参**', () => {
const before = ['a', 'b'];
const added = A.toggled(before, 'c');
assert.deepEqual(added, ['a', 'b', 'c'], '没勾上的项要加进去');
assert.deepEqual(before, ['a', 'b'],
'★ 入参被就地改了:@State 靠**引用变化**触发重渲染,就地 push/splice 改同一个数组不会刷新界面,' +
'表现是"点了没反应"而数据其实已改;而且入参可能是另一个 @State 的当前值,就地改会让两处互相污染');
const removed = A.toggled(added, 'a');
assert.deepEqual(removed, ['b', 'c'], '已勾上的项再点要取消');
assert.notEqual(added, removed, '★ 必须返回**新数组**(同一个引用不会被 @State 认成变化)');
});
test('勾选:边界(空表、重复项只当一次、原顺序不被打乱)', () => {
assert.deepEqual(A.toggled([], 'x'), ['x'], '空表勾第一个');
assert.deepEqual(A.toggled(['x'], 'x'), [], '唯一项取消后是空表(不是 null/undefined)');
assert.deepEqual(A.toggled(['b', 'a'], 'c'), ['b', 'a', 'c'],
'追加在末尾(不重排既有项:列表顺序变了会让用户以为选项跳了)');
});
/* ────────────────────── ② 真跑:最后登录文案 ────────────────────── */
test('最后登录:**缺席**与**空串**都显示"从未登录"(服务端是 omitempty)', () => {
assert.equal(A.lastLoginLabel({ last_login: undefined }), '从未登录', '字段缺席 = 从未登录');
assert.equal(A.lastLoginLabel({}), '从未登录', '整个字段不存在也一样');
assert.equal(A.lastLoginLabel({ last_login: '' }), '从未登录',
'★ 空串也要当"从未登录":只判 undefined 会让空串在界面上留一块空白,看起来像"读取失败"');
assert.equal(A.lastLoginLabel({ last_login: '2026-09-15 07:02' }), '2026-09-15 07:02',
'有值就原样显示(不在这里改格式:服务端给的就是要显示的那个串)');
});
/* ────────────────────── ③ 真跑:角色判定 ────────────────────── */
test('角色判定:**严格等于 admin**(口径与 WebUI 的 user?.role === "admin" 一致)', () => {
assert.equal(A.isAdminRole('admin'), true);
assert.equal(A.isAdminRole('user'), false);
assert.equal(A.isAdminRole(''), false);
assert.equal(A.isAdminRole(undefined), false,
'★ 读不到 role 一律 false:乐观放行会让任何一次 /me 失败都变成"对所有人显示管理入口",点进去一片 403');
for (const loose of ['Admin', 'ADMIN', 'admin ', ' admin', 'admin\n', 'superadmin', 'admin2']) {
assert.equal(A.isAdminRole(loose), false,
`★ ${JSON.stringify(loose)} 不该放行:只有严格相等才与 WebUI 同口径(宽松匹配会让某天服务端改大小写时两端行为分叉)`);
}
});
/* ────────────────────── ④ 真跑:受限徽标 ────────────────────── */
test('受限徽标:普通用户且白名单非空才显示(空 = 不限,不是"什么都不许")', () => {
assert.equal(A.isRestricted({ role: 'user', allowed_agents: ['pimail'], allowed_paths: [] }), true);
assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: ['/tmp'] }), true);
assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: [] }), false,
'★ 全空不叫受限:空 = 不限,打上「受限」会让用户以为自己什么都点不动');
assert.equal(A.isRestricted({ role: 'admin', allowed_agents: ['pimail'], allowed_paths: ['/tmp'] }), false,
'★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导');
});
/* ────────────────────── ⑤ 真跑:服务端文案不被吞 ────────────────────── */
test('服务端文案原样透出(400/409 的中文文案是唯一能让人立刻改的东西)', () => {
const cases = [
'该名称已被用户或 Agent 占用',
'密码至少 8 位',
'用户名只能包含小写字母、数字、点、下划线和连字符',
'系统至少需要保留一个可用管理员',
'用户不存在'
];
for (const msg of cases) {
const out = A.messageOfApiError(true, msg);
assert.ok(out.includes(msg),
`★ 服务端文案被改写了:「${msg}」→「${out}」。管理页的失败原因几乎都是"人能立刻改的东西",吞掉只剩反复试`);
assert.equal(out, msg, '不做任何包装:原样显示');
}
assert.ok(A.messageOfApiError(true, '').length > 0, '服务端没给文案时也要有话说');
assert.ok(A.messageOfApiError(false, 'Network unreachable').includes('Network unreachable'),
'本地异常(网络层)的 message 也要给用户看');
assert.ok(A.messageOfApiError(false, '').length > 0, '什么都没有时给兜底句');
});
test('兜底句要说清"服务端没给原因"(否则用户分不清"服务端说不行"和"客户端没收到")', () => {
/*
* ★ 这条判据第一版写的是 `/没(有)?给|未知|失败/`,**红不了** —— 变异测试抓出来的:
* 把兜底句改成光秃秃的「操作失败」,它照样匹配上了(`失败` 这个分支太宽)。
* ⇒ 收窄成"必须出现**原因缺席**这件事",而不是"出现了某个失败词"。
*/
const fallback = A.messageOfApiError(false, '');
assert.ok(/没(有)?(给|提供)原因|未(给|提供)原因|无原因/.test(fallback),
`★ 兜底句「${fallback}」只说了"失败",没说**原因缺席** —— 用户会把它当成服务端的拒绝理由,` +
'于是反复重试同一个注定失败的动作');
});
/* ────────────────────── ⑥ 形态:路由与入口 ────────────────────── */
test('管理页**注册成路由**(没注册的 @Entry 页 pushUrl 会失败)', () => {
const pages = JSON.parse(prose(PAGES_JSON));
assert.ok(Array.isArray(pages.src), 'main_pages.json 要有 src 数组');
assert.ok(pages.src.includes('pages/AdminUsersPage'),
`★ 管理页不在 main_pages.json 里(实际 ${JSON.stringify(pages.src)})⇒ pushUrl({url:'pages/AdminUsersPage'}) 起不来`);
assert.equal(new Set(pages.src).size, pages.src.length, '清单里不该有重复项');
});
test('设置页的管理入口:仅管理员可见、点进管理页', () => {
const src = code(SETTINGS_PAGE);
assert.ok(/if \(this\.isAdmin\)/.test(src),
'★ 管理入口没有 isAdmin 门禁 ⇒ 每个普通用户都会看到一个点进去 403 的入口');
assert.ok(/pushUrl\(\{ url: 'pages\/AdminUsersPage' \}\)/.test(src),
'入口要真的推到管理页(推一个别的地方 = 用户点了看不到管理面)');
assert.ok(/isAdminRole\(/.test(src), '★ 门禁要用共用的 isAdminRole,而不是就地写 === "admin"(两处口径会漂移)');
assert.ok(/loadRole\(\)/.test(src), '要真去读一次身份');
});
test('身份读不到时**不**乐观显示入口(isAdmin 初值 false,失败也保持 false)', () => {
const src = code(SETTINGS_PAGE);
assert.ok(/@State isAdmin: boolean = false/.test(src), 'isAdmin 初值必须是 false');
const catchBlock = src.slice(src.indexOf('async loadRole()'));
const body = catchBlock.slice(catchBlock.indexOf('catch'), catchBlock.indexOf('catch') + 200);
assert.ok(/this\.isAdmin = false/.test(body),
`★ loadRole 的 catch 里没有把 isAdmin 置 false;读一次片段:${body.slice(0, 120)}`);
});
/* ────────────────────── ⑦ 形态:动作 ↔ 服务端调用 ────────────────────── */
test('管理页的每个动作都落到真实的服务端调用(不是只画了个按钮)', () => {
const page = code(ADMIN_PAGE);
// 「用户真正会点的那一层」:按钮的 onClick 要能走到这些调用
const required = ['listUsers', 'listScopes', 'createUser', 'updateUser', 'disableUser', 'resetPassword'];
for (const fn of required) {
assert.ok(new RegExp(`\\.${fn}\\(`).test(page),
`★ 页面上没有调用 api.${fn}() ⇒ 那个动作是死的(点下去什么都不发生)`);
assert.ok(new RegExp(`async ${fn}\\(`).test(code(ADMIN_API)),
`★ AdminApi 里没有 ${fn} ⇒ 页面调的是不存在的东西,编不过`);
}
});
test('禁用只发 status 一个字段(服务端是**部分更新**,多发的字段会被当成"改成这个值")', () => {
const page = code(ADMIN_PAGE);
const idx = page.indexOf('async setStatus(');
assert.ok(idx > 0, '要有 setStatus');
const body = page.slice(idx, page.indexOf('async resetPassword', idx));
assert.ok(/disableUser\(/.test(body), '禁用要调 disableUser(DEL)');
assert.ok(/onlyStatus\.status = 'active'/.test(body),
'★ 启用路径要显式只设 status');
assert.ok(!/display_name/.test(body) && !/allowed_agents/.test(body),
'★ 启停路径里出现了 display_name/allowed_agents ⇒ 服务端会把显示名与白名单一起改成这些值(部分更新的经典踩法)');
});
test('服务端拦"最后一个管理员"的文案有落点(409 要能显示出来)', () => {
const page = code(ADMIN_PAGE);
assert.ok(/this\.messageOf\(e\)/.test(page),
'★ 捕获到的异常没走 messageOf ⇒ 服务端文案被吞,用户不知道"最后一个管理员不能禁用"');
assert.ok(/this\.errorText = this\.messageOf\(e\)/.test(page), '文案要落到可见的 errorText');
});
/* ────────────────────── ⑧ 形态:交付判据(pi 那几条) ────────────────────── */
test('不许新写死颜色(色一律走 Theme;品牌色只写在 Theme 那一个文件里)', () => {
for (const f of [ADMIN_PAGE, SETTINGS_PAGE]) {
const src = code(f);
const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
assert.deepEqual(hex, [],
`★ ${f.replace(ROOT + '/', '')} 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`);
const rgb = src.match(/\brgba?\(/g) || [];
assert.deepEqual(rgb, [], `★ ${f.replace(ROOT + '/', '')} 里出现了手写 rgba/rgb`);
}
});
test('列表项每项一张卡(不是整列共用一张底)', () => {
const src = code(ADMIN_PAGE);
assert.ok(/ListItem\(\)/.test(src), '用户列表要用 List + ListItem');
assert.ok(/for \(const|ForEach\(this\.users/.test(src), '要真的遍历用户列表');
// 卡片样式必须落在被 ForEach 调用的那个 @Builder 里(`UserCard`),而不是外层容器上
const cardIdx = src.indexOf('UserCard(user: AdminUser)');
assert.ok(cardIdx > 0, '要有 UserCard @Builder');
const card = src.slice(cardIdx, cardIdx + 2000);
assert.ok(/backgroundColor\(Theme\.surface\)/.test(card) && /borderRadius\(Theme\.radiusCard\)/.test(card),
'★ 每项那张卡的底色/圆角要在 UserCard 里 ⇒ 否则是一整列共用一张底');
});
test('页面**真的用上**了那几个纯函数(否则纯逻辑全绿、界面却是死的)', () => {
/*
* ★ 这条是变异测试抓出来的缺口:把页面里的 `if (isRestricted(user))` 改成 `if (false)`,
* 删掉「受限」徽标 —— **纯逻辑那几条判据全绿**(`isRestricted` 本身没错),
* 而用户再也看不到徽标。这正是"判据覆盖了模块、没覆盖接线"的经典形状。
* ⇒ 每个纯函数都要在**页面代码**里出现一次(断言调用,不是断言注释里提过)。
*/
const src = code(ADMIN_PAGE);
const wired = [
['isRestricted(user)', '「受限」徽标'],
['isAdminRole(', '管理员门禁'],
['lastLoginLabel(user)', '最后登录文案'],
['messageOfApiError(', '异常兜底文案'],
['toggled(', '白名单勾选']
];
for (const [call, what] of wired) {
assert.ok(src.includes(call),
`★ 页面里没有 ${call} ⇒ ${what} 是死的(纯逻辑判据会全绿,而界面上什么都不会发生)`);
}
});
test('管理页不碰背景/模糊(同一张底只允许被模糊一次,那是 MainPage 外观层的事)', () => {
const src = code(ADMIN_PAGE);
assert.ok(!/blur\(|BackdropBlur|backgroundBlurStyle/.test(src),
'★ 管理页里出现了模糊 ⇒ 与 MainPage 的壁纸层叠起来就是"一张底被模糊两次"');
});
/* ────────────────────── ⑨ 形态:ArkTS 编译坑 ────────────────────── */
test('ArkTS 硬坑:本页不出现解构 / any / unknown / 函数表达式', () => {
const src = code(ADMIN_PAGE);
assert.ok(!/\bany\b/.test(src), '不许 any');
assert.ok(!/\bunknown\b/.test(src), '不许 unknown');
assert.ok(!/\bfunction\s*\(/.test(src), '不许函数表达式(ArkTS 只认箭头函数)');
assert.ok(!/const\s*\{[^}]*\}\s*=/.test(src) && !/const\s*\[[^\]]*\]\s*=/.test(src),
'不许解构赋值');
});
test('ArkTS 硬坑:页面文件只导出那个 struct(工具函数放 model/ 里)', () => {
const src = prose(ADMIN_PAGE);
const exports = src.match(/^export\s+(function|const|class|interface|enum)/gm) || [];
assert.deepEqual(exports, [],
`★ 页面里出现了 ${JSON.stringify(exports)} ⇒ 本仓库页面清一色只导出 struct(7 个页面 0 个 export function);` +
'而且 .ets 里的函数判据跑不了,纯逻辑必须放 model/*.ts');
/*
* ★ 这条原来写的是"页面要 `export struct`",**是错的**,被它自己抓出来了:
* 本仓库 5 个 `@Entry` 页(LoginPage/MainPage/SettingsPage/SessionsPage/MailDetailPage)
* 清一色 `struct Xxx {`(**不带** export),而 `export struct` 只出现在
* 被当子组件用的那些(`CalendarPage`/`BackgroundPicker`)。
* 路由页由 `main_pages.json` 指名加载,不需要导出。
* ⇒ 判据改成钉**这个**形态(两件事分别断言,不混在一句里)。
*/
assert.ok(/^@Entry$/m.test(src) && /^@Component$/m.test(src), '页面要有 @Entry + @Component');
assert.ok(/^struct AdminUsersPage \{/m.test(src),
'★ @Entry 页要写成不带 export 的 struct(与另外 5 个路由页同形)');
assert.ok(!/^export struct AdminUsersPage/m.test(src),
'★ @Entry 路由页带 export 与本仓库既有 5 个路由页不一致(路由页由 main_pages.json 指名加载,不需要导出)');
});
test('页面里不 import `.ets` 进纯逻辑层(否则那个文件从"能真跑"退化成"只读源码")', () => {
const src = prose(ADMIN_TS);
const imports = src.match(/^import\s/gm) || [];
assert.deepEqual(imports, [],
`★ model/AdminUsers.ts 有 ${imports.length} 个 import。本目录下 Wallpaper/Calendar/Appearance 全是零 import,` +
'那正是它们能被 node --experimental-strip-types 直接跑的原因;import 了 .ets 就再也跑不了,判据只能读源码');
});
/* ────────────────────── ⑩ 形态:ArkUI 状态绑定 ────────────────────── */
test('背景选择器:@Link 不许给初值(ArkTS 会报 "forbidden to specify default value for @Link")', () => {
const src = code(join(ETS, 'common/BackgroundPicker.ets'));
const links = src.match(/@Link\s+\w+\s*:\s*[^;]+;/g) || [];
assert.ok(links.length > 0, '选择器要用 @Link 双向绑');
for (const l of links) {
assert.ok(!/=/.test(l),
`★ 「${l.trim()}」给 @Link 写了初值 ⇒ 编不过(V1 家规:@Link 不许有 initializer)`);
}
});
test('背景选择器:父组件用 $ 传 @Link(传 this.xxx 会变成单向 @Prop,改不动父状态)', () => {
const src = code(SETTINGS_PAGE);
const idx = src.indexOf('BackgroundPicker({');
assert.ok(idx > 0, '设置页要挂上 BackgroundPicker');
const mount = src.slice(idx, idx + 600);
for (const name of ['bgKind', 'bgPresetId', 'bgDim', 'bgBlur']) {
assert.ok(new RegExp(`${name}: \\$${name}`).test(mount),
`★ ${name} 没用 $${name} 传 ⇒ 双向绑失效`);
assert.ok(!new RegExp(`${name}: this\\.${name}`).test(mount),
`★ ${name} 用了 this.${name} ⇒ 那是单向传值,选择器改不动页面的 @State`);
}
});

View File

@ -157,6 +157,28 @@ test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞
assert.ok(members.includes(tier), `${tier} 必须是系统 BlurStyle 的成员`);
}
assert.ok(members.includes(sdk));
/*
* 另一半:`名字 → BlurStyle 枚举` 那张表在页面里(纯逻辑层看不到 BlurStyle),
* 它的**键必须与 SDK 成员逐字相同** —— 写错一个字母是"编译不过或静默不生效",
* 而这类错在真机上表现为"拖滑杆没反应"(最难查的那一类)。
* 这里判据自己去页面源码里把键取出来,逐个对着 SDK 的成员名核。
*/
const main = read('pages/MainPage.ets');
const tableAt = main.indexOf('const BLUR_STYLE_OF: Record<string, BlurStyle> = {');
assert.ok(tableAt > 0, '页面里要有 `BLUR_STYLE_OF`(档位名 → SDK 枚举)那张表');
const tableEnd = main.indexOf('};', tableAt);
const table = main.slice(tableAt, tableEnd);
const keys = [...table.matchAll(/'([A-Z_]+)':\s*BlurStyle\.([A-Z_]+)/g)];
assert.ok(keys.length >= 4, `要从表里读到键(实际 ${keys.length} 项)`);
for (const [, key, val] of keys) {
assert.equal(key, val, `表项 '${key}': BlurStyle.${val} —— 键与值必须同名(不同名几乎必然是写错了)`);
assert.ok(members.includes(key), `'${key}' 不是 SDK 的 BlurStyle 成员(写自造名字会编译不过或不生效)`);
}
// 四档都要在(漏一档会让那个档位静默回落成 BlurStyle.NONE)
for (const tier of ['NONE', 'COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK']) {
assert.ok(keys.some(([, k]) => k === tier), `表里缺 ${tier} ⇒ 该档会静默回落成"不模糊"`);
}
});
test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不自己维护一套色值)', () => {
@ -427,10 +449,35 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统
const wallpaperBuilder = mainLines.slice(start, stop).join('\n');
assert.ok(!/NavBar/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去');
assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文');
assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), '壁纸层不许再用材质(同一张底糊两遍 = 更脏更掉帧)');
assert.ok(!/blur\(/i.test(wallpaperBuilder), '壁纸层不许出现任何模糊调用');
// 导航条那一次模糊仍然在(背后是会滚动的内容,遮蔽有意义)
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条必须有系统材质');
/*
* ── 这两条 2026-09-15 修正过,理由值得留在这里 ──
*
* 原来是「壁纸层不许出现**任何**模糊调用」(`!/blur\(/i`)。那条**太宽**:
* 它把两种**不同的物理量**当成了一件事,而 WebUI 侧的源码证明它们是分开的
* (`client/electron/src/index.css`):
* · `.app-backdrop`(z-index:-1,背后什么都没有)吃 `filter: blur(var(--bg-blur))`
* ⇒ **图片内容模糊**(服务端 `bg_blur` 那个 px 值,作用对象是壁纸自己);
* · `.app-backdrop` 之上的面吃 `backdrop-filter: blur(8px)`
* ⇒ **背后内容模糊**(面板材质)。
* "同一张底被糊两遍"指的是**后者在一张已经糊过的底上再做一次**,不是"壁纸自己不许糊"。
*
* 所以正确的互斥形式是:**壁纸层只许做图片内容模糊,不许做面板材质模糊**;
* 面板材质只许出现在导航条那种"背后是可变内容"的层。两条分别钉住。
*
* (触发这次修正的是 P4c:加背景选择器时滑杆能拖、`bg_blur` 能存,
* 但壁纸一点没糊 —— 而计划文档 §7.12 恰好写着"若将来鸿蒙开始消费它,
* 那时必须补一条映射判据,并更新本行"。)
*/
assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder),
'★ 壁纸层不许用**面板材质**(`backgroundBlurStyle` 作用在背景=背后的内容上;' +
'壁纸层背后什么都没有,那是"给一张糊过的底再糊一遍"的形状)');
assert.match(wallpaperBuilder, /\.blur\(this\.bgPlan\.blurPx\)/,
'壁纸层要按服务端 `bg_blur` 的**px 原值**做图片内容模糊(与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)');
const imgBlur = [...wallpaperBuilder.matchAll(/\.blur\(/g)];
assert.equal(imgBlur.length, 1, `壁纸层的图片内容模糊只许一次(实际 ${imgBlur.length} 次)`);
// 导航条的**面板材质**仍然在(背后是会滚动的内容,遮蔽有意义),且档位来自用户偏好
assert.match(main, /\.backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/,
'导航条的面板材质要由 `bg_blur` **映射**而来(计划文档 §7.12 要求的那个调用点)');
// 材料档次由用户偏好映射而来(不是写死的半径)
const store = read('common/AppearanceStore.ets');
assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式');
@ -577,7 +624,15 @@ test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅
else if (main[i] === ')') { depth--; if (depth === 0) { callEnd = i; break; } }
}
const callText = main.slice(callAt, callEnd + 1);
assert.ok(/\bdark\b\s*\)/.test(callText), `深浅色要传给背景计划:${callText}`);
/*
* ★ 这条原来写的是 `/\bdark\b\s*\)/`(要求 dark 是**最后一个**实参)。
* 那句断的是"参数顺序",不是"深浅色有没有传过去" —— P4c 在 `dark` 后面加了
* `blurPx` 入参,它立刻红了,而传给计划的东西一个没少。**邻接不是语义**
* (与文件里取调用点正文要按括号配对是同一条规则)。改成"dark 确实在实参里"。
*/
assert.ok(/\bdark\b\s*[,)]/.test(callText), `深浅色要传给背景计划:${callText}`);
assert.ok(/\bsnap\.bgBlur\b/.test(callText),
`模糊档也要传给背景计划(否则滑杆能拖、壁纸不糊):${callText}`);
});
test('★ 预设档的遮盖:两档同一个浓度(WebUI 的 --bg-dim 不区分档位)+ 遮盖层用系统遮罩色', () => {
@ -757,7 +812,14 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮
});
/**
* ★ `bgBlur` 是"只写不读"的字段:**消费侧出现次数必须为 0**(pi 2026-09-14 指出同一形状只有一边有判据)。
* ★ 模糊字段的**消费侧必须逐文件登记 + 计数**(pi 2026-09-14 指出同一形状只有一边有判据)。
*
* ── 2026-09-15 状态变了(原来是"只写不读 ⇒ 消费侧必须为 0")──
*
* P4c 补上了消费点(见下面登记表的理由),所以 0 这个值**不再成立**,
* 判据的**形状**保留(逐文件 + 计数 + 写理由),值改准。
* 标题与断言里原来那句"消费侧出现次数必须为 0"如果留着,就会变成**假话**——
* 而假话比没有判据更糟:下一个人会以为"这里登记 0 是真的"。
*
* WebUI 的 `LEGACY_BACKUP_KEY` 早就钉着"只写不读,否则它会变成新的继承源";
* 而鸿蒙侧 `bgBlur`(`Appearance.ts` clamp 存入、`AppearanceStore` 同步)**只有文档**。
@ -769,7 +831,7 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮
* 那时要补的是"px ↔ 材质档位"的**映射判据**(见 CRITERIA.md §10 与计划文档 §7.12),
* 而不是把次数从 0 改成 1 了事。
*/
test('★ bgBlur 只写不读:消费侧出现次数必须为 0(要消费就得先补映射判据)', () => {
test('★ 模糊字段的消费侧:逐文件登记 + 计数(P4c 起不再是「只写不读」,登记值已随之改准)', () => {
const files = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
@ -791,8 +853,29 @@ test('★ bgBlur 只写不读:消费侧出现次数必须为 0(要消费就
* 只按文件放行 ⇒ "在已允许的文件里顺手再读一下 bgBlur 做别的事"会被静默吞掉
* (例如有人在 DTO 文件里拿它算点别的)。次数写死在这里,多一次即红。
*/
/*
* ── 2026-09-15:登记值从"没有这一项(即 0)"改成 1 处 —— 按本条判据自己的要求做的 ──
*
* 这条判据的注释写着「消费侧一旦出现就红 —— 那是**必须停下来**的时刻:
* 那时要补的是"px ↔ 材质档位"的**映射判据**,而不是把次数从 0 改成 1 了事」。
* P4c 加背景选择器时确实踩到了:滑杆能拖、`bg_blur` 能存,但壁纸一点没糊。
*
* 停下来核完之后,**映射判据早就在了**(`blurStyleFor` 的分档边界/单调性/NaN 那条),
* 缺的是**调用点**。所以这次补的是调用点,并把下面这条登记改准:
* · `pages/MainPage.ets` 2 处:壁纸层 `.blur(this.bgPlan.blurPx)`(**图片内容模糊**,
* 与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)+ 导航条
* `backgroundBlurStyle(blurStyleFor(this.bgPlan.blurPx))`(**面板材质**)。
* 两处都只是**把用户那个数用出去**,不在这里做分档判断(分档在 Appearance.ts)。
* · `common/BackgroundPicker.ets` 2 处:选择器的滑杆(`@Link bgBlur` 的绑定与 onChange)
* —— 那是**输入**,不是消费。
* ⇒ 这是"消费点出现时按判据要求补判据"的正常流程走完一遍,不是把 0 改成 1 了事。
*/
const plumbing = new Map([
['model/Appearance.ts', { max: 11, why: '域模型:声明 + clamp + 合并 + 映射函数 blurStyleFor(px → 材质档,见下一条判据)—— 搬运与映射,都不是消费' }],
['pages/MainPage.ets', { max: 3, why: 'P4c 补上的两个**消费点**(映射判据早已存在,见本段说明):壁纸层图片内容模糊 + 导航条面板材质;第 3 处是同文件里说明这件事的注释' }],
['common/BackgroundPicker.ets', { max: 6, why: '选择器的滑杆:**输入**(@Link 声明 + 上报 + 显示 + Slider 值 + onChange + 一处注释),不是"拿这个值决定画什么"' }],
['model/Wallpaper.ts', { max: 1, why: '计划只**搬运**这个值(`blurPx`)+ 一处注释;分档判断不在这里(在 Appearance.ts 的 blurStyleFor)' }],
['pages/SettingsPage.ets', { max: 4, why: '页面持有该值的 @State(声明 + 推服务端时写入 + 从快照复制回 + 用 `$bgBlur` 传给选择器)—— 全是搬运/传参' }],
['common/AppearanceStore.ets', { max: 4, why: '状态同步:与快照互转(搬运)' }],
['api/AppearanceApi.ets', { max: 1, why: '线上 DTO 声明 bg_blur(传输格式,不是消费)' }]
]);
@ -817,8 +900,9 @@ test('★ bgBlur 只写不读:消费侧出现次数必须为 0(要消费就
});
}
assert.deepEqual(consumers, [],
`有人在消费 bgBlur 了 —— 停下:那时**必须**先补"px ↔ 材质档位"的映射判据` +
`(CRITERIA.md §10 / 计划文档 §7.12),而不是把登记值从 0 改成 1:\n ${consumers.join('\n ')}`);
`模糊字段出现了**未登记**的读取点。要么它是消费(那就要先补"px ↔ 材质档位"的映射判据,` +
`映射表见 model/Appearance.ts 的 blurStyleFor;分档边界 0/8/20 已有行为判据),` +
`要么它是搬运/输入(那就按文件登记次数并写清理由)—— 但**不许不声不响地多一处**:\n ${consumers.join('\n ')}`);
});
/**

View File

@ -0,0 +1,393 @@
/*
* 壁纸上传(P4c)的判据 —— **行为**判据(`model/ImagePrep.ts` 真跑)+ 形态判据(`.ets` 读源码)。
*
* ── 这一层判的是"数值与策略",不是"图好不好看" ──
*
* 压图那一段本机**跑不了**(要 `@ohos.multimedia.image` + 真机)。
* 所以能做的是把**决定行为的那些数**抽到纯逻辑层(`model/ImagePrep.ts`,零 `@ohos` 依赖)
* 并在这里真跑它们 —— 于是"缩到多大 / 什么质量 / 什么时候退第二档 / 什么时候放弃"
* 全都有判据,而不是埋在 `.ets` 的 async 函数里只能拿正则猜。
*
* ⚠️ **本判据不能证明** "压出来的图能看"、"真机上 picker 能打开"、
* "服务端真的收下了" —— 那几条见文件末的未验清单。
*/
import { join } from 'node:path';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { code, prose } from './lib/read.mjs';
const ROOT = '/home/program/agentmail';
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const PREP_TS = join(ETS, 'model/ImagePrep.ts');
const PICKER = join(ETS, 'common/BackgroundPicker.ets');
const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets');
const P = await import(pathToFileURL(PREP_TS).href);
/** 服务端壁纸上限(`server/internal/handler/appearance.go` 的 `appearanceMaxBytes()` 默认值) */
const SERVER_LIMIT = 4 << 20;
/* ────────────────────── ① 缩放 ────────────────────── */
test('缩放:**横竖都不超过**最长边(竖拍按宽算会超,所以必须除以 max(w,h))', () => {
const sizes = [
[4000, 3000], [3000, 4000], // 横 / 竖
[8000, 600], [600, 8000], // 极端长条
[1080, 1080], [2560, 2560],
[5120, 2880], [1, 1], [1, 5000]
];
for (const [w, h] of sizes) {
const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE);
const longest = Math.max(s.width, s.height);
assert.ok(longest <= P.MAX_EDGE,
`★ ${w}×${h} 缩成 ${s.width}×${s.height}:最长边 ${longest} 超过 MAX_EDGE=${P.MAX_EDGE}` +
'(竖拍照片会被按宽算的公式放大到超出最长边)');
assert.ok(s.width >= 1 && s.height >= 1, `${w}×${h} 缩出了 0 边(0 边会让解码直接抛)`);
}
});
test('缩放:**不放大小图**(放大同时变糊与变大,而"变大"浪费掉那道字节上限)', () => {
const s = P.scaleToMaxEdge(800, 600, P.MAX_EDGE);
assert.deepEqual({ w: s.width, h: s.height }, { w: 800, h: 600 },
`★ 800×600 被放大成 ${s.width}×${s.height}:小图不该被放大`);
const tiny = P.scaleToMaxEdge(320, 240, P.MAX_EDGE);
assert.deepEqual({ w: tiny.width, h: tiny.height }, { w: 320, h: 240 }, '更小的图更不该放大');
});
test('缩放:保持长宽比(差一像素的舍入可以,比例不能变)', () => {
for (const [w, h] of [[4000, 3000], [3840, 2160], [3000, 4000]]) {
const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE);
const before = w / h;
const after = s.width / s.height;
assert.ok(Math.abs(before - after) / before < 0.01,
`★ ${w}×${h} → ${s.width}×${s.height}:长宽比从 ${before.toFixed(4)} 变成 ${after.toFixed(4)}(照片会被拉变形)`);
}
});
test('缩放:退化输入不抛(0 / 负数边)', () => {
for (const [w, h] of [[0, 0], [-1, 100], [0, 500]]) {
const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE);
assert.ok(Number.isFinite(s.width) && Number.isFinite(s.height),
`${w}×${h} 给出了非有限值 ${s.width}×${s.height}`);
}
});
/* ────────────────────── ② 两档策略 ────────────────────── */
test('两档:首档 2560/0.85,超限档 1280/0.78(与 WebUI `prepareImage` 同值)', () => {
const plan = P.planCompress(4000, 3000);
assert.equal(plan.passes.length, 2, '★ 档数不是 2:少一档 ⇒ 第一档超限后没有退路,直接失败');
const [first, second] = plan.passes;
assert.equal(first.maxEdge, 2560, '首档最长边');
assert.equal(first.quality, 0.85, '首档质量');
assert.equal(first.attempt, 1, '首档 attempt');
assert.equal(second.maxEdge, 1280, '★ 退档最长边不是 1280(WebUI 用 MAX_EDGE/2)');
assert.equal(second.quality, 0.78, '退档质量');
assert.equal(second.attempt, 2, '退档 attempt');
assert.ok(second.maxEdge < first.maxEdge && second.quality < first.quality,
'★ 第二档必须**同时**更小更低质:只降一个的话省不下来多少(分辨率不变时质量降 0.07 几乎不省)');
});
test('两档:第二档的估算体积**明显**小于第一档(否则退档没有意义)', () => {
const plan = P.planCompress(4000, 3000);
const a = P.estimateJpegBytes(2560, 1920, plan.passes[0].quality);
const b = P.estimateJpegBytes(1280, 960, plan.passes[1].quality);
assert.ok(b < a / 2,
`★ 退档只小了 ${(100 - b / a * 100).toFixed(0)}%(${a} → ${b} 字节)⇒ 这一档救不回"首档超限"的情况`);
});
test('计划的目标尺寸按**首档**算(界面显示"将缩到 W×H",用户看的是第一档的结果)', () => {
const plan = P.planCompress(4000, 3000);
assert.deepEqual({ w: plan.targetWidth, h: plan.targetHeight }, { w: 2560, h: 1920 },
'目标尺寸要等于首档缩放结果');
const scaled = P.scaleToMaxEdge(4000, 3000, plan.passes[0].maxEdge);
assert.deepEqual({ w: plan.targetWidth, h: plan.targetHeight }, { w: scaled.width, h: scaled.height },
'★ 目标尺寸与 passes[0] 的 maxEdge 算出来的不一致 ⇒ 界面写的和实际压的不是一件事');
});
/* ────────────────────── ③ 估算与真实字节数 ────────────────────── */
test('估算:随像素数与质量**单调**增(否则"要不要退档"的判断会给出反直觉的结论)', () => {
const base = P.estimateJpegBytes(1000, 1000, 0.85);
assert.ok(P.estimateJpegBytes(2000, 1000, 0.85) > base, '像素多一倍,估算该更大');
assert.ok(P.estimateJpegBytes(1000, 2000, 0.85) > base, '竖过来也该更大');
assert.ok(P.estimateJpegBytes(1000, 1000, 0.95) > base, '质量更高,估算该更大');
assert.ok(P.estimateJpegBytes(1000, 1000, 0.5) < base, '质量更低,估算该更小');
});
test('估算:**不超过**服务端那道门的那一档,估算值要落在上传上限之内', () => {
// 2560×1920 是 4000×3000 缩到首档后的实际尺寸 —— 正常照片走的就是这一档
const est = P.estimateJpegBytes(2560, 1920, 0.85);
assert.ok(est < P.MAX_UPLOAD_BYTES,
`★ 正常照片首档估算 ${est} 就已经超过上传上限 ${P.MAX_UPLOAD_BYTES} ⇒ 每张照片都会被白白多压一档`);
assert.ok(est > P.MAX_UPLOAD_BYTES / 4,
`★ 正常照片首档估算只有 ${est}(上限的 ${(est / P.MAX_UPLOAD_BYTES * 100).toFixed(0)}%)⇒ 上限压得过狠,白扔分辨率`);
});
test('上限:**留在**服务端那道门之内,且留出了余量(贴着上限会在服务端调小后变 413)', () => {
assert.ok(P.MAX_UPLOAD_BYTES < SERVER_LIMIT,
`★ 客户端上限 ${P.MAX_UPLOAD_BYTES} ≥ 服务端 ${SERVER_LIMIT} ⇒ 客户端会放过去一个必然 413 的东西`);
assert.ok(P.MAX_UPLOAD_BYTES > SERVER_LIMIT * 0.7,
`★ 客户端上限只有服务端的 ${(P.MAX_UPLOAD_BYTES / SERVER_LIMIT * 100).toFixed(0)}% ⇒ 白扔分辨率`);
});
test('真实字节数:**严格大于**上限才退档(等于上限要放行)', () => {
assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES - 1), false, '差一字节不该退档');
assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES), false,
'★ 等于上限被判定为要退档 ⇒ 多压一档,白损失清晰度(服务端那边 `>` 才是拒)');
assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES + 1), true, '超过一字节就该退档');
assert.equal(P.shouldRetryWithActual(0), false, '0 字节不退档');
});
test('估算**不是**测量:源码里要写明"拿到真实长度后再判一次"', () => {
const src = prose(PREP_TS);
assert.ok(/shouldRetryWithActual/.test(src) && /真实/.test(src),
'★ 估算函数的注释里没写"要拿真实字节数再判一次" ⇒ 下一个人会把估算当测量值用,' +
'于是"估出来没超、实际超了"的图会被直传成 413');
});
/* ────────────────────── ④ 入口检查:每条拒绝路径都有原因 ────────────────────── */
test('入口检查:非图片 / 超过 20MB / 正好 20MB 三条边界的判定与文案', () => {
const ok = P.judgePick(3 * 1024 * 1024, 'image/jpeg');
assert.equal(ok.ok, true, '正常照片该放行');
assert.equal(ok.reason, '', '放行时不该带原因');
const notImage = P.judgePick(1000, 'application/pdf');
assert.equal(notImage.ok, false, '★ 非图片被放行了 ⇒ 会走到解码那一步才炸');
assert.equal(notImage.reason, P.NOT_IMAGE_REASON, '非图片的原因要用常量(文案改了判据要跟着红)');
assert.ok(notImage.reason.length > 0, '★ 拒绝必须**带原因**:静默失败在 WebUI 那边踩过(P4c 第②条)');
const tooBig = P.judgePick(P.MAX_SOURCE_BYTES + 1, 'image/jpeg');
assert.equal(tooBig.ok, false, '超过 20MB 该拒');
assert.equal(tooBig.reason, P.SOURCE_TOO_LARGE_REASON);
assert.ok(tooBig.reason.length > 0, '★ 拒绝必须带原因');
assert.equal(P.judgePick(P.MAX_SOURCE_BYTES, 'image/jpeg').ok, true,
'★ 正好 20MB 被拒了 ⇒ 边界用错(WebUI 是 `> 20MB` 才拒)');
for (const mime of ['image/jpeg', 'image/png', 'image/webp', 'image/gif']) {
assert.equal(P.judgePick(1000, mime).ok, true, `${mime} 是服务端认的图片类型,该放行`);
}
});
test('入口检查:先判类型再判体积(非图片且超大时给的是"不是图片",不是"太大")', () => {
const both = P.judgePick(999 * 1024 * 1024, 'application/zip');
assert.equal(both.reason, P.NOT_IMAGE_REASON,
'★ 两条都不满足时给了"太大" ⇒ 用户会去裁剪一个本来就不是图片的文件');
});
test('入口估算:偏小的一侧才安全(估偏大会**误拒正常照片**)', () => {
// 12MP 手机照片(4032×3024)的典型 JPEG 大小是 3–5MB
const est = P.estimateSourceBytes(4032, 3024);
assert.ok(est < P.MAX_SOURCE_BYTES,
`★ 一张 4032×3024 的普通手机照片直接被判成"超过 20MB"(估出 ${est})⇒ 误拒正常需求`);
assert.ok(est > 1024 * 1024,
`估出 ${est} 太小 ⇒ 这道粗筛形同没有(那个系数写成 0.35 是有意的:4032×3024×0.35 ≈ 4.1MB,` +
'正好落在手机照片的真实区间里)');
});
/* ────────────────────── ⑤ 上传失败必须带原因 ────────────────────── */
test('上传失败:服务端文案**原样**透出(415/413 的中文文案是唯一能让人立刻改的东西)', () => {
const serverMsg = '壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)';
const out = P.uploadFailureHint(serverMsg);
assert.ok(out.includes(serverMsg),
`★ 服务端文案被改写了:「${serverMsg}」→「${out}」⇒ 用户只知道"失败了",不知道该换成什么格式`);
assert.ok(out.length > 0);
});
test('上传失败:服务端**没给**文案时也要说清"没给原因"(别只说"上传失败")', () => {
const out = P.uploadFailureHint('');
assert.ok(/没(有)?给/.test(out) || /未/.test(out),
`★ 兜底句「${out}」没说原因缺席 ⇒ 用户分不清"服务端拒了"和"网断了"`);
});
test('成功文案要说"以服务端为准"(P4c 第③条:上传成功后仍以服务端为权威)', () => {
assert.ok(/服务端/.test(P.UPLOAD_OK_HINT),
`★ 成功文案「${P.UPLOAD_OK_HINT}」没提服务端 ⇒ 用户不知道这张壁纸会不会跟着账号走`);
// ★ 重新同步在**页面**层(组件只负责"报结果",见 BackgroundPicker 文件头的分工)。
// 我第一版把这条判在组件上,判据直接红了 —— 红得对:那是真位置不同,不是漏实现。
const page = code(SETTINGS_PAGE);
const picker = code(PICKER);
assert.ok(/onUploaded\(/.test(picker), '上传成功要通知页面(组件不自己宣布成功)');
assert.ok(/async onWallpaperUploaded\(/.test(page), '页面要有上传成功后的处理');
const idx = page.indexOf('async onWallpaperUploaded(');
const body = page.slice(idx, idx + 900);
assert.ok(/syncFromServer\(/.test(body),
'★ onWallpaperUploaded 里没有重新同步 ⇒ 本地自作主张标成 image 档,而服务端才是权威(P4c 第③条)');
assert.ok(!/this\.bgKind = 'image'/.test(page),
"★ 页面里直接写了 bgKind = 'image' ⇒ 那是本地宣布成功;服务端没记成 image 的话," +
'界面会显示一块取不回来的空白(resolveBackground 对"image 档但没图"给 none)');
});
/* ────────────────────── ⑥ 形态:压缩链真的接上了 ────────────────────── */
test('压缩链:picker → 尺寸 → 入口检查 → 计划 → 逐档 → 打包 → 上传', () => {
const src = code(PICKER);
const chain = [
['PhotoViewPicker', '打开相册(picker)'],
['getImageInfo', '读原始尺寸'],
['judgePick', '入口检查(P4c:失败必须给原因)'],
['planCompress', '造压图计划'],
['scaleToMaxEdge', '按档缩放'],
['desiredSize', '按目标尺寸解码(峰值内存只有目标那一份)'],
['shouldRetryWithActual', '用**真实**字节数判要不要退档'],
['uploadImageBytes', '上传(内存直传)'],
['onUploaded', '报结果给页面(重新同步在页面层,见下一条判据)']
];
for (const [needle, why] of chain) {
assert.ok(new RegExp(needle).test(src), `★ 压缩链断了:没有 ${needle}(${why})`);
}
// 最后一环落在页面上:组件报结果 ⇒ 页面重新同步(两半都要在,缺一半链子就是断的)
assert.ok(/syncFromServer\(/.test(code(SETTINGS_PAGE)),
'★ 压缩链断了:页面里没有 syncFromServer(以服务端为准重新同步)');
});
test('压缩链:档位循环遍历 plan.passes(不是写死"试一次")', () => {
const src = code(PICKER);
assert.ok(/for \(let i = 0; i < plan\.passes\.length; i\+\+\)/.test(src),
'★ 没有遍历 plan.passes ⇒ 第二档永远不会被走到(两档策略等于只有一档)');
assert.ok(/break/.test(src), '不超限要能提前跳出(否则每张图都被压两遍,白等一遍)');
});
test('压缩链:**退档判定只有一处**,而且循环外不许重判一次(重判会把判据架空)', () => {
/*
* ★ 这条是变异测试抓出来的**真洞**,值得把来龙去脉留在这里:
*
* 原实现里"要不要退下一档"判在**两处**:
* ① 循环里 `if (!shouldRetryWithActual(usedBytes)) break;`
* ② 循环后 `if (packed === null || shouldRetryWithActual(usedBytes)) fail(TOO_LARGE_REASON)`
* 这两处**互相抵消**:把 ① 改成 `if (true)`(永远只压一档,**第二档彻底死掉**),
* 超限这件事仍然被 ② 接住 ⇒ 整套判据全绿。
* 也就是"两个判据各自描述同一件事",最后**谁都没被真正钉住**。
*
* ⇒ 形态上钉死:`shouldRetryWithActual` 在这条链上只许出现**两次**——
* 循环里一次(产生结论)、`overLimit` 声明处一次(初值)——
* 且**循环结束之后**不许再出现调用。
*/
const src = code(PICKER);
const calls = src.match(/(?<!\{ )shouldRetryWithActual\(/g) || [];
assert.ok(calls.length >= 1,
'★ 循环里根本没**调用** shouldRetryWithActual ⇒ "要不要退下一档"压根没按真实字节数判' +
'(拆掉这一处、只把循环外那句留着也能靠另一条判据)');
assert.equal(calls.length, 1,
`★ BackgroundPicker 里 shouldRetryWithActual 被**调用** ${calls.length} 次(应为 1)。` +
'多于 1 次通常就是"循环外又重判一次"——两处判定互相抵消,把循环里那处改成 if(true) 也不会红');
const loopEnd = src.indexOf('overLimit = shouldRetryWithActual(usedBytes);');
assert.ok(loopEnd > 0, '★ 循环里没有 `overLimit = shouldRetryWithActual(usedBytes);` ⇒ 退档结论不是从循环里产生的');
assert.ok(/if \(!overLimit\) \{\s*\n\s*break;/.test(src),
'★ `overLimit` 算出来了却没拿它决定 `break` ⇒ 循环不会因为"不超限"而提前结束(每张图都白压两档)');
assert.ok(/overLimit = shouldRetryWithActual\(usedBytes\);\s*\n\s*if \(!overLimit\) \{\s*\n\s*break;/.test(src),
'★ `overLimit` 的赋值与紧跟的 `if (!overLimit) { break; }` 之间被改了 ⇒ 结论没有真的接上判定');
const loopClose = src.indexOf('}', src.indexOf('if (!overLimit)', loopEnd));
const afterLoop = src.slice(loopClose, loopClose + 400);
assert.ok(!/shouldRetryWithActual/.test(afterLoop),
'★ 循环**之后**又调了一次 shouldRetryWithActual ⇒ 两处判定互相抵消(把循环里那处改成 if(true) 也不会红)。' +
'循环外只该读 overLimit 这个结论');
});
test('压缩链:两档都超限 ⇒ **必须**明确拒绝(P4c:失败必须给原因)', () => {
const src = code(PICKER);
assert.ok(/if \(packed === null \|\| overLimit\) \{/.test(src),
'★ 没有"两档都超限就拒绝"这条分支 ⇒ 超限的图会被直传,服务端回 413,而用户在界面上看不到"为什么"');
const idx = src.indexOf('if (packed === null || overLimit) {');
assert.ok(/TOO_LARGE_REASON/.test(src.slice(idx, idx + 200)),
'★ 拒绝时要给出 TOO_LARGE_REASON("图片压缩后仍过大,请换一张更小的图片"),不能静默');
});
test('压缩链:PackingOption 的 quality 是 0~100(SDK 口径),而计划里是 0~1 —— 换算只在一处', () => {
const src = code(PICKER);
const conversions = src.match(/quality \* 100/g) || [];
assert.equal(conversions.length, 1,
`★ 出现 ${conversions.length} 处 \`quality * 100\` ⇒ 换算散在多处会漂移(一边 85 一边 0.85 就会压出比原图还大的东西)。` +
'计划里的 0~1 是照 WebUI 的 toDataURL 口径,换成 SDK 的 0~100 只该有一处');
assert.ok(/format: 'image\/jpeg'/.test(src), '格式要是 image/jpeg(与 mime 一致,否则服务端 415)');
});
test('压缩链:图片资源在**每条出口**都被释放(release 是 Promise,要 await)', () => {
const src = code(PICKER);
const releases = src.match(/await \w+\.release\(\)/g) || [];
assert.ok(releases.length >= 3,
`★ 只找到 ${releases.length} 处 \`await …release()\`:ImageSource(头) + PixelMap + ImageSource(档) 至少三处。` +
'不释放的话,连选几张图就会把相册/解码器的内存吃光(真机上表现为"选第三张时闪退")');
const unawaited = (src.match(/^\s*(?!await)[a-zA-Z]+\.release\(\)/gm) || []);
assert.deepEqual(unawaited, [],
`★ 有没 await 的 release(): ${JSON.stringify(unawaited)} ⇒ 返回的是 Promise<void>,"没人管的 promise"在 ArkTS 里是编不过/丢异常`);
assert.ok(/finally \{/.test(src),
'★ 释放没有放在 finally 里 ⇒ 中途抛异常时资源不释放(而且容易在"提前 return"那条路上漏掉)');
});
test('压缩链:上传的字段名与 mime 交给 uploadBytes(发错 mime 会被服务端 415)', () => {
const api = code(join(ETS, 'api/ApiClient.ets'));
/*
* ★ 判据要**钉在方法体里**,不能钉在整个文件里 —— 变异测试抓出来的:
* `ApiClient` 里有**两个** multipart 构造(`uploadFile` 用 filePath 那份、
* `uploadBytes` 第二份),原来那条 `/name: 'file'/` 在整个文件里匹配,
* 把 `uploadBytes` 里的字段名改成 `'image'` **照样全绿**(另一份还在)。
* ⇒ 先切出 `uploadBytes` 的方法体,再在那里判。这是"判据范围比结论范围宽"的典型形状。
*/
const upIdx = api.indexOf('async uploadBytes(');
assert.ok(upIdx > 0, 'ApiClient 要有 uploadBytes(内存直传那一份)');
const upBody = api.slice(upIdx, api.indexOf('async uploadFile(', upIdx) > 0
? api.indexOf('async uploadFile(', upIdx)
: upIdx + 2000);
assert.ok(/name: 'file'/.test(upBody),
"★ uploadBytes 里的 multipart 字段名不是 'file' ⇒ 服务端 appearance.go 读不到文件" +
"(415,或服务端报缺少 file 字段)");
assert.ok(/multiFormDataList/.test(api), '要真的走 multipart');
assert.ok(/data: data/.test(api),
'★ 没把内存字节放进 data ⇒ 那就得走 filePath(临时文件),而上限检查在两边都会多一个失败面');
const appApi = code(join(ETS, 'api/AppearanceApi.ets'));
assert.ok(/'image\/jpeg'/.test(appApi),
'★ 上传的 mime 不是 image/jpeg ⇒ 服务端 detectContentType 会拒(415)');
});
test('压缩链:解码尺寸用的是**算出来**的目标尺寸,不是原图尺寸', () => {
/*
* ★ 这条也是变异测试抓出来的:原来只断言出现了 `desiredSize` 这个词,
* 把它改成 `desiredSize: { width: info.size.width, height: info.size.height }`
* (= 按**原图**尺寸解码,等于完全不缩放,4K 照片在低端机上直接爆内存)**判据照样全绿**。
* ⇒ 必须钉到"喂进去的是哪个变量",不能只钉"这个键存在"。
*/
const src = code(PICKER);
const idx = src.indexOf('desiredSize:');
assert.ok(idx > 0, '要有 desiredSize(按目标尺寸解码,峰值内存只有目标那一份)');
const block = src.slice(idx, idx + 160);
assert.ok(/width: size\.width/.test(block) && /height: size\.height/.test(block),
`★ desiredSize 用的不是算出来的目标尺寸(片段:${block.split('\n').slice(0, 3).join(' ')})。` +
'若用 info.size(原图尺寸)就等于完全没缩放,4K 照片解码后约 48MB,会把低端机推爆');
assert.ok(!/desiredSize:[^}]*info\.size/.test(block),
'★ desiredSize 里出现了 info.size ⇒ 那是原图尺寸,不是目标尺寸');
});
/* ────────────────────── ⑦ 形态:交付判据 ────────────────────── */
test('不许新写死颜色(背景选择器也一样)', () => {
const src = code(PICKER);
const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
assert.deepEqual(hex, [],
`★ BackgroundPicker.ets 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`);
assert.ok(/Theme\./.test(src), '色要走 Theme');
});
test('成员名不与通用属性冲突(@State opacity 这类会编不过)', () => {
const src = code(PICKER);
// 通用属性名清单:这些都是 ArkUI 的 attribute,同名成员会冲突
const reserved = ['opacity', 'visibility', 'width', 'height', 'scale', 'rotate', 'translate', 'margin', 'padding'];
for (const name of reserved) {
const decl = new RegExp(`@(State|Prop|Link)\\s+${name}\\s*:`);
assert.ok(!decl.test(src),
`★ 出现了「@State/@Prop/@Link ${name}」⇒ 与 ArkUI 通用属性同名,ArkTS 判据会报冲突`);
}
});
test('未验必须如实标注(本机无设备 ⇒ 观感类结论不许写成已验)', () => {
for (const f of [PICKER, join(ETS, 'pages/AdminUsersPage.ets')]) {
const src = prose(f);
assert.ok(/未验/.test(src),
`★ ${f.replace(ROOT + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`);
}
});

View File

@ -190,7 +190,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
'四周要留白(左右 + 离底),贴边就不是悬浮');
assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)');
assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '材质用系统档次,不手写 alpha');
assert.match(bar, /\.backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/,
'材质用系统档次(不手写 alpha),且档位由用户的模糊偏好映射而来');
/*
* 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值",
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
@ -265,10 +266,24 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS
* 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`(GLASS_REGISTRY)。
*/
const wallpaper = builderBody(main, 'WallpaperLayer() {');
assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)');
assert.ok(!/blur\(/i.test(stripComments(wallpaper)), '壁纸层不许有任何模糊调用');
/*
* ★ 2026-09-15 修正(P4c 补上"壁纸真的会糊"之后):原来这里写的是
* 「壁纸层不许有**任何**模糊调用」。那句把两种不同的物理量混为一谈 ——
* 壁纸层要做的是 **图片内容模糊**(`.blur(px)`,与 WebUI 的
* `filter: blur(var(--bg-blur))` 同一个量),**不许**做的是 **面板材质**
* (`backgroundBlurStyle`:作用对象是"背后的内容",而壁纸层背后什么都没有,
* 那才是"给一张糊过的底再糊一遍"的形状)。理由与出处见
* `harmony-appearance.test.mjs` 里那条"模糊归属"。判定改成按**两种模糊**分别钉。
*/
const wallpaperCode = stripComments(wallpaper);
assert.ok(!/backgroundBlurStyle/.test(wallpaperCode),
'壁纸层不许有**面板材质**(作用在背后内容上;壁纸层背后什么都没有)');
const imgBlurs = wallpaperCode.match(/\.blur\(this\.bgPlan\.blurPx\)/g) || [];
assert.equal(imgBlurs.length, 1,
`壁纸层的**图片内容模糊**只许一次(现在 ${imgBlurs.length} 次)—— 同一张底糊两遍 = 更脏更掉帧`);
const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)');
assert.match(bar, /backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/,
'悬浮条必须有系统材质(背后是滚动内容),且档位由用户偏好映射而来');
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
const mainRaw = read('pages/MainPage.ets');
const navDoc = mainRaw.slice(mainRaw.indexOf('底部导航:**自绘的悬浮玻璃条**'), mainRaw.indexOf('@Builder\n NavBar() {'));

View File

@ -131,3 +131,33 @@ test('★ 静默兜底留痕:换过要带出原 id,没换过必须是空串'
}
}
});
/* ── 模糊档的搬运与归一(P4c 起壁纸真的会糊了) ── */
test('模糊档:计划**搬得动**这个值,且 0~40 归一(边界与 Appearance.ts 的 clamp 同值)', () => {
/*
* ★ 这条是变异测试补上的:把 `plan.blurPx = normalizeBlur(blurPx)` 改成 `= 0`
* (计划根本不搬运模糊值)时,appearance 那几条**全绿** ——
* 它们判的是"页面把值传进来了"和"页面拿它去糊了",中间这一段没人判。
*/
assert.equal(typeof W.normalizeBlur, 'function', 'Wallpaper 要提供 normalizeBlur');
assert.equal(W.MAX_BLUR_PX, 40, '上限 40(与 model/Appearance.ts 的 clampNumber(bgBlur,0,40,4) 同值)');
for (const [input, want] of [[0, 0], [4, 4], [40, 40], [41, 40], [999, 40], [-5, 0], [-0.4, 0]]) {
assert.equal(W.normalizeBlur(input), want, `normalizeBlur(${input}) 应当是 ${want}`);
}
assert.equal(W.normalizeBlur(4.6), 5, '小数要四舍五入到整数 px');
assert.equal(W.normalizeBlur(NaN), 0, 'NaN 要归 0(不能是 NaN,否则喂给 blur() 是未定义行为)');
// 搬运:三档都要带上,而且值就是传进去的那个
for (const kind of ['preset', 'image', 'none']) {
const plan = W.resolveBackground(kind, 'aurora', 0.2, kind === 'image', false, 12);
assert.equal(plan.blurPx, 12, `\`${kind}\` 档没有把模糊值搬进计划(计划里是 ${plan.blurPx})`);
}
// 缺省入参:老调用点(不传第 6 个参数)语义不变 ⇒ 0
const legacy = W.resolveBackground('preset', 'aurora', 0.2, false, false);
assert.equal(legacy.blurPx, 0, '不传模糊档时应当是 0(老调用点语义不变)');
// 越界要**归一**再搬,而不是把 999 原样带出去
assert.equal(W.resolveBackground('image', 'aurora', 0.2, true, false, 999).blurPx, 40,
'越界的模糊值要在搬运时就归一(否则页面会把 999 直接喂给 blur())');
});