/** * 回归:设备开关关闭后不得再尝试连接蓝牙 * * 现场日志(复现源): * [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();