t42-refresh-lifecycle.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /** T42 刷新生命周期门禁(票 42 重开;规格 14:86 核对链)。 */
  2. import assert from 'node:assert/strict'
  3. import { readFileSync } from 'node:fs'
  4. import { createPinia, setActivePinia } from 'pinia'
  5. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  6. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  7. import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
  8. import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
  9. import { useDeviceLayerStore } from '../../../src/modules/cockpit/stores/deviceLayerStore.js'
  10. const root = new URL('../../../src/modules/cockpit/', import.meta.url)
  11. const read = (rel) => readFileSync(new URL(rel, root), 'utf8')
  12. const checks = []
  13. let failed = 0
  14. async function check(name, action) {
  15. checks.length += 1
  16. try { await action(); console.log(`PASS ${name}`) }
  17. catch (error) { failed += 1; console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`) }
  18. }
  19. setActivePinia(createPinia())
  20. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  21. /** real 模式:让注入的 fetchImpl 真正接到请求(mock 模式走 mock 传输,注入无效)。 */
  22. const realConfig = { dataSource: 'real', api: { baseUrl: 'http://acceptance' } }
  23. const point = (code, extra = {}) => ({ pointCode: code, pointName: `点 ${code}`, pointStatus: 'NORMAL', deviceCodes: [],
  24. longitude: 110.4, latitude: 28.44,
  25. coordinateValidation: { state: 'INVALID', reason: 'OUTSIDE_PRIMARY_AREA', renderable: false, crs: 'WGS84' }, ...extra })
  26. const pageBody = (points, { complete = true, total = points.length } = {}) => ({
  27. snapshotAt: 'S', batchUpdatedAt: 'S', page: 1, pageSize: 1, total, complete, monitoringPoints: points, unassociatedEquipment: [] })
  28. const ok = (body) => Promise.resolve({ ok: true, status: 200, json: async () => body })
  29. const expired = () => Promise.resolve({ ok: false, status: 400, json: async () => ({ code: 500, msg: 'fail', data: { category: 'SNAPSHOT_EXPIRED' } }) })
  30. /** 造一个分页 fetch:pages[i] 为第 i+1 页载荷;delayPage 挂起并回 .resolve(page) 供测试放行。 */
  31. function pagingFetch(pages, { delayPage = -1 } = {}) {
  32. const pending = new Map()
  33. const fetch = (url) => {
  34. const page = Number(new URL(url, 'http://acceptance').searchParams.get('page') || 1)
  35. const body = pages[page - 1]
  36. if (!body) return expired()
  37. if (page === delayPage) return new Promise((resolve) => { pending.set(page, () => resolve(ok(body))) })
  38. return ok(body)
  39. }
  40. fetch.resolve = (page) => { const release = pending.get(page); if (release) { pending.delete(page); release() } }
  41. return fetch
  42. }
  43. // ---- A1 refreshing:发起即亮(根因 ① 的撤层触发信号,同步可见) ----
  44. {
  45. const store = useMapStore()
  46. store.reset()
  47. const fetch = pagingFetch([pageBody([point('P-1')])], { delayPage: 1 })
  48. store.setup({ config: realConfig, fetchImpl: fetch })
  49. const pending = store.refresh()
  50. check('A1-refreshing-true-at-initiation', () => assert.equal(store.refreshing, true))
  51. fetch.resolve(1)
  52. await pending
  53. check('A1-refreshing-false-after-settle', () => assert.equal(store.refreshing, false))
  54. }
  55. // ---- A2 完整链收口 + 全页存在性索引(护栏无关) ----
  56. {
  57. const store = useMapStore()
  58. store.reset()
  59. store.setup({ config: realConfig, fetchImpl: pagingFetch([
  60. pageBody([point('P-1')], { complete: false, total: 2 }),
  61. pageBody([point('P-2')]),
  62. ]) })
  63. await store.refresh()
  64. check('A2-phase-complete', () => assert.equal(store.summary.phase, 'complete'))
  65. check('A2-refreshing-false-after-settle', () => assert.equal(store.refreshing, false))
  66. check('A2-page1-point-indexed', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-1'), true))
  67. check('A2-page2-point-indexed', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-2'), true))
  68. check('A2-unknown-absent', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-X'), false))
  69. }
  70. // ---- A3 快照失效:expired 相位(batchUpdatedAt 不变,外壳单独触发撤层) ----
  71. {
  72. const store = useMapStore()
  73. store.reset()
  74. store.setup({ config: realConfig, fetchImpl: pagingFetch([pageBody([point('P-1')], { complete: false, total: 2 })]) })
  75. await store.refresh()
  76. check('A3-expired-phase', () => assert.equal(store.summary.phase, 'expired'))
  77. check('A3-refreshing-false-after-expired', () => assert.equal(store.refreshing, false))
  78. check('A3-index-keeps-fetched-pages', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-1'), true))
  79. }
  80. // ---- A4 换快照重置存在性索引 ----
  81. {
  82. const store = useMapStore()
  83. store.reset()
  84. store.setup({ config: realConfig, fetchImpl: pagingFetch([pageBody([point('P-1')], { complete: false, total: 2 }), pageBody([point('P-2')])]) })
  85. await store.refresh()
  86. check('A4-old-snapshot-indexed', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-2'), true))
  87. store.setup({ config: realConfig, fetchImpl: pagingFetch([pageBody([point('P-1')])]) })
  88. await store.refresh()
  89. check('A4-index-reset-on-new-snapshot', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-2'), false))
  90. }
  91. // ---- A5 reloadPointDetail:强制重取(不查缓存)且保留选择层级 ----
  92. {
  93. resetMockTransportsForTest()
  94. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  95. const detail = useDetailStore()
  96. detail.reset()
  97. detail.configure({ config: mockConfig })
  98. let hits = 0
  99. registerMockTransport('/monitoring-points/MP-COUNT', () => { hits += 1; return {
  100. monitoringPointCode: 'MP-COUNT', monitoringPointName: '计数点', pointStatus: 'NORMAL', statusLabel: '正常',
  101. statusReasons: [], deviceStatistics: { totalDeviceCount: 0 }, devices: [],
  102. coordinateValidation: { state: 'VALID', reason: null, renderable: false, crs: 'WGS84' } } })
  103. await detail.openPoint('MP-COUNT')
  104. await detail.openPoint('MP-COUNT')
  105. check('A5-openPoint-uses-cache', () => assert.equal(hits, 1))
  106. await detail.selectDevice('TC-PS-WS-202609020012').catch(() => {})
  107. const levelBefore = detail.selection.level
  108. await detail.reloadPointDetail('MP-COUNT')
  109. check('A5-reload-forces-refetch', () => assert.equal(hits, 2))
  110. check('A5-reload-keeps-selection-level', () => assert.equal(detail.selection.level, levelBefore))
  111. check('A5-reload-refreshes-section', () => assert.equal(detail.sections.pointDetail.data.code, 'MP-COUNT'))
  112. }
  113. // ---- A6 suspendForSnapshot:撤下且不按旧摘要重算 ----
  114. {
  115. const device = useDeviceLayerStore()
  116. device.reset()
  117. device.rows = [{ code: 'EQ-1', pointCode: 'MP-1', placement: 'drawn', status: 'NORMAL' }]
  118. device.total = 5
  119. device.suspendForSnapshot()
  120. check('A6-suspend-withdraws-rows', () => assert.equal(device.rows.length, 0))
  121. check('A6-suspend-clears-total', () => assert.equal(device.total, 0))
  122. check('A6-suspend-paints-false', () => assert.equal(device.painted, false))
  123. const source = read('stores/deviceLayerStore.js')
  124. const body = source.match(/function suspendForSnapshot\(\)[^]*?\n \}/)[0]
  125. check('A6-suspend-does-not-replan', () => assert.ok(!body.includes('syncForSelection'), '挂起不得立即重算'))
  126. }
  127. // ---- B 外壳静态守卫:三个监听 + 核对函数 + 可观察 chip ----
  128. {
  129. const shell = read('CockpitShell.vue')
  130. check('B1-refreshing-watcher-suspends', () => assert.match(shell,
  131. /watch\(\(\) => mapStore\.refreshing, \(on\) => \{[^]*?deviceLayerStore\.suspendForSnapshot\(\)/))
  132. check('B2-expired-watcher-suspends', () => assert.match(shell,
  133. /watch\(\(\) => mapStore\.summary\.phase[\s\S]{0,120}MAP_SUMMARY_PHASE\.EXPIRED[\s\S]{0,160}suspendForSnapshot\(\)/))
  134. check('B3-settled-watcher-reconciles', () => assert.match(shell,
  135. /watch\(\(\) => mapStore\.settled[\s\S]{0,120}reconcileSnapshotSelection\(\)/))
  136. const reconcile = shell.match(/function reconcileSnapshotSelection\(\)[^]*?\n\}/)[0]
  137. check('B4-reconcile-found-reloads', () => assert.match(reconcile, /hasSnapshotObject\('monitoring-point', pointCode\)/) && assert.match(reconcile, /reloadPointDetail\(pointCode\)/))
  138. check('B5-reconcile-absent-closes', () => assert.match(reconcile, /snapshotAbsentNote\.value =/) && assert.match(reconcile, /closeDetail\(\)/))
  139. check('B6-reconcile-failure-keeps-pending', () => assert.match(reconcile, /MAP_SUMMARY_PHASE\.FAILED \|\| phase === MAP_SUMMARY_PHASE\.EXPIRED/))
  140. check('B7-pending-chip', () => assert.match(shell, /data-snapshot-pending/))
  141. check('B8-absent-chip', () => assert.match(shell, /data-snapshot-absent/))
  142. check('B9-store-exports', () => {
  143. assert.match(read('stores/mapStore.js'), /refreshing, filters, hasSnapshotObject/)
  144. assert.match(read('stores/detailStore.js'), /reloadPointDetail,/)
  145. assert.match(read('stores/deviceLayerStore.js'), /suspendForSnapshot,/)
  146. })
  147. }
  148. console.log(`T42 refresh lifecycle: ${checks.length - failed}/${checks.length}`)
  149. process.exitCode = failed ? 1 : 0