charging-cabinet/test/device_status_parse.test.mjs
12451 ee92102f3c feat(scope)+fix(db)+feat(security): 数据范围(项目级)生效、库列类型对齐、安全加固、状态位分层展示、能耗实时聚合
- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
  总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
  前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
  GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
  DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
  恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
  新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
  服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
  仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
  每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
2026-09-20 02:44:51 +08:00

258 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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