AI_UIAutomation/tests/ceiling_light/eave_light_control.test.ts

252 lines
12 KiB
TypeScript

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, waitForSource } from '../../utils/common';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// 灯类首页卡片控制(以屋檐灯 Permanent Outdoor Lights 为样板,卡片关键词可配,可复用到同款浮层滑条灯)。
// 浮层:状态字(Off / "On | NN% | Color") + **无文案电源圆键** + **亮度滑条** + More。流程同加湿器2/空净。
// 实测 1080x2400:电源键中心 (540,1455)=(w/2,h*0.606);滑条 track x[48..1032] y1737(h*0.724)。
// 亮度可由副标题 "On | NN%" 直接读出,拖滑条后校验 % 变化。ONES 控制号待补,先 [P0] 占位。
const CARD_KEYWORD = process.env.LIGHT_CARD || 'Permanent Outdoor Lights';
const ADD_ANCHOR = '[P0]'; // TODO: 待补 屋檐灯/灯类 控制 ONES 编号
describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
let W = 1080, H = 2400;
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
reporter = new TestReporter('Light_Card', driver.platform.toUpperCase());
try { const s = await driver.getWindowSize(); if (s?.width) { W = s.width; H = s.height; } } catch { /* 默认 1080x2400 */ }
});
beforeEach(async () => {
await driver.dismissPopupIfPresent();
await driver.goBackToHomepage();
await sleep(500);
await driver.dismissPopupIfPresent();
});
afterAll(async () => {
reporter.generate();
await driver.destroySession();
});
async function findCard(): Promise<string> {
// 先滚到顶复位(避免上条用例滚动后卡片落在视窗上方找不到)
for (let i = 0; i < 4; i++) { await driver.swipe(540, 700, 540, 1600, 0.3).catch(() => {}); await sleep(300); }
for (let r = 0; r < 10; r++) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null);
if (el) return el;
await driver.swipe(540, 1500, 540, 700, 0.4).catch(() => {});
await sleep(900);
}
throw new Error(`找不到${CARD_KEYWORD}卡片`);
}
async function openCardPopup(): Promise<void> {
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', 6000))) throw new Error('卡片快捷控制浮层未弹出');
}
// 动态找电源键:浮层里近正方形(150-300px)、水平居中的可点节点。找不到回退比例点。
async function tapPower(): Promise<void> {
const src = await driver.getSource();
const cands = Array.from(src.matchAll(/clickable="true"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g))
.map((m) => { const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4]; return { w: x2 - x1, h: y2 - y1, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2 }; })
.filter((b) => b.w >= 150 && b.w <= 320 && b.h >= 150 && b.h <= 320 && Math.abs(b.cx - W / 2) < W * 0.15 && b.cy > H * 0.4);
const px = cands.length ? Math.round(cands[0].cx) : Math.round(W * 0.5);
const py = cands.length ? Math.round(cands[0].cy) : Math.round(H * 0.606);
console.log(`点电源键 (${px}, ${py})${cands.length ? '' : ' [回退默认]'}`);
return driver.tap(px, py);
}
// 读副标题:首个含设备名后的状态字。关="Off";开="On | NN% | Color"。
async function readStatus(): Promise<string> {
const src = await driver.getSource();
const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]);
const i = texts.findIndex((t) => t.includes(CARD_KEYWORD));
return i >= 0 ? (texts[i + 1] || '') : '';
}
async function isOff(): Promise<boolean> {
const s = await readStatus();
console.log(`状态字="${s}"`);
return /^Off$|Offline/.test(s);
}
// 当前亮度%:扫全部文本节点找含 NN% 的那条(色温/模式无%,唯一的%即亮度;比 texts[i+1] 位置法稳)。
// 偶发瞬时读不到 → 重试;仍无返回 -1。
async function readBrightness(retries = 4): Promise<number> {
for (let i = 0; i < retries; i++) {
const src = await driver.getSource();
for (const m of src.matchAll(/text="([^"]+)"/g)) {
const mm = m[1].match(/(\d+)\s*%/);
if (mm) return Number(mm[1]);
}
await sleep(600);
}
return -1;
}
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;
}
// 动态找滑条 track:浮层里宽 View(w>屏宽80%、高<280)。排除屏幕底部(y1<H*0.88,滤掉 nav/home indicator 误检)。
// 按 y 升序返回(可能多条,如彩灯有亮度+色温)。
async function findSliderTracks(): Promise<Array<{ left: number; right: number; y: number }>> {
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();
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})`);
await driver.swipe(startX, tr.y, targetX, tr.y, 0.8);
await sleep(2500);
}
it(`${ADD_ANCHOR} 首页找到灯卡片`, async () => {
const start = Date.now();
try {
const cardId = await findCard();
const rect = await driver.getElementRect(cardId);
const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`;
console.log(`${CARD_KEYWORD} ${detail}`);
expect(rect.width).toBeGreaterThan(0);
reporter.record(`${ADD_ANCHOR} 首页找到灯卡片`, 'PASS', Date.now() - start, detail);
} 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(true); }
await tapPower();
const on = await pollState(false);
expect(on).toBe(true);
reporter.record(`${ADD_ANCHOR} 首页卡片开灯`, 'PASS', Date.now() - start, '电源键开灯成功(状态→On)');
} 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 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); // 拖动确实改变了亮度(亮>暗)
reporter.record(`${ADD_ANCHOR} 首页卡片拖动滑条调亮度`, 'PASS', Date.now() - start, `滑条拖动生效: 调亮${hi}% > 调暗${lo}%`);
} 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 ensureBrightnessTrack(); // 先确定哪条是亮度,色温=另一条
const tracks = await findSliderTracks();
const ct = tracks.find((t) => Math.abs(t.y - brightnessTrack!.y) > 50);
if (!ct) { // 单滑条灯(屋檐灯/灯带等)无独立色温条
reporter.record(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, 'SKIP', Date.now() - start, '该灯无独立色温滑条(单滑条)');
return;
}
// 拖动色温条覆盖(不校验设备值):从中点抓,拖到右端再拖回左端(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);
reporter.record(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, 'PASS', Date.now() - start, `色温滑条(y=${ct.y})拖动完成(仅覆盖,不校验设备)`);
} 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);
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;
}
});
});