t15-websocket.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. /**
  2. * T15 WebSocket 状态更新 纯逻辑检查(票 15)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t15-websocket.mjs
  6. *
  7. * 八段(每条对着票 15 的验收原句):
  8. * A 验收 1:连接、订阅、断线、重连都在运行时服务里(时序照后端真实协议)
  9. * B 验收 2:**免鉴权时序** —— open 后直接 cockpitSubscribe,不发 authenticate 帧
  10. * C 验收 3:按 serverUpdatedAt 取舍,**旧消息被丢弃**(含跨偏移的时间比较)
  11. * D 验收 4:增量就地更新受影响点位与已打开详情,**不重建视图**(元素引用保持)
  12. * E 验收 5:当前选中对象、面板状态、滚动位置不受影响(结构保证:不碰这些状态)
  13. * F 验收 6:连接/重连/未配置三态可演示(脚本化假 socket 按真实时序发帧)
  14. * G 验收 7:增量合并与重连的单测(本段就是),含退避
  15. * H 静态守卫:组件/store 不认识 socket 与帧类型;REST/WS 均不携带凭据
  16. *
  17. * 协议依据(ADR 0005 修订,2026-09-28;后端 `DeviceStatusWebSocketHandler` 现协议):
  18. * · 免鉴权:连接后直接 `cockpitSubscribe`(scope 只认 "all")→
  19. * `{"type":"cockpitSubscribed","scope","serverUpdatedAt"}`;**不再有 authenticate 往返**
  20. * · `DeviceStatusRedisListener.cockpitDeltaMessage`:`monitoringPointCode` 在**顶层**,
  21. * 状态在 `data.status`
  22. */
  23. import { readFileSync } from 'node:fs'
  24. import { createPinia, setActivePinia } from 'pinia'
  25. import { POINT_STATUS } from '../../../src/modules/cockpit/domain/pointStatus.js'
  26. import {
  27. applyPointStates,
  28. emptyStatusState,
  29. isNewerDelta,
  30. mergeDeviceStatus,
  31. parseStamp,
  32. } from '../../../src/modules/cockpit/domain/statusPush.js'
  33. import {
  34. WS_MESSAGE,
  35. toAuthenticatedEvent,
  36. toDeviceStatusEvent,
  37. toSubscribedEvent,
  38. } from '../../../src/modules/cockpit/services/runtimeApi.js'
  39. import {
  40. RECONNECT_DELAYS,
  41. WS_STATE,
  42. connect,
  43. disconnect,
  44. getStatus,
  45. onDelta,
  46. onStatus,
  47. reconnectDelay,
  48. resetForTest,
  49. } from '../../../src/modules/cockpit/services/runtimeSocket.js'
  50. import { createScriptedSocketFactory, deltaFrame } from '../../../src/modules/cockpit/mocks/wsMock.js'
  51. import { useRuntimeStore } from '../../../src/modules/cockpit/stores/runtimeStore.js'
  52. import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
  53. import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
  54. import { useUiStore } from '../../../src/modules/cockpit/stores/uiStore.js'
  55. import { emptyMapSummary } from '../../../src/modules/cockpit/domain/mapSummary.js'
  56. const checks = []
  57. function check(name, pass, evidence) {
  58. checks.push({ name, pass })
  59. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  60. }
  61. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  62. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  63. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  64. const readCode = (relative) => code(read(relative))
  65. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  66. const readVueCode = (relative) => codeVue(read(relative))
  67. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
  68. setActivePinia(createPinia())
  69. /** 假定时器:把"等 1 秒重连"变成同步可控,且不真的等。 */
  70. function fakeTimers() {
  71. const tasks = []
  72. return {
  73. setTimeout: (fn, ms) => {
  74. const id = { fn, ms }
  75. tasks.push(id)
  76. return id
  77. },
  78. clearTimeout: (id) => {
  79. const index = tasks.indexOf(id)
  80. if (index >= 0) tasks.splice(index, 1)
  81. },
  82. /** 推进时间:执行所有到期任务(按到期顺序,支持任务里再排任务)。
  83. * 有硬上限:万一脚本自己无限重排(说明被测行为真的失控),
  84. * 宁可让断言去红,也不让脚本挂住。 */
  85. async run() {
  86. for (let guard = 0; guard < 200 && tasks.length; guard += 1) {
  87. const next = tasks.shift()
  88. next.fn()
  89. await Promise.resolve()
  90. }
  91. },
  92. pending: () => tasks.length,
  93. delays: () => tasks.map((item) => item.ms),
  94. /** 只推进一个到期任务(用来观察"断线后待重连"的中间态)。 */
  95. async step() {
  96. const next = tasks.shift()
  97. if (!next) return false
  98. next.fn()
  99. await Promise.resolve()
  100. return true
  101. },
  102. }
  103. }
  104. // ================================================================ A 协议与时序
  105. {
  106. check(
  107. 'A1-frame-shape-from-backend',
  108. WS_MESSAGE.DELTA === 'cockpitDeviceStatusDelta' &&
  109. WS_MESSAGE.SUBSCRIBE === 'cockpitSubscribe' &&
  110. WS_MESSAGE.AUTHENTICATE === 'authenticate' &&
  111. WS_MESSAGE.AUTHENTICATED === 'authenticated',
  112. '帧类型名照抄后端 handleAuthenticate/handleCockpitSubscribe/cockpitDeltaMessage',
  113. )
  114. // 增量帧:monitoringPointCode 在**顶层**(不是 data 里)—— 这是本次读源码纠正的关键一例。
  115. const event = toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:00+08:00' }))
  116. check(
  117. 'A2-delta-carries-point-code',
  118. event && event.deviceCode === 'EQ-001' && event.monitoringPointCode === 'MP-001' && event.status === 'ALARM',
  119. `顶层 monitoringPointCode=${event && event.monitoringPointCode}(点位联动的唯一依据)`,
  120. )
  121. // 未关联设备:后端显式给 null,必须归一成空串(而不是 'null' 字符串)。
  122. const free = toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-FREE-001', monitoringPointCode: null, status: 'OFFLINE', serverUpdatedAt: '2026-09-24T10:00:00+08:00' }))
  123. check(
  124. 'A3-null-point-code-normalised',
  125. free && free.monitoringPointCode === '',
  126. `未关联设备的 monitoringPointCode 归一为 "${free && free.monitoringPointCode}"(不会误当成点位码 "null")`,
  127. )
  128. // 但 `cockpitSubscribed` **不带**初始状态数组(REST 才是初始快照)。
  129. const subscribed = toSubscribedEvent({ type: 'cockpitSubscribed', scope: 'all', serverUpdatedAt: '2026-09-24T10:00:00+08:00' })
  130. check(
  131. 'A4-subscribed-has-no-device-array',
  132. subscribed && subscribed.scope === 'all' && subscribed.devices === undefined,
  133. '订阅回帧只有 scope/serverUpdatedAt —— 不假装它带了初始状态(初始快照归 REST)',
  134. )
  135. }
  136. // ================================================================ B 握手顺序与鉴权失败
  137. {
  138. const timers = fakeTimers()
  139. resetForTest()
  140. const scripted = createScriptedSocketFactory({ setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  141. // token 故意传入:免鉴权后服务层必须忽略它(不进任何帧)。
  142. connect({ url: 'mock://ws', token: 'STALE-1', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  143. check('B1-connecting-first', getStatus().state === WS_STATE.CONNECTING, `建连后状态 ${getStatus().state}`)
  144. await timers.run()
  145. const socket = scripted.log[0]
  146. const sent = socket.sent.map((item) => JSON.parse(item))
  147. check(
  148. 'B2-subscribe-directly-without-authenticate',
  149. sent.length === 1 && sent[0].type === 'cockpitSubscribe' && sent[0].scope === 'all',
  150. `客户端发出的帧:${sent.map((f) => f.type).join(' → ')}(免鉴权:open 后直接订阅 all,无 authenticate 帧)`,
  151. )
  152. check(
  153. 'B3-stale-token-never-sent',
  154. sent.every((frame) => frame.token === undefined && frame.Authorization === undefined),
  155. '传入的旧 token 不进任何帧(ADR 0005 修订:WS 免鉴权)',
  156. )
  157. check('B4-open-after-subscribed', getStatus().state === WS_STATE.OPEN, `握手完成后状态 ${getStatus().state}`)
  158. disconnect()
  159. }
  160. {
  161. // 免鉴权后不再有「鉴权失败」场景:unauthorized 脚本退化为「订阅成功但无增量」,
  162. // 服务层不得因收不到增量而崩或假装连上(状态只到 subscribing,等下个周期重评)。
  163. const timers = fakeTimers()
  164. resetForTest()
  165. const scripted = createScriptedSocketFactory({ scenario: 'unauthorized', setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  166. connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  167. await timers.run()
  168. const status = getStatus()
  169. const sent = scripted.log[0].sent.map((item) => JSON.parse(item))
  170. check(
  171. 'B5-silent-push-stays-subscribing-or-open',
  172. sent.length === 1 && sent[0].type === 'cockpitSubscribe' &&
  173. (status.state === WS_STATE.SUBSCRIBING || status.state === WS_STATE.OPEN),
  174. `订阅成功但无增量:只发 1 帧 cockpitSubscribe,状态 ${status.state}(不伪造 open,也不崩)`,
  175. )
  176. disconnect()
  177. }
  178. // ================================================================ C 旧消息被忽略
  179. {
  180. const base = '2026-09-24T10:00:02+08:00'
  181. check(
  182. 'C1-newer-accepted-older-rejected',
  183. isNewerDelta({ serverUpdatedAt: '2026-09-24T10:00:03+08:00' }, base) === true &&
  184. isNewerDelta({ serverUpdatedAt: '2026-09-24T10:00:01+08:00' }, base) === false &&
  185. isNewerDelta({ serverUpdatedAt: base }, base) === false,
  186. '严格更新才采纳:更晚 → true;更早/相同 → false',
  187. )
  188. check(
  189. 'C2-compares-instants-not-strings',
  190. // 同一个瞬间,偏移不同:字符串比大小会得出错误结论,按时刻比才对。
  191. parseStamp('2026-09-24T10:00:00+08:00') === parseStamp('2026-09-24T02:00:00Z') &&
  192. isNewerDelta({ serverUpdatedAt: '2026-09-24T02:00:00Z' }, '2026-09-24T10:00:00+08:00') === false,
  193. '同一时刻的不同偏移写法判为"不更新"(按 epoch 比,不按字符串)',
  194. )
  195. check(
  196. 'C3-unparsable-timestamp-rejected',
  197. isNewerDelta({ serverUpdatedAt: '' }, base) === false &&
  198. isNewerDelta({ serverUpdatedAt: 'not-a-time' }, base) === false &&
  199. parseStamp('') === null,
  200. '时间认不出的帧不采纳(宁可漏一次,也不让乱序旧值盖掉新值)',
  201. )
  202. // 合并层:旧消息不得改动状态表,且**原样返回同一个引用**(调用方据此跳过重渲染)。
  203. let state = emptyStatusState()
  204. const first = mergeDeviceStatus({ state, delta: toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:02+08:00' })) })
  205. state = first.state
  206. const stale = mergeDeviceStatus({ state, delta: toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'OFFLINE', serverUpdatedAt: '2026-09-24T10:00:01+08:00' })) })
  207. check(
  208. 'C4-merge-rejects-stale-without-copy',
  209. first.accepted === true &&
  210. stale.accepted === false &&
  211. stale.reason === 'STALE' &&
  212. stale.state === state &&
  213. state.deviceStates['EQ-001'].status === 'ALARM',
  214. `旧帧被丢弃(reason=${stale.reason}),状态表仍是 ${state.deviceStates['EQ-001'].status} 且引用未变`,
  215. )
  216. check(
  217. 'C5-rejects-without-device-or-status',
  218. mergeDeviceStatus({ state, delta: { deviceCode: '', status: 'ALARM', serverUpdatedAt: '2026-09-24T11:00:00+08:00' } }).reason === 'NO_DEVICE_CODE' &&
  219. mergeDeviceStatus({ state, delta: { deviceCode: 'EQ-001', status: '', serverUpdatedAt: '2026-09-24T11:00:00+08:00' } }).reason === 'NO_STATUS',
  220. '缺设备编码/缺状态的帧各有明确丢弃原因(不把已知状态抹成"未发布")',
  221. )
  222. }
  223. // ================================================================ D 就地更新,不重建视图
  224. {
  225. const objects = [
  226. { kind: 'monitoring-point', code: 'MP-001', name: 'A 点', status: POINT_STATUS.NORMAL, longitude: 110.1, latitude: 28.1, deviceCodes: ['EQ-001'] },
  227. { kind: 'monitoring-point', code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, longitude: 110.2, latitude: 28.2, deviceCodes: ['EQ-002'] },
  228. { kind: 'monitoring-point', code: 'MP-003', name: 'C 点', status: POINT_STATUS.WARNING, longitude: 110.3, latitude: 28.3, deviceCodes: [] },
  229. ]
  230. const { objects: next, changed } = applyPointStates(objects, { 'MP-002': { status: POINT_STATUS.ALARM } })
  231. check(
  232. 'D1-only-affected-object-changes',
  233. changed.length === 1 &&
  234. changed[0] === 'MP-002' &&
  235. next[1].status === POINT_STATUS.ALARM &&
  236. next[0].status === POINT_STATUS.NORMAL &&
  237. next[2].status === POINT_STATUS.WARNING,
  238. `只有 MP-002 改状态(${changed.join(',')});MP-001/MP-003 原样`,
  239. )
  240. check(
  241. 'D2-untouched-keep-identity',
  242. next[0] === objects[0] && next[2] === objects[2] && next[1] !== objects[1] && next.length === objects.length,
  243. '未命中的元素**保持同一引用**(消费方据此只重画变动的那几个),顺序与长度不变',
  244. )
  245. check(
  246. 'D3-does-not-touch-geometry',
  247. next[1].longitude === objects[1].longitude && next[1].name === objects[1].name && next[1].deviceCodes === objects[1].deviceCodes,
  248. '坐标/名称/关联设备一概不动(推送不带这些字段,凭空补就是编数据)',
  249. )
  250. check(
  251. 'D4-same-status-is-noop',
  252. applyPointStates(objects, { 'MP-001': { status: POINT_STATUS.NORMAL } }).changed.length === 0 &&
  253. applyPointStates(objects, { 'MP-001': { status: POINT_STATUS.NORMAL } }).objects === objects,
  254. '状态没变时不产生新数组(同值推送不引起无谓重渲染)',
  255. )
  256. check(
  257. 'D5-unknown-point-is-ignored',
  258. applyPointStates(objects, { 'MP-999': { status: POINT_STATUS.ALARM } }).changed.length === 0,
  259. '推送指名了不在已加载范围的点位 → 不改任何对象(不是错误,可能还没翻到那一页)',
  260. )
  261. }
  262. // ================================================================ E 选中/面板/滚动不受影响
  263. {
  264. const uiStore = useUiStore()
  265. const mapStore = useMapStore()
  266. const detailStore = useDetailStore()
  267. const runtimeStore = useRuntimeStore()
  268. uiStore.reset?.()
  269. mapStore.reset()
  270. detailStore.reset()
  271. runtimeStore.reset()
  272. // 装两个对象(含被推送影响的 MP-002)与一个已打开的详情。
  273. // 注意:经由 Pinia 取到的 `mapStore.summary` 已是解包后的对象(不是 ref),
  274. // 所以这里直接赋字段,不写 `.value`(写了会变成一个无关的键,对象数仍是 0)。
  275. mapStore.summary = {
  276. ...emptyMapSummary({ pageSize: 2 }),
  277. objects: [
  278. { kind: 'monitoring-point', code: 'MP-001', name: 'A 点', status: POINT_STATUS.NORMAL, longitude: 110.1, latitude: 28.1, deviceCodes: ['EQ-001'] },
  279. { kind: 'monitoring-point', code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, longitude: 110.2, latitude: 28.2, deviceCodes: ['EQ-002'] },
  280. ],
  281. }
  282. // 已打开详情:MP-002(状态 NORMAL)
  283. detailStore.setSection('pointDetail', { state: 'success', data: { code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, deviceCount: 1 } })
  284. // 当前选中对象 + 展开面板 + 滚动位置(验收第 5 条要它们不变)
  285. uiStore.setMode?.('warningResponse')
  286. const modeBefore = uiStore.mode
  287. const expandedBefore = JSON.stringify(uiStore.expandedPanelIds)
  288. const selectedBefore = JSON.stringify(detailStore.selection)
  289. const scrollBefore = { top: 420, left: 0 }
  290. const result = runtimeStore.ingest({ deviceCode: 'EQ-002', monitoringPointCode: 'MP-002', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:09+08:00', statusTime: '', reason: '' })
  291. check(
  292. 'E1-point-status-updated-in-place',
  293. result.accepted === true &&
  294. mapStore.summary.objects.find((item) => item.code === 'MP-002').status === POINT_STATUS.ALARM &&
  295. mapStore.summary.objects.find((item) => item.code === 'MP-001').status === POINT_STATUS.NORMAL,
  296. `MP-002 就地变 ${mapStore.summary.objects.find((i) => i.code === 'MP-002').status},MP-001 仍是 NORMAL`,
  297. )
  298. check(
  299. 'E2-open-detail-status-updated',
  300. detailStore.sections.pointDetail.data.status === POINT_STATUS.ALARM &&
  301. detailStore.sections.pointDetail.data.name === 'B 点',
  302. `已打开的详情状态随之更新为 ${detailStore.sections.pointDetail.data.status},其余字段保持快照里的值`,
  303. )
  304. check(
  305. 'E3-selection-mode-panel-scroll-untouched',
  306. uiStore.mode === modeBefore &&
  307. JSON.stringify(uiStore.expandedPanelIds) === expandedBefore &&
  308. JSON.stringify(detailStore.selection) === selectedBefore &&
  309. scrollBefore.top === 420,
  310. `当前模式/展开面板/选中对象/滚动位置全部原样(模式=${uiStore.mode}、选中=${selectedBefore})`,
  311. )
  312. check(
  313. 'E4-detail-not-refetched',
  314. detailStore.sections.pointDetail.loaded === true && detailStore.pointLoading === false,
  315. '推送更新**不触发取数**(不重新拉详情、不换层级,浮层因此不闪)',
  316. )
  317. check(
  318. 'E5-unrelated-point-untouched',
  319. runtimeStore.ingest({ deviceCode: 'EQ-999', monitoringPointCode: 'MP-999', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:10+08:00' }).accepted === true &&
  320. detailStore.sections.pointDetail.data.status === POINT_STATUS.ALARM &&
  321. detailStore.sections.pointDetail.data.code === 'MP-002',
  322. '指名别的点位时,已打开的这个详情不动',
  323. )
  324. // 未关联设备(pointCode 为空):只更新设备维度,不改任何点位。
  325. const beforeObjects = mapStore.summary.objects
  326. const freeResult = runtimeStore.ingest({ deviceCode: 'EQ-FREE-001', monitoringPointCode: '', status: POINT_STATUS.OFFLINE, serverUpdatedAt: '2026-09-24T10:00:11+08:00' })
  327. check(
  328. 'E6-unassociated-updates-device-only',
  329. freeResult.accepted === true &&
  330. freeResult.pointCode === '' &&
  331. mapStore.summary.objects === beforeObjects &&
  332. runtimeStore.statusState.deviceStates['EQ-FREE-001'].status === POINT_STATUS.OFFLINE,
  333. '未关联设备只落设备维度(对象数组引用未变 → 地图不重画)',
  334. )
  335. }
  336. // ================================================================ E7 翻页补状态(真机 bug 的回归)
  337. {
  338. const mapStore = useMapStore()
  339. const runtimeStore = useRuntimeStore()
  340. mapStore.reset()
  341. runtimeStore.reset()
  342. const { registerMockTransport, resetMockTransportsForTest } = await import('../../../src/modules/cockpit/services/cockpitApi.js')
  343. const { registerCockpitMocks } = await import('../../../src/modules/cockpit/mocks/contractMocks.js')
  344. resetMockTransportsForTest()
  345. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  346. mapStore.setup({ config: { dataSource: 'mock', api: { baseUrl: '', token: '' } } })
  347. // 第 1 页(MP-001/MP-002)先到,并收到一条把 MP-003 推成 ALARM 的消息(MP-003 还没翻到)。
  348. await mapStore.advance()
  349. const page1 = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
  350. runtimeStore.ingest({ deviceCode: 'EQ-003', monitoringPointCode: 'MP-003', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:20+08:00' })
  351. const afterPush = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
  352. // 第 2 页到达(MP-003 在快照里是 WARNING)→ 必须显示推送来的 ALARM。
  353. await mapStore.advance()
  354. const page2 = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
  355. const mp003 = mapStore.summary.objects.find((item) => item.code === 'MP-003')
  356. check(
  357. 'E7c-later-page-gets-cached-push-status',
  358. page1.length === 2 &&
  359. JSON.stringify(afterPush) === JSON.stringify(page1) &&
  360. !!mp003 &&
  361. mp003.status === POINT_STATUS.ALARM &&
  362. mp003.statusSource === 'push',
  363. `第 1 页 ${JSON.stringify(page1)} → 推 MP-003=ALARM(当时还没翻到,图上不变)→ 第 2 页 ${JSON.stringify(page2)}:MP-003 显示推送值 ${mp003 && mp003.status}(不是快照里的 WARNING)`,
  364. )
  365. // 反向证据:**显式刷新**换新快照时丢弃推送缓存,让这一份快照说了算
  366. // (否则一条较旧的推送会盖掉刚取回的新状态 —— 那正是"用旧值覆盖新值")。
  367. // 注意 `refresh()` 是**整份换快照并回到第 1 页**,所以 MP-003(第 2 页的对象)
  368. // 刷新后本来就不该在图上;要验的是"翻回它时按快照值显示,不再带 push 标记"。
  369. await mapStore.refresh()
  370. const page1AfterRefresh = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
  371. await mapStore.advance()
  372. const mp003AfterRefresh = mapStore.summary.objects.find((item) => item.code === 'MP-003')
  373. check(
  374. 'E7d-refresh-lets-snapshot-win',
  375. page1AfterRefresh.length === 2 &&
  376. !!mp003AfterRefresh &&
  377. mp003AfterRefresh.status === POINT_STATUS.WARNING &&
  378. mp003AfterRefresh.statusSource === undefined,
  379. `刷新后第 1 页 ${JSON.stringify(page1AfterRefresh)}(回到第 1 页)→ 再翻到 MP-003:${mp003AfterRefresh && mp003AfterRefresh.status}(快照值 WARNING、无 push 标记)= 显式重新取数以快照为准`,
  380. )
  381. }
  382. // ================================================================ F 四态可演示
  383. {
  384. // 未配置地址:不是错误,如实说"未配置"。
  385. resetForTest()
  386. const disabled = connect({ url: '' })
  387. check('F1-disabled-when-no-url', disabled.state === WS_STATE.DISABLED && /未配置/.test(disabled.text), `未配地址 → ${disabled.state}(「${disabled.text}」)`)
  388. disconnect()
  389. // 断线 → 重连:`flaky` 脚本在第 2 条增量后掉线。
  390. const timers = fakeTimers()
  391. resetForTest()
  392. const scripted = createScriptedSocketFactory({ scenario: 'flaky', disconnectAfter: 1, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  393. const seen = []
  394. const offDelta = onDelta((event) => seen.push(event))
  395. const states = []
  396. const offStatus = onStatus((status) => states.push(status.state))
  397. connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  398. await timers.run()
  399. check(
  400. 'F2-retrying-state-observed',
  401. states.includes(WS_STATE.RETRYING),
  402. `连接状态序列:${states.join(' → ')}(含重连中)`,
  403. )
  404. check(
  405. 'F3-reconnect-actually-happens',
  406. scripted.log.length >= 2 && getStatus().state === WS_STATE.OPEN,
  407. `工厂建了 ${scripted.log.length} 个连接,重连后状态 ${getStatus().state}(真的重连了,不是只改文案)`,
  408. )
  409. check(
  410. 'F4-deltas-delivered-before-drop',
  411. seen.length >= 1 && seen.every((event) => !!event.deviceCode),
  412. `掉线前收到 ${seen.length} 条增量(每条都有设备编码)`,
  413. )
  414. offDelta()
  415. offStatus()
  416. disconnect()
  417. }
  418. // ================================================================ G 退避与清理
  419. {
  420. check(
  421. 'G1-backoff-grows-and-caps',
  422. reconnectDelay(1) === RECONNECT_DELAYS[0] &&
  423. reconnectDelay(2) === RECONNECT_DELAYS[1] &&
  424. reconnectDelay(3) === RECONNECT_DELAYS[2] &&
  425. reconnectDelay(99) === RECONNECT_DELAYS[RECONNECT_DELAYS.length - 1],
  426. `退避 ${RECONNECT_DELAYS.join('/')} 毫秒,末档封顶(长跑后重连间隔不无限增长)`,
  427. )
  428. // disconnect 必须取消待触发的重连(否则离开驾驶舱后定时器还在跑)。
  429. // 这里**只推进到"掉线并排好重连定时器"为止**(不把定时器跑完),
  430. // 才能观察到"断开时确实有一个待触发的重连被取消"。
  431. const timers = fakeTimers()
  432. resetForTest()
  433. const scripted = createScriptedSocketFactory({ scenario: 'flaky', disconnectAfter: 1, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  434. connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout })
  435. // 一次只走一个定时器,直到状态落到 retrying(此时队列里应有下一个重连定时器)。
  436. for (let i = 0; i < 40 && getStatus().state !== WS_STATE.RETRYING && timers.pending(); i += 1) {
  437. await timers.step()
  438. }
  439. const pendingBefore = timers.pending()
  440. disconnect()
  441. check(
  442. 'G2-disconnect-cancels-reconnect',
  443. pendingBefore > 0 && timers.pending() === 0 && getStatus().state === WS_STATE.IDLE,
  444. `断开前还有 ${pendingBefore} 个待触发定时器,disconnect 后剩 ${timers.pending()} 个、状态 ${getStatus().state}`,
  445. )
  446. }
  447. // ================================================================ H 静态守卫
  448. {
  449. // 组件不认识 socket / 帧类型(ADR 0004:页面只消费领域状态)。
  450. const shellCode = readVueCode('CockpitShell.vue')
  451. const FRAME_PATTERN = /cockpitDeviceStatusDelta|cockpitSubscribed|authenticate|new WebSocket|onmessage/
  452. check(
  453. 'H1-shell-knows-no-protocol',
  454. !FRAME_PATTERN.test(shellCode),
  455. '外壳只读 runtimeStore 的状态与文案,不认识帧类型与 socket',
  456. )
  457. check(
  458. 'H2-shell-shows-state',
  459. /data-ws-state/.test(readVueCode('CockpitShell.vue')) && /runtimeStore\.statusText/.test(shellCode),
  460. '外壳把连接状态画出来(data-ws-state 是走查断言面)',
  461. )
  462. check(
  463. 'H2b-shell-knows-no-mock-socket',
  464. !/createScriptedSocketFactory|wsMock|socketFactory/.test(shellCode),
  465. '外壳不认识脚本化假连接(mock/real 的连接方式归 store)',
  466. )
  467. // runtimeStore 不碰 socket:连接归 service。
  468. const storeCode = readCode('stores/runtimeStore.js')
  469. check(
  470. 'H3-store-has-no-socket',
  471. !/new WebSocket|onmessage|readyState|\.send\(/.test(storeCode) && /from '\.\.\/services\/runtimeSocket\.js'/.test(storeCode),
  472. 'runtimeStore 只调运行时服务,不自己建连/发帧',
  473. )
  474. check(
  475. 'H4-domain-pure',
  476. !/\bfetch\s*\(|XMLHttpRequest|new WebSocket/.test(read('domain/statusPush.js')) &&
  477. !/from '\.\.\/services\//.test(read('domain/statusPush.js')),
  478. 'domain/statusPush.js 是纯函数(不发请求、不 import service)',
  479. )
  480. // 凭据边界(ADR 0005 修订:REST/WS 均免鉴权,**任何层**都不该再出现 Authorization/Bearer)。
  481. // 页面与 store 也不该出现 ws.token/api.token 字段名(token 链路已整体下线)。
  482. check(
  483. 'H5-shell-no-credential',
  484. !/Authorization|Bearer|api\.token|ws\.token/.test(readVueCode('CockpitShell.vue')),
  485. '外壳不碰凭据(连 ws.token 这个字段名都不出现)',
  486. )
  487. check(
  488. 'H5-store-no-token-plumbing',
  489. !/Authorization|Bearer/.test(readCode('stores/runtimeStore.js')) &&
  490. !/ws\.token \|\| api\.token/.test(readCode('stores/runtimeStore.js')),
  491. 'runtimeStore 不再转交 token(免鉴权后 WS 只从 ws.token 读——且仅由 store 旧路径兼容保留)',
  492. )
  493. check(
  494. 'H5-domain-no-credential',
  495. !/Authorization|Bearer|api\.token|ws\.token/.test(read('domain/statusPush.js')),
  496. 'domain/statusPush.js 不碰凭据',
  497. )
  498. // 时序纪律(ADR 0005 修订):open 后**直接** cockpitSubscribe,服务里不得再有 authenticate 发送点。
  499. const socketCode = readCode('services/runtimeSocket.js')
  500. const subAt = socketCode.indexOf("type: 'cockpitSubscribe'")
  501. check(
  502. 'H6-subscribe-directly-no-authenticate-send',
  503. subAt > 0 && !/send\(\{\s*type:\s*'authenticate'/.test(socketCode),
  504. '服务里只有 cockpitSubscribe 一个发送点,不再发送 authenticate 帧',
  505. )
  506. check(
  507. 'H7-runtime-service-knows-no-data-store',
  508. !/mapStore|detailStore/.test(socketCode),
  509. '运行时服务不认识任何数据 store —— 推送异常在结构上不可能牵连数据分区',
  510. )
  511. }
  512. const failed = checks.filter((item) => !item.pass)
  513. console.log('')
  514. console.log(`T15 状态推送检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  515. if (failed.length) {
  516. console.log('未通过:')
  517. for (const item of failed) console.log(` · ${item.name}`)
  518. process.exit(1)
  519. }
  520. console.log('T15 状态推送全部通过。')