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