/* * 壁纸上传(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 << ` 兜底值' }; 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("…", << )`' }; 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(/(?= 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,"没人管的 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 + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`); } }); /* ════════ 设备侧:用**真实素材**验压缩链的输入事实 ════════ */ test('★ 设备:真实壁纸在设备上的尺寸/体积与压缩决策的输入一致', async (t) => { /* * 补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口: * 「上传链的设备侧:`@ohos.multimedia.image` + 相册要设备才能真跑」 * * 上面 30 条判的都是 `model/ImagePrep.ts` 的**纯逻辑**(阈值、单调性、 * 边界…),它们全绿时有一件事从未验过:**那些数字与设备上真实的图片对得上吗**。 * 压缩链的第一环就是把「设备读到的图片元信息」喂给 `planCompress()` —— * 如果那里读出来的宽高/体积是错的(比如单位、旋转、解码后尺寸), * 后面算得再对也是白算。 * * ★ 素材用**真实数据**而不是合成图:`/data/local/tmp/real-wallpaper.jpg` * 是用户真上传过的那张壁纸(1402×1122、152570 字节), * 由 `GET /me/appearance/image` 取回。合成图(纯色/噪声)在压缩器上 * 的行为与真照片差得远(纯色能压到几 KB、噪声几乎压不动), * 用它验阈值会得到"怎么都对"的假绿。 * * 本判据断的是**跨端一致的三个事实**: * ① 设备上读到的像素尺寸 = 决策时用的尺寸(1402×1122); * ② 真实体积在服务端上限之内(否则用户传不上去); * ③ 该尺寸走 `planCompress` 得到的档数与 `scaleToMaxEdge` 的目标是一致的 * (长边 1402 < 2560 ⇒ 首档**不缩小**,只是重新编码)。 * * ★★ **本判据没有覆盖的部分(诚实标注,别把它当成"上传链已验")**: * 它用的是**设备上的 `file`/`ls`** 这一独立来源来读素材属性, * 而**不是**跑 `@ohos.multimedia.image` 的 `createImageSource` + `ImagePacker`。 * 也就是说: * · 已验:真实素材在设备上的尺寸/体积,与压缩决策的输入对得上; * · **未验**:应用真的用 `image.createImagePacker()` 压一次时, * 产出的字节数是否落在预期区间(`packJpeg` 那条路径)。 * * 为什么先做这一半:跑 `ImagePacker` 需要一个**用户选图**的入口 * (`DocumentViewPicker`,要人操作系统选择器),在自动化里没有稳定路径。 * 编一个"绕过选择器直接调 packJpeg"的测试专用入口,会是**只有测试在用的代码** * —— 那种代码不会被真实场景触到,验它等于验一个不存在的东西。 * * ⇒ 这一半的欠账记在 `docs/DEBTS.json` 的 `harmony-p4c-boundary-decls` 那组里 * (它本来就是"上传链的边界声明"那笔账),不在这里假装完成。 */ const D = await import('./lib/harmony-device.mjs'); const hdc = D.findHdc(); if (!hdc || !D.hasTarget(hdc)) { return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住纯逻辑)'); } /* 素材在设备上吗?不在就显式跳过(不假装通过) */ const probe = D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg'); const listed = (probe.stdout || '').trim(); /* * ★ 判"在不在"必须看**退出码**,不能只看输出里有没有那个文件名。 * * `hdc shell` 会把 stderr 并进 stdout,于是文件不存在时 stdout 是 * `ls: /data/local/tmp/real-wallpaper.jpg: No such file or directory` —— * **里面仍然含有文件名**,于是原来的 `listed.includes('real-wallpaper.jpg')` * 判成"在",本该跳过的判据继续往下跑,撞在 `/bin/file` 上**报错**。 * 设备上没有素材是正常状态(没传过壁纸的设备),不该让整条判据变红。 */ if (probe.status !== 0 || !/^-.*\breal-wallpaper\.jpg$/m.test(listed)) { return t.skip('设备上没有真实素材 /data/local/tmp/real-wallpaper.jpg —— 本次不跑'); } /* ① 设备自己报的像素尺寸(用设备上的 `file`,那是独立于本应用的第二来源) */ const fileOut = (D.shellOn(hdc, '/bin/file /data/local/tmp/real-wallpaper.jpg').stdout || '').trim(); const dim = /(\d+)x(\d+)/.exec(fileOut); assert.ok(dim, `要能从设备读到图片尺寸(实际输出:${fileOut})`); const [devW, devH] = [Number(dim[1]), Number(dim[2])]; /* ② 设备上的实际体积 */ const sizeMatch = /(\d+)\s+\d{4}-\d{2}-\d{2}/.exec(listed) || /(\d+)/.exec(listed); const devBytes = sizeMatch ? Number(sizeMatch[1]) : 0; assert.ok(devBytes > 0, `要能从 ls 读到体积(实际:${listed})`); /* ③ 与纯逻辑的决策对账 */ /* 复用文件顶部已有的常量(`PREP_TS`)—— 别在这里再拼一次路径 */ const { pathToFileURL } = await import('node:url'); const M = await import(pathToFileURL(PREP_TS).href); assert.ok(devBytes <= M.MAX_UPLOAD_BYTES, `真实素材(${devBytes} 字节)必须在客户端上限(${M.MAX_UPLOAD_BYTES})之内 —— ` + '超了会被 judgePick 拦下,用户看到"图片太大"'); const plan = M.planCompress(devW, devH); assert.ok(plan.passes.length >= 1, 'planCompress 至少给一档'); /* * ★ 长边 1402 < MAX_EDGE(2560) ⇒ 首档**不缩小**(只重编码)。 * 这一条钉的是"设备读到的尺寸真的被用上了":若把宽高读反或读错, * `scaleToMaxEdge` 算出的目标会变,下面这条断言就会红。 */ const scaled = M.scaleToMaxEdge(devW, devH, plan.passes[0].maxEdge); assert.equal(scaled.width, devW, `★ 首档不应缩小(${devW}×${devH} 的长边 ${Math.max(devW, devH)} ` + `< 首档上限 ${plan.passes[0].maxEdge})—— 不等说明设备读到的尺寸与决策用的尺寸不一致`); assert.equal(scaled.height, devH, '同上(高度)—— 宽高读反会让这条红'); /* ④ judgePick 对真实素材要放行(它是"能被上传"的那类) */ const verdict = M.judgePick(devBytes, 'image/jpeg'); assert.equal(verdict.ok, true, `真实素材应被 judgePick 放行(实际 reason="${verdict.reason}")—— ` + '拦下真壁纸说明阈值或 MIME 判定有问题'); });