410 lines
27 KiB
TypeScript
410 lines
27 KiB
TypeScript
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} 湿度校准`, '湿度校准');
|
||
});
|
||
});
|