| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- /**
- * T15 WebSocket 状态更新 纯逻辑检查(票 15)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t15-websocket.mjs
- *
- * 八段(每条对着票 15 的验收原句):
- * A 验收 1:连接、订阅、断线、重连都在运行时服务里(时序照后端真实协议)
- * B 验收 2:**免鉴权时序** —— open 后直接 cockpitSubscribe,不发 authenticate 帧
- * C 验收 3:按 serverUpdatedAt 取舍,**旧消息被丢弃**(含跨偏移的时间比较)
- * D 验收 4:增量就地更新受影响点位与已打开详情,**不重建视图**(元素引用保持)
- * E 验收 5:当前选中对象、面板状态、滚动位置不受影响(结构保证:不碰这些状态)
- * F 验收 6:连接/重连/未配置三态可演示(脚本化假 socket 按真实时序发帧)
- * G 验收 7:增量合并与重连的单测(本段就是),含退避
- * H 静态守卫:组件/store 不认识 socket 与帧类型;REST/WS 均不携带凭据
- *
- * 协议依据(ADR 0005 修订,2026-09-28;后端 `DeviceStatusWebSocketHandler` 现协议):
- * · 免鉴权:连接后直接 `cockpitSubscribe`(scope 只认 "all")→
- * `{"type":"cockpitSubscribed","scope","serverUpdatedAt"}`;**不再有 authenticate 往返**
- * · `DeviceStatusRedisListener.cockpitDeltaMessage`:`monitoringPointCode` 在**顶层**,
- * 状态在 `data.status`
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import { POINT_STATUS } from '../../../src/modules/cockpit/domain/pointStatus.js'
- import {
- applyPointStates,
- emptyStatusState,
- isNewerDelta,
- mergeDeviceStatus,
- parseStamp,
- } from '../../../src/modules/cockpit/domain/statusPush.js'
- import {
- WS_MESSAGE,
- toAuthenticatedEvent,
- toDeviceStatusEvent,
- toSubscribedEvent,
- } from '../../../src/modules/cockpit/services/runtimeApi.js'
- import {
- RECONNECT_DELAYS,
- WS_STATE,
- connect,
- disconnect,
- getStatus,
- onDelta,
- onStatus,
- reconnectDelay,
- resetForTest,
- } from '../../../src/modules/cockpit/services/runtimeSocket.js'
- import { createScriptedSocketFactory, deltaFrame } from '../../../src/modules/cockpit/mocks/wsMock.js'
- import { useRuntimeStore } from '../../../src/modules/cockpit/stores/runtimeStore.js'
- import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
- import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
- import { useUiStore } from '../../../src/modules/cockpit/stores/uiStore.js'
- import { emptyMapSummary } from '../../../src/modules/cockpit/domain/mapSummary.js'
- const checks = []
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const readCode = (relative) => code(read(relative))
- const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
- const readVueCode = (relative) => codeVue(read(relative))
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
- setActivePinia(createPinia())
- /** 假定时器:把"等 1 秒重连"变成同步可控,且不真的等。 */
- function fakeTimers() {
- const tasks = []
- return {
- setTimeout: (fn, ms) => {
- const id = { fn, ms }
- tasks.push(id)
- return id
- },
- clearTimeout: (id) => {
- const index = tasks.indexOf(id)
- if (index >= 0) tasks.splice(index, 1)
- },
- /** 推进时间:执行所有到期任务(按到期顺序,支持任务里再排任务)。
- * 有硬上限:万一脚本自己无限重排(说明被测行为真的失控),
- * 宁可让断言去红,也不让脚本挂住。 */
- async run() {
- for (let guard = 0; guard < 200 && tasks.length; guard += 1) {
- const next = tasks.shift()
- next.fn()
- await Promise.resolve()
- }
- },
- pending: () => tasks.length,
- delays: () => tasks.map((item) => item.ms),
- /** 只推进一个到期任务(用来观察"断线后待重连"的中间态)。 */
- async step() {
- const next = tasks.shift()
- if (!next) return false
- next.fn()
- await Promise.resolve()
- return true
- },
- }
- }
- // ================================================================ A 协议与时序
- {
- check(
- 'A1-frame-shape-from-backend',
- WS_MESSAGE.DELTA === 'cockpitDeviceStatusDelta' &&
- WS_MESSAGE.SUBSCRIBE === 'cockpitSubscribe' &&
- WS_MESSAGE.AUTHENTICATE === 'authenticate' &&
- WS_MESSAGE.AUTHENTICATED === 'authenticated',
- '帧类型名照抄后端 handleAuthenticate/handleCockpitSubscribe/cockpitDeltaMessage',
- )
- // 增量帧:monitoringPointCode 在**顶层**(不是 data 里)—— 这是本次读源码纠正的关键一例。
- const event = toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:00+08:00' }))
- check(
- 'A2-delta-carries-point-code',
- event && event.deviceCode === 'EQ-001' && event.monitoringPointCode === 'MP-001' && event.status === 'ALARM',
- `顶层 monitoringPointCode=${event && event.monitoringPointCode}(点位联动的唯一依据)`,
- )
- // 未关联设备:后端显式给 null,必须归一成空串(而不是 'null' 字符串)。
- const free = toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-FREE-001', monitoringPointCode: null, status: 'OFFLINE', serverUpdatedAt: '2026-09-24T10:00:00+08:00' }))
- check(
- 'A3-null-point-code-normalised',
- free && free.monitoringPointCode === '',
- `未关联设备的 monitoringPointCode 归一为 "${free && free.monitoringPointCode}"(不会误当成点位码 "null")`,
- )
- // 但 `cockpitSubscribed` **不带**初始状态数组(REST 才是初始快照)。
- const subscribed = toSubscribedEvent({ type: 'cockpitSubscribed', scope: 'all', serverUpdatedAt: '2026-09-24T10:00:00+08:00' })
- check(
- 'A4-subscribed-has-no-device-array',
- subscribed && subscribed.scope === 'all' && subscribed.devices === undefined,
- '订阅回帧只有 scope/serverUpdatedAt —— 不假装它带了初始状态(初始快照归 REST)',
- )
- }
- // ================================================================ B 握手顺序与鉴权失败
- {
- const timers = fakeTimers()
- resetForTest()
- const scripted = createScriptedSocketFactory({ setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- // token 故意传入:免鉴权后服务层必须忽略它(不进任何帧)。
- connect({ url: 'mock://ws', token: 'STALE-1', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- check('B1-connecting-first', getStatus().state === WS_STATE.CONNECTING, `建连后状态 ${getStatus().state}`)
- await timers.run()
- const socket = scripted.log[0]
- const sent = socket.sent.map((item) => JSON.parse(item))
- check(
- 'B2-subscribe-directly-without-authenticate',
- sent.length === 1 && sent[0].type === 'cockpitSubscribe' && sent[0].scope === 'all',
- `客户端发出的帧:${sent.map((f) => f.type).join(' → ')}(免鉴权:open 后直接订阅 all,无 authenticate 帧)`,
- )
- check(
- 'B3-stale-token-never-sent',
- sent.every((frame) => frame.token === undefined && frame.Authorization === undefined),
- '传入的旧 token 不进任何帧(ADR 0005 修订:WS 免鉴权)',
- )
- check('B4-open-after-subscribed', getStatus().state === WS_STATE.OPEN, `握手完成后状态 ${getStatus().state}`)
- disconnect()
- }
- {
- // 免鉴权后不再有「鉴权失败」场景:unauthorized 脚本退化为「订阅成功但无增量」,
- // 服务层不得因收不到增量而崩或假装连上(状态只到 subscribing,等下个周期重评)。
- const timers = fakeTimers()
- resetForTest()
- const scripted = createScriptedSocketFactory({ scenario: 'unauthorized', setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- await timers.run()
- const status = getStatus()
- const sent = scripted.log[0].sent.map((item) => JSON.parse(item))
- check(
- 'B5-silent-push-stays-subscribing-or-open',
- sent.length === 1 && sent[0].type === 'cockpitSubscribe' &&
- (status.state === WS_STATE.SUBSCRIBING || status.state === WS_STATE.OPEN),
- `订阅成功但无增量:只发 1 帧 cockpitSubscribe,状态 ${status.state}(不伪造 open,也不崩)`,
- )
- disconnect()
- }
- // ================================================================ C 旧消息被忽略
- {
- const base = '2026-09-24T10:00:02+08:00'
- check(
- 'C1-newer-accepted-older-rejected',
- isNewerDelta({ serverUpdatedAt: '2026-09-24T10:00:03+08:00' }, base) === true &&
- isNewerDelta({ serverUpdatedAt: '2026-09-24T10:00:01+08:00' }, base) === false &&
- isNewerDelta({ serverUpdatedAt: base }, base) === false,
- '严格更新才采纳:更晚 → true;更早/相同 → false',
- )
- check(
- 'C2-compares-instants-not-strings',
- // 同一个瞬间,偏移不同:字符串比大小会得出错误结论,按时刻比才对。
- parseStamp('2026-09-24T10:00:00+08:00') === parseStamp('2026-09-24T02:00:00Z') &&
- isNewerDelta({ serverUpdatedAt: '2026-09-24T02:00:00Z' }, '2026-09-24T10:00:00+08:00') === false,
- '同一时刻的不同偏移写法判为"不更新"(按 epoch 比,不按字符串)',
- )
- check(
- 'C3-unparsable-timestamp-rejected',
- isNewerDelta({ serverUpdatedAt: '' }, base) === false &&
- isNewerDelta({ serverUpdatedAt: 'not-a-time' }, base) === false &&
- parseStamp('') === null,
- '时间认不出的帧不采纳(宁可漏一次,也不让乱序旧值盖掉新值)',
- )
- // 合并层:旧消息不得改动状态表,且**原样返回同一个引用**(调用方据此跳过重渲染)。
- let state = emptyStatusState()
- const first = mergeDeviceStatus({ state, delta: toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:02+08:00' })) })
- state = first.state
- const stale = mergeDeviceStatus({ state, delta: toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'OFFLINE', serverUpdatedAt: '2026-09-24T10:00:01+08:00' })) })
- check(
- 'C4-merge-rejects-stale-without-copy',
- first.accepted === true &&
- stale.accepted === false &&
- stale.reason === 'STALE' &&
- stale.state === state &&
- state.deviceStates['EQ-001'].status === 'ALARM',
- `旧帧被丢弃(reason=${stale.reason}),状态表仍是 ${state.deviceStates['EQ-001'].status} 且引用未变`,
- )
- check(
- 'C5-rejects-without-device-or-status',
- mergeDeviceStatus({ state, delta: { deviceCode: '', status: 'ALARM', serverUpdatedAt: '2026-09-24T11:00:00+08:00' } }).reason === 'NO_DEVICE_CODE' &&
- mergeDeviceStatus({ state, delta: { deviceCode: 'EQ-001', status: '', serverUpdatedAt: '2026-09-24T11:00:00+08:00' } }).reason === 'NO_STATUS',
- '缺设备编码/缺状态的帧各有明确丢弃原因(不把已知状态抹成"未发布")',
- )
- }
- // ================================================================ D 就地更新,不重建视图
- {
- const objects = [
- { kind: 'monitoring-point', code: 'MP-001', name: 'A 点', status: POINT_STATUS.NORMAL, longitude: 110.1, latitude: 28.1, deviceCodes: ['EQ-001'] },
- { kind: 'monitoring-point', code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, longitude: 110.2, latitude: 28.2, deviceCodes: ['EQ-002'] },
- { kind: 'monitoring-point', code: 'MP-003', name: 'C 点', status: POINT_STATUS.WARNING, longitude: 110.3, latitude: 28.3, deviceCodes: [] },
- ]
- const { objects: next, changed } = applyPointStates(objects, { 'MP-002': { status: POINT_STATUS.ALARM } })
- check(
- 'D1-only-affected-object-changes',
- changed.length === 1 &&
- changed[0] === 'MP-002' &&
- next[1].status === POINT_STATUS.ALARM &&
- next[0].status === POINT_STATUS.NORMAL &&
- next[2].status === POINT_STATUS.WARNING,
- `只有 MP-002 改状态(${changed.join(',')});MP-001/MP-003 原样`,
- )
- check(
- 'D2-untouched-keep-identity',
- next[0] === objects[0] && next[2] === objects[2] && next[1] !== objects[1] && next.length === objects.length,
- '未命中的元素**保持同一引用**(消费方据此只重画变动的那几个),顺序与长度不变',
- )
- check(
- 'D3-does-not-touch-geometry',
- next[1].longitude === objects[1].longitude && next[1].name === objects[1].name && next[1].deviceCodes === objects[1].deviceCodes,
- '坐标/名称/关联设备一概不动(推送不带这些字段,凭空补就是编数据)',
- )
- check(
- 'D4-same-status-is-noop',
- applyPointStates(objects, { 'MP-001': { status: POINT_STATUS.NORMAL } }).changed.length === 0 &&
- applyPointStates(objects, { 'MP-001': { status: POINT_STATUS.NORMAL } }).objects === objects,
- '状态没变时不产生新数组(同值推送不引起无谓重渲染)',
- )
- check(
- 'D5-unknown-point-is-ignored',
- applyPointStates(objects, { 'MP-999': { status: POINT_STATUS.ALARM } }).changed.length === 0,
- '推送指名了不在已加载范围的点位 → 不改任何对象(不是错误,可能还没翻到那一页)',
- )
- }
- // ================================================================ E 选中/面板/滚动不受影响
- {
- const uiStore = useUiStore()
- const mapStore = useMapStore()
- const detailStore = useDetailStore()
- const runtimeStore = useRuntimeStore()
- uiStore.reset?.()
- mapStore.reset()
- detailStore.reset()
- runtimeStore.reset()
- // 装两个对象(含被推送影响的 MP-002)与一个已打开的详情。
- // 注意:经由 Pinia 取到的 `mapStore.summary` 已是解包后的对象(不是 ref),
- // 所以这里直接赋字段,不写 `.value`(写了会变成一个无关的键,对象数仍是 0)。
- mapStore.summary = {
- ...emptyMapSummary({ pageSize: 2 }),
- objects: [
- { kind: 'monitoring-point', code: 'MP-001', name: 'A 点', status: POINT_STATUS.NORMAL, longitude: 110.1, latitude: 28.1, deviceCodes: ['EQ-001'] },
- { kind: 'monitoring-point', code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, longitude: 110.2, latitude: 28.2, deviceCodes: ['EQ-002'] },
- ],
- }
- // 已打开详情:MP-002(状态 NORMAL)
- detailStore.setSection('pointDetail', { state: 'success', data: { code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, deviceCount: 1 } })
- // 当前选中对象 + 展开面板 + 滚动位置(验收第 5 条要它们不变)
- uiStore.setMode?.('warningResponse')
- const modeBefore = uiStore.mode
- const expandedBefore = JSON.stringify(uiStore.expandedPanelIds)
- const selectedBefore = JSON.stringify(detailStore.selection)
- const scrollBefore = { top: 420, left: 0 }
- const result = runtimeStore.ingest({ deviceCode: 'EQ-002', monitoringPointCode: 'MP-002', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:09+08:00', statusTime: '', reason: '' })
- check(
- 'E1-point-status-updated-in-place',
- result.accepted === true &&
- mapStore.summary.objects.find((item) => item.code === 'MP-002').status === POINT_STATUS.ALARM &&
- mapStore.summary.objects.find((item) => item.code === 'MP-001').status === POINT_STATUS.NORMAL,
- `MP-002 就地变 ${mapStore.summary.objects.find((i) => i.code === 'MP-002').status},MP-001 仍是 NORMAL`,
- )
- check(
- 'E2-open-detail-status-updated',
- detailStore.sections.pointDetail.data.status === POINT_STATUS.ALARM &&
- detailStore.sections.pointDetail.data.name === 'B 点',
- `已打开的详情状态随之更新为 ${detailStore.sections.pointDetail.data.status},其余字段保持快照里的值`,
- )
- check(
- 'E3-selection-mode-panel-scroll-untouched',
- uiStore.mode === modeBefore &&
- JSON.stringify(uiStore.expandedPanelIds) === expandedBefore &&
- JSON.stringify(detailStore.selection) === selectedBefore &&
- scrollBefore.top === 420,
- `当前模式/展开面板/选中对象/滚动位置全部原样(模式=${uiStore.mode}、选中=${selectedBefore})`,
- )
- check(
- 'E4-detail-not-refetched',
- detailStore.sections.pointDetail.loaded === true && detailStore.pointLoading === false,
- '推送更新**不触发取数**(不重新拉详情、不换层级,浮层因此不闪)',
- )
- check(
- 'E5-unrelated-point-untouched',
- runtimeStore.ingest({ deviceCode: 'EQ-999', monitoringPointCode: 'MP-999', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:10+08:00' }).accepted === true &&
- detailStore.sections.pointDetail.data.status === POINT_STATUS.ALARM &&
- detailStore.sections.pointDetail.data.code === 'MP-002',
- '指名别的点位时,已打开的这个详情不动',
- )
- // 未关联设备(pointCode 为空):只更新设备维度,不改任何点位。
- const beforeObjects = mapStore.summary.objects
- const freeResult = runtimeStore.ingest({ deviceCode: 'EQ-FREE-001', monitoringPointCode: '', status: POINT_STATUS.OFFLINE, serverUpdatedAt: '2026-09-24T10:00:11+08:00' })
- check(
- 'E6-unassociated-updates-device-only',
- freeResult.accepted === true &&
- freeResult.pointCode === '' &&
- mapStore.summary.objects === beforeObjects &&
- runtimeStore.statusState.deviceStates['EQ-FREE-001'].status === POINT_STATUS.OFFLINE,
- '未关联设备只落设备维度(对象数组引用未变 → 地图不重画)',
- )
- }
- // ================================================================ E7 翻页补状态(真机 bug 的回归)
- {
- const mapStore = useMapStore()
- const runtimeStore = useRuntimeStore()
- mapStore.reset()
- runtimeStore.reset()
- const { registerMockTransport, resetMockTransportsForTest } = await import('../../../src/modules/cockpit/services/cockpitApi.js')
- const { registerCockpitMocks } = await import('../../../src/modules/cockpit/mocks/contractMocks.js')
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- mapStore.setup({ config: { dataSource: 'mock', api: { baseUrl: '', token: '' } } })
- // 第 1 页(MP-001/MP-002)先到,并收到一条把 MP-003 推成 ALARM 的消息(MP-003 还没翻到)。
- await mapStore.advance()
- const page1 = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
- runtimeStore.ingest({ deviceCode: 'EQ-003', monitoringPointCode: 'MP-003', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:20+08:00' })
- const afterPush = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
- // 第 2 页到达(MP-003 在快照里是 WARNING)→ 必须显示推送来的 ALARM。
- await mapStore.advance()
- const page2 = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
- const mp003 = mapStore.summary.objects.find((item) => item.code === 'MP-003')
- check(
- 'E7c-later-page-gets-cached-push-status',
- page1.length === 2 &&
- JSON.stringify(afterPush) === JSON.stringify(page1) &&
- !!mp003 &&
- mp003.status === POINT_STATUS.ALARM &&
- mp003.statusSource === 'push',
- `第 1 页 ${JSON.stringify(page1)} → 推 MP-003=ALARM(当时还没翻到,图上不变)→ 第 2 页 ${JSON.stringify(page2)}:MP-003 显示推送值 ${mp003 && mp003.status}(不是快照里的 WARNING)`,
- )
- // 反向证据:**显式刷新**换新快照时丢弃推送缓存,让这一份快照说了算
- // (否则一条较旧的推送会盖掉刚取回的新状态 —— 那正是"用旧值覆盖新值")。
- // 注意:`refresh()` 是**整份换快照并自动连翻到完成**,所以 MP-003 刷新后会在对象列表里;
- // 要验的是它按快照值显示、不再带 push 标记。
- await mapStore.refresh()
- const page1AfterRefresh = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
- const mp003AfterRefresh = mapStore.summary.objects.find((item) => item.code === 'MP-003')
- check(
- 'E7d-refresh-lets-snapshot-win',
- page1AfterRefresh.length >= 3 &&
- !!mp003AfterRefresh &&
- mp003AfterRefresh.status === POINT_STATUS.WARNING &&
- mp003AfterRefresh.statusSource === undefined,
- `刷新后对象 ${JSON.stringify(page1AfterRefresh)} → MP-003=${mp003AfterRefresh && mp003AfterRefresh.status}(快照值 WARNING、无 push 标记)= 显式重新取数以快照为准`,
- )
- }
- // ================================================================ F 四态可演示
- {
- // 未配置地址:不是错误,如实说"未配置"。
- resetForTest()
- const disabled = connect({ url: '' })
- check('F1-disabled-when-no-url', disabled.state === WS_STATE.DISABLED && /未配置/.test(disabled.text), `未配地址 → ${disabled.state}(「${disabled.text}」)`)
- disconnect()
- // 断线 → 重连:`flaky` 脚本在第 2 条增量后掉线。
- const timers = fakeTimers()
- resetForTest()
- const scripted = createScriptedSocketFactory({ scenario: 'flaky', disconnectAfter: 1, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- const seen = []
- const offDelta = onDelta((event) => seen.push(event))
- const states = []
- const offStatus = onStatus((status) => states.push(status.state))
- connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- await timers.run()
- check(
- 'F2-retrying-state-observed',
- states.includes(WS_STATE.RETRYING),
- `连接状态序列:${states.join(' → ')}(含重连中)`,
- )
- check(
- 'F3-reconnect-actually-happens',
- scripted.log.length >= 2 && getStatus().state === WS_STATE.OPEN,
- `工厂建了 ${scripted.log.length} 个连接,重连后状态 ${getStatus().state}(真的重连了,不是只改文案)`,
- )
- check(
- 'F4-deltas-delivered-before-drop',
- seen.length >= 1 && seen.every((event) => !!event.deviceCode),
- `掉线前收到 ${seen.length} 条增量(每条都有设备编码)`,
- )
- offDelta()
- offStatus()
- disconnect()
- }
- // ================================================================ G 退避与清理
- {
- check(
- 'G1-backoff-grows-and-caps',
- reconnectDelay(1) === RECONNECT_DELAYS[0] &&
- reconnectDelay(2) === RECONNECT_DELAYS[1] &&
- reconnectDelay(3) === RECONNECT_DELAYS[2] &&
- reconnectDelay(99) === RECONNECT_DELAYS[RECONNECT_DELAYS.length - 1],
- `退避 ${RECONNECT_DELAYS.join('/')} 毫秒,末档封顶(长跑后重连间隔不无限增长)`,
- )
- // disconnect 必须取消待触发的重连(否则离开驾驶舱后定时器还在跑)。
- // 这里**只推进到"掉线并排好重连定时器"为止**(不把定时器跑完),
- // 才能观察到"断开时确实有一个待触发的重连被取消"。
- const timers = fakeTimers()
- resetForTest()
- const scripted = createScriptedSocketFactory({ scenario: 'flaky', disconnectAfter: 1, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
- // 一次只走一个定时器,直到状态落到 retrying(此时队列里应有下一个重连定时器)。
- for (let i = 0; i < 40 && getStatus().state !== WS_STATE.RETRYING && timers.pending(); i += 1) {
- await timers.step()
- }
- const pendingBefore = timers.pending()
- disconnect()
- check(
- 'G2-disconnect-cancels-reconnect',
- pendingBefore > 0 && timers.pending() === 0 && getStatus().state === WS_STATE.IDLE,
- `断开前还有 ${pendingBefore} 个待触发定时器,disconnect 后剩 ${timers.pending()} 个、状态 ${getStatus().state}`,
- )
- }
- // ================================================================ H 静态守卫
- {
- // 组件不认识 socket / 帧类型(ADR 0004:页面只消费领域状态)。
- const shellCode = readVueCode('CockpitShell.vue')
- const FRAME_PATTERN = /cockpitDeviceStatusDelta|cockpitSubscribed|authenticate|new WebSocket|onmessage/
- check(
- 'H1-shell-knows-no-protocol',
- !FRAME_PATTERN.test(shellCode),
- '外壳只读 runtimeStore 的状态与文案,不认识帧类型与 socket',
- )
- check(
- 'H2-shell-shows-state',
- /data-ws-state/.test(readVueCode('CockpitShell.vue')) && /runtimeStore\.statusText/.test(shellCode),
- '外壳把连接状态画出来(data-ws-state 是走查断言面)',
- )
- check(
- 'H2b-shell-knows-no-mock-socket',
- !/createScriptedSocketFactory|wsMock|socketFactory/.test(shellCode),
- '外壳不认识脚本化假连接(mock/real 的连接方式归 store)',
- )
- // runtimeStore 不碰 socket:连接归 service。
- const storeCode = readCode('stores/runtimeStore.js')
- check(
- 'H3-store-has-no-socket',
- !/new WebSocket|onmessage|readyState|\.send\(/.test(storeCode) && /from '\.\.\/services\/runtimeSocket\.js'/.test(storeCode),
- 'runtimeStore 只调运行时服务,不自己建连/发帧',
- )
- check(
- 'H4-domain-pure',
- !/\bfetch\s*\(|XMLHttpRequest|new WebSocket/.test(read('domain/statusPush.js')) &&
- !/from '\.\.\/services\//.test(read('domain/statusPush.js')),
- 'domain/statusPush.js 是纯函数(不发请求、不 import service)',
- )
- // 凭据边界(ADR 0005 修订:REST/WS 均免鉴权,**任何层**都不该再出现 Authorization/Bearer)。
- // 页面与 store 也不该出现 ws.token/api.token 字段名(token 链路已整体下线)。
- check(
- 'H5-shell-no-credential',
- !/Authorization|Bearer|api\.token|ws\.token/.test(readVueCode('CockpitShell.vue')),
- '外壳不碰凭据(连 ws.token 这个字段名都不出现)',
- )
- check(
- 'H5-store-no-token-plumbing',
- !/Authorization|Bearer/.test(readCode('stores/runtimeStore.js')) &&
- !/ws\.token \|\| api\.token/.test(readCode('stores/runtimeStore.js')),
- 'runtimeStore 不再转交 token(免鉴权后 WS 只从 ws.token 读——且仅由 store 旧路径兼容保留)',
- )
- check(
- 'H5-domain-no-credential',
- !/Authorization|Bearer|api\.token|ws\.token/.test(read('domain/statusPush.js')),
- 'domain/statusPush.js 不碰凭据',
- )
- // 时序纪律(ADR 0005 修订):open 后**直接** cockpitSubscribe,服务里不得再有 authenticate 发送点。
- const socketCode = readCode('services/runtimeSocket.js')
- const subAt = socketCode.indexOf("type: 'cockpitSubscribe'")
- check(
- 'H6-subscribe-directly-no-authenticate-send',
- subAt > 0 && !/send\(\{\s*type:\s*'authenticate'/.test(socketCode),
- '服务里只有 cockpitSubscribe 一个发送点,不再发送 authenticate 帧',
- )
- check(
- 'H7-runtime-service-knows-no-data-store',
- !/mapStore|detailStore/.test(socketCode),
- '运行时服务不认识任何数据 store —— 推送异常在结构上不可能牵连数据分区',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T15 状态推送检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const item of failed) console.log(` · ${item.name}`)
- process.exit(1)
- }
- console.log('T15 状态推送全部通过。')
|