控制稳定性与报告增强 + 多项用例修复
- 蓝牙: 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:
parent
fb1c13e086
commit
4b76e5de17
|
|
@ -304,11 +304,11 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
|||
},
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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)})')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
|
|
@ -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<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 () => {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
// 用 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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -37,15 +37,24 @@ describe('Water Detector Control - 功能页操作', () => {
|
|||
});
|
||||
|
||||
async function enterControlPage(): Promise<void> {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,31 @@ export function sleep(ms: number): Promise<void> {
|
|||
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(
|
||||
driver: DeviceDriver,
|
||||
using: string,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -29,6 +29,9 @@ export async function goToManageRooms(driver: DeviceDriver): Promise<boolean> {
|
|||
|
||||
/** 在 Manage Rooms 页创建房间:Create Room → 输入名 → OK。返回房间是否出现在列表。 */
|
||||
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;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -358,8 +358,21 @@ export class WDAHelper {
|
|||
return hasMainTabBar && !hasPopup;
|
||||
}
|
||||
|
||||
async goBackToHomepage(): Promise<boolean> {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
/** 等首页渲染稳定:可见卡片**数量**连续两次不变 = 加载完成(用数量而非名,避免摄像头"Motion detected"等状态变化导致永不稳定)。最多 ~2.5s。
|
||||
* 根治"页面没加载完就甩动/点击 → 落到半渲染卡上误点"。 */
|
||||
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();
|
||||
|
||||
// ★ 加载守卫: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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue