charging-cabinet/test/rssi_signal.test.mjs

195 lines
8.1 KiB
JavaScript
Raw Permalink Normal View History

fix(security)+fix(api)+docs: 越权面修复、登录门控与状态语义对齐、文档全面校对 - 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id 参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、 cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经 charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、 GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、 POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、 /api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2; /api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin - TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则 - 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式) - 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到") ⚠️ device-server 与 api-server 必须同批发布 - 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、 行号引用改小节引用、修 3 处错引小节 - 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2 与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、 charge_orders.status 标注 2 保留未使用) - 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红) - RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs - nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像 - 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级; docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正; 权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
2026-09-20 03:23:00 +08:00
/**
* RSSI 信号强度分档(协议定点:`rssi` 是 **0–99 的信号等级**,非 dBm)
*
* 覆盖**两端**(PC `software/web/src/utils/deviceStatus.ts` 与
* 小程序 `software/h5-uniapp/src/utils/deviceStatus.ts`)的 `rssiSignal` / `rssiBarColors`,
* 并断言**两端口径完全一致**(同一输入 → 同一 `level` / `bars` / `color`)。
*
* 为什么需要它:2026-09 之前两端**本来就不一致**——
* - PC 是 5 档(≥70 绿 / ≥55 蓝 / ≥40 黄 / ≥30 橙 / <30 灰),格数随档位增删;
* - 小程序只有 4 档(**没有 ≥70**)、30–39 用**红色**、且**恒渲染 5 格只改颜色**。
* 本次收敛为统一 5 档后,用本用例把"两端一致"钉死:**只改一端就会红**。
*
* 另外断言**档位边界**与**颜色表**(防止有人"统一"成另一套阈值却没人发现)。
*
* 运行:node test/rssi_signal.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])
}
// ---------------------------------------------------------------------------
// 权威分档表(写死在这里 = 契约;与两个实现文件里的 RSSI_TIERS 必须一致)
// ---------------------------------------------------------------------------
const EXPECTED_TIERS = [
{ min: 70, level: 4, bars: 5, color: '#16a34a', key: 'strong' },
{ min: 55, level: 3, bars: 4, color: '#2563eb', key: 'good' },
{ min: 40, level: 2, bars: 3, color: '#f59e0b', key: 'fair' },
{ min: 30, level: 1, bars: 2, color: '#f97316', key: 'weak' },
]
const EXPECTED_NONE = { level: 0, bars: 1, color: '#9ca3af', key: 'none' }
/**
* 只取契约字段:`rssiSignal()` 返回 `{level,bars,color,key}`,
* **不含** `RSSI_TIERS` 里的 `min`(阈值是实现细节,不进返回值)。
*/
function contract({ level, bars, color, key }) {
return { level, bars, color, key }
}
/** 权威分档表的契约形态(供边界断言直接用) */
const T = EXPECTED_TIERS.map(contract)
/** 该值按权威契约应落在哪一档 */
function expectedFor(rssi) {
if (rssi === null || rssi === undefined) return EXPECTED_NONE
const value = Number(rssi)
if (!Number.isFinite(value)) return EXPECTED_NONE
for (const tier of EXPECTED_TIERS) {
if (value >= tier.min) return contract(tier)
}
return EXPECTED_NONE
}
// ---------------------------------------------------------------------------
// 1. 两端一致性:遍历 0–99(协议全量程)逐值断言
// ---------------------------------------------------------------------------
test('test_web_and_mp_rssi_signal_agree_over_0_99', () => {
for (let rssi = 0; rssi <= 99; rssi++) {
assert.deepEqual(
web.rssiSignal(rssi),
mp.rssiSignal(rssi),
`rssi=${rssi} 两端 rssiSignal 不一致`,
)
assert.deepEqual(
web.rssiBarColors(rssi),
mp.rssiBarColors(rssi),
`rssi=${rssi} 两端 rssiBarColors 不一致`,
)
}
})
test('test_web_and_mp_rssi_agree_on_missing_and_boundary_values', () => {
const values = [null, undefined, Number.NaN, Number.POSITIVE_INFINITY, -1, 29, 30, 39, 40, 54, 55, 69, 70, 99, 100, 255]
for (const value of values) {
assert.deepEqual(web.rssiSignal(value), mp.rssiSignal(value), `rssi=${String(value)} 两端不一致`)
assert.deepEqual(
web.rssiBarColors(value),
mp.rssiBarColors(value),
`rssi=${String(value)} 两端 rssiBarColors 不一致`,
)
}
})
test('test_web_and_mp_rssi_tier_tables_are_identical', () => {
assert.deepEqual(web.RSSI_TIERS, mp.RSSI_TIERS, '两端 RSSI_TIERS 必须逐字一致')
assert.deepEqual(web.RSSI_NONE, mp.RSSI_NONE, '两端 RSSI_NONE 必须一致')
assert.equal(web.RSSI_MAX_BARS, mp.RSSI_MAX_BARS, '两端 RSSI_MAX_BARS 必须一致')
assert.equal(web.RSSI_MAX_BARS, 5, '统一 5 格制')
})
// ---------------------------------------------------------------------------
// 2. 契约本身:档位边界 + 颜色表(防止"统一"成另一套阈值而无人发现)
// ---------------------------------------------------------------------------
test('test_rssi_tier_boundaries_match_agreed_contract', () => {
const boundaries = [
[-1, EXPECTED_NONE],
[0, EXPECTED_NONE],
[29, EXPECTED_NONE],
[30, T[3]],
[39, T[3]],
[40, T[2]],
[54, T[2]],
[55, T[1]],
[69, T[1]],
[70, T[0]],
[99, T[0]],
]
for (const [rssi, expected] of boundaries) {
assert.deepEqual(web.rssiSignal(rssi), expected, `rssi=${rssi} 档位不符契约`)
}
// 无数据 / 非数字一律落"无信号"档(灰色底格),不得猜测成"信号好"
for (const bad of [null, undefined, Number.NaN]) {
assert.deepEqual(web.rssiSignal(bad), EXPECTED_NONE, `rssi=${String(bad)} 必须落无信号档`)
}
})
test('test_rssi_colors_match_agreed_contract', () => {
// 收敛后的配色:绿 → 蓝 → 黄 → 橙 → 灰(**注意 ≥55 是蓝、30–39 是橙而非红**)
assert.deepEqual(
[99, 60, 45, 33, 10].map((v) => web.rssiSignal(v).color),
['#16a34a', '#2563eb', '#f59e0b', '#f97316', '#9ca3af'],
)
})
test('test_rssi_bars_increase_with_level_and_stay_within_max', () => {
let prev = 0
// 逐档递增,且 1..5 全部落在 5 格制内
for (const rssi of [10, 30, 40, 55, 70]) {
const { bars } = web.rssiSignal(rssi)
assert.ok(bars > prev, `rssi=${rssi} 的格数应比上一档多(bars=${bars})`)
assert.ok(bars >= 1 && bars <= web.RSSI_MAX_BARS, `rssi=${rssi} 格数越界:${bars}`)
prev = bars
}
// 满档 = 5 格;离线也是 1 格底格(不是 0 格 —— 0 格会让图标"消失")
assert.equal(web.rssiSignal(70).bars, 5)
assert.equal(web.rssiSignal(null).bars, 1)
})
test('test_rssi_bar_colors_length_equals_bars_and_single_color', () => {
for (let rssi = 0; rssi <= 99; rssi++) {
const signal = web.rssiSignal(rssi)
const colors = web.rssiBarColors(rssi)
assert.equal(colors.length, signal.bars, `rssi=${rssi} 颜色数组长度必须等于格数`)
assert.ok(colors.every((c) => c === signal.color), `rssi=${rssi} 每格必须同色`)
}
})
// ---------------------------------------------------------------------------
// 3. 反证:把"收敛前的两端不一致"作为反例 —— 若谁把某一端改回去,本组会红
// ---------------------------------------------------------------------------
test('test_mp_no_longer_uses_the_old_four_tier_red_30_39_scale', () => {
// 旧小程序口径:30–39 → 红;且没有 ≥70 档(55 就是最高)
// 收敛后:30–39 必须是**橙** `#f97316`,且 70 必须是**独立的一档**(绿,5 格)
assert.equal(mp.rssiSignal(35).color, '#f97316', '小程序 30–39 必须是橙色(旧实现是红 #ef4444)')
assert.notEqual(mp.rssiSignal(35).color, '#ef4444', '不得再出现旧的红色 30–39 档')
assert.notEqual(mp.rssiSignal(80).color, mp.rssiSignal(60).color, '≥70 必须是独立档(不与 ≥55 同色)')
assert.equal(mp.rssiSignal(80).bars, 5, '≥70 应为满格 5 格')
})
test('test_bars_are_not_constant_five_for_all_levels', () => {
// 收敛前的 bug:小程序恒渲染 5 格、只改颜色 ⇒ 所有档位 bars 都是 5。
// 现在格数必须随档位变化(否则本用例红)。
const barsSet = new Set([10, 30, 40, 55, 70].map((v) => mp.rssiSignal(v).bars))
assert.ok(barsSet.size >= 4, `格数必须随档位变化,实际集合:${[...barsSet].join(',')}`)
assert.notEqual(mp.rssiSignal(10).bars, mp.rssiSignal(70).bars, '离线档与满档格数必须不同')
})
// ---------------------------------------------------------------------------
// 运行
// ---------------------------------------------------------------------------
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)