控制稳定性与报告增强 + 多项用例修复

- 蓝牙: resetBluetooth(adb svc 关→开)接入吸顶灯Pro/relay switch添加前; switchToBleAndVerify(切BLE+下拉刷新+响应度校验+重试)接入BLE控制阶段
- 卡片查找: scrollToAndTap 点击前重取新鲜句柄+stale重取(治RN首页"does not exist in DOM anymore"); 找不到设备时打印当前页设备卡(logVisibleDeviceCards)接入blind_tilt/灯/空净/加湿器finder
- 用例修复: 创建房间补点Got it引导; 账号注销destroyBto兜底+诊断; plug添加后ensurePlugOn保证供电
- 报告: gen-p0-report 增 BLE/WiFi 控制成功率拆分(按阶段标记去重); run-ios-adds 清.results.json使iOS报告不混Android
- 重试: vitest用例级retry:1(VITEST_RETRY 控制阶段开启, 两端); run-p0-all 加 CONTROL_ONLY 只跑控制 + 末尾自动出成功率表

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
woan 2026-06-26 16:17:48 +08:00
parent fb1c13e086
commit 4b76e5de17
27 changed files with 480 additions and 195 deletions

View File

@ -304,11 +304,11 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
}, },
robot_s10: { robot_s10: {
buttons: { buttons: {
a: { android: 13 /*, ios: <填> */ }, // 扫地机 S10 配对键1(ch13,与 K20+ 同口,物理同时只接一个) a: { android: 17 /*, ios: <填> */ }, // 扫地机 S10 配对键1(ch17)
b: { android: 14 /*, ios: <填> */ }, // 扫地机 S10 配对键2(ch14) b: { android: 18 /*, ios: <填> */ }, // 扫地机 S10 配对键2(ch18)
}, },
actions: { 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: { ai_art_frame: {

View File

@ -27,15 +27,19 @@ start = max(starts) if starts else 0
seg = lines[start:] seg = lines[start:]
hdr = lines[start].strip() if starts else '(未找到回归开始标记,取全量)' 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: 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) m = re.search(r'[✓✗○] \[(PASS|FAIL|SKIP)\]\s*(.+?)\s*\(([\d.]+)s\)', l)
if m: 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() add_seq = OrderedDict()
for s, n in rows: for s, n, ph in rows:
if '添加' in n: if '添加' in n:
add_seq.setdefault(n, []).append(s) add_seq.setdefault(n, []).append(s)
add_first, add_retry, add_fail = [], [], [] add_first, add_retry, add_fail = [], [], []
@ -57,7 +61,7 @@ def cat(n):
if '首页卡片' in n or '首页找到' in n: return '灯/卡片控制' if '首页卡片' in n or '首页找到' in n: return '灯/卡片控制'
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) fg = defaultdict(Counter)
for s, n in nonadd: for s, n in nonadd:
if s == 'FAIL': fg[cat(n)][n] += 1 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_fail = sum(1 for s, n in nonadd if s == 'FAIL')
na_skip = sum(1 for s, n in nonadd if s == 'SKIP') 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 = []
o.append('# Android P0 真实结果\n') o.append('# Android P0 真实结果\n')
o.append(f'> 来源:{hdr}(最后一轮)。\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'- **添加(按用例):{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') 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'## 添加用例({add_total})\n')
o.append(f'\n### 🔁 重试后成功({len(add_retry)})— 计通过,稳定性需关注\n') o.append(f'\n### 🔁 重试后成功({len(add_retry)})— 计通过,稳定性需关注\n')
for n, f in sorted(add_retry, key=lambda x: -x[1]): 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)) open(OUT, 'w', encoding='utf-8').write(''.join(o))
print(f'已生成 {OUT}') print(f'已生成 {OUT}')
print(f'添加 {add_total}: 首通{len(add_first)} 重试成功{len(add_retry)} 真失败{len(add_fail)} | ' 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)})')

View File

@ -7,6 +7,10 @@ export PLATFORM=ios SOAK_MODE=1
BUNDLE=com.wohand.wohand BUNDLE=com.wohand.wohand
LOG=reports/ios-adds.log LOG=reports/ios-adds.log
: > "$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 进程仍在即恢复)。 # WDA 健康检查 + 恢复:/status 不通则重启 iproxy 端口转发(覆盖过夜端口转发断开;设备上 WDA 进程仍在即恢复)。
ensure_wda(){ ensure_wda(){
@ -52,8 +56,8 @@ ADDS=(
"safety_alarm|npx vitest run tests/safety_alarm/safety_alarm_connect.test.ts" "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" "water_detector|npx vitest run tests/water_detector/water_detector_connect.test.ts"
"curtain|npx vitest run tests/curtain/curtain_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_2b|CURTAIN_DEVICE='Curtain3 2B' CURTAIN_CALIBRATE=1 npx vitest run tests/curtain/curtain_connect.test.ts"
"curtain3_2025|npx vitest run tests/curtain/curtain3_2025_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_vision|npx vitest run tests/keypad/keypad_vision_connect.test.ts"
"keypad_touch|npx vitest run tests/keypad/keypad_touch_connect.test.ts" "keypad_touch|npx vitest run tests/keypad/keypad_touch_connect.test.ts"
# —— WiFi Hub 类 —— # —— WiFi Hub 类 ——
@ -83,6 +87,7 @@ ADDS=(
"urc|npx vitest run tests/urc/urc_connect.test.ts" "urc|npx vitest run tests/urc/urc_connect.test.ts"
# —— 扫地机 —— # —— 扫地机 ——
"robot_s1p|npx vitest run tests/robot/robot_s1p_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 口径)—— # —— 摄像头/门铃添加(iOS 需扫码/校准无方案;账号已保留这两设备 → 多为已存在 SKIP,纳入为对齐 Android P0 口径)——
"doorbell|npx vitest run tests/camera/doorbell_connect.test.ts" "doorbell|npx vitest run tests/camera/doorbell_connect.test.ts"
"outdoor_ptc|npx vitest run tests/camera/outdoor_ptc_connect.test.ts" "outdoor_ptc|npx vitest run tests/camera/outdoor_ptc_connect.test.ts"

View File

@ -7,7 +7,7 @@
# WDA 须已在 localhost:8100。 # WDA 须已在 localhost:8100。
set -u set -u
cd "$(dirname "$0")/.." 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" 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"),不写死 # PLUG_CTRL_DEVICE 不导出:plug 控制走 getDeviceName('plug') → 注册表实际名(iOS 当前 "Plug EU"),不写死
BUNDLE=com.wohand.wohand BUNDLE=com.wohand.wohand

View File

@ -22,8 +22,8 @@ ADD_CMDS=(
"npx vitest run tests/sensor/presence_sensor_connect.test.ts" "npx vitest run tests/sensor/presence_sensor_connect.test.ts"
"npx vitest run tests/remote/remote_connect.test.ts" "npx vitest run tests/remote/remote_connect.test.ts"
"npx vitest run tests/curtain/curtain_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" "CURTAIN_DEVICE='Curtain3 2B' CURTAIN_CALIBRATE=1 npx vitest run tests/curtain/curtain_connect.test.ts"
"npx vitest run tests/curtain/curtain3_2025_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/water_detector/water_detector_connect.test.ts"
"npx vitest run tests/hub/hub_matter_connect.test.ts" "npx vitest run tests/hub/hub_matter_connect.test.ts"
"npx vitest run tests/find_card/find_card_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/neon_light_connect.test.ts"
"npx vitest run tests/strip_light/strip_light_3_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_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_vision_connect.test.ts"
"npx vitest run tests/keypad/keypad_touch_connect.test.ts" "npx vitest run tests/keypad/keypad_touch_connect.test.ts"
"npx vitest run tests/aihub/aihub_connect.test.ts -t '通过BLE添加'" "npx vitest run tests/aihub/aihub_connect.test.ts -t '通过BLE添加'"
@ -126,6 +127,7 @@ echo "===== $(date '+%F %T') P0 全量回归开始 ====="
echo "----- 复位:删除所有可重加设备(保留 锁/Blind Tilt + 摄像头/门铃,自动化无法重加)-----" echo "----- 复位:删除所有可重加设备(保留 锁/Blind Tilt + 摄像头/门铃,自动化无法重加)-----"
# 锁需物理校准、摄像头需扫码,均跳过添加 → 绝不可删,否则对应控制/拉流用例整晚无设备失败。 # 锁需物理校准、摄像头需扫码,均跳过添加 → 绝不可删,否则对应控制/拉流用例整晚无设备失败。
# Lock Lite / Video Doorbell 同样保留(Doorbell 关键字覆盖门铃,Lock Lite 关键字覆盖锁丽特)。 # 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 "复位异常(继续跑)" 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 阶段 -----" echo "----- ADD 阶段 -----"
@ -149,6 +151,9 @@ if [ ${#FAILED[@]} -gt 0 ]; then
done done
echo "重试后仍失败 ${#STILL[@]} 个(已放弃,继续后续用例)" echo "重试后仍失败 ${#STILL[@]} 个(已放弃,继续后续用例)"
fi fi
else
echo "----- CONTROL_ONLY=1:跳过复位+添加阶段,直接控制现有设备 -----"
fi
# 添加后:打开 Action Panel Settings 所有开关 → 点设备卡片才弹「快捷动作浮层」(灯/加湿器卡片控制依赖; # 添加后:打开 Action Panel Settings 所有开关 → 点设备卡片才弹「快捷动作浮层」(灯/加湿器卡片控制依赖;
# 新加设备该开关默认可能 OFF → 否则点卡片进功能页致控制失败)。必须在 CONTROL 之前。 # 新加设备该开关默认可能 OFF → 否则点卡片进功能页致控制失败)。必须在 CONTROL 之前。
@ -160,6 +165,7 @@ echo "----- CONTROL 阶段(BLE + WiFi 各跑一遍,分别打 15975/15974 锚点)
run_control_pass() { run_control_pass() {
local label="$1" cmd local label="$1" cmd
local failed=() local failed=()
export VITEST_RETRY=1 # 控制阶段:单个 it 失败自动重跑一次(用例级,见 vitest.config.ts);添加阶段不受影响
for cmd in "${CONTROL[@]}"; do for cmd in "${CONTROL[@]}"; do
echo "--- $(date +%T) [$label] $cmd ---" echo "--- $(date +%T) [$label] $cmd ---"
run_one "$cmd" || failed+=("$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" run_one "$cmd" && echo "[CTRL-RETRY-OK][$label] $cmd" || echo "[CTRL-RETRY-GIVEUP][$label] $cmd"
done done
fi fi
unset VITEST_RETRY
} }
# BLE 阶段:关手机 WiFi 强制 BLE 直连控制 # BLE 阶段:切"仅 BLE"并校验设备已连上(关 WiFi + 复位蓝牙 + 下拉刷新 + 响应度校验 + 重试)。
adb shell svc bluetooth enable >/dev/null 2>&1 # 根治"中途切 BLE 后首页卡片全无响应 → BLE 控制整片失败"。失败不阻塞,后续控制照跑。
adb shell svc wifi disable >/dev/null 2>&1; sleep 5 echo "----- BLE 控制前:切 BLE 并校验设备连上(下拉刷新+重试)-----"
PLATFORM=android npx ts-node scripts/switch-ble.ts 2>&1 | tail -6 || echo "切 BLE 校验异常(继续跑)"
export PROTO=ble export PROTO=ble
echo "===== 控制 PROTO=ble(手机 WiFi 已关)=====" echo "===== 控制 PROTO=ble(手机 WiFi 已关)====="
run_control_pass ble run_control_pass ble
@ -190,9 +198,14 @@ unset PROTO
restore_wifi # 进平台/账号前再确保 WiFi 在线 restore_wifi # 进平台/账号前再确保 WiFi 在线
echo "----- 平台 + 账号阶段 -----" echo "----- 平台 + 账号阶段 -----"
if [ -z "${CONTROL_ONLY:-}" ]; then
for cmd in "${TAIL[@]}"; do echo "--- $(date +%T) $cmd ---"; run_one "$cmd" || true; done for cmd in "${TAIL[@]}"; do echo "--- $(date +%T) $cmd ---"; run_one "$cmd" || true; done
else
echo "----- CONTROL_ONLY=1:跳过平台/账号阶段 -----"
fi
echo "----- 生成合并报告 -----" echo "----- 生成合并报告 -----"
npx ts-node scripts/gen-combined.ts P0_All_Android 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') 完成 =====" echo "===== $(date '+%F %T') 完成 ====="
} >> "$LOG" 2>&1 } >> "$LOG" 2>&1

21
scripts/switch-ble.ts Normal file
View File

@ -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); });

View File

@ -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 { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory'; import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter'; 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 dotenv from 'dotenv';
import * as path from 'path'; import * as path from 'path';
@ -19,63 +19,119 @@ const ADD_ANCHOR = '[P0]'; // TODO: 待补 Air Purifier 控制 ONES 编号
describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => { describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => {
let driver: DeviceDriver; let driver: DeviceDriver;
let reporter: TestReporter; let reporter: TestReporter;
let W = 1080, H = 2400;
const isIOS = () => driver.platform === 'ios';
beforeAll(async () => { beforeAll(async () => {
driver = createDriver(); driver = createDriver();
await driver.createSession(); await driver.createSession();
reporter = new TestReporter('AirPurifier_Card', driver.platform.toUpperCase()); 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<void> {
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 () => { beforeEach(async () => {
await dismissSheetIfOpenIOS();
await driver.dismissPopupIfPresent(); await driver.dismissPopupIfPresent();
await driver.goBackToHomepage(); await driver.goBackToHomepage();
await driver.dismissPopupIfPresent(); await driver.dismissPopupIfPresent();
}); });
afterEach(async () => {
await dismissSheetIfOpenIOS();
await driver.dismissPopupIfPresent().catch(() => {});
});
afterAll(async () => { afterAll(async () => {
reporter.generate(); reporter.generate();
await driver.destroySession(); await driver.destroySession();
}); });
async function findCard(): Promise<string> { async function findCard(): Promise<string> {
for (let r = 0; r < 8; r++) { // 同一文件内多 it 间不重启 → 先快速回顶(scrollHomeToTopIOS),再从顶滚找(页面稳定时甩动不误点)。
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); if (isIOS()) await scrollHomeToTopIOS(driver).catch(() => {});
if (el) return el; for (let r = 0; r < 14; r++) {
await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); const el = isIOS()
await sleep(900); ? 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}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`);
} }
// 点卡片中心 → 弹快捷控制浮层 // 点卡片中心 → 弹快捷控制浮层(未弹回首页重试)
async function openCardPopup(): Promise<void> { async function openCardPopup(): Promise<void> {
for (let attempt = 0; attempt < 3; attempt++) {
const cardId = await findCard(); const cardId = await findCard();
const rect = await driver.getElementRect(cardId); const rect = await driver.getElementRect(cardId);
await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2);
await sleep(2000); await sleep(2000);
const ok = await waitForSource(driver, 'More', 6000); if (await waitForSource(driver, 'More', 5000)) return;
if (!ok) throw new Error('卡片快捷控制浮层未弹出'); 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<void> { async function tapPower(): Promise<void> {
let w = 1080, h = 2400; if (isIOS()) {
try { const s: any = await (driver as any).getWindowSize?.(); if (s?.width) { w = s.width; h = s.height; } } catch { /* 默认 1080x2400 */ } const p = await findPowerBtnIOS();
const px = Math.round(w / 2); const px = p ? p.cx : Math.round(W / 2);
const py = Math.round(h * 0.52); 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})`); console.log(`点电源键 (${px}, ${py})`);
await driver.tap(px, py); await driver.tap(px, py);
} }
// 当前是否关机:取首个含设备名(首页卡)后的状态字 —— 首页卡副标题镜像设备态:关=Off,开=模式/数值。 // 当前是否关机:iOS 读浮层状态(name/label,以 On/Off 开头);Android 读 text= 卡名后状态字
async function isOff(): Promise<boolean> { async function isOff(): Promise<boolean> {
const src = await driver.getSource(); const src = await driver.getSource();
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 texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]);
const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); const i = texts.findIndex((t) => t.includes(CARD_KEYWORD));
const status = i >= 0 ? (texts[i + 1] || '') : ''; status = i >= 0 ? (texts[i + 1] || '') : '';
}
console.log(`状态字="${status}"`); console.log(`状态字="${status}"`);
return /^Off$|Offline|待机/.test(status); return /^Off$|^Off\b|Offline|待机/.test(status);
} }
async function pollState(wantOff: boolean, ms = 15000): Promise<boolean> { async function pollState(wantOff: boolean, ms = 15000): Promise<boolean> {
@ -87,22 +143,6 @@ describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => {
return (await isOff()) === wantOff; 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 () => { it(`${ADD_ANCHOR} 首页卡片开机`, async () => {
const start = Date.now(); const start = Date.now();
try { try {

View File

@ -61,24 +61,6 @@ describe('CeilingLight Card - 首页卡片操作', () => {
return el; 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 () => { it('首页开关吸顶灯', async () => {
const start = Date.now(); const start = Date.now();
try { try {

View File

@ -11,6 +11,7 @@ import {
powerCycle, powerCycle,
configureHubWifi, configureHubWifi,
setupResilientHooks, setupResilientHooks,
resetBluetooth,
} from '../../utils/common'; } from '../../utils/common';
import { getWifiCredentials } from '../../config/wifi.config'; import { getWifiCredentials } from '../../config/wifi.config';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
@ -51,6 +52,9 @@ describe('CeilingLight Pro Connect - 添加吸顶灯Pro(两次点按配对 + WiF
return; return;
} }
// BLE 配对前复位手机蓝牙(关→开),根治偶发 "FAIL: connection timeout"(重跑才过 = 蓝牙栈陈旧)
await resetBluetooth(driver);
const result = await addDeviceViaBLE(driver, { const result = await addDeviceViaBLE(driver, {
categoryName: CATEGORY, categoryName: CATEGORY,
deviceKeyword: SCAN_KEYWORD, deviceKeyword: SCAN_KEYWORD,

View File

@ -2,7 +2,7 @@ import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect } from
import { DeviceDriver } from '../../drivers/types'; import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory'; import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter'; 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 dotenv from 'dotenv';
import * as path from 'path'; import * as path from 'path';
@ -12,10 +12,11 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// 浮层:状态字(Off / "On | NN% | Color") + **无文案电源圆键** + **亮度滑条** + More。流程同加湿器2/空净。 // 浮层:状态字(Off / "On | NN% | Color") + **无文案电源圆键** + **亮度滑条** + More。流程同加湿器2/空净。
// 实测 1080x2400:电源键中心 (540,1455)=(w/2,h*0.606);滑条 track x[48..1032] y1737(h*0.724)。 // 实测 1080x2400:电源键中心 (540,1455)=(w/2,h*0.606);滑条 track x[48..1032] y1737(h*0.724)。
// 亮度可由副标题 "On | NN%" 直接读出,拖滑条后校验 % 变化。ONES 控制号待补,先 [P0] 占位。 // 亮度可由副标题 "On | NN%" 直接读出,拖滑条后校验 % 变化。ONES 控制号待补,先 [P0] 占位。
// 灯卡片关键词:**优先按添加时注册的实际名**(LIGHT_REG=注册 key,如 'stripLight'→"Strip Light SB"), // 灯卡片关键词:**优先用 LIGHT_CARD(干净品类名,如 "Permanent Outdoor Lights")** 做 CONTAINS 匹配 ——
// 不写死;LIGHT_CARD 作兜底(注册表无该 key 时用)。方案A:8 个灯各注册独立 key。 // 注册表存的名可能含实时状态(如 "...66On30% Color",状态会变 → CONTAINS 永远匹配不到)。LIGHT_CARD 各灯唯一、不串台。
// 注册名仅作兜底(注册表存的若干净、且无 LIGHT_CARD 时用)。
const LIGHT_REG = process.env.LIGHT_REG; 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 编号 const ADD_ANCHOR = '[P0]'; // TODO: 待补 屋檐灯/灯类 控制 ONES 编号
describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => {
@ -59,8 +60,9 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => {
: (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null)); : (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null));
} }
async function findCard(): Promise<string> { async function findCard(): Promise<string> {
// 用 driver.scrollDown(实测稳定滚动、不误点卡片);自定义 swipe 起手在卡片上会被当成点击(误开摄像头)。 // 跨控制文件:正式跑 reset_home 重启到顶,无需回顶。但**同一文件内多个 it 之间不重启**,
// 复位到顶:beforeEach 的 goBackToHomepage 已回首页顶部。 // 前一个 it 会把列表停在中部 → 后续 findCard 须先**快速回顶**(scrollHomeToTopIOS ~1s)再从顶往下找。
if (isIOS()) await scrollHomeToTopIOS(driver).catch(() => {});
for (let r = 0; r < 12; r++) { for (let r = 0; r < 12; r++) {
const el = await findCardEl(); const el = await findCardEl();
if (el) { if (el) {
@ -70,6 +72,7 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => {
await driver.scrollDown(500); await driver.scrollDown(500);
await sleep(800); await sleep(800);
} }
await logVisibleDeviceCards(driver, `找不到${CARD_KEYWORD}卡片`);
throw new Error(`找不到${CARD_KEYWORD}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`);
} }
@ -93,18 +96,19 @@ describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => {
// iOS 找电源键 Button(居中圆键,~50-120px,y>H*0.4);返回中心,找不到返回 null。两类灯(单/双滑条)位置不同,必须动态找。 // iOS 找电源键 Button(居中圆键,~50-120px,y>H*0.4);返回中心,找不到返回 null。两类灯(单/双滑条)位置不同,必须动态找。
async function findPowerBtnIOS(): Promise<{ cx: number; cy: number } | null> { async function findPowerBtnIOS(): Promise<{ cx: number; cy: number } | null> {
const btns = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton"').catch(() => [] as string[]); 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[]) { for (const b of btns as string[]) {
const r = await driver.getElementRect(b).catch(() => null); const r = await driver.getElementRect(b).catch(() => null);
if (!r) continue; if (!r) continue;
// 电源圆键:近正方形(|w-h|小)、~50-120px、水平居中、在浮层区(y>H*0.4)。定时按钮(1hr等)不居中/更小,背景卡片按钮偏侧 → 排除 // 电源圆键:近正方形、~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.16 && r.y > H * 0.4 && r.y < H * 0.78) { 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) }); 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; if (!cands.length) return null;
cands.sort((a, b) => a.cy - b.cy); // 取最靠上的(电源键在滑条/定时之上) // 取**宽度最大**的(电源键 72×72 比定时/控制 48×49 大);同宽取最靠上。
return cands[0]; 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> { async function tapPower(): Promise<void> {
if (isIOS()) { 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 () => { it(`${ADD_ANCHOR} 首页卡片开灯`, async () => {
const start = Date.now(); const start = Date.now();
try { try {

View File

@ -2,7 +2,7 @@ import { describe, it, beforeAll, beforeEach, afterAll, expect } from 'vitest';
import { DeviceDriver } from '../../drivers/types'; import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory'; import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter'; 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 { setupResilientHooks } from "../../utils/common";
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as path from 'path'; import * as path from 'path';
@ -60,6 +60,8 @@ describe('Blind Tilt Control - 百叶帘控制(首页快捷弹窗)', () => {
} }
throw new Error(`找不到${deviceName}卡片`); throw new Error(`找不到${deviceName}卡片`);
} }
// Android:scrollToAndTap 已做「滚动定位 + 点击前重取新鲜句柄 + stale 重取」,不会再因 RN 刷新 stale 报找不到。
await waitForSource(driver, deviceName, 12000).catch(() => false); // 冷启动等卡片渲染出来再滚
const ok = await scrollToAndTap(driver, deviceName); const ok = await scrollToAndTap(driver, deviceName);
if (!ok) throw new Error(`找不到${deviceName}卡片`); if (!ok) throw new Error(`找不到${deviceName}卡片`);
await sleep(2500); await sleep(2500);
@ -109,7 +111,7 @@ describe('Blind Tilt Control - 百叶帘控制(首页快捷弹窗)', () => {
return st; return st;
} }
it(`${CTRL_BT} 向下关闭(Close Down)`, { timeout: 90000 }, async () => { it(`${CTRL_BT} 向下关闭(Close Down)`, { timeout: 150000 }, async () => {
const start = Date.now(); const start = Date.now();
try { try {
await openBtPopup(); 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(); const start = Date.now();
try { try {
await openBtPopup(); await openBtPopup();

View File

@ -8,6 +8,7 @@ import {
findDeviceNameOnHomepage, findDeviceNameOnHomepage,
saveAddedDevice, saveAddedDevice,
configureHubWifi, configureHubWifi,
resetBluetooth,
} from '../../utils/common'; } from '../../utils/common';
import { getWifiCredentials } from '../../config/wifi.config'; import { getWifiCredentials } from '../../config/wifi.config';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
@ -51,6 +52,9 @@ describe('RelaySwitch Connect - 添加Relay Switch(串口配对 + WiFi配网)',
return; return;
} }
// BLE 配对前复位手机蓝牙(关→开),根治偶发配对/连接不上
await resetBluetooth(driver);
const result = await addDeviceWithSerialPairing(driver, { const result = await addDeviceWithSerialPairing(driver, {
categoryName: CATEGORY, categoryName: CATEGORY,
categoryScrollHint: 0, categoryScrollHint: 0,

View File

@ -2,7 +2,7 @@ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest';
import { DeviceDriver } from '../../drivers/types'; import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory'; import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter'; 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 dotenv from 'dotenv';
import * as path from 'path'; import * as path from 'path';
@ -37,12 +37,17 @@ describe('Humidifier2 Card - 首页卡片控制(Evaporative Humidifier)', () =>
}); });
async function findCard(): Promise<string> { async function findCard(): Promise<string> {
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); const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null);
if (el) return el; if (el) {
await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); const rect = await driver.getElementRect(el).catch(() => null);
await sleep(900); 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}卡片`); throw new Error(`找不到${CARD_KEYWORD}卡片`);
} }
@ -98,22 +103,6 @@ describe('Humidifier2 Card - 首页卡片控制(Evaporative Humidifier)', () =>
return (await isOff()) === wantOff; 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 () => { it(`${ADD_ANCHOR} 首页卡片开机`, async () => {
const start = Date.now(); const start = Date.now();
try { try {

View File

@ -6,6 +6,7 @@ import {
sleep, sleep,
addDeviceViaBLE, addDeviceViaBLE,
isDeviceOnHomepage, isDeviceOnHomepage,
ensurePlugOn,
} from '../../utils/common'; } from '../../utils/common';
import { getDeviceName } from '../../config/device.config'; import { getDeviceName } from '../../config/device.config';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
@ -38,6 +39,8 @@ describe('Plug Connect - 添加Plug设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName); const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) { if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`); 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}已存在, 无需重新添加`); reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return; return;
} }
@ -53,6 +56,9 @@ describe('Plug Connect - 添加Plug设备', () => {
}); });
expect(result).toBe(true); expect(result).toBe(true);
// 供电前提:灯/灯泡插在该插座上,确保插座为 ON(下游灯才有电配对/控制)。失败不影响添加结果。
await ensurePlugOn(driver, deviceName).catch((e) => console.log(`ensurePlugOn 跳过(非致命): ${e.message}`));
const elapsed = ((Date.now() - start) / 1000).toFixed(1); const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
} catch (e: any) { } catch (e: any) {

View File

@ -8,6 +8,7 @@ import {
findDeviceNameOnHomepage, findDeviceNameOnHomepage,
saveAddedDevice, saveAddedDevice,
configureHubWifi, configureHubWifi,
ensurePlugOn,
onesAdd, onesAdd,
} from '../../utils/common'; } from '../../utils/common';
import { getWifiCredentials } from '../../config/wifi.config'; import { getWifiCredentials } from '../../config/wifi.config';
@ -50,6 +51,8 @@ describe('Plug Mini Connect - 添加Plug Mini设备(串口配对 + WiFi配网)',
if (existing) { if (existing) {
console.log(`${existing}已在首页,跳过重新添加`); console.log(`${existing}已在首页,跳过重新添加`);
saveAddedDevice('plug', 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}已存在`); reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'SKIP', Date.now() - start, `${existing}已存在`);
return; return;
} }
@ -80,6 +83,9 @@ describe('Plug Mini Connect - 添加Plug Mini设备(串口配对 + WiFi配网)',
}); });
expect(result).toBe(true); expect(result).toBe(true);
// 供电前提:灯/灯泡插在该插座上,确保插座为 ON(下游灯才有电配对/控制)。失败不影响添加结果。
await ensurePlugOn(driver, deviceName).catch((e) => console.log(`ensurePlugOn 跳过(非致命): ${e.message}`));
const elapsed = ((Date.now() - start) / 1000).toFixed(1); 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`); reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'PASS', Date.now() - start, `${deviceName}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`);
} catch (e: any) { } catch (e: any) {

View File

@ -4,12 +4,10 @@ import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter'; import { TestReporter } from '../../utils/test-reporter';
import { import {
sleep, sleep,
addDeviceViaBLE, addDeviceWithSerialPairing,
findDeviceNameOnHomepage, findDeviceNameOnHomepage,
saveAddedDevice, saveAddedDevice,
enterPairingMode,
configureHubWifi, configureHubWifi,
setupResilientHooks,
} from '../../utils/common'; } from '../../utils/common';
import { getWifiCredentials } from '../../config/wifi.config'; import { getWifiCredentials } from '../../config/wifi.config';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
@ -17,17 +15,16 @@ import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') }); dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// 扫地机 S10:WiFi 产品。流程同 K20+(空净式):入口「floor cleaning robot s10」→ 过 Next → // 扫地机 S10:**与水浸同款添加流程**(串口配对 + WiFi 配网)。
// 长按 ch13+14 5s 进添加模式 → Connect Device → WiFi 配网(Next 后弹"要连接至设备吗"点连接) // 入口品类「Floor Cleaning Robot S10」→ 长按 ch17+18 5s 进添加模式 → Connect Device → Configure Wi-Fi 配网
const CATEGORY = process.env.ROBOT_S10_CATEGORY || 'floor cleaning robot s10'; const CATEGORY = process.env.ROBOT_S10_CATEGORY || 'Floor Cleaning Robot S10';
const SCAN_KEYWORD = process.env.ROBOT_S10_SCAN || '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 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 driver: DeviceDriver;
let reporter: TestReporter; let reporter: TestReporter;
setupResilientHooks(() => driver);
beforeAll(async () => { beforeAll(async () => {
driver = createDriver(); driver = createDriver();
@ -52,34 +49,31 @@ describe('Robot S10 Connect - 添加扫地机S10(两键同按 + WiFi配网)', ()
return; return;
} }
const result = await addDeviceViaBLE(driver, { const result = await addDeviceWithSerialPairing(driver, {
categoryName: CATEGORY, categoryName: CATEGORY,
deviceKeyword: SCAN_KEYWORD, categoryScrollHint: 0,
scanTimeout: 30000, deviceKeyword: deviceName,
skipScanStep: true, relayDevice: 'robot_s10', // ch17+18 同时长按 5s(config/relay.config)
// 机器人向导(同 S1 Plus):第一步 Next → "Step 2: Enter pairing mode" 按 ch13+14(Home+Mode) 5s → Connect Device registryCategory: 'robot',
preSelectSteps: async (d) => { namePattern: NAME_PATTERN,
const n1 = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); // 比水浸多一步向导:选品类后是「Step 1: Power on」→ 点 Next 到「Step 2: Enter pairing mode」,helper 在此按 ch17+18。
if (n1) { await d.tapElement(n1); await sleep(2000); } // 第一步 → 第二步 beforePairing: async (d) => {
await enterPairingMode('robot_s10').catch((e) => console.log(`relay ch13+14 配对失败: ${e.message}`)); for (let i = 0; i < 8; i++) {
await sleep(2500); if (/Step 2|Enter pairing mode|Connect Device/i.test(await d.getSource())) break; // 已到第二步
for (const t of ['Connect device', 'Connect Device', 'Connect Devices', 'Next']) { const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null);
const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); if (n) { await d.tapElement(n); await sleep(2000); } else break;
if (b) { await d.tapElement(b); break; }
} }
await sleep(3000);
}, },
// 搜到设备后点 Configure network 进 WiFi 配网(configureHubWifi 已处理"要连接至设备吗"弹框) // 连接后(同水浸):① 点 "Connect Device" 启动连接 ② 进 Configure Wi-Fi 页 → configureHubWifi 填 SSID/密码
postConnectionSteps: async (d) => { postConnectSteps: async (d) => {
const cn = await d.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Configure network")').catch(() => null); const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")')
if (cn) { await d.tapElement(cn); await sleep(3000); } || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect device")');
if (btn) { await d.tapElement(btn); await sleep(2500); }
await configureHubWifi(d, wifi); await configureHubWifi(d, wifi);
}, },
wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'],
connectionKeywords: [ connectionKeywords: [
'found', 'Configure network', 'Enter Wi-Fi', 'Enter the password', 'Connect Device', 'Configure Wi-Fi', 'Connecting', 'Discover device',
'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', 'Pick a room', 'Done', 'added successfully', 'Added successfully', 'Initial Setup',
'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room',
], ],
}); });
expect(result).toBe(true); expect(result).toBe(true);

View File

@ -61,24 +61,6 @@ describe('StripLight Card - 首页卡片操作', () => {
return el; 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 () => { it('首页开关灯带', async () => {
const start = Date.now(); const start = Date.now();
try { try {

View File

@ -37,15 +37,24 @@ describe('Water Detector Control - 功能页操作', () => {
}); });
async function enterControlPage(): Promise<void> { async function enterControlPage(): Promise<void> {
let el = await driver.findElementRaw('name', deviceName); await driver.goBackToHomepage().catch(() => {});
if (!el) { await sleep(800);
await driver.scrollDown(250); const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
await sleep(1000); // 滚到找到(原只滚1次 + 'name' 精确匹配;实测设备在第4屏 → 够不到)。CONTAINS + 屏内校验 + 滚动找。
el = await driver.findElementRaw('name', deviceName); 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;
} }
if (!el) throw new Error(`找不到${deviceName}卡片`); }
await driver.tapElement(el); await driver.scrollDown(500); await sleep(800);
await sleep(3000); }
throw new Error(`找不到${deviceName}卡片`);
} }
it('切换检测模式', async () => { it('切换检测模式', async () => {

View File

@ -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, 'Next'); await sleep(2000); // Account Deletion Information 页推进
await tapText(driver, 'Confirm'); await sleep(2500); // 确认框 Cancel|Confirm → Confirm await tapText(driver, 'Confirm'); await sleep(2500); // 确认框 Cancel|Confirm → Confirm
} else { } 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 sleep(2000);
await tapText(driver, 'OK'); // 确认框 "Delete Account | Cancel | OK" → OK await tapText(driver, 'OK'); // 确认框 "Delete Account | Cancel | OK" → OK
} }

View File

@ -1,5 +1,5 @@
import { DeviceDriver } from '../../drivers/types'; 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<boolean> { export async function enterDeviceSettings(driver: DeviceDriver, deviceKeyword: string): Promise<boolean> {
let deviceEl: string | null = null; 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<boolean> { export async function scrollToAndTap(driver: DeviceDriver, name: string): Promise<boolean> {
if (driver.platform === 'android') { if (driver.platform === 'android') {
const el = await driver.findElementRaw('-android uiautomator', // scrollIntoView 把目标滚进可视区(返回的句柄**不复用**,只为定位);点击前按名称重新取新鲜句柄再点。
`new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("${name}"))`); // RN 自绘首页状态刷新会让滚动时拿的旧句柄失效(tapElement 抛 "does not exist in DOM anymore"):
if (!el) return false; // find→tap 之间又 stale 就重取,最多 4 次。选择器恒为 text(名称),保证点的是目标卡(不退化为坐标)。
await driver.tapElement(el); const sel = `new UiSelector().text("${name}")`;
return true; 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 // iOS
@ -56,7 +65,7 @@ export async function scrollToAndTap(driver: DeviceDriver, name: string): Promis
await sleep(500); await sleep(500);
el = await driver.findElementRaw('name', name); 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); const rect = await driver.getElementRect(el);
await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2);
return true; return true;

View File

@ -4,6 +4,31 @@ export function sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms)); return new Promise(r => setTimeout(r, ms));
} }
/**
* 诊断:打印当前页面上的设备卡名(,便 = ,//)
* Android nameText/nameTextMeter ;iOS XCUIElementTypeCell name,
*/
export async function logVisibleDeviceCards(driver: DeviceDriver, context: string): Promise<void> {
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( export async function waitForElement(
driver: DeviceDriver, driver: DeviceDriver,
using: string, using: string,

View File

@ -13,6 +13,7 @@ export * from './ones-anchor.helper';
export * from './zendesk.helper'; export * from './zendesk.helper';
export * from './serial_controller'; export * from './serial_controller';
export * from './relay.helper'; export * from './relay.helper';
export * from './plug-power.helper';
export * from './device-registry.helper'; export * from './device-registry.helper';
export * from './qr.helper'; export * from './qr.helper';
export * from './curtain.helper'; export * from './curtain.helper';

View File

@ -119,3 +119,72 @@ export async function applyProtoNetwork(driver: DeviceDriver, proto: 'ble' | 'wi
} }
await sleep(2000); await sleep(2000);
} }
/**
* ,BLE /( Pro "FAIL: connection timeout"
* )用于: BLE BLE ()
*
* Android `adb svc bluetooth disable/enable` () radio(bluetooth_on 101,
* exit=0), UI Appium app ( svc UI,)
*/
export async function resetBluetooth(driver: DeviceDriver): Promise<void> {
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<boolean> {
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;
}

View File

@ -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<EnsurePlugResult> {
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<boolean | null> => {
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';
}

View File

@ -29,6 +29,9 @@ export async function goToManageRooms(driver: DeviceDriver): Promise<boolean> {
/** 在 Manage Rooms 页创建房间:Create Room → 输入名 → OK。返回房间是否出现在列表。 */ /** 在 Manage Rooms 页创建房间:Create Room → 输入名 → OK。返回房间是否出现在列表。 */
export async function createRoom(driver: DeviceDriver, roomName: string): Promise<boolean> { export async function createRoom(driver: DeviceDriver, roomName: string): Promise<boolean> {
// 进 Manage Rooms 首次会弹「新手引导文档」,会遮挡 Create Room → 先点 Got it 关掉(无则 tapText 返回 false,无副作用)。
await tapText(driver, 'Got it');
await sleep(800);
if (!(await tapText(driver, 'Create Room'))) return false; if (!(await tapText(driver, 'Create Room'))) return false;
await sleep(1500); await sleep(1500);
const ed = await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); 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 sleep(500);
await tapText(driver, 'OK'); await tapText(driver, 'OK');
await sleep(2500); await sleep(2500);
await tapText(driver, 'Got it'); // 创建后可能再弹引导,兜底关掉
await sleep(1000);
const ok = (await driver.getSource()).includes(roomName); const ok = (await driver.getSource()).includes(roomName);
console.log(`创建房间 ${roomName}: ${ok}`); console.log(`创建房间 ${roomName}: ${ok}`);
return ok; return ok;

View File

@ -358,8 +358,21 @@ export class WDAHelper {
return hasMainTabBar && !hasPopup; return hasMainTabBar && !hasPopup;
} }
async goBackToHomepage(): Promise<boolean> { /** :**** = (,"Motion detected") ~2.5s
for (let i = 0; i < 12; i++) { * "页面没加载完就甩动/点击 → 落到半渲染卡上误点" */
private async waitHomeSettled(maxMs = 2500): Promise<void> {
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<boolean> { for (let i = 0; i < 12; i++) {
const source = await this.getSource(); const source = await this.getSource();
// ★ 加载守卫:App 刚重启/页面切换时 source 极稀疏(只有 "SwitchBot" 启动图,Add/More/主页 还没渲染)。 // ★ 加载守卫:App 刚重启/页面切换时 source 极稀疏(只有 "SwitchBot" 启动图,Add/More/主页 还没渲染)。
@ -409,6 +422,7 @@ export class WDAHelper {
const isMainHome = source.includes('主页') || source.includes('自动化') 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')); || (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) { if (isMainHome) {
await this.waitHomeSettled(); // 等首页卡片渲染稳定再返回 → 之后找卡/甩动不会落到半渲染卡上误点
return true; return true;
} }

View File

@ -4,6 +4,9 @@ export default defineConfig({
test: { test: {
testTimeout: 60000, testTimeout: 60000,
hookTimeout: 120000, hookTimeout: 120000,
// 用例级重试:控制阶段设 VITEST_RETRY=1,单个 it 失败自动重跑一次(控制偶发:浮层/卡片漂移/同步延迟)。
// 默认 0,不影响添加等长流程用例(添加另有 shell 层整文件重试)。
retry: Number(process.env.VITEST_RETRY) || 0,
globals: true, globals: true,
reporters: ['verbose'], reporters: ['verbose'],
outputFile: './reports/test-results.json', outputFile: './reports/test-results.json',