fastbee/yack-mini/tests/gateway-disabled-device.test.js
2026-09-27 14:46:05 +08:00

330 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 回归:设备开关关闭后不得再尝试连接蓝牙
*
* 现场日志(复现源):
* [Storage] 禁用设备: BT_ECB0D1002F84
* [BLE] 连接超时: EC:B0:D1:00:2F:84
* [BLE] 连续 3 次建链失败,复位适配器 ← 替一个已经关掉的设备复位整个蓝牙栈
* [GW] 启动失败: Error: 连接失败: 连接超时。 ← 还把失败上屏 + 排 5s 自动重试
*
* 红线(本套用例锁死):
* 1. 设备开关关闭后,蓝牙**不再发起任何建链尝试**(不建链、不排自动重试、不复位适配器)
* 2. 关闭动作必须**立即生效**,不能等队列里在途的 _boot(最坏一次建链超时 + 适配器复位)
* 3. MQTT 是应用级连接,**不受设备开关影响**(防止有人把 MQTT 也绑进设备开关)
*
* 跑法:node tests/gateway-disabled-device.test.js
*/
const {
loadRuntime, seedScene, sleep,
} = require('./helpers/fake-wx.js');
/* ============================ 迷你断言器 ============================ */
let passed = 0;
let failed = 0;
/** 安全的展示值(_reconnectTimer 是 Timeout 对象,直接 JSON.stringify 会因循环引用抛错) */
function safe(v) {
if (v === undefined) return 'undefined';
if (v === null || typeof v !== 'object') return JSON.stringify(v);
try {
return JSON.stringify(v);
} catch (e) {
return String(v);
}
}
function ok(cond, label, extra) {
if (cond) {
passed++;
process.stdout.write(` ✓ ${label}\n`);
} else {
failed++;
process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${safe(extra)}` : ''}\n`);
}
}
function eq(actual, expected, label) {
ok(actual === expected, `${label} (期望 ${safe(expected)},实际 ${safe(actual)})`);
}
/* ============================ 用例 ============================ */
const DEV = { serialNumber: 'BT_ECB0D1002F84', deviceId: 'EC:B0:D1:00:2F:84' };
/** 关掉 console 噪音,只留我们自己写的结果 */
function muteConsole() {
const raw = { log: console.log, warn: console.warn, error: console.error };
console.log = () => {};
console.warn = () => {};
console.error = () => {};
return () => Object.assign(console, raw);
}
/**
* 用例 1:无启用设备时,不碰蓝牙;MQTT 照旧(应用级)
*/
async function case1_noEnabledDevice() {
process.stdout.write('\n[1] 无启用设备 → 不碰蓝牙,MQTT 仍按应用级自动补连\n');
const restore = muteConsole();
const rt = loadRuntime();
seedScene(rt, { ...DEV, enabled: false });
rt.bleService.setConfig({ connectTimeout: 150, opTimeout: 150, adapterOpTimeout: 120, closeWaitTimeout: 60 });
await rt.gateway.ensureStarted();
await rt.gateway.resume();
await sleep(120);
restore();
eq(rt.state.calls.create.length, 0, '没有发起任何建链');
eq(rt.state.calls.openAdapter, 0, '没有打开蓝牙适配器');
eq(rt.gateway.getSnapshot().phase, 'stopped', '相位收敛到 stopped');
ok(rt.mqtt._rec.connectCalls.length > 0, 'MQTT 仍被自动补连(应用级,不受设备开关影响)', rt.mqtt._rec.connectCalls);
}
/**
* 用例 2:运行中关闭设备开关(等价首页 index.handleDeviceSwitch 关闭分支)
* → 立即中止在途建链,不复位适配器,不上屏错误,不排自动重试
*/
async function case2_disableWhileConnecting() {
process.stdout.write('\n[2] 采集中关闭设备 → 立即中止在途建链 / 不复位适配器 / 不上屏 / 不重试\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'hang' }); // 建链永不回调:模拟"连接超时"现场
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 150, opTimeout: 150, adapterOpTimeout: 120, closeWaitTimeout: 60 });
// 已经连续失败 2 次:再来 1 次就够 3 次阈值 → 差一点就要"复位适配器"。
// 这正是现场日志里那一步;关闭设备后它必须被跳过
rt.bleService._consecutiveCreateFails = 2;
const boot = rt.gateway.ensureStarted(); // 首页 onShow / 启用设备后的启动
await sleep(40);
eq(rt.state.calls.create.length, 1, '建链已在途(1 次)');
// 记录整条状态轨迹:关闭设备后**任何时刻**都不该出现"异常/启动失败",
// 这样断言与"收尾拆除"的先后无关(看单次快照会被 teardown 清干净而假绿)
const trace = [];
const unsub = rt.gateway.subscribe((s) => trace.push({ phase: s.phase, lastError: s.lastError }));
// === 用户按下设备开关的关闭 ==(index.handleDeviceSwitch)
rt.storage.deactivateDevice(DEV.serialNumber);
const stop = rt.gateway.ensureStarted();
await Promise.allSettled([boot, stop]);
await sleep(120);
const snap = rt.gateway.getSnapshot();
const createAfter = rt.state.calls.create.length;
const closeConnAfter = rt.state.calls.closeConn.length;
const adapterResets = rt.state.calls.closeAdapter;
// 观察窗口:远超建链超时,任何新的自动重连/失败重试都会冒头
await sleep(500);
const createSettled = rt.state.calls.create.length;
unsub();
restore();
ok(closeConnAfter >= 1, '关闭时立即拆链(closeBLEConnection 已调用)', closeConnAfter);
eq(adapterResets, 0, '不复位蓝牙适配器(作废流程不得升级为全局复位)');
eq(createSettled, createAfter, '关闭后窗口内没有新增建链尝试');
ok(!trace.some((t) => t.phase === 'error'), '状态轨迹里从未出现 error 相位', trace);
ok(trace.every((t) => t.lastError === null), '状态轨迹里从未上屏"启动失败"', trace);
eq(snap.lastError, null, '终态无残留错误');
eq(rt.gateway._initRetryTimer, null, '没有排下"启动失败自动重试"');
eq(rt.gateway._device, null, '网关已无采集目标');
eq(snap.phase, 'stopped', '相位收敛到 stopped');
}
/**
* 用例 3:bleService 隔离 —— 作废的流程抛 cancelled,且不升级为"复位适配器"
*/
async function case3_obsoleteFlowNoEscalate() {
process.stdout.write('\n[3] bleService:作废流程抛 cancelled,不复位适配器\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'hang' });
rt.bleService.setConfig({ connectTimeout: 120, opTimeout: 120, adapterOpTimeout: 100, closeWaitTimeout: 50 });
await rt.bleService.initBluetooth();
const opensAfterInit = rt.state.calls.openAdapter;
rt.bleService._consecutiveCreateFails = 2;
const p = rt.bleService.connectDevice(DEV.deviceId);
await sleep(30);
await rt.bleService.disconnect(); // 用户关闭设备 → 作废在途流程
const err = await p.then(() => null, (e) => e);
await sleep(160);
restore();
ok(err && err.cancelled === true, '在途流程以 cancelled 结算(上层按"不算失败"处理)', err && err.message);
eq(rt.state.calls.closeAdapter, 0, '没有复位适配器');
eq(rt.state.calls.openAdapter, opensAfterInit, '没有重新打开适配器');
eq(rt.bleService.getState(), 'disconnected', '状态为 disconnected');
}
/**
* 用例 4:resetAdapter 必须"复原"手动断开意图,而不是无条件清零
*/
async function case4_resetAdapterKeepsIntent() {
process.stdout.write('\n[4] resetAdapter 复原 _manuallyDisconnected(不得抹掉用户意图)\n');
const restore = muteConsole();
const rt = loadRuntime();
rt.bleService.setConfig({ adapterOpTimeout: 100 });
rt.bleService._manuallyDisconnected = true; // 场景:设备已被关闭
await rt.bleService.resetAdapter();
eq(rt.bleService._manuallyDisconnected, true, '复位后仍保持"已停用/主动断开"意图');
rt.bleService._manuallyDisconnected = false; // 场景:正常自愈重连中复位
await rt.bleService.resetAdapter();
eq(rt.bleService._manuallyDisconnected, false, '正常复位后不残留 true(否则进入死态)');
restore();
}
/**
* 用例 5:主动断开后,自动重连不得把链路拉回来
*/
async function case5_noReconnectAfterDisconnect() {
process.stdout.write('\n[5] 断开后不排自动重连(入口 + 定时器触发双重把关)\n');
const restore = muteConsole();
const rt = loadRuntime();
rt.bleService.setConfig({ connectTimeout: 120, opTimeout: 120 });
await rt.bleService.initBluetooth();
await rt.bleService.disconnect();
rt.bleService._scheduleReconnect();
eq(rt.bleService._reconnectTimer, null, '主动断开后 _scheduleReconnect 不排定时器');
// 反过来:允许重连时排下的定时器,在触发前用户关了设备 → 到点也不得重连
rt.bleService._lastDeviceId = DEV.deviceId;
rt.bleService._manuallyDisconnected = false;
rt.bleService._reconnectAttempts = 0;
rt.bleService._scheduleReconnect(); // 1s 后触发
await rt.bleService.disconnect(); // 触发前关闭设备
const before = rt.state.calls.create.length;
await sleep(1300);
const after = rt.state.calls.create.length;
restore();
eq(after, before, '定时器到点后没有重连');
}
/**
* 用例 6:重新开启设备后一切照旧(防止"关掉就再也连不上"的修过头)
*/
async function case6_reenableWorks() {
process.stdout.write('\n[6] 重新开启设备 → 正常建链并进入采集中\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'ok' });
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
const boot = rt.gateway.ensureStarted();
await sleep(60);
eq(rt.state.calls.create.length, 1, '建链 1 次');
rt.storage.deactivateDevice(DEV.serialNumber);
await rt.gateway.ensureStarted();
await boot.catch(() => {});
await sleep(80);
eq(rt.gateway.getSnapshot().phase, 'stopped', '关闭后停止');
rt.storage.activateDevice(DEV.serialNumber);
await rt.gateway.ensureStarted();
await sleep(150);
const snap = rt.gateway.getSnapshot();
restore();
ok(rt.state.calls.create.length >= 2, '重新开启后再次建链', rt.state.calls.create.length);
eq(snap.bleConnected, true, 'BLE 已就绪');
eq(snap.phase, 'running', '相位 running(采集中)');
eq(snap.lastError, null, '无残留错误');
}
/**
* 用例 7:gateway.start(已停用的设备) 既不建链,也不连累当前正在跑的采集
*/
async function case7_startDisabledDeviceIsIgnored() {
process.stdout.write('\n[7] start(已停用设备) → 不建链且不打断当前采集\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'ok' });
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
await rt.gateway.ensureStarted();
await sleep(150);
eq(rt.gateway.getSnapshot().phase, 'running', '设备 A 采集中');
const closeConnBefore = rt.state.calls.closeConn.length;
await rt.gateway.start({
serialNumber: 'BT_OTHER_DEVICE',
deviceId: 'AA:BB:CC:DD:EE:FF',
enabled: false,
});
await sleep(80);
const snap = rt.gateway.getSnapshot();
restore();
ok(!rt.state.calls.create.includes('AA:BB:CC:DD:EE:FF'), '没有替已停用设备建链', rt.state.calls.create);
eq(rt.state.calls.closeConn.length, closeConnBefore, '没有拆掉当前设备的链路');
eq(snap.phase, 'running', '当前采集不受影响');
eq(snap.device && snap.device.serialNumber, DEV.serialNumber, '目标仍是设备 A');
}
/**
* 用例 8:直接切换设备(A 开 → 开 B,A 自动失活)不得被同步中止逻辑弄坏
* 单 enabled 不变量下开 B 等于关 A,_syncTarget 会立即拆 A 的链
*/
async function case8_switchDeviceStillWorks() {
process.stdout.write('\n[8] 切换到另一台设备(原设备自动失活)仍能正常接管\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'ok' });
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
await rt.gateway.ensureStarted();
await sleep(120);
eq(rt.gateway.getSnapshot().phase, 'running', '设备 A 采集中');
const B = { serialNumber: 'BT_AAAABBBBCCCC', deviceId: 'AA:AA:BB:BB:CC:CC', name: 'B' };
rt.storage.addDevice({ ...B, bleName: B.serialNumber, enabled: false, createTime: Date.now() });
rt.storage.activateDevice(B.serialNumber); // 开 B = 关 A
await rt.gateway.ensureStarted();
await sleep(200);
const snap = rt.gateway.getSnapshot();
const adapterResets = rt.state.calls.closeAdapter;
const creates = rt.state.calls.create.slice();
restore();
eq(rt.storage.getEnabledDevice().serialNumber, B.serialNumber, 'B 成为唯一启用设备');
eq(rt.storage.getDevice(DEV.serialNumber).enabled, false, 'A 已失活');
ok(creates.includes(B.deviceId), '已为 B 建链', creates);
eq(snap.device && snap.device.serialNumber, B.serialNumber, '网关目标切到 B');
eq(snap.bleConnected, true, 'B 的 BLE 已就绪');
eq(snap.phase, 'running', '采集中');
eq(adapterResets, 0, '切换设备不需要复位适配器');
eq(snap.lastError, null, '切换过程无错误上屏');
}
/* ============================ 主流程 ============================ */
async function main() {
const cases = [
case1_noEnabledDevice,
case2_disableWhileConnecting,
case3_obsoleteFlowNoEscalate,
case4_resetAdapterKeepsIntent,
case5_noReconnectAfterDisconnect,
case6_reenableWorks,
case7_startDisabledDeviceIsIgnored,
case8_switchDeviceStillWorks,
];
for (const c of cases) {
try {
await c();
} catch (e) {
failed++;
process.stdout.write(` ✗ 用例抛异常: ${e && e.stack}\n`);
}
}
process.stdout.write(`\n===== 设备开关关闭后不再连蓝牙:通过 ${passed} / 失败 ${failed} =====\n`);
process.exit(failed ? 1 : 0);
}
main();