/** * P4 判据:外观(主题 + 壁纸)在服务端与本地之间的搬运。 * * 被测对象是鸿蒙客户端**真正引用的那份逻辑**(`model/Appearance.ts`,纯逻辑无 UI 依赖), * 用 node 的 `--experimental-strip-types` 直接执行 —— 断言的是行为,不是源码字符串; * 只有"接线"那几条读源码(因为"逻辑写好了没人用"正是要防的)。 * * 这一期为什么值得单独一组判据:WebUI 侧这套同步**曾经整整一段时间没生效过** * 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL(路径多写了一层 `/api/v1`)。 * 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。 */ import { code, prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..', '..'); const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); /** SDK 位置(与 harmony-system-api 同一套默认值/环境变量) */ const CLT = process.env.HARMONY_CLT || '/opt/huawei/command-line-tools'; const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts'); const A = await import(pathToFileURL(MODULE_TS).href); /** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */ const read = (rel) => code(join(HARMONY_ETS, rel)); const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over); const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over); // ───────────────────── 归一层 ───────────────────── test('服务端回包 → 快照:认不出的值退回默认,不做半信半疑的处理', () => { // 全空(服务端新字段/老数据):每一项都要有安全的默认 const empty = A.snapshotFromResponse(resp()); assert.equal(empty.theme, 'system', '认不出的主题要跟随系统,而不是硬选一个'); assert.equal(empty.bgKind, 'none'); assert.equal(empty.bgPresetId, 'aurora'); assert.equal(empty.bgDim, 12); assert.equal(empty.bgBlur, 4); // 脏值(拼错的枚举、越界数字、小数、NaN)不该被照单全收 const dirty = A.snapshotFromResponse(resp({ theme: 'drak', bg_kind: 'IMAGE', bg_dim: 999, bg_blur: -5 })); assert.equal(dirty.theme, 'system'); assert.equal(dirty.bgKind, 'none', '枚举大小写不同也是认不出(不做模糊匹配)'); assert.equal(dirty.bgDim, 90, '压暗值上限 90'); assert.equal(dirty.bgBlur, 0, '模糊值下限 0'); const frac = A.snapshotFromResponse(resp({ bg_dim: 12.6, bg_blur: 4.4 })); assert.equal(frac.bgDim, 13, '小数要取整(渲染值不能是半个像素)'); assert.equal(frac.bgBlur, 4); assert.equal(A.snapshotFromResponse(resp({ bg_dim: NaN })).bgDim, 12, 'NaN 退回默认'); }); test('本地快照 → PUT 报文:选了图片档却没有图,要退回 none', () => { const noImage = A.payloadFromLocal(snap({ bgKind: 'image' }), false); assert.equal(noImage.bg_kind, 'none', '否则服务端会存一个指向空图的记录'); const withImage = A.payloadFromLocal(snap({ bgKind: 'image' }), true); assert.equal(withImage.bg_kind, 'image'); // 字段名要与服务端 JSON 一致(蛇形);混进驼峰名服务端只会静默用默认值 const payload = A.payloadFromLocal(snap(), false); for (const k of ['theme', 'bg_kind', 'bg_preset_id', 'bg_dim', 'bg_blur']) { assert.ok(k in payload, `PUT 报文要有 ${k}`); } for (const bad of ['bgKind', 'bgDim', 'bgBlur', 'bgPresetId']) { assert.ok(!(bad in payload), `PUT 报文不该出现驼峰名 ${bad}`); } }); // ───────────────────── 合并决策(这一期的验收核心) ───────────────────── test('★ 服务端没有记录时:以**本地**为准并推上去,绝不拿默认值覆盖本地', () => { /* * 这是两条最贵的规则之一。服务端在没有记录时回的是一份**默认值**, * 拿它覆盖本地等于把用户已有的外观(尤其是本地缓存的壁纸)抹掉 —— * WebUI 侧漏了这条时,"每个老用户升级后第一次登录都会发现主题被重置"。 */ const local = snap({ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 }); const m = A.mergeAppearance(local, resp({ saved: false }), false); assert.equal(m.action, 'push-local', '谁覆盖谁:本地覆盖服务端'); assert.equal(m.shouldPush, true, '要把本地这份推上去作为账号的初始外观'); assert.equal(m.status, 'pending', '状态要能看出"正在同步到账号"'); assert.deepEqual( { theme: m.snapshot.theme, bgKind: m.snapshot.bgKind, bgPresetId: m.snapshot.bgPresetId, bgDim: m.snapshot.bgDim, bgBlur: m.snapshot.bgBlur }, { theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 }, '本地那份必须原样保留(一个字段都不能被默认值顶掉)' ); // 反例:这是变异测试要打的那一枪 —— 拿默认值覆盖会立刻丢主题与壁纸 assert.notEqual(m.snapshot.theme, 'system'); }); test('★ 服务端有记录时:以服务端为准,但**不擦掉**本地那张服务端还没有的图', () => { // 正常情形:服务端说了算 const applied = A.mergeAppearance(snap({ theme: 'light', bgKind: 'preset', bgPresetId: 'x', bgDim: 5, bgBlur: 5 }), resp({ saved: true, theme: 'dark', bg_kind: 'preset', bg_preset_id: 'aurora', bg_dim: 30, bg_blur: 20 }), false); assert.equal(applied.action, 'apply-remote'); assert.equal(applied.shouldPush, false); assert.equal(applied.status, 'synced'); assert.equal(applied.snapshot.theme, 'dark', '服务端说了算'); assert.equal(applied.snapshot.bgPresetId, 'aurora'); assert.equal(applied.snapshot.bgDim, 30); // 服务端记着 image 档、但**本体不在**(本地还没推上去 / 图被清过): // 不能照着 image 档渲染一块空地,也不能把本地那张擦掉 const keepLocal = A.mergeAppearance(snap({ bgKind: 'image' }), resp({ saved: true, bg_kind: 'image' }), false); assert.equal(keepLocal.snapshot.bgKind, 'image', '本地有图 → 先按本地算'); const noLocal = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), false); assert.equal(noLocal.snapshot.bgKind, 'none', '本地也没图 → 不能渲染一块空地'); // 服务端真有图:照服务端 const remoteImage = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), true); assert.equal(remoteImage.snapshot.bgKind, 'image'); }); test('离线/未登录:本地就是全部,而且**状态要看得见**(降级不可见 = 用户以为能带走)', () => { const only = A.localOnly(snap({ theme: 'dark' })); assert.equal(only.status, 'local-only'); assert.equal(only.snapshot.theme, 'dark'); assert.equal(only.shouldPush, false, '离线时不该尝试推'); // 三个状态文案要分得开 const labels = ['synced', 'pending', 'local-only'].map(A.statusLabel); assert.equal(new Set(labels).size, 3, '三种状态要有不同文案'); assert.match(A.statusLabel('local-only'), /仅本机/); assert.match(A.statusLabel('pending'), /同步/); }); // ───────────────────── 系统方案:数字 → 系统材质 / 色彩模式 ───────────────────── /* * ── 这里原有的一条判据已删除(2026-09-15)── * * 它判的是 `blurStyleFor`:`bg_blur` px → `BlurStyle` 档位名的映射(分档边界 0/8/20、 * 单调性、NaN、以及"产出的名字必须是 SDK 成员")。**函数本身已按 pi 的裁定删除** —— * 它没有任何调用点,唯一的计划消费者(导航条档位跟随 `bg_blur`)已被否决, * 所以不是"暂时没消费者"而是**不该有**。理由碑文在 `Appearance.ts` 的 `blurStyleFor` 位置。 * * ★ 删掉它**没有留下覆盖空洞**,这一点是我删之前专门核过的: * "档位名必须是 SDK 的 `BlurStyle` 成员、且不是 NONE"这条性质,现在由 * `harmony-nav.test.mjs` 的「导航条材质是**固定系统档**」语义 C 直接判 —— * 它读 `Theme.ets` 里 `navMaterial` 的**声明**,把档位名对着 SDK 枚举成员核。 * 而 `Theme.navMaterial` 正是**唯一剩下的**档位名产生者(原来那个函数没了)。 * ⇒ 覆盖面从"核一个没人调的函数的产出"变成"核真正在用的那个令牌", * **判的还是同一件事,但对象终于是活的**。 * * 教训留在这里(比删掉的那几条断言值钱):**"有测试"不等于"有人用"**。 * 这条判据全绿、逻辑正确,守的却是一个没有消费者的函数 —— * 与 `Theme.navMaterial` 那个"只被不可达 `??` 分支引用"的死令牌是同一个形状。 */ test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不自己维护一套色值)', () => { assert.equal(A.colorModeFor('system'), 'COLOR_MODE_NOT_SET', '跟随系统是默认档'); assert.equal(A.colorModeFor('light'), 'COLOR_MODE_LIGHT'); assert.equal(A.colorModeFor('dark'), 'COLOR_MODE_DARK'); assert.equal(A.colorModeFor('乱七八糟'), 'COLOR_MODE_NOT_SET', '认不出就跟随系统'); /* * 数值必须与 SDK 的 `ConfigurationConstant.ColorMode` 一致 —— 这**容易记反**: * `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对, * 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。 */ const constDts = code(process.env.HARMONY_CONFIG_CONSTANT_DTS || '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8'); const valueOf = (name) => { const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts); assert.ok(m, `SDK 里要有 ${name}`); return Number(m[1]); }; assert.equal(A.colorModeValue('dark'), valueOf('COLOR_MODE_DARK'), '深色的数值要跟 SDK 一致'); assert.equal(A.colorModeValue('light'), valueOf('COLOR_MODE_LIGHT'), '浅色的数值要跟 SDK 一致'); assert.equal(A.colorModeValue('system'), valueOf('COLOR_MODE_NOT_SET'), '跟随系统的数值要跟 SDK 一致'); // 三个数值必须互不相同(写反了这里也能看出来) assert.equal(new Set(['dark', 'light', 'system'].map(A.colorModeValue)).size, 3); // 落地处必须真的调系统 API,而且用同一个映射(免得两边各有一套判断) const store = read('common/AppearanceStore.ets'); assert.match(store, /app\.setColorMode\(colorModeValue\(theme\)\)/, '主题要交给系统色彩模式,数值走纯逻辑'); assert.ok(!/setColorMode\(\s*-?\d\s*\)/.test(store), '页面/store 里不该自己写死色彩模式数值(容易写反)'); // 遮罩浓度:0~90 → 0~1 assert.equal(A.scrimOpacity(0), 0); assert.equal(A.scrimOpacity(90), 0.9); assert.equal(A.scrimOpacity(500), 0.9, '越界要夹住'); }); // ───────────────────── 接线("逻辑写好了没人用"是这一期要防的) ───────────────────── test('★ 路径是相对基地址的(WebUI 那条"整套同步从来没生效过"的坑)', () => { const api = read('api/AppearanceApi.ets'); assert.match(api, /get\('\/me\/appearance'\)/, 'GET 路径'); assert.match(api, /put\('\/me\/appearance'/, 'PUT 路径'); assert.match(api, /uploadFile\('\/me\/appearance\/image'/, '上传壁纸路径'); assert.match(api, /getBytes\('\/me\/appearance\/image'\)/, '取壁纸路径'); // base 已经含 /api/v1:再写一层就是 /api/v1/api/v1/... (WebUI 侧真发生过) assert.ok(!/\/api\/v1\//.test(api), 'AppearanceApi 里不该出现 /api/v1 前缀'); // 图片必须带认证取回来:不能用 ?token=(进日志与历史),也不该让 Image 直接加载 http assert.ok(!/\?token=/.test(api), '不接受把密钥写进 URL'); const client = read('api/ApiClient.ets'); assert.match(client, /expectDataType: http\.HttpDataType\.ARRAY_BUFFER/, '取图要按二进制收,不能当 JSON 解析'); }); test('缓存键**带账号**(多账号共用一份 = WebUI 的原始缺陷)', () => { const store = read('common/AppearanceStore.ets'); assert.match(store, /const KEY_PREFIX: string = 'appearance\.'/, '缓存键要有账号前缀'); assert.match(store, /return KEY_PREFIX \+ accountId;/, '键必须拼上账号 id'); assert.match(store, /prefKey\(accountId\)/, '读缓存要按账号取键'); // 换账号后外观要跟着走:设置页与主界面都要用**当前激活账号**去读 const settings = read('pages/SettingsPage.ets'); assert.match(settings, /store\.loadLocal\(ctx, this\.activeId\)/, '设置页按激活账号读缓存'); const main = read('pages/MainPage.ets'); assert.match(main, /store\.loadLocal\(ctx, acctMgr\.getActiveId\(\)\)/, '主界面按激活账号读缓存'); }); test('两处入口都真的应用了外观(只有设置页生效 = 一进主界面就变回去)', () => { const main = read('pages/MainPage.ets'); assert.match(main, /AppearanceStore\.getInstance\(\)/, '主界面要用同一个 store'); assert.match(main, /store\.syncFromServer\(ctx, client\)/, '主界面进入时要拉一次'); const settings = read('pages/SettingsPage.ets'); assert.match(settings, /await store\.syncFromServer\(ctx, client\)/, '设置页要拉一次'); assert.match(settings, /new AppearanceApi\(client\)\.put\(snap, store\.wallpaper !== null\)/, '改主题要写回服务端'); // 降级要显示给人看("仅本机"),而不是只在内部变量里 assert.match(settings, /statusLabel\(this\.appearanceStatus\)/, '状态要渲染出来'); assert.match(settings, /this\.appearanceStatus = 'local-only'/, '写服务端失败时要如实降级'); // 主题切换三档要齐(跟随系统 / 浅色 / 深色) assert.match(settings, /\['system', 'light', 'dark'\]/, '三档主题'); }); test('判据自检:把「服务端没记录」判成覆盖本地,必须判红', () => { /* * 自检不重跑源码,而是**验证这条判据真的能区分两种行为**: * 手工构造"错误实现"的输出,确认断言会拒绝它。 * (只断言"看起来能红"是不够的 —— 变异测试在下一层做,见提交信息。) */ const wrong = { action: 'apply-remote', status: 'synced', shouldPush: false, snapshot: snap() }; const right = A.mergeAppearance(snap({ theme: 'dark' }), resp({ saved: false }), false); assert.notDeepEqual( { action: wrong.action, shouldPush: wrong.shouldPush }, { action: right.action, shouldPush: right.shouldPush }, '自检:错误实现与正确实现的这三个字段必须不同,否则判据区分不出行为' ); }); export const __coverage = ['snapshotFromResponse', 'payloadFromLocal', 'mergeAppearance', 'localOnly', 'blurStyleFor', 'colorModeFor', 'scrimOpacity', 'statusLabel']; // ───────────────────── P4b:预设档的画法(能力对等,pi 指出的信息对等缺口) ───────────────────── const WALL_TS = join(HARMONY_ETS, 'model/Wallpaper.ts'); const W = await import(pathToFileURL(WALL_TS).href); const webCss = code(join(ROOT, 'client/electron/src/index.css')); const webBgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); test('★ 预设清单与 WebUI 一致(id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => { /* * pi 的原话(2026-09-14):WebUI 的背景有预设渐变,若鸿蒙只认 image/none, * 那"换账号后外观跟随"对预设档就是**不成立**的 —— 用户设了预设, * 在鸿蒙看到的是没有背景。这是**信息对等**缺口,比"能不能上传壁纸"更基础。 * 所以判据从 WebUI 的源码里抽 id 与标签来比,不在这边再抄一遍。 */ const block = webBgStore.slice(webBgStore.indexOf('export const PRESETS'), webBgStore.indexOf('];', webBgStore.indexOf('export const PRESETS'))); const webIds = [...block.matchAll(/id:\s*'([a-z]+)'/g)].map(m => m[1]); const webLabels = [...block.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); assert.ok(webIds.length >= 4, `WebUI 的 PRESETS 要能抽出 id,实际 ${webIds.length} 个`); assert.deepEqual(W.PRESET_IDS, webIds, '预设 id 与顺序必须与 WebUI 一致'); assert.deepEqual(W.PRESET_IDS.map(W.presetLabel), webLabels, '预设标签必须与 WebUI 一致(用户看到的就是这两个字)'); // 每一个预设都要真能画出层来("有 id 但画不出东西"就是这个缺口的原始形态) for (const id of W.PRESET_IDS) { const layers = W.layersFor(id, false); assert.ok(layers.length >= 1, `预设 ${id} 至少要有一层`); assert.ok(layers.every(l => l.colors.length > 0 || l.kind === 'grid'), `预设 ${id} 的层要有色标`); assert.ok(layers.some(l => l.kind === 'grid' || l.colors.length >= 2), `预设 ${id} 要能画出渐变`); } }); test('★ 预设色值与 WebUI 的调色板变量逐个对照(不是凭印象写的)', () => { /* * 这类"看起来差不多"的色值是跨端最容易悄悄分叉的东西:两边各写一遍十六进制, * 谁也说不清哪个是当前的。所以从 CSS 的调色板变量里读出 RGB,再比到这边写死的色值。 */ const cssVar = (name) => { const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(webCss); assert.ok(m, `CSS 里要有 ${name}`); return '#' + [m[1], m[2], m[3]] .map(n => Number(n).toString(16).padStart(2, '0').toUpperCase()) .join(''); }; const used = new Set(); for (const id of W.PRESET_IDS) { for (const l of W.layersFor(id, false)) { for (const c of l.colors) if (c !== W.TRANSPARENT) used.add(c); if (l.lineColor) used.add(l.lineColor); } } const palette = { '#F3F4F6': cssVar('--c-gray-100'), '#EAECF1': cssVar('--c-gray-200'), '#DBEAFE': cssVar('--c-blue-100'), '#BFDBFE': cssVar('--c-blue-200'), '#DCFCE7': cssVar('--c-green-100'), '#FEF3C7': cssVar('--c-amber-100'), '#FFEDD5': cssVar('--c-orange-100') }; for (const c of used) { assert.ok(palette[c] !== undefined, `预设里出现了不在登记色板内的色:${c}`); assert.equal(c, palette[c], `预设色 ${c} 与 WebUI 调色板不一致(CSS 里是 ${palette[c]})`); } // 反向:登记色板里的每个色都要真被用上(登记了不用 = 名单在过期) for (const c of Object.keys(palette)) { assert.ok(used.has(c), `色板里 ${c} 没有被任何预设使用(色板过期了)`); } // 透明必须用关键字而不是 8 位色值(8 位色值在本仓是"手写玻璃"的证据,另有一条判据禁) for (const id of W.PRESET_IDS) { for (const l of W.layersFor(id, false)) { for (const c of l.colors) { assert.ok(!/^#[0-9A-Fa-f]{8}$/.test(c), `预设里的透明要用 Color.Transparent 语义(${c} 是 8 位色值)`); } } } // 层数/层序有据可依:每个预设的层数与 CSS 里的渐变段数对应 for (const id of W.PRESET_IDS) { const cssBlock = webCss.slice(webCss.indexOf(`.bg-preset-${id}`)); const cssBody = cssBlock.slice(0, cssBlock.indexOf('}')); const cssSegments = (cssBody.match(/(radial|linear)-gradient/g) || []).length; const mine = W.layersFor(id, false).filter(l => l.kind !== 'grid').length + (W.layersFor(id, false).some(l => l.kind === 'grid') ? 1 : 0); assert.ok(cssSegments >= 1, `CSS 里 ${id} 要有渐变段`); assert.equal(mine >= cssSegments, true, `${id}:CSS 有 ${cssSegments} 段,鸿蒙只画了 ${mine} 层`); } }); test('画什么:none / preset / image 三档,图没取回来不许画空白', () => { // 认不出的 preset → 默认档(与 WebUI 的 normalize 同一规则),不是"没有背景" assert.equal(W.normalizePreset('不认识'), 'aurora'); assert.equal(W.normalizePreset(''), 'aurora'); assert.equal(W.layersFor('不认识', false).length, W.layersFor('aurora', false).length, '认不出要走默认档的画法'); const none = W.resolveBackground('none', 'aurora', 0.2, false, false); assert.equal(none.kind, 'none'); assert.deepEqual(none.layers, [], 'none 档不该画任何层'); const preset = W.resolveBackground('preset', 'mint', 0.2, false, false); assert.equal(preset.kind, 'preset'); assert.ok(preset.layers.length >= 1, 'preset 档要真的画出层来(这就是那个缺口的判据)'); /* * ⚠️ 这里原来断言的是"preset 档不压暗"。pi 2026-09-14 读 WebUI 源码后指出 * **两边不一致**:WebUI 的 `applyBackground()` 无条件写 `--bg-dim`(默认 24), * `.app-backdrop::after` 是 `rgb(var(--bg-scrim) / var(--bg-dim))` —— 遮罩不区分档位; * 它的注释写着目的「背景越花,正文越需要一层遮罩才读得动」(可读性,不是装饰)。 * 而鸿蒙当时只在 image 档压,且我们已经让出页面底 → 正文直接压在原色渐变上,比 WebUI 更艳更亮。 * 现在两档都压(同一个浓度),判据按"两边一致"钉住。 */ assert.equal(preset.scrim, 0.2, 'preset 档**也要**压暗,浓度与 image 档一致(WebUI 的 --bg-dim 不区分档位)'); const img = W.resolveBackground('image', 'aurora', 0.24, true, false); assert.equal(img.kind, 'image'); assert.equal(img.scrim, 0.24, '压暗浓度要传给画的那一层'); const imgMissing = W.resolveBackground('image', 'aurora', 0.24, false, false); assert.equal(imgMissing.kind, 'none', 'image 档但图没取回来 → 什么都不画(画一块空白会被当成"壁纸坏了")'); }); test('★ 页面真的把背景画出来了(这一条是补漏:P4 第一版只取回了图,没有任何东西去画)', () => { /* * P4 第一版的实际状态:`AppearanceStore` 取回了 `PixelMap`、算好了快照, * 但**没有任何组件去画它** —— 也就是说壁纸只有数据没有画面。 * 当时的提交信息没写错("取回 PixelMap"),但文档里把它列成"未验渲染", * 听着像已经画出来了 —— 那是我说得比证据强。这条判据盯的就是这一层不许再缺。 */ const main = read('pages/MainPage.ets'); assert.match(main, /WallpaperLayer\(\)/, '主界面要真的铺一层背景'); assert.match(main, /resolveBackground\(/, '画什么由纯逻辑决定(不是页面里现编)'); assert.match(main, /radialGradient\(\{/, '预设档用系统径向渐变'); assert.match(main, /linearGradient\(\{/, '预设档用系统线性渐变'); assert.match(main, /Canvas\(this\.gridCtx\)/, '网格档用系统 Canvas 画线(没有对应的系统渐变原语)'); assert.match(main, /Image\(this\.wallpaperImage\)/, '图片档要真的把图渲染出来'); assert.match(main, /\.objectFit\(ImageFit\.Cover\)/, '图片要铺满(不是拉伸变形或留白)'); // 压暗用系统遮罩色 + 服务端浓度 /* * 遮盖层:用**页面底色系**的 `Theme.wallpaperScrim`(不是模态遮罩 mask)—— * 浅色主题下 mask 是深色(#99182431),会把预设压暗,而 WebUI 是把预设洗淡(--bg-scrim 是白)。 * 这一条与下面那条"遮盖色方向"是同一件事的两个面:这里钉"层在不在、浓度接没接上"。 */ assert.match(main, /\.backgroundColor\(Theme\.wallpaperScrim\)[\s\S]{0,80}?\.opacity\(this\.bgPlan\.scrim\)/, '遮盖层要用页面底色系令牌与算出来的浓度'); /* * 背景在主界面这一层:内容之前不该再有不透明的底色把背景盖死。 * ⚠️ P5 起根 `Stack` 带了构造参数(`{ alignContent: Alignment.Bottom }`,浮动条贴底用), * 所以这里不能写死 `Stack() {` —— 判据钉的是"壁纸是**第一个**子节点",不是 Stack 的写法。 */ const rootFrom = main.indexOf('this.WallpaperLayer()'); assert.ok(rootFrom > 0, '根里要渲染壁纸层'); const stackOpen = [...main.matchAll(/Stack\([^)]*\)\s*\{/g)].map(m => m.index).filter(i => i < rootFrom).pop(); assert.ok(stackOpen !== undefined, '壁纸层要挂在某个 Stack 里(浮在最底)'); const between = main.slice(stackOpen, rootFrom); assert.ok(!/[A-Za-z]+\(/.test(between.replace(/Stack\([^)]*\)/, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')), `壁纸层必须是 Stack 的**第一个**子节点(中间不该有别的组件):${between.replace(/\s+/g, ' ').slice(0, 120)}`); }); test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统材质(互斥形式,pi 修正后的口径)', () => { /* * pi 撤回了他原来那句"模糊只由壁纸层负责":那是 WebUI 的架构结论 * (它的壁纸图层自带 `filter: blur()`,浮在它上面的面再 backdrop-filter 就是把 * 同一张糊过的底糊第二遍),不是通用规则。正确的形式是两条: * ① 同一张底只许被模糊**一次**; * ② 模糊该出现在"背后是**可变内容**"的层(导航条背后是滚动内容,壁纸层背后什么都没有)。 * 于是判据写成互斥/分工,而不是"归谁"。 */ const main = read('pages/MainPage.ets'); /* * 取"壁纸层 builder 的正文"要**按行**截:用 `indexOf('build() {')` 两头夹, * 要么撞上文件里更早的那个 build()(切片成空串),要么一路跨到后面的 * 底栏 builder(P5 起叫 `NavBar`,那里**正当地**有 `.backgroundBlurStyle`)——于是判据会误报。 * 这是我自己的切片毛病,和 pi 指出 §三 那条是同一类。 */ const mainLines = main.split('\n'); const start = mainLines.findIndex(l => l.includes('WallpaperLayer() {')); assert.ok(start > 0, '要能找到壁纸层的 builder'); let stop = mainLines.length; for (let i = start + 1; i < mainLines.length; i++) { if (/^ (@Builder|build\()/.test(mainLines[i])) { stop = i; break; } } const wallpaperBuilder = mainLines.slice(start, stop).join('\n'); assert.ok(!/NavBar/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去'); assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文'); /* * ── 这两条 2026-09-15 修正过,理由值得留在这里 ── * * 原来是「壁纸层不许出现**任何**模糊调用」(`!/blur\(/i`)。那条**太宽**: * 它把两种**不同的物理量**当成了一件事,而 WebUI 侧的源码证明它们是分开的 * (`client/electron/src/index.css`): * · `.app-backdrop`(z-index:-1,背后什么都没有)吃 `filter: blur(var(--bg-blur))` * ⇒ **图片内容模糊**(服务端 `bg_blur` 那个 px 值,作用对象是壁纸自己); * · `.app-backdrop` 之上的面吃 `backdrop-filter: blur(8px)` * ⇒ **背后内容模糊**(面板材质)。 * "同一张底被糊两遍"指的是**后者在一张已经糊过的底上再做一次**,不是"壁纸自己不许糊"。 * * 所以正确的互斥形式是:**壁纸层只许做图片内容模糊,不许做面板材质模糊**; * 面板材质只许出现在导航条那种"背后是可变内容"的层。两条分别钉住。 * * (触发这次修正的是 P4c:加背景选择器时滑杆能拖、`bg_blur` 能存, * 但壁纸一点没糊 —— 而计划文档 §7.12 恰好写着"若将来鸿蒙开始消费它, * 那时必须补一条映射判据,并更新本行"。) */ assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), '★ 壁纸层不许用**面板材质**(`backgroundBlurStyle` 作用在背景=背后的内容上;' + '壁纸层背后什么都没有,那是"给一张糊过的底再糊一遍"的形状)'); assert.match(wallpaperBuilder, /\.blur\(this\.bgPlan\.blurPx\)/, '壁纸层要按服务端 `bg_blur` 的**px 原值**做图片内容模糊(与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)'); const imgBlur = [...wallpaperBuilder.matchAll(/\.blur\(/g)]; assert.equal(imgBlur.length, 1, `壁纸层的图片内容模糊只许一次(实际 ${imgBlur.length} 次)`); // 导航条的**面板材质**仍然在(背后是会滚动的内容,遮蔽有意义),且档位来自用户偏好 assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条的面板材质用**固定系统档**(`Theme.navMaterial`)—— 不跟随 `bg_blur`'); // 材料档次由用户偏好映射而来(不是写死的半径) const store = read('common/AppearanceStore.ets'); assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式'); /* * "理由写清了没"要读**原文**(`prose()`;`code()`/`read()` 会剥注释 —— 而理由就在注释里)。 * 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。 */ const wallRaw = prose(join(HARMONY_ETS, 'model/Wallpaper.ts')); // 理由在注释里 → prose(见上) assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)'); assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)'); }); test('判据自检:预设清单少一档必须判红', () => { // 自检:把 id 列表裁掉一个,确认"与 WebUI 一致"那条会红 const block = webBgStore.slice(webBgStore.indexOf('export const PRESETS'), webBgStore.indexOf('];', webBgStore.indexOf('export const PRESETS'))); const webIds = [...block.matchAll(/id:\s*'([a-z]+)'/g)].map(m => m[1]); const trimmed = webIds.slice(0, -1); assert.notDeepEqual(trimmed, W.PRESET_IDS, '自检:裁掉一档后必须与实现不一致(否则这条判据没有分辨力)'); }); test('★ 背景画出来了还不够:每个页面要**让出**页面底,否则壁纸全被盖住', () => { /* * 这一条是"渲染"这句话的另一半。只把壁纸铺在最底层、而每个页面自己又刷一层 * **不透明**的系统页面底,壁纸就等于没画(用户看到的仍然是纯色页面)。 * WebUI 侧的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class, * 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。 * * 所以判据钉的是"没有一处页面底还在用不透明的系统页面底"—— * 漏掉任何一个页面,就是那一页看不到壁纸。 */ const main = read('pages/MainPage.ets'); assert.equal([...main.matchAll(/\.backgroundColor\(Theme\.pageBg\)/g)].length, 0, '还有页面底用不透明的 Theme.pageBg —— 那几页看不到壁纸'); /* * ★★ 2026-09-19 改成判**不变式**而不是判**字面量**。 * * 原来断言的是"内联三元 `.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)` * 至少出现 5 次" —— 那是在数**一种写法**,不是数"页面有没有让出底"。 * 而本次把这条三连收敛进了 `PaneModifier.of(this.bgActive)` * (用户:「应该定义一个基础玻璃容器给各个组件引用」)⇒ 写法变了、行为没变, * 判据却红了。这是典型的"判据锚在实现上、不是锚在它声称的东西上"。 * * 现在判两件事,都是**不变式**: * ① 让出页面底只有一条路径(`PaneModifier`),且它真的会因 `bgActive` 变透明; * ② 每个窗格都**收到**这个开关(漏传 = 该页恒不透明,等于没让出)。 */ const paneMod = read('common/Surface.ets'); /* 相对 HARMONY_ETS(= .../ets),不是相对 pages/ */ assert.match(paneMod, /class PaneModifier implements AttributeModifier/, '页面底要让位给壁纸 ⇒ 必须有一个统一的窗格属性集(不是每页各写三元)'); assert.match(paneMod, /backgroundColor\(this\.active \? Color\.Transparent : Theme\.pageBg\)/, 'PaneModifier 必须在 active 时返回透明(否则"让出"这件事根本没发生)'); /* * ★★ 2026-09-24 改:判"让出页面底"的**两种入口**,而不是数一种写法。 * * 原断言:`\.attributeModifier\(PaneModifier\.of\(this\.bgActive\)\)` 至少出现 5 次。 * * 本次加了 `PaneModifier.plain(...)`(同背景语义、**不投投影**)—— * 它同样“让出页面底”(`backgroundColor` 那段是共用的), * 而旧断言只数 `of(` ⇒ 当场假红(实测:`实际 1 处`、`fail 1`)。 * * 这正是本文件自己反复记过的形状:**判据锚在实现的一种写法上, * 而不是锚在它声称的那件事上**。"让出页面底"的两种合法写法都要算。 * (`plain` 的出现理由:同一窗格被多层嵌套时,只有**最外层的并列窗格** * 该投投影,内层用 `plain` —— 见 `Surface.ets` 的 `shadowed` 注释。) */ const yielded = [...main.matchAll(/\.attributeModifier\(PaneModifier\.(?:of|plain)\(this\.bgActive\)\)/g)].length; assert.ok(yielded >= 5, `要让出页面底的页面至少 5 个(通信/联系人 + 三个 pane),实际 ${yielded} 处`); // 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出) for (const comp of ['CommPage', 'ContactsTab']) { // 组件现在多接一个 navReserve(底部条高度 → 列表末尾让位),所以判"收到了 bgActive"而不是整行字面量 const at = main.indexOf(`${comp}({`); assert.ok(at >= 0, `主界面要渲染 ${comp}`); const call = main.slice(at, main.indexOf('})', at) + 2); assert.match(call, /bgActive:\s*this\.bgActive/, `主界面要把 bgActive 传给 ${comp}`); } for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) { // 参数已是多行形式(InboxTab/SentTab 还接了 onOpenMail),所以判"这个组件收到了 bgActive" // 而不是要求整行字面量 —— 只判字面量的话,加点别的参数就会假红。 const at = main.indexOf(`${pane}({`); assert.ok(at >= 0, `通信页要渲染 ${pane}`); const call = main.slice(at, main.indexOf('})', at) + 2); assert.match(call, /bgActive:\s*this\.bgActive/, `通信页要把 bgActive 传给 ${pane}`); } // 开关必须由**背景计划**驱动(不是写死的 true/false) assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定'); /* * 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图)。 * * ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件** * (`pages/PermissionTab.ets`)。原来这份名单一律在 `main` 里找 * `struct X {` —— 抽出后 `PermissionTab` 不在 `main` 里了, * `indexOf` 返回 -1 ⇒ `main.slice(-1, 399)` 得到**空串** ⇒ 断言红。 * * ★ 值得记的是这个红**是好红**:它立刻指出了"有个组件搬家了"。 * 而 `harmony-logic` 那条同形状的判据当时**静默变成了空断言** * (它用的是 `slice(indexOf(...))`,-1 会变成"从末尾取一个字符") * —— 两者差别只在于 `slice` 的参数形状。**同一类搬家,一个红一个哑。** * ⇒ 这次把"组件在哪个文件"显式写进名单,而不是靠"它恰好在 main 里"。 */ const PANE_SOURCES = { CommPage: main, ContactsTab: main, InboxTab: main, SentTab: main, /* * ★ 已抽出,读它们自己的文件(搬家的地方只在这里写一次)。 * * 2026-09-23:按用户「把组件按页面封装以便与 WebUI 一一对应」的要求, * `PermissionTab` 与 `ContactsTab` 都从 `MainPage.ets` 抽成了独立文件。 * 这份名单必须跟着改 —— 否则判据报"找不到",而组件其实好好的。 */ PermissionTab: read('pages/PermissionTab.ets'), ContactsTab: read('pages/ContactsTab.ets') }; for (const comp of Object.keys(PANE_SOURCES)) { const src = PANE_SOURCES[comp]; const at = src.indexOf(`struct ${comp} {`); assert.ok(at >= 0, `${comp} 要能在它该在的文件里找到(组件搬家要一起改本判据)`); const head = src.slice(at, at + 400); assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`); } }); /* * ═══════════════════════════════════════════════════════════════════ * 窗格投影:**只有"并列窗格"该投**,面板内部不投 * ═══════════════════════════════════════════════════════════════════ * * 用户 2026-09-24:「顶栏莫名其妙的底部阴影」+「跟 webui 同步」。 * * ── 症状 ── * 页签条(收件箱/发件箱/授权)内部从上到下 254→234 **单调递减**一条暗带, * 像素实测贯通整条(x 250→1150 亮度恒 224-227)。 * * ── 根因(几何取证,不是猜)── * `InboxTab` 的根容器与页签条是 `Column` 里的**兄弟**,且**绘制在后** * (页签条 y 142→271,InboxTab y 271→2202)。它的 `PaneModifier` 投影向四周扩散 * ⇒ 向上扩散的那 ~30px 正好压住页签条(观测到的渐变区 y 240→268,吻合)。 * * 实测验证(只把 `InboxTab` 改 `plain`,其余不动): * 修前落差 21(254→233) → 修后落差 8(255→247)✅ * 而窗格自己那圈投影(x=230 处 176-180)**仍在** —— 没把该有的层次感一起删掉。 * * ── WebUI 为什么没这问题 ── * `box-shadow` 只挂在 `.app-shell > *`(`index.css:980`)—— * 那是**三个并列面板**(Sidebar / list / main),**没有嵌套**。 * 面板**内部**(列表、卡片)不再投投影。 * * ── 为什么不只盯 `InboxTab` 一个名字 ── * 同一个形状在仓里有**多处**(`SentTab`/`PermissionTab`/`ContactsTab` 的 navBar 内容)。 * 只钉一个名字的话,下次改另一个 tab 就会把同一条阴影带回来。 * 所以判的是**结构不变式**: * · `Navigation` 壳(并列窗格)→ `PaneModifier.of`(带投影); * · 壳**内部**那几层 → `PaneModifier.plain`(不投)。 */ test('★ 窗格投影只给并列窗格;面板内部的子 tab 不得再投一次(顶栏阴影的根因)', () => { const surface = read('common/Surface.ets'); /* 前提:两类工厂都得存在,且 plain 真的不投投影 */ assert.match(surface, /static plain\(active: boolean\): PaneModifier/, '要有一个"只要背景语义、不投投影"的入口(面板内部用它)'); assert.match(surface, /private shadowed: boolean = true;/, 'PaneModifier 要有一个显式开关区分"投/不投",而不是靠调用点自己记'); assert.match(surface, /if \(this\.shadowed\) \{\s*instance\.shadow\(Theme\.glassShadow\);\s*\}/, '`plain` 必须**真的**不调 shadow —— 否则这个开关是装饰品'); /* * 不变式:**子 tab 的根容器**不得带投影(它们就在页签条下方、绘制在后)。 * * ★ 判法:先定位每个 modifier 落在哪个 `struct` 里,再要求子 tab 那些 struct * 一律用 `plain`。 * * ⚠ 第一版写的是"该文件里 `plainCount >= 1`" —— **变异测不出来**: * 把 `InboxTab` 改回 `of` 后,同文件的 `SentTab`/navBar 那几处 `plain` * 仍然存在 ⇒ 计数 ≥1 照样绿(实测:变异① 没抓到)。 * "存在某个正确的" 与 "该正确的那个不许错" 是两件事。 */ const CHILD_TABS = ['InboxTab', 'SentTab', 'PermissionTab', 'ContactsTab']; /* 某个偏移落在哪个 struct 里(取它前面最近的那个 `struct X {`) */ const structAt = (src, idx) => { const before = src.slice(0, idx); const ms = [...before.matchAll(/(?:^|\n)struct\s+(\w+)\s*\{/g)]; return ms.length ? ms[ms.length - 1][1] : '(top)'; }; for (const [file, label] of [ ['pages/MainPage.ets', 'CommPage'], ['pages/ContactsTab.ets', 'ContactsTab 页'], ['pages/PermissionTab.ets', 'PermissionTab'], ]) { const src = read(file); const offenders = []; for (const m of src.matchAll(/PaneModifier\.(of|plain)\(this\.bgActive\)/g)) { const owner = structAt(src, m.index); if (m[1] === 'of' && CHILD_TABS.includes(owner)) offenders.push(owner); } assert.deepEqual(offenders, [], `${label}: ${offenders.join('/')} 的根容器用了 \`PaneModifier.of\`(带投影),` + '但它们就在页签条下方且绘制在后 ⇒ 投影向上扩散会压住页签条。' + '要改用 `PaneModifier.plain`(投影只留给 Navigation 那层并列窗格)'); } /* 同时:并列窗格那层仍要**保留**投影,否则层次感整个丢掉(不是只删不建) */ const mainSrc = read('pages/MainPage.ets'); const commAt = mainSrc.indexOf('struct CommPage {'); assert.ok(commAt >= 0, '要能找到 CommPage'); const commBody = mainSrc.slice(commAt, mainSrc.indexOf('\n}\n', commAt)); assert.match(commBody, /PaneModifier\.of\(this\.bgActive\)/, 'CommPage 的 Navigation(并列窗格)要保留带投影的 `PaneModifier.of` —— ' + '否则窗格浮在壁纸上的层次感没了'); /* 同一个实例上不得挂两个 .attributeModifier(后者静默覆盖前者,意图丢失) */ const main = read('pages/MainPage.ets'); const navAt = main.indexOf('Navigation(this.navPathStack)'); assert.ok(navAt >= 0, 'CommPage 要有 Navigation'); /* * ⚠ 取**属性链**(从 `navDestination(` 到闭合 `}`)而不是".slice(4000)": * 第一版写 4000 字符,跨到了下一个组件的 modifier ⇒ 数出 3 个、假红。 * 属性链上有固定的一组锚(navDestination / mode / navBarWidth), * 用它定界比按字符数可靠。 */ const chainStart = main.indexOf('.navDestination(', navAt); const chainEnd = main.indexOf('\n }\n}', chainStart); assert.ok(chainStart > navAt && chainEnd > chainStart, '要能找到 Navigation 的属性链边界'); const chain = main.slice(chainStart, chainEnd); const navMods = [...chain.matchAll(/^\s*\.attributeModifier\(PaneModifier\./gm)].length; assert.ok(navMods <= 1, `同一个 Navigation 实例上挂了 ${navMods} 个 .attributeModifier —— ` + '`.attributeModifier` 是**单一插槽**,后者会静默覆盖前者,' + '于是"壳有没有投影"取决于书写顺序而不是意图(2026-09-24 已删掉那处重复)'); }); // ───────────── 深色色板:pi 指出这是"机制上确定不同",不是"观感未验" ───────────── /** 从 index.css 的某个段(:root 或 .dark)里读出调色板变量的 RGB */ function cssPalette(selector) { const at = selector === ':root' ? webCss.indexOf(':root') : webCss.indexOf('.dark {'); assert.ok(at >= 0, `CSS 里要有 ${selector} 段`); let depth = 0; let end = at; for (let i = webCss.indexOf('{', at); i < webCss.length; i++) { if (webCss[i] === '{') depth++; else if (webCss[i] === '}') { depth--; if (depth === 0) { end = i; break; } } } const body = webCss.slice(webCss.indexOf('{', at), end); const read = (name) => { const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(body); assert.ok(m, `${selector} 里要有 ${name}`); return '#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0').toUpperCase()).join(''); }; return { gray100: read('--c-gray-100'), gray200: read('--c-gray-200'), blue100: read('--c-blue-100'), blue200: read('--c-blue-200'), green100: read('--c-green-100'), amber100: read('--c-amber-100'), orange100: read('--c-orange-100') }; } test('★ 预设色板**两套**、且深色那套与 CSS 的 `.dark` 段逐个相等(不是"观感未验",是机制)', () => { /* * pi 的原话(2026-09-14):WebUI 的 `.bg-preset-*` 写的是 `rgb(var(--c-blue-100))`, * 而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → 30 43 67), * 所以 WebUI 的预设**自动随主题变**。这边若只有浅色那套, * 深色主题下就是"浅色渐变垫在深色系统表面之下" —— 同一个病。 * **它不需要真机就能判**:机制写在代码里。所以从"未验"改成"判"。 */ const lightCss = cssPalette(':root'); const darkCss = cssPalette('.dark'); // 两套必须真的不同(否则"两套"是假的:同一套抄了两遍) for (const k of Object.keys(lightCss)) { assert.notEqual(lightCss[k], darkCss[k], `CSS 里 ${k} 的深浅两套应当不同(判据前提)`); } const light = W.paletteFor(false); const dark = W.paletteFor(true); assert.equal(light.dark, false); assert.equal(dark.dark, true); for (const k of Object.keys(lightCss)) { assert.equal(light[k], lightCss[k], `浅色色板 ${k} 与 CSS :root 不一致`); assert.equal(dark[k], darkCss[k], `深色色板 ${k} 与 CSS .dark 不一致(深色档漏了一个色就是"半深不浅")`); } // 每个预设的层在两种主题下都要能画出来,且**颜色确实换了** for (const id of W.PRESET_IDS) { const l = W.layersFor(id, false); const d = W.layersFor(id, true); assert.equal(l.length, d.length, `${id} 深浅两套的层数要一致`); assert.notDeepEqual( l.map(x => x.colors.join(',') + '|' + x.lineColor), d.map(x => x.colors.join(',') + '|' + x.lineColor), `${id} 的深色档与浅色档颜色相同 —— 预设没有真的跟着主题走` ); } // 网格档的线色也要换 const meshL = W.layersFor('mesh', false).find(x => x.kind === 'grid'); const meshD = W.layersFor('mesh', true).find(x => x.kind === 'grid'); assert.equal(meshL.lineColor, lightCss.gray200); assert.equal(meshD.lineColor, darkCss.gray200, '网格线色也要跟着主题(否则深色下是一张白网格)'); }); test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅色(与 WebUI 默认一致)', () => { assert.equal(A.isDarkMode('dark', 1), true, '用户选了深色就照办(即使系统是浅色)'); assert.equal(A.isDarkMode('light', 0), false, '用户选了浅色就照办'); assert.equal(A.isDarkMode('system', 0), true, 'system + 系统深色 → 深色色板'); assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板'); assert.equal(A.isDarkMode('system', -1), false, '系统还没定(NOT_SET)→ 按浅色,与 WebUI 的 :root 默认一致'); // 数值锚到 SDK:0=深、1=浅 const sdkConst = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts')); assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0(别记反)'); assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1'); // 页面真的按主题选色板(不是写死 false) const main = read('pages/MainPage.ets'); assert.match(main, /isDarkMode\(snap\.theme, systemMode\)/, '主界面要按当前主题算深浅色'); // 取调用点的正文要按**括号配对**,别用 `[^)]*` —— 里面还套着 `scrimOpacity(...)`, // 一遇到内层 `)` 就断(这正是"邻接/窗口不是结构"那条规则,我自己先守) const callAt = main.indexOf('resolveBackground('); assert.ok(callAt > 0, '要能找到 resolveBackground 调用'); let depth = 0; let callEnd = callAt; for (let i = main.indexOf('(', callAt); i < main.length; i++) { if (main[i] === '(') depth++; else if (main[i] === ')') { depth--; if (depth === 0) { callEnd = i; break; } } } const callText = main.slice(callAt, callEnd + 1); /* * ★ 这条原来写的是 `/\bdark\b\s*\)/`(要求 dark 是**最后一个**实参)。 * 那句断的是"参数顺序",不是"深浅色有没有传过去" —— P4c 在 `dark` 后面加了 * `blurPx` 入参,它立刻红了,而传给计划的东西一个没少。**邻接不是语义** * (与文件里取调用点正文要按括号配对是同一条规则)。改成"dark 确实在实参里"。 */ assert.ok(/\bdark\b\s*[,)]/.test(callText), `深浅色要传给背景计划:${callText}`); assert.ok(/\bsnap\.bgBlur\b/.test(callText), `模糊档也要传给背景计划(否则滑杆能拖、壁纸不糊):${callText}`); }); test('★ 预设档的遮盖:两档同一个浓度(WebUI 的 --bg-dim 不区分档位)+ 遮盖层用系统遮罩色', () => { /* * pi 的原话:「这个遮罩在 WebUI 那里服务的是**可读性**,不是装饰。」 * 所以判据钉三件事:浓度来自同一个入参(不是各写一个数)、preset 也要有、 * 遮盖层用的是**系统遮罩色**(深浅换向由系统负责,不是我们写 alpha)。 */ const same = W.resolveBackground('preset', 'aurora', 0.3, false, false); const img = W.resolveBackground('image', 'aurora', 0.3, true, false); assert.equal(same.scrim, 0.3); assert.equal(img.scrim, 0.3, '两档要用同一个浓度(同一个服务端字段)'); for (const id of W.PRESET_IDS) { for (const dark of [false, true]) { assert.equal(W.resolveBackground('preset', id, 0.15, false, dark).scrim, 0.15, `${id}(dark=${dark})预设档也要压暗`); } } // WebUI 侧的前提:遮罩真的不区分档位(否则"对齐"就没有依据) const bgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); assert.match(bgStore, /--bg-dim|setProperty\('--bg-dim'/, 'WebUI 要无条件写 --bg-dim(这是"两档都压"的依据)'); const main = read('pages/MainPage.ets'); // 两档各有一处遮盖层(都用系统遮罩色 + 算出来的浓度) const scrims = [...main.matchAll(/\.backgroundColor\(Theme\.wallpaperScrim\)\s*\n\s*\.opacity\(this\.bgPlan\.scrim\)/g)]; assert.ok(scrims.length >= 2, `预设档与图片档各要有一层遮盖(实际 ${scrims.length} 处)`); }); test('★ 多账号缓存键:**按账号**分(鸿蒙是对的,不许为"对齐 WebUI"退回全局键)', () => { /* * pi 2026-09-14:WebUI 的缓存键是**全局常量** `agentmail.background`, * 后果是切到一个服务端没有记录的账号时 `saved=false` 分支会把**上一个账号的外观** * push 上去(于是新账号"继承"了外观,而且写进了服务端)。 * 鸿蒙这边按账号分键是对的 —— 所以这条判据**防的是将来有人为了"两边一致"把它改回去**。 */ const store = read('common/AppearanceStore.ets'); /* * 键由 `prefKey(accountId)` 拼 —— 所以判据要**取出这个函数的正文**再断言 * (`prefKey` 存在不等于它带账号;这正是"判结构要配对/解析"那条), * 而不是看调用点有没有出现 `accountId` 就当数。 */ const at = store.indexOf('prefKey(accountId: string)'); assert.ok(at > 0, '要有一个按账号取键的函数'); let depth = 0; let end = at; for (let i = store.indexOf('{', at); i < store.length; i++) { if (store[i] === '{') depth++; else if (store[i] === '}') { depth--; if (depth === 0) { end = i; break; } } } const body = store.slice(at, end + 1); assert.match(body, /KEY_PREFIX\s*\+\s*accountId/, `取键函数必须把账号拼进去(现在:${body.replace(/\s+/g, ' ')})`); assert.match(store, /loadLocal\([^)]*accountId/, '读缓存要按账号'); assert.ok(!/AGENTMAIL_BACKGROUND|'agentmail\.background'/.test(store), '不要退回 WebUI 那个全局键(那正是"换账号继承上一个人的外观"的成因)'); assert.match(store, /loadLocal\([^)]*accountId/, '读缓存要按账号'); /* * ⚠️ 这条判据原来还断言"WebUI 是全局键"(作为差异记录的依据)。 * 2026-09-14 WebUI 侧也按账号分键了(dsh 接手 pi 的两个开项),**差异已消除** —— * 所以现在断言的是"两端都是按账号的键",而且不允许任何一端退回全局键。 */ const webStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts')); assert.match(webStore, /export function storageKey\(/, 'WebUI 也要有按账号取键的函数'); assert.match(webStore, /LEGACY_STORAGE_KEY/, '旧全局键只作为迁移源存在'); assert.ok(!/setItem\('agentmail\.background'/.test(webStore), 'WebUI 也不许再往全局键写'); }); test('★ 主题变化时**我们自己算的值**要跟着重算(pi 的规则:系统只跟它自己那部分)', () => { /* * pi 的规则:「系统自动跟随的东西(语义色、材质)不会顺带把"我们自己算出来的值" * 一起更新 —— 凡是我们计算/缓存且随主题变化的值,都必须挂在**同一个主题变化事件**上重算, * 否则它迟早是那唯一一处不跟随的。」 * 这里"我们自己算的"就是预设色板(`layersFor(id, dark)`):系统 surface/文字/材质会立刻换, * 色板不重算 → 界面上一部分跟随、一部分不跟随(撕裂)。 */ const main = read('pages/MainPage.ets'); assert.match(main, /getApplicationContext\(\)\.on\('environment'/, '要订阅系统环境变化'); assert.match(main, /onConfigurationUpdated/, '要处理配置变化回调'); assert.match(main, /config\.colorMode !== this\.lastColorMode/, '只在深浅色真的换了时才重算'); assert.match(main, /off\('environment'/, '页面销毁要退订(否则回调挂在已销毁的页面上)'); // 重算的必须是那份"我们算的值",而不是重新读一遍系统色 const watchAt = main.indexOf("on('environment'"); let depth = 0; let end = watchAt; for (let i = main.indexOf('(', watchAt); i < main.length; i++) { if (main[i] === '(') depth++; else if (main[i] === ')') { depth--; if (depth === 0) { end = i; break; } } } assert.ok(/applyAppearance\(\)/.test(main.slice(watchAt, end + 400)), '回调里要重算外观(色板属于"我们算的")'); // SDK 锚点:这个 API 与回调形状不能凭记忆写 const sdk = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts')); assert.match(sdk, /onConfigurationUpdated\(config: Configuration\): void/, 'SDK 里回调是 onConfigurationUpdated(别写错名字)'); }); // ───────── 遮盖色方向:从 SDK 两张表读出真值再判(pi 2026-09-14 的方向性疑问) ───────── /** 从 SDK 读出 `sys.color.ohos_id_color_*` 的真实值(名字→id 取编译器那张表,id→值取预览器那张) */ function sdkSystemColors() { const sysRes = code(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js')); const resTxt = code(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt')); const name2id = new Map(); for (const m of sysRes.matchAll(/'?(ohos_id_color_[a-z_]+)'?:\s*(\d+)/g)) { if (!name2id.has(m[1])) name2id.set(m[1], Number(m[2])); } const id2val = new Map(); for (const m of resTxt.matchAll(/id:(\d+),\s*'([^']*)'/g)) { const id = Number(m[1]); if (!id2val.has(id)) id2val.set(id, m[2]); } const out = {}; for (const [name, id] of name2id) if (id2val.has(id)) out[name] = id2val.get(id); return out; } /** '#AARRGGBB' → 亮度(0=黑 1=白);只用于判"深还是浅" */ function luminanceOf(argb) { const r = parseInt(argb.slice(3, 5), 16) / 255; const g = parseInt(argb.slice(5, 7), 16) / 255; const b = parseInt(argb.slice(7, 9), 16) / 255; return 0.2126 * r + 0.7152 * g + 0.0722 * b; } test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮罩),壁纸遮盖必须用**页面底色系**', () => { /* * pi 的疑问原话:「系统那三个 mask —— 名字里的 light/regular/thick 是**浓度档** * (同一个色的三个 alpha),不是深浅主题的两套值。它们的用途是**模态遮罩**(弹层背后压暗), * 所以两套主题下通常都是深色。如果预设/图片的遮盖层用 mask 色,**浅色主题下会把预设压暗, * 而 WebUI 是把预设洗淡**:方向相反,而且这是"机制上确定不同",不是观感。」 * * 他让我先把值读出来再定 —— 这里就是那次读数,做成判据(免得以后凭记忆选令牌)。 */ let colors; try { colors = sdkSystemColors(); } catch (e) { assert.fail(`读不到 SDK 的系统色表(这条判据无从判起):${e.message}`); } const maskRegular = colors['ohos_id_color_mask_regular']; const maskDark = colors['ohos_id_color_mask_regular_dark']; const bg = colors['ohos_id_color_background']; const bgDark = colors['ohos_id_color_background_dark']; assert.ok(maskRegular && maskDark && bg && bgDark, '系统色表里这几个令牌都要有值'); // 事实一:mask 在浅色主题下也是深色(#99182431)—— 所以它是模态遮罩语义 assert.ok(luminanceOf(maskRegular) < 0.2, `mask_regular 在浅色主题下应当是深色(实测 ${maskRegular})—— 若是浅色,这条判据的前提要重写`); assert.ok(luminanceOf(maskDark) < 0.2, `mask 深色主题下也应当是深色(实测 ${maskDark})`); // 事实二:页面底色系随主题换向(浅色白、深色近黑)—— 这才是"朝底色淡化" assert.ok(luminanceOf(bg) > 0.8, `ohos_id_color_background 浅色主题下应当是白(实测 ${bg})`); assert.ok(luminanceOf(bgDark) < 0.2, `ohos_id_color_background_dark 应当近黑(实测 ${bgDark})`); // 事实三:WebUI 的遮罩方向是"朝底色淡化"(浅色白、深色黑)—— 与页面底色系同向、与 mask 反向 const webCss = code(join(ROOT, 'client/electron/src/index.css')); const scrimLight = /--bg-scrim:\s*(\d+)\s+(\d+)\s+(\d+);/.exec(webCss); assert.ok(scrimLight, 'CSS 里要有 --bg-scrim'); assert.deepEqual([scrimLight[1], scrimLight[2], scrimLight[3]], ['255', '255', '255'], 'WebUI 浅色下的遮罩是**白**(把图案洗淡);这是"必须用页面底色系"的依据'); // 结论落到代码:壁纸遮盖用页面底色系令牌,且**不是** mask const theme = code(join(HARMONY_ETS, 'common/Theme.ets')); assert.match(theme, /static readonly wallpaperScrim: Resource = \$r\('sys\.color\.ohos_id_color_background'\)/, '壁纸遮盖色要用页面底色系(ohos_id_color_background)'); assert.match(theme, /static readonly overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, 'overlay(模态遮罩)保持 mask'); const main = read('pages/MainPage.ets'); const scrims = [...main.matchAll(/\.backgroundColor\(Theme\.(\w+)\)\s*\n\s*\.opacity\(this\.bgPlan\.scrim\)/g)]; assert.equal(scrims.length, 2, '预设档与图片档各一层遮盖'); for (const s of scrims) { assert.equal(s[1], 'wallpaperScrim', '壁纸遮盖层的颜色必须用 wallpaperScrim(页面底色系)。用 mask 会在浅色主题下把预设压暗,与 WebUI 反向'); } // 两个语义不许共用一个令牌(这个仓库撞过四次的那个模式) assert.notEqual('wallpaperScrim', 'overlay'); /* * ★★ 2026-09-21 改:不再要求 `SettingsPage` 里出现 `Theme.overlay`。 * * 原来这两条是: * assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask') * assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色') * * 它们守的**真实意图**是「两个语义别混」(`overlay`=模态遮罩 / `wallpaperScrim`=壁纸压暗)—— * 这个仓库为此撞过四次。而现在 SettingsPage **已经不自绘遮罩了** * (新增账号与修改密码都换成了官方 `bindSheet`,遮罩由系统画) * ⇒ `Theme.overlay` 在这份文件里**只应出现在说明它被删掉的注释里**。 * * ★ 不能简单删掉这两条:那会丢掉"不许把壁纸遮盖色当弹层遮罩"这个约束。 * 改成: * ① `wallpaperScrim` 仍然**绝不得**出现在 SettingsPage(语义不混); * ② 弹层必须走官方容器(`bindSheet`)—— 系统自己会用遮罩, * 不需要也不应该再铺一层 `overlay`(两层遮罩会叠成双倍变暗)。 * * ★ 为什么"不许出现 overlay"是对的:若有人往 `bindSheet` 内容里 * 再铺一层 `Theme.overlay`,那就是**系统遮罩 + 自绘遮罩叠两层**, * 背景会暗两倍。这条断言正是拦这个。 */ const settings2 = read('pages/SettingsPage.ets'); assert.match(settings2, /\.bindSheet\(\$\$this\./, '弹层应走官方 bindSheet(系统自带遮罩/圆角/动画)'); assert.ok(!/wallpaperScrim/.test(settings2), '弹层不该用壁纸遮盖色(两个语义别混)'); assert.ok(!/\.backgroundColor\(Theme\.overlay\)/.test(settings2), '已交给 `bindSheet` 的弹层**不得**再自铺一层 overlay —— 系统遮罩 + 自绘遮罩 = 双倍变暗'); }); /** * ★ 模糊字段的**消费侧必须逐文件登记 + 计数**(pi 2026-09-14 指出同一形状只有一边有判据)。 * * ── 2026-09-15 状态变了(原来是"只写不读 ⇒ 消费侧必须为 0")── * * P4c 补上了消费点(见下面登记表的理由),所以 0 这个值**不再成立**, * 判据的**形状**保留(逐文件 + 计数 + 写理由),值改准。 * 标题与断言里原来那句"消费侧出现次数必须为 0"如果留着,就会变成**假话**—— * 而假话比没有判据更糟:下一个人会以为"这里登记 0 是真的"。 * * WebUI 的 `LEGACY_BACKUP_KEY` 早就钉着"只写不读,否则它会变成新的继承源"; * 而鸿蒙侧 `bgBlur`(`Appearance.ts` clamp 存入、`AppearanceStore` 同步)**只有文档**。 * 同一个形状只有一边有判据 ⇒ 这一边补上,不必等一张新的字段清册: * **登记表本身就是清册**(与 `server/internal/repo/mail_status_readers_test.go` 的 * "文件 + 出现次数"同一个模板,只是这里的登记值是 0)。 * * 消费侧一旦出现(有人按 px 选档位/设模糊半径)本条就红 —— 那是**必须停下来**的时刻: * 那时要补的是"px ↔ 材质档位"的**映射判据**(见 CRITERIA.md §10 与计划文档 §7.12), * 而不是把次数从 0 改成 1 了事。 */ test('★ 模糊字段的消费侧:逐文件登记 + 计数(P4c 起不再是「只写不读」,登记值已随之改准)', () => { const files = []; const walk = dir => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) { walk(p); continue; } if (!/\.(ets|ts)$/.test(e.name)) continue; files.push(p); } }; walk(HARMONY_ETS); /* * 豁免**按文件登记 + 写理由**(不是"凡是这几个目录都放行"):这三处是**搬运/传输**, * 不是消费 —— 判据要挡的是"有人拿这个值去决定画什么"。 * 与 `server/internal/repo/mail_status_readers_test.go` 的豁免同一个形状。 */ /* * 豁免必须**按文件 + 次数**(pi 2026-09-14 交叉提醒,与 migrate.go 那处同一条): * 只按文件放行 ⇒ "在已允许的文件里顺手再读一下 bgBlur 做别的事"会被静默吞掉 * (例如有人在 DTO 文件里拿它算点别的)。次数写死在这里,多一次即红。 */ /* * ── 2026-09-15:登记值从"没有这一项(即 0)"改成 1 处 —— 按本条判据自己的要求做的 ── * * 这条判据的注释写着「消费侧一旦出现就红 —— 那是**必须停下来**的时刻: * 那时要补的是"px ↔ 材质档位"的**映射判据**,而不是把次数从 0 改成 1 了事」。 * P4c 加背景选择器时确实踩到了:滑杆能拖、`bg_blur` 能存,但壁纸一点没糊。 * * 停下来核完之后,**映射判据早就在了**(`blurStyleFor` 的分档边界/单调性/NaN 那条), * 缺的是**调用点**。所以这次补的是调用点,并把下面这条登记改准: * · `pages/MainPage.ets` 2 处:壁纸层 `.blur(this.bgPlan.blurPx)`(**图片内容模糊**, * 与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)+ 导航条 * `backgroundBlurStyle(blurStyleFor(this.bgPlan.blurPx))`(**面板材质**)。 * 两处都只是**把用户那个数用出去**,不在这里做分档判断(分档在 Appearance.ts)。 * · `common/BackgroundPicker.ets` 2 处:选择器的滑杆(`@Link bgBlur` 的绑定与 onChange) * —— 那是**输入**,不是消费。 * ⇒ 这是"消费点出现时按判据要求补判据"的正常流程走完一遍,不是把 0 改成 1 了事。 */ const plumbing = new Map([ ['model/Appearance.ts', { max: 12, why: '域模型:声明 + clamp + 合并 + 一个映射函数(`blurStyleFor`,现为孤岛:判据在跑、页面无人调,见其文档)—— 搬运与映射,都不是消费' }], ['pages/MainPage.ets', { max: 3, why: 'P4c 补上的两个**消费点**(映射判据早已存在,见本段说明):壁纸层图片内容模糊 + 导航条面板材质;第 3 处是同文件里说明这件事的注释' }], ['common/BackgroundPicker.ets', { max: 6, why: '选择器的滑杆:**输入**(@Link 声明 + 上报 + 显示 + Slider 值 + onChange + 一处注释),不是"拿这个值决定画什么"' }], ['model/Wallpaper.ts', { max: 1, why: '计划只**搬运**这个值(`blurPx`)+ 一处注释;分档判断不在这里(在 Appearance.ts 的 blurStyleFor)' }], ['pages/SettingsPage.ets', { max: 4, why: '页面持有该值的 @State(声明 + 推服务端时写入 + 从快照复制回 + 用 `$bgBlur` 传给选择器)—— 全是搬运/传参' }], ['common/AppearanceStore.ets', { max: 4, why: '状态同步:与快照互转(搬运)' }], ['api/AppearanceApi.ets', { max: 1, why: '线上 DTO 声明 bg_blur(传输格式,不是消费)' }] ]); const plumbingSeen = new Map(); const consumers = []; for (const f of files) { const src = prose(f); const rel = f.slice(HARMONY_ETS.length + 1); src.split('\n').forEach((line, i) => { if (!/\bbgBlur\b|\bbg_blur\b/.test(line)) return; const where = rel + ':' + (i + 1); const rule = plumbing.get(rel); if (rule) { const n = (plumbingSeen.get(rel) || 0) + 1; plumbingSeen.set(rel, n); if (n <= rule.max) return; consumers.push(`${where} **超出豁免上限**(${rel} 上限 ${rule.max} 处,理由:${rule.why})—— ${line.trim()}`); return; } consumers.push(`${where} ${line.trim()}`); }); } assert.deepEqual(consumers, [], `模糊字段出现了**未登记**的读取点。要么它是消费(那就要先补"px ↔ 材质档位"的映射判据,` + `映射表见 model/Appearance.ts 的 blurStyleFor;分档边界 0/8/20 已有行为判据),` + `要么它是搬运/输入(那就按文件登记次数并写清理由)—— 但**不许不声不响地多一处**:\n ${consumers.join('\n ')}`); }); /** * ★ `blurStyleFor`:**px → 系统材质档** 的映射是**行为**,不是注释(可以真跑)。 * * 这条的来历值得记:我先前把"鸿蒙没有消费点"登记成"**不存在映射表 ⇒ 钉映射判据是假判据**", * 那个结论**是错的** —— 正是 pi 要求把豁免改成"文件 + 次数"之后,逐处核对出现次数 * 才把 `model/Appearance.ts` 里的 `blurStyleFor` 翻出来:**映射表早就写了** * (文件里的注释还写着「判据可以直接跑它」),只是**没有任何调用点**。 * * 所以正确的登记是:**映射表存在且可判(本判据);缺的是调用点**。 * "有没有人用它"是另一件事,由上面那条消费侧计数判据管(登记值为 0)。 */ test('★ 碑文:`blurStyleFor` 不许回来 + 理由必须留在原处(旧的三条行为性质随主语一起作废)', async () => { const { pathToFileURL } = await import('node:url'); const A = await import(pathToFileURL(join(HARMONY_ETS, 'model', 'Appearance.ts')).href); // ★ 原先这里断言"必须存在且可跑"。事实相反:它是**有意删除**的(碑文 Wallpaper.ts:246)。 // ⇒ 断言改成**反回归**:不许回来。("标签必须等于断言范围":这条测试的标题也一并改了。) assert.equal(typeof A.blurStyleFor, 'undefined', '`blurStyleFor` 已被有意删除(2026-09-15):它没有、也不该有消费者 —— 不许加回来'); /* * 这一段(cases + 单调性 + NaN 方向)**整体作废,而且不是"改一改还能用"**: * 它们的**主语**是一个已被删除的映射函数。常数没有"单调性",也没有"越界归到最近档", * 更没有"NaN 掉到哪一档" —— 三条性质**随主语一起消失**了。 * * 所以这里**不重新发明**它们,只留两句钉住"去哪了"与"为什么": * · 那句碑文还在(理由必须留在原处,否则下一个人只会看到"这里什么都没有"); * · 常量本身仍被钉着(固定档 = COMPONENT_THICK,见本文件前半段那条现居地判据)。 * 判据**变少是对的**:性质没了就该少,硬造一条等价的只会是假判据。 */ assert.match(prose(WALL_TS), /blurStyleFor[\s\S]{0,200}?已随/, '碑文仍在原处:删除理由必须写在它被删掉的地方(否则下一个人看不出这里曾经有过什么)'); }); /* ═══════════════ 设备层:壁纸/外观**真的画在屏幕上** ═══════════════ */ /* * 上面全部判据的最后一层都停在"数据/接线",从没验过 **屏幕上真的出现了那个东西**。 * * ★ 这不是可有可无的一层 —— 2026-09-19 我在手势上刚吃过一模一样的教训: * `PanGesture` 的代码全对、静态判据全绿,而真机上**一次都没触发** * (起点坐标落在相邻的右栏里)。静态判据证明不了"用户点/看得见"。 * * 这一条验的是 P4 的那句原始要求:**「壁纸开关打开后,屏幕上真的有壁纸」**。 * 做法:进「我的」页 → 打开背景开关 → 对比开关前后**顶层结构**里 * 壁纸层的存在(而不是截图比像素 —— 那太脆且依赖具体图片)。 */ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只看数据流)', async (t) => { const D = await import('./lib/harmony-device.mjs'); const hdc = D.findHdc(); if (!hdc || !D.hasTarget(hdc)) { return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住数据/接线)'); } assert.ok(await D.launchOurApp(hdc), '要能拉起我们的应用并等到它到前台'); await new Promise((r) => setTimeout(r, 1200)); /* * 进「我的」窗格。★ **两种布局的入口不同**,必须分别处理 —— * 这是我第一版直接红掉的地方(写 `tapText(hdc,'我的')`,而宽屏下根本没有底栏): * * 窄屏:底栏 4 项,第 4 项文字是「我的」(WebUI `NarrowNav.tsx` 同形) * 宽屏:**没有底栏**,侧栏只有 3 项导航轨,「我的」是**底部那个头像按钮** * (WebUI `Sidebar.tsx:186` 的 `setViewMode('account')`,二者同构) * * 判定方式与 `harmony-nav` 一致:用**宽高比 > 1.2** 区分形态 * (不是写死 vp 阈值,也不是拿屏幕 px 去比源码里的 vp —— 密度是第二个真相)。 */ const screenOf = (root) => { let w = 0; let h = 0; for (const n of D.walk(root)) { const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || ''); if (m) { w = Math.max(w, Number(m[1])); h = Math.max(h, Number(m[2])); } } return { w, h }; }; const root0 = D.dumpLayout(hdc); const { w: scrW } = screenOf(root0); const wide = scrW / screenOf(root0).h > 1.2; if (wide) { /* * 宽屏:点侧栏底部**头像**(无文字、贴屏底)。用"左 1/6 内 + 在屏幕下半部 + * 可点的方块"定位它 —— 与 `harmony-nav` 的 `navRailItemsOf` 同一套形状判法。 */ const screenH = screenOf(root0).h; const avatar = [...D.walk(root0)].find((n) => { const a = n.attributes || {}; if (a.clickable !== 'true') return false; const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); if (!m) return false; const [, x1, y1, x2, y2] = m.map(Number); /* * ★★ 2026-09-21 修(真隐患):加 `宽高比 ≈ 1` 这一条。 * * ── 原来只要求「宽>80 且 高>80」,而侧栏下半部有**三个**可点方块 ── * 设备实测(屏幕 3184×2232,density 2.875): * #0 头像 [57,1791][172,1906] 115×115px = 40×40vp ratio 1.000 * #1 主题切换 [63,1918][167,1999] 104× 81px = 36×28vp ratio 1.284 * #2 退出登录 [63,2010][167,2091] 104× 81px = 36×28vp ratio 1.284 * **三个全都满足**「宽>80 且 高>80」。 * * 今天它没出事,纯靠 `.find()` 返回**第一个**(walk 序恰好是头像)—— * 也就是说这条判据的正确性**不取决于形状判得准**,而取决于 * "渲染树的遍历顺序恰好把头像排在前面"。那种保证是会失效的: * 谁调整一下侧栏底部簇的子元素顺序(把主题/退出提到前面), * 这里就会**点中「退出登录」** ⇒ 设备被登出、账号列表被清空 * (`performLogout` → `AccountManager.clearAll()`,实测 * `accounts_json` 会变成 `[]`)。 * * ★ 这不是理论风险:我这一次调试期间设备**真的被登出了** * (09:21 `agentmail_accounts` 变成 `accounts_json=[]`), * 排查了一圈才定位到"形状判据太宽 + 依赖遍历顺序"这个形状。 * * ⇒ 加宽高比约束:头像 40×40(1.000),另两个 36×28(1.284)。 * 阈值 1.15 落在两者之间,且留了余量(不写 1.0 的精确等号 —— * 那是拿"渲染出来的像素"当"源码里的 vp",两者不该硬等)。 */ const w = x2 - x1; const h = y2 - y1; return x2 <= scrW * 0.08 && y1 > screenH * 0.5 && w > 80 && h > 80 && w / h <= 1.15; }); assert.ok(avatar, '宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了'); const ac = D.boundsCenter(avatar.attributes.bounds); assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像'); } else { /* * 窄屏:点底栏第 4 项。 * * ★★ 2026-09-21 修(真 bug):原来用的是 `D.tapText(hdc, '我的')`, * 而 "我的" 这个文案在**同屏出现两次**: * · 页面头部标题 `Text('我的')`(`SettingsPage` 的 AppHeader), * 它的 bounds 是 **[99,201][783,255]**(实测)—— 在屏**上半部**; * · 底部导航项的文字,**[819,2061][877,2096]**。 * `tapText` 的实现是「取第一个可点的;都不可点就取第一个」—— * 两者都 `clickable=false`(点击挂在祖先容器上)⇒ 它取到的是**页头标题**, * 点在那儿什么都不会发生。 * * 症状极具误导性:报"点完之后应真的在「我的」页", * 看起来像页面切不过去,实际是**点错了地方**(而且点的就是当前页的标题)。 * * ⇒ 按位置挑:只取下半个屏幕、且 x 在屏宽右侧(底栏第 4 项)。 * 与宽屏那一分支的"按形状找"同一套思路 —— 文案会重复,位置不会。 */ const scrH = screenOf(root0).h; const navItem = [...D.walk(root0)].find((n) => { const a = n.attributes || {}; if ((a.text || '').trim() !== '我的') return false; const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); if (!m) return false; const [, x1, y1, x2, y2] = m.map(Number); return y1 > scrH * 0.75 && x1 > scrW * 0.6 && (x2 - x1) < 200 && (y2 - y1) < 200; }); assert.ok(navItem, '窄屏底栏第 4 项要有「我的」文字(按位置找,不按文案 —— 页头标题同名)'); const nc = D.boundsCenter(navItem.attributes.bounds); /* 文字节点通常不可点(点击挂在祖先),但点它的中心会冒泡到那一项 */ assert.ok(D.tap(hdc, nc.cx, Math.round(nc.cy - 20)), '要能点到底栏的「我的」'); } /* * ★ 等页面真的切过去(**轮询**,不是睡固定时长)。 * 我第一版睡 1800ms 后断言,实测红 —— 而**手动点同一个坐标是立刻切过去的**。 * 原因就是固定等待不够稳(同一条纪律:手势判据也栽在固定 2500ms 上)。 * 现在等"「我的」页的标志性字段出现",最多 ~10 秒。 */ const inMePane = (root) => [...D.walk(root)].some((n) => { const t = (n.attributes?.text || '').trim(); /* 「我的」页独有:这几样在别的窗格里不会同时出现 */ return t.includes('用户名') || t.includes('连接密钥') || t.includes('主题由系统'); }); let landed = false; /* * ★★ 2026-09-19 修(套件里红、单独跑绿):轮询成功后必须**用新的 dump**, * 不能继续用切页之前的 `root0`。 * * 这个错法很隐蔽:`root0` 是"进「我的」之前"那一屏(套件里常常是日历页), * 于是后面找「背景」时读的是**日历页的文字表** ⇒ 假红。 * 单独跑时之所以绿,是因为我手动摆现场时前台**恰好**已经在「我的」页 —— * 也就是说:判据通过与否取决于跑之前那一屏是什么,而不是取决于代码对不对。 * (这正是"设备判据必须自己搭现场"的另一个理由。) */ let meRoot = null; /* * ★★ 2026-09-21 补:**先滑回顶部再轮询**(顺序很重要)。 * * `SettingsPane` 在 `MainPage` 里是**常驻挂载**的 ⇒ 它的 `Scroll` * 保留上一次的位置。上一次跑这套判据时滑到了底,这一次点进「我的」 * 直接就是滚到底的屏,而 `inMePane` 找的「用户名 / 连接密钥」在**上半部** * ⇒ 报「点完之后应真的在「我的」页」,而实际**已经在了**。 * ("判据必须自己搭现场" —— 这条纪律本文件自己的注释里就写着。) */ const scrNav = screenOf(root0); for (let i = 0; i < 3; i++) { /* 回顶:手指从上往下拉(y 小→大) */ D.swipe(hdc, Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.25), Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.75)); await new Promise((r) => setTimeout(r, 250)); } for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 500)); const snap = D.dumpLayout(hdc); if (inMePane(snap)) { landed = true; meRoot = snap; break; } } /* * ★★ 2026-09-21 补:点进去之后**先回到顶部**再找「背景」。 * * 上面那个 `inMePane` 找的是「用户名 / 连接密钥 / 主题由系统」—— * 而这三样都在页面**上半部**。而 `SettingsPane` 在 `MainPage` 里是 * **常驻挂载**的(宽屏/窄屏都不重建)⇒ 它的 `Scroll` **保留上一次的位置**。 * * 后果:上一次跑这套判据时滑到底了,这一次点进「我的」就直接是滚到底的屏, * `用户名` 不在可见节点里 ⇒ 报「点完之后应真的在「我的」页」。 * 而实际**已经在了** —— 这是"判据没自己搭现场"(本仓已有这条纪律, * `harmony-appearance` 自己的注释里就写着)。 * * ⇒ 点完之后一律先滑回顶部(用屏幕尺寸算坐标,不写死宽屏值)。 */ assert.ok(landed && meRoot !== null, '点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' + '只断言"点到了坐标"证明不了页面切过去了'); /* * ★★ 2026-09-19 修:**先滚到「背景」再找它**。 * * 原来只在**首屏可见节点**里找「背景」,于是它红过一次很误导的红: * 实际读到的是"客户端连接密钥 / 修改密码 / 退出登录 / 管理"—— * 即**页面没问题,只是「背景」在首屏之下**(dumpLayout 只给**可见**节点, * 这条在 `harmony-appearance` 自己的注释里就写过,我却在这里踩了同一个坑)。 * * 判据要判的是"「我的」页里**有**背景那一节",不是"它在第一屏"。 * 所以往下滚几屏找;找不到才是真问题。 */ const hasBg = (root) => [...D.walk(root)] .some((n) => { const t = (n.attributes?.text || '').trim(); return t.includes('背景') || t.includes('壁纸'); }); let texts0 = [...D.walk(meRoot)] .map((n) => (n.attributes?.text || '').trim()) .filter(Boolean); /* * ★★ 关键:**先回到顶部,再逐屏往下扫**。 * * 我第一版只朝一个方向滑("往上滑 = 内容上移"),结果一直在往页尾走, * 而「背景」在「客户端连接密钥」**上面** ⇒ 越滑越远,6 次之后文字表 * 一字未变(那正是"滑反了"的信号,而不是"页面没有那一节")。 * * 所以先往回滑到顶(滑不动了就是到了),再一屏一屏往下找。 * 这也顺手把"页面本身能不能滚"变成了判据的一部分 —— * 滑了但可视内容不变 = 这个 Scroll 是死的。 */ if (!hasBg(meRoot)) { /* * ★★ 2026-09-21 修(真 bug,套件红 / 单独跑也红):**滑动坐标写死了宽屏的**。 * * 原来这两行是: * D.swipe(hdc, 1600, 900, 1600, 1500); // 回顶 * D.swipe(hdc, 1600, 1500, 1600, 900); // 下扫 * * `x=1600` 是**宽屏(3184px)**下的坐标。而窄屏只有 **1008px** 宽 * ⇒ x=1600 在屏外,`uitest uiInput swipe` 不接受、什么都不做。 * 于是"回顶"没回、"下扫"没扫,`meRoot` 一直是那一屏 * ⇒ 实测报「滚过 6 屏仍没有背景那一节」,而**手动滑两下就能看到「背景」** * (它就在「客户端连接密钥」上面)。 * * 危害不止假红:这段注释本身写着 * 「滑了但可视内容不变 = 这个 Scroll 是死的」—— * 也就是说这条判据**本意要顺便验证 Scroll 能不能滚**, * 而坐标写死后它连"滚"这个动作都没发生, * 那个顺带的验证也从来没生效过(一直是"空变量")。 * * ⇒ 从实测屏幕宽高算中心,不写死:宽屏/窄屏/折叠态都能跑。 * 横向取屏幕中心,纵向取上/下各 1/5 处(比贴边稳,不碰系统手势区)。 */ const scr = screenOf(meRoot); const cx = Math.round(scr.w / 2); const topY = Math.round(scr.h * 0.25); const botY = Math.round(scr.h * 0.75); for (let i = 0; i < 5; i++) { // ① 回到顶部 /* 从下往上**不行** —— 回顶要"内容下移" ⇒ 手指从上往下拉(y 小→大) */ D.swipe(hdc, cx, topY, cx, botY); await new Promise((r) => setTimeout(r, 300)); } meRoot = D.dumpLayout(hdc); for (let i = 0; i < 8 && !hasBg(meRoot); i++) { // ② 逐屏往下扫 /* 往下看 = 内容上移 ⇒ 手指从下往上推(y 大→小) */ D.swipe(hdc, cx, botY, cx, topY); await new Promise((r) => setTimeout(r, 400)); meRoot = D.dumpLayout(hdc); } texts0 = [...D.walk(meRoot)] .map((n) => (n.attributes?.text || '').trim()) .filter(Boolean); } assert.ok(hasBg(meRoot), '「我的」页里要能找到背景/壁纸那一节(滚过 6 屏仍没有 = 真的缺了)——' + `实际读到:${texts0.slice(0, 40).join(' | ')}`); /* * 记下"壁纸层是否存在"的判据:`MainPage.WallpaperLayer()` 在有壁纸时会挂 * 一个铺满屏幕的图片/渐变层。它在 dump 里表现为一个**铺满全屏的 Image 或 * 带渐变背景的 Column**,且 `MainPage` 的 `bgActive` 为真时页面底的 * 背景色会变成 Transparent。 * * ★ 判据断的是**结构**(有铺满全屏的壁纸容器)而不是像素颜色 —— * 后者要与主题/图片内容耦合,会在换壁纸时假红。 */ /* * ★★ 判定"壁纸层在不在"的**正确形状**(我前两版都写错了): * * 第一版:找"铺满屏幕的 Image/Stack" ⇒ **恒为 true**(外壳本身就有铺满的结构)。 * 第二版:找"全屏 Column 的渐变属性" ⇒ dump 里根本没有渐变字段(不报)。 * * 有效的信号是**几何**:壁纸开启时 `bgActive=true` ⇒ 各内容面板的底色变 * `Transparent`,于是**面板与屏幕边缘之间的缝隙**会露出壁纸;关闭时那里是 * 面板自己的不透明底色。 * * 实测(密度 2.875、屏 3184px): * 预设档:侧栏区 `(60,1200)` = **#F4F5F7**、缝隙 `(215,1200)` = **#E0E2E4** * 不设档:两处都是 **#FFFFFF**(纯白) * * 判据读的是 `dumpLayout` 里对应节点的 `backgroundColor` —— 比截图比像素稳 * (不依赖具体预设色的取值,只看"是不是纯白/是否透明让位")。 */ const panelSlotColor = (root) => { const all = [...D.walk(root)]; let screenW = 0; let screenH = 0; for (const n of all) { const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || ''); if (m) { screenW = Math.max(screenW, Number(m[1])); screenH = Math.max(screenH, Number(m[2])); } } if (screenW === 0) return null; /* * 找侧栏(左 1/6 内、纵贯大部分屏幕)与内容面板(在侧栏右侧、宽度很大), * 取它们各自最外层容器的背景色。 */ let sidebarBg = null; let contentBg = null; for (const n of all) { const a = n.attributes || {}; const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); if (!m || typeof a.backgroundColor !== 'string') continue; const [, x1, y1, x2, y2] = m.map(Number); const w = x2 - x1; const h = y2 - y1; if (h < screenH * 0.8) continue; // 要纵贯屏幕的容器 if (x2 <= screenW * 0.08 && sidebarBg === null) sidebarBg = a.backgroundColor; if (x1 > screenW * 0.07 && x1 < screenW * 0.2 && w > screenW * 0.5 && contentBg === null) { contentBg = a.backgroundColor; } } return { sidebarBg, contentBg }; }; /** * 壁纸开着 ⇔ **档位选中态不是"不设"**。 * * ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事): * ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有) * ② 找"面板底色是否透明" ⇒ 恒 false(ArkUI 报的实色与我的正则对不上) * 两版都没真正问出"壁纸开了吗"。 * * 而这个问题**有一个确定答案**:段式选择器的选中态。 * 实测:选中 = `#FF2563EB`(品牌蓝)、未选中 = `#FFF1F3F5`(浅灰)。 * `bg_kind` 与选中态是一一对应的(同一个 @State),所以问它就等于问数据库。 * * ★ 这一版为什么不脆:它读的是**控件自己的状态**,不依赖渲染管线的实现细节 * (材质/透明色如何上报、渐变节点是否出现在 dump 里)。 */ const SELECTED_BG = '#FF2563EB'; const isSelected = (root, label) => chipState(root, label) === SELECTED_BG; const chipState = (root, label) => { const node = [...D.walk(root)].find((n) => { const a = n.attributes || {}; return (a.text || '').trim() === label && typeof a.backgroundColor === 'string'; }); return node ? (node.attributes.backgroundColor || '').toUpperCase() : null; }; const currentKind = (root) => { for (const [label, kind] of [['不设', 'none'], ['预设', 'preset'], ['自定义图片', 'image']]) { if (isSelected(root, label)) return kind; } return null; }; const before = currentKind(meRoot) !== 'none'; /* * 切换背景开关。**幂等性处理**:开关当前是什么状态未知(上一次跑可能留下了), * 所以先读当前状态、只在需要时点它 —— 盲目点会把"已开"点成"关"。 * 判据断的是**切换后状态真的变了**,不是"点了就成功"。 */ /* * ★ 开关的形态是**三选一的段式选择**:「不设 / 预设 / 自定义图片」—— * 不是 `Toggle`(我第一版按 Toggle 找,于是**永远 skip**)。 * 「背景」本身只是那一节的**标签文本**(`clickable=false`),点它没用。 * * 这里用「切换到一个与当前不同的档位」的方式做幂等切换: * 当前是「不设」就点「预设」,否则点「不设」。 * 这样跑第二遍时方向相反,但结论一致(判据必须幂等)。 */ const clickableText = (root, want) => [...D.walk(root)].find((n) => { const a = n.attributes || {}; return a.clickable === 'true' && (a.text || '').trim() === want; }); /* * ★★ 第二版修正(第一版又错了,这次是**判"当前档"的方式错**): * * 我第一版用「能不能找到『不设』那个按钮」来判"当前是不设档" —— * 但**三个按钮永远都在**(它是段式选择器,不是"点了才出现")。 * 于是它每次都去点「预设」,而壁纸本来就开着 ⇒ 切换前后无变化 ⇒ 假红。 * * 正确判法是读**选中态**。实测(dump 的属性): * 选中 `backgroundColor = #FF2563EB`(品牌蓝) * 未选中 `backgroundColor = #FFF1F3F5`(浅灰) * 这与 WebUI 的段式选择器同形(选中项是品牌底)。 */ /* * ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事): * ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有) * ② 找"面板底色是否透明" ⇒ 恒 false(dump 里的色值与我的正则对不上) * 两版都没真正问出"壁纸开了吗"。 * * 而这个问题**有一个确定答案**:段式选择器的选中态(`chipState`,下面定义)。 * 选中的 `bg_kind` 与 `@State` 一一对应 ⇒ 问它就等于问状态。 * 它读的是**控件自己的状态**,不依赖渲染管线细节(材质/透明色如何上报、 * 渐变节点是否出现在 dump 里)—— 这正是前两版脆的原因。 */ const isNone = isSelected(meRoot, '不设'); const targetLabel = isNone ? '预设' : '不设'; const sw = clickableText(meRoot, targetLabel); if (!sw) { return t.skip(`找不到背景档位「${targetLabel}」(可能在图片档或文案变了)—— 本次不跑,但不假装通过`); } const c = D.boundsCenter(sw.attributes?.bounds); assert.ok(c, '背景档位按钮要有可解析的边界'); assert.ok(D.tap(hdc, c.cx, c.cy), `要能点到背景档位「${targetLabel}」`); await new Promise((r) => setTimeout(r, 2500)); // 切换 + 推服务端 + 重绘 const after = currentKind(D.dumpLayout(hdc)) !== 'none'; /* * ★ 断"**状态变了**"而不是断"变成 true":开关初始状态取决于是上一次跑留下的, * 而判据必须**幂等**(跑两遍结论相同)。所以两种切换方向都算通过, * 真正要防的是"点了却什么都没发生"(那才是接线断了)。 */ assert.notEqual(after, before, '★ 切换背景开关后,壁纸层的存在性必须发生变化 —— ' + `切换前=${before}、切换后=${after}。两者相同说明**开关点了没反应**:` + '要么开关没接上状态,要么壁纸层没跟着重绘(这正是静态判据看不到的那一层)。'); /* 复位:点回**原来那一档**(不是"再点一次当前那个"——档位是单选,再点不变) */ const backLabel = isNone ? '不设' : '预设'; const backBtn = clickableText(D.dumpLayout(hdc), backLabel); assert.ok(backBtn, `复位时还能找到「${backLabel}」`); const bc = D.boundsCenter(backBtn.attributes.bounds); assert.ok(D.tap(hdc, bc.cx, bc.cy), '要能点回去'); await new Promise((r) => setTimeout(r, 2000)); assert.equal(currentKind(D.dumpLayout(hdc)) !== 'none', before, '复位后应回到判据开始时的状态(判据必须幂等,不留副作用)'); }); test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值曾被顶出屏外)', async (t) => { /* * ★★ 这条来自一个追了很久的真 bug(2026-09-19)。 * * 症状:用户/我反复看到「我的」页压暗滑杆旁边印着 `56.000000`。 * 真相有两层,两层都值得记下来: * * ① `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本** * —— 屏幕上根本没有这串字(截图可证)。**dump 的 text ≠ 看得见的字**。 * ② 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`, * 而屏幕宽 3184 ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。 * * 根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,而内容列写的是 * `.width('100%')` —— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp * **相加** ⇒ 必然溢出 60vp。修法:内容列改 `.layoutWeight(1)`(吃剩余空间)。 * * 为什么值得一条判据:**同一处错误在不同 pane 上表现不同**(日历页自己算宽度, * 就没露出来),所以很容易被当成"某一页的样式问题"去调,而不是回到布局本身。 * 这条判据检查的是**所有窗格**,任何一个新 pane 犯了同样的错都会红。 */ const D = await import('./lib/harmony-device.mjs'); const hdc = D.findHdc(); if (!hdc || !D.hasTarget(hdc)) { return t.skip('设备不在 —— 本条的设备半边本次不跑'); } assert.ok(await D.launchOurApp(hdc), '要能拉起应用'); /* 回主界面(前面判据可能把前台留在 push 出去的页上 —— 那些页没有侧栏) */ assert.ok(await D.backToMain(hdc), '要能回到主界面'); /* * 逐个窗格检查。宽屏下「我的」用侧栏头像进,窄屏用底栏 —— 这里只跑宽屏那份 * (窄屏的几何不同,若要覆盖应单独一条,不要在一个判据里塞两种形态)。 */ const root0 = D.dumpLayout(hdc); const screenOf = (root) => { let w = 0; let h = 0; for (const n of D.walk(root)) { const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || ''); if (m) { w = Math.max(w, Number(m[1])); h = Math.max(h, Number(m[2])); } } return { w, h }; }; const { w: scrW, h: scrH } = screenOf(root0); /* * 越界判定:右边缘超出屏宽、或左边缘为负。 * **排除系统组件**(状态栏那一条 `Stack` 实测 x2=3230 > 3184,那是系统的, * 不归我们管)—— 按"是否属于我们应用的组件树"排除不现实,改用 * "越界幅度 + 节点类型":系统状态栏整条横贯顶部(y 很小且高度小)。 */ const overflows = (root) => { const bad = []; for (const n of D.walk(root)) { const a = n.attributes || {}; const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); if (!m) continue; const [, x1, y1, x2, y2] = m.map(Number); if (y1 < scrH * 0.05 && (y2 - y1) < scrH * 0.06) continue; // 状态栏,跳过 if (x1 < -2) bad.push(`${a.type} 左越界 x1=${x1}`); else if (x2 > scrW + 2) bad.push(`${a.type} 右越界 x2=${x2}(屏宽 ${scrW})`); } return bad; }; assert.deepEqual(overflows(root0), [], `★ 「通信」窗格有内容超出屏幕 —— 超出 = 用户看不见(不是"偏了一点")`); /* 进「我的」窗格(宽屏走侧栏头像;找不到就跳过,不假装通过) */ const avatar = [...D.walk(root0)].find((n) => { const a = n.attributes || {}; if (a.clickable !== 'true') return false; const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); if (!m) return false; const [, x1, y1, x2, y2] = m.map(Number); /* * ★★ 2026-09-21 修:与文件里另一处「找头像」同一个隐患 —— 见 `:1020` 那段注释。 * 侧栏下半部三个可点方块(头像 40×40 / 主题 36×28 / 退出 36×28)**都**满足 * `(y2-y1) > 80`,`.find()` 取第一个才碰巧是头像。加宽高比约束把它变成 * **形状判得准**,而不是"依赖遍历顺序"。 * * 这里尤其要紧:这一处找到之后点它进「我的」页,若点到「退出登录」, * 设备会被登出并清空账号(`clearAll()`)—— 实测 `accounts_json` 变 `[]`。 * * ⚠️ 注意这里必须解构出 **`x1`**(原来写的是 `[, , y1, x2, y2]`, * 跳过了 `x1`)—— 我加宽高比时需要它算 `w`,漏了就会在**运行期** * `ReferenceError: x1 is not defined`。它不是断言失败:整个文件 * **一条都跑不了**(套件把它记成 `broken=1`,而 broken 证明不了任何事)。 * 实测就是这么红的(`ran=34 checks=553 … broken=1`)。 */ const w = x2 - x1; const h = y2 - y1; return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && h > 80 && w / h <= 1.15; }); if (!avatar) { return t.skip('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑'); } const ac = D.boundsCenter(avatar.attributes.bounds); assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像进「我的」'); await new Promise((r) => setTimeout(r, 2500)); const rootMe = D.dumpLayout(hdc); assert.deepEqual(overflows(rootMe), [], '★ 「我的」窗格有内容超出屏幕 —— 这正是那个 `56%` 被顶出屏外的形状。' + '常见原因:内容列写了 `width(\'100%\')` 而不是 `layoutWeight(1)`(与侧栏相加必溢出)'); });