AI_UIAutomation/tests/ceiling_light/eave_light_control.test.ts

331 lines
16 KiB
TypeScript

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,
getAddedDevice,
findHomeCard,
onesCtrlByCard,
tapHomeCardTopRight,
getDeviceStatus,
} from '../../utils/common';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// 灯类首页卡片控制(样板=屋檐灯 Permanent Outdoor Lights,卡片关键词可配,复用到同款浮层滑条灯)。
// 开/关:**首页卡片右上角开关钮**(tapHomeCardTopRight)+ getDeviceStatus 读副标题("On|NN%"/"Off")校验。
// —— 已实测:右上角钮点击可靠翻转电源(Off↔On),getDeviceStatus 读态可靠(卡名带尾缀由 contains 匹配)。
// 亮度/色温:**快捷控制浮层**(点卡弹出),内含亮度/色温滑条,拖动并读 % 校验。
// 卡片关键词优先用 LIGHT_CARD(干净品类名,如 "Color Bulb");注册名(可能含实时状态)仅作兜底。
const LIGHT_REG = process.env.LIGHT_REG;
const CARD_KEYWORD = process.env.LIGHT_CARD || (LIGHT_REG && getAddedDevice(LIGHT_REG)) || 'Permanent Outdoor Lights';
const ADD_ANCHOR = onesCtrlByCard(CARD_KEYWORD) || '[P0]'; // 按卡片名取灯类控制锚点(15975/15974 step);未命中回退 [P0]
// FRESH:首次定位,回顶+清弹框把卡带进可视区(单次较慢,深处卡片要滚)。
// CUR:卡已在视区时就地复用当前视图(不回顶/不清弹框),点右上角钮不离开首页 → 后续读态/再点都快。
const FRESH = { resetPosition: 'top', logOnFail: true } as const;
const CUR = { resetPosition: 'none', ensureHome: false, dismissPopups: false } as const;
// 单步超时:首页设备多时单次 findHomeCard 可能 ~25s,一步含定位+翻转+轮询,给足 120s(默认 60s 会误超时)。
const STEP_TIMEOUT = 120000;
type StepResult = { status?: 'PASS' | 'SKIP'; detail: string };
describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
let W = 1080, H = 2400;
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
// suite 带卡名 → 每个灯记录可区分(合并报告按 suite+用例名去重,否则多灯会塌缩成一个)。形如 "Light_Color Bulb"。
reporter = new TestReporter(`Light_${CARD_KEYWORD}`, driver.platform.toUpperCase());
try { const s = await driver.getWindowSize(); if (s?.width) { W = s.width; H = s.height; } } catch { /* 默认 1080x2400 */ }
});
beforeEach(async () => {
// iOS 浮层是底部 bottom sheet:android back 关不掉 → 点顶部暗区收起(H*0.12 才在暗区内)。
if (driver.platform === 'ios') { await driver.tap(Math.round(W * 0.5), Math.round(H * 0.12)).catch(() => {}); await sleep(700); }
await driver.dismissPopupIfPresent();
await driver.goBackToHomepage();
await sleep(500);
await driver.dismissPopupIfPresent();
});
afterAll(async () => {
reporter.generate();
await driver.destroySession();
});
// 每个用例结束都收起底部浮层(iOS sheet 不会自动关,残留会污染下一用例/下一设备)。
afterEach(async () => {
await closeQuickPopup().catch(() => {});
await driver.dismissPopupIfPresent().catch(() => {});
});
// 用例包装:统一 try/catch + 计时 + 失败截图 + reporter.record。正文只写业务逻辑并返回 { detail }(可选 status=SKIP)。
function step(name: string, fn: () => Promise<StepResult>): void {
it(name, async () => {
const start = Date.now();
try {
const r = await fn();
reporter.record(name, r.status ?? 'PASS', Date.now() - start, r.detail);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(name, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
}, STEP_TIMEOUT);
}
const isIOS = () => driver.platform === 'ios';
// ============ 电源:首页卡片右上角开关钮 + getDeviceStatus 读态(两端通用,已实测可靠) ============
// 读电源态。fresh=true:回顶重定位把卡带进可视区(每步首次用);否则就地复用当前视图(卡在视区时更快)。
async function readPower(fresh = false): Promise<'on' | 'off' | 'unknown'> {
const s = await getDeviceStatus(driver, CARD_KEYWORD, fresh ? FRESH : CUR);
console.log(`卡片状态="${s.raw}" power=${s.power}`);
return s.power;
}
// 点右上角钮后轮询卡片状态到目标态或超时(卡状态回写有延迟,先等 ~2.5s 再读)。
async function pollPower(target: 'on' | 'off', ms = 12000): Promise<boolean> {
await sleep(2500);
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if ((await readPower()) === target) return true;
await sleep(1500);
}
return (await readPower()) === target;
}
// 把灯驱到目标态(带反馈,不盲翻):首次 fresh 读态定位;已到位直接返回;否则点右上角钮翻转+轮询,最多 3 次。
async function setPower(target: 'on' | 'off'): Promise<boolean> {
let p = await readPower(true);
for (let i = 0; i < 3 && p !== target; i++) {
await tapHomeCardTopRight(driver, CARD_KEYWORD, CUR); // 卡已在视区,就地点右上角钮
if (await pollPower(target)) return true;
p = await readPower(true); // 翻后重读(卡可能被滚动/弹框挪动,fresh 重定位)
}
return p === target;
}
// 亮度/色温调整前确保灯已开:读态,非 on 就用卡片右上角钮开(不再用浮层电源键,统一走已验证的卡片开关)。
async function ensureOn(): Promise<void> {
if ((await readPower(true)) !== 'on') await setPower('on');
}
// ============ 亮度/色温:快捷控制浮层 + 滑条 ============
async function closeQuickPopup(): Promise<void> {
const src = await driver.getSource().catch(() => '');
const hasPopup = src.includes('More') && /(?:text|name|label|value)="(?:On|Off)(?:\b|\s*\|)/.test(src);
if (!hasPopup) return;
// 不用系统 Back 关浮层:Android 在首页按 Back 会退出 App。统一点浮层外暗区收起。
await driver.tap(Math.round(W * 0.5), Math.round(H * 0.12)).catch(() => {});
await sleep(600);
}
// 点卡片弹出"快捷控制浮层"(含 More + 滑条);灯类偶尔误进功能页 → 检测不到浮层就回首页重试。
// find 默认 CUR(ensureOn/开灯已把卡带进视区,就地点更快);找不到时 findHomeCard(CUR) 会自行滚动兜底。
async function openCardPopup(): Promise<void> {
for (let attempt = 0; attempt < 3; attempt++) {
const cardId = await findHomeCard(driver, CARD_KEYWORD, attempt === 0 ? CUR : FRESH);
if (!cardId) { await driver.goBackToHomepage(); await sleep(800); continue; }
// 点卡用 tapElement(点准卡元素),而非坐标点中心 —— 拥挤首页坐标易点到邻卡/误进功能页。
await driver.tapElement(cardId).catch(async () => {
const rect = await driver.getElementRect(cardId).catch(() => null);
if (rect) await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2);
});
await sleep(2000);
if (await isQuickPopupVisible()) {
brightnessTrack = null; // 每次重开浮层重新探测滑条,避免复用旧 sheet/旧灯型坐标。
return;
}
console.log(`未弹快捷浮层(疑误进功能页),回首页重试(第 ${attempt + 1}/3)`);
await driver.dismissPopupIfPresent().catch(() => {});
await driver.goBackToHomepage();
await sleep(800);
}
throw new Error('卡片快捷控制浮层未弹出(多次重试后仍误进功能页)');
}
async function isQuickPopupVisible(): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < 6000) {
const src = await driver.getSource().catch(() => '');
if (src.includes('More')) return true;
if (/(?:text|name|label|value)="(?:On|Off)(?:\b|\s*\|)/.test(src)) return true;
const tracks = await findSliderTracks().catch(() => []);
if (tracks.length > 0) return true;
await sleep(500);
}
return false;
}
// 读浮层副标题:首个含设备名后的状态字。关="Off";开="On | NN% | Color"。(iOS 属性是 name/label/value,非 text)
const ATTR = /(?:text|name|label|value)="([^"]+)"/g;
async function readStatus(): Promise<string> {
const src = await driver.getSource();
const vals = Array.from(src.matchAll(ATTR)).map((m) => m[1]);
if (isIOS()) {
// 浮层状态是独立元素、以 On/Off 开头(如 "On | 21% | White" / "Off");优先取它(拖动时实时,背景卡片滞后)。
const overlay = vals.map((t) => t.trim()).find((t) => /^(On|Off)\b/.test(t));
if (overlay) return overlay;
for (let i = 0; i < vals.length; i++) {
if (vals[i].includes(CARD_KEYWORD)) {
const combined = `${vals[i]} ${vals[i + 1] || ''}`.trim();
if (/On|Off/.test(combined)) return combined;
}
}
return vals.find((t) => t.includes(CARD_KEYWORD)) || '';
}
const i = vals.findIndex((t) => t.includes(CARD_KEYWORD));
return i >= 0 ? (vals[i + 1] || '') : '';
}
// 当前亮度%:从含卡名的状态串取 NN%;取不到重试(瞬时无% → -1)。
async function readBrightness(retries = 4): Promise<number> {
for (let i = 0; i < retries; i++) {
if (isIOS()) {
const mm = (await readStatus()).match(/(\d+)\s*%/);
if (mm) return Number(mm[1]);
} else {
const src = await driver.getSource();
for (const m of src.matchAll(ATTR)) { const mm = m[1].match(/(\d+)\s*%/); if (mm) return Number(mm[1]); }
}
await sleep(600);
}
return -1;
}
// 动态找滑条 track:浮层里宽 View(w>屏宽80%、高<280)。排除屏幕底部(y1<H*0.88,滤 nav/home-indicator)。按 y 升序返回。
async function findSliderTracks(): Promise<Array<{ left: number; right: number; y: number }>> {
if (isIOS()) {
const src = await driver.getSource();
const seen = new Set<number>();
const tracks: Array<{ left: number; right: number; y: number }> = [];
for (const node of src.split('<')) {
if (!/XCUIElementTypeOther/.test(node)) continue;
const b = node.match(/x="(-?\d+)"\s+y="(-?\d+)"\s+width="(\d+)"\s+height="(\d+)"/);
if (!b) continue;
const x = +b[1], y = +b[2], w = +b[3], h = +b[4];
if (w > W * 0.8 && h >= 40 && h <= 90 && y > H * 0.45 && y < H * 0.85) {
const cy = Math.round(y + h / 2);
const key = Math.round(cy / 30);
if (!seen.has(key)) { seen.add(key); tracks.push({ left: x + 12, right: x + w - 12, y: cy }); }
}
}
tracks.sort((a, b) => a.y - b.y);
return tracks; // 1 条=单滑条(无色温) / 2 条=双滑条(含色温)
}
const src = await driver.getSource();
const tracks = Array.from(src.matchAll(/class="android\.view\.View"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g))
.map((m) => { const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4]; return { x1, y1, w: x2 - x1, h: y2 - y1, left: x1 + 10, right: x2 - 10, y: Math.round((y1 + y2) / 2) }; })
.filter((b) => b.w > W * 0.8 && b.h > 40 && b.h < 280 && b.y1 > H * 0.4 && b.y1 < H * 0.88);
return tracks.sort((a, b) => a.y - b.y);
}
// 缓存亮度滑条。多滑条时用「幅度+方向」判定:真亮度条=拖低读低%、拖高读高%、且高-低≥25(色温条拖动亮度%基本不变被排除;反向条也排除)。
let brightnessTrack: { left: number; right: number; y: number } | null = null;
// 触点 x 夹紧远离屏幕左右边缘(避免 Android 边缘返回手势把浮层划掉 —— 亮度100%时滑块在最右端尤甚)。
const clampX = (x: number) => Math.max(Math.round(W * 0.085), Math.min(Math.round(W * 0.915), x));
async function dragOnTrack(tr: { left: number; right: number; y: number }, toF: number): Promise<void> {
const cur = await readBrightness();
const curF = cur >= 0 ? cur / 100 : 0.5;
const sx = clampX(Math.round(tr.left + curF * (tr.right - tr.left))); // 从当前滑块(按亮度%反算)起拖
const tx = clampX(Math.round(tr.left + toF * (tr.right - tr.left)));
await driver.swipe(sx, tr.y, tx, tr.y, 0.8);
await sleep(2000);
}
async function ensureBrightnessTrack(): Promise<void> {
if (brightnessTrack) return;
const tracks = await findSliderTracks();
if (isIOS()) {
brightnessTrack = tracks[0] || { left: Math.round(W * 0.0444), right: Math.round(W * 0.9556), y: Math.round(H * 0.7237) };
console.log(`iOS 亮度条=y${brightnessTrack.y}${tracks[0] ? '(固定首条)' : '(回退默认)'}`);
return;
}
for (const tr of tracks) {
await dragOnTrack(tr, 0.25); const lo = await readBrightness();
await dragOnTrack(tr, 0.8); const hi = await readBrightness();
console.log(`试探滑条 y=${tr.y}: 拖低→${lo}% 拖高→${hi}%`);
if (hi > 0 && lo > 0 && hi > lo && hi - lo >= 25) { brightnessTrack = tr; console.log(`→ 亮度条=y${tr.y}`); return; }
}
brightnessTrack = tracks[0] || { left: Math.round(W * 0.0444), right: Math.round(W * 0.9556), y: Math.round(H * 0.7237) };
console.log(`亮度条=y${brightnessTrack.y}(回退,未明确检出)`);
}
// 拖亮度滑条到占比 f(0..1):起点落在当前滑块(按当前浮层亮度%反算),拖到目标。
async function setBrightness(f: number): Promise<void> {
await ensureBrightnessTrack();
const tr = brightnessTrack!;
const cur = await readBrightness();
const startF = cur >= 0 ? cur / 100 : 0.5;
const startX = clampX(Math.round(tr.left + startF * (tr.right - tr.left)));
const targetX = clampX(Math.round(tr.left + f * (tr.right - tr.left)));
console.log(`拖亮度 ${cur}%→${Math.round(f * 100)}% (track y=${tr.y}, x ${startX}${targetX})`);
if (isIOS()) {
// iOS 自绘滑块:0.8s swipe 抓不住 → 用更长按住(1.5s)让其 grab 再移;回写有延迟多等再读。
await driver.swipe(startX, tr.y, targetX, tr.y, 1.5);
await sleep(5000);
} else {
await driver.swipe(startX, tr.y, targetX, tr.y, 0.8);
await sleep(2500);
}
}
// ============ 用例(4 步:开灯 / 拖亮度 / 拖色温 / 关灯) ============
step(`${ADD_ANCHOR} 首页卡片开灯`, async () => {
const on = await setPower('on');
expect(on).toBe(true);
return { detail: '右上角开关钮开灯成功(状态→On)' };
});
step(`${ADD_ANCHOR} 首页卡片拖动滑条调亮度`, async () => {
await ensureOn();
await openCardPopup();
await setBrightness(0.8); // 调亮到 ~80%
const hi = await readBrightness();
await setBrightness(0.2); // 调暗到 ~20%
const lo = await readBrightness();
console.log(`亮度: 调亮后=${hi}% 调暗后=${lo}%`);
expect(hi).toBeGreaterThan(0);
expect(lo).toBeGreaterThan(0);
expect(hi).toBeGreaterThan(lo); // 拖动确实改变了亮度(亮>暗)
return { detail: `滑条拖动生效: 调亮${hi}% > 调暗${lo}%` };
});
step(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, async () => {
await ensureOn();
await openCardPopup();
await ensureBrightnessTrack(); // 先确定哪条是亮度,色温=另一条
const tracks = await findSliderTracks();
const ct = tracks.find((t) => Math.abs(t.y - brightnessTrack!.y) > 50);
if (!ct) { // 单滑条灯(屋檐灯/灯带等)无独立色温条
return { status: 'SKIP', detail: '该灯无独立色温滑条(单滑条)' };
}
// 拖动色温条覆盖(不校验设备值):从中点抓,拖到右端再拖回左端(x 夹紧避免边缘手势)。
const mid = clampX(Math.round((ct.left + ct.right) / 2));
const rx = clampX(ct.right), lx = clampX(ct.left);
console.log(`拖色温条 y=${ct.y} (mid=${mid}${lx}/${rx})`);
await driver.swipe(mid, ct.y, rx, ct.y, 0.8); await sleep(1800);
await driver.swipe(rx, ct.y, lx, ct.y, 0.8); await sleep(1800);
return { detail: `色温滑条(y=${ct.y})拖动完成(仅覆盖,不校验设备)` };
});
step(`${ADD_ANCHOR} 首页卡片关灯`, async () => {
const off = await setPower('off');
expect(off).toBe(true);
return { detail: '右上角开关钮关灯成功(状态→Off)' };
});
});