AI_UIAutomation/tests/switch/relay_switch_2pm_control.te...

166 lines
9.3 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, onesCtrl, setupResilientHooks, findHomeCard } from '../../utils/common';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// Relay Switch 2PM 控制(双路):点卡**直接进功能页**(无弹窗) → 功能页有 **Switch 1(左) / Switch 2(右)** 两个独立电源圆键 + 各自 "On/Off"。
// 逐个开关:读状态 → 点电源键 → 校验翻转 → 切回。既覆盖"首页发起控制"(点卡进)也覆盖"功能页控制"。
// 实测 1080x2400:Switch1 电源键 (W*0.262,H*0.313)、Switch2 (W*0.738,H*0.313);"On/Off" 在电源圈下方 y~909(左 x~110 / 右 x~624)。
const deviceName = process.env.RELAY_SWITCH_DEVICE || 'Relay Switch 2PM';
const CTRL = onesCtrl('plug', 'Relay Switch 2PM') || '[P0]'; // 2PM 双路开关控制 → 15975#PG62s725(step42 Relay2pm开关)
const SWITCHES = [
{ label: 'Switch 1', px: 0.262, side: 'left' as const },
{ label: 'Switch 2', px: 0.738, side: 'right' as const },
];
describe('Relay Switch 2PM Control - 双路功能页控制(进设备页 开/关)', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
let W = 1080, H = 2400;
setupResilientHooks(() => driver);
const isIOS = () => driver.platform === 'ios';
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
reporter = new TestReporter('RelaySwitch2PM_Control', driver.platform.toUpperCase());
try { const s = await driver.getWindowSize(); if (s?.width) { W = s.width; H = s.height; } } catch { /* 默认 */ }
});
beforeEach(async () => {
await driver.dismissPopupIfPresent();
await driver.goBackToHomepage();
await sleep(500);
await driver.dismissPopupIfPresent();
});
afterAll(async () => {
reporter.generate();
await driver.destroySession();
});
// 读某一路开关状态:电源圈下方 y~850-1000 的 "On/Off",按 x 分左(Switch1)/右(Switch2)。
function readState(src: string, side: 'left' | 'right'): 'On' | 'Off' | 'unknown' {
if (isIOS()) {
// iOS 功能页:两个独立电源圈(XCUIElementTypeOther)在页面上方 y~206、w~125,name/label 同时=On/Off。
// 坑:每个圈的 name 和 label 都命中 On|Off → 全局正则每元素匹配 2 次,按出现顺序取 arr[0]/arr[1] 时
// "right" 实际取到左圈的 label(而非右圈)→ 右路永远读左路状态 → 点击生效但校验必失败。
// 正解:按元素几何(中心 x 分左右 + 只认上方电源圈 y<H*0.6)取,和 Android 分支一致。
for (const m of src.matchAll(/<XCUIElementType\w+\b([^>]*?)\/?>/g)) {
const a = m[1];
const st = (a.match(/name="(On|Off)"/) || [])[1] as 'On' | 'Off' | undefined;
if (!st) continue;
const x = +((a.match(/\bx="(-?\d+)"/) || [])[1] ?? NaN);
const w = +((a.match(/\bwidth="(\d+)"/) || [])[1] ?? NaN);
const y = +((a.match(/\by="(-?\d+)"/) || [])[1] ?? NaN);
if (!isFinite(x) || !isFinite(w)) continue;
if (isFinite(y) && y > H * 0.6) continue; // 排除下方列表里的 On/Off,只认上方电源圈
const isLeft = (x + w / 2) < W / 2;
if ((side === 'left') === isLeft) return st;
}
return 'unknown';
}
for (const m of src.matchAll(/text="(On|Off)"[^>]*bounds="\[(\d+),(\d+)\]\[\d+,\d+\]"/g)) {
const x = +m[2], y = +m[3];
if (y > 820 && y < 1050) { // 功能页:状态在电源圈下方 y~909(左 Switch1 / 右 Switch2)
const isLeft = x < W / 2;
if ((side === 'left') === isLeft) return m[1] as 'On' | 'Off';
}
}
return 'unknown';
}
// 点某一路开关:切换目标是**电源圈(clickable ViewGroup)**,圈心 (W*px, H*0.313)。
// 坑1:内含 On/Off TextView 是 clickable=false 且在圈**底部**(y~0.39);用 childSelector(On|Off) 会返回该文字元素,tapElement 点到圈底缘 → 圆按钮死角,时中时不中(飘)。
// 坑2:driver.tap(W3C pointer)对该 RN 圈按钮**不 actuate**(实测点圈心坐标不翻转,但 adb input / 元素 click 能翻转)。
// 正解:几何筛出电源圈 ViewGroup(clickable + 圈心 y≈H*0.313 + 圈宽 0.22~0.45*W),按 x 选侧,tapElement 点其真实中心(=圈心)。
async function tapPower(side: 'left' | 'right'): Promise<void> {
const els = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().clickable(true)').catch(() => [] as string[]);
const cands: { e: string; cx: number }[] = [];
for (const e of els) {
const r = await driver.getElementRect(e).catch(() => null); if (!r) continue;
const cx = r.x + r.width / 2, cy = r.y + r.height / 2;
if (Math.abs(cy - H * 0.313) < H * 0.06 && r.width > W * 0.22 && r.width < W * 0.45) cands.push({ e, cx });
}
const pick = cands.find(c => (side === 'left') === (c.cx < W / 2));
if (pick) { await driver.tapElement(pick.e); await sleep(1500); return; }
console.log(`[RS2PM] tapPower(${side}) 未找到电源圈(候选${cands.length}),走兜底坐标`);
await driver.tap(Math.round(W * (side === 'left' ? 0.262 : 0.738)), Math.round(H * 0.313)); await sleep(1500); // 兜底圈心坐标(注:W3C tap 可能不 actuate,仅作最后退路)
}
// 翻转到目标态,带漏点重试:tapPower 后短窗口(~5s)内校验;未翻转即判漏点重试(已生效的会在~2.5s内反映,不会误触回弹)。最多 3 次。
async function toggleTo(side: 'left' | 'right', want: 'On' | 'Off'): Promise<'On' | 'Off' | 'unknown'> {
for (let a = 0; a < 3; a++) {
await tapPower(side); // 内含 sleep 1500
let st = readState(await driver.getSource(), side);
for (let i = 0; i < 3 && st !== want; i++) { await sleep(1200); st = readState(await driver.getSource(), side); }
if (st === want) return st;
console.log(`[RS2PM] ${side}${a + 1}次点击未翻转(仍${st}),重试`);
}
return readState(await driver.getSource(), side);
}
// 进 2PM 功能页:点卡**弹浮层**(含 Switch1/2 + More)→ 点 "More" 进完整功能页(功能页电源圈在 y~751)。
// 用功能页特有的 History Data/Schedules 确认已进功能页(浮层没有),否则会停在浮层、tapPower 点到后面的首页卡。
async function enterFeature(): Promise<boolean> {
const card = await findHomeCard(driver, deviceName);
if (!card) { console.log(`[RS2PM] 首页找不到 ${deviceName}`); return false; }
await driver.tapElement(card);
await sleep(2000);
for (let i = 0; i < 8; i++) {
const more = isIOS()
? await driver.findElementRaw('predicate string', 'name == "More" OR label == "More"').catch(() => null)
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("More")').catch(() => null);
if (more) { await driver.tapElement(more); await sleep(2500); console.log('[RS2PM] 点 More 进功能页'); break; }
await sleep(700);
}
for (let i = 0; i < 8; i++) {
const src = await driver.getSource();
if (/History Data|Schedules/.test(src) && /Switch 1/.test(src)) return true; // 功能页特有,区分浮层
await sleep(1000);
}
return /History Data|Schedules/.test(await driver.getSource().catch(() => ''));
}
it(`${CTRL} 功能页控制${deviceName}(双路 开+关)`, { timeout: 180000 }, async () => {
const start = Date.now();
try {
if (!(await enterFeature())) {
// 首页有该开关却进不去功能页 = 未正确执行 → FAIL(交给 catch);首页确无该开关(未添加/本平台无实体)→ 合法 SKIP。
await driver.goBackToHomepage().catch(() => {}); await sleep(1200);
if (await findHomeCard(driver, deviceName).catch(() => null)) throw new Error('进功能页失败(首页有该开关但进不去功能页,未正确执行)');
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'SKIP', Date.now() - start, '首页无该开关(未添加/本平台无实体),跳过');
return;
}
// 进一次功能页,在同一页依次控制两路开关(各 开→校验→关→校验,覆盖 on/off 两向)。
const summary: string[] = [];
for (const sw of SWITCHES) {
const s0 = readState(await driver.getSource(), sw.side);
console.log(`[RS2PM] ${sw.label} 初始: ${s0}`);
const target1 = s0 === 'On' ? 'Off' : 'On';
const s1 = await toggleTo(sw.side, target1);
console.log(`[RS2PM] ${sw.label} 操作1后: ${s1}(目标${target1})`);
expect(s1, `${sw.label} 操作1 未翻转到 ${target1}`).toBe(target1);
const target2 = target1 === 'On' ? 'Off' : 'On';
const s2 = await toggleTo(sw.side, target2);
console.log(`[RS2PM] ${sw.label} 操作2后: ${s2}(目标${target2})`);
expect(s2, `${sw.label} 操作2 未翻转回 ${target2}`).toBe(target2);
summary.push(`${sw.label}:${s0}${s1}${s2}`);
}
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'PASS', Date.now() - start, `双路开/关均生效(${summary.join(' | ')})`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});
});