| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- /**
- * T08 地图摘要分页与快照 纯逻辑检查(票 08)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary.mjs
- *
- * 分四段:
- * A 批次合并(跨页拼接、按 kind+code 去重、完成/总数/快照标记)
- * B 演示态词(装载中 / 完成 / 失败 / 快照过期 / 空,都要有中文文案)
- * C 装载器状态机(同一批加载保持同一快照、刷新换新快照、失败后重试不跳页)
- * D 适配层分页参数(page / pageSize / snapshotAt 真的出现在查询串上)
- *
- * 契约依据是**后端实现**而不是演示页的乐观假设:
- * `CockpitMapSnapshotService.validatePagination` —— 第 2 页起必须带 `snapshotAt`,
- * 且带了就必须与后端当前快照一致,否则抛 `SNAPSHOT_EXPIRED`。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import {
- MAP_SUMMARY_LIMITS,
- MAP_SUMMARY_PHASE,
- createMapSummaryLoader,
- mergeMapPage,
- summaryView,
- } from '../../../src/modules/cockpit/domain/mapSummary.js'
- import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
- import {
- getMockTransportPaths,
- registerMockTransport,
- requestCockpitJson,
- resetMockTransportsForTest,
- } from '../../../src/modules/cockpit/services/cockpitApi.js'
- import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
- import { MAP_SUMMARY_PAGE_SIZE, useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
- import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
- const checks = []
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
- /** 造一条**后端载荷**里的监测点/设备(字段名照抄 map-snapshot-demo.html)。 */
- const rawPoint = (code) => ({
- pointCode: code,
- pointName: `${code} 点位`,
- pointStatus: 'NORMAL',
- statusReasons: [],
- longitude: 110.141,
- latitude: 28.452,
- coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
- })
- /** 造一页后端载荷。 */
- function page({ page: pageNo = 1, pageSize = 2, snapshotAt = '2026-09-02T10:00:00+08:00', total = 4, complete = false, points = [], equipment = [] } = {}) {
- return { page: pageNo, pageSize, snapshotAt, batchUpdatedAt: snapshotAt, total, complete, monitoringPoints: points, unassociatedEquipment: equipment }
- }
- const point = (code, lon = 110.1, lat = 28.4) => ({
- kind: 'monitoring-point',
- code,
- name: `${code} 点位`,
- status: 'NORMAL',
- statusReasons: [],
- longitude: lon,
- latitude: lat,
- coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
- missing: [],
- })
- const device = (code, lon = 110.2, lat = 28.5) => ({
- kind: 'unassociated-equipment',
- code,
- name: `${code} 设备`,
- status: '',
- statusReasons: [],
- longitude: lon,
- latitude: lat,
- coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
- missing: [],
- })
- // ================================================================ A 批次合并
- {
- const first = mergeMapPage(null, {
- page: 1,
- pageSize: 2,
- snapshotAt: '2026-09-02T10:00:00+08:00',
- total: 4,
- complete: false,
- objects: [point('MP-001'), device('EQ-001')],
- })
- check(
- 'A1-first-page',
- first.page === 1 && first.snapshot === '2026-09-02T10:00:00+08:00' && first.total === 4 && first.complete === false && first.loaded === 2,
- JSON.stringify({ page: first.page, loaded: first.loaded }),
- )
- // 后端的分页窗口是「监测点 + 未关联设备」这一条总序列,跨页会横跨两类。
- const second = mergeMapPage(first, {
- page: 2,
- pageSize: 2,
- snapshotAt: '2026-09-02T10:00:00+08:00',
- total: 4,
- complete: true,
- objects: [point('MP-002'), device('EQ-002')],
- })
- check(
- 'A2-second-page-keeps-snapshot',
- second.page === 2 && second.snapshot === first.snapshot && second.complete === true && second.loaded === 4,
- `snapshot ${second.snapshot} / loaded ${second.loaded}`,
- )
- check(
- 'A2-both-kinds-on-map',
- second.objects.filter((item) => item.kind === 'monitoring-point').length === 2 &&
- second.objects.filter((item) => item.kind === 'unassociated-equipment').length === 2,
- JSON.stringify(second.objects.map((item) => item.kind)),
- )
- // 后端分页是「快照内的切片」:同一对象重复出现(重放/重试)不得在地图上变两遍。
- const replayed = mergeMapPage(second, {
- page: 2,
- snapshotAt: '2026-09-02T10:00:00+08:00',
- total: 4,
- complete: true,
- objects: [point('MP-002'), device('EQ-002')],
- })
- check('A3-dedupe-by-kind-and-code', replayed.loaded === 4 && replayed.objects.length === 4, `loaded ${replayed.loaded}`)
- // 不同 kind 的同名编码是不同对象(监测点 MP-1 与设备 MP-1 都要在图上)。
- const crossKind = mergeMapPage(null, { page: 1, objects: [point('SAME-1'), device('SAME-1')] })
- check('A3-cross-kind-not-merged', crossKind.loaded === 2 && crossKind.objects.length === 2, `objects ${crossKind.objects.length}`)
- // 同一 kind 下没有编码的脏对象不得被当成「同一个」丢掉。
- const noCode = mergeMapPage(null, { page: 1, objects: [{ kind: 'monitoring-point' }, { kind: 'monitoring-point' }] })
- check('A3-codeless-not-dropped', noCode.loaded === 2, `loaded ${noCode.loaded}`)
- // 刷新换快照 = 从空的合并态重来(旧快照的点不得留在地图上)。
- const refreshed = mergeMapPage(null, {
- page: 1,
- snapshotAt: '2026-09-02T10:05:00+08:00',
- total: 4,
- complete: false,
- objects: [point('MP-009')],
- })
- check(
- 'A4-refresh-drops-old-snapshot',
- refreshed.snapshot === '2026-09-02T10:05:00+08:00' && refreshed.loaded === 1 && refreshed.objects[0].code === 'MP-009',
- JSON.stringify(refreshed.objects.map((item) => item.code)),
- )
- // 长跑护栏:只保留 LIMITS 内的批次与对象(地图不是无限内存)。
- let long = null
- for (let index = 0; index < MAP_SUMMARY_LIMITS.maxBatches + 3; index += 1) {
- long = mergeMapPage(long, { page: index + 1, objects: [point(`MP-${index}`)], total: 999 })
- }
- check(
- 'A5-long-run-bounded',
- long.batches.length === MAP_SUMMARY_LIMITS.maxBatches && long.objects.length <= MAP_SUMMARY_LIMITS.maxObjects && long.page === MAP_SUMMARY_LIMITS.maxBatches + 3,
- `batches ${long.batches.length} objects ${long.objects.length} page ${long.page}`,
- )
- }
- // ================================================================ B 演示态词
- {
- const view = (phase, extra = {}) => summaryView({ phase, ...extra })
- check('B1-idle', view(MAP_SUMMARY_PHASE.IDLE).label.includes('框架已就绪'), view(MAP_SUMMARY_PHASE.IDLE).label)
- 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)
- check('B3-complete', view(MAP_SUMMARY_PHASE.COMPLETE, { loaded: 7 }).label.includes('加载完成'), view(MAP_SUMMARY_PHASE.COMPLETE, { loaded: 7 }).label)
- check('B3-empty', view(MAP_SUMMARY_PHASE.EMPTY).label.includes('没有可上图对象'), view(MAP_SUMMARY_PHASE.EMPTY).label)
- check('B4-expired', view(MAP_SUMMARY_PHASE.EXPIRED).label.includes('已失效') && view(MAP_SUMMARY_PHASE.EXPIRED).tone === 'lv1', JSON.stringify(view(MAP_SUMMARY_PHASE.EXPIRED)))
- check('B4-failed', view(MAP_SUMMARY_PHASE.FAILED).tone === 'lv1' && view(MAP_SUMMARY_PHASE.FAILED).label.includes('失败'), JSON.stringify(view(MAP_SUMMARY_PHASE.FAILED)))
- // 对抗式:内部原因码不得出现在给人读的文案里(票 05 的同一条纪律)。
- const leaked = []
- for (const phase of Object.values(MAP_SUMMARY_PHASE)) {
- const text = JSON.stringify(summaryView({ phase, reason: 'snapshot-expired' }))
- if (text.includes('snapshot-expired')) leaked.push(phase)
- }
- check('B5-no-reason-code-in-ui', leaked.length === 0, leaked.length ? leaked.join(', ') : '五态都有中文文案')
- }
- // ================================================================ C 装载器状态机
- {
- /** 一段可控的分页后端:记录每次请求的快照入参,第 2 页起要求与当前快照一致。 */
- function fakeBackend({ total = 4, pageSize = 2, expireAtPage = 0, failAtPage = 0 } = {}) {
- let snapshotAt = '2026-09-02T10:00:00+08:00'
- const requests = []
- let sequence = 0
- const objects = Array.from({ length: total }, (_, index) =>
- index < 2 ? point(`MP-00${index + 1}`) : device(`EQ-00${index + 1}`),
- )
- return {
- requests,
- setSnapshot: (value) => {
- snapshotAt = value
- },
- async fetchPage({ page: pageNo, snapshotAt: requested }) {
- requests.push({ page: pageNo, snapshotAt: requested })
- if (failAtPage && pageNo === failAtPage) return { ok: false, reason: 'unavailable' }
- if (pageNo > 1 && !requested) return { ok: false, reason: 'bad-payload' }
- if (pageNo > 1 && requested !== snapshotAt) return { ok: false, reason: 'snapshot-expired' }
- if (expireAtPage && pageNo === expireAtPage) snapshotAt = '2026-09-02T10:09:00+08:00'
- const from = (pageNo - 1) * pageSize
- const slice = objects.slice(from, from + pageSize)
- return {
- ok: true,
- mode: 'mock',
- data: {
- snapshotAt,
- total,
- complete: from + pageSize >= total,
- objects: slice,
- },
- }
- },
- }
- }
- // 第 1 页(后端默认页大小 500)与第 2 页用的是**同一个**快照标记。
- {
- const backend = fakeBackend()
- const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
- await loader.loadNext()
- const afterFirst = loader.getState()
- // 还有下一页时停在「等下一页」而不是「完成」——渐进加载不是加载到一半就算完。
- check('C2-partial-is-not-complete', afterFirst.phase === MAP_SUMMARY_PHASE.IDLE && afterFirst.loaded === 2, afterFirst.phase)
- await loader.loadNext()
- const afterSecond = loader.getState()
- check(
- 'C1-locked-snapshot',
- backend.requests.length === 2 &&
- backend.requests[0].snapshotAt === undefined &&
- backend.requests[1].snapshotAt === afterFirst.snapshot &&
- afterSecond.snapshot === afterFirst.snapshot,
- JSON.stringify(backend.requests),
- )
- check(
- 'C2-progressive',
- afterSecond.loaded === 4 && afterSecond.phase === MAP_SUMMARY_PHASE.COMPLETE,
- `${afterFirst.phase}(${afterFirst.loaded}) → ${afterSecond.phase}(${afterSecond.loaded})`,
- )
- }
- // 完成后再点:不再发请求,也不换快照。
- {
- const backend = fakeBackend()
- const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
- await loader.loadNext()
- await loader.loadNext()
- const callsAfterComplete = backend.requests.length
- await loader.loadNext()
- const state = loader.getState()
- check(
- 'C3-complete-is-terminal',
- backend.requests.length === callsAfterComplete && state.phase === MAP_SUMMARY_PHASE.COMPLETE,
- `requests ${backend.requests.length}`,
- )
- }
- // 刷新:换新快照,已加载对象从零开始(旧快照的点不再留在图上)。
- {
- const backend = fakeBackend()
- const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
- await loader.loadNext()
- const oldSnapshot = loader.getState().snapshot
- backend.setSnapshot('2026-09-02T10:05:00+08:00')
- await loader.refresh()
- const state = loader.getState()
- check(
- 'C4-refresh-new-snapshot',
- state.snapshot === '2026-09-02T10:05:00+08:00' &&
- state.snapshot !== oldSnapshot &&
- state.page === 1 &&
- state.loaded === 2 &&
- backend.requests[backend.requests.length - 1].snapshotAt === undefined,
- JSON.stringify({ snapshot: state.snapshot, page: state.page, lastRequest: backend.requests[backend.requests.length - 1] }),
- )
- }
- // 同一批加载中途快照被后端换掉:如实报「快照过期」,绝不把两个快照的对象拼在一起。
- {
- const backend = fakeBackend({ expireAtPage: 2 })
- const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
- await loader.loadNext()
- const afterFirst = loader.getState()
- await loader.loadNext()
- const state = loader.getState()
- check(
- 'C5-expired-not-merged',
- state.phase === MAP_SUMMARY_PHASE.EXPIRED && state.reason === 'snapshot-expired' && state.loaded === afterFirst.loaded && state.objects.length === 2,
- JSON.stringify({ phase: state.phase, loaded: state.loaded }),
- )
- }
- // 失败:保留已加载的点,下一次点继续从**同一页**重试(不静默跳页丢数据)。
- {
- const backend = fakeBackend({ failAtPage: 2 })
- const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
- await loader.loadNext()
- await loader.loadNext()
- const failed = loader.getState()
- const retry = await loader.loadNext()
- check(
- 'C6-failed-then-retry-same-page',
- failed.phase === MAP_SUMMARY_PHASE.FAILED &&
- failed.loaded === 2 &&
- failed.page === 1 &&
- retry.phase === MAP_SUMMARY_PHASE.FAILED &&
- retry.nextPage === 2 &&
- backend.requests[backend.requests.length - 1].page === 2,
- JSON.stringify({ failedPhase: failed.phase, page: failed.page, retriedPage: backend.requests[backend.requests.length - 1].page }),
- )
- }
- // 空快照:不是失败,也不是「加载完成」的假象。
- {
- const loader = createMapSummaryLoader({
- fetchPage: async () => ({ ok: true, mode: 'mock', data: { snapshotAt: '2026-09-02T10:00:00+08:00', total: 0, complete: true, objects: [] } }),
- })
- await loader.loadNext()
- const state = loader.getState()
- check('C7-empty-phase', state.phase === MAP_SUMMARY_PHASE.EMPTY && state.loaded === 0, JSON.stringify(state.phase))
- }
- // 首屏框架与取数彼此独立:还没取数时装载器就说得出「框架已就绪,等待分页数据」。
- {
- const loader = createMapSummaryLoader({ fetchPage: async () => ({ ok: true, mode: 'mock', data: {} }) })
- check('C8-frame-before-data', loader.getState().phase === MAP_SUMMARY_PHASE.IDLE && summaryView(loader.getState()).label.includes('等待'), loader.getState().phase)
- }
- // 分页参数由调用方给(票面:「分页参数和快照参数由适配层管理」)。
- {
- const seen = []
- const loader = createMapSummaryLoader({
- pageSize: 250,
- fetchPage: async (options) => {
- seen.push(options)
- return { ok: true, mode: 'mock', data: { snapshotAt: 's1', total: 1, complete: true, objects: [point('MP-001')] } }
- },
- })
- await loader.loadNext()
- check('C9-pagination-params', seen[0].page === 1 && seen[0].pageSize === 250, JSON.stringify(seen[0]))
- }
- }
- // ================================================================ D 适配层分页参数
- {
- const seen = []
- resetMockTransportsForTest()
- registerMockTransport('/map-snapshot', async (context) => {
- seen.push(context.path)
- return page({ points: [rawPoint('MP-001')], complete: true, total: 1 })
- })
- const first = await fetchMapSnapshot({ page: 1, pageSize: 2, config: mockConfig })
- const second = await fetchMapSnapshot({ page: 2, pageSize: 2, snapshotAt: '2026-09-02T10:00:00+08:00', config: mockConfig })
- check('D1-query-carries-page-and-size', seen[0] === '/map-snapshot?page=1&pageSize=2', seen[0])
- check(
- 'D2-query-carries-snapshot',
- seen[1] === '/map-snapshot?page=2&pageSize=2&snapshotAt=2026-09-02T10%3A00%3A00%2B08%3A00',
- seen[1],
- )
- check(
- 'D3-domain-batch',
- first.state === 'success' && first.data.page === 1 && first.data.objects.length === 1 && second.data.complete === true,
- JSON.stringify({ state: first.state, objects: first.data.objects.length }),
- )
- // 后端「快照已失效」是 400 + `CockpitErrorCategory.SNAPSHOT_EXPIRED`;适配层必须把它
- // 当成可演示的独立原因交给界面,而不是笼统的「加载失败」。
- const errorBody = { code: 400, msg: '地图快照已失效,请重新加载', data: { category: 'SNAPSHOT_EXPIRED', message: '地图快照已失效,请重新加载' } }
- const expired = await requestCockpitJson('/map-snapshot?page=2&snapshotAt=old', {
- config: { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } },
- fetchImpl: async () => ({ ok: false, status: 400, json: async () => errorBody, text: async () => JSON.stringify(errorBody) }),
- })
- const invalid = await requestCockpitJson('/map-snapshot?page=0', {
- config: { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } },
- fetchImpl: async () => ({
- ok: false,
- status: 400,
- json: async () => ({ code: 400, msg: '地图快照分页参数不合法', data: { category: 'INVALID_REQUEST' } }),
- }),
- })
- check('D4-snapshot-expired-reason', expired.ok === false && expired.reason === 'snapshot-expired', JSON.stringify(expired))
- check('D4-invalid-request-stays-generic', invalid.reason === 'unavailable', JSON.stringify(invalid))
- }
- // ================================================================ E 装载器抛错不发散
- {
- const loader = createMapSummaryLoader({
- fetchPage: async () => {
- throw new Error('boom')
- },
- })
- const state = await loader.loadNext()
- check('E1-throw-becomes-failed', state.phase === MAP_SUMMARY_PHASE.FAILED && state.reason === 'unreachable', JSON.stringify(state.phase))
- }
- // ================================================================ F 契约化 mock 的真实分页
- {
- // mock 也要按后端语义分页:分页窗口横跨两类对象、第 2 页起不带快照即失效。
- resetMockTransportsForTest()
- registerCockpitMocks({
- scenario: 'success',
- mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
- handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
- })
- const first = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
- const second = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig })
- const third = await fetchMapSnapshot({ page: 3, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig })
- const noSnapshot = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
- check(
- 'F1-mock-pages-are-real-slices',
- first.data.objects.length === MAP_SUMMARY_PAGE_SIZE &&
- second.data.objects.length === MAP_SUMMARY_PAGE_SIZE &&
- third.data.complete === true &&
- first.data.snapshotAt === second.data.snapshotAt,
- JSON.stringify({ total: first.data.total, thirdComplete: third.data.complete }),
- )
- check(
- 'F2-mock-spans-both-kinds',
- new Set([...first.data.objects, ...second.data.objects, ...third.data.objects].map((item) => item.kind)).size === 2,
- JSON.stringify([...first.data.objects, ...second.data.objects, ...third.data.objects].map((item) => `${item.kind}:${item.code}`)),
- )
- // 分区块状结果里没有 `ok` 字段(那是装载器的词汇):失效在票 05 的词汇里是 `failed` + 原因。
- check(
- 'F3-mock-page2-without-snapshot-expires',
- noSnapshot.state === 'failed' && noSnapshot.reason === 'snapshot-expired',
- JSON.stringify({ state: noSnapshot.state, reason: noSnapshot.reason }),
- )
- }
- // ================================================================ G 装载器 → store → 分区状态
- {
- setActivePinia(createPinia())
- resetMockTransportsForTest()
- registerCockpitMocks({
- scenario: 'success',
- mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
- handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
- })
- const mapStore = useMapStore()
- const detailStore = useDetailStore()
- const freshMapStore = mapStore
- mapStore.setup({ config: mockConfig })
- const before = mapStore.summary
- await mapStore.advance()
- const afterFirst = mapStore.summary
- const partialSection = detailStore.syncMapSnapshotSection()
- await detailStore.loadSharedSections({ config: mockConfig })
- const section = detailStore.sections.mapSnapshot
- check(
- 'G1-store-progressive',
- before.loaded === 0 && before.phase === MAP_SUMMARY_PHASE.IDLE && afterFirst.loaded === MAP_SUMMARY_PAGE_SIZE && afterFirst.page === 1,
- JSON.stringify({ before: before.phase, after: `${afterFirst.phase}/${afterFirst.loaded}` }),
- )
- // 还没翻完 ≠ 过期:取到的一部分是**新鲜**数据,分区就是成功(把"没翻完"说成"已过期"
- // 是对值班人员撒谎,也会与地图上的真实进度互相矛盾)。
- check(
- 'G2-partial-is-success-not-stale',
- partialSection.state === 'success' && partialSection.retained === false && partialSection.data.loaded === MAP_SUMMARY_PAGE_SIZE,
- JSON.stringify({ state: partialSection.state, retained: partialSection.retained }),
- )
- // 一个点都还没取到时不得改写分区:不能用「无数据」或「已过期」去描述"正在取第 1 页"。
- // 用一个全新的 detailStore 验(上面那个已经载过成功值,断言会看不出"有没有被改写")。
- {
- freshMapStore.reset()
- const freshDetailStore = useDetailStore()
- freshDetailStore.reset()
- const untouched = freshDetailStore.syncMapSnapshotSection()
- check(
- 'G2b-nothing-loaded-does-not-write-section',
- untouched.loaded === false && untouched.state === 'empty',
- JSON.stringify({ loaded: untouched.loaded, state: untouched.state }),
- )
- }
- check(
- 'G3-section-carries-snapshot',
- section.loaded === true && !!section.data && section.data.snapshot === afterFirst.snapshot && section.data.loaded > 0,
- JSON.stringify({ snapshot: section.data && section.data.snapshot, loaded: section.data && section.data.loaded }),
- )
- check('G4-page-size-hidden', MAP_SUMMARY_PAGE_SIZE === 2, `演示页大小 ${MAP_SUMMARY_PAGE_SIZE}`)
- // 刷新换新快照:装载态清空后重新从第 1 页开始,快照标记不变(后端当前快照没变)。
- const refreshed = await mapStore.refresh()
- check(
- 'G5-refresh-restarts-at-page-1',
- refreshed.page === 1 && refreshed.loaded === MAP_SUMMARY_PAGE_SIZE && refreshed.snapshot === afterFirst.snapshot,
- JSON.stringify({ page: refreshed.page, loaded: refreshed.loaded }),
- )
- // 快照过期场景:取完第 1 页后后端换快照,第 2 页即失效,且已加载的点保留。
- mapStore.reset()
- resetMockTransportsForTest()
- registerCockpitMocks({
- scenario: 'stale',
- mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE, expireAfterPage: 1 },
- handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
- })
- mapStore.setup({ config: mockConfig })
- await mapStore.advance()
- const expired = await mapStore.advance()
- check(
- 'G6-expired-keeps-loaded',
- expired.phase === MAP_SUMMARY_PHASE.EXPIRED && expired.loaded === MAP_SUMMARY_PAGE_SIZE && mapStore.view.label.includes('失效'),
- JSON.stringify({ phase: expired.phase, loaded: expired.loaded, label: mapStore.view.label }),
- )
- }
- // ================================================================ H 静态守卫:分页参数不上界面
- {
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- // 票 08 验收第 2 条:分页参数和快照参数由适配层管理。
- // 界面(组件)不得自己拼 `page`/`pageSize`/`snapshotAt` —— 它们只出现在
- // `stores/mapStore.js`(把装载器的查询参数接到领域接口的唯一一处)与其下的适配层。
- const uiFiles = [
- 'CockpitShell.vue',
- 'components/MapCanvas.vue',
- 'components/CockpitPanel.vue',
- 'components/PanelPlaceholder.vue',
- 'components/StatusTag.vue',
- 'components/CockpitIcon.vue',
- 'components/SectionStateTag.vue',
- 'components/CockpitIntro.vue',
- ]
- const leaked = []
- for (const file of uiFiles) {
- const text = read(file)
- // 只看真正的接口参数名(`pageSize`/`snapshotAt`),不误伤 `page` 这类普通词。
- if (/pageSize|snapshotAt/.test(text)) leaked.push(file)
- }
- check('H1-pagination-params-not-in-ui', leaked.length === 0, leaked.length ? leaked.join(', ') : '界面文件里零分页/快照参数')
- const storeText = read('stores/mapStore.js')
- check(
- 'H2-adapter-owns-the-params',
- /page:\s*query\.page/.test(storeText) && /pageSize:\s*query\.pageSize/.test(storeText) && /snapshotAt:\s*query\.snapshotAt/.test(storeText),
- '分页与快照参数只在 mapStore 的适配翻译处拼',
- )
- check(
- 'H3-shell-says-what-not-how',
- !/fetchMapSnapshot|loadNext\(/.test(read('CockpitShell.vue')),
- '外壳只说「再取一批/刷新」,不直接翻页',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T08 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
- console.log('T08 逻辑检查全部通过。')
|