charging-cabinet/test/device_status_parse.test.mjs

258 lines
12 KiB
JavaScript
Raw Normal View History

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