Files
MailUI4Agents/client/electron/test/harmony-admin.test.mjs
JianFeeeee 474cadaf54 跨端: 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"——
凭记忆累加的,错了,已更正。)

**未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的
实际档位观感)。代码齐 ≠ 真机验过。
2026-09-15 11:03:22 +08:00

309 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 管理页(用户管理)的判据 —— **行为**判据(纯逻辑真跑)+ **形态**判据(`.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`);
}
});