/** * 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 逻辑检查全部通过。')