/** T42 刷新生命周期门禁(票 42 重开;规格 14:86 核对链)。 */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js' import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js' import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js' import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js' import { useDeviceLayerStore } from '../../../src/modules/cockpit/stores/deviceLayerStore.js' const root = new URL('../../../src/modules/cockpit/', import.meta.url) const read = (rel) => readFileSync(new URL(rel, root), 'utf8') const checks = [] let failed = 0 async function check(name, action) { checks.length += 1 try { await action(); console.log(`PASS ${name}`) } catch (error) { failed += 1; console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`) } } setActivePinia(createPinia()) const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } } /** real 模式:让注入的 fetchImpl 真正接到请求(mock 模式走 mock 传输,注入无效)。 */ const realConfig = { dataSource: 'real', api: { baseUrl: 'http://acceptance' } } const point = (code, extra = {}) => ({ pointCode: code, pointName: `点 ${code}`, pointStatus: 'NORMAL', deviceCodes: [], longitude: 110.4, latitude: 28.44, coordinateValidation: { state: 'INVALID', reason: 'OUTSIDE_PRIMARY_AREA', renderable: false, crs: 'WGS84' }, ...extra }) const pageBody = (points, { complete = true, total = points.length } = {}) => ({ snapshotAt: 'S', batchUpdatedAt: 'S', page: 1, pageSize: 1, total, complete, monitoringPoints: points, unassociatedEquipment: [] }) const ok = (body) => Promise.resolve({ ok: true, status: 200, json: async () => body }) const expired = () => Promise.resolve({ ok: false, status: 400, json: async () => ({ code: 500, msg: 'fail', data: { category: 'SNAPSHOT_EXPIRED' } }) }) /** 造一个分页 fetch:pages[i] 为第 i+1 页载荷;delayPage 挂起并回 .resolve(page) 供测试放行。 */ function pagingFetch(pages, { delayPage = -1 } = {}) { const pending = new Map() const fetch = (url) => { const page = Number(new URL(url, 'http://acceptance').searchParams.get('page') || 1) const body = pages[page - 1] if (!body) return expired() if (page === delayPage) return new Promise((resolve) => { pending.set(page, () => resolve(ok(body))) }) return ok(body) } fetch.resolve = (page) => { const release = pending.get(page); if (release) { pending.delete(page); release() } } return fetch } // ---- A1 refreshing:发起即亮(根因 ① 的撤层触发信号,同步可见) ---- { const store = useMapStore() store.reset() const fetch = pagingFetch([pageBody([point('P-1')])], { delayPage: 1 }) store.setup({ config: realConfig, fetchImpl: fetch }) const pending = store.refresh() check('A1-refreshing-true-at-initiation', () => assert.equal(store.refreshing, true)) fetch.resolve(1) await pending check('A1-refreshing-false-after-settle', () => assert.equal(store.refreshing, false)) } // ---- A2 完整链收口 + 全页存在性索引(护栏无关) ---- { const store = useMapStore() store.reset() store.setup({ config: realConfig, fetchImpl: pagingFetch([ pageBody([point('P-1')], { complete: false, total: 2 }), pageBody([point('P-2')]), ]) }) await store.refresh() check('A2-phase-complete', () => assert.equal(store.summary.phase, 'complete')) check('A2-refreshing-false-after-settle', () => assert.equal(store.refreshing, false)) check('A2-page1-point-indexed', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-1'), true)) check('A2-page2-point-indexed', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-2'), true)) check('A2-unknown-absent', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-X'), false)) } // ---- A3 快照失效:expired 相位(batchUpdatedAt 不变,外壳单独触发撤层) ---- { const store = useMapStore() store.reset() store.setup({ config: realConfig, fetchImpl: pagingFetch([pageBody([point('P-1')], { complete: false, total: 2 })]) }) await store.refresh() check('A3-expired-phase', () => assert.equal(store.summary.phase, 'expired')) check('A3-refreshing-false-after-expired', () => assert.equal(store.refreshing, false)) check('A3-index-keeps-fetched-pages', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-1'), true)) } // ---- A4 换快照重置存在性索引 ---- { const store = useMapStore() store.reset() store.setup({ config: realConfig, fetchImpl: pagingFetch([pageBody([point('P-1')], { complete: false, total: 2 }), pageBody([point('P-2')])]) }) await store.refresh() check('A4-old-snapshot-indexed', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-2'), true)) store.setup({ config: realConfig, fetchImpl: pagingFetch([pageBody([point('P-1')])]) }) await store.refresh() check('A4-index-reset-on-new-snapshot', () => assert.equal(store.hasSnapshotObject('monitoring-point', 'P-2'), false)) } // ---- A5 reloadPointDetail:强制重取(不查缓存)且保留选择层级 ---- { resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) const detail = useDetailStore() detail.reset() detail.configure({ config: mockConfig }) let hits = 0 registerMockTransport('/monitoring-points/MP-COUNT', () => { hits += 1; return { monitoringPointCode: 'MP-COUNT', monitoringPointName: '计数点', pointStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], deviceStatistics: { totalDeviceCount: 0 }, devices: [], coordinateValidation: { state: 'VALID', reason: null, renderable: false, crs: 'WGS84' } } }) await detail.openPoint('MP-COUNT') await detail.openPoint('MP-COUNT') check('A5-openPoint-uses-cache', () => assert.equal(hits, 1)) await detail.selectDevice('TC-PS-WS-202609020012').catch(() => {}) const levelBefore = detail.selection.level await detail.reloadPointDetail('MP-COUNT') check('A5-reload-forces-refetch', () => assert.equal(hits, 2)) check('A5-reload-keeps-selection-level', () => assert.equal(detail.selection.level, levelBefore)) check('A5-reload-refreshes-section', () => assert.equal(detail.sections.pointDetail.data.code, 'MP-COUNT')) } // ---- A6 suspendForSnapshot:撤下且不按旧摘要重算 ---- { const device = useDeviceLayerStore() device.reset() device.rows = [{ code: 'EQ-1', pointCode: 'MP-1', placement: 'drawn', status: 'NORMAL' }] device.total = 5 device.suspendForSnapshot() check('A6-suspend-withdraws-rows', () => assert.equal(device.rows.length, 0)) check('A6-suspend-clears-total', () => assert.equal(device.total, 0)) check('A6-suspend-paints-false', () => assert.equal(device.painted, false)) const source = read('stores/deviceLayerStore.js') const body = source.match(/function suspendForSnapshot\(\)[^]*?\n \}/)[0] check('A6-suspend-does-not-replan', () => assert.ok(!body.includes('syncForSelection'), '挂起不得立即重算')) } // ---- B 外壳静态守卫:三个监听 + 核对函数 + 可观察 chip ---- { const shell = read('CockpitShell.vue') check('B1-refreshing-watcher-suspends', () => assert.match(shell, /watch\(\(\) => mapStore\.refreshing, \(on\) => \{[^]*?deviceLayerStore\.suspendForSnapshot\(\)/)) check('B2-expired-watcher-suspends', () => assert.match(shell, /watch\(\(\) => mapStore\.summary\.phase[\s\S]{0,120}MAP_SUMMARY_PHASE\.EXPIRED[\s\S]{0,160}suspendForSnapshot\(\)/)) check('B3-settled-watcher-reconciles', () => assert.match(shell, /watch\(\(\) => mapStore\.settled[\s\S]{0,120}reconcileSnapshotSelection\(\)/)) const reconcile = shell.match(/function reconcileSnapshotSelection\(\)[^]*?\n\}/)[0] check('B4-reconcile-found-reloads', () => assert.match(reconcile, /hasSnapshotObject\('monitoring-point', pointCode\)/) && assert.match(reconcile, /reloadPointDetail\(pointCode\)/)) check('B5-reconcile-absent-closes', () => assert.match(reconcile, /snapshotAbsentNote\.value =/) && assert.match(reconcile, /closeDetail\(\)/)) check('B6-reconcile-failure-keeps-pending', () => assert.match(reconcile, /MAP_SUMMARY_PHASE\.FAILED \|\| phase === MAP_SUMMARY_PHASE\.EXPIRED/)) check('B7-pending-chip', () => assert.match(shell, /data-snapshot-pending/)) check('B8-absent-chip', () => assert.match(shell, /data-snapshot-absent/)) check('B9-store-exports', () => { assert.match(read('stores/mapStore.js'), /refreshing, filters, hasSnapshotObject/) assert.match(read('stores/detailStore.js'), /reloadPointDetail,/) assert.match(read('stores/deviceLayerStore.js'), /suspendForSnapshot,/) }) } console.log(`T42 refresh lifecycle: ${checks.length - failed}/${checks.length}`) process.exitCode = failed ? 1 : 0