336 lines
18 KiB
TypeScript
336 lines
18 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, 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;
|
||
}
|
||
});
|
||
});
|