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, setupResilientHooks, findHomeCard } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); // Air Purifier(空净)功能页控制(在首页卡片控制 air_purifier_card 之外补充):点卡**弹浮层 → 点 "More" 进完整功能页**(两端都需要,之前漏点 More 一直在浮层操作)。 // 功能页(实测):中央状态圈 待机="On standby"/运行=空气质量("Very Good"等);独立无名电源圆键(Android 坐标 W*0.5,H*0.694; // iOS 是居中无名 Other ~74x74 @ cy~H*0.712,**坐标 tap 不 actuate → 必须 tapElement**);模式行 Level/Auto/Pet/Sleep; // 底部 Environmental Data / Schedules(功能页特有,用于判定"已进功能页而非浮层")。 const CARD_KEYWORD = process.env.AIR_PURIFIER_CARD || 'Air Purifier'; const MODES = ['Level', 'Auto', 'Pet', 'Sleep']; const CTRL = '[P0]'; // it() 标题显示用;实际回写锚点由 rec()/apNames 双发(美规+日规) // 空净**不分美规/日规**:两个 step(29 美规 7qxA1ajE / 30 日规 R5PDp8BR,均在 15975 BLE 超级用例)都写同一结果。 // wifi 轮无对应 15974 step(uuid 未知)→ 回退 [P0]。 const AP_BLE_STEPS = ['7qxA1ajE', 'R5PDp8BR']; const apNames = (base: string): string[] => process.env.PROTO === 'wifi' ? [`[P0] ${base}`] : AP_BLE_STEPS.map(u => `[P0][ONES:15975#${u}][ble] ${base}`); describe('AirPurifier Feature Control - 功能页控制(进设备页 电源/模式)', () => { let driver: DeviceDriver; let reporter: TestReporter; let W = 1080, H = 2400; setupResilientHooks(() => driver); const isIOS = () => driver.platform === 'ios'; // 双发回写:空净不分区,同一结果写 美规+日规 两个 step。 const rec = (base: string, status: 'PASS' | 'FAIL' | 'SKIP', dur: number, detail: string, ss?: string) => { for (const n of apNames(base)) reporter.record(n, status, dur, detail, ss); }; beforeAll(async () => { driver = createDriver(); await driver.createSession(); reporter = new TestReporter('AirPurifier_Feature_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 800-1150):"On standby"(待机/关) 或 空气质量读数(运行)。 function readStatus(src: string): string { if (isIOS()) { const m = src.match(/(?:name|label|value)="(On standby|Off|Very Good|Good|Moderate|Poor|Very Poor|Unhealthy[^"]*)"/i); return m ? m[1] : 'unknown'; } for (const m of src.matchAll(/text="([^"]+)"[^>]*bounds="\[\d+,(\d+)\]\[\d+,\d+\]"/g)) { const y = +m[2]; if (y > 800 && y < 1150 && m[1].trim().length >= 2) return m[1].trim(); } return 'unknown'; } // 待机/关机态:"On standby" / "Off" / 离线 const isStandby = (s: string) => /On standby|^Off$|Offline|待机/i.test(s); // 点电源圆键:Android 坐标 (W*0.5,H*0.694);iOS 无名 Other 坐标 tap 不生效 → 找居中方形 Other(cy~H*0.71)tapElement。 async function tapPower(): Promise { if (isIOS()) { const els = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeOther" OR type == "XCUIElementTypeButton"').catch(() => [] as string[]); let best: { e: string; d: number } | null = null; 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; // 居中、55-100px 近方形、cy 在 H*0.62~0.82(状态圈在上、模式行在下之间) if (Math.abs(cx - W / 2) < 30 && r.width >= 55 && r.width <= 100 && Math.abs(r.width - r.height) < 25 && cy > H * 0.62 && cy < H * 0.82) { const d = Math.abs(cy - H * 0.712); if (!best || d < best.d) best = { e, d }; } } if (best) { await driver.tapElement(best.e); await sleep(1500); return; } await driver.tap(Math.round(W * 0.5), Math.round(H * 0.712)); await sleep(1500); // 兜底 return; } await driver.tap(Math.round(W * 0.5), Math.round(H * 0.694)); await sleep(1500); } async function waitUntil(pred: (s: string) => boolean, ms = 16000): Promise { const deadline = Date.now() + ms; let st = readStatus(await driver.getSource()); while (Date.now() < deadline && !pred(st)) { await sleep(1500); st = readStatus(await driver.getSource()); } return st; } // 进空净功能页:点卡(弹浮层)→ 点 "More" 进完整功能页 →(关 Got it/OK)→ 等功能页就绪(Environmental Data/Schedules,浮层没有)。 async function enterFeature(): Promise { const card = await findHomeCard(driver, CARD_KEYWORD); if (!card) { console.log(`[APFeat] 首页找不到 ${CARD_KEYWORD}`); return false; } await driver.tapElement(card); // tapElement 更可靠(拥挤首页坐标易误点) await sleep(2000); for (let i = 0; i < 8; i++) { const src = await driver.getSource().catch(() => ''); if (/Environmental Data|Schedules/.test(src)) break; // 已在功能页(Android 点卡直进,无 More)→ 不空等 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('[APFeat] 点 More 进功能页'); break; } await sleep(700); } for (let i = 0; i < 3; i++) { const g = isIOS() ? await driver.findElementRaw('predicate string', 'name == "Got it" OR name == "OK"').catch(() => null) : await driver.findElementRaw('-android uiautomator', 'new UiSelector().textMatches("Got it|OK")').catch(() => null); if (!g) break; await driver.tapElement(g); await sleep(1000); } // 就绪:功能页特有的 Environmental Data / Schedules 出现(区分浮层),且能读到状态 for (let i = 0; i < 8; i++) { const src = await driver.getSource(); if (/Environmental Data|Schedules/.test(src) && readStatus(src) !== 'unknown') return true; await sleep(1000); } return /Environmental Data|Schedules/.test(await driver.getSource().catch(() => '')); } it(`${CTRL} 功能页电源开/关(Air Purifier)`, { timeout: 120000 }, async () => { const start = Date.now(); try { if (!(await enterFeature())) { // 首页有该空净却进不去功能页 = 未正确执行 → FAIL(交给 catch);首页确无该空净(未添加/本平台无实体)→ 合法 SKIP。 await driver.goBackToHomepage().catch(() => {}); await sleep(1200); if (await findHomeCard(driver, CARD_KEYWORD).catch(() => null)) throw new Error('进功能页失败(首页有该空净但进不去功能页,未正确执行)'); rec('功能页电源开/关', 'SKIP', Date.now() - start, '首页无该空净(未添加/本平台无实体),跳过'); return; } const s0 = readStatus(await driver.getSource()); console.log(`[APFeat] 初始: ${s0}`); // 先确保待机态,再开机:待机 → 点电源 → 运行(状态≠待机,显示空气质量) if (!isStandby(s0)) { await tapPower(); await waitUntil(isStandby); } await tapPower(); const sOn = await waitUntil(s => !isStandby(s) && s !== 'unknown'); console.log(`[APFeat] 开机后: ${sOn}`); expect(isStandby(sOn)).toBe(false); // 已运行(状态翻出待机) // 关机:运行 → 点电源 → "On standby" await tapPower(); const sOff = await waitUntil(isStandby); console.log(`[APFeat] 关机后: ${sOff}`); expect(isStandby(sOff)).toBe(true); rec('功能页电源开/关', 'PASS', Date.now() - start, `电源开/关均生效(${s0}→开:${sOn}→关:${sOff})`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); rec('功能页电源开/关', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it(`${CTRL} 功能页切换模式(Level/Auto/Pet/Sleep)`, { timeout: 120000 }, async () => { const start = Date.now(); try { if (!(await enterFeature())) { // 首页有该空净却进不去功能页 = 未正确执行 → FAIL(交给 catch);首页确无该空净(未添加/本平台无实体)→ 合法 SKIP。 await driver.goBackToHomepage().catch(() => {}); await sleep(1200); if (await findHomeCard(driver, CARD_KEYWORD).catch(() => null)) throw new Error('进功能页失败(首页有该空净但进不去功能页,未正确执行)'); rec('功能页切换模式', 'SKIP', Date.now() - start, '首页无该空净(未添加/本平台无实体),跳过'); return; } // 切模式前需先开机(待机时模式置灰) if (isStandby(readStatus(await driver.getSource()))) { await tapPower(); await waitUntil(s => !isStandby(s) && s !== 'unknown'); } const switched: string[] = []; for (const m of MODES) { const cont = await driver.findElementRaw('-android uiautomator', `new UiSelector().clickable(true).childSelector(new UiSelector().text("${m}"))`).catch(() => null) || await driver.findElementRaw(isIOS() ? 'predicate string' : '-android uiautomator', isIOS() ? `name == "${m}" OR label == "${m}"` : `new UiSelector().text("${m}")`).catch(() => null); if (cont) { await driver.tapElement(cont); await sleep(2000); switched.push(m); } else console.log(`[APFeat] 未找到模式键: ${m}`); } console.log(`[APFeat] 已切换模式: ${switched.join('/')}`); expect(switched.length).toBe(MODES.length); rec('功能页切换模式', 'PASS', Date.now() - start, `依次切换 ${switched.join('/')} 成功`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); rec('功能页切换模式', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); });