pi 报的那格(4b 的 if(false))已由并发会话的自检 4c 补上,我变异验证通过(三种恒假写法都红)。
这轮顺着"第 2 列现在每次运行都可见"去读那 6 条理由,挖出**同族的另一条缝**:
`harmony-imageprep` 的
/** 服务端壁纸上限(appearance.go 的 appearanceMaxBytes() 默认值) */
const SERVER_LIMIT = 4 << 20;
注释说它来自 Go 源,而它一个字节的 Go 源都没读。实测(同刻 A/B):
把 Go 里那个默认值改成 8<<20(真漂移)⇒ 本文件 **29 pass / 0 fail 一个字都没变**
⇒ 这条判据存在的全部理由(客户端上限要留在服务端那道门之内,否则必然 413 /
白扔分辨率)在服务端那道门真动了时**不会红**。危险处在于注释让读者以为已对齐。
修法(三件):
① 从**真源头**解析,且**两处都读、要求相等** ——
⚠️ 我第一版只读了 appearance.go 的 `return 4<<20`,那是**兜底分支**;
生产里 config.C 非 nil ⇒ 生效值来自 config.go 的
`MaxAppearanceBytes: getEnvInt64("AGENTMAIL_MAX_APPEARANCE_BYTES", 4<<20)`。
"读了源"还不够,还得问"读的是不是生效的那一处"(同族缝的下一层)。
② 解析失败必须红,**不许静默回退到硬编码**(回退 = 把"我读不到"变成"值是对的")。
③ 新增一条判据钉住"真的读出来了":两处都无 err、生效值等于 config 那处、等于兜底那处、且 >0。
并**如实标出标签范围**:这证明"与默认值对齐",**不证明**"与运行值对齐" ——
AGENTMAIL_MAX_APPEARANCE_BYTES 可覆盖;别把本条读成"413 已不可能发生"。
变异验证(每个只动一处):config.go 默认值→8MB ⇒ fail=2;
appearance.go 兜底→8MB(两处不一致)⇒ fail=1(恰为"两处相等"那条,
余量那条**故意不红**,因为生效值没变 ⇒ 所以"两处相等"必须单独存在);
config.go 那行删掉 ⇒ fail=2(不许静默)。基线 30 pass / 0 fail。
另:`STATIC_ONLY` 第 2 列里那句「+ 服务端,三样本机都没有」是**假话** ——
`server/internal/handler/attachments.go` 在本机、其 Go 测试 `-run Attach` 跑得通、
且本判据根本没连服务端。已改成"欠的只有设备侧那一半"(列每次运行都播报,假话会被读出来)。
注册条数 29→30 已同步。全套:files=31 checks=396 pass=384 fail=12 red=9 broken=3(跑在隔离 worktree)。
`CRITERIA.md §16.4`:通用规则 —— **注释里写"这个值来自 X"不构成读 X**;
凡"必须与别处一致"的判据先问:它真读了别处,还是抄了一份?
476 lines
29 KiB
JavaScript
476 lines
29 KiB
JavaScript
/*
|
||
* 壁纸上传(P4c)的判据 —— **行为**判据(`model/ImagePrep.ts` 真跑)+ 形态判据(`.ets` 读源码)。
|
||
*
|
||
* ── 这一层判的是"数值与策略",不是"图好不好看" ──
|
||
*
|
||
* 压图那一段本机**跑不了**(要 `@ohos.multimedia.image` + 真机)。
|
||
* 所以能做的是把**决定行为的那些数**抽到纯逻辑层(`model/ImagePrep.ts`,零 `@ohos` 依赖)
|
||
* 并在这里真跑它们 —— 于是"缩到多大 / 什么质量 / 什么时候退第二档 / 什么时候放弃"
|
||
* 全都有判据,而不是埋在 `.ets` 的 async 函数里只能拿正则猜。
|
||
*
|
||
* ⚠️ **本判据不能证明** "压出来的图能看"、"真机上 picker 能打开"、
|
||
* "服务端真的收下了" —— 那几条见文件末的未验清单。
|
||
*/
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
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';` —— pi 2026-09-15 实测出后果:
|
||
* 把带违规的提交检出到**别的目录**再跑,判据**读的仍是 `/home/program/agentmail`**,
|
||
* 于是"在一个 import 顺序明显违规的检出上 3/3 全绿"。
|
||
* 两层后果,第二层最糟:
|
||
* ① 它**永远无法验证任何别的 checkout / CI / 镜像**(换目录不是"红",是 readdirSync 直接抛);
|
||
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿** —— 正是我们这几轮在消的形状,
|
||
* 这次长在判据自己身上。**"规则进来了,对象没进来"**。
|
||
* 修法照邻居(10 个鸿蒙判据都是 `join(HERE, '..', '..', '..')`)。
|
||
*/
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..', '..');
|
||
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);
|
||
|
||
/*
|
||
* 服务端壁纸上限 —— **从 Go 源里读出来,不许硬编码**。
|
||
*
|
||
* ★ 原来这里是 `const SERVER_LIMIT = 4 << 20;`,注释还写着"(`appearance.go` 的
|
||
* `appearanceMaxBytes()` 默认值)"—— 但**它一个字节的 Go 源都没读**。
|
||
* 实测(dsh 2026-09-19):把 Go 里那个默认值改成 `8 << 20`,本文件
|
||
* **29 pass / 0 fail 一个字都没变** ⇒ 真漂移了它不会红。
|
||
*
|
||
* ★★ 而**我第一版修法读错了地方**(自查抓出,如实记下):
|
||
* 我去读 `appearance.go` 的 `return 4 << 20` —— 那是**兜底分支**
|
||
* (`if config.C != nil && config.C.MaxAppearanceBytes > 0` 之后才轮到它)。
|
||
* 生产里 `config.C` 非 nil ⇒ **真正生效的值来自 `config.go` 里
|
||
* `MaxAppearanceBytes: getEnvInt64("AGENTMAIL_MAX_APPEARANCE_BYTES", 4<<20)`**。
|
||
* ⇒ 只读兜底分支的修法,**在"有人分了 config 默认值"时会漏**。
|
||
*
|
||
* ⇒ 现在**两处都读,并要求它们相等**:
|
||
* · `config.go` 的 env 默认值 = **运行时真正生效的那一处**(生产);
|
||
* · `appearance.go` 的 `return` = 兜底分支;
|
||
* 两者不一致本身就报红(那意味着"config 没设时"与"config 设了但为 0"走不同的门)。
|
||
* ★ **仍然读不到运行期实际值**:`AGENTMAIL_MAX_APPEARANCE_BYTES` 可以被环境变量覆盖 ⇒
|
||
* 静态读源码永远只能说"与**默认值**对齐"。要判运行值得真起服务端。别把本条读成
|
||
* "413 已经不可能发生"(见 `CRITERIA.md §16.4` 的标签范围)。
|
||
*/
|
||
const APPEARANCE_GO = join(ROOT, 'server/internal/handler/appearance.go');
|
||
const CONFIG_GO = join(ROOT, 'server/internal/config/config.go');
|
||
|
||
/** 从 `appearance.go` 的 `appearanceMaxBytes()` **兜底分支**取默认值 */
|
||
function fallbackLimit() {
|
||
const src = code(APPEARANCE_GO);
|
||
const fn = /func appearanceMaxBytes\(\)[^{]*\{([\s\S]*?)\n\}/.exec(src);
|
||
if (!fn) return { err: `在 appearance.go 里找不到 func appearanceMaxBytes()` };
|
||
// 取函数体内**最后**一个 return(前面的 return 是配置分支)
|
||
const all = [...fn[1].matchAll(/return\s+(\d+)\s*<<\s*(\d+)/g)];
|
||
if (!all.length) return { err: 'appearanceMaxBytes() 里没有 `return <n> << <m>` 兜底值' };
|
||
const m = all[all.length - 1];
|
||
return { value: Number(m[1]) * 2 ** Number(m[2]), raw: `(${m[1]} << ${m[2]})` };
|
||
}
|
||
|
||
/** 从 `config.go` 的 `MaxAppearanceBytes:` 取 env 默认值 —— **这才是生产里生效的那一处** */
|
||
function configLimit() {
|
||
const src = code(CONFIG_GO);
|
||
const m = /MaxAppearanceBytes:\s*getEnvInt64\(\s*"[^"]*"\s*,\s*(\d+)\s*<<\s*(\d+)\s*\)/.exec(src);
|
||
if (!m) return { err: '在 config.go 里找不到 `MaxAppearanceBytes: getEnvInt64("…", <n> << <m>)`' };
|
||
return { value: Number(m[1]) * 2 ** Number(m[2]), raw: `(${m[1]} << ${m[2]})` };
|
||
}
|
||
|
||
const LIMIT_FALLBACK = fallbackLimit();
|
||
const LIMIT_CONFIG = configLimit();
|
||
// 生效值以 config 那处为准(生产里 config.C 非 nil,走的是它)
|
||
const SERVER_LIMIT_SRC = LIMIT_CONFIG;
|
||
const SERVER_LIMIT = SERVER_LIMIT_SRC.value;
|
||
|
||
/* ────────────────────── ① 缩放 ────────────────────── */
|
||
|
||
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('★ 服务端上限**真的从 Go 源读出来了** —— 两处都读到、且两处相等(读不到必须红,不许静默回退)', () => {
|
||
assert.ok(!SERVER_LIMIT_SRC.err,
|
||
`★ 读不出生效上限(config.go):${SERVER_LIMIT_SRC.err} ⇒ 下面两条"余量"断言就退化成**
|
||
用我猜的数去比客户端**(那正是这次修掉的那条缝:注释说是服务端的值,实际一个字节没读)`);
|
||
assert.ok(!LIMIT_FALLBACK.err,
|
||
`★ 读不出兜底上限(appearance.go):${LIMIT_FALLBACK.err} ⇒ 兜底分支没人守`
|
||
+ '(config 没设/为 0 时走的是它,与生产走的是**不同的门**)');
|
||
assert.equal(SERVER_LIMIT, SERVER_LIMIT_SRC.value,
|
||
'客户端用的上限与从 Go 源解析出来的值不一致 ⇒ 解析没接上');
|
||
// ★ 两处必须相等:不相等意味着"config 为 nil 时"与"config 设了"走不同的门
|
||
assert.equal(SERVER_LIMIT, LIMIT_FALLBACK.value,
|
||
`★ 两处默认值不一致:config.go ${SERVER_LIMIT} ≠ appearance.go 兜底 ${LIMIT_FALLBACK.value}`
|
||
+ ' ⇒ config 未注入时服务端会用另一个上限(客户端余量就是按错的那个算的)');
|
||
// 上限本身要是个合理的正数(防止解析出 0 让"小于上限"变成恒真)
|
||
assert.ok(SERVER_LIMIT > 0, `★ 解析出的服务端上限是 ${SERVER_LIMIT} ⇒ 不是有效上限`);
|
||
// ⚠️ 标签范围:这只证明"与**默认值**对齐",不证明"与运行值对齐"——
|
||
// AGENTMAIL_MAX_APPEARANCE_BYTES 可覆盖(见 §16.4)
|
||
});
|
||
|
||
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 + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`);
|
||
}
|
||
});
|