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 { 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 { 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 { 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 { 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 => { 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; } }); });