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