331 lines
16 KiB
TypeScript
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)' };
|
|
});
|
|
});
|