Files
MailUI4Agents/client/electron/test/harmony-presets.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

164 lines
8.6 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.

/*
* 预设的**行为**判据:每一档都真的画得出来。
*
* 为什么需要它(pi 2026-09-14 指出):`appearance-defaults.test.mjs` 那条
* "鸿蒙 `PRESET_IDS` 与 WebUI `PRESETS` 的 id 与顺序逐项一致" 只挡得住**一半** ——
* 它挡"清单漏一档",**挡不住"清单有、渲染器没有"**:将来两边都加了第 7 档、
* 却忘了给 `layersFor` 加分支,那条判据**全绿**,而用户看到的是"无背景"
* ——正是"信息对等缺口"的同一个症状,只是路径换了。
*
* 所以这里是**两条配对**:值判据管"清单对不对",行为判据管"每一档真的画得出来"。
* 这与命中区那处(数值在 `NavItems.ts`、判据直接 import)是同一个配对方式。
*
* **能真跑**,不是形态判据:`model/Wallpaper.ts` 是纯逻辑(无 `@ohos` 依赖、
* 类型可擦除),用 node 的 `--experimental-strip-types` 直接执行它 —— 与
* `harmony-logic.test.mjs` 同一个先例。因此它**不需要设备**,
* 也**不该**登记进 `static` 欠账(那会虚增余额)。
*/
import { join } from 'node:path';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
const ROOT = '/home/program/agentmail';
const WALLPAPER_TS = join(ROOT, 'client/harmony/entry/src/main/ets/model/Wallpaper.ts');
// 直接执行 `.ets` 树里的**纯逻辑** `.ts`(有 `@ohos` 依赖的文件不能这样跑)
const W = await import(pathToFileURL(WALLPAPER_TS).href);
const IDS = W.PRESET_IDS;
const THEMES = [false, true];
const themeName = d => (d ? '深色' : '浅色');
/** 层栈的稳定序列化(只取有意义字段,便于比较与报错) */
const shapeOf = layers => JSON.stringify(layers.map(l => ({
kind: l.kind,
cx: l.cx, cy: l.cy, radius: l.radius, angle: l.angle,
colors: l.colors, stops: l.stops,
lineColor: l.lineColor, lineAlpha: l.lineAlpha, step: l.step
})));
test('六档 × 两种主题:都画得出来(不抛异常、非空、每层字段有效)', () => {
assert.ok(Array.isArray(IDS) && IDS.length >= 6, `预设清单要能取到(实际 ${IDS?.length})`);
for (const id of IDS) {
for (const dark of THEMES) {
let layers;
assert.doesNotThrow(() => { layers = W.layersFor(id, dark); },
`${id}(${themeName(dark)})调用就抛了 —— 用户看到的是"无背景"`);
assert.ok(Array.isArray(layers) && layers.length >= 1,
`${id}(${themeName(dark)})返回空层栈 ⇒ 这一档画出来是空白`);
for (const [i, l] of layers.entries()) {
assert.ok(['radial', 'linear', 'grid'].includes(l.kind),
`${id}(${themeName(dark)})第 ${i} 层的 kind 认不出:${l.kind}`);
if (l.kind === 'grid') {
assert.ok(typeof l.step === 'number' && l.step > 0, `${id} 网格档缺 step`);
assert.ok(typeof l.lineColor === 'string' && l.lineColor.length > 0, `${id} 网格档缺线色`);
} else {
assert.ok(Array.isArray(l.colors) && l.colors.length >= 2 && l.colors.every(c => typeof c === 'string' && c.length > 0),
`${id}(${themeName(dark)})第 ${i} 层的色标不完整:${JSON.stringify(l.colors)}`);
}
}
}
}
});
/*
* ★ 这条是 pi 那半边的直接对应物:**"清单里有、渲染器里没有"必须红。**
*
* 手法:拿一个**不存在的 id** 调一次 —— `normalizePreset` 会把它落到默认档
* (aurora),于是"兜底长什么样"就有了可比较的对象。任何一档如果**丢了分支**,
* 它会静默落到同一个兜底上 ⇒ 与兜底逐字节相同 ⇒ 红。
*
* **已知边界(写出来,不假装全覆盖)**:`aurora` 自己**就是**那个兜底档,
* 所以它"丢了分支"在行为上不可观测(结果一样)。这条判据能测到的是
* **另外五档**;aurora 的存在性由值判据(清单相等)与下面那条"六档两两不同"兜住。
*/
test('★ 清单里有、渲染器里没有 → 红(每一档都必须与"兜底档"不同)', () => {
for (const dark of THEMES) {
const fallback = shapeOf(W.layersFor('__这个 id 不存在__', dark));
const aurora = shapeOf(W.layersFor('aurora', dark));
assert.equal(fallback, aurora, `自检:认不出的 id 应当落到默认档 aurora(${themeName(dark)})`);
for (const id of IDS) {
if (id === 'aurora') continue; // 见上面的"已知边界"
assert.notEqual(shapeOf(W.layersFor(id, dark)), fallback,
`${id}(${themeName(dark)})画出来的和"兜底档"一模一样 ⇒ 它没有自己的渲染分支` +
`(清单里有、渲染器没有:用户看到的是 aurora,不是他选的那档)`);
}
}
});
test('六档两两不同(复制粘贴分支没改参数 → 红)', () => {
for (const dark of THEMES) {
const seen = new Map();
for (const id of IDS) {
const s = shapeOf(W.layersFor(id, dark));
assert.ok(!seen.has(s),
`${id} 与 ${seen.get(s)} 在${themeName(dark)}下画出来完全相同 —— 复制分支后忘了改参数`);
seen.set(s, id);
}
}
});
test('深色档必须与浅色档不同("深色没换色" → 红)', () => {
for (const id of IDS) {
assert.notEqual(shapeOf(W.layersFor(id, true)), shapeOf(W.layersFor(id, false)),
`${id} 的深色档与浅色档完全一样 ⇒ 深色模式下这一档没有换色`);
}
});
/*
* ★ 静默兜底必须留下**可观测的痕迹**(pi 2026-09-14)。
*
* §10 登记了"未知 id 静默换成 aurora"这条缺省语义,但**登记只防"被误报成 bug"**,
* 防不住"没人知道它正在发生" —— 真实环境里它不留任何痕迹。所以返回值里带
* `presetSubstitutedFrom`:换过就是原来那个 id,没换过是空串,页面据此打一行日志。
*
* 这条判据同时钉住两件事:① 替换**必须**留痕;② 没替换时**不许**留痕
* (否则痕迹会退化成噪声,等于没有)。
*/
test('★ 静默兜底留痕:换过要带出原 id,没换过必须是空串', () => {
const unknown = 'ocean-v2-这个档还不存在';
const bad = W.resolveBackground('preset', unknown, 0.2, false, false);
assert.equal(bad.presetId, 'aurora', `自检:未知 id 应当被换成默认档(实际 ${bad.presetId})`);
assert.equal(bad.presetSubstitutedFrom, unknown,
'认不出的 id 被静默替换了,却没留下痕迹 ⇒ 版本偏移时无法确认它在发生(只能猜)');
for (const id of IDS) {
for (const dark of THEMES) {
const plan = W.resolveBackground('preset', id, 0.2, false, dark);
assert.equal(plan.presetSubstitutedFrom, '',
`${id}(${themeName(dark)})是已知档,不该留下"替换过"的痕迹(痕迹变成噪声就等于没有)`);
}
}
});
/* ── 模糊档的搬运与归一(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())');
});