t08-map-summary.mjs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. /**
  2. * T08 地图摘要分页与快照 纯逻辑检查(票 08)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary.mjs
  6. *
  7. * 分四段:
  8. * A 批次合并(跨页拼接、按 kind+code 去重、完成/总数/快照标记)
  9. * B 演示态词(装载中 / 完成 / 失败 / 快照过期 / 空,都要有中文文案)
  10. * C 装载器状态机(同一批加载保持同一快照、刷新换新快照、失败后重试不跳页)
  11. * D 适配层分页参数(page / pageSize / snapshotAt 真的出现在查询串上)
  12. *
  13. * 契约依据是**后端实现**而不是演示页的乐观假设:
  14. * `CockpitMapSnapshotService.validatePagination` —— 第 2 页起必须带 `snapshotAt`,
  15. * 且带了就必须与后端当前快照一致,否则抛 `SNAPSHOT_EXPIRED`。
  16. */
  17. import { readFileSync } from 'node:fs'
  18. import { createPinia, setActivePinia } from 'pinia'
  19. import {
  20. MAP_SUMMARY_LIMITS,
  21. MAP_SUMMARY_PHASE,
  22. createMapSummaryLoader,
  23. mergeMapPage,
  24. summaryView,
  25. } from '../../../src/modules/cockpit/domain/mapSummary.js'
  26. import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
  27. import {
  28. getMockTransportPaths,
  29. registerMockTransport,
  30. requestCockpitJson,
  31. resetMockTransportsForTest,
  32. } from '../../../src/modules/cockpit/services/cockpitApi.js'
  33. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  34. import { MAP_SUMMARY_PAGE_SIZE, useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
  35. import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
  36. const checks = []
  37. function check(name, pass, evidence) {
  38. checks.push({ name, pass })
  39. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  40. }
  41. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  42. /** 造一条**后端载荷**里的监测点/设备(字段名照抄 map-snapshot-demo.html)。 */
  43. const rawPoint = (code) => ({
  44. pointCode: code,
  45. pointName: `${code} 点位`,
  46. pointStatus: 'NORMAL',
  47. statusReasons: [],
  48. longitude: 110.141,
  49. latitude: 28.452,
  50. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  51. })
  52. /** 造一页后端载荷。 */
  53. function page({ page: pageNo = 1, pageSize = 2, snapshotAt = '2026-09-02T10:00:00+08:00', total = 4, complete = false, points = [], equipment = [] } = {}) {
  54. return { page: pageNo, pageSize, snapshotAt, batchUpdatedAt: snapshotAt, total, complete, monitoringPoints: points, unassociatedEquipment: equipment }
  55. }
  56. const point = (code, lon = 110.1, lat = 28.4) => ({
  57. kind: 'monitoring-point',
  58. code,
  59. name: `${code} 点位`,
  60. status: 'NORMAL',
  61. statusReasons: [],
  62. longitude: lon,
  63. latitude: lat,
  64. coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
  65. missing: [],
  66. })
  67. const device = (code, lon = 110.2, lat = 28.5) => ({
  68. kind: 'unassociated-equipment',
  69. code,
  70. name: `${code} 设备`,
  71. status: '',
  72. statusReasons: [],
  73. longitude: lon,
  74. latitude: lat,
  75. coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
  76. missing: [],
  77. })
  78. // ================================================================ A 批次合并
  79. {
  80. const first = mergeMapPage(null, {
  81. page: 1,
  82. pageSize: 2,
  83. snapshotAt: '2026-09-02T10:00:00+08:00',
  84. total: 4,
  85. complete: false,
  86. objects: [point('MP-001'), device('EQ-001')],
  87. })
  88. check(
  89. 'A1-first-page',
  90. first.page === 1 && first.snapshot === '2026-09-02T10:00:00+08:00' && first.total === 4 && first.complete === false && first.loaded === 2,
  91. JSON.stringify({ page: first.page, loaded: first.loaded }),
  92. )
  93. // 后端的分页窗口是「监测点 + 未关联设备」这一条总序列,跨页会横跨两类。
  94. const second = mergeMapPage(first, {
  95. page: 2,
  96. pageSize: 2,
  97. snapshotAt: '2026-09-02T10:00:00+08:00',
  98. total: 4,
  99. complete: true,
  100. objects: [point('MP-002'), device('EQ-002')],
  101. })
  102. check(
  103. 'A2-second-page-keeps-snapshot',
  104. second.page === 2 && second.snapshot === first.snapshot && second.complete === true && second.loaded === 4,
  105. `snapshot ${second.snapshot} / loaded ${second.loaded}`,
  106. )
  107. check(
  108. 'A2-both-kinds-on-map',
  109. second.objects.filter((item) => item.kind === 'monitoring-point').length === 2 &&
  110. second.objects.filter((item) => item.kind === 'unassociated-equipment').length === 2,
  111. JSON.stringify(second.objects.map((item) => item.kind)),
  112. )
  113. // 后端分页是「快照内的切片」:同一对象重复出现(重放/重试)不得在地图上变两遍。
  114. const replayed = mergeMapPage(second, {
  115. page: 2,
  116. snapshotAt: '2026-09-02T10:00:00+08:00',
  117. total: 4,
  118. complete: true,
  119. objects: [point('MP-002'), device('EQ-002')],
  120. })
  121. check('A3-dedupe-by-kind-and-code', replayed.loaded === 4 && replayed.objects.length === 4, `loaded ${replayed.loaded}`)
  122. // 不同 kind 的同名编码是不同对象(监测点 MP-1 与设备 MP-1 都要在图上)。
  123. const crossKind = mergeMapPage(null, { page: 1, objects: [point('SAME-1'), device('SAME-1')] })
  124. check('A3-cross-kind-not-merged', crossKind.loaded === 2 && crossKind.objects.length === 2, `objects ${crossKind.objects.length}`)
  125. // 同一 kind 下没有编码的脏对象不得被当成「同一个」丢掉。
  126. const noCode = mergeMapPage(null, { page: 1, objects: [{ kind: 'monitoring-point' }, { kind: 'monitoring-point' }] })
  127. check('A3-codeless-not-dropped', noCode.loaded === 2, `loaded ${noCode.loaded}`)
  128. // 刷新换快照 = 从空的合并态重来(旧快照的点不得留在地图上)。
  129. const refreshed = mergeMapPage(null, {
  130. page: 1,
  131. snapshotAt: '2026-09-02T10:05:00+08:00',
  132. total: 4,
  133. complete: false,
  134. objects: [point('MP-009')],
  135. })
  136. check(
  137. 'A4-refresh-drops-old-snapshot',
  138. refreshed.snapshot === '2026-09-02T10:05:00+08:00' && refreshed.loaded === 1 && refreshed.objects[0].code === 'MP-009',
  139. JSON.stringify(refreshed.objects.map((item) => item.code)),
  140. )
  141. // 长跑护栏:只保留 LIMITS 内的批次与对象(地图不是无限内存)。
  142. let long = null
  143. for (let index = 0; index < MAP_SUMMARY_LIMITS.maxBatches + 3; index += 1) {
  144. long = mergeMapPage(long, { page: index + 1, objects: [point(`MP-${index}`)], total: 999 })
  145. }
  146. check(
  147. 'A5-long-run-bounded',
  148. long.batches.length === MAP_SUMMARY_LIMITS.maxBatches && long.objects.length <= MAP_SUMMARY_LIMITS.maxObjects && long.page === MAP_SUMMARY_LIMITS.maxBatches + 3,
  149. `batches ${long.batches.length} objects ${long.objects.length} page ${long.page}`,
  150. )
  151. }
  152. // ================================================================ B 演示态词
  153. {
  154. const view = (phase, extra = {}) => summaryView({ phase, ...extra })
  155. check('B1-idle', view(MAP_SUMMARY_PHASE.IDLE).label.includes('框架已就绪'), view(MAP_SUMMARY_PHASE.IDLE).label)
  156. check('B2-loading', view(MAP_SUMMARY_PHASE.LOADING, { attemptPage: 2, loaded: 2, total: 7 }).label.includes('第 2 页'), view(MAP_SUMMARY_PHASE.LOADING, { attemptPage: 2 }).label)
  157. check('B3-complete', view(MAP_SUMMARY_PHASE.COMPLETE, { loaded: 7 }).label.includes('加载完成'), view(MAP_SUMMARY_PHASE.COMPLETE, { loaded: 7 }).label)
  158. check('B3-empty', view(MAP_SUMMARY_PHASE.EMPTY).label.includes('没有可上图对象'), view(MAP_SUMMARY_PHASE.EMPTY).label)
  159. check('B4-expired', view(MAP_SUMMARY_PHASE.EXPIRED).label.includes('已失效') && view(MAP_SUMMARY_PHASE.EXPIRED).tone === 'lv1', JSON.stringify(view(MAP_SUMMARY_PHASE.EXPIRED)))
  160. check('B4-failed', view(MAP_SUMMARY_PHASE.FAILED).tone === 'lv1' && view(MAP_SUMMARY_PHASE.FAILED).label.includes('失败'), JSON.stringify(view(MAP_SUMMARY_PHASE.FAILED)))
  161. // 对抗式:内部原因码不得出现在给人读的文案里(票 05 的同一条纪律)。
  162. const leaked = []
  163. for (const phase of Object.values(MAP_SUMMARY_PHASE)) {
  164. const text = JSON.stringify(summaryView({ phase, reason: 'snapshot-expired' }))
  165. if (text.includes('snapshot-expired')) leaked.push(phase)
  166. }
  167. check('B5-no-reason-code-in-ui', leaked.length === 0, leaked.length ? leaked.join(', ') : '五态都有中文文案')
  168. }
  169. // ================================================================ C 装载器状态机
  170. {
  171. /** 一段可控的分页后端:记录每次请求的快照入参,第 2 页起要求与当前快照一致。 */
  172. function fakeBackend({ total = 4, pageSize = 2, expireAtPage = 0, failAtPage = 0 } = {}) {
  173. let snapshotAt = '2026-09-02T10:00:00+08:00'
  174. const requests = []
  175. let sequence = 0
  176. const objects = Array.from({ length: total }, (_, index) =>
  177. index < 2 ? point(`MP-00${index + 1}`) : device(`EQ-00${index + 1}`),
  178. )
  179. return {
  180. requests,
  181. setSnapshot: (value) => {
  182. snapshotAt = value
  183. },
  184. async fetchPage({ page: pageNo, snapshotAt: requested }) {
  185. requests.push({ page: pageNo, snapshotAt: requested })
  186. if (failAtPage && pageNo === failAtPage) return { ok: false, reason: 'unavailable' }
  187. if (pageNo > 1 && !requested) return { ok: false, reason: 'bad-payload' }
  188. if (pageNo > 1 && requested !== snapshotAt) return { ok: false, reason: 'snapshot-expired' }
  189. if (expireAtPage && pageNo === expireAtPage) snapshotAt = '2026-09-02T10:09:00+08:00'
  190. const from = (pageNo - 1) * pageSize
  191. const slice = objects.slice(from, from + pageSize)
  192. return {
  193. ok: true,
  194. mode: 'mock',
  195. data: {
  196. snapshotAt,
  197. total,
  198. complete: from + pageSize >= total,
  199. objects: slice,
  200. },
  201. }
  202. },
  203. }
  204. }
  205. // 第 1 页(后端默认页大小 500)与第 2 页用的是**同一个**快照标记。
  206. {
  207. const backend = fakeBackend()
  208. const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
  209. await loader.loadNext()
  210. const afterFirst = loader.getState()
  211. // 还有下一页时停在「等下一页」而不是「完成」——渐进加载不是加载到一半就算完。
  212. check('C2-partial-is-not-complete', afterFirst.phase === MAP_SUMMARY_PHASE.IDLE && afterFirst.loaded === 2, afterFirst.phase)
  213. await loader.loadNext()
  214. const afterSecond = loader.getState()
  215. check(
  216. 'C1-locked-snapshot',
  217. backend.requests.length === 2 &&
  218. backend.requests[0].snapshotAt === undefined &&
  219. backend.requests[1].snapshotAt === afterFirst.snapshot &&
  220. afterSecond.snapshot === afterFirst.snapshot,
  221. JSON.stringify(backend.requests),
  222. )
  223. check(
  224. 'C2-progressive',
  225. afterSecond.loaded === 4 && afterSecond.phase === MAP_SUMMARY_PHASE.COMPLETE,
  226. `${afterFirst.phase}(${afterFirst.loaded}) → ${afterSecond.phase}(${afterSecond.loaded})`,
  227. )
  228. }
  229. // 完成后再点:不再发请求,也不换快照。
  230. {
  231. const backend = fakeBackend()
  232. const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
  233. await loader.loadNext()
  234. await loader.loadNext()
  235. const callsAfterComplete = backend.requests.length
  236. await loader.loadNext()
  237. const state = loader.getState()
  238. check(
  239. 'C3-complete-is-terminal',
  240. backend.requests.length === callsAfterComplete && state.phase === MAP_SUMMARY_PHASE.COMPLETE,
  241. `requests ${backend.requests.length}`,
  242. )
  243. }
  244. // 刷新:换新快照,已加载对象从零开始(旧快照的点不再留在图上)。
  245. {
  246. const backend = fakeBackend()
  247. const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
  248. await loader.loadNext()
  249. const oldSnapshot = loader.getState().snapshot
  250. backend.setSnapshot('2026-09-02T10:05:00+08:00')
  251. await loader.refresh()
  252. const state = loader.getState()
  253. check(
  254. 'C4-refresh-new-snapshot',
  255. state.snapshot === '2026-09-02T10:05:00+08:00' &&
  256. state.snapshot !== oldSnapshot &&
  257. state.page === 1 &&
  258. state.loaded === 2 &&
  259. backend.requests[backend.requests.length - 1].snapshotAt === undefined,
  260. JSON.stringify({ snapshot: state.snapshot, page: state.page, lastRequest: backend.requests[backend.requests.length - 1] }),
  261. )
  262. }
  263. // 同一批加载中途快照被后端换掉:如实报「快照过期」,绝不把两个快照的对象拼在一起。
  264. {
  265. const backend = fakeBackend({ expireAtPage: 2 })
  266. const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
  267. await loader.loadNext()
  268. const afterFirst = loader.getState()
  269. await loader.loadNext()
  270. const state = loader.getState()
  271. check(
  272. 'C5-expired-not-merged',
  273. state.phase === MAP_SUMMARY_PHASE.EXPIRED && state.reason === 'snapshot-expired' && state.loaded === afterFirst.loaded && state.objects.length === 2,
  274. JSON.stringify({ phase: state.phase, loaded: state.loaded }),
  275. )
  276. }
  277. // 失败:保留已加载的点,下一次点继续从**同一页**重试(不静默跳页丢数据)。
  278. {
  279. const backend = fakeBackend({ failAtPage: 2 })
  280. const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
  281. await loader.loadNext()
  282. await loader.loadNext()
  283. const failed = loader.getState()
  284. const retry = await loader.loadNext()
  285. check(
  286. 'C6-failed-then-retry-same-page',
  287. failed.phase === MAP_SUMMARY_PHASE.FAILED &&
  288. failed.loaded === 2 &&
  289. failed.page === 1 &&
  290. retry.phase === MAP_SUMMARY_PHASE.FAILED &&
  291. retry.nextPage === 2 &&
  292. backend.requests[backend.requests.length - 1].page === 2,
  293. JSON.stringify({ failedPhase: failed.phase, page: failed.page, retriedPage: backend.requests[backend.requests.length - 1].page }),
  294. )
  295. }
  296. // 空快照:不是失败,也不是「加载完成」的假象。
  297. {
  298. const loader = createMapSummaryLoader({
  299. fetchPage: async () => ({ ok: true, mode: 'mock', data: { snapshotAt: '2026-09-02T10:00:00+08:00', total: 0, complete: true, objects: [] } }),
  300. })
  301. await loader.loadNext()
  302. const state = loader.getState()
  303. check('C7-empty-phase', state.phase === MAP_SUMMARY_PHASE.EMPTY && state.loaded === 0, JSON.stringify(state.phase))
  304. }
  305. // 首屏框架与取数彼此独立:还没取数时装载器就说得出「框架已就绪,等待分页数据」。
  306. {
  307. const loader = createMapSummaryLoader({ fetchPage: async () => ({ ok: true, mode: 'mock', data: {} }) })
  308. check('C8-frame-before-data', loader.getState().phase === MAP_SUMMARY_PHASE.IDLE && summaryView(loader.getState()).label.includes('等待'), loader.getState().phase)
  309. }
  310. // 分页参数由调用方给(票面:「分页参数和快照参数由适配层管理」)。
  311. {
  312. const seen = []
  313. const loader = createMapSummaryLoader({
  314. pageSize: 250,
  315. fetchPage: async (options) => {
  316. seen.push(options)
  317. return { ok: true, mode: 'mock', data: { snapshotAt: 's1', total: 1, complete: true, objects: [point('MP-001')] } }
  318. },
  319. })
  320. await loader.loadNext()
  321. check('C9-pagination-params', seen[0].page === 1 && seen[0].pageSize === 250, JSON.stringify(seen[0]))
  322. }
  323. }
  324. // ================================================================ D 适配层分页参数
  325. {
  326. const seen = []
  327. resetMockTransportsForTest()
  328. registerMockTransport('/map-snapshot', async (context) => {
  329. seen.push(context.path)
  330. return page({ points: [rawPoint('MP-001')], complete: true, total: 1 })
  331. })
  332. const first = await fetchMapSnapshot({ page: 1, pageSize: 2, config: mockConfig })
  333. const second = await fetchMapSnapshot({ page: 2, pageSize: 2, snapshotAt: '2026-09-02T10:00:00+08:00', config: mockConfig })
  334. check('D1-query-carries-page-and-size', seen[0] === '/map-snapshot?page=1&pageSize=2', seen[0])
  335. check(
  336. 'D2-query-carries-snapshot',
  337. seen[1] === '/map-snapshot?page=2&pageSize=2&snapshotAt=2026-09-02T10%3A00%3A00%2B08%3A00',
  338. seen[1],
  339. )
  340. check(
  341. 'D3-domain-batch',
  342. first.state === 'success' && first.data.page === 1 && first.data.objects.length === 1 && second.data.complete === true,
  343. JSON.stringify({ state: first.state, objects: first.data.objects.length }),
  344. )
  345. // 后端「快照已失效」是 400 + `CockpitErrorCategory.SNAPSHOT_EXPIRED`;适配层必须把它
  346. // 当成可演示的独立原因交给界面,而不是笼统的「加载失败」。
  347. const errorBody = { code: 400, msg: '地图快照已失效,请重新加载', data: { category: 'SNAPSHOT_EXPIRED', message: '地图快照已失效,请重新加载' } }
  348. const expired = await requestCockpitJson('/map-snapshot?page=2&snapshotAt=old', {
  349. config: { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } },
  350. fetchImpl: async () => ({ ok: false, status: 400, json: async () => errorBody, text: async () => JSON.stringify(errorBody) }),
  351. })
  352. const invalid = await requestCockpitJson('/map-snapshot?page=0', {
  353. config: { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } },
  354. fetchImpl: async () => ({
  355. ok: false,
  356. status: 400,
  357. json: async () => ({ code: 400, msg: '地图快照分页参数不合法', data: { category: 'INVALID_REQUEST' } }),
  358. }),
  359. })
  360. check('D4-snapshot-expired-reason', expired.ok === false && expired.reason === 'snapshot-expired', JSON.stringify(expired))
  361. check('D4-invalid-request-stays-generic', invalid.reason === 'unavailable', JSON.stringify(invalid))
  362. }
  363. // ================================================================ E 装载器抛错不发散
  364. {
  365. const loader = createMapSummaryLoader({
  366. fetchPage: async () => {
  367. throw new Error('boom')
  368. },
  369. })
  370. const state = await loader.loadNext()
  371. check('E1-throw-becomes-failed', state.phase === MAP_SUMMARY_PHASE.FAILED && state.reason === 'unreachable', JSON.stringify(state.phase))
  372. }
  373. // ================================================================ F 契约化 mock 的真实分页
  374. {
  375. // mock 也要按后端语义分页:分页窗口横跨两类对象、第 2 页起不带快照即失效。
  376. resetMockTransportsForTest()
  377. registerCockpitMocks({
  378. scenario: 'success',
  379. mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
  380. handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
  381. })
  382. const first = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
  383. const second = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig })
  384. const third = await fetchMapSnapshot({ page: 3, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig })
  385. const noSnapshot = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
  386. check(
  387. 'F1-mock-pages-are-real-slices',
  388. first.data.objects.length === MAP_SUMMARY_PAGE_SIZE &&
  389. second.data.objects.length === MAP_SUMMARY_PAGE_SIZE &&
  390. third.data.complete === true &&
  391. first.data.snapshotAt === second.data.snapshotAt,
  392. JSON.stringify({ total: first.data.total, thirdComplete: third.data.complete }),
  393. )
  394. check(
  395. 'F2-mock-spans-both-kinds',
  396. new Set([...first.data.objects, ...second.data.objects, ...third.data.objects].map((item) => item.kind)).size === 2,
  397. JSON.stringify([...first.data.objects, ...second.data.objects, ...third.data.objects].map((item) => `${item.kind}:${item.code}`)),
  398. )
  399. // 分区块状结果里没有 `ok` 字段(那是装载器的词汇):失效在票 05 的词汇里是 `failed` + 原因。
  400. check(
  401. 'F3-mock-page2-without-snapshot-expires',
  402. noSnapshot.state === 'failed' && noSnapshot.reason === 'snapshot-expired',
  403. JSON.stringify({ state: noSnapshot.state, reason: noSnapshot.reason }),
  404. )
  405. }
  406. // ================================================================ G 装载器 → store → 分区状态
  407. {
  408. setActivePinia(createPinia())
  409. resetMockTransportsForTest()
  410. registerCockpitMocks({
  411. scenario: 'success',
  412. mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
  413. handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
  414. })
  415. const mapStore = useMapStore()
  416. const detailStore = useDetailStore()
  417. const freshMapStore = mapStore
  418. mapStore.setup({ config: mockConfig })
  419. const before = mapStore.summary
  420. await mapStore.advance()
  421. const afterFirst = mapStore.summary
  422. const partialSection = detailStore.syncMapSnapshotSection()
  423. await detailStore.loadSharedSections({ config: mockConfig })
  424. const section = detailStore.sections.mapSnapshot
  425. check(
  426. 'G1-store-progressive',
  427. before.loaded === 0 && before.phase === MAP_SUMMARY_PHASE.IDLE && afterFirst.loaded === MAP_SUMMARY_PAGE_SIZE && afterFirst.page === 1,
  428. JSON.stringify({ before: before.phase, after: `${afterFirst.phase}/${afterFirst.loaded}` }),
  429. )
  430. // 还没翻完 ≠ 过期:取到的一部分是**新鲜**数据,分区就是成功(把"没翻完"说成"已过期"
  431. // 是对值班人员撒谎,也会与地图上的真实进度互相矛盾)。
  432. check(
  433. 'G2-partial-is-success-not-stale',
  434. partialSection.state === 'success' && partialSection.retained === false && partialSection.data.loaded === MAP_SUMMARY_PAGE_SIZE,
  435. JSON.stringify({ state: partialSection.state, retained: partialSection.retained }),
  436. )
  437. // 一个点都还没取到时不得改写分区:不能用「无数据」或「已过期」去描述"正在取第 1 页"。
  438. // 用一个全新的 detailStore 验(上面那个已经载过成功值,断言会看不出"有没有被改写")。
  439. {
  440. freshMapStore.reset()
  441. const freshDetailStore = useDetailStore()
  442. freshDetailStore.reset()
  443. const untouched = freshDetailStore.syncMapSnapshotSection()
  444. check(
  445. 'G2b-nothing-loaded-does-not-write-section',
  446. untouched.loaded === false && untouched.state === 'empty',
  447. JSON.stringify({ loaded: untouched.loaded, state: untouched.state }),
  448. )
  449. }
  450. check(
  451. 'G3-section-carries-snapshot',
  452. section.loaded === true && !!section.data && section.data.snapshot === afterFirst.snapshot && section.data.loaded > 0,
  453. JSON.stringify({ snapshot: section.data && section.data.snapshot, loaded: section.data && section.data.loaded }),
  454. )
  455. check('G4-page-size-hidden', MAP_SUMMARY_PAGE_SIZE === 2, `演示页大小 ${MAP_SUMMARY_PAGE_SIZE}`)
  456. // 刷新换新快照:装载态清空后重新从第 1 页开始,快照标记不变(后端当前快照没变)。
  457. const refreshed = await mapStore.refresh()
  458. check(
  459. 'G5-refresh-restarts-at-page-1',
  460. refreshed.page === 1 && refreshed.loaded === MAP_SUMMARY_PAGE_SIZE && refreshed.snapshot === afterFirst.snapshot,
  461. JSON.stringify({ page: refreshed.page, loaded: refreshed.loaded }),
  462. )
  463. // 快照过期场景:取完第 1 页后后端换快照,第 2 页即失效,且已加载的点保留。
  464. mapStore.reset()
  465. resetMockTransportsForTest()
  466. registerCockpitMocks({
  467. scenario: 'stale',
  468. mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE, expireAfterPage: 1 },
  469. handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
  470. })
  471. mapStore.setup({ config: mockConfig })
  472. await mapStore.advance()
  473. const expired = await mapStore.advance()
  474. check(
  475. 'G6-expired-keeps-loaded',
  476. expired.phase === MAP_SUMMARY_PHASE.EXPIRED && expired.loaded === MAP_SUMMARY_PAGE_SIZE && mapStore.view.label.includes('失效'),
  477. JSON.stringify({ phase: expired.phase, loaded: expired.loaded, label: mapStore.view.label }),
  478. )
  479. }
  480. // ================================================================ H 静态守卫:分页参数不上界面
  481. {
  482. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  483. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  484. // 票 08 验收第 2 条:分页参数和快照参数由适配层管理。
  485. // 界面(组件)不得自己拼 `page`/`pageSize`/`snapshotAt` —— 它们只出现在
  486. // `stores/mapStore.js`(把装载器的查询参数接到领域接口的唯一一处)与其下的适配层。
  487. const uiFiles = [
  488. 'CockpitShell.vue',
  489. 'components/MapCanvas.vue',
  490. 'components/CockpitPanel.vue',
  491. 'components/PanelPlaceholder.vue',
  492. 'components/StatusTag.vue',
  493. 'components/CockpitIcon.vue',
  494. 'components/SectionStateTag.vue',
  495. 'components/CockpitIntro.vue',
  496. ]
  497. const leaked = []
  498. for (const file of uiFiles) {
  499. const text = read(file)
  500. // 只看真正的接口参数名(`pageSize`/`snapshotAt`),不误伤 `page` 这类普通词。
  501. if (/pageSize|snapshotAt/.test(text)) leaked.push(file)
  502. }
  503. check('H1-pagination-params-not-in-ui', leaked.length === 0, leaked.length ? leaked.join(', ') : '界面文件里零分页/快照参数')
  504. const storeText = read('stores/mapStore.js')
  505. check(
  506. 'H2-adapter-owns-the-params',
  507. /page:\s*query\.page/.test(storeText) && /pageSize:\s*query\.pageSize/.test(storeText) && /snapshotAt:\s*query\.snapshotAt/.test(storeText),
  508. '分页与快照参数只在 mapStore 的适配翻译处拼',
  509. )
  510. check(
  511. 'H3-shell-says-what-not-how',
  512. !/fetchMapSnapshot|loadNext\(/.test(read('CockpitShell.vue')),
  513. '外壳只说「再取一批/刷新」,不直接翻页',
  514. )
  515. }
  516. const failed = checks.filter((item) => !item.pass)
  517. console.log('')
  518. console.log(`T08 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  519. if (failed.length) {
  520. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  521. process.exit(1)
  522. }
  523. console.log('T08 逻辑检查全部通过。')