/** * 状态位解析纯函数单元测试(协议 §3.8 / §4.2) * * 覆盖**两端**(PC `software/web/src/utils/deviceStatus.ts` 与 * 小程序 `software/h5-uniapp/src/utils/deviceStatus.ts`)的柜级位域解析: * `parseCabinetStatus1` / `parseCabinetStatus2` / `cabinetDoorState` / `cabinetStatusTags`, * 并断言**两端口径完全一致**(同一输入 → 同一输出)。 * * 为什么用 Node 直跑:两个前端工程都没有测试运行器(无 vitest/jest), * 而 Node ≥ 23.6 原生支持 TS 类型擦除,可直接 import 这两个纯函数模块。 * * 运行:node test/device_status_parse.test.mjs */ import assert from 'node:assert/strict' import * as web from '../software/web/src/utils/deviceStatus.ts' import * as mp from '../software/h5-uniapp/src/utils/deviceStatus.ts' /** 极简用例收集器(保持仓库零依赖) */ const cases = [] function test(name, fn) { cases.push([name, fn]) } // --------------------------------------------------------------------------- // cabinet.status1(协议 §3.8) // --------------------------------------------------------------------------- test('test_parse_cabinet_status1_00_means_all_normal_not_missing', () => { const bits = web.parseCabinetStatus1('00') assert.ok(bits, '"00" 必须是有效值(全正常),不能当成无数据') assert.deepEqual(bits, { powerFail: false, smoke: false, waterLevel: false, tilt: false, doorOpen: false, heaterFanOn: false, contactorOpen: false, sprinklerOn: false, }) assert.equal(mp.parseCabinetStatus1('00').doorOpen, false) }) test('test_parse_cabinet_status1_82_is_sprinkler_and_smoke', () => { // "82" = 1000 0010 → bit7 喷淋 + bit1 烟雾(协议 §3.8 解析示例) const bits = web.parseCabinetStatus1('82') assert.equal(bits.sprinklerOn, true, 'bit7 = 喷淋') assert.equal(bits.smoke, true, 'bit1 = 烟雾') assert.equal(bits.doorOpen, false) assert.equal(bits.powerFail, false, 'bit0 未置位') }) test('test_parse_cabinet_status1_10_is_door_open', () => { // "10" = 0001 0000 → bit4 柜门打开(协议 §3.8 解析示例) const webBits = web.parseCabinetStatus1('10') const mpBits = mp.parseCabinetStatus1('10') assert.equal(webBits.doorOpen, true) assert.equal(mpBits.doorOpen, true) // 其余位全部为 0 assert.equal(webBits.powerFail, false) assert.equal(webBits.contactorOpen, false, 'bit6 未置位 = 接触器导通') assert.equal(web.cabinetDoorState('10'), 'open') assert.equal(mp.cabinetDoorState('10'), 'open') }) test('test_parse_cabinet_status1_each_bit_maps_to_its_own_name', () => { const expect = [ ['01', 'powerFail'], ['02', 'smoke'], ['04', 'waterLevel'], ['08', 'tilt'], ['10', 'doorOpen'], ['20', 'heaterFanOn'], ['40', 'contactorOpen'], ['80', 'sprinklerOn'], ] for (const [hex, field] of expect) { const bits = web.parseCabinetStatus1(hex) assert.equal(bits[field], true, `${hex} 应置位 ${field}`) // 只有该位为 true assert.equal(Object.values(bits).filter(Boolean).length, 1, `${hex} 只应有一个位置位`) } }) // --------------------------------------------------------------------------- // cabinet.status2(协议 §3.8) // --------------------------------------------------------------------------- test('test_parse_cabinet_status2_01_is_sound_light_alarm', () => { const webBits = web.parseCabinetStatus2('01') const mpBits = mp.parseCabinetStatus2('01') assert.equal(webBits.soundLightAlarm, true) assert.equal(webBits.doorOpenTimeout, false) assert.deepEqual(webBits, mpBits) }) test('test_parse_cabinet_status2_02_is_door_open_timeout', () => { // bit1 柜门打开超时:**平台只呈现,阈值由硬件定义**(协议 §3.8) const bits = web.parseCabinetStatus2('02') assert.equal(bits.doorOpenTimeout, true) assert.equal(bits.soundLightAlarm, false) assert.equal(web.parseCabinetStatus2('03').soundLightAlarm, true, '03 = 声光报警 + 超时') assert.equal(web.parseCabinetStatus2('03').doorOpenTimeout, true) }) test('test_parse_cabinet_status2_reserved_bits_are_ignored', () => { // bit2–bit7 是硬件保留位:置 1 也不得产出任何展示项 const bits = web.parseCabinetStatus2('fc') assert.deepEqual(bits, { soundLightAlarm: false, doorOpenTimeout: false }) assert.deepEqual(web.cabinetStatusTags('00', 'fc'), [], '保留位不得产生告警标签') }) test('test_parse_pow_status_reserved_bits_are_ignored', () => { // pow_status bit3–bit6 是保留位(0x08/0x10/0x20/0x40):不参与展示判断 assert.equal(web.parsePowStatus('08').label, '无电源', '只有保留位 = 无电源,不得报故障') assert.equal(mp.parsePowStatus('08').label, '无电源') assert.equal(web.parsePowStatus('80').label, '电源正常(未打开)') }) // --------------------------------------------------------------------------- // 非法输入 / 缺失:必须返回"未知",绝不 panic、绝不落 "00" // --------------------------------------------------------------------------- test('test_parse_cabinet_status_invalid_input_returns_null', () => { // 长度 >2 / 非 hex / 空串 / 纯空白 → 全部"未知"(null) // 注:`"1"` 这类 1 位写法是**合法**的(`parseHexByte` 与后端 `normalize_hex_byte` 之外的 // 既有前端约定:接受 1–2 位 hex),因此不在非法集合里。 const bad = ['', ' ', '123', 'zz', '0x10', '-1', '1g', '柜门'] for (const raw of bad) { assert.equal(web.parseCabinetStatus1(raw), null, `status1 非法输入 ${JSON.stringify(raw)} 必须为 null`) assert.equal(web.parseCabinetStatus2(raw), null, `status2 非法输入 ${JSON.stringify(raw)} 必须为 null`) assert.equal(mp.parseCabinetStatus1(raw), null) assert.equal(mp.parseCabinetStatus2(raw), null) assert.equal(web.cabinetDoorState(raw), 'unknown', '非法输入 → 柜门状态未知(不能显示成"关闭")') } }) test('test_parse_cabinet_status_uppercase_hex_is_accepted', () => { // 大小写不敏感:"1A" 与 "1a" 等价(后端统一存小写,但前端必须容错) assert.deepEqual(web.parseCabinetStatus1('1A'), web.parseCabinetStatus1('1a')) assert.equal(web.parseCabinetStatus1('1A').doorOpen, true, '0x1A bit4 = 1 → 柜门打开') assert.equal(mp.parseCabinetStatus1('1A').doorOpen, true) assert.equal(web.parseCabinetStatus1(' 10 ').doorOpen, true, '两侧空白应被 trim') assert.equal(mp.parseCabinetStatus1(' 10 ').doorOpen, true) assert.equal(web.cabinetDoorState('1A'), 'open', '大写 hex 同样要按位解析') assert.equal(web.cabinetDoorState('aB'), 'closed', '0xAB = 1010 1011,bit4 = 0') }) test('test_parse_cabinet_status_null_and_undefined_return_null', () => { for (const raw of [null, undefined]) { assert.equal(web.parseCabinetStatus1(raw), null) assert.equal(web.parseCabinetStatus2(raw), null) assert.equal(mp.parseCabinetStatus1(raw), null) assert.equal(mp.parseCabinetStatus2(raw), null) assert.equal(web.cabinetDoorState(raw), 'unknown') assert.equal(mp.cabinetDoorState(raw), 'unknown') } }) // --------------------------------------------------------------------------- // 柜门三态(协议 §4.2) // --------------------------------------------------------------------------- test('test_cabinet_door_state_open_closed_unknown', () => { assert.equal(web.cabinetDoorState('10'), 'open', 'bit4=1 → 已被拉开') assert.equal(web.cabinetDoorState('00'), 'closed', '"00" 是有效值 → 已关闭') assert.equal(web.cabinetDoorState(null), 'unknown', '无数据 → 未知(不得显示成"关闭")') assert.equal(web.cabinetDoorTag('10').label, '柜门打开') assert.equal(web.cabinetDoorTag('00').label, '柜门关闭') assert.equal(web.cabinetDoorTag(null).label, '柜门未知') assert.equal(mp.cabinetDoorState('10'), 'open') assert.equal(mp.cabinetDoorState('00'), 'closed') assert.equal(mp.cabinetDoorState(null), 'unknown') assert.equal(mp.cabinetDoorTag(null).label, '柜门未知') }) // --------------------------------------------------------------------------- // 柜级告警标签:null(无数据)与 [](确实无告警)必须区分 // --------------------------------------------------------------------------- test('test_cabinet_status_tags_null_when_any_side_missing', () => { // 任一维度没数据 → 整体"未知",**不能返回空数组**(空数组 = 全正常) assert.equal(web.cabinetStatusTags(null, '00'), null) assert.equal(web.cabinetStatusTags('00', null), null) assert.equal(web.cabinetStatusTags(null, null), null) assert.equal(web.cabinetStatusTags(undefined, undefined), null) assert.equal(web.cabinetStatusTags('zz', '00'), null, '非法 hex 同样视为无数据') assert.equal(mp.cabinetStatusTags(null, '00'), null) assert.equal(mp.cabinetStatusTags(undefined, undefined), null) }) test('test_cabinet_status_tags_empty_array_when_all_normal', () => { // "00"+"00" = 全部正常 → 空数组(与上面的 null 语义严格区分) assert.deepEqual(web.cabinetStatusTags('00', '00'), []) assert.deepEqual(mp.cabinetStatusTags('00', '00'), []) }) test('test_cabinet_status_tags_lists_active_bits_in_order', () => { // status1="82"(喷淋+烟雾)+ status2="01"(声光报警) const webLabels = web.cabinetStatusTags('82', '01').map((t) => t.label) const mpLabels = mp.cabinetStatusTags('82', '01').map((t) => t.label) assert.deepEqual(webLabels, ['烟雾', '喷淋', '声光报警']) assert.deepEqual(mpLabels, webLabels, '两端标签文案与顺序必须一致') // 位域可同时置位:断电+烟雾+水位+倾倒+接触器断开+喷淋 + 声光报警 + 柜门超时 + 加热风扇("ff") const all = web.cabinetStatusTags('ff', '03').map((t) => t.label) assert.deepEqual(all, ['断电', '烟雾', '水位', '倾倒', '喷淋', '接触器断开', '声光报警', '柜门打开超时', '加热风扇']) // 柜门(bit4)不是告警:单独展示,不出现在告警列表里 assert.deepEqual(web.cabinetStatusTags('10', '00'), [], '柜门打开不进告警标签') assert.deepEqual(web.cabinetStatusTags('20', '00').map((t) => t.label), ['加热风扇']) }) // --------------------------------------------------------------------------- // 两端一致性(PC 与小程序口径必须逐位一致) // --------------------------------------------------------------------------- test('test_web_and_mp_cabinet_status_parsers_agree', () => { for (let i = 0; i <= 0xff; i++) { const hex = i.toString(16).padStart(2, '0') assert.deepEqual( web.parseCabinetStatus1(hex), mp.parseCabinetStatus1(hex), `status1=${hex} 两端解析不一致`, ) assert.deepEqual( web.parseCabinetStatus2(hex), mp.parseCabinetStatus2(hex), `status2=${hex} 两端解析不一致`, ) assert.equal(web.cabinetDoorState(hex), mp.cabinetDoorState(hex), `status1=${hex} 柜门三态不一致`) assert.deepEqual( web.cabinetStatusTags(hex, hex).map((t) => t.label), mp.cabinetStatusTags(hex, hex).map((t) => t.label), `status1/2=${hex} 告警标签不一致`, ) } }) // --------------------------------------------------------------------------- // 运行 // --------------------------------------------------------------------------- let failed = 0 for (const [name, fn] of cases) { try { fn() console.log(`ok ${name}`) } catch (err) { failed += 1 console.error(`FAIL ${name}\n ${err.message}`) } } console.log(`\n${cases.length - failed} passed, ${failed} failed (共 ${cases.length} 个用例)`) process.exit(failed === 0 ? 0 : 1)