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

394 lines
24 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.

/*
* 壁纸上传(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 + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`);
}
});