AI_UIAutomation/tests/ceiling_light/eave_light_control.test.ts

336 lines
18 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, getAddedDevice, scrollHomeToTopIOS, logVisibleDeviceCards } 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] 占位。
// 灯卡片关键词:**优先用 LIGHT_CARD(干净品类名,如 "Permanent Outdoor Lights")** 做 CONTAINS 匹配 ——
// 注册表存的名可能含实时状态(如 "...66On30% Color",状态会变 → CONTAINS 永远匹配不到)。LIGHT_CARD 各灯唯一、不串台。
// 注册名仅作兜底(注册表存的若干净、且无 LIGHT_CARD 时用)。
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 = '[P0]'; // TODO: 待补 屋檐灯/灯类 控制 ONES 编号
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":产品=Light,模块=灯名。
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(33,69)关不掉 → 点顶部**暗区**收起。注意 H*0.05 太高(状态栏)关不掉,实测 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 不会自动关,残留会污染下一用例/下一设备的控制测试)。Android back 已能关,无害。
afterEach(async () => {
if (driver.platform === 'ios') { await driver.tap(Math.round(W * 0.5), Math.round(H * 0.12)).catch(() => {}); await sleep(600); }
await driver.dismissPopupIfPresent().catch(() => {});
});
const isIOS = () => driver.platform === 'ios';
async function findCardEl(): Promise<string | null> {
return isIOS()
? (await driver.findElementRaw('predicate string', `name CONTAINS "${CARD_KEYWORD}" OR label CONTAINS "${CARD_KEYWORD}"`).catch(() => null))
: (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null));
}
async function findCard(): Promise<string> {
// 跨控制文件:正式跑 reset_home 重启到顶,无需回顶。但**同一文件内多个 it 之间不重启**,
// 前一个 it 会把列表停在中部 → 后续 findCard 须先**快速回顶**(scrollHomeToTopIOS ~1s)再从顶往下找。
if (isIOS()) await scrollHomeToTopIOS(driver).catch(() => {});
for (let r = 0; r < 12; r++) {
const el = await findCardEl();
if (el) {
const rect = await driver.getElementRect(el).catch(() => null);
if (rect && rect.width > 0 && rect.y > 50 && rect.y < H - 80) return el; // 在可视区内才用(避免贴边/视窗外)
}
await driver.scrollDown(500);
await sleep(800);
}
await logVisibleDeviceCards(driver, `找不到${CARD_KEYWORD}卡片`);
throw new Error(`找不到${CARD_KEYWORD}卡片`);
}
async function openCardPopup(): Promise<void> {
// 点卡片中心应弹"快捷控制浮层"(含 More + 滑条);但灯类有时会误进**功能页**(整页设备详情) → 检测不到浮层就回首页重试。
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('卡片快捷控制浮层未弹出(多次重试后仍误进功能页)');
}
// 动态找电源键:浮层里近正方形(150-300px)、水平居中的可点节点。找不到回退比例点。
// iOS 找电源键 Button(居中圆键,~50-120px,y>H*0.4);返回中心,找不到返回 null。两类灯(单/双滑条)位置不同,必须动态找。
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;
// 电源圆键:近正方形、~45-120px、**正中**(收紧到 W*0.08:屋檐灯浮层有 x=143 的 48px 控制键 cx=167 偏 28px,会被旧的 0.16 误纳)、浮层区。
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;
// 取**宽度最大**的(电源键 72×72 比定时/控制 48×49 大);同宽取最靠上。
cands.sort((a, b) => b.w - a.w || a.cy - b.cy);
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 * 0.5);
const py = p ? p.cy : Math.round(H * 0.5);
console.log(`点电源键(iOS) ${px},${py}${p ? '' : ' [回退]'}`);
return driver.tap(px, py);
}
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"。(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()) {
// 浮层(bottom sheet)状态是**独立元素、以 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] || '') : '';
}
async function isOff(): Promise<boolean> {
const s = await readStatus();
console.log(`状态字="${s}"`);
return /Off|Offline/.test(s);
}
// 当前亮度%:从**含卡名的状态串**取 NN%(iOS 浮层是 bottom sheet,背景首页其它卡片的% 会污染全局扫描)。
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;
}
async function pollState(wantOff: boolean, ms = 15000): Promise<boolean> {
await sleep(3000); // 控制(开/关)后等待 ~3s 让设备状态回写同步,再开始校验(灯卡片状态有延迟,直接读易误判)
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 }>> {
if (isIOS()) {
// 对齐 Android:**数实际滑条 track 元素**(iOS 是 XCUIElementTypeOther,w≈W*0.9、h≈56,在浮层滑条区)。
// 实测 Color Bulb(双) 有 2 条(y≈486 亮度 / y≈566 色温);单滑条灯只 1 条 → 色温用例自动 SKIP。按 y 去重(同条会有 track+填充两个元素)。
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 条=双滑条(含色温),同 Android 数 track
}
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]; console.log(`iOS 亮度条=y${tracks[0].y}(固定首条,不试探避免污染状态)`); 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);
}
}
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;
}
});
});