diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/tool/ToolController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/tool/ToolController.java index 337dd926..7726cf06 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/tool/ToolController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/tool/ToolController.java @@ -175,11 +175,10 @@ public class ToolController extends BaseController { if (mqttConfig.getUsername().equals(username) && mqttConfig.getPassword().equals(password)) { log.info("-----------服务端mqtt认证成功,clientId:{}---------------", clientid); ret.put("result", "allow"); - return ResponseEntity.ok().body(ret); } else { ret.put("result", "deny"); - return ResponseEntity.ok().body(ret); } + return ResponseEntity.ok().body(ret); } else if (clientid.startsWith("web") || clientid.startsWith("phone")) { // web端和移动端认证:token认证 String token = password; diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductAuthenticateModel.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductAuthenticateModel.java index 3d3ef548..cd7202f4 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductAuthenticateModel.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductAuthenticateModel.java @@ -1,8 +1,6 @@ package com.fastbee.iot.model; -import com.fastbee.common.annotation.Excel; - public class ProductAuthenticateModel { /** * 产品分类ID @@ -59,6 +57,9 @@ public class ProductAuthenticateModel { */ private String mqttSecret; + /** + * 1=简单认证,2=加密认证,3=简单+加密认证 + */ private int vertificateMethod; public int getVertificateMethod() { diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ToolServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ToolServiceImpl.java index 8b13ca43..8621843f 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ToolServiceImpl.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ToolServiceImpl.java @@ -310,7 +310,7 @@ public class ToolServiceImpl implements IToolService { if (productModel.getIsAuthorize() == 1) { // 授权码验证和处理 String resultMessage = authCodeProcess(authCode, mqttModel, productModel); - if (!resultMessage.equals("")) { + if (!resultMessage.isEmpty()) { return returnUnauthorized(mqttModel, resultMessage); } } @@ -318,13 +318,13 @@ public class ToolServiceImpl implements IToolService { if (productModel.getStatus() == 2) { return returnUnauthorized(mqttModel, "设备简单认证,设备处于禁用状态"); } - log.info("-----------设备简单认证成功,clientId:" + mqttModel.getClientId() + "---------------"); + log.info("-----------设备简单认证成功,clientId:{}---------------", mqttModel.getClientId()); return ResponseEntity.ok().body("ok"); } else { // 自动添加设备 int result = deviceService.insertDeviceAuto(mqttModel.getDeviceNumber(), mqttModel.getUserId(), mqttModel.getProductId()); if (result == 1) { - log.info("-----------设备简单认证成功,并自动添加设备到系统,clientId:" + mqttModel.getClientId() + "---------------"); + log.info("-----------设备简单认证成功,并自动添加设备到系统,clientId:{}---------------", mqttModel.getClientId()); return ResponseEntity.ok().body("ok"); } return returnUnauthorized(mqttModel, "设备简单认证,自动添加设备失败"); @@ -343,7 +343,7 @@ public class ToolServiceImpl implements IToolService { return returnUnauthorized(mqttModel, "设备加密认证,设备对应产品不支持加密认证"); } String decryptPassword = AESUtils.decrypt(mqttModel.getPassword(), productModel.getMqttSecret()); - if (decryptPassword == null || decryptPassword.equals("")) { + if (decryptPassword == null || decryptPassword.isEmpty()) { return returnUnauthorized(mqttModel, "设备加密认证,mqtt密码解密失败"); } String[] passwordArray = decryptPassword.split("&"); @@ -352,7 +352,7 @@ public class ToolServiceImpl implements IToolService { return returnUnauthorized(mqttModel, "设备加密认证,mqtt密码加密格式为:密码 & 过期时间 & 授权码,其中授权码为可选"); } String mqttPassword = passwordArray[0]; - Long expireTime = Long.valueOf(passwordArray[1]); + long expireTime = Long.parseLong(passwordArray[1]); String authCode = passwordArray.length == 3 ? passwordArray[2] : ""; // 验证用户名 if (!mqttModel.getUserName().equals(productModel.getMqttAccount())) { @@ -370,7 +370,7 @@ public class ToolServiceImpl implements IToolService { if (productModel.getIsAuthorize() == 1) { // 授权码验证和处理 String resultMessage = authCodeProcess(authCode, mqttModel, productModel); - if (!resultMessage.equals("")) { + if (!resultMessage.isEmpty()) { return returnUnauthorized(mqttModel, resultMessage); } } @@ -379,13 +379,13 @@ public class ToolServiceImpl implements IToolService { if (productModel.getStatus() == 2) { return returnUnauthorized(mqttModel, "设备加密认证,设备处于禁用状态"); } - log.info("-----------设备加密认证成功,clientId:" + mqttModel.getClientId() + "---------------"); + log.info("-----------设备加密认证成功,clientId:{}---------------", mqttModel.getClientId()); return ResponseEntity.ok().body("ok"); } else { // 自动添加设备 int result = deviceService.insertDeviceAuto(mqttModel.getDeviceNumber(), mqttModel.getUserId(), mqttModel.getProductId()); if (result == 1) { - log.info("-----------设备加密认证成功,并自动添加设备到系统,clientId:" + mqttModel.getClientId() + "---------------"); + log.info("-----------设备加密认证成功,并自动添加设备到系统,clientId:{}---------------", mqttModel.getClientId()); return ResponseEntity.ok().body("ok"); } return returnUnauthorized(mqttModel, "设备加密认证,自动添加设备失败"); @@ -434,8 +434,8 @@ public class ToolServiceImpl implements IToolService { ret.put("is_superuser", false); // 设备端认证:加密认证(E)和简单认证(S,配置的账号密码认证) String[] clientArray = clientid.split("&"); - if (clientArray.length != 4 || clientArray[0].equals("") || clientArray[1].equals("") || clientArray[2].equals("") || clientArray[3].equals("")) { - return this.returnUnauthorized(new MqttAuthenticationModel(clientid, username, password), "设备mqtt客户端Id格式为:认证类型 & 设备编号 & 产品ID & 用户ID"); + if (clientArray.length != 4 || clientArray[0].isEmpty() || clientArray[1].isEmpty() || clientArray[2].isEmpty() || clientArray[3].isEmpty()) { + return this.returnUnauthorized(new MqttAuthenticationModel(clientid, username, password), "设备MQTT客户端Id格式为:认证类型 & 设备编号 & 产品ID & 用户ID"); } String authType = clientArray[0]; String deviceNumber = clientArray[1]; @@ -444,13 +444,13 @@ public class ToolServiceImpl implements IToolService { // 产品认证信息 ProductAuthenticateModel model = deviceService.selectProductAuthenticate(new AuthenticateInputModel(deviceNumber, productId)); if (model == null) { - log.error("-----------设备认证,通过产品ID查询不到信息,clientId:" + clientid + "---------------"); + log.error("-----------设备认证,通过产品ID查询不到信息,clientId:{}---------------", clientid); ret.put("result", "deny"); return ResponseEntity.ok().body(ret); } if (model.getProductStatus() != 2) { // 产品必须为发布状态:1-未发布,2-已发布 - log.error("-----------设备认证,设备对应产品还未发布,clientId:" + clientid + "---------------"); + log.error("-----------设备认证,设备对应产品还未发布,clientId:{}---------------", clientid); ret.put("result", "deny"); return ResponseEntity.ok().body(ret); } @@ -460,24 +460,22 @@ public class ToolServiceImpl implements IToolService { ResponseEntity res = this.simpleMqttAuthentication(new MqttAuthenticationModel(clientid, username, password, deviceNumber, productId, userId), model); if (res.getStatusCodeValue() == HttpStatus.OK.value()) { ret.put("result", "allow"); - return ResponseEntity.ok().body(ret); } else { ret.put("result", "deny"); - return ResponseEntity.ok().body(ret); } + return ResponseEntity.ok().body(ret); } else if (authType.equals("E")) { // 设备加密认证 ResponseEntity res = this.encryptAuthentication(new MqttAuthenticationModel(clientid, username, password, deviceNumber, productId, userId), model); if (res.getStatusCodeValue() == HttpStatus.OK.value()) { ret.put("result", "allow"); - return ResponseEntity.ok().body(ret); } else { ret.put("result", "deny"); - return ResponseEntity.ok().body(ret); } + return ResponseEntity.ok().body(ret); } else { - log.error("-----------设备认证,认证类型有误,clientId:" + clientid + "---------------"); + log.error("-----------设备认证,认证类型有误,clientId:{}---------------", clientid); ret.put("result", "deny"); return ResponseEntity.ok().body(ret); } @@ -488,8 +486,8 @@ public class ToolServiceImpl implements IToolService { */ private String authCodeProcess(String authCode, MqttAuthenticationModel mqttModel, ProductAuthenticateModel productModel) { String message = ""; - if (authCode.equals("")) { - return message = "设备认证,设备授权码不能为空"; + if (authCode.isEmpty()) { + return "设备认证,设备授权码不能为空"; } // 查询授权码是否存在 ProductAuthorize authorize = productAuthorizeMapper.selectFirstAuthorizeByAuthorizeCode(new ProductAuthorize(authCode, productModel.getProductId())); diff --git a/yack-mini/miniprogram/env.config.js b/yack-mini/miniprogram/env.config.js index e7d8c91c..07c9c6dd 100644 --- a/yack-mini/miniprogram/env.config.js +++ b/yack-mini/miniprogram/env.config.js @@ -13,10 +13,10 @@ const DEV_CONFIG = { // 生产环境 const PROD_CONFIG = { baseUrl: 'https://ycdat.cn/prod-api', - mqttServer: 'wxs://ycdat.cn/mqtt', + mqttServer: 'wss://ycdat.cn/mqtt', }; -// 当前环境:development | production -const ENV = 'development'; +// 当前环境:development wxede2cc5d39b2c6ea | production wx8bcff4708d1f6d2b +const ENV = 'production'; module.exports = ENV === 'development' ? DEV_CONFIG : PROD_CONFIG; \ No newline at end of file diff --git a/yack-mini/miniprogram/pages/mine/mine.wxml b/yack-mini/miniprogram/pages/mine/mine.wxml index 09eb6b62..0ec7e296 100644 --- a/yack-mini/miniprogram/pages/mine/mine.wxml +++ b/yack-mini/miniprogram/pages/mine/mine.wxml @@ -38,16 +38,16 @@ MQTT 连接 - - 设备编号(SN) - - 产品ID - 产品密钥 + 产品Mqtt账号 + + + + 产品Mqtt密码 diff --git a/yack-mini/project.config.json b/yack-mini/project.config.json index 0f2e11c9..8ee365b2 100644 --- a/yack-mini/project.config.json +++ b/yack-mini/project.config.json @@ -48,7 +48,7 @@ }, "compileType": "miniprogram", "libVersion": "3.3.4", - "appid": "wxede2cc5d39b2c6ea", + "appid": "wx8bcff4708d1f6d2b", "projectname": "yack-mini", "condition": {}, "editorSetting": { diff --git a/yack-mini/tests/detail-console-toggles.test.js b/yack-mini/tests/detail-console-toggles.test.js deleted file mode 100644 index 173571e6..00000000 --- a/yack-mini/tests/detail-console-toggles.test.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * 回归:设备详情页底部收发控制台的三个开关(滚动 / 接收Hex / 发送Hex) - * - * 缺陷(修复前): - * 三处都是「裸 checkbox + bindchange」——而 bindchange 是 checkbox-group 的事件, - * checkbox 自身不提供(官方属性表只有 value/disabled/checked/color)。 - * handler 又按 group 的数组语义写成 `e.detail.value.length > 0`: - * 布尔值下 `true.length === undefined` → `undefined > 0 === false`,三个开关恒定失效。 - * 现场表现:取消「接收Hex」切不到 ASCII(曾被误判成 asciiOfHex 显示「·」的设计行为)、 - * 「滚动」关不掉也开不了、发送区一直按 hex 语义走。 - * - * 红线(本套用例锁死): - * 1. wxml 结构:每个 checkbox 必须在 checkbox-group 内,group 各绑一个 change - * 2. detail.js 不得回退成 `e.detail.value.length > 0` 这种数组语义误用 - * 3. group 派发 value 数组时,三个开关能正确切换 data - * 4. 取消「接收Hex」→ 日志切成 ASCII 视图;勾上 → 回到 hex 视图(用最近快照重渲染) - * 5. 取消「滚动」→ autoScroll 与 logFollowing 同时关闭;勾上 → 同时恢复 - * - * 跑法:node tests/detail-console-toggles.test.js - */ -const fs = require('fs'); -const path = require('path'); -const { MINI_ROOT, loadRuntime } = require('./helpers/fake-wx.js'); - -/* ============================ 迷你断言器 ============================ */ - -let passed = 0; -let failed = 0; - -function ok(cond, label, extra) { - if (cond) { - passed++; - process.stdout.write(` ✓ ${label}\n`); - } else { - failed++; - process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${JSON.stringify(extra)}` : ''}\n`); - } -} - -function eq(actual, expected, label) { - ok(actual === expected, `${label} (期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)})`); -} - -/* ============================ 被测文件与页面实例 ============================ */ - -const DETAIL_DIR = path.join(MINI_ROOT, 'pages/device/detail'); -const WXML = fs.readFileSync(path.join(DETAIL_DIR, 'detail.wxml'), 'utf8'); -const DETAIL_JS = fs.readFileSync(path.join(DETAIL_DIR, 'detail.js'), 'utf8'); -const DETAIL_SRC = path.join(DETAIL_DIR, 'detail.js'); - -/** - * 每次调用都重建一套干净的运行时 + 全新的页面实例, - * 用例之间不共享 data,避免"上一条用例把开关改过"造成的假绿/假红 - */ -function loadPage() { - const rt = loadRuntime(); // 内部 clearMiniCache + 重建 global.wx - global.getApp = () => ({ globalData: {} }); - let cfg = null; - global.Page = (c) => { cfg = c; }; - require(DETAIL_SRC); - if (!cfg) throw new Error('detail.js 未调用 Page(),加载失败'); - - const page = Object.assign({}, cfg); - page.data = JSON.parse(JSON.stringify(cfg.data)); - page.setData = function (patch, cb) { - Object.assign(this.data, patch); - if (cb) cb(); - }; - return { rt, page }; -} - -/* ============================ 用例 ============================ */ - -/** 结构红线:勾选框必须被 checkbox-group 包着 */ -function case1_wxmlStructure() { - process.stdout.write('\n[1] wxml 结构:checkbox 必须在 checkbox-group 内\n'); - - const groups = WXML.match(/]*>[\s\S]*?<\/checkbox-group>/g) || []; - eq(groups.length, 3, '三个开关各有一个 checkbox-group'); - eq((WXML.match(/]/g) || []).length, 3, '页面共 3 个 checkbox'); - - ok(!/]*bindchange/.test(WXML), - '没有任何「裸 checkbox 直接挂 bindchange」的写法(这是本次缺陷的形态)'); - - ok(groups.some((g) => g.includes('bindchange="onAutoScrollToggle"') && g.includes('value="scroll"')), - '「滚动」group 绑 onAutoScrollToggle,子项 value=scroll'); - ok(groups.some((g) => g.includes('bindchange="onRecvHexToggle"') && g.includes('value="hex"')), - '接收「Hex」group 绑 onRecvHexToggle,子项 value=hex'); - ok(groups.some((g) => g.includes('bindchange="onSendHexToggle"') && g.includes('value="hex"')), - '发送「Hex」group 绑 onSendHexToggle,子项 value=hex'); -} - -/** 代码红线:不得回退成数组语义误用 */ -function case2_codeGuards() { - process.stdout.write('\n[2] detail.js 取值方式:读 group 的 value 数组,而非误判为布尔\n'); - - ok(!/\.detail\.value\.length\s*>\s*0/.test(DETAIL_JS), - '没有 `e.detail.value.length > 0` 的残留(该写法在布尔值下恒 false)'); - - ok(/onAutoScrollToggle\(e\)\s*\{[^}]*includes\('scroll'\)/.test(DETAIL_JS), - 'onAutoScrollToggle 用 includes(\'scroll\') 读值'); - ok(/onRecvHexToggle\(e\)\s*\{[^}]*includes\('hex'\)/.test(DETAIL_JS), - 'onRecvHexToggle 用 includes(\'hex\') 读值'); - ok(/onSendHexToggle\(e\)\s*\{[^}]*includes\('hex'\)/.test(DETAIL_JS), - 'onSendHexToggle 用 includes(\'hex\') 读值'); - - // 反例固化:裸 checkbox 若派发布尔值,旧写法必然恒 false —— 本次缺陷的数学根因 - const legacy = (v) => v.length > 0; - eq(legacy(true), false, '反例:布尔 true 在旧写法下取到 false'); - eq(legacy(false), false, '反例:布尔 false 在旧写法下取到 false'); -} - -/** 滚动开关:勾选/取消都要同时管住 autoScroll 与 logFollowing */ -function case3_autoScrollToggle() { - process.stdout.write('\n[3] 「滚动」开关\n'); - const { page } = loadPage(); - - eq(page.data.autoScroll, true, '初始勾选(默认自动跟随)'); - eq(page.data.logFollowing, true, '初始处于跟随态'); - - page.onAutoScrollToggle({ detail: { value: [] } }); // 用户取消勾选 - eq(page.data.autoScroll, false, '取消勾选 → 停止自动滚动'); - eq(page.data.logFollowing, false, '取消勾选 → 同时退出跟随态'); - - page.onAutoScrollToggle({ detail: { value: ['scroll'] } }); // 用户重新勾选 - eq(page.data.autoScroll, true, '重新勾选 → 恢复自动滚动'); - eq(page.data.logFollowing, true, '重新勾选 → 恢复跟随态'); -} - -/** 接收 Hex 开关:切换 data 并用最近快照重渲染日志视图 */ -function case4_recvHexToggle() { - process.stdout.write('\n[4] 接收「Hex」开关(含视图重渲染)\n'); - const { page } = loadPage(); - - // 造一份最近快照:gateway 的 dataList 最新在前 - page.data.device = { serialNumber: 'BT_1' }; - page._lastSnap = { - device: { serialNumber: 'BT_1' }, - dataList: [ - { id: '2', hex: '41424344' }, // 最新一帧 - { id: '1', hex: '31323334' }, - ], - }; - - eq(page.data.recvHex, true, '初始勾选(hex 视图)'); - - page.onRecvHexToggle({ detail: { value: [] } }); // 取消勾选 → ASCII 视图 - eq(page.data.recvHex, false, '取消勾选 → 切到 ASCII 视图'); - eq(page.data.displayList.length, 2, 'ASCII 视图:用最近快照重渲染出 2 行'); - eq(page.data.displayList[1].text, 'ABCD', '最新在后:末行为最新帧的 ASCII 文本'); - eq(page.data.displayList[0].text, '1234', 'ASCII 视图:可打印字节原样显示'); - - page.onRecvHexToggle({ detail: { value: ['hex'] } }); // 勾上 → 回 hex 视图 - eq(page.data.recvHex, true, '勾选 → 回到 hex 视图'); - eq(page.data.displayList[1].text, '41424344', 'hex 视图:末行为原始 hex 帧'); - - // 非本设备的快照不得上屏 - page._lastSnap = { device: { serialNumber: 'BT_OTHER' }, dataList: [{ id: '9', hex: 'AA' }] }; - page.onRecvHexToggle({ detail: { value: [] } }); - eq(page.data.displayList.length, 0, '非本设备快照:不上屏(保持空列表)'); -} - -/** 发送 Hex 开关:切换语义 + 已有输入大小写归一 */ -function case5_sendHexToggle() { - process.stdout.write('\n[5] 发送「Hex」开关\n'); - const { page } = loadPage(); - - eq(page.data.sendHex, true, '初始勾选(按 hex 字节发送)'); - - page.data.sendInput = 'at+csq'; - page.onSendHexToggle({ detail: { value: [] } }); // 取消 → ASCII 文本发送 - eq(page.data.sendHex, false, '取消勾选 → 按 ASCII 文本发送'); - eq(page.data.sendInput, 'at+csq', 'ASCII 模式保留原文大小写(大小写是内容)'); - - page.data.sendInput = 'at+csq'; - page.onSendHexToggle({ detail: { value: ['hex'] } }); // 勾上 → hex 语义 - eq(page.data.sendHex, true, '勾选 → 按 hex 字节发送'); - eq(page.data.sendInput, 'AT+CSQ', 'hex 模式统一大写(便于阅读)'); -} - -/* ============================ 主流程 ============================ */ - -function main() { - process.stdout.write('===== 详情页收发控制台开关 回归 =====\n'); - const cases = [ - case1_wxmlStructure, - case2_codeGuards, - case3_autoScrollToggle, - case4_recvHexToggle, - case5_sendHexToggle, - ]; - for (const c of cases) { - try { - c(); - } catch (e) { - failed++; - process.stdout.write(` ✗ 用例抛异常: ${e && e.stack}\n`); - } - } - - process.stdout.write(`\n===== 详情页收发控制台开关:通过 ${passed} / 失败 ${failed} =====\n`); - process.exit(failed ? 1 : 0); -} - -main(); diff --git a/yack-mini/tests/gateway-disabled-device.test.js b/yack-mini/tests/gateway-disabled-device.test.js deleted file mode 100644 index 5eb2df77..00000000 --- a/yack-mini/tests/gateway-disabled-device.test.js +++ /dev/null @@ -1,329 +0,0 @@ -/** - * 回归:设备开关关闭后不得再尝试连接蓝牙 - * - * 现场日志(复现源): - * [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(); diff --git a/yack-mini/tests/helpers/fake-wx.js b/yack-mini/tests/helpers/fake-wx.js deleted file mode 100644 index 575f0057..00000000 --- a/yack-mini/tests/helpers/fake-wx.js +++ /dev/null @@ -1,307 +0,0 @@ -/** - * 假 wx 运行时(供 yack-mini 纯 JS 模块的 Node 回归测试使用) - * - * 设计要点(踩过的坑,勿改回去): - * - **storage 必须深拷贝**:真机 wx.setStorageSync 是序列化存储,直接存对象引用会让 - * "读列表 → 改字段 → 存回"与内存里的对象互为别名,测出假的通过。 - * - **native 桩必须能"失败"与"永不回调"**:被测状态机的分支几乎都由失败码驱动, - * 只回 success 会让一半用例假绿(链接不存在时要回 10002、适配器未开时要回 10001)。 - * - **异步回调**:wx 的回调都是异步的,同步触发会打乱"先注册监听再连接"的顺序。 - * - 只暴露被测代码真正会用到的 API,**新增 wx.onXxx 用法时只改这一个文件**。 - */ -const path = require('path'); - -const MINI_ROOT = path.resolve(__dirname, '../../miniprogram'); - -/* ============================ 存储 ============================ */ - -function createStorageArea() { - const map = new Map(); - const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v))); - return { - _map: map, - getStorageSync: (k) => (map.has(k) ? clone(map.get(k)) : ''), - setStorageSync: (k, v) => { map.set(k, clone(v)); }, - removeStorageSync: (k) => { map.delete(k); }, - clearStorageSync: () => map.clear(), - }; -} - -/* ============================ BLE 桩 ============================ */ - -// 一个非通用(非 1800/1801/180a/180f)服务,带 1 个 notify + 1 个 write 特征, -// 让 _discoverServices/_discoverCharacteristics 能走到 READY -const SVC = '0000FFF0-0000-1000-8000-00805F9B34FB'; -const CHAR_NOTIFY = '0000FFF1-0000-1000-8000-00805F9B34FB'; -const CHAR_WRITE = '0000FFF2-0000-1000-8000-00805F9B34FB'; - -function createBleState() { - return { - adapterOpen: false, - links: new Set(), // 已建立的链路 deviceId(createBLEConnection 成功才加入) - createMode: 'ok', // 'ok' | 'hang'(native 永不回调)| 'fail' - createErrMsg: '连接超时', - listeners: { conn: [], char: [], adapter: [] }, - calls: { - openAdapter: 0, closeAdapter: 0, - create: [], closeConn: [], mtu: [], services: [], chars: [], notify: [], - }, - }; -} - -function createBleApi(st) { - const emitConn = (deviceId, connected) => { - st.listeners.conn.forEach((cb) => cb({ deviceId, connected })); - }; - - return { - onBLEConnectionStateChange: (cb) => st.listeners.conn.push(cb), - onBLECharacteristicValueChange: (cb) => st.listeners.char.push(cb), - onBluetoothAdapterStateChange: (cb) => st.listeners.adapter.push(cb), - - openBluetoothAdapter: (o = {}) => { - st.calls.openAdapter++; - st.adapterOpen = true; - setTimeout(() => o.success && o.success({}), 0); - }, - - closeBluetoothAdapter: (o = {}) => { - st.calls.closeAdapter++; - st.adapterOpen = false; - // native 关适配器会同时拆掉全部链路并派发断链事件(实测如此) - const ids = Array.from(st.links); - st.links.clear(); - setTimeout(() => { - ids.forEach((id) => emitConn(id, false)); - o.success && o.success({}); - o.complete && o.complete({}); - }, 0); - }, - - createBLEConnection: (o = {}) => { - st.calls.create.push(o.deviceId); - if (st.createMode === 'hang') return; // 坏桩:永不回调(测超时兜底) - setTimeout(() => { - if (st.createMode === 'fail') { - o.fail && o.fail({ errCode: 10003, errMsg: st.createErrMsg }); - return; - } - st.links.add(o.deviceId); - o.success && o.success({}); - }, 0); - }, - - closeBLEConnection: (o = {}) => { - st.calls.closeConn.push(o.deviceId); - const had = st.links.has(o.deviceId); - setTimeout(() => { - if (!had) { - // 链路本就不存在:真机回 fail 且**不派发** onBLEConnectionStateChange - o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' }); - o.complete && o.complete({}); - return; - } - st.links.delete(o.deviceId); - emitConn(o.deviceId, false); - o.success && o.success({}); - o.complete && o.complete({}); - }, 0); - }, - - setBLEMTU: (o = {}) => { - st.calls.mtu.push(o.deviceId); - setTimeout(() => { o.success && o.success({}); o.complete && o.complete({}); }, 0); - }, - - getBLEDeviceServices: (o = {}) => { - st.calls.services.push(o.deviceId); - setTimeout(() => { - if (!st.links.has(o.deviceId)) { - // 链路不存在时必须失败,否则"GATT 探活"永远返回 alive(死链发现不了) - o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' }); - return; - } - o.success && o.success({ - services: [ - { uuid: '00001800-0000-1000-8000-00805F9B34FB', isPrimary: true }, - { uuid: SVC, isPrimary: true }, - ], - }); - }, 0); - }, - - getBLEDeviceCharacteristics: (o = {}) => { - st.calls.chars.push(o.characteristicId || o.serviceId); - setTimeout(() => { - if (!st.links.has(o.deviceId)) { - o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' }); - return; - } - o.success && o.success({ - characteristics: [ - { uuid: CHAR_NOTIFY, properties: { notify: true, read: true } }, - { uuid: CHAR_WRITE, properties: { write: true, writeNoResponse: true } }, - ], - }); - }, 0); - }, - - notifyBLECharacteristicValueChange: (o = {}) => { - st.calls.notify.push(o.characteristicId); - setTimeout(() => o.success && o.success({}), 0); - }, - - getSystemInfoSync: () => ({ platform: 'android' }), - }; -} - -/* ============================ wx 组装 ============================ */ - -function createWx(storageArea, bleState) { - return { - ...createBleApi(bleState), - ...storageArea, - arrayBufferToBase64: (buf) => Buffer.from(new Uint8Array(buf)).toString('base64'), - base64ToArrayBuffer: (b64) => { - const buf = Buffer.from(b64, 'base64'); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); - }, - getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0), - onNetworkStatusChange: () => {}, - showToast: () => {}, - showModal: () => {}, - showLoading: () => {}, - hideLoading: () => {}, - }; -} - -/* ============================ 模块加载 ============================ */ - -function clearMiniCache() { - Object.keys(require.cache).forEach((k) => { - if (k.startsWith(MINI_ROOT + path.sep) || k.startsWith(MINI_ROOT + '/')) delete require.cache[k]; - }); -} - -function stubModule(id, exports) { - return { id, filename: id, loaded: true, exports, children: [], paths: [] }; -} - -/** - * 假 MQTT 客户端(隔离无关依赖:只记录"被要求建连"的次数) - */ -function createFakeMqtt() { - const rec = { connectCalls: [], publishCalls: [], disconnectCalls: 0, reconnectCalls: 0 }; - let held = false; // 是否已持有客户端(canAutoConnect 语义:持有即视为已连/自愈中) - const client = { - userDisconnected: false, - isConnected: () => false, - getState: () => 'disconnected', - canAutoConnect: () => !client.userDisconnected && !held, - onStateChange: () => {}, - connect: (opts) => { - rec.connectCalls.push((opts && opts.clientId) || ''); - held = true; - return Promise.resolve(); - }, - reconnect: (opts) => { - rec.reconnectCalls++; - rec.connectCalls.push((opts && opts.clientId) || ''); - held = true; - return Promise.resolve(); - }, - disconnect: () => { - rec.disconnectCalls++; - client.userDisconnected = true; - held = false; - return Promise.resolve(); - }, - publish: (topic, message) => { - rec.publishCalls.push({ topic, message }); - return Promise.resolve(); - }, - }; - return { - MQTT_STATE: { DISCONNECTED: 'disconnected', CONNECTING: 'connecting', CONNECTED: 'connected' }, - mqttClient: client, - _rec: rec, - }; -} - -/** - * 准备一套干净的运行时并加载被测模块 - * @param {Object} [opts] - * @param {'ok'|'hang'|'fail'} [opts.createMode] - 建链桩行为 - * @returns {Object} { wx, state, storageArea, gateway, bleService, storage, measurePoint, mqtt, GATEWAY_PHASE, BLE_STATE } - */ -function loadRuntime(opts = {}) { - clearMiniCache(); - - const storageArea = createStorageArea(); - const bleState = createBleState(); - if (opts.createMode) bleState.createMode = opts.createMode; - global.wx = createWx(storageArea, bleState); - - const mqtt = createFakeMqtt(); - const mqttPath = require.resolve(path.join(MINI_ROOT, 'services/mqtt-client.js')); - require.cache[mqttPath] = stubModule(mqttPath, { - MQTT_STATE: mqtt.MQTT_STATE, - mqttClient: mqtt.mqttClient, - }); - - const { gateway, GATEWAY_PHASE } = require(path.join(MINI_ROOT, 'services/gateway.js')); - const { bleService, BLE_STATE } = require(path.join(MINI_ROOT, 'services/ble.js')); - const storage = require(path.join(MINI_ROOT, 'utils/storage.js')); - const measurePoint = require(path.join(MINI_ROOT, 'utils/measure-point.js')); - - return { - wx: global.wx, - state: bleState, - storageArea, - mqtt, - gateway, - bleService, - storage, - measurePoint, - GATEWAY_PHASE, - BLE_STATE, - }; -} - -/** - * 造一个已登录 + 已配 MQTT 凭证 + 一台启用设备的常规现场 - */ -function seedScene(rt, { serialNumber = 'BT_ECB0D1002F84', deviceId = 'EC:B0:D1:00:2F:84', enabled = true } = {}) { - rt.storage.setUserInfo({ userId: 1, userName: 'tester' }); - rt.storage.setMqttConfig({ serialNumber, productId: 12, productSecret: 'secret' }); - rt.measurePoint.setMeasurePointId('371883'); - rt.storage.addDevice({ - deviceId, - serialNumber, - name: serialNumber, - bleName: serialNumber, - enabled, - connected: false, - createTime: Date.now(), - }); - if (enabled) rt.storage.activateDevice(serialNumber); - return rt.storage.getDevice(serialNumber); -} - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -module.exports = { - MINI_ROOT, - SVC, - CHAR_NOTIFY, - CHAR_WRITE, - createStorageArea, - createBleState, - createWx, - createFakeMqtt, - clearMiniCache, - stubModule, - loadRuntime, - seedScene, - sleep, -}; diff --git a/yack-mini/tests/p0-regressions.test.js b/yack-mini/tests/p0-regressions.test.js deleted file mode 100644 index c3f8f1cc..00000000 --- a/yack-mini/tests/p0-regressions.test.js +++ /dev/null @@ -1,247 +0,0 @@ -/** - * 回归:本轮 code review 修掉的 5 个 P0 缺陷 - * - * 每条都锁死"修复后的语义",并尽可能给出**反向证明**(展示修复前的写法会产出什么), - * 防止将来有人"顺手简化"时把缺陷放回去。 - * - * 红线: - * 1. crypto 必须按**字节**做 XOR(UTF-8),非 ASCII 无损往返;失败不 fail-open - * 2. addDevice 必须返回布尔,数量达上限时返回 false(调用方据此中断) - * 3. publish 必须有超时出口,底层永不回调时也能结算 - * 4. subscriptions 的生命周期属于"业务意图",只有 disconnect() 能清 - * 5. 产品ID 必须是正整数,NaN / 小数不得进入 topic - * - * 跑法:node tests/p0-regressions.test.js - */ -const fs = require('fs'); -const path = require('path'); -const { MINI_ROOT, loadRuntime, clearMiniCache } = require('./helpers/fake-wx.js'); - -/* ============================ 迷你断言器 ============================ */ - -let passed = 0; -let failed = 0; - -function ok(cond, label, extra) { - if (cond) { - passed++; - process.stdout.write(` ✓ ${label}\n`); - } else { - failed++; - process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${JSON.stringify(extra)}` : ''}\n`); - } -} - -function eq(actual, expected, label) { - ok(actual === expected, `${label} (期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)})`); -} - -/* ============================ 加载真实 MQTT 模块 ============================ */ - -/** - * ⚠️ 必须在 loadRuntime() 之前调用:loadRuntime 会用假 mqtt-client 顶掉 - * services/mqtt-client.js,之后 require 拿到的是桩而不是被测实现 - */ -function loadRealMqtt() { - clearMiniCache(); - global.wx = { - getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0), - arrayBufferToBase64: () => '', - base64ToArrayBuffer: () => new ArrayBuffer(0), - }; - return require(path.join(MINI_ROOT, 'services/mqtt-client.js')); -} - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -/* ============================ 用例 ============================ */ - -/** P0-5:publish 超时出口 */ -async function casePublishTimeout() { - process.stdout.write('\n[1] P0-5 publish 必须有超时出口(底层永不回调也能结算)\n'); - - const { mqttClient } = loadRealMqtt(); - - // 坏桩:publish 永不回调,模拟卡在在途队列里的 QoS1 消息 - mqttClient.client = { connected: true, publish: () => {} }; - const t0 = Date.now(); - let msg = null; - try { - await mqttClient.publish('/1/BT_01/001/measure/post', 'AABB', { publishTimeout: 120 }); - } catch (err) { - msg = err.message; - } - const cost = Date.now() - t0; - eq(msg, '发布超时', '超时后 reject'); - ok(cost >= 110 && cost < 1000, `约 120ms 内结算,不是永久挂起 (实际 ${cost}ms)`); - - // 未连接仍应立刻拒绝,不进超时路径 - mqttClient.client = null; - let msg2 = null; - try { await mqttClient.publish('/t', 'x'); } catch (err) { msg2 = err.message; } - eq(msg2, 'MQTT 未连接', '未连接时立即 reject'); - - // 成功路径不受影响 - mqttClient.client = { connected: true, publish: (t, m, o, cb) => cb && cb(null) }; - let resolved = false; - await mqttClient.publish('/t', 'x').then(() => { resolved = true; }).catch(() => {}); - ok(resolved, '正常回调时仍然 resolve'); -} - -/** P0-4:订阅表不得随连接被清空 */ -async function caseSubscriptions() { - process.stdout.write('\n[2] P0-4 订阅表生命周期属于业务意图,只有 disconnect() 能清\n'); - - const { mqttClient } = loadRealMqtt(); - - mqttClient.subscriptions.set('/p/1/sn/function/get', () => {}); - mqttClient.client = { end: () => {} }; - mqttClient._teardownClient(mqttClient.client); - eq(mqttClient.subscriptions.size, 1, '拆掉客户端后订阅表仍在(供 _resubscribe 恢复)'); - - const SRC = fs.readFileSync(path.join(MINI_ROOT, 'services/mqtt-client.js'), 'utf8'); - const clears = SRC.match(/this\.subscriptions\.clear\(\)/g) || []; - eq(clears.length, 1, 'subscriptions.clear() 全文件只允许 1 处'); - ok(SRC.indexOf('this.subscriptions.clear()') > SRC.indexOf(' disconnect()'), - '唯一那处在 disconnect() 内(用户主动断开才清)'); - ok(SRC.includes('_resubscribe'), 'connect 成功后仍有 _resubscribe 恢复订阅'); -} - -/** P0-3:crypto 字节级编解码 */ -async function caseCrypto() { - process.stdout.write('\n[3] P0-3 crypto 按字节做 XOR,非 ASCII 无损往返\n'); - - const rt = loadRuntime(); - const { encrypt, decrypt, encryptFields, decryptFields } = require(path.join(MINI_ROOT, 'utils/crypto.js')); - const KEY = 'fastbee-mini-2026'; - - // 修复前的写法(按 UTF-16 charCode XOR + setUint8 截断),用于兼容性与反向证明 - const oldEncrypt = (text, key = KEY) => { - let s = ''; - for (let i = 0; i < text.length; i++) s += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length)); - const bytes = []; - for (let i = 0; i < s.length; i++) bytes.push(s.charCodeAt(i) & 0xFF); - return rt.wx.arrayBufferToBase64(new Uint8Array(bytes).buffer); - }; - - // 存量兼容:纯 ASCII 的新密文必须与旧实现逐字节一致,老数据才能继续解开 - for (const s of ['secret', 'fastbee', 'Abc123!@#', '0123456789abcdefXYZ']) { - eq(encrypt(s), oldEncrypt(s), `新密文与旧实现一致(存量可解): ${s}`); - eq(decrypt(oldEncrypt(s)), s, `新实现能解开旧密文: ${s}`); - } - - // 非 ASCII:修复前会静默损坏,修复后必须无损 - for (const s of ['密钥中文', '密码abc中文', '测点·2026', '🔧扳手', 'Ünïcödé']) { - eq(decrypt(encrypt(s)), s, `往返无损: ${s}`); - } - ok(decrypt(oldEncrypt('密钥中文')) !== '密钥中文', - '反向证明:旧实现对中文确实不可逆损坏', decrypt(oldEncrypt('密钥中文'))); - - // 不得 fail-open(旧实现异常时返回明文/密文本身) - eq(encrypt(''), '', 'encrypt("") 返回空串'); - eq(decrypt(''), '', 'decrypt("") 返回空串'); - const realDecode = global.wx.base64ToArrayBuffer; - global.wx.base64ToArrayBuffer = () => { throw new Error('decode boom'); }; - eq(decrypt('anycipher'), '', '解码异常时返回空串,不拿密文冒充明文'); - global.wx.base64ToArrayBuffer = realDecode; - - // 字段级 - const enc = encryptFields({ productSecret: '我的密钥123', serialNumber: 'BT_01' }, ['productSecret']); - ok(enc.productSecret !== '我的密钥123' && enc._productSecret_encrypted === true, '敏感字段已加密并打标记'); - eq(enc.serialNumber, 'BT_01', '非敏感字段不受影响'); - const dec = decryptFields(enc, ['productSecret']); - eq(dec.productSecret, '我的密钥123', '字段级往返无损'); - eq(dec._productSecret_encrypted, undefined, '解密后清除标记'); -} - -/** P0-2:addDevice 返回值 */ -async function caseAddDevice() { - process.stdout.write('\n[4] P0-2 addDevice 数量达上限必须返回 false(供调用方中断)\n'); - - const rt = loadRuntime(); - let added = 0; - for (let i = 0; i < 60; i++) { - const sn = 'DEV_' + String(i).padStart(3, '0'); - if (rt.storage.addDevice({ deviceId: 'ID_' + i, serialNumber: sn, name: sn, enabled: false }) === true) added++; - } - eq(added, 50, '恰好写入 50 台后开始返回 false'); - eq(rt.storage.addDevice({ serialNumber: 'DEV_OVER', deviceId: 'X' }), false, '第 51 台返回 false'); - eq(rt.storage.getDeviceList().length, 50, '列表未被超限写入污染'); - eq(rt.storage.addDevice({ serialNumber: 'DEV_000', deviceId: 'ID_0', name: '改名' }), true, '覆盖既有编号仍返回 true'); - - // 调用方确实检查了返回值:scan.js 里 addDevice 之后不得无条件继续激活+启动 - const SCAN = fs.readFileSync(path.join(MINI_ROOT, 'pages/device/scan/scan.js'), 'utf8'); - ok(/if\s*\(\s*!storage\.addDevice\(/.test(SCAN), 'scan.js 对 addDevice 的返回值做了失败分支'); - ok(!/^\s*storage\.addDevice\(deviceConfig\);\s*$/m.test(SCAN), '不再有「无视返回值直接调用」的写法'); -} - -/** P0-1:产品ID 必须是正整数 */ -async function caseProductId() { - process.stdout.write('\n[5] P0-1 产品ID 必须校验为整数,NaN/小数不得进入 topic\n'); - - const rt = loadRuntime(); - // 必须种进 storage:auth.getMqttCredentials 读的是存储里的用户信息,不是页面 data。 - // 不种的话合法用例会走到"未登录"分支而失败,非法用例也会因提前 return 而假绿 - rt.storage.setUserInfo({ userId: 1, userName: 'tester' }); - - const toasts = []; - global.wx.showToast = (o) => toasts.push(o.title); - global.wx.showModal = (o) => toasts.push('MODAL:' + o.title); - global.wx.showLoading = () => {}; - global.wx.hideLoading = () => {}; - - global.getApp = () => ({ globalData: {} }); - let cfg = null; - global.Page = (c) => { cfg = c; }; - require(path.join(MINI_ROOT, 'pages/mine/mine.js')); - if (!cfg) throw new Error('mine.js 未调用 Page(),加载失败'); - - const page = Object.assign({}, cfg); - page.data = JSON.parse(JSON.stringify(cfg.data)); - page.setData = function (patch) { Object.assign(this.data, patch); }; - page.setData({ userInfo: { userId: 1 } }); - - const auth = require(path.join(MINI_ROOT, 'utils/auth.js')); - - // 反向证明:修复前的 parseInt 会产出 NaN,而网关的判空两道都挡不住它 - eq(auth.buildMeasurePostTopic(parseInt('abc'), 'BT_01', '001'), '/NaN/BT_01/001/measure/post', - '反向证明:parseInt("abc") 会造出 /NaN/ 脏 topic'); - eq(auth.buildMeasurePostTopic(parseInt('12.5'), 'BT_01', '001'), '/12/BT_01/001/measure/post', - '反向证明:parseInt("12.5") 会静默截断成 12'); - - for (const bad of ['abc', '12.5', '0', '-3', '']) { - rt.storage.removeMqttConfig(); - toasts.length = 0; - page.setData({ mqttConnectingLocal: false }); - page.data.mqttForm = { serialNumber: 'BT_01', productId: bad, productSecret: 'sec' }; - await page.handleMqttConnect(); - eq(rt.storage.getMqttConfig(), null, `非法产品ID "${bad}" 不落盘`); - } - - // 合法输入仍正常 - rt.storage.removeMqttConfig(); - toasts.length = 0; - page.setData({ mqttConnectingLocal: false }); - page.data.mqttForm = { serialNumber: 'BT_01', productId: '12', productSecret: 'sec' }; - await page.handleMqttConnect(); - const saved = rt.storage.getMqttConfig(); - ok(saved !== null, '合法产品ID 正常保存'); - eq(saved && saved.productId, 12, '保存为数字 12(不是字符串)'); - eq(auth.buildMeasurePostTopic(saved.productId, saved.serialNumber, '001'), '/12/BT_01/001/measure/post', - '构造出的 topic 正确'); -} - -/* ============================ 执行 ============================ */ - -(async () => { - process.stdout.write('\n===== P0 缺陷回归 =====\n'); - // 顺序敏感:[1][2] 必须先加载真实 mqtt-client,之后 loadRuntime 才会注入假模块 - await casePublishTimeout(); - await caseSubscriptions(); - await caseCrypto(); - await caseAddDevice(); - await caseProductId(); - - process.stdout.write(`\n===== 通过 ${passed} / 失败 ${failed} =====\n`); - process.exit(failed === 0 ? 0 : 1); -})(); diff --git a/yack-web/src/views/iot/device/device-edit.vue b/yack-web/src/views/iot/device/device-edit.vue index e6e4c8bd..d833262a 100644 --- a/yack-web/src/views/iot/device/device-edit.vue +++ b/yack-web/src/views/iot/device/device-edit.vue @@ -55,25 +55,6 @@ - - - - - - -