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]*?)\/?>/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 { 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 { 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; } }); });