236 lines
12 KiB
TypeScript
236 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, onesCtrl, setupResilientHooks, findHomeCard, parseHomeCardStatus } from '../../utils/common';
|
||
import * as dotenv from 'dotenv';
|
||
import * as path from 'path';
|
||
|
||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||
|
||
// Curtain 功能页控制(在首页弹窗控制 curtain_control 之外补充):点卡 → 弹窗点 "More" 进完整功能页 → Open/Close + 校验状态。
|
||
// 功能页(实测):Open/Pause/Close 文字按钮 + 位置"X%"/"Fully open/closed" + Slide 滑条 + Light Sensing/Delay/Schedules。
|
||
const deviceName = process.env.CURTAIN_DEVICE || 'Curtain Auto';
|
||
const CTRL = onesCtrl('curtain', deviceName) || '[P0]'; // 按卡名(Curtain Auto/Curtain3 Auto/Curtain3 2025 Auto)取对应 step,不再硬编码 Curtain 1A
|
||
|
||
describe('Curtain Feature Control - 功能页控制(进设备页 开/关)', () => {
|
||
let driver: DeviceDriver;
|
||
let reporter: TestReporter;
|
||
setupResilientHooks(() => driver);
|
||
const isIOS = () => driver.platform === 'ios';
|
||
|
||
beforeAll(async () => {
|
||
driver = createDriver();
|
||
await driver.createSession();
|
||
reporter = new TestReporter('Curtain_Feature_Control', driver.platform.toUpperCase());
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await driver.dismissPopupIfPresent();
|
||
await driver.goBackToHomepage();
|
||
await sleep(500);
|
||
await driver.dismissPopupIfPresent();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
reporter.generate();
|
||
await driver.destroySession();
|
||
});
|
||
|
||
// 读窗帘状态:Fully open / Fully closed / "X% Opened..."(复用 curtain_control 逻辑)
|
||
function readState(src: string): string {
|
||
if (isIOS()) {
|
||
const m = src.match(new RegExp(`"${deviceName}(Fully open|Fully closed|[0-9]{1,3}% [^"]*)"`));
|
||
return m ? m[1].trim() : 'unknown';
|
||
}
|
||
const m = src.match(/text="(Fully open|Fully closed|[0-9]{1,3}% [^"]*)"/);
|
||
return m ? m[1] : 'unknown';
|
||
}
|
||
|
||
// 点功能页 Open/Close/Pause:文字只是 label,真正可点的圆形图标在文字**正上方**(iOS ~50px / Android ~100px)。
|
||
// 实测 Curtain3 1080×2400:Open 文字 top=1914,其上图标 ViewGroup [*,1727][*,1888] 中心 ~1807 → 文字 top−100 命中。
|
||
async function tapBtn(label: string): Promise<boolean> {
|
||
if (isIOS()) {
|
||
// iOS:Open/Pause/Close 是文字标签(StaticText),上方有圆形图标 Button。**坐标 tap 不 actuate**(实测点了不动)→
|
||
// 找标签 cx,再找其**上方**(y 更小)cx 匹配的图标 Button,**tapElement** 它(实测能驱动:Slide 0%→100%)。
|
||
const lbls = await driver.findElementsRaw('predicate string', `name == "${label}" OR label == "${label}"`).catch(() => [] as string[]);
|
||
let lcx = -1, ly = -1;
|
||
for (const l of lbls) { const r = await driver.getElementRect(l).catch(() => null); if (r && r.width > 0 && r.y > ly) { lcx = r.x + r.width / 2; ly = r.y; } }
|
||
if (lcx < 0) return false;
|
||
const btns = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => [] as string[]);
|
||
let best: string | null = null, bestDx = 999;
|
||
for (const b of btns) {
|
||
const r = await driver.getElementRect(b).catch(() => null);
|
||
if (r && r.y < ly && r.y > ly - 130) { const dx = Math.abs(r.x + r.width / 2 - lcx); if (dx < 40 && dx < bestDx) { bestDx = dx; best = b; } }
|
||
}
|
||
if (best) { await driver.tapElement(best); return true; }
|
||
await driver.tap(Math.round(lcx), Math.round(ly - 50)); // 兜底
|
||
return true;
|
||
}
|
||
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${label}")`).catch(() => null);
|
||
if (!el) return false;
|
||
const r = await driver.getElementRect(el).catch(() => null);
|
||
if (!r) { await driver.tapElement(el); return true; }
|
||
await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y - 100)); // 点文字正上方图标
|
||
return true;
|
||
}
|
||
|
||
// 进窗帘功能页:点卡(弹快捷窗)→ 点 "More" 进完整功能页 →(点掉 Got it 引导)→ 等 Open 按钮出现
|
||
async function enterCurtainFeature(): Promise<boolean> {
|
||
const card = await findHomeCard(driver, deviceName);
|
||
if (!card) { console.log(`[CurtainFeat] 首页找不到 ${deviceName}`); return false; }
|
||
await driver.tapElement(card);
|
||
await sleep(1800);
|
||
// 弹窗里点 More 进功能页
|
||
for (let i = 0; i < 8; i++) {
|
||
const more = isIOS()
|
||
? await driver.findElementRaw('predicate string', 'name == "More" OR label == "More"').catch(() => null)
|
||
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("More")').catch(() => null);
|
||
if (more) { await driver.tapElement(more); await sleep(2500); console.log('[CurtainFeat] 点 More 进功能页'); break; }
|
||
await sleep(800);
|
||
}
|
||
for (let i = 0; i < 3; i++) {
|
||
const g = isIOS()
|
||
? await driver.findElementRaw('predicate string', 'name == "Got it" OR label == "Got it"').catch(() => null)
|
||
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Got it")').catch(() => null);
|
||
if (!g) break; await driver.tapElement(g); await sleep(1200);
|
||
}
|
||
// 等功能页 Open 按钮出现
|
||
for (let i = 0; i < 6; i++) {
|
||
const open = isIOS()
|
||
? await driver.findElementRaw('predicate string', 'name == "Open" OR label == "Open"').catch(() => null)
|
||
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Open")').catch(() => null);
|
||
if (open) return true;
|
||
await sleep(1000);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 直接读**功能页**实时位置(不回首页,省导航):"Fully open"/"Fully closed"/"Partially open XX%"。
|
||
// 返回开合百分比(高=开 低=关);Fully open=100 Fully closed=0;读不到=-1。
|
||
function readFeaturePct(src: string): number {
|
||
const m = src.match(/(?:text|name|label|value)="(Fully open|Fully closed|Partially open \d+%)[^"]*"/);
|
||
if (!m) return -1;
|
||
if (/Fully open/.test(m[1])) return 100;
|
||
if (/Fully closed/.test(m[1])) return 0;
|
||
const p = m[1].match(/(\d+)%/);
|
||
return p ? +p[1] : -1;
|
||
}
|
||
|
||
// 点按钮后原地轮询功能页位置,**检测到方向移动即返回**(开→%增大 / 关→%减小,不要求到端点)。
|
||
// 窗帘运动较慢且单次点击偶发不触发 → 中途(~20s)若仍无位移,补点一次。
|
||
async function actuateAndWait(label: string, baseline: number, dir: 'up' | 'down'): Promise<number> {
|
||
await tapBtn(label); await sleep(1500);
|
||
let pct = readFeaturePct(await driver.getSource());
|
||
for (let i = 0; i < 28; i++) {
|
||
if (dir === 'up' && pct > baseline) return pct;
|
||
if (dir === 'down' && pct >= 0 && pct < baseline) return pct;
|
||
if (i === 8 && pct === baseline) { await tapBtn(label); await sleep(800); } // 没动→补点一次
|
||
await sleep(2500);
|
||
pct = readFeaturePct(await driver.getSource());
|
||
}
|
||
return pct;
|
||
}
|
||
|
||
// 非 Curtain3:功能页无实时 % → 回首页读卡片 desText 文字(Fully open/closed/Partially open)校验。
|
||
async function verifyViaCard(pred: (s: string) => boolean): Promise<string> {
|
||
await driver.dismissPopupIfPresent();
|
||
await driver.goBackToHomepage(); await sleep(1200);
|
||
await findHomeCard(driver, deviceName);
|
||
let st = parseHomeCardStatus(await driver.getSource(), deviceName, isIOS());
|
||
for (let i = 0; i < 16 && !pred(st); i++) {
|
||
await sleep(2500);
|
||
await findHomeCard(driver, deviceName);
|
||
st = parseHomeCardStatus(await driver.getSource(), deviceName, isIOS());
|
||
}
|
||
return st;
|
||
}
|
||
|
||
// 仅 Curtain3 / Curtain3 2025 功能页有实时位置 % → 原地校验方向移动;其他窗帘回首页读卡片状态。
|
||
const isC3 = /Curtain3/i.test(deviceName);
|
||
|
||
it(`${CTRL} 功能页控制${deviceName}(开+关)`, { timeout: 240000 }, async () => {
|
||
const start = Date.now();
|
||
try {
|
||
if (!(await enterCurtainFeature())) {
|
||
// 首页有该窗帘却进不去功能页 = 未正确执行 → FAIL(交给 catch);首页确无该窗帘(未添加/本平台无实体)→ 合法 SKIP。
|
||
await driver.goBackToHomepage().catch(() => {}); await sleep(1200);
|
||
if (await findHomeCard(driver, deviceName).catch(() => null)) throw new Error('进功能页失败(首页有该窗帘但进不去功能页,未正确执行)');
|
||
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'SKIP', Date.now() - start, '首页无该窗帘(未添加/本平台无实体),跳过');
|
||
return;
|
||
}
|
||
|
||
if (isC3) {
|
||
// Curtain3/2025:功能页实时 %,有方向移动即通过。
|
||
// 实测 Curtain3 Open 方向从近关位不响应(设备/校准问题)→ 按指示**跳过开方向断言**,
|
||
// 仅硬断言 Close 方向(% 下降或已关到底);Open 做 best-effort(动了记录,不动不判失败)。
|
||
const p0 = readFeaturePct(await driver.getSource());
|
||
console.log(`[CurtainFeat] 初始: ${p0}%`);
|
||
|
||
// Open:best-effort,不断言
|
||
await tapBtn('Open');
|
||
const pOpen = p0 >= 99 ? p0 : await actuateAndWait('Open', p0, 'up');
|
||
const openMoved = pOpen > p0;
|
||
console.log(`[CurtainFeat] Open 后: ${pOpen}%(${openMoved ? '开方向生效' : '开方向未响应,跳过'})`);
|
||
|
||
// Close:硬断言——% 下降,或已关到底(≤8%)
|
||
const base = Math.max(pOpen, p0);
|
||
expect(await tapBtn('Close')).toBe(true);
|
||
const pClose = base <= 8 ? base : await actuateAndWait('Close', base, 'down'); // 已近关位则不空轮询
|
||
console.log(`[CurtainFeat] Close 后: ${pClose}%(关前${base}%)`);
|
||
expect(pClose >= 0 && (pClose < base || pClose <= 8)).toBe(true); // 关:下降 或 已到底
|
||
|
||
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'PASS', Date.now() - start,
|
||
`Close方向生效(${base}%→${pClose}%);开方向${openMoved ? `生效(→${pOpen}%)` : '设备不响应已跳过'}`);
|
||
} else if (isIOS()) {
|
||
// iOS 普通窗帘:功能页有 "Slide to X%"(0=全开,100=全关)实时位置 → **原地校验**(不回首页/不重进,窗帘慢也能等)。
|
||
// 坐标 tap 不 actuate,tapBtn 已改为 tapElement(图标 Button)。
|
||
const slidePct = (src: string): number => {
|
||
if (/Fully open/.test(src)) { /* 端点标签同时存在,以 Slide to 为准 */ }
|
||
const m = src.match(/"Slide to (\d+)%"/);
|
||
if (m) return +m[1];
|
||
if (/"Fully closed"/.test(src)) return 100;
|
||
if (/"Fully open"/.test(src)) return 0;
|
||
return -1;
|
||
};
|
||
const waitSlide = async (pred: (p: number) => boolean): Promise<number> => {
|
||
let p = slidePct(await driver.getSource());
|
||
for (let i = 0; i < 28 && !pred(p); i++) { await sleep(2500); p = slidePct(await driver.getSource()); }
|
||
return p;
|
||
};
|
||
expect(await tapBtn('Open')).toBe(true);
|
||
const pOpen = await waitSlide(p => p >= 0 && p <= 10); // 开 → Slide 到 ~0%
|
||
console.log(`[CurtainFeat] Open 后(Slide): ${pOpen}%`);
|
||
expect(pOpen >= 0 && pOpen <= 10).toBe(true);
|
||
|
||
expect(await tapBtn('Close')).toBe(true);
|
||
const pClose = await waitSlide(p => p >= 90); // 关 → Slide 到 ~100%
|
||
console.log(`[CurtainFeat] Close 后(Slide): ${pClose}%`);
|
||
expect(pClose >= 90).toBe(true);
|
||
|
||
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'PASS', Date.now() - start, `功能页开/关均生效(开→Slide ${pOpen}% 关→Slide ${pClose}%)`);
|
||
} else {
|
||
// Android 其他窗帘:功能页打开 → 回首页读卡片;重进功能页关闭 → 回首页读卡片
|
||
expect(await tapBtn('Open')).toBe(true);
|
||
await sleep(2000);
|
||
const sOpen = await verifyViaCard(s => /fully open/i.test(s));
|
||
console.log(`[CurtainFeat] Open 后(卡片): ${sOpen}`);
|
||
expect(/fully open/i.test(sOpen)).toBe(true);
|
||
|
||
if (!(await enterCurtainFeature())) throw new Error('关闭前重进功能页失败');
|
||
expect(await tapBtn('Close')).toBe(true);
|
||
await sleep(2000);
|
||
const sClose = await verifyViaCard(s => /fully closed/i.test(s));
|
||
console.log(`[CurtainFeat] Close 后(卡片): ${sClose}`);
|
||
expect(/fully closed/i.test(sClose)).toBe(true);
|
||
|
||
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'PASS', Date.now() - start, `功能页开/关均生效(开→${sOpen} 关→${sClose})`);
|
||
}
|
||
} catch (e: any) {
|
||
const ss = await driver.screenshot().catch(() => '');
|
||
reporter.record(`${CTRL} 功能页控制${deviceName}`, 'FAIL', Date.now() - start, e.message, ss);
|
||
throw e;
|
||
}
|
||
});
|
||
});
|