pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。
## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来
`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:
```
$ git worktree add --detach /tmp/wt-verify 7f4fa26 # 那个检出里 import 顺序**确实**违规
(核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3 # pass 3 # fail 0 ← **在一个明显违规的检出上 3/3 全绿**
```
因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。
同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):
```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts → const ROOT = '/home/program/agentmail';
其余 10 个 → const ROOT = join(HERE, '..', '..', '..');
```
已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。
## 二、修这条时又牵出一个:`stripComments` **改变了行号**
修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。
这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。
## 三、新增两条判据,让这两个形状不能再回来
1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
(`code()` 剥注释,否则本文件自己的说明文字就会误报),找
`const X = '/绝对路径'` 且**看着像仓库内**的声明。
**例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
**行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
(别为了保行号把注释留下)。
两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
(第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
同样这个变异,在修路径**之前**是绿的。
## 四、未做 / 未验
- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
`hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
408 lines
25 KiB
JavaScript
408 lines
25 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);
|
||
|
||
/** 服务端壁纸上限(`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 + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`);
|
||
}
|
||
});
|