AI_UIAutomation/tests/air_purifier/air_purifier_card.test.ts

202 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
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<string> {
// 同一文件内多 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<void> {
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<void> {
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<boolean> {
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<boolean> {
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;
}
});
});