diff --git a/config/relay.config.ts b/config/relay.config.ts index 7e6d394..61d441d 100644 --- a/config/relay.config.ts +++ b/config/relay.config.ts @@ -304,11 +304,11 @@ export const RELAY_MAP: Record = { }, robot_s10: { buttons: { - a: { android: 13 /*, ios: <填> */ }, // 扫地机 S10 配对键1(ch13,与 K20+ 同口,物理同时只接一个) - b: { android: 14 /*, ios: <填> */ }, // 扫地机 S10 配对键2(ch14) + a: { android: 17 /*, ios: <填> */ }, // 扫地机 S10 配对键1(ch17) + b: { android: 18 /*, ios: <填> */ }, // 扫地机 S10 配对键2(ch18) }, actions: { - pairing: { buttons: ['a', 'b'], holdMs: 5000, desc: '同时长按 ch13+14 5s 进入添加模式(扫地机 S10;流程同 K20+)' }, + pairing: { buttons: ['a', 'b'], holdMs: 5000, desc: '同时长按 ch17+18 5s 进入添加模式(扫地机 S10;入口 Floor Cleaning Robot S10,流程同水浸串口配对+配网)' }, }, }, ai_art_frame: { diff --git a/scripts/gen-p0-report.py b/scripts/gen-p0-report.py index 6f6779a..b0955b3 100644 --- a/scripts/gen-p0-report.py +++ b/scripts/gen-p0-report.py @@ -27,15 +27,19 @@ start = max(starts) if starts else 0 seg = lines[start:] hdr = lines[start].strip() if starts else '(未找到回归开始标记,取全量)' -rows = [] # (status, name) 按出现顺序 +rows = [] # (status, name, phase) 按出现顺序;phase: None=添加/setup, 'ble'/'wifi'=控制双轮, 'tail'=平台账号 +phase = None for l in seg: + if '控制 PROTO=ble' in l: phase = 'ble' + elif '控制 PROTO=wifi' in l: phase = 'wifi' + elif '平台 + 账号阶段' in l: phase = 'tail' m = re.search(r'[✓✗○] \[(PASS|FAIL|SKIP)\]\s*(.+?)\s*\(([\d.]+)s\)', l) if m: - rows.append((m.group(1), re.sub(r'\s+', ' ', m.group(2)).strip())) + rows.append((m.group(1), re.sub(r'\s+', ' ', m.group(2)).strip(), phase)) # --- 添加用例:按用例最终结果归并 --- add_seq = OrderedDict() -for s, n in rows: +for s, n, ph in rows: if '添加' in n: add_seq.setdefault(n, []).append(s) add_first, add_retry, add_fail = [], [], [] @@ -57,7 +61,7 @@ def cat(n): if '首页卡片' in n or '首页找到' in n: return '灯/卡片控制' return '设备控制(其他)' -nonadd = [(s, n) for s, n in rows if '添加' not in n] +nonadd = [(s, n) for s, n, ph in rows if '添加' not in n] fg = defaultdict(Counter) for s, n in nonadd: if s == 'FAIL': fg[cat(n)][n] += 1 @@ -65,6 +69,23 @@ na_pass = sum(1 for s, n in nonadd if s == 'PASS') na_fail = sum(1 for s, n in nonadd if s == 'FAIL') na_skip = sum(1 for s, n in nonadd if s == 'SKIP') +# --- 控制 BLE / WiFi 按阶段拆(按用例最终结果去重:每阶段同名用例取最后一次结果,得真实成功率,不受重试占位干扰)--- +def phase_final(target): + seq = OrderedDict() + for s, n, ph in rows: + if ph == target and '添加' not in n: + seq.setdefault(n, []).append(s) + p = f = sk = 0 + for n, sts in seq.items(): + last = sts[-1] + if last == 'PASS': p += 1 + elif last == 'FAIL': f += 1 + else: sk += 1 + return p, f, sk +ble_p, ble_f, ble_sk = phase_final('ble') +wifi_p, wifi_f, wifi_sk = phase_final('wifi') +def rate(p, f): return f'{100 * p / (p + f):.1f}%' if (p + f) else 'N/A' + o = [] o.append('# Android P0 真实结果\n') o.append(f'> 来源:{hdr}(最后一轮)。\n') @@ -75,6 +96,15 @@ o.append('## 汇总\n') o.append(f'- **添加(按用例):{add_total} 个 → ✅通过 {add_ok}(其中重试后成功 {len(add_retry)})· ❌失败 {len(add_fail)}**\n') o.append(f'- **控制/其他(按执行):✅PASS {na_pass} · ❌FAIL {na_fail} · ⏭SKIP {na_skip}**\n\n') +# BLE / WiFi 控制成功率(仅 Android 双轮有效;iOS SKIP_PROTO_NET 单轮则两行多为 0/N·A) +if (ble_p + ble_f + ble_sk + wifi_p + wifi_f + wifi_sk) > 0: + o.append('### BLE / WiFi 控制成功率(按用例最终结果,每阶段去重)\n') + o.append('> 区分依据:日志 `控制 PROTO=ble` / `控制 PROTO=wifi` 阶段标记;同名用例取该阶段最后一次结果,成功率=通过/(通过+失败)。\n\n') + o.append('| 协议 | 通过 | 失败 | 跳过 | 成功率 |\n|---|---|---|---|---|\n') + o.append(f'| BLE | {ble_p} | {ble_f} | {ble_sk} | {rate(ble_p, ble_f)} |\n') + o.append(f'| WiFi | {wifi_p} | {wifi_f} | {wifi_sk} | {rate(wifi_p, wifi_f)} |\n\n') + + o.append(f'## 添加用例({add_total})\n') o.append(f'\n### 🔁 重试后成功({len(add_retry)})— 计通过,稳定性需关注\n') for n, f in sorted(add_retry, key=lambda x: -x[1]): @@ -94,4 +124,5 @@ for c in sorted(fg, key=lambda k: -sum(fg[k].values())): open(OUT, 'w', encoding='utf-8').write(''.join(o)) print(f'已生成 {OUT}') print(f'添加 {add_total}: 首通{len(add_first)} 重试成功{len(add_retry)} 真失败{len(add_fail)} | ' - f'非添加 PASS{na_pass} FAIL{na_fail} SKIP{na_skip}') + f'非添加 PASS{na_pass} FAIL{na_fail} SKIP{na_skip} | ' + f'BLE {ble_p}/{ble_p+ble_f}({rate(ble_p,ble_f)}) WiFi {wifi_p}/{wifi_p+wifi_f}({rate(wifi_p,wifi_f)})') diff --git a/scripts/run-ios-adds.sh b/scripts/run-ios-adds.sh index 5059a2f..65ac017 100644 --- a/scripts/run-ios-adds.sh +++ b/scripts/run-ios-adds.sh @@ -7,6 +7,10 @@ export PLATFORM=ios SOAK_MODE=1 BUNDLE=com.wohand.wohand LOG=reports/ios-adds.log : > "$LOG" +# iOS 阶段起点:清空 .results.json,使 iOS 合并报告(iOSAdds/iOSAll)只含 iOS 结果。 +# 否则会 append 到 Android 阶段(run-p0-all 跑完未清)的记录后 → iOSAll 把 Android 的 ble/wifi 记录一并计入(总数虚高,如 267)。 +# 仅在此清(iOS 阶段开头);run-ios-controls 不清,以便 iOSAll 含「添加+控制」全程 iOS 结果。 +rm -f reports/.results.json # WDA 健康检查 + 恢复:/status 不通则重启 iproxy 端口转发(覆盖过夜端口转发断开;设备上 WDA 进程仍在即恢复)。 ensure_wda(){ @@ -52,8 +56,8 @@ ADDS=( "safety_alarm|npx vitest run tests/safety_alarm/safety_alarm_connect.test.ts" "water_detector|npx vitest run tests/water_detector/water_detector_connect.test.ts" "curtain|npx vitest run tests/curtain/curtain_connect.test.ts" - "curtain3_2b|CURTAIN_DEVICE='Curtain3 2B' npx vitest run tests/curtain/curtain_connect.test.ts" - "curtain3_2025|npx vitest run tests/curtain/curtain3_2025_connect.test.ts" + "curtain3_2b|CURTAIN_DEVICE='Curtain3 2B' CURTAIN_CALIBRATE=1 npx vitest run tests/curtain/curtain_connect.test.ts" + "curtain3_2025|CURTAIN_CALIBRATE=1 npx vitest run tests/curtain/curtain3_2025_connect.test.ts" "keypad_vision|npx vitest run tests/keypad/keypad_vision_connect.test.ts" "keypad_touch|npx vitest run tests/keypad/keypad_touch_connect.test.ts" # —— WiFi Hub 类 —— @@ -83,6 +87,7 @@ ADDS=( "urc|npx vitest run tests/urc/urc_connect.test.ts" # —— 扫地机 —— "robot_s1p|npx vitest run tests/robot/robot_s1p_connect.test.ts" + "robot_s10|npx vitest run tests/robot/robot_s10_connect.test.ts" # —— 摄像头/门铃添加(iOS 需扫码/校准无方案;账号已保留这两设备 → 多为已存在 SKIP,纳入为对齐 Android P0 口径)—— "doorbell|npx vitest run tests/camera/doorbell_connect.test.ts" "outdoor_ptc|npx vitest run tests/camera/outdoor_ptc_connect.test.ts" diff --git a/scripts/run-ios-controls.sh b/scripts/run-ios-controls.sh index 19b7be7..4e1bf48 100644 --- a/scripts/run-ios-controls.sh +++ b/scripts/run-ios-controls.sh @@ -7,7 +7,7 @@ # WDA 须已在 localhost:8100。 set -u cd "$(dirname "$0")/.." -export PLATFORM=ios SOAK_MODE=1 SKIP_PROTO_NET=1 +export PLATFORM=ios SOAK_MODE=1 SKIP_PROTO_NET=1 VITEST_RETRY=1 # 控制阶段:单个 it 失败自动重跑一次(对齐 Android) export LOCK_DEVICE="Lock 6D" LOCK_PRO_DEVICE="Lock Pro DE" BT_DEVICE="Blind Tilt 42" # PLUG_CTRL_DEVICE 不导出:plug 控制走 getDeviceName('plug') → 注册表实际名(iOS 当前 "Plug EU"),不写死 BUNDLE=com.wohand.wohand diff --git a/scripts/run-p0-all.sh b/scripts/run-p0-all.sh index 3711858..47f7f89 100755 --- a/scripts/run-p0-all.sh +++ b/scripts/run-p0-all.sh @@ -22,8 +22,8 @@ ADD_CMDS=( "npx vitest run tests/sensor/presence_sensor_connect.test.ts" "npx vitest run tests/remote/remote_connect.test.ts" "npx vitest run tests/curtain/curtain_connect.test.ts" - "CURTAIN_DEVICE='Curtain3 2B' npx vitest run tests/curtain/curtain_connect.test.ts" - "npx vitest run tests/curtain/curtain3_2025_connect.test.ts" + "CURTAIN_DEVICE='Curtain3 2B' CURTAIN_CALIBRATE=1 npx vitest run tests/curtain/curtain_connect.test.ts" + "CURTAIN_CALIBRATE=1 npx vitest run tests/curtain/curtain3_2025_connect.test.ts" "npx vitest run tests/water_detector/water_detector_connect.test.ts" "npx vitest run tests/hub/hub_matter_connect.test.ts" "npx vitest run tests/find_card/find_card_connect.test.ts" @@ -39,6 +39,7 @@ ADD_CMDS=( "npx vitest run tests/strip_light/neon_light_connect.test.ts" "npx vitest run tests/strip_light/strip_light_3_connect.test.ts" "npx vitest run tests/robot/robot_s1p_connect.test.ts" + "npx vitest run tests/robot/robot_s10_connect.test.ts" "npx vitest run tests/keypad/keypad_vision_connect.test.ts" "npx vitest run tests/keypad/keypad_touch_connect.test.ts" "npx vitest run tests/aihub/aihub_connect.test.ts -t '通过BLE添加'" @@ -126,6 +127,7 @@ echo "===== $(date '+%F %T') P0 全量回归开始 =====" echo "----- 复位:删除所有可重加设备(保留 锁/Blind Tilt + 摄像头/门铃,自动化无法重加)-----" # 锁需物理校准、摄像头需扫码,均跳过添加 → 绝不可删,否则对应控制/拉流用例整晚无设备失败。 # Lock Lite / Video Doorbell 同样保留(Doorbell 关键字覆盖门铃,Lock Lite 关键字覆盖锁丽特)。 +if [ -z "${CONTROL_ONLY:-}" ]; then RESET_EXCEPT=1 KEEP_DEVICES="Lock 6D,Lock Pro DE,Blind Tilt 42" KEEP_KEYWORDS="Cam,Doorbell,Lock Lite" npx ts-node scripts/reset-except.ts 2>&1 | tail -8 || echo "复位异常(继续跑)" echo "----- ADD 阶段 -----" @@ -149,6 +151,9 @@ if [ ${#FAILED[@]} -gt 0 ]; then done echo "重试后仍失败 ${#STILL[@]} 个(已放弃,继续后续用例)" fi +else + echo "----- CONTROL_ONLY=1:跳过复位+添加阶段,直接控制现有设备 -----" +fi # 添加后:打开 Action Panel Settings 所有开关 → 点设备卡片才弹「快捷动作浮层」(灯/加湿器卡片控制依赖; # 新加设备该开关默认可能 OFF → 否则点卡片进功能页致控制失败)。必须在 CONTROL 之前。 @@ -160,6 +165,7 @@ echo "----- CONTROL 阶段(BLE + WiFi 各跑一遍,分别打 15975/15974 锚点) run_control_pass() { local label="$1" cmd local failed=() + export VITEST_RETRY=1 # 控制阶段:单个 it 失败自动重跑一次(用例级,见 vitest.config.ts);添加阶段不受影响 for cmd in "${CONTROL[@]}"; do echo "--- $(date +%T) [$label] $cmd ---" run_one "$cmd" || failed+=("$cmd") @@ -171,11 +177,13 @@ run_control_pass() { run_one "$cmd" && echo "[CTRL-RETRY-OK][$label] $cmd" || echo "[CTRL-RETRY-GIVEUP][$label] $cmd" done fi + unset VITEST_RETRY } -# BLE 阶段:关手机 WiFi 强制 BLE 直连控制 -adb shell svc bluetooth enable >/dev/null 2>&1 -adb shell svc wifi disable >/dev/null 2>&1; sleep 5 +# BLE 阶段:切"仅 BLE"并校验设备已连上(关 WiFi + 复位蓝牙 + 下拉刷新 + 响应度校验 + 重试)。 +# 根治"中途切 BLE 后首页卡片全无响应 → BLE 控制整片失败"。失败不阻塞,后续控制照跑。 +echo "----- BLE 控制前:切 BLE 并校验设备连上(下拉刷新+重试)-----" +PLATFORM=android npx ts-node scripts/switch-ble.ts 2>&1 | tail -6 || echo "切 BLE 校验异常(继续跑)" export PROTO=ble echo "===== 控制 PROTO=ble(手机 WiFi 已关)=====" run_control_pass ble @@ -190,9 +198,14 @@ unset PROTO restore_wifi # 进平台/账号前再确保 WiFi 在线 echo "----- 平台 + 账号阶段 -----" +if [ -z "${CONTROL_ONLY:-}" ]; then for cmd in "${TAIL[@]}"; do echo "--- $(date +%T) $cmd ---"; run_one "$cmd" || true; done +else + echo "----- CONTROL_ONLY=1:跳过平台/账号阶段 -----" +fi echo "----- 生成合并报告 -----" npx ts-node scripts/gen-combined.ts P0_All_Android +python3 scripts/gen-p0-report.py reports/p0-all.log reports/P0_Android_真实结果.md 2>&1 | tail -2 # 含 BLE/WiFi 控制成功率表 echo "===== $(date '+%F %T') 完成 =====" } >> "$LOG" 2>&1 diff --git a/scripts/switch-ble.ts b/scripts/switch-ble.ts new file mode 100644 index 0000000..fbbd8a4 --- /dev/null +++ b/scripts/switch-ble.ts @@ -0,0 +1,21 @@ +/** + * 切到"仅 BLE"模式并校验设备已连上(供 run-p0-all.sh BLE 控制阶段前调用)。 + * 关 WiFi → 复位蓝牙 → 下拉刷新首页 → 校验响应度,大部分无响应则重试整套切换。 + * 用法: PLATFORM=android npx ts-node scripts/switch-ble.ts 失败不阻塞(后续控制照跑)。 + */ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { switchToBleAndVerify } from '../utils/common/network.helper'; // 直接从模块导入,避开 barrel 的 vitest 依赖 + +async function main() { + const driver = createDriver(); + await driver.createSession(); + try { + await switchToBleAndVerify(driver, Number(process.env.BLE_SWITCH_RETRIES) || 3); + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('switch-ble ERR(非致命):', e.message); process.exit(0); }); diff --git a/tests/air_purifier/air_purifier_card.test.ts b/tests/air_purifier/air_purifier_card.test.ts index b3ee1a2..fe1dd81 100644 --- a/tests/air_purifier/air_purifier_card.test.ts +++ b/tests/air_purifier/air_purifier_card.test.ts @@ -1,8 +1,8 @@ -import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +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 } from '../../utils/common'; +import { sleep, waitForSource, scrollHomeToTopIOS, logVisibleDeviceCards } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -19,63 +19,119 @@ const ADD_ANCHOR = '[P0]'; // TODO: 待补 Air Purifier 控制 ONES 编号 describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => { let driver: DeviceDriver; let reporter: TestReporter; + let W = 1080, H = 2400; + const isIOS = () => driver.platform === 'ios'; beforeAll(async () => { driver = createDriver(); await driver.createSession(); reporter = new TestReporter('AirPurifier_Card', driver.platform.toUpperCase()); + try { const s = await driver.getWindowSize(); if (s?.width) { W = s.width; H = s.height; } } catch { /* 默认 1080x2400 */ } }); + // iOS 浮层是底部 sheet(android back 关不掉),需点顶部暗区收起。但**无脑点会误触首页卡**—— + // 每条 it 开头根本没浮层,(W/2,H*0.12) 正落在首页顶部卡片上 → 误进设备页。 + // 改为**条件收起**:仅当 findPowerBtnIOS 检测到浮层电源键(=浮层确实开着)时才点暗区,否则跳过。 + async function dismissSheetIfOpenIOS(): Promise { + if (!isIOS()) return; + const p = await findPowerBtnIOS().catch(() => null); + if (!p) return; // 没浮层 → 不点,根除首页误触 + await driver.tap(Math.round(W * 0.5), Math.round(H * 0.12)).catch(() => {}); + await sleep(600); + } + beforeEach(async () => { + await dismissSheetIfOpenIOS(); await driver.dismissPopupIfPresent(); await driver.goBackToHomepage(); await driver.dismissPopupIfPresent(); }); + afterEach(async () => { + await dismissSheetIfOpenIOS(); + await driver.dismissPopupIfPresent().catch(() => {}); + }); + afterAll(async () => { reporter.generate(); await driver.destroySession(); }); async function findCard(): Promise { - for (let r = 0; r < 8; r++) { - const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); - if (el) return el; - await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); - await sleep(900); + // 同一文件内多 it 间不重启 → 先快速回顶(scrollHomeToTopIOS),再从顶滚找(页面稳定时甩动不误点)。 + if (isIOS()) await scrollHomeToTopIOS(driver).catch(() => {}); + for (let r = 0; r < 14; r++) { + const el = isIOS() + ? await driver.findElementRaw('predicate string', `name CONTAINS "${CARD_KEYWORD}" AND type == "XCUIElementTypeCell"`).catch(() => null) + : await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); + if (el) { + const rect = await driver.getElementRect(el).catch(() => null); + if (rect && rect.width > 0 && rect.y > H * 0.06 && rect.y + rect.height < H * 0.92) return el; // 屏内才用(WDA 会返回屏外元素) + } + await driver.scrollDown(500); await sleep(800); } + await logVisibleDeviceCards(driver, `找不到${CARD_KEYWORD}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`); } - // 点卡片中心 → 弹快捷控制浮层 + // 点卡片中心 → 弹快捷控制浮层(未弹回首页重试) async function openCardPopup(): Promise { - 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); - const ok = await waitForSource(driver, 'More', 6000); - if (!ok) throw new Error('卡片快捷控制浮层未弹出'); + 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('卡片快捷控制浮层未弹出'); + } + + // 动态找电源键(iOS):浮层里**最大的居中方键**(实测 72×72,正中;上方有 48px 干扰键需排除)。 + 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; + 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; + cands.sort((a, b) => b.w - a.w || a.cy - b.cy); // 取最大(电源键 72 > 控制键 48);同宽取最靠上 + return { cx: cands[0].cx, cy: cands[0].cy }; } - // 无文案电源圆键:浮层是固定底部弹层,电源键在屏中线、状态字下方/模式行上方。 - // 实测 1080x2400 电源键中心 (540, 1247) ≈ (w/2, h*0.52)。 async function tapPower(): Promise { - let w = 1080, h = 2400; - try { const s: any = await (driver as any).getWindowSize?.(); if (s?.width) { w = s.width; h = s.height; } } catch { /* 默认 1080x2400 */ } - const px = Math.round(w / 2); - const py = Math.round(h * 0.52); + if (isIOS()) { + const p = await findPowerBtnIOS(); + const px = p ? p.cx : Math.round(W / 2); + const py = p ? p.cy : Math.round(H * 0.52); + console.log(`点电源键(iOS) ${px},${py}${p ? '' : ' [回退]'}`); + return driver.tap(px, py); + } + const px = Math.round(W / 2), py = Math.round(H * 0.52); console.log(`点电源键 (${px}, ${py})`); await driver.tap(px, py); } - // 当前是否关机:取首个含设备名(首页卡)后的状态字 —— 首页卡副标题镜像设备态:关=Off,开=模式/数值。 + // 当前是否关机:iOS 读浮层状态(name/label,以 On/Off 开头);Android 读 text= 卡名后状态字。 async function isOff(): Promise { const src = await driver.getSource(); - const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); - const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); - const status = i >= 0 ? (texts[i + 1] || '') : ''; + let status = ''; + if (isIOS()) { + const vals = Array.from(src.matchAll(/(?:name|label|value)="([^"]+)"/g)).map((m) => m[1].trim()); + status = vals.find((t) => /^(On|Off)\b/.test(t)) || vals.find((t) => t.includes(CARD_KEYWORD)) || ''; + } else { + const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); + status = i >= 0 ? (texts[i + 1] || '') : ''; + } console.log(`状态字="${status}"`); - return /^Off$|Offline|待机/.test(status); + return /^Off$|^Off\b|Offline|待机/.test(status); } async function pollState(wantOff: boolean, ms = 15000): Promise { @@ -87,22 +143,6 @@ describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => { return (await isOff()) === wantOff; } - it(`${ADD_ANCHOR} 首页找到空净卡片`, async () => { - const start = Date.now(); - try { - const cardId = await findCard(); - const rect = await driver.getElementRect(cardId); - const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; - console.log(`${CARD_KEYWORD} ${detail}`); - expect(rect.width).toBeGreaterThan(0); - reporter.record(`${ADD_ANCHOR} 首页找到空净卡片`, 'PASS', Date.now() - start, detail); - } 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 { diff --git a/tests/ceiling_light/ceiling_light_card.test.ts b/tests/ceiling_light/ceiling_light_card.test.ts index ec7b584..92f70f8 100644 --- a/tests/ceiling_light/ceiling_light_card.test.ts +++ b/tests/ceiling_light/ceiling_light_card.test.ts @@ -61,24 +61,6 @@ describe('CeilingLight Card - 首页卡片操作', () => { return el; } - it('首页找到吸顶灯卡片', async () => { - const start = Date.now(); - try { - const cardId = await findDeviceCard(); - const rect = await driver.getElementRect(cardId); - const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; - console.log(`${deviceName} ${detail}`); - expect(rect.width).toBeGreaterThan(0); - expect(rect.height).toBeGreaterThan(0); - - reporter.record('首页找到吸顶灯卡片', 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('首页找到吸顶灯卡片', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - it('首页开关吸顶灯', async () => { const start = Date.now(); try { diff --git a/tests/ceiling_light/ceiling_light_pro_connect.test.ts b/tests/ceiling_light/ceiling_light_pro_connect.test.ts index 8c160fb..dc5c84e 100644 --- a/tests/ceiling_light/ceiling_light_pro_connect.test.ts +++ b/tests/ceiling_light/ceiling_light_pro_connect.test.ts @@ -11,6 +11,7 @@ import { powerCycle, configureHubWifi, setupResilientHooks, + resetBluetooth, } from '../../utils/common'; import { getWifiCredentials } from '../../config/wifi.config'; import * as dotenv from 'dotenv'; @@ -51,6 +52,9 @@ describe('CeilingLight Pro Connect - 添加吸顶灯Pro(两次点按配对 + WiF return; } + // BLE 配对前复位手机蓝牙(关→开),根治偶发 "FAIL: connection timeout"(重跑才过 = 蓝牙栈陈旧) + await resetBluetooth(driver); + const result = await addDeviceViaBLE(driver, { categoryName: CATEGORY, deviceKeyword: SCAN_KEYWORD, diff --git a/tests/ceiling_light/eave_light_control.test.ts b/tests/ceiling_light/eave_light_control.test.ts index 9c1d5f8..ccca70a 100644 --- a/tests/ceiling_light/eave_light_control.test.ts +++ b/tests/ceiling_light/eave_light_control.test.ts @@ -2,7 +2,7 @@ import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect } from import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; -import { sleep, waitForSource, getAddedDevice } from '../../utils/common'; +import { sleep, waitForSource, getAddedDevice, scrollHomeToTopIOS, logVisibleDeviceCards } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -12,10 +12,11 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') }); // 浮层:状态字(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_REG=注册 key,如 'stripLight'→"Strip Light SB"), -// 不写死;LIGHT_CARD 作兜底(注册表无该 key 时用)。方案A:8 个灯各注册独立 key。 +// 灯卡片关键词:**优先用 LIGHT_CARD(干净品类名,如 "Permanent Outdoor Lights")** 做 CONTAINS 匹配 —— +// 注册表存的名可能含实时状态(如 "...66On30% Color",状态会变 → CONTAINS 永远匹配不到)。LIGHT_CARD 各灯唯一、不串台。 +// 注册名仅作兜底(注册表存的若干净、且无 LIGHT_CARD 时用)。 const LIGHT_REG = process.env.LIGHT_REG; -const CARD_KEYWORD = (LIGHT_REG && getAddedDevice(LIGHT_REG)) || process.env.LIGHT_CARD || 'Permanent Outdoor Lights'; +const CARD_KEYWORD = process.env.LIGHT_CARD || (LIGHT_REG && getAddedDevice(LIGHT_REG)) || 'Permanent Outdoor Lights'; const ADD_ANCHOR = '[P0]'; // TODO: 待补 屋檐灯/灯类 控制 ONES 编号 describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { @@ -59,8 +60,9 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { : (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null)); } async function findCard(): Promise { - // 用 driver.scrollDown(实测稳定滚动、不误点卡片);自定义 swipe 起手在卡片上会被当成点击(误开摄像头)。 - // 复位到顶:beforeEach 的 goBackToHomepage 已回首页顶部。 + // 跨控制文件:正式跑 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) { @@ -70,6 +72,7 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { await driver.scrollDown(500); await sleep(800); } + await logVisibleDeviceCards(driver, `找不到${CARD_KEYWORD}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`); } @@ -93,18 +96,19 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { // 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 }[] = []; + 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; - // 电源圆键:近正方形(|w-h|小)、~50-120px、水平居中、在浮层区(y>H*0.4)。定时按钮(1hr等)不居中/更小,背景卡片按钮偏侧 → 排除。 - 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.16 && 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) }); + // 电源圆键:近正方形、~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; - cands.sort((a, b) => a.cy - b.cy); // 取最靠上的(电源键在滑条/定时之上) - return cands[0]; + // 取**宽度最大**的(电源键 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 { if (isIOS()) { @@ -250,22 +254,6 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { } } - it(`${ADD_ANCHOR} 首页找到灯卡片`, async () => { - const start = Date.now(); - try { - const cardId = await findCard(); - const rect = await driver.getElementRect(cardId); - const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; - console.log(`${CARD_KEYWORD} ${detail}`); - expect(rect.width).toBeGreaterThan(0); - reporter.record(`${ADD_ANCHOR} 首页找到灯卡片`, 'PASS', Date.now() - start, detail); - } 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 { diff --git a/tests/curtain/blind_tilt_control.test.ts b/tests/curtain/blind_tilt_control.test.ts index bcd17f5..5f5c020 100644 --- a/tests/curtain/blind_tilt_control.test.ts +++ b/tests/curtain/blind_tilt_control.test.ts @@ -2,7 +2,7 @@ import { describe, it, beforeAll, beforeEach, afterAll, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; -import { sleep, scrollToAndTap, onesCtrl, waitForHomeReady } from '../../utils/common'; +import { sleep, scrollToAndTap, onesCtrl, waitForHomeReady, waitForSource } from '../../utils/common'; import { setupResilientHooks } from "../../utils/common"; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -60,6 +60,8 @@ describe('Blind Tilt Control - 百叶帘控制(首页快捷弹窗)', () => { } throw new Error(`找不到${deviceName}卡片`); } + // Android:scrollToAndTap 已做「滚动定位 + 点击前重取新鲜句柄 + stale 重取」,不会再因 RN 刷新 stale 报找不到。 + await waitForSource(driver, deviceName, 12000).catch(() => false); // 冷启动等卡片渲染出来再滚 const ok = await scrollToAndTap(driver, deviceName); if (!ok) throw new Error(`找不到${deviceName}卡片`); await sleep(2500); @@ -109,7 +111,7 @@ describe('Blind Tilt Control - 百叶帘控制(首页快捷弹窗)', () => { return st; } - it(`${CTRL_BT} 向下关闭(Close Down)`, { timeout: 90000 }, async () => { + it(`${CTRL_BT} 向下关闭(Close Down)`, { timeout: 150000 }, async () => { const start = Date.now(); try { await openBtPopup(); @@ -125,7 +127,7 @@ describe('Blind Tilt Control - 百叶帘控制(首页快捷弹窗)', () => { } }); - it(`${CTRL_BT} 完全打开(Fully open)`, { timeout: 90000 }, async () => { + it(`${CTRL_BT} 完全打开(Fully open)`, { timeout: 150000 }, async () => { const start = Date.now(); try { await openBtPopup(); diff --git a/tests/hub/relay_switch_connect.test.ts b/tests/hub/relay_switch_connect.test.ts index 8898202..57836ec 100644 --- a/tests/hub/relay_switch_connect.test.ts +++ b/tests/hub/relay_switch_connect.test.ts @@ -8,6 +8,7 @@ import { findDeviceNameOnHomepage, saveAddedDevice, configureHubWifi, + resetBluetooth, } from '../../utils/common'; import { getWifiCredentials } from '../../config/wifi.config'; import * as dotenv from 'dotenv'; @@ -51,6 +52,9 @@ describe('RelaySwitch Connect - 添加Relay Switch(串口配对 + WiFi配网)', return; } + // BLE 配对前复位手机蓝牙(关→开),根治偶发配对/连接不上 + await resetBluetooth(driver); + const result = await addDeviceWithSerialPairing(driver, { categoryName: CATEGORY, categoryScrollHint: 0, diff --git a/tests/humidifier/humidifier2_card.test.ts b/tests/humidifier/humidifier2_card.test.ts index c92197b..7de508a 100644 --- a/tests/humidifier/humidifier2_card.test.ts +++ b/tests/humidifier/humidifier2_card.test.ts @@ -2,7 +2,7 @@ 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, waitForSource } from '../../utils/common'; +import { sleep, waitForSource, logVisibleDeviceCards } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -37,12 +37,17 @@ describe('Humidifier2 Card - 首页卡片控制(Evaporative Humidifier)', () => }); async function findCard(): Promise { - for (let r = 0; r < 8; r++) { + const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 })); + for (let r = 0; r < 14; r++) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); - if (el) return el; - await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); - await sleep(900); + if (el) { + const rect = await driver.getElementRect(el).catch(() => null); + if (rect && rect.width > 0 && rect.y > winH * 0.06 && rect.y + rect.height < winH * 0.92) return el; // 屏内才用(WDA 会返回屏外元素) + } + await driver.scrollDown(500); await sleep(800); // 平台无关(iOS 旧的 swipe(540,..) x 在屏外无效) + if (driver.platform === 'ios' && !(await driver.isOnHomepage().catch(() => true))) { await driver.goBackToHomepage().catch(() => {}); await sleep(800); } // 误点离开自愈 } + await logVisibleDeviceCards(driver, `找不到${CARD_KEYWORD}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`); } @@ -98,22 +103,6 @@ describe('Humidifier2 Card - 首页卡片控制(Evaporative Humidifier)', () => return (await isOff()) === wantOff; } - it(`${ADD_ANCHOR} 首页找到加湿器2卡片`, async () => { - const start = Date.now(); - try { - const cardId = await findCard(); - const rect = await driver.getElementRect(cardId); - const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; - console.log(`${CARD_KEYWORD} ${detail}`); - expect(rect.width).toBeGreaterThan(0); - reporter.record(`${ADD_ANCHOR} 首页找到加湿器2卡片`, 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record(`${ADD_ANCHOR} 首页找到加湿器2卡片`, 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - it(`${ADD_ANCHOR} 首页卡片开机`, async () => { const start = Date.now(); try { diff --git a/tests/plug/plug_connect.test.ts b/tests/plug/plug_connect.test.ts index 28318ce..df66de4 100644 --- a/tests/plug/plug_connect.test.ts +++ b/tests/plug/plug_connect.test.ts @@ -6,6 +6,7 @@ import { sleep, addDeviceViaBLE, isDeviceOnHomepage, + ensurePlugOn, } from '../../utils/common'; import { getDeviceName } from '../../config/device.config'; import * as dotenv from 'dotenv'; @@ -38,6 +39,8 @@ describe('Plug Connect - 添加Plug设备', () => { const alreadyExists = await isDeviceOnHomepage(driver, deviceName); if (alreadyExists) { console.log(`${deviceName}已在首页,跳过重新添加`); + // 供电前提:灯/灯泡插在该插座上,确保插座为 ON(下游灯才有电配对/控制)。失败不影响添加结果。 + await ensurePlugOn(driver, deviceName).catch((e) => console.log(`ensurePlugOn 跳过(非致命): ${e.message}`)); reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`); return; } @@ -53,6 +56,9 @@ describe('Plug Connect - 添加Plug设备', () => { }); expect(result).toBe(true); + // 供电前提:灯/灯泡插在该插座上,确保插座为 ON(下游灯才有电配对/控制)。失败不影响添加结果。 + await ensurePlugOn(driver, deviceName).catch((e) => console.log(`ensurePlugOn 跳过(非致命): ${e.message}`)); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); } catch (e: any) { diff --git a/tests/plug/plug_mini_connect.test.ts b/tests/plug/plug_mini_connect.test.ts index 6297089..d1b0c96 100644 --- a/tests/plug/plug_mini_connect.test.ts +++ b/tests/plug/plug_mini_connect.test.ts @@ -8,6 +8,7 @@ import { findDeviceNameOnHomepage, saveAddedDevice, configureHubWifi, + ensurePlugOn, onesAdd, } from '../../utils/common'; import { getWifiCredentials } from '../../config/wifi.config'; @@ -50,6 +51,8 @@ describe('Plug Mini Connect - 添加Plug Mini设备(串口配对 + WiFi配网)', if (existing) { console.log(`${existing}已在首页,跳过重新添加`); saveAddedDevice('plug', existing); + // 供电前提:灯/灯泡插在该插座上,确保插座为 ON(下游灯才有电配对/控制)。失败不影响添加结果。 + await ensurePlugOn(driver, existing).catch((e) => console.log(`ensurePlugOn 跳过(非致命): ${e.message}`)); reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'SKIP', Date.now() - start, `${existing}已存在`); return; } @@ -80,6 +83,9 @@ describe('Plug Mini Connect - 添加Plug Mini设备(串口配对 + WiFi配网)', }); expect(result).toBe(true); + // 供电前提:灯/灯泡插在该插座上,确保插座为 ON(下游灯才有电配对/控制)。失败不影响添加结果。 + await ensurePlugOn(driver, deviceName).catch((e) => console.log(`ensurePlugOn 跳过(非致命): ${e.message}`)); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'PASS', Date.now() - start, `${deviceName}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); } catch (e: any) { diff --git a/tests/robot/robot_s10_connect.test.ts b/tests/robot/robot_s10_connect.test.ts index a295eb9..b37cf61 100644 --- a/tests/robot/robot_s10_connect.test.ts +++ b/tests/robot/robot_s10_connect.test.ts @@ -4,12 +4,10 @@ import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; import { sleep, - addDeviceViaBLE, + addDeviceWithSerialPairing, findDeviceNameOnHomepage, saveAddedDevice, - enterPairingMode, configureHubWifi, - setupResilientHooks, } from '../../utils/common'; import { getWifiCredentials } from '../../config/wifi.config'; import * as dotenv from 'dotenv'; @@ -17,17 +15,16 @@ import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); -// 扫地机 S10:WiFi 产品。流程同 K20+(空净式):入口「floor cleaning robot s10」→ 过 Next → -// 长按 ch13+14 5s 进添加模式 → Connect Device → WiFi 配网(Next 后弹"要连接至设备吗"点连接)。 -const CATEGORY = process.env.ROBOT_S10_CATEGORY || 'floor cleaning robot s10'; -const SCAN_KEYWORD = process.env.ROBOT_S10_SCAN || 'S10'; +// 扫地机 S10:**与水浸同款添加流程**(串口配对 + WiFi 配网)。 +// 入口品类「Floor Cleaning Robot S10」→ 长按 ch17+18 5s 进添加模式 → Connect Device → Configure Wi-Fi 配网。 +const CATEGORY = process.env.ROBOT_S10_CATEGORY || 'Floor Cleaning Robot S10'; +const deviceName = process.env.ROBOT_S10_DEVICE || 'S10'; const NAME_PATTERN = process.env.ROBOT_S10_PATTERN || 'S10[^"]*\\s*[0-9A-Za-z]{0,4}'; -const ADD_ANCHOR = '[P0]'; // TODO: 待补 扫地机 S10 添加 ONES 编号 +const ADD_ANCHOR = '[P0][ONES:78369]'; // 扫地机 S10 添加(Robot S10) -describe('Robot S10 Connect - 添加扫地机S10(两键同按 + WiFi配网)', () => { +describe('Robot S10 Connect - 添加扫地机S10(串口配对 + WiFi配网,同水浸)', () => { let driver: DeviceDriver; let reporter: TestReporter; - setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -52,34 +49,31 @@ describe('Robot S10 Connect - 添加扫地机S10(两键同按 + WiFi配网)', () return; } - const result = await addDeviceViaBLE(driver, { + const result = await addDeviceWithSerialPairing(driver, { categoryName: CATEGORY, - deviceKeyword: SCAN_KEYWORD, - scanTimeout: 30000, - skipScanStep: true, - // 机器人向导(同 S1 Plus):第一步 Next → "Step 2: Enter pairing mode" 按 ch13+14(Home+Mode) 5s → Connect Device - preSelectSteps: async (d) => { - const n1 = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); - if (n1) { await d.tapElement(n1); await sleep(2000); } // 第一步 → 第二步 - await enterPairingMode('robot_s10').catch((e) => console.log(`relay ch13+14 配对失败: ${e.message}`)); - await sleep(2500); - for (const t of ['Connect device', 'Connect Device', 'Connect Devices', 'Next']) { - const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); - if (b) { await d.tapElement(b); break; } + categoryScrollHint: 0, + deviceKeyword: deviceName, + relayDevice: 'robot_s10', // ch17+18 同时长按 5s(config/relay.config) + registryCategory: 'robot', + namePattern: NAME_PATTERN, + // 比水浸多一步向导:选品类后是「Step 1: Power on」→ 点 Next 到「Step 2: Enter pairing mode」,helper 在此按 ch17+18。 + beforePairing: async (d) => { + for (let i = 0; i < 8; i++) { + if (/Step 2|Enter pairing mode|Connect Device/i.test(await d.getSource())) break; // 已到第二步 + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); + if (n) { await d.tapElement(n); await sleep(2000); } else break; } - await sleep(3000); }, - // 搜到设备后点 Configure network 进 WiFi 配网(configureHubWifi 已处理"要连接至设备吗"弹框) - postConnectionSteps: async (d) => { - const cn = await d.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Configure network")').catch(() => null); - if (cn) { await d.tapElement(cn); await sleep(3000); } + // 连接后(同水浸):① 点 "Connect Device" 启动连接 ② 进 Configure Wi-Fi 页 → configureHubWifi 填 SSID/密码 + postConnectSteps: async (d) => { + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect device")'); + if (btn) { await d.tapElement(btn); await sleep(2500); } await configureHubWifi(d, wifi); }, - wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], connectionKeywords: [ - 'found', 'Configure network', 'Enter Wi-Fi', 'Enter the password', - 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', - 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + 'Connect Device', 'Configure Wi-Fi', 'Connecting', 'Discover device', + 'Pick a room', 'Done', 'added successfully', 'Added successfully', 'Initial Setup', ], }); expect(result).toBe(true); diff --git a/tests/strip_light/strip_light_card.test.ts b/tests/strip_light/strip_light_card.test.ts index 27c785d..56f4c49 100644 --- a/tests/strip_light/strip_light_card.test.ts +++ b/tests/strip_light/strip_light_card.test.ts @@ -61,24 +61,6 @@ describe('StripLight Card - 首页卡片操作', () => { return el; } - it('首页找到灯带卡片', async () => { - const start = Date.now(); - try { - const cardId = await findDeviceCard(); - const rect = await driver.getElementRect(cardId); - const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; - console.log(`${deviceName} ${detail}`); - expect(rect.width).toBeGreaterThan(0); - expect(rect.height).toBeGreaterThan(0); - - reporter.record('首页找到灯带卡片', 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('首页找到灯带卡片', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - it('首页开关灯带', async () => { const start = Date.now(); try { diff --git a/tests/water_detector/water_detector_control.test.ts b/tests/water_detector/water_detector_control.test.ts index 9467f3d..14e8ecb 100644 --- a/tests/water_detector/water_detector_control.test.ts +++ b/tests/water_detector/water_detector_control.test.ts @@ -37,15 +37,24 @@ describe('Water Detector Control - 功能页操作', () => { }); async function enterControlPage(): Promise { - let el = await driver.findElementRaw('name', deviceName); - if (!el) { - await driver.scrollDown(250); - await sleep(1000); - el = await driver.findElementRaw('name', deviceName); + await driver.goBackToHomepage().catch(() => {}); + await sleep(800); + const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 })); + // 滚到找到(原只滚1次 + 'name' 精确匹配;实测设备在第4屏 → 够不到)。CONTAINS + 屏内校验 + 滚动找。 + for (let r = 0; r < 14; r++) { + const el = driver.platform === 'ios' + ? await driver.findElementRaw('predicate string', `type == "XCUIElementTypeCell" AND name CONTAINS "${deviceName}"`).catch(() => null) + : await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${deviceName}")`).catch(() => null); + if (el) { + const rect = await driver.getElementRect(el).catch(() => null); + if (rect && rect.width > 0 && rect.y > winH * 0.06 && rect.y + rect.height < winH * 0.92) { + await driver.tap(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2)); + await sleep(3000); return; + } + } + await driver.scrollDown(500); await sleep(800); } - if (!el) throw new Error(`找不到${deviceName}卡片`); - await driver.tapElement(el); - await sleep(3000); + throw new Error(`找不到${deviceName}卡片`); } it('切换检测模式', async () => { diff --git a/utils/common/auth.helper.ts b/utils/common/auth.helper.ts index a672697..58f29f9 100644 --- a/utils/common/auth.helper.ts +++ b/utils/common/auth.helper.ts @@ -420,7 +420,22 @@ export async function deleteAccount(driver: DeviceDriver, params: { token: strin await tapText(driver, 'Next'); await sleep(2000); // Account Deletion Information 页推进 await tapText(driver, 'Confirm'); await sleep(2500); // 确认框 Cancel|Confirm → Confirm } else { - if (!(await tapId(driver, 'destroyBto'))) throw new Error('未找到 destroyBto 销毁按钮'); + // 优先按原 destroyBto id;找不到 → 打印页面按钮诊断 + 滚到底重试 + 文案兜底(App 改版/按钮在页底时仍可注销)。 + let pushed = await tapId(driver, 'destroyBto'); + if (!pushed) { + const src = await driver.getSource(); + const btns = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t && t.length < 24))]; + console.log(`[DEL诊断] destroyBto 未找到,Account Deletion 页按钮: ${JSON.stringify(btns.slice(0, 20))}`); + await driver.scrollDown(600).catch(() => {}); + await sleep(800); + pushed = await tapId(driver, 'destroyBto'); + if (!pushed) { + for (const b of ['Delete Account', 'Confirm', 'Delete', 'Continue', 'Next', '注销账号', '确认注销']) { + if (await tapText(driver, b)) { pushed = true; break; } + } + } + } + if (!pushed) throw new Error('未找到 destroyBto 销毁按钮(已试滚动+文案兜底,见[DEL诊断]按钮列表)'); await sleep(2000); await tapText(driver, 'OK'); // 确认框 "Delete Account | Cancel | OK" → OK } diff --git a/utils/common/device-settings.helper.ts b/utils/common/device-settings.helper.ts index 5df008a..baf678d 100644 --- a/utils/common/device-settings.helper.ts +++ b/utils/common/device-settings.helper.ts @@ -1,5 +1,5 @@ import { DeviceDriver } from '../../drivers/types'; -import { sleep, waitForElement, waitForSource, scrollUntilFound } from './element.helper'; +import { sleep, waitForElement, waitForSource, scrollUntilFound, logVisibleDeviceCards } from './element.helper'; export async function enterDeviceSettings(driver: DeviceDriver, deviceKeyword: string): Promise { let deviceEl: string | null = null; @@ -35,11 +35,20 @@ export async function enterDeviceSettings(driver: DeviceDriver, deviceKeyword: s export async function scrollToAndTap(driver: DeviceDriver, name: string): Promise { if (driver.platform === 'android') { - const el = await driver.findElementRaw('-android uiautomator', - `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("${name}"))`); - if (!el) return false; - await driver.tapElement(el); - return true; + // scrollIntoView 把目标滚进可视区(返回的句柄**不复用**,只为定位);点击前按名称重新取新鲜句柄再点。 + // RN 自绘首页状态刷新会让滚动时拿的旧句柄失效(tapElement 抛 "does not exist in DOM anymore"): + // find→tap 之间又 stale 就重取,最多 4 次。选择器恒为 text(名称),保证点的是目标卡(不退化为坐标)。 + const sel = `new UiSelector().text("${name}")`; + const scrolled = await driver.findElementRaw('-android uiautomator', + `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(${sel})`).catch(() => null); + if (!scrolled) { await logVisibleDeviceCards(driver, `scrollToAndTap 找不到"${name}"`); return false; } + for (let i = 0; i < 4; i++) { + const el = await driver.findElementRaw('-android uiautomator', sel).catch(() => null); + if (!el) { await sleep(400); continue; } + try { await driver.tapElement(el); return true; } + catch { await sleep(400); } // 句柄在 find→tap 间 stale → 丢弃重取 + } + return false; } // iOS @@ -56,7 +65,7 @@ export async function scrollToAndTap(driver: DeviceDriver, name: string): Promis await sleep(500); el = await driver.findElementRaw('name', name); } - if (!el) return false; + if (!el) { await logVisibleDeviceCards(driver, `scrollToAndTap 找不到"${name}"`); return false; } const rect = await driver.getElementRect(el); await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); return true; diff --git a/utils/common/element.helper.ts b/utils/common/element.helper.ts index 1857480..f61a756 100644 --- a/utils/common/element.helper.ts +++ b/utils/common/element.helper.ts @@ -4,6 +4,31 @@ export function sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } +/** + * 诊断:打印当前页面上的设备卡名(找不到目标设备时调,方便定位 = 真没这设备,还是名字/状态变了/还没渲染)。 + * Android 取 nameText/nameTextMeter 节点文本;iOS 取 XCUIElementTypeCell 的 name。失败只告警,不抛。 + */ +export async function logVisibleDeviceCards(driver: DeviceDriver, context: string): Promise { + try { + const src = await driver.getSource(); + let names: string[] = []; + if (driver.platform === 'android') { + for (const n of src.split('<')) { + if (!/resource-id="[^"]*\/(nameText|nameTextMeter)"/.test(n)) continue; + const t = (n.match(/text="([^"]*)"/) || [])[1]; + if (t && t.trim()) names.push(t.trim()); + } + } else { + names = (src.match(/XCUIElementTypeCell[^>]*?name="([^"]+)"/g) || []) + .map((m) => (m.match(/name="([^"]+)"/) || [])[1]).filter(Boolean) as string[]; + } + names = [...new Set(names)]; + console.log(`[诊断]${context} 当前页设备卡(${names.length}): ${names.slice(0, 50).join(' | ') || '(无)'}`); + } catch (e: any) { + console.log(`[诊断]${context} 取设备卡列表失败: ${e.message}`); + } +} + export async function waitForElement( driver: DeviceDriver, using: string, diff --git a/utils/common/index.ts b/utils/common/index.ts index 653550d..d06827e 100644 --- a/utils/common/index.ts +++ b/utils/common/index.ts @@ -13,6 +13,7 @@ export * from './ones-anchor.helper'; export * from './zendesk.helper'; export * from './serial_controller'; export * from './relay.helper'; +export * from './plug-power.helper'; export * from './device-registry.helper'; export * from './qr.helper'; export * from './curtain.helper'; diff --git a/utils/common/network.helper.ts b/utils/common/network.helper.ts index 43ac544..3a20fa3 100644 --- a/utils/common/network.helper.ts +++ b/utils/common/network.helper.ts @@ -119,3 +119,72 @@ export async function applyProtoNetwork(driver: DeviceDriver, proto: 'ble' | 'wi } await sleep(2000); } + +/** + * 关→开手机蓝牙,复位蓝牙栈。BLE 扫描/配对偶发卡死(吸顶灯 Pro "FAIL: connection timeout"、 + * 重跑就过)的根治。用于:跑 BLE 控制前、灯类 BLE 配对添加前。失败不抛(非致命)。 + * + * Android 直接用 `adb svc bluetooth disable/enable` —— 本机(三星)实测可真切 radio(bluetooth_on 1→0→1, + * exit=0),不走设置 UI、不与 Appium 冲突、前台 app 不变。(早期机器 svc 被禁才绕设置 UI,本机不需要。) + */ +export async function resetBluetooth(driver: DeviceDriver): Promise { + try { + if (driver.platform === 'android') { + adbShell('svc bluetooth disable'); + await sleep(2500); + adbShell('svc bluetooth enable'); + await sleep(5000); // 等蓝牙栈重新就绪 + } else { + // iOS 无 adb,走系统设置 UI 关再开,完成后切回 SwitchBot app + await iosSetBluetooth(driver, false); + await sleep(1500); + await iosSetBluetooth(driver, true); + await sleep(4000); + await driver.activateApp(APP_CONFIG.ios.bundleId); + await sleep(2000); + } + console.log('已重置手机蓝牙(关→开)'); + } catch (e: any) { + console.log(`重置蓝牙失败(非致命): ${e.message}`); + } +} + +/** 首页"响应度":设备卡数 + 带实时状态(On/Off/℃/%/open 等)的卡数。BLE 未连上时卡片多为无状态/无响应。 */ +async function homepageResponsiveness(driver: DeviceDriver): Promise<{ cards: number; stateHits: number }> { + const src = await driver.getSource().catch(() => ''); + const cards = driver.platform === 'android' + ? (src.match(/resource-id="[^"]*\/(nameText|nameTextMeter)"/g) || []).length + : (src.match(/XCUIElementTypeCell[^>]*?name="/g) || []).length; + // 实时状态关键词(连上 BLE 才会刷出来):开关/温湿度/窗帘/传感器等 + const stateHits = (src.match(/(On\b|Off\b|Fully open|Fully closed|Partially open|Opened|Closed|℃|°C|\d%|ppm|Detected|unoccupied|Locked|Unlocked)/g) || []).length; + return { cards, stateHits }; +} + +/** + * 切到"仅 BLE"模式并**校验设备已连上**:关手机 WiFi → 复位蓝牙 → 下拉刷新首页 → 看卡片是否有响应。 + * 大部分卡无响应(BLE 没连上)就重试整套切换(最多 maxRetries 次)。根治"中途切 BLE 后首页卡片全无响应 → BLE 控制整片失败"。 + * 仅 Android(BLE 控制阶段);校验是启发式(状态关键词/卡数比例),非致命,失败只告警。 + */ +export async function switchToBleAndVerify(driver: DeviceDriver, maxRetries = 3): Promise { + if (driver.platform !== 'android') return true; + adbShell('svc wifi disable'); // 关 WiFi 强制 BLE 直连 + await sleep(3000); + const { width: w, height: h } = await driver.getWindowSize().catch(() => ({ width: 1080, height: 2280 })); + for (let attempt = 1; attempt <= maxRetries; attempt++) { + await resetBluetooth(driver); // 关→开蓝牙复位 + await driver.activateApp(APP_CONFIG.android.appPackage).catch(() => {}); + await sleep(2000); + await driver.goBackToHomepage().catch(() => {}); + await sleep(1500); + // 下拉刷新首页(从上方下拉),触发 App 重新建立 BLE 连接 + await driver.swipe(Math.round(w / 2), Math.round(h * 0.22), Math.round(w / 2), Math.round(h * 0.72), 0.6).catch(() => {}); + await sleep(10000); // 等 BLE 重连 + 状态刷新 + const { cards, stateHits } = await homepageResponsiveness(driver); + // 响应度:有状态关键词数 ≥ 卡数的一半,视为大部分设备已连上 + const ok = cards > 0 && stateHits >= Math.max(2, Math.ceil(cards * 0.5)); + console.log(`[BLE校验] 第${attempt}/${maxRetries}次:卡片${cards}、状态命中${stateHits} → ${ok ? '已连上' : '大部分无响应,重试切换'}`); + if (ok) return true; + } + console.log('[BLE校验] 多次重试后仍大部分无响应(后续 BLE 控制可能失败,日志已标记)'); + return false; +} diff --git a/utils/common/plug-power.helper.ts b/utils/common/plug-power.helper.ts new file mode 100644 index 0000000..5f94445 --- /dev/null +++ b/utils/common/plug-power.helper.ts @@ -0,0 +1,68 @@ +import { DeviceDriver } from '../../drivers/types'; +import { sleep } from './element.helper'; + +export type EnsurePlugResult = 'already-on' | 'turned-on' | 'still-off' | 'not-found' | 'unknown'; + +/** + * 确保给吸顶灯/灯泡供电的插座(SwitchBot Plug)处于 ON。 + * 灯/灯泡物理插在该插座上 → 插座 OFF 则下游灯无电,无法配对/无法控制。 + * 用法:在 plug 添加用例结束后(无论本轮新加还是已存在)调用,作为后续灯/灯泡用例的供电前提。 + * + * 状态判断只读「卡片自身 label」(不读整页 source,避免 'ON' 子串到处命中而误判)。 + * - label 含 off → 点卡片右侧电源键打开并校验 + * - label 含 on → 已开,直接返回 + * - label 读不出状态 → 只告警、**不乱点**(避免把已 ON 的插座点成 OFF) + * + * @param deviceName 插座在首页的卡片名(如 'Plug EU') + */ +export async function ensurePlugOn(driver: DeviceDriver, deviceName: string): Promise { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await sleep(600); + await driver.dismissPopupIfPresent(); + + // 找卡片(找不到则向下滚几次) + let card = await driver.findElementRaw('name', deviceName); + for (let i = 0; i < 4 && !card; i++) { + await driver.scrollDown(250); + await sleep(800); + card = await driver.findElementRaw('name', deviceName); + } + if (!card) { + console.log(`ensurePlugOn: 找不到插座卡片「${deviceName}」(无法保证供电)`); + return 'not-found'; + } + + const readState = async (el: string): Promise => { + const label = (await driver.getElementAttribute(el, 'label').catch(() => '')) || ''; + if (/\boff\b/i.test(label)) return false; + if (/\bon\b/i.test(label)) return true; + return null; // label 未含明确状态 + }; + + const state = await readState(card); + if (state === true) { + console.log(`插座「${deviceName}」已是 ON`); + return 'already-on'; + } + if (state === null) { + console.log(`ensurePlugOn: 卡片「${deviceName}」label 未含 on/off,跳过(不乱点,默认新加插座为 ON)`); + return 'unknown'; + } + + // state === false → 点右侧电源键打开(复用 plug_card 验证过的点位) + const rect = await driver.getElementRect(card); + await driver.tap(rect.x + rect.width - 30, rect.y + rect.height / 2); + await sleep(8000); + + // 重新取卡片(开关后树可能变化)再校验 + let after = await driver.findElementRaw('name', deviceName); + if (!after) { await driver.scrollDown(150); await sleep(600); after = await driver.findElementRaw('name', deviceName); } + const post = after ? await readState(after) : null; + if (post === true) { + console.log(`插座「${deviceName}」OFF → ON ✓`); + return 'turned-on'; + } + console.log(`插座「${deviceName}」点电源键后仍非 ON(${post === false ? 'still OFF' : '状态未知'})`); + return post === false ? 'still-off' : 'unknown'; +} diff --git a/utils/common/room.helper.ts b/utils/common/room.helper.ts index 661e242..cda6a46 100644 --- a/utils/common/room.helper.ts +++ b/utils/common/room.helper.ts @@ -29,6 +29,9 @@ export async function goToManageRooms(driver: DeviceDriver): Promise { /** 在 Manage Rooms 页创建房间:Create Room → 输入名 → OK。返回房间是否出现在列表。 */ export async function createRoom(driver: DeviceDriver, roomName: string): Promise { + // 进 Manage Rooms 首次会弹「新手引导文档」,会遮挡 Create Room → 先点 Got it 关掉(无则 tapText 返回 false,无副作用)。 + await tapText(driver, 'Got it'); + await sleep(800); if (!(await tapText(driver, 'Create Room'))) return false; await sleep(1500); const ed = await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); @@ -37,6 +40,8 @@ export async function createRoom(driver: DeviceDriver, roomName: string): Promis await sleep(500); await tapText(driver, 'OK'); await sleep(2500); + await tapText(driver, 'Got it'); // 创建后可能再弹引导,兜底关掉 + await sleep(1000); const ok = (await driver.getSource()).includes(roomName); console.log(`创建房间 ${roomName}: ${ok}`); return ok; diff --git a/utils/wda-helper.ts b/utils/wda-helper.ts index 27609ab..ae2d86e 100644 --- a/utils/wda-helper.ts +++ b/utils/wda-helper.ts @@ -358,8 +358,21 @@ export class WDAHelper { return hasMainTabBar && !hasPopup; } - async goBackToHomepage(): Promise { - for (let i = 0; i < 12; i++) { + /** 等首页渲染稳定:可见卡片**数量**连续两次不变 = 加载完成(用数量而非名,避免摄像头"Motion detected"等状态变化导致永不稳定)。最多 ~2.5s。 + * 根治"页面没加载完就甩动/点击 → 落到半渲染卡上误点"。 */ + private async waitHomeSettled(maxMs = 2500): Promise { + let lastN = -1; + const t0 = Date.now(); + while (Date.now() - t0 < maxMs) { + const s = await this.getSource(); + const n = (s.match(/XCUIElementTypeCell/g) || []).length; + if (n > 0 && n === lastN) return; // 卡片数两次一致 = 稳定 + lastN = n; + await new Promise((r) => setTimeout(r, 600)); + } + } + + async goBackToHomepage(): Promise { for (let i = 0; i < 12; i++) { const source = await this.getSource(); // ★ 加载守卫:App 刚重启/页面切换时 source 极稀疏(只有 "SwitchBot" 启动图,Add/More/主页 还没渲染)。 @@ -409,6 +422,7 @@ export class WDAHelper { const isMainHome = source.includes('主页') || source.includes('自动化') || (source.includes('Add') && source.includes('More') && !source.includes('Direction') && !source.includes('Features') && !source.includes('Playback') && !source.includes('Home Assistant') && !source.includes('Device Settings') && !source.includes('Scanning for Bluetooth') && !source.includes('Add Manually')); if (isMainHome) { + await this.waitHomeSettled(); // 等首页卡片渲染稳定再返回 → 之后找卡/甩动不会落到半渲染卡上误点 return true; } diff --git a/vitest.config.ts b/vitest.config.ts index 37b70d1..88e6834 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,9 @@ export default defineConfig({ test: { testTimeout: 60000, hookTimeout: 120000, + // 用例级重试:控制阶段设 VITEST_RETRY=1,单个 it 失败自动重跑一次(控制偶发:浮层/卡片漂移/同步延迟)。 + // 默认 0,不影响添加等长流程用例(添加另有 shell 层整文件重试)。 + retry: Number(process.env.VITEST_RETRY) || 0, globals: true, reporters: ['verbose'], outputFile: './reports/test-results.json',