import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; import { sleep, waitForSource, scrollHomeToTopIOS, logVisibleDeviceCards } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); // Air Purifier(口头"空净",WiFi):控制必测=**首页卡片快捷控制浮层**(非功能页)。流程同加湿器2(Evaporative Humidifier)。 // 点首页卡片 → 弹浮层:顶部状态字(Off/运行) + **无文案电源圆键** + 模式行(Level/Auto/Pet/Sleep) + More。 // 实测 1080x2400:电源键中心 ≈ (540, 1247)=屏中线、h*0.52(浮层顶 y≈911,加湿器2 的 h*0.36 落在浮层外故不通用)。 // 模式键有文字,按 text 定位即可;切模式需先开机。ONES 控制号待补,先 [P0] 占位。 const CARD_KEYWORD = process.env.AIR_PURIFIER_CARD || 'Air Purifier'; const MODES = ['Level', 'Auto', 'Pet', 'Sleep']; // 实测浮层模式集合 const ADD_ANCHOR = '[P0]'; // TODO: 待补 Air Purifier 控制 ONES 编号 describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => { let driver: DeviceDriver; let reporter: TestReporter; let W = 1080, H = 2400; const isIOS = () => driver.platform === 'ios'; beforeAll(async () => { driver = createDriver(); await driver.createSession(); reporter = new TestReporter('AirPurifier_Card', driver.platform.toUpperCase()); try { const s = await driver.getWindowSize(); if (s?.width) { W = s.width; H = s.height; } } catch { /* 默认 1080x2400 */ } }); // iOS 浮层是底部 sheet(android back 关不掉),需点顶部暗区收起。但**无脑点会误触首页卡**—— // 每条 it 开头根本没浮层,(W/2,H*0.12) 正落在首页顶部卡片上 → 误进设备页。 // 改为**条件收起**:仅当 findPowerBtnIOS 检测到浮层电源键(=浮层确实开着)时才点暗区,否则跳过。 async function dismissSheetIfOpenIOS(): Promise { if (!isIOS()) return; const p = await findPowerBtnIOS().catch(() => null); if (!p) return; // 没浮层 → 不点,根除首页误触 await driver.tap(Math.round(W * 0.5), Math.round(H * 0.12)).catch(() => {}); await sleep(600); } beforeEach(async () => { await dismissSheetIfOpenIOS(); await driver.dismissPopupIfPresent(); await driver.goBackToHomepage(); await driver.dismissPopupIfPresent(); }); afterEach(async () => { await dismissSheetIfOpenIOS(); await driver.dismissPopupIfPresent().catch(() => {}); }); afterAll(async () => { reporter.generate(); await driver.destroySession(); }); async function findCard(): Promise { // 同一文件内多 it 间不重启 → 先快速回顶(scrollHomeToTopIOS),再从顶滚找(页面稳定时甩动不误点)。 if (isIOS()) await scrollHomeToTopIOS(driver).catch(() => {}); for (let r = 0; r < 14; r++) { const el = isIOS() ? await driver.findElementRaw('predicate string', `name CONTAINS "${CARD_KEYWORD}" AND type == "XCUIElementTypeCell"`).catch(() => null) : await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); if (el) { const rect = await driver.getElementRect(el).catch(() => null); if (rect && rect.width > 0 && rect.y > H * 0.06 && rect.y + rect.height < H * 0.92) return el; // 屏内才用(WDA 会返回屏外元素) } await driver.scrollDown(500); await sleep(800); } await logVisibleDeviceCards(driver, `找不到${CARD_KEYWORD}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`); } // 点卡片中心 → 弹快捷控制浮层(未弹回首页重试) async function openCardPopup(): Promise { for (let attempt = 0; attempt < 3; attempt++) { const cardId = await findCard(); const rect = await driver.getElementRect(cardId); await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); await sleep(2000); if (await waitForSource(driver, 'More', 5000)) return; console.log(`未弹快捷浮层,回首页重试(第 ${attempt + 1}/3)`); await driver.dismissPopupIfPresent().catch(() => {}); await driver.goBackToHomepage(); await sleep(800); } throw new Error('卡片快捷控制浮层未弹出'); } // 动态找电源键(iOS):浮层里**最大的居中方键**(实测 72×72,正中;上方有 48px 干扰键需排除)。 async function findPowerBtnIOS(): Promise<{ cx: number; cy: number } | null> { const btns = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => [] as string[]); const cands: { cx: number; cy: number; w: number }[] = []; for (const b of btns as string[]) { const r = await driver.getElementRect(b).catch(() => null); if (!r) continue; if (r.width >= 45 && r.width <= 120 && Math.abs(r.width - r.height) < 24 && Math.abs(r.x + r.width / 2 - W / 2) < W * 0.08 && r.y > H * 0.4 && r.y < H * 0.78) { cands.push({ cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2), w: r.width }); } } if (!cands.length) return null; cands.sort((a, b) => b.w - a.w || a.cy - b.cy); // 取最大(电源键 72 > 控制键 48);同宽取最靠上 return { cx: cands[0].cx, cy: cands[0].cy }; } async function tapPower(): Promise { if (isIOS()) { const p = await findPowerBtnIOS(); const px = p ? p.cx : Math.round(W / 2); const py = p ? p.cy : Math.round(H * 0.52); console.log(`点电源键(iOS) ${px},${py}${p ? '' : ' [回退]'}`); return driver.tap(px, py); } const px = Math.round(W / 2), py = Math.round(H * 0.52); console.log(`点电源键 (${px}, ${py})`); await driver.tap(px, py); } // 当前是否关机:iOS 读浮层状态(name/label,以 On/Off 开头);Android 读 text= 卡名后状态字。 async function isOff(): Promise { const src = await driver.getSource(); let status = ''; if (isIOS()) { const vals = Array.from(src.matchAll(/(?:name|label|value)="([^"]+)"/g)).map((m) => m[1].trim()); status = vals.find((t) => /^(On|Off)\b/.test(t)) || vals.find((t) => t.includes(CARD_KEYWORD)) || ''; } else { const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); status = i >= 0 ? (texts[i + 1] || '') : ''; } console.log(`状态字="${status}"`); return /^Off$|^Off\b|Offline|待机/.test(status); } async function pollState(wantOff: boolean, ms = 15000): Promise { const deadline = Date.now() + ms; while (Date.now() < deadline) { if ((await isOff()) === wantOff) return true; await sleep(1500); } return (await isOff()) === wantOff; } it(`${ADD_ANCHOR} 首页卡片开机`, async () => { const start = Date.now(); try { await openCardPopup(); if (!(await isOff())) { await tapPower(); await pollState(true); } await tapPower(); const on = await pollState(false); console.log(`开机后 isOff=${!on}`); expect(on).toBe(true); reporter.record(`${ADD_ANCHOR} 首页卡片开机`, 'PASS', Date.now() - start, '卡片浮层电源键开机成功(状态→运行)'); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${ADD_ANCHOR} 首页卡片开机`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it(`${ADD_ANCHOR} 首页卡片关机`, async () => { const start = Date.now(); try { await openCardPopup(); if (await isOff()) { await tapPower(); await pollState(false); } await tapPower(); const off = await pollState(true); console.log(`关机后 isOff=${off}`); expect(off).toBe(true); reporter.record(`${ADD_ANCHOR} 首页卡片关机`, 'PASS', Date.now() - start, '卡片浮层电源键关机成功(状态→Off)'); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${ADD_ANCHOR} 首页卡片关机`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it(`${ADD_ANCHOR} 首页卡片切换模式(Level/Auto/Pet/Sleep)`, async () => { const start = Date.now(); try { await openCardPopup(); // 切模式前需先开机(关机时模式键置灰) if (await isOff()) { await tapPower(); await pollState(false); } const switched: string[] = []; for (const m of MODES) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${m}")`).catch(() => null); if (el) { await driver.tapElement(el); await sleep(2000); switched.push(m); } else console.log(`未找到模式键: ${m}`); } console.log(`已切换模式: ${switched.join('/')}`); expect(switched.length).toBe(MODES.length); reporter.record(`${ADD_ANCHOR} 首页卡片切换模式`, 'PASS', Date.now() - start, `依次切换 ${switched.join('/')} 成功`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${ADD_ANCHOR} 首页卡片切换模式`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); });