258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
|
|
/**
|
|||
|
|
* 状态位解析纯函数单元测试(协议 §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)
|