- 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
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)
|