import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; import { sleep, enterDeviceSettings, onesBleStep, findHomeCard } from '../../utils/common'; import { getDeviceName } from '../../config/device.config'; import { PNG } from 'pngjs'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); // Meter「报警条件」+「温湿度校准」——均在 设备详情页 → 齿轮(top_bar_right_icon) → Settings 页(MeterSettingsActivity)。 // 报警条件(id/sivDesired):Temperature/Humidity/Absolute Humidity/Dew Point/VPD 五个 Alert 开关(id/enableSwitch)+ Save(id/confirm_button)。 // 校准(id/sivCalibration):列表 id/sivTemp「Calibrate the Temperature」/ id/sivHumi「Calibrate the Humidity」→ 说明页 btnNext → offset 页 // (tvCurrentVal 当前值 / tvCalibration 偏移"+0.0°C" / ivSub 减 / ivAdd 加)。 // 这些是 15975「蓝牙控制」超级用例下的 step:报警 NvpUv9Tt / 校温 3Jm5FAdW / 校湿 QfYLdj8s。Meter 纯 BLE。 // ⚠️ 设置类改动**做完还原**(报警开关切回原态并保存;校准偏移 +1 步再 -1 步归零),不改变设备真实配置。 // ⚠️ BLE 偶发连接失败→页面空/进不去:进 Settings/报警/校准 均最多重试 3 次(用户实测确认)。 // ⚠️ 结果口径:Meter 纯 BLE。**仅 WiFi-only 轮(PROTO=wifi/蓝牙关)才合法 SKIP**(设备不支持控制); // BLE 轮(或单跑)下若 3 次重试仍进不去/按钮缺失 = 未正确执行 = 真失败 FAIL,不再用 SKIP 掩盖。 const deviceName = getDeviceName('meter', 'METER_DEVICE'); // 默认 Meter 0B;换 METER_DEVICE 覆盖其他 meter 型号 const PKG = 'com.theswitchbot.switchbot'; // step uuid 按 METER 家族参数化(默认基础 Meter);各型号在 P0 传对应 uuid(见 run-p0-all): // 基础 Meter 报警 NvpUv9Tt / 校温 3Jm5FAdW / 校湿 QfYLdj8s // Meter Plus 报警 UZCtZRPy / 校温 9mheLMDJ / 校湿 GSZv8BYm // Outdoor Meter 报警 QwnZJARA / 校温 Ncg7BDeR / 校湿 k6cqKqtZ (IOSensor) // Meter Pro 报警 JggzNXDo / 校温 L967RGHa / 校湿 8Wzaj5pB const A_ALERT = onesBleStep(process.env.METER_ALERT_STEP || 'NvpUv9Tt'); // 报警 const A_CAL_T = onesBleStep(process.env.METER_CALT_STEP || '3Jm5FAdW'); // 校温 const A_CAL_H = onesBleStep(process.env.METER_CALH_STEP || 'QfYLdj8s'); // 校湿 describe('Meter 报警条件 + 温湿度校准(设备设置页)', () => { let driver: DeviceDriver; let reporter: TestReporter; const isAndroid = () => driver.platform === 'android'; // WiFi-only 轮:纯 WiFi 控制(PROTO=wifi,手机蓝牙已关)。Meter 纯 BLE,此态下设备不可控 → 合法跳过。 const isWifiOnly = () => process.env.PROTO === 'wifi'; // 仅在 WiFi-only 下用于"合法跳过"的哨兵异常(catch 里识别 → 记 SKIP、不 FAIL、不 rethrow)。 class SkipSignal extends Error {} // BLE 设备"没跑通"时的分流: // WiFi-only(蓝牙关)→ Meter 纯 BLE 不支持 → 抛 SkipSignal(合法 SKIP); // 否则(BLE 本应可用)→ 抛普通 Error → 外层记 FAIL 并让 vitest 退出码非 0(不再用 SKIP 掩盖未正确执行的用例)。 function bailBleUnavailable(msg: string): never { if (isWifiOnly()) throw new SkipSignal(`WiFi-only(纯WiFi/蓝牙关):Meter 为纯 BLE 设备,不支持控制 → 跳过(${msg})`); throw new Error(msg); } beforeAll(async () => { driver = createDriver(); await driver.createSession(); reporter = new TestReporter('Meter_AlertCalibration', driver.platform.toUpperCase()); }); beforeEach(async () => { await driver.dismissPopupIfPresent(); await driver.goBackToHomepage(); await sleep(500); await driver.dismissPopupIfPresent(); }); afterAll(async () => { reporter.generate(); await driver.destroySession(); }); // 读某 id 控件的 text(找不到返回 '')。用于读校准偏移值 tvCalibration 等。 async function idText(id: string): Promise { const el = await driver.findElementRaw('id', `${PKG}:id/${id}`).catch(() => null); if (!el) return ''; return (await driver.getElementAttribute(el, 'text').catch(() => '')) || ''; } // 点 +/- 后**轮询 tvCalibration 直到相对 prev 变化**(实测校准值更新有 ~1.5s 延迟,固定 sleep 会读到旧值→假失败)。 async function waitCalChange(prev: string, timeoutMs = 6000): Promise { const deadline = Date.now() + timeoutMs; let cur = prev; while (Date.now() < deadline) { await sleep(500); cur = await idText('tvCalibration'); if (cur && cur !== prev) return cur; // 已更新 } return cur; } // 进 Meter Settings 页(含 Alert Conditions/Calibration 行)。设备详情页 → 齿轮。BLE 抖动致空页 → 最多重试 3 次。 // Android:enterDeviceSettings 落详情页 → 点 id/top_bar_right_icon 齿轮;iOS:findHomeCard 点卡 → 详情页右上角**无名 Button**(y<130 最右)齿轮。 async function enterSettingsPage(deadline = Date.now() + 90000): Promise { for (let attempt = 0; attempt < 3; attempt++) { // ★ 时间预算闸:BLE 连不上时下面的整链路(回首页+找卡+进齿轮)很慢,3 次重试会拖到 it 超时(320s/220s) // 被 vitest 强杀 → finally 来不及跑 → App 残留在 Meter 子页 → 拖累同文件后续 it。超预算即快速 false。 if (Date.now() > deadline) { console.log('[Meter] 进 Settings 页时间预算耗尽,提前结束(避免拖到 it 超时被强杀)'); return false; } if (attempt > 0) { await driver.goBackToHomepage(); await sleep(1500); await driver.dismissPopupIfPresent(); } if (isAndroid()) { if (!(await enterDeviceSettings(driver, deviceName))) continue; await sleep(1200); await driver.dismissPopupIfPresent().catch(() => {}); // Meter Pro 进功能页弹"云存储推荐"模态 → 先清掉(Android 兜底已含 Got it) let src = await driver.getSource(); if (!/Alert Conditions|Calibration/.test(src)) { const gear = await driver.findElementRaw('id', `${PKG}:id/top_bar_right_icon`).catch(() => null); if (gear) await driver.tapElement(gear); else await driver.tap(996, 146); await sleep(2000); src = await driver.getSource(); } if (/Alert Conditions|Calibration/.test(src)) return true; } else { // iOS:点卡进详情页 → 找右上角齿轮(y<130、x 最大的 Button)→ 进 Settings const card = await findHomeCard(driver, deviceName, { maxScrolls: 16 }); if (!card) { console.log(`[Meter] iOS 首页找不到 ${deviceName}`); continue; } await driver.tapElement(card); await sleep(2500); await driver.dismissPopupIfPresent().catch(() => {}); // Meter Pro 进功能页会弹"云存储推荐"模态,盖住页面挡住齿轮 → 先清掉(勾"不再显示"+Got it) const btns: string[] = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => []); let gear: string | null = null, bestX = -1; for (const b of btns) { const r = await driver.getElementRect(b).catch(() => null); if (r && r.y < 130 && r.x > bestX) { bestX = r.x; gear = b; } } if (gear) { await driver.tapElement(gear); await sleep(2500); } if (/Alert Conditions|Calibration/.test(await driver.getSource())) return true; } console.log(`[Meter] 第${attempt + 1}/3 次未进到 Settings 页(BLE 未连?),重试`); } return false; } // 点 Settings 页某行(按 id 优先,文案兜底);进子页后校验 readyKw 出现(BLE 加载,最多等 ~16s)。 // 子页加载失败(BLE 未连)→ **回首页重连蓝牙 + 重新进 Settings 页**再点本行(不退上一页:退一层往往不重连 BLE)。最多 3 次。 async function openRow(rowId: string, rowText: string, readyKw: RegExp, deadline = Date.now() + 90000): Promise { for (let attempt = 0; attempt < 3; attempt++) { if (Date.now() > deadline) { console.log(`[Meter] 进 "${rowText}" 页时间预算耗尽,提前结束`); return false; } if (attempt > 0) { await driver.goBackToHomepage(); await sleep(1500); await driver.dismissPopupIfPresent(); if (!(await enterSettingsPage(deadline))) continue; // 回首页触发 BLE 重连,重进 Settings 页(共享同一 deadline,不再 3×3 无限嵌套) } const row = isAndroid() ? (await driver.findElementRaw('id', `${PKG}:id/${rowId}`).catch(() => null) || await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${rowText}")`).catch(() => null)) : await driver.findElementRaw('predicate string', `name == "${rowText}" OR label == "${rowText}"`).catch(() => null); if (!row) { await sleep(1000); continue; } await driver.tapElement(row); await sleep(2500); for (let t = 0; t < 8; t++) { if (readyKw.test(await driver.getSource())) return true; // 子页已加载(BLE 连上) if (Date.now() > deadline) return false; // 预算耗尽,别再空等 await sleep(1500); } console.log(`[Meter] "${rowText}" 页未加载(BLE 未连?),回首页重连重进(第${attempt + 1}/3)`); } return false; } // 读某报警行(容器 id)的开关 checked 态。 async function alertChecked(rowContainerId: string): Promise { const sw = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("${PKG}:id/${rowContainerId}").childSelector(new UiSelector().resourceId("${PKG}:id/enableSwitch"))`).catch(() => null); if (!sw) throw new Error(`找不到 ${rowContainerId} 行的 enableSwitch 开关`); return (await driver.getElementAttribute(sw, 'checked')) === 'true'; } async function tapAlertSwitch(rowContainerId: string): Promise { const sw = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("${PKG}:id/${rowContainerId}").childSelector(new UiSelector().resourceId("${PKG}:id/enableSwitch"))`).catch(() => null); if (!sw) throw new Error(`找不到 ${rowContainerId} 行的 enableSwitch 开关`); await driver.tapElement(sw); await sleep(1200); } // 保存报警设置(底部 Save)。 async function saveAlert(): Promise { const save = isAndroid() ? (await driver.findElementRaw('id', `${PKG}:id/confirm_button`).catch(() => null) || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Save")').catch(() => null)) : await driver.findElementRaw('predicate string', 'name == "Save" OR label == "Save"').catch(() => null); if (save) { await driver.tapElement(save); await sleep(2000); } } // 进报警页 → 把温度报警设为指定态 → 保存。启用后当前室温落在默认阈值区间 → 触发报警(卡片变红)。 // Android:temp 行 enableSwitch;iOS:报警页 name 含 "Temperature Alert" 的真实开关(取 y 最小,非影子节点)。 // ⚠️ 重连由下层负责:进 Settings 失败 → enterSettingsPage 内部回首页重试 3 次;进报警子页失败 → openRow 回首页重连重进 3 次。 // 本函数不再套外层整链路重试(下层已覆盖回首页重连);任一步失败直接返回 false。 async function setTempAlert(enable: boolean): Promise { const deadline = Date.now() + 100000; // 单次报警设置总预算 ~100s:BLE 连不上就快速 FAIL 并回首页,绝不拖到 it 超时(320s)被强杀→残留子页拖累后续用例 try { if (!(await enterSettingsPage(deadline))) return false; if (!(await openRow('sivDesired', 'Alert Conditions', /Temperature Alert|enableSwitch|VPD Alert/, deadline))) { console.log('[Meter] 报警设置页未正常进入(已回首页重连重试仍失败)'); return false; } if (isAndroid()) { if ((await alertChecked('temp')) !== enable) await tapAlertSwitch('temp'); if ((await alertChecked('temp')) !== enable) { console.log('[Meter] 温度报警开关未切到目标态'); return false; } } else { // iOS:报警页每个开关在 a11y tree 有**两个节点**(真实行 y 小 + 影子节点 y 大,落在屏幕空白处)。 // ① 取 name 含 "Temperature Alert" 且 y 最小(真实第一行)的开关; // ② 该开关是 RN 自绘 toggle,**tapElement(element click)不触发** → 必须 **坐标 tap 元素中心**(实测 value 0→1); // ③ tap 后**回读确认**切到目标态。 const pickTempSwitch = async (): Promise<{ id: string; val: string; cx: number; cy: number } | null> => { const all: string[] = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSwitch"').catch(() => []); const cand: { id: string; y: number; cx: number; cy: number }[] = []; for (const s of all) { const nm = (await driver.getElementAttribute(s, 'name').catch(() => '')) || ''; if (/Temperature Alert/.test(nm)) { const r = await driver.getElementRect(s).catch(() => null); if (r) cand.push({ id: s, y: r.y, cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2) }); } } if (!cand.length) return null; cand.sort((a, b) => a.y - b.y); const c = cand[0]; return { id: c.id, cx: c.cx, cy: c.cy, val: (await driver.getElementAttribute(c.id, 'value').catch(() => '')) || '' }; }; const isOn = (v: any) => { const s = String((v && typeof v === 'object' ? (v.value ?? v.checked) : v) ?? ''); return s === '1' || s === 'true'; }; // Switch 在 BLE 连上后才渲染 → 轮询等真实 Temperature 开关出现(最多 ~12s,超预算即止)。 let cur: { id: string; val: string; cx: number; cy: number } | null = null; for (let t = 0; t < 8 && !cur && Date.now() < deadline; t++) { cur = await pickTempSwitch(); if (!cur) await sleep(1500); } if (!cur) { console.log('[Meter] iOS Temperature 报警开关未出现(BLE 未连?)'); return false; } console.log(`[Meter] iOS 温度报警开关 value="${cur.val}" → ${isOn(cur.val) ? 'on' : 'off'}`); if (isOn(cur.val) !== enable) { await driver.tap(cur.cx, cur.cy); await sleep(1500); // ★ 坐标 tap 中心(element click 对 RN toggle 无效) const after = await pickTempSwitch(); // 回读:未切换能在此暴露 console.log(`[Meter] iOS tap 后 value="${after?.val}"(目标 ${enable ? 'on' : 'off'})`); if (!after || isOn(after.val) !== enable) { console.log('[Meter] iOS 温度报警开关未切到目标态'); return false; } } } await saveAlert(); return true; } catch (e: any) { console.log(`[Meter] 报警设置异常(${e?.message})`); return false; } } // 数首页设备卡片区域内的**红色像素**(温度报警触发时卡片温度值/温度计图标变红)。 // findHomeCard 返回卡底名字元素 → 检测区域向名字**上方扩展**覆盖顶部温度值,x 取该卡所在列(避开相邻行/通知红点)。 async function countCardRed(): Promise { const card = await findHomeCard(driver, deviceName); if (!card) throw new Error(`首页找不到 ${deviceName} 卡片`); const r = await driver.getElementRect(card); const { width: W } = await driver.getWindowSize().catch(() => ({ width: 1080 })); const png = PNG.sync.read(Buffer.from(await driver.screenshot(), 'base64')); // ★ iOS 截图分辨率=点×scale(retina 2/3x),getElementRect 是点 → 检测区域坐标须×scale;Android scale=1。 const scale = png.width / W; const left = r.x + r.width / 2 < W / 2; const rx0 = Math.round((left ? 30 : W / 2 + 10) * scale), rx1 = Math.round((left ? W / 2 - 10 : W - 30) * scale); const ry0 = Math.max(0, Math.round((r.y - 240) * scale)), ry1 = Math.round((r.y + r.height + 10) * scale); let red = 0; for (let y = ry0; y < Math.min(png.height, ry1); y++) { for (let x = rx0; x < Math.min(png.width, rx1); x++) { const i = (png.width * y + x) << 2; const rr = png.data[i], gg = png.data[i + 1], bb = png.data[i + 2]; if (rr > 150 && gg < 110 && bb < 110 && rr - gg > 60 && rr - bb > 60) red++; } } console.log(`[Meter] 卡片红色检测(scale=${scale.toFixed(2)}) x[${rx0},${rx1}] y[${ry0},${ry1}] 红像素=${red}`); return red; } it(`${A_ALERT} 设置报警条件(启用温度报警→首页卡片变红→还原)`, { timeout: 320000 }, async () => { const start = Date.now(); try { // 1. 启用温度报警并保存(默认阈值触发,当前室温满足 → 真正触发报警)。iOS/Android 均适配(setTempAlert 内分支)。 if (!(await setTempAlert(true))) bailBleUnavailable('进报警页/启用温度报警失败(BLE 未连,3 次重试)'); // 2. 回首页,校验设备卡片温度变红(报警真正生效)。★ 数据恢复放 finally:无论校验成败都关闭温度报警,不留设备在报警态。 let red = 0; try { await driver.goBackToHomepage(); await sleep(2500); await driver.dismissPopupIfPresent(); red = await countCardRed(); } finally { await setTempAlert(false).catch((e: any) => console.log('[Meter] 还原(关闭温度报警)失败:', e?.message)); } expect(red).toBeGreaterThan(50); // 卡片温度变红 = 报警条件真正设置生效 reporter.record(`${A_ALERT} 设置报警条件`, 'PASS', Date.now() - start, `启用温度报警→首页卡片变红(红像素${red})→已还原`); } catch (e: any) { if (e instanceof SkipSignal) { reporter.record(`${A_ALERT} 设置报警条件`, 'SKIP', Date.now() - start, e.message); return; } const ss = await driver.screenshot().catch(() => ''); reporter.record(`${A_ALERT} 设置报警条件`, 'FAIL', Date.now() - start, e.message, ss); throw e; } finally { await driver.goBackToHomepage().catch(() => {}); // 无论成败都回首页,避免残留在 Meter 子页拖累后续 it } }); // iOS offset 页读**Calibrated value**(偏移,形如 "0.0℃"/"+0%");注意区分上方 "Showing value"(当前读数)。找不到返回 ''。 async function offsetValIOS(): Promise { const src = await driver.getSource().catch(() => ''); const i = src.indexOf('Calibrated value'); const seg = i >= 0 ? src.slice(i) : src; // 取 "Calibrated value" 之后,避开 Showing value const m = seg.match(/(?:name|label|value)="([+\-]?\d+(?:\.\d+)?\s*(?:°C|℃|%))"/); return m ? m[1] : ''; } // iOS offset 页 +/- 调节按钮:同一行(y~217)两个无名 XCUIElementTypeButton(w~45),左=减 右=加;排除左上返回键(y<130)。 // ★ 返回**元素引用**(非坐标):坐标 driver.tap() 对这些按钮不 actuate,必须 tapElement(element click) 才生效(实测点加值 0.0→0.2℉)。 // ★ getElementRect 偶发读空 → 重试一次(不然减按钮 rect 读空会塌成只剩1个按钮 → 误判"未找到")。 // ★ 每点一步后 RN 会重渲染 → 元素引用 stale,**调用方每次点击前都要重新 pick**。 async function offsetBtnsIOS(): Promise<{ minus: string; plus: string } | null> { const btns: string[] = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => []); const flank: { el: string; cx: number }[] = []; for (const b of btns) { let r = await driver.getElementRect(b).catch(() => null); if (!r) { await sleep(300); r = await driver.getElementRect(b).catch(() => null); } if (r && r.width >= 28 && r.width <= 60 && r.y > 150 && r.y < 320) flank.push({ el: b, cx: r.x + r.width / 2 }); } flank.sort((a, z) => a.cx - z.cx); if (flank.length < 2) return null; return { minus: flank[0].el, plus: flank[flank.length - 1].el }; } // 校准某维度(温/湿):进 Calibration → 点入口 → Next → offset 页:+1 步(断言变化)→ -1 步(还原)。两端适配。 async function calibrate(dim: 'temp' | 'humi', anchor: string, label: string): Promise { const start = Date.now(); const deadline = start + 160000; // 校准总预算 ~160s(< it 超时 220s):BLE 连不上就快速 FAIL 并回首页,不拖到超时被强杀→残留子页 try { if (!(await enterSettingsPage(deadline))) bailBleUnavailable('进 Settings 页失败(BLE 未连,超时预算内重试仍失败)'); if (!(await openRow('sivCalibration', 'Calibration', /Calibrate the (Temperature|Humidity)/, deadline))) { bailBleUnavailable('进校准列表失败(BLE 未连,超时预算内重试仍失败)'); } const entryId = dim === 'temp' ? 'sivTemp' : 'sivHumi'; const entryText = dim === 'temp' ? 'Calibrate the Temperature' : 'Calibrate the Humidity'; const readOffset = async () => isAndroid() ? await idText('tvCalibration') : await offsetValIOS(); let ready = false; for (let attempt = 0; attempt < 3 && !ready && Date.now() < deadline; attempt++) { for (let b = 0; b < 2 && !/Calibrate the (Temperature|Humidity)/.test(await driver.getSource().catch(() => '')); b++) { await driver.goBack().catch(() => {}); await sleep(1800); } const entry = isAndroid() ? await driver.findElementRaw('id', `${PKG}:id/${entryId}`).catch(() => null) : await driver.findElementRaw('predicate string', `name == "${entryText}" OR label == "${entryText}"`).catch(() => null); if (!entry) { await sleep(1000); continue; } await driver.tapElement(entry); await sleep(2500); const next = isAndroid() ? (await driver.findElementRaw('id', `${PKG}:id/btnNext`).catch(() => null) || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null)) : await driver.findElementRaw('predicate string', 'name == "Next" OR label == "Next"').catch(() => null); if (next) { await driver.tapElement(next); await sleep(2500); } for (let t = 0; t < 8 && !ready && Date.now() < deadline; t++) { if (await readOffset()) { ready = true; break; } const rc = isAndroid() ? await driver.findElementRaw('-android uiautomator', 'new UiSelector().textMatches("(?i)Reconnect|Retry|Try Again|重连|重试")').catch(() => null) : await driver.findElementRaw('predicate string', 'name CONTAINS "try again" OR name CONTAINS "Retry" OR label CONTAINS "try again"').catch(() => null); if (rc) { await driver.tapElement(rc); await sleep(2500); } else await sleep(1500); } if (!ready) console.log(`[Meter] ${label} offset 页未加载(BLE 未连?),返回重进(第${attempt + 1}/3)`); } if (!ready) bailBleUnavailable(`${label} offset 页未加载(BLE 未连,超时预算内重试仍失败)`); const v0 = await readOffset(); console.log(`[Meter] ${label} 初始偏移: ${v0}`); if (isAndroid()) { const add = await driver.findElementRaw('id', `${PKG}:id/ivAdd`); const sub = await driver.findElementRaw('id', `${PKG}:id/ivSub`); if (!add || !sub) throw new Error('找不到 ivAdd/ivSub 校准调节按钮'); const rAdd = await driver.getElementRect(add); const rSub = await driver.getElementRect(sub); const cxAdd = Math.round(rAdd.x + rAdd.width / 2), cyAdd = Math.round(rAdd.y + rAdd.height / 2); const cxSub = Math.round(rSub.x + rSub.width / 2), cySub = Math.round(rSub.y + rSub.height / 2); let v1 = v0; try { await driver.tap(cxAdd, cyAdd); v1 = await waitCalChange(v0); console.log(`[Meter] ${label} +1 步后: ${v1}`); expect(v1).not.toBe(v0); } finally { for (let k = 0; k < 4; k++) { const c = await idText('tvCalibration'); if (!c || c === v0) break; await driver.tap(cxSub, cySub); await sleep(1800); } } const v2 = await idText('tvCalibration'); console.log(`[Meter] ${label} 还原后: ${v2}`); expect(v2).toBe(v0); const save = await driver.findElementRaw('id', `${PKG}:id/confirm_button`).catch(() => null) || await driver.findElementRaw('-android uiautomator', 'new UiSelector().textMatches("Save|Confirm|Done")').catch(() => null); if (save) { await driver.tapElement(save); await sleep(1500); } reporter.record(anchor, 'PASS', Date.now() - start, `${label}偏移可调并还原:${v0}→${v1}→${v2}`); } else { // iOS offset:[−] Calibrated value [+]。点加 → 轮询变化 → 点减还原。 // ★ 用 tapElement(element click),不用坐标 driver.tap()(对这些 RN 按钮不 actuate)。 // ★ 每步后 RN 重渲染致元素 stale → **每次点击前都重新 offsetBtnsIOS() 取新引用**。 let v1 = v0; try { const b = await offsetBtnsIOS(); if (!b) bailBleUnavailable(`iOS offset +/- 按钮未找到(值=${v0})`); await driver.tapElement(b.plus); for (let t = 0; t < 8 && v1 === v0; t++) { await sleep(600); v1 = await offsetValIOS(); } console.log(`[Meter][iOS] ${label} +1 步后: ${v1}`); expect(v1).not.toBe(v0); // 核心:校准偏移可调 } finally { // 数据恢复:点减直到回 v0(最多 4 次)。每轮重新 pick(上一步 tap 后元素已 stale),并轮询等本步生效再下一轮。 for (let k = 0; k < 4; k++) { const c = await offsetValIOS(); if (!c || c === v0) break; const bb = await offsetBtnsIOS(); if (!bb) break; await driver.tapElement(bb.minus); for (let t = 0; t < 8; t++) { await sleep(600); if ((await offsetValIOS()) !== c) break; } } } const v2 = await offsetValIOS(); console.log(`[Meter][iOS] ${label} 还原后: ${v2}`); expect(v2).toBe(v0); const save = await driver.findElementRaw('predicate string', 'name == "Save" OR name == "Confirm" OR name == "Done"').catch(() => null); if (save) { await driver.tapElement(save); await sleep(1500); } reporter.record(anchor, 'PASS', Date.now() - start, `${label}偏移可调并还原:${v0}→${v1}→${v2}`); } } catch (e: any) { if (e instanceof SkipSignal) { reporter.record(anchor, 'SKIP', Date.now() - start, e.message); return; } const ss = await driver.screenshot().catch(() => ''); reporter.record(anchor, 'FAIL', Date.now() - start, e.message, ss); throw e; } finally { await driver.goBackToHomepage().catch(() => {}); // 无论成败都回首页,避免残留在校准子页拖累后续 it } } it(`${A_CAL_T} 温度校准(偏移调节+还原)`, { timeout: 220000 }, async () => { await calibrate('temp', `${A_CAL_T} 温度校准`, '温度校准'); }); it(`${A_CAL_H} 湿度校准(偏移调节+还原)`, { timeout: 220000 }, async () => { await calibrate('humi', `${A_CAL_H} 湿度校准`, '湿度校准'); }); });