/** * 覆盖网络 · **序㉖ 骨干稳定选路(jitter 主序)** 单测。 * * ## 这个文件要回答的三个问题 * 1. **选路到底看不看 jitter?** —— `E1`(**本序主判据**):两条候选里 **jitter 更低但 RTT 更高** * 的那条**必须被选中**。改造前是"按目录发布序取第一个" ⇒ 这条断言**必红**。 * 2. **jitter 劣化会不会真的换路、有没有可断言的计数?** —— `E2`:超阈值 ⇒ 换 + `[relay-jitter]` * 告警 + `jitterSwitches` 计数;⛔ 且**冷却语义一字不动**(jitter 换址**没有**豁免权)。 * 3. **利用率余量门在不在?** —— `E4`:`used/max ≥ RELAY_UTIL_MAX_PCT` ⇒ 拒新接入(⛔ 不打满), * 且已在册节点重连**仍然优先**。 * * ## 🔴 零回归的两条机器判据(本文件也在替它们守门) * - **`D9`**:tracker **零样本** ⇒ `orderByJitter` 返回**同一个数组**(⛔ 不是"等值的新数组") * ⇒ 改造前后**逐字一致**,`npm test` 的 176 条老用例才可能一条都不动。 * - **`D3` 护栏**:稳态换址的**文案与判据**逐字不变(jitter 只**新增**触发条件)。 * * 运行:`npm run build && node --test test/overlay-jitter.test.mjs`(测 `lib/` 产物)。 * ⚠️ **本文件尚未挂进 `npm test` / `npm run verify`**(那两个入口在 `package.json` 里, * 不在序㉖ 的在册文件集内 ⇒ 按 R7 不动它,已在回报里点名)。 * * @module test/overlay-jitter */ import assert from 'node:assert/strict' import { createHmac, randomBytes } from 'node:crypto' import { test } from 'node:test' import { MUX, RelayServer, decodeMux, encodeJsonFrame } from '../lib/net/relay/index.js' import { JitterTracker, absDeltas, histogram, jitterThresholds, orderByJitter, percentile, pickJitterTarget, statsFromDeltas, } from '../lib/net/relay/jitter.js' import { RelayFailoverSupervisor } from '../lib/net/relay/switcher.js' const BASE = 47000 const SPAN = 100 const PATH = '/dshs-relay' /** 测试用阈值:`sampleGapMs = 0`(不节流,夹具要能"每 tick 都采样")。 */ const TH = { enabled: true, sampleMax: 32, minSamples: 3, switchMs: 20, histMaxMs: 200, histBuckets: 8, sampleGapMs: 0, } const URL_A = 'wss://relay-a.example/dshs-relay' const URL_B = 'wss://relay-b.example/dshs-relay' const URL_C = 'wss://relay-c.example/dshs-relay' /** * `E1` 的夹具数据(⚠️ **数字就是判据本身**,⛔ 别改成"随便造两组"): * * | 候选 | RTT 序列 | 平均 RTT | `p95|ΔRTT|` | * |---|---|---|---| * | **A** | 20 / 35 / 20 / 35 | **27.5 ms** | **15 ms** | * | **B** | 30 / 32 / 30 / 32 | **31.0 ms** | **2 ms** | * * ⇒ **A 的 RTT 更低**(改造前的判据会选它),**B 的 jitter 更低**(本序判据必须选它)。 */ const SEQ_A = [20, 35, 20, 35] const SEQ_B = [30, 32, 30, 32] function trackerWith(url, seq, th = TH) { const t = new JitterTracker(th) for (const v of seq) t.record(url, v) return t } const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length /* ─────────── 搭台工具(与 relay-failover.test.mjs 同风格:只测决策,不碰真 socket) ─────────── */ function fakeChannel(url, health = { state: 'up', attempts: 0, unhealthyForMs: 0 }) { const ch = { url, closed: 0, _h: { ...health }, health: () => ({ ...ch._h }), setHealth(next) { ch._h = { ...next } }, close() { ch.closed += 1 }, } return ch } function collector() { const lines = [] return { lines, log: (l) => lines.push(l), switchLines: () => lines.filter((l) => l.startsWith('[relay-switch]')).length, jitterLines: () => lines.filter((l) => l.startsWith('[relay-jitter]')).length, } } /* ─────────── S1:jitter 采样 / 直方图(纯函数逐项可断言) ─────────── */ test('J1 absDeltas 取**相邻差分绝对值**(⛔ 不是标准差:单调漂移不算抖动)', () => { assert.deepEqual(absDeltas([10, 12, 9, 30]), [2, 3, 21]) assert.deepEqual(absDeltas([5]), []) assert.deepEqual(absDeltas([]), []) }) test('J2 percentile 与 scripts/overlay-jitter.cjs **同口径**(索引 = floor(len·p),越界取末位)', () => { assert.equal(percentile([], 0.95), 0) assert.equal(percentile([7], 0.95), 7) // 20 个 1..20 ⇒ floor(20×0.95)=19 ⇒ sorted[19]=20 assert.equal(percentile(Array.from({ length: 20 }, (_, i) => i + 1), 0.95), 20) // 3 个 ⇒ floor(3×0.95)=2 ⇒ 末位 assert.equal(percentile([5, 1, 3], 0.95), 5) }) test('J3 histogram 桶数恒等于 histBuckets,≥ histMaxMs 落末桶', () => { const h = histogram([0, 24, 25, 26, 199, 200, 999], 200, 8) assert.equal(h.length, 8) // 每桶宽度 25ms:0→0 桶;24/25/26→0/1/1 桶;199→7 桶;200 与 999→末桶 assert.deepEqual(h, [2, 2, 0, 0, 0, 0, 0, 3]) assert.equal(histogram([], 200, 8).reduce((a, b) => a + b, 0), 0) }) test('J4 statsFromDeltas:p95 / 均值 / 最大值 / 桶数(口径与探针一致)', () => { const st = statsFromDeltas([1, 2, 3, 4], TH) assert.equal(st.deltas, 4) assert.equal(st.p95AbsDeltaMs, percentile([1, 2, 3, 4], 0.95)) assert.equal(st.meanAbsDeltaMs, 2.5) assert.equal(st.maxAbsDeltaMs, 4) assert.equal(st.hist.length, TH.histBuckets) }) test('J5 JitterTracker:非法样本丢弃、环上限生效、样本不足 ⇒ **undefined(⛔ 不当 0)**', () => { const t = new JitterTracker({ ...TH, sampleMax: 4 }) assert.equal(t.record(URL_A, Number.NaN), false) assert.equal(t.record(URL_A, -1), false) assert.equal(t.record('', 10), false) // 只有一个样本 ⇒ 算不出差分 ⇒ 未知 assert.equal(t.record(URL_A, 10), true) assert.equal(t.stats(URL_A), undefined) assert.equal(t.jitterMs(URL_A), undefined) // 差分数 < minSamples(3) ⇒ 仍然未知(⛔ 这是"样本不足不当 0"的机器判据) t.record(URL_A, 12) t.record(URL_A, 14) assert.equal(t.stats(URL_A), undefined) // 第 4 个样本 ⇒ 3 个差分 ⇒ 可判 t.record(URL_A, 16) assert.equal(t.stats(URL_A)?.deltas, 3) // 环上限 = 4 ⇒ 再喂两个只留最后 4 个([14,16,18,20]) t.record(URL_A, 18) t.record(URL_A, 20) assert.equal(t.snapshot()[0].samples, 4) assert.equal(t.jitterMs(URL_A), 2) }) test('J6 JITTER_ENABLE=0 ⇒ 采样与排序**全部失效**(回改造前行为)', () => { const t = new JitterTracker({ ...TH, enabled: false }) assert.equal(t.record(URL_A, 10), false) assert.equal(t.enabled, false) }) test('J7 jitterThresholds:值格**必须纯数字**(带夹注 ⇒ 静默回退默认值)', () => { assert.equal(jitterThresholds({ JITTER_LIMIT_MS: '35' }).switchMs, 35) assert.equal(jitterThresholds({ JITTER_LIMIT_MS: '35(实测)' }).switchMs, 20, '夹注必须回退默认值') assert.equal(jitterThresholds({ JITTER_ENABLE: '0' }).enabled, false) assert.equal(jitterThresholds({ JITTER_ENABLE: '1(默认)' }).enabled, true, '非法值回退默认 1') }) /* ─────────── S2:E1 主判据 —— jitter 更低(但 RTT 更高)者被选中 ─────────── */ test('E1-a 主判据:**jitter 更低的那条排在首位,即使它 RTT 更高**', () => { const t = trackerWith(URL_A, SEQ_A) for (const v of SEQ_B) t.record(URL_B, v) // 先自证"两条的 RTT 关系确实与 jitter 关系相反"(⛔ 否则这条用例证明不了任何事) assert.ok(mean(SEQ_B) > mean(SEQ_A), `夹具失效:B 的平均 RTT(${mean(SEQ_B)}) 必须高于 A(${mean(SEQ_A)})`) assert.ok( t.jitterMs(URL_B) < t.jitterMs(URL_A), `夹具失效:B 的 jitter(${t.jitterMs(URL_B)}) 必须低于 A(${t.jitterMs(URL_A)})`, ) const ordered = orderByJitter([URL_A, URL_B], t) assert.deepEqual(ordered, [URL_B, URL_A], '必须选 B(jitter 2ms)—— 而不是 RTT 更低的 A') }) test('E1-b 零回归(D9):**零样本 ⇒ 返回同一个数组**(⛔ 不是等值的新数组)', () => { const empty = new JitterTracker(TH) const urls = [URL_A, URL_B, URL_C] assert.equal(orderByJitter(urls, empty), urls, '零样本必须原样返回(逐字一致)') assert.equal(orderByJitter(urls, undefined), urls, '无 tracker 必须原样返回') assert.deepEqual(orderByJitter([URL_A], trackerWith(URL_A, SEQ_A)), [URL_A], '单候选 ⇒ 不排序') }) test('E1-c 未测样本的候选**保原序排在其后**(⛔ 不惩罚备用中继、也不给它虚位)', () => { const t = trackerWith(URL_A, SEQ_A) for (const v of SEQ_B) t.record(URL_B, v) // C 无样本;目录原序 = [A, C, B] assert.deepEqual(orderByJitter([URL_A, URL_C, URL_B], t), [URL_B, URL_A, URL_C]) }) test('E1-d 稳定排序:jitter 并列时**保原相对序**(⛔ 不许随机洗牌已有语义)', () => { const t = trackerWith(URL_A, SEQ_A) for (const v of SEQ_A) t.record(URL_B, v) assert.deepEqual(orderByJitter([URL_A, URL_B, URL_C], t), [URL_A, URL_B, URL_C]) assert.deepEqual(orderByJitter([URL_B, URL_A, URL_C], t), [URL_B, URL_A, URL_C]) }) test('E1-e **端到端主判据**:当前通道不健康 ⇒ 换到 jitter 更低的那条(目录序里它排第二)', async () => { const t = trackerWith(URL_A, SEQ_A) for (const v of SEQ_B) t.record(URL_B, v) const cur = fakeChannel(URL_C, { state: 'backoff', attempts: 5, unhealthyForMs: 60_000 }) const switched = [] const log = collector() const sup = new RelayFailoverSupervisor({ open: async (url) => { switched.push(url) return fakeChannel(url) }, candidates: async () => [URL_A, URL_B], log: log.log, thresholds: { minAttempts: 1, graceMs: 0, cooldownMs: 1000, checkMs: 10_000, upTimeoutMs: 10 }, jitterTracker: t, }) sup.seed(cur) await sup.tick() assert.equal(sup.channel?.url, URL_B, '必须换到 B(jitter 更低),⛔ 不是目录序首位的 A') assert.deepEqual(switched, [URL_B]) assert.equal(sup.stats().switches, 1) assert.equal(log.switchLines(), 1, '判别器:`[relay-switch]` 行数必须等于 switches(D7)') assert.ok(log.lines[0].startsWith('[relay-switch] #1'), `原文=${log.lines[0]}`) }) /* ─────────── S3:E2 —— jitter 劣化即切(⛔ 冷却语义一字不动) ─────────── */ function supDeps({ urls, tracker, log, clock }) { return { open: async (url) => fakeChannel(url), candidates: async () => urls, log: log.log, thresholds: { minAttempts: 1, graceMs: 0, cooldownMs: 1000, checkMs: 10_000, upTimeoutMs: 10 }, nowMs: () => clock.t, jitterTracker: tracker, } } test('E2-a 健康但**抖动超标** ⇒ 换到更稳的候选 + `[relay-jitter]` 告警 + 计数(= 0 增量判据)', async () => { // 当前 A 的 jitter = 25ms ≥ 20ms(阈值),B = 2ms ⇒ 应切 B const t = trackerWith(URL_A, [20, 45, 20, 45]) for (const v of SEQ_B) t.record(URL_B, v) assert.equal(t.jitterMs(URL_A), 25) const log = collector() const clock = { t: 1_000_000 } const sup = new RelayFailoverSupervisor(supDeps({ urls: [URL_A, URL_B], tracker: t, log, clock })) sup.seed(fakeChannel(URL_A)) await sup.tick() assert.equal(sup.channel?.url, URL_B, '抖动超标必须换到更稳的那条') const st = sup.stats() assert.equal(st.jitterSwitches, 1) assert.equal(st.jitterAlerts, 1) assert.equal(st.switches, 1) assert.equal(log.switchLines(), 1) assert.ok(log.lines.some((l) => l.startsWith('[relay-jitter] ⚠')), `缺告警行:${log.lines.join('|')}`) assert.ok(log.lines.some((l) => l.includes('抖动量超标')), '切换原因必须点名 jitter(可被 grep 到)') }) test('E2-b 抖动超标但**候选全在冷却** ⇒ 原地不动,⛔ 且**不得动用一跳豁免**(D3 护栏)', async () => { const t = trackerWith(URL_A, [20, 45, 20, 45]) for (const v of SEQ_B) t.record(URL_B, v) const log = collector() const clock = { t: 1_000_000 } const sup = new RelayFailoverSupervisor(supDeps({ urls: [URL_A, URL_B], tracker: t, log, clock })) sup.seed(fakeChannel(URL_A)) // 先手工把 B 打进冷却(复现"B 刚被换掉 / 刚失败"的现场) const opened = await sup.replace(URL_B, '夹具:先切到 B', 'health') assert.equal(opened, true) assert.equal(sup.channel?.url, URL_B) // 再切回 A(夹具造"当前 A 抖动超标、B 在冷却") await sup.replace(URL_A, '夹具:切回 A', 'health') assert.ok(sup.stats().cooldown.some((c) => c.url === URL_B), 'B 必须已在冷却中') /** * ⚠️ 断言必须取**增量**:上面两次 `replace` 是**夹具自己**造的换址(其中"切回 A"走的就是 * 一跳豁免 —— 因为 A 刚被换掉、正在冷却)⇒ 拿绝对值判 "exemptSwitches === 0" 会把 * **夹具的动作**记到产品头上(本条第一版就是这么错的,已改)。 */ const pre = sup.stats() await sup.tick() const after = sup.stats() assert.equal(after.switches, pre.switches, '⛔ 不得切换(唯一候选在冷却中 ⇒ 原地不动)') assert.equal(after.exemptSwitches, pre.exemptSwitches, '⛔ jitter 换址**没有**豁免权(豁免只属于"当前已经挂了")') assert.equal(after.jitterSwitches, pre.jitterSwitches) assert.ok( log.lines.some((l) => l.startsWith('[relay-jitter] ⚠')), '超标本身必须**照样告警**(⛔ 不换路 ≠ 不报警)', ) }) test('E2-c 采样门限:同一份**缓存读数**只记一次(否则差分恒 0 ⇒ jitter 假绿)', async () => { const t = new JitterTracker({ ...TH, sampleGapMs: 20_000 }) const log = collector() const clock = { t: 1_000_000 } const ch = fakeChannel(URL_A, { state: 'up', attempts: 0, unhealthyForMs: 0, rttMs: 40 }) const sup = new RelayFailoverSupervisor(supDeps({ urls: [URL_A], tracker: t, log, clock })) sup.seed(ch) for (let i = 0; i < 5; i++) { clock.t += 1_000 await sup.tick() } assert.equal(sup.stats().jitterSamples, 1, '门限内(20s)同一份缓存值只能记 1 个样本') clock.t += 25_000 await sup.tick() assert.equal(sup.stats().jitterSamples, 2, '超过门限后允许再记一个(心跳周期已过)') }) test('E2-d `jitterTracker: null` ⇒ **逐字回到序⑧**(不采样、不按 jitter 排序、不因抖动切换)', async () => { const log = collector() const clock = { t: 1_000_000 } const ch = fakeChannel(URL_A, { state: 'up', attempts: 0, unhealthyForMs: 0, rttMs: 40 }) const sup = new RelayFailoverSupervisor({ ...supDeps({ urls: [URL_A, URL_B], tracker: undefined, log, clock }), jitterTracker: null, }) sup.seed(ch) await sup.tick() assert.equal(sup.stats().jitterSamples, 0) assert.equal(sup.stats().jitterSwitches, 0) assert.equal(sup.stats().switches, 0) assert.equal(log.switchLines(), 0) }) test('E2-e 缺省(不传 jitterTracker)⇒ 用**进程级共享 tracker**(装配点零改动即生效)', async () => { // ⛔ 这条专门守"静默失效":装配点不在在册文件集里 ⇒ 若默认不是共享单例,生产上永远不会生效。 const { sharedJitterTracker } = await import('../lib/net/relay/jitter.js') const shared = sharedJitterTracker() shared.reset() for (const v of [20, 45, 20, 45]) shared.record(URL_A, v) for (const v of SEQ_B) shared.record(URL_B, v) const log = collector() const clock = { t: 1_000_000 } const sup = new RelayFailoverSupervisor({ open: async (url) => fakeChannel(url), candidates: async () => [URL_A, URL_B], log: log.log, thresholds: { minAttempts: 1, graceMs: 0, cooldownMs: 1000, checkMs: 10_000, upTimeoutMs: 10 }, nowMs: () => clock.t, }) sup.seed(fakeChannel(URL_A)) await sup.tick() assert.equal(sup.channel?.url, URL_B, '缺省必须走共享 tracker(否则本序在生产上是静默失效)') shared.reset() }) test('E2-f pickJitterTarget 单点判据:没劣化 / 候选不更稳 / 候选在冷却 ⇒ 一律 undefined', () => { const t = trackerWith(URL_A, SEQ_A) // 15ms for (const v of SEQ_B) t.record(URL_B, v) // 2ms const base = { urls: [URL_A, URL_B], tracker: t, curUrl: URL_A, switchMs: 20, minSamples: 3 } assert.equal(pickJitterTarget({ ...base, curJitterMs: 15 }), undefined, '未达阈值 ⇒ 不动') assert.equal(pickJitterTarget({ ...base, curJitterMs: 25 })?.url, URL_B, '超标且 B 更稳 ⇒ 选 B') assert.equal( pickJitterTarget({ ...base, curJitterMs: 25, blocked: new Set([URL_B]) }), undefined, '⛔ 冷却中的候选不许被 jitter 换址挑中(无豁免权)', ) assert.equal(pickJitterTarget({ ...base, curJitterMs: 25, urls: [URL_A, URL_A] }), undefined, '⛔ 不许切到自己') }) /* ─────────── S4:E4 —— 利用率软门(真起 relay,真握手) ─────────── */ async function rawHello(wsUrl, hostId, secret, portsCsv) { const ws = new WebSocket(wsUrl) ws.binaryType = 'arraybuffer' await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }) ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true }) }) const ts = Date.now() const nonce = randomBytes(16).toString('hex') const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex') ws.send(encodeJsonFrame(MUX.HELLO, 0, { v: 1, hostId, ts, nonce, portsCsv, mac })) const frame = await new Promise((resolve) => { const timer = setTimeout(() => resolve(undefined), 3000) ws.addEventListener('message', (ev) => { clearTimeout(timer) resolve(decodeMux(Buffer.from(ev.data))) }) ws.addEventListener('close', () => { clearTimeout(timer) resolve(undefined) }) }) return { ws, frame } } test('E4 利用率软门:`used/max ≥ RELAY_UTIL_MAX_PCT` ⇒ 拒新接入(⛔ 不打满),**已在册重连仍优先**', async (t) => { const secrets = { a: randomBytes(32).toString('hex'), b: randomBytes(32).toString('hex'), d: randomBytes(32).toString('hex') } const server = new RelayServer({ port: 0, keys: new Map([ ['w-a', secrets.a], ['w-b', secrets.b], ['w-d', secrets.d], ]), instancePortBase: BASE, instancePortSpan: SPAN, maxHosts: 10, utilMaxPct: 20, log: () => {}, }) await server.start() t.after(() => server.stop()) const url = `ws://127.0.0.1:${server.boundPort}${PATH}` const a = await rawHello(url, 'w-a', secrets.a, String(BASE + 40)) t.after(() => a.ws.close()) assert.equal(a.frame?.type, MUX.HELLO_ACK, '第 1 台必须准入(used=0 ⇒ 0% < 20%)') const b = await rawHello(url, 'w-b', secrets.b, String(BASE + 41)) t.after(() => b.ws.close()) assert.equal(b.frame?.type, MUX.HELLO_ACK, '第 2 台必须准入(used=1 ⇒ 10% < 20%)') assert.equal(server.status().capacity.utilPct, 20, 'used=2 / max=10 ⇒ utilPct 必须 = 20') assert.equal(server.status().capacity.utilMaxPct, 20) // 第 3 个**新面孔** ⇒ 必须被软门拦下(⛔ 而不是等到 10 台才拦) const d = await rawHello(url, 'w-d', secrets.d, String(BASE + 42)) t.after(() => d.ws.close()) assert.equal(d.frame?.type, MUX.HELLO_ERR, '利用率达软门 ⇒ 新节点必须被拒') const msg = JSON.parse(Buffer.from(d.frame.payload).toString('utf8')) assert.equal(msg.reason, 'at-util-limit') assert.equal(msg.retryable, true, '这是**临时**状态 ⇒ 对端应排队重试') assert.equal(msg.capacity.utilPct, 20) assert.equal(msg.capacity.utilMaxPct, 20) assert.equal(server.status().counters.utilRefused, 1, '⛔ 软门拒绝必须与硬门 `refused` 分开计数') assert.equal(server.status().counters.refused, 0) assert.ok(!server.isOnline('w-d')) // **已在册**的 hostId 重连**永远优先**(它占的位子本来就是它的) const a2 = await rawHello(url, 'w-a', secrets.a, String(BASE + 40)) t.after(() => a2.ws.close()) assert.equal(a2.frame?.type, MUX.HELLO_ACK, '已在册节点重连不受软门影响') }) test('E4-b `utilMaxPct = 0` ⇒ 软门关闭(逐字回到改造前:只有硬容量门)', async (t) => { const secrets = { a: randomBytes(32).toString('hex'), b: randomBytes(32).toString('hex'), c: randomBytes(32).toString('hex') } const server = new RelayServer({ port: 0, keys: new Map([ ['w-a', secrets.a], ['w-b', secrets.b], ['w-c', secrets.c], ]), instancePortBase: BASE + SPAN, instancePortSpan: SPAN, maxHosts: 10, utilMaxPct: 0, log: () => {}, }) await server.start() t.after(() => server.stop()) const url = `ws://127.0.0.1:${server.boundPort}${PATH}` for (const [i, [id, s]] of [['w-a', secrets.a], ['w-b', secrets.b], ['w-c', secrets.c]].entries()) { const r = await rawHello(url, id, s, String(BASE + SPAN + 40 + i)) t.after(() => r.ws.close()) assert.equal(r.frame?.type, MUX.HELLO_ACK, `${id} 必须准入(软门已关)`) } assert.equal(server.status().counters.utilRefused, 0) }) test('E4-c `/status` 结构:`capacity.utilPct` / `jitter` 块**恒在**(⛔ 不因"没样本"缺键)', async (t) => { const s = randomBytes(32).toString('hex') const server = new RelayServer({ port: 0, keys: new Map([['w-a', s]]), instancePortBase: BASE + 2 * SPAN, instancePortSpan: SPAN, maxHosts: 7515, log: () => {}, }) await server.start() t.after(() => server.stop()) const url = `ws://127.0.0.1:${server.boundPort}${PATH}` const a = await rawHello(url, 'w-a', s, String(BASE + 2 * SPAN + 40)) t.after(() => a.ws.close()) const st = server.status() assert.equal(st.capacity.max, 7515, '⛔ 容量值一字不动(本序不许改 RELAY_MAX_HOSTS)') assert.equal(typeof st.capacity.utilPct, 'number') assert.equal(st.capacity.utilMaxPct, 70, '缺省软门 = 70%(留 30%+ 余量)') assert.equal(st.counters.utilRefused, 0) const j = st.jitter for (const k of ['samples', 'deltas', 'p95AbsDeltaMs', 'meanAbsDeltaMs', 'maxAbsDeltaMs', 'thresholdMs', 'overThreshold', 'alerts', 'sessions']) { assert.ok(k in j, `jitter.${k} 缺失(探针 OBS-19 的判别器面)`) } assert.equal(j.hist.length, TH.histBuckets, '直方图桶数必须 = 参数表值') assert.equal(j.thresholdMs, TH.switchMs, '阈值必须 = 参数表 JITTER_LIMIT_MS') assert.equal(j.sessions, 0, 'rawHello 不回应 PING ⇒ 没有 RTT 差分 ⇒ sessions=0(诚实报 0,⛔ 不编数)') })