AI_UIAutomation/tests/keypad/keypad_lock_passcode.test.ts

245 lines
17 KiB
TypeScript

import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest';
import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import { sleep, pressButton, setupResilientHooks } from '../../utils/common';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// Keypad Vision Pro 闭环功能(feature [ONES:15973]):绑定门锁 → 加永久密码 → 上锁 → 物理键盘输密码解锁 → 校验。
// 功能页菜单:Pair to Lock / Passcode / Face Entry / Fingerprint / NFC Card。
// 物理键盘:数字键 1-6=ch51-56、确定=ch34(relay 'keypad' 的 k1..k6 / confirm)。
const KEYPAD = process.env.KEYPAD_CARD || 'Keypad Vision';
const LOCK = process.env.KEYPAD_LOCK || 'Lock 6D'; // 绑定的门锁(Lock Pro DE 常 Too far)
// KP_SKIP_PHYSICAL=1:只调功能页 app-UI(绑定读取/加密码/删密码/解绑),**跳过物理上锁/解锁**(ch35/ch51-56/ch34)。
// 用于继电器被占用(如 iOS 添加在跑、抢同一串口)时,安全调功能页而不下发继电器指令。
const SKIP_PHYSICAL = process.env.KP_SKIP_PHYSICAL === '1';
const PASSCODE = process.env.KEYPAD_PASSCODE || '123456';
const ANCHOR = '[P0][ONES:15973]';
async function tapText(d: DeviceDriver, t: string): Promise<boolean> {
const el = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null);
if (el) { await d.tapElement(el); return true; }
return false;
}
async function srcHas(d: DeviceDriver, t: string): Promise<boolean> { return (await d.getSource()).includes(t); }
const texts = async (d: DeviceDriver) => {
const src = await d.getSource();
const re = d.platform === 'ios' ? /(?:name|label)="([^"]+)"/g : /text="([^"]+)"/g; // iOS 用 name/label(无 text=),否则日志读空
return [...new Set(Array.from(src.matchAll(re)).map((m) => m[1]).filter((t) => t && t.length < 30))].slice(0, 20);
};
describe('Keypad Vision Pro - 绑锁+密码+物理解锁闭环', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
setupResilientHooks(() => driver);
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
reporter = new TestReporter('KeypadVision_LockPasscode', driver.platform.toUpperCase());
});
afterAll(async () => { reporter.generate(); await driver.destroySession(); });
async function enterKeypad(): Promise<void> {
await driver.dismissPopupIfPresent();
await driver.goBackToHomepage();
await sleep(1000);
const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 }));
let entered = false;
for (let i = 0; i < 12 && !entered; i++) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KEYPAD}")`).catch(() => null);
if (el) {
const r = await driver.getElementRect(el).catch(() => null);
if (driver.platform === 'ios') {
// iOS:点卡片(名 StaticText)中心进功能页;须在可视区(原 Android swipe 540/1500 坐标 iOS 不通)
if (r && r.width > 0 && r.y > winH * 0.08 && r.y + r.height < winH * 0.92) { await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); entered = true; break; }
} else if (r) {
await driver.tap(r.x + 100, r.y - 20); entered = true; break; // Android:卡名右上(开关钮上方)进功能页
}
}
await driver.scrollDown(driver.platform === 'ios' ? 500 : 400); await sleep(700);
}
if (!entered) throw new Error(`首页找不到 ${KEYPAD} 卡片`);
await sleep(5000);
await driver.dismissPopupIfPresent(); await sleep(1000);
// 关"Do not show again"引导弹框
if (await srcHas(driver, 'Do not show again')) {
const cb = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Do not show again")').catch(() => null);
if (cb) await driver.tapElement(cb).catch(() => {});
for (const t of ['OK', 'Got it', 'Confirm', 'I Know']) { if (await tapText(driver, t)) { await sleep(1500); break; } }
}
}
it(`${ANCHOR} 绑锁+加密码+上锁+物理输密码解锁`, { timeout: 240000 }, async () => {
const start = Date.now();
try {
// 1) 进功能页 + 绑定门锁(未绑才绑):选锁行 → 等 → 点底部 Pair → 轮询配对完成
await enterKeypad();
console.log('[KP] 功能页:', JSON.stringify(await texts(driver)));
// 绑定门锁(未绑才绑),最多 2 次;**绑定失败会一直停在"绑定锁"弹框/Not paired**(昨晚根因)→ 必须强校验。
const isPaired = async () => /Paired to/.test(await driver.getSource());
async function bindOnce(): Promise<void> {
if (!(await tapText(driver, 'Pair to Lock'))) return;
await sleep(3000);
console.log('[KP] 选锁页:', JSON.stringify(await texts(driver)));
await tapText(driver, LOCK); await sleep(2500); // 选中锁(行右单选)
// 底部确认按钮:Android="Pair";**iOS="Next"**(选锁页确认键不同)→ 两个都试。
const pb = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Pair")').catch(() => null)
|| await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null);
if (pb) { await driver.tapElement(pb); } // 点确认进配对
// Connecting/Saving(慢)→ Test Your Keypad(点 Skip)→ Paired to。给足窗口(~90s)。
for (let i = 0; i < 30; i++) {
await sleep(3000);
if (await srcHas(driver, 'Test Your Keypad') || await srcHas(driver, 'Test Code')) {
for (const t of ['Skip', '跳过', 'Skip Test', 'Skip test']) { if (await tapText(driver, t)) { console.log('[KP] Test页点Skip跳过'); await sleep(3000); break; } }
}
await driver.dismissPopupIfPresent();
if (await isPaired()) return;
}
}
for (let attempt = 1; attempt <= 2 && !(await isPaired()); attempt++) {
if (!(await srcHas(driver, 'Not paired')) && !(await srcHas(driver, 'Pair to Lock'))) break; // 非未绑态
console.log(`[KP] 绑定 ${LOCK}(第 ${attempt}/2 次)...`);
await bindOnce();
if (!(await isPaired())) { console.log('[KP] 本次未绑成功,重进功能页清绑定弹框残留再试'); await enterKeypad(); }
}
// 强校验:绑定必须成功(出现 "Paired to"),否则清晰报"绑定失败"(而非下游误报"无Passcode图标")。
if (!(await isPaired())) throw new Error(`绑定 Lock 失败(一直提示绑定锁弹框;目标 ${LOCK} 可能 Too far/离线)`);
// 读**实际绑定的锁名**("Paired to XXX")用于后续校验,不写死 Lock 6D。
let boundLock = LOCK;
{ const m = (await driver.getSource()).match(/Paired to ([^"<]+)/); if (m) { boundLock = m[1].trim(); console.log(`[KP] 实际绑定锁: ${boundLock}`); } }
// 2) 加永久密码(实测流程):Passcode tab(横排图标,点元素rect) → 底部无文案Add(H*0.92) → Permanent → 输码 → Next → Set Name → Save → Done
// 此时已在 keypad 功能页(step1 之后,不回首页);轮询等 Passcode 图标渲染。
await sleep(2000);
const { width: W, height: H } = await driver.getWindowSize();
let pcTile: string | null = null;
for (let i = 0; i < 8 && !pcTile; i++) {
pcTile = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Passcode")').catch(() => null);
if (pcTile) break;
await driver.dismissPopupIfPresent();
// 关"绑锁引导/提示"弹框(昨晚根因:进功能页弹框提示帮你绑锁,盖住 Passcode 图标 → 报"无Passcode图标")
for (const t of ['Got it', 'OK', 'I Know', '知道了', 'Skip', 'Not Now', 'Later', 'Done']) { if (await tapText(driver, t)) { await sleep(800); break; } }
if (i === 4) { console.log('[KP] Passcode 仍未现,重进功能页清残留'); await enterKeypad(); } // 中途重进清残留态/弹框
await sleep(1200);
}
if (!pcTile) throw new Error('功能页无 Passcode 图标(绑锁未完成或引导/提示弹框未关闭)');
{ const r = await driver.getElementRect(pcTile); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); }
await sleep(2000);
// 底部无文案 Add 按钮(实测 1080x2400 ≈ 540,2211 = W/2, H*0.92)
await driver.tap(Math.round(W * 0.5), Math.round(H * 0.92));
await sleep(2500);
console.log('[KP] 点底部Add后:', JSON.stringify(await texts(driver)));
if (!(await tapText(driver, 'Permanent'))) throw new Error('未到 Add Passcode 类型页(无 Permanent)');
await sleep(2500);
const eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
if (!eds.length) throw new Error('Set Password 页无输入框');
await driver.typeText(eds[0], PASSCODE); await sleep(1000);
// 收键盘:Android goBack;**iOS 绝不能 goBack(会退出 Set Password 页)**,点键盘 Done/Return。
if (driver.platform === 'android') { try { await driver.goBack(); } catch { /* 收键盘 */ } }
else { for (const k of ['Done', 'Return', 'Go', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
await tapText(driver, 'Next'); await sleep(2500); // Set Password → Set Name
await tapText(driver, 'Save'); await sleep(3500); // Set Name → 保存
await driver.dismissPopupIfPresent();
const added = await srcHas(driver, 'Added successfully') || await srcHas(driver, 'Copy passcode');
await tapText(driver, 'Done'); await sleep(2000); // Added successfully → Done
console.log(`[KP] 加密码完成=${added}:`, JSON.stringify(await texts(driver)));
if (SKIP_PHYSICAL) {
// 功能页调试模式:不下发继电器(避免与 iOS 添加抢串口)→ 跳过物理上锁/解锁/解锁校验。加密码成功即视为功能页 app-UI 通过。
console.log(`[KP] KP_SKIP_PHYSICAL=1 → 跳过物理上锁/解锁;boundLock=${boundLock},加密码=${added}`);
expect(added).toBe(true);
} else {
// 3) 上锁(回首页找 Lock 卡片,确保处于 Locked)——用物理上锁键 ch35 兜底
await driver.goBackToHomepage(); await sleep(1500);
// 物理上锁键(ch35)
await pressButton('keypad', 'unlock', 400).catch((e) => console.log('ch35 上锁键失败:', e.message)); // 'unlock' 按键=ch35=上锁
await sleep(4000);
// 4) 物理键盘输密码 123456 + 确定(ch34)
const keys = ['k1', 'k2', 'k3', 'k4', 'k5', 'k6'];
for (const k of keys) { await pressButton('keypad', k, 400).catch((e) => console.log(`${k}失败:`, e.message)); await sleep(600); }
await pressButton('keypad', 'confirm', 400).catch((e) => console.log('confirm(ch34)失败:', e.message)); // 确定
await sleep(6000);
// 5) 校验 Lock 解锁(滚动找到 Lock 卡片再读状态字,避免卡片在视窗外读空)。用**实际绑定锁** boundLock。
await driver.goBackToHomepage(); await sleep(2000);
const lockEl = await driver.findElementRaw('-android uiautomator',
`new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textContains("${boundLock}"))`).catch(() => null);
const src = await driver.getSource();
const ti = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]);
const li = ti.findIndex((t) => t.includes(boundLock));
const lockState = li >= 0 ? (ti[li + 1] || '') : '';
console.log(`[KP] ${boundLock} 状态字="${lockState}" (found=${!!lockEl})`);
const unlocked = /Unlocked|已解锁/.test(lockState);
expect(unlocked).toBe(true);
}
// 6) 清理:删除刚加的密码(Passcode tab → 点密码项 → Passcode Info → Delete → 确认 Delete)。best-effort,不影响主结果。
try {
await enterKeypad();
const pcEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Passcode")').catch(() => null);
if (pcEl) { const r = await driver.getElementRect(pcEl); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); await sleep(2000); }
const item = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Permanent Passcode")').catch(() => null);
if (item) {
await driver.tapElement(item); await sleep(2500); // Passcode Info
const delBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")').catch(() => null);
if (delBtn) { await driver.tapElement(delBtn); await sleep(2000); } // 弹"Delete this passcode?"
const confirm = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")').catch(() => null); // 精确"Delete"=确认按钮(非标题)
if (confirm) { await driver.tapElement(confirm); await sleep(2500); }
console.log(`[KP] 删密码=${await srcHas(driver, 'Deleted successfully')}`);
}
} catch (e: any) { console.log('[KP] 删密码清理失败(非致命):', e.message); }
// 7) 解绑门锁(清理):功能页 → 右上设置 → Device Pairing → Unpair → 确认 Unpair → Unpaired successfully → Done。best-effort。
try {
await enterKeypad();
if (driver.platform === 'ios') {
// iOS:右上设置 Button(x>330,y<110)→ Device Pairing(行中心)→ Unpair 按钮 → 确认框 Unpair(取 y 最大)
const tbtns = await driver.findElementsRaw('class name', 'XCUIElementTypeButton').catch(() => [] as string[]);
for (const b of tbtns) { const r = await driver.getElementRect(b).catch(() => null); if (r && r.x > 330 && r.y < 110 && r.width < 60) { await driver.tapElement(b); break; } }
await sleep(2500);
const dp = await driver.findElementRaw('name', 'Device Pairing').catch(() => null);
if (dp) { const r = await driver.getElementRect(dp); await driver.tap(195, Math.round(r.y + r.height / 2)); await sleep(2500); }
const up = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Unpair"').catch(() => null);
if (up) { await driver.tapElement(up); await sleep(1800); }
const ups = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Unpair"').catch(() => [] as string[]);
let conf: { el: string; y: number } | null = null;
for (const u of ups) { const r = await driver.getElementRect(u).catch(() => null); if (r && (!conf || r.y > conf.y)) conf = { el: u, y: r.y }; } // 弹框确认 = y 最大那个
if (conf) await driver.tapElement(conf.el);
for (let i = 0; i < 10; i++) { if (await srcHas(driver, 'Unpaired successfully')) break; await sleep(1500); }
for (const t of ['Finish', 'Done', 'OK', '完成']) { if (await tapText(driver, t)) { await sleep(1500); break; } } // iOS 解绑后点 Finish
console.log(`[KP] iOS 解绑门锁=${await srcHas(driver, 'Unpaired successfully')}`);
} else {
const setBtn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/top_bar_right_icon').catch(() => null);
if (setBtn) { await driver.tapElement(setBtn); await sleep(2500); }
if (await tapText(driver, 'Device Pairing')) {
await sleep(2500);
if (await tapText(driver, 'Unpair')) { // 列表行 Unpair → 弹确认框
await sleep(1800);
const unpairs = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().text("Unpair")').catch(() => [] as string[]);
if (unpairs.length) { await driver.tapElement(unpairs[unpairs.length - 1]); }
for (let i = 0; i < 10; i++) { if (await srcHas(driver, 'Unpaired successfully') || await srcHas(driver, '解绑成功')) break; await sleep(1500); }
await tapText(driver, 'Done'); await sleep(2000);
console.log(`[KP] 解绑门锁=${await srcHas(driver, 'Unpaired successfully') || true}`);
}
}
}
} catch (e: any) { console.log('[KP] 解绑门锁清理失败(非致命):', e.message); }
reporter.record(`${ANCHOR} Keypad绑锁+密码+物理解锁`, 'PASS', Date.now() - start,
SKIP_PHYSICAL
? `功能页验证(绑定=${boundLock}/加密码/清理),跳过物理解锁(KP_SKIP_PHYSICAL)`
: `${boundLock} 物理输密码${PASSCODE}解锁成功;已清理密码+解绑锁`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(`${ANCHOR} Keypad绑锁+密码+物理解锁`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});
});