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