AI_UIAutomation/tests/meter/meter_alert_calibration.tes...

410 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> {
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<string> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<number> {
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<string> {
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<void> {
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} 湿度校准`, '湿度校准');
});
});