|
@@ -0,0 +1,219 @@
|
|
|
|
|
+import assert from 'node:assert/strict'
|
|
|
|
|
+import test from 'node:test'
|
|
|
|
|
+import { createApp, h, nextTick } from 'vue'
|
|
|
|
|
+import { createPinia, setActivePinia } from 'pinia'
|
|
|
|
|
+import CockpitRoot from '../../src/pages/cockpit/index.vue'
|
|
|
|
|
+import PipeEnhancement from '../../src/modules/cockpit/components/PipeEnhancement.vue'
|
|
|
|
|
+import VideoMonitorPanel from '../../src/modules/cockpit/components/VideoMonitorPanel.vue'
|
|
|
|
|
+import { useEnhancementDevicesStore } from '../../src/modules/cockpit/stores/enhancementDevicesStore.js'
|
|
|
|
|
+import { loadCockpitConfig, resetCockpitConfigForTest } from '../../src/modules/cockpit/services/cockpitConfig.js'
|
|
|
|
|
+import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js'
|
|
|
|
|
+
|
|
|
|
|
+const settle = async () => { for (let i = 0; i < 8; i++) { await new Promise(setImmediate); await nextTick() } }
|
|
|
|
|
+const envelope = (data, status = 200) => Response.json({ code: 200, data }, { status })
|
|
|
|
|
+const cameraCode = 'CAM/03 空格'
|
|
|
|
|
+const coordinate = { state: 'INVALID', renderable: false, reasons: ['MISSING_LONGITUDE'], longitude: null, latitude: null }
|
|
|
|
|
+const point = { pointCode: 'P-FIXED', pointName: '无位置摄像机监测点', pointStatus: 'NORMAL', industry: 'DRAINAGE',
|
|
|
|
|
+ deviceCodes: [cameraCode], longitude: null, latitude: null, coordinateValidation: coordinate }
|
|
|
|
|
+const camera = { deviceCode: cameraCode, deviceName: '无位置现场摄像机', deviceType: '20', deviceTypeName: '视频监控设备',
|
|
|
|
|
+ deviceStatus: 'NORMAL', industry: 'DRAINAGE', longitude: null, latitude: null, coordinateValidation: coordinate }
|
|
|
|
|
+const device = { deviceCode: cameraCode, deviceName: camera.deviceName, typeCode: '20', bound: true, capabilities: { live: true } }
|
|
|
|
|
+const aggregate = { deviceCode: cameraCode, aggregateState: 'READY',
|
|
|
|
|
+ archive: { state: 'SUCCESS', data: camera }, metrics: { state: 'EMPTY', data: null },
|
|
|
|
|
+ warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] }, workOrders: { state: 'EMPTY', data: null },
|
|
|
|
|
+ timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null } }
|
|
|
|
|
+const emptyEndpoints = new Set(['/overview/perception', '/overview/pipe-network', '/overview/warning-study', '/overview/alarm-disposal',
|
|
|
|
|
+ '/monitoring/overview', '/monitoring/situation', '/events/current'])
|
|
|
|
|
+
|
|
|
|
|
+function installGpu() {
|
|
|
|
|
+ const viewers = []
|
|
|
|
|
+ let visible = true, time = 0
|
|
|
|
|
+ const frame = () => { for (const viewer of viewers) for (const callback of [...viewer.frames]) callback() }
|
|
|
|
|
+ const projection = () => visible ? { x: 800, y: 450, visible: true, iconSize: 20 } : null
|
|
|
|
|
+ const gpu = {
|
|
|
|
|
+ createViewer(host) {
|
|
|
|
|
+ const canvas = document.createElement('canvas'); host.appendChild(canvas)
|
|
|
|
|
+ const frames = new Set()
|
|
|
|
|
+ const viewer = { canvas, frames, points: [], devices: [],
|
|
|
|
|
+ camera: { positionWC: { x: 0, y: 0, z: 6371000 }, directionWC: { x: 0, y: 0, z: -1 }, upWC: { x: 0, y: 1, z: 0 } },
|
|
|
|
|
+ scene: { canvas, globe: { ellipsoid: {} }, requestRender() {},
|
|
|
|
|
+ postRender: { addEventListener(callback) { frames.add(callback); return () => frames.delete(callback) } } } }
|
|
|
|
|
+ viewers.push(viewer); return viewer
|
|
|
|
|
+ },
|
|
|
|
|
+ destroyViewer(viewer) { viewer.frames.clear(); viewer.canvas.remove(); viewers.splice(viewers.indexOf(viewer), 1) },
|
|
|
|
|
+ resizeViewer() {}, applyOverlays: () => [], sampleTerrainHeight: async () => 0,
|
|
|
|
|
+ sampleTerrainHeights: async (_viewer, points) => points.map(() => 0),
|
|
|
|
|
+ groundHeightAt: () => 0, markerGroundHeight: () => 0,
|
|
|
|
|
+ createCameraEngine(viewer) { return {
|
|
|
|
|
+ applyView() {}, readView: () => ({ heading: 0, pitch: -45, range: 1000 }),
|
|
|
|
|
+ pickGroundAtCenter: () => ({ lon: 110.4, lat: 28.4, height: 0 }), projectPivot: projection,
|
|
|
|
|
+ inputTarget: () => viewer.canvas, viewport: () => ({ width: 1600, height: 900 }), fovY: () => Math.PI / 3,
|
|
|
|
|
+ now: () => time, requestFrame: (callback) => setTimeout(() => { time += 100; callback(time) }, 0),
|
|
|
|
|
+ } },
|
|
|
|
|
+ createOverlayReadiness: () => ({ state: { ready: true, pending: 0, failed: 0, lastError: '' }, refresh() {}, detach() {} }),
|
|
|
|
|
+ hasFogStage: () => false,
|
|
|
|
|
+ syncPointMarkers(viewer, rows) { viewer.points = rows; return { rendered: rows.length, unchanged: 0, skipped: 0, complete: true } },
|
|
|
|
|
+ syncAssociatedDevices(viewer, rows) { viewer.devices = rows; return { rendered: rows.length, skipped: 0 } },
|
|
|
|
|
+ clearAssociatedDevices(viewer) { viewer.devices = [] }, clearPointMarkers(viewer) { viewer.points = [] },
|
|
|
|
|
+ hasPointMarker: (viewer, kind, code) => viewer.points.some((row) => row.kind === kind && row.code === code),
|
|
|
|
|
+ hasDeviceMarker: (viewer, code) => viewer.devices.some((row) => row.code === code),
|
|
|
|
|
+ projectPointMarker: (viewer, kind, code) => gpu.hasPointMarker(viewer, kind, code) ? projection() : null,
|
|
|
|
|
+ projectDeviceMarker: (viewer, code) => gpu.hasDeviceMarker(viewer, code) ? projection() : null,
|
|
|
|
|
+ projectDeviceMarkers: (viewer) => viewer.devices.map((row) => ({ ...row, ...projection() })),
|
|
|
|
|
+ projectOverlapSymbols: () => [], readPointMarkers: (viewer) => viewer.points,
|
|
|
|
|
+ readAssociatedDevices: (viewer) => viewer.devices, readDeviceEffects: () => [], readOverlapTargets: () => [],
|
|
|
|
|
+ highlightEntities: () => 1, syncPipeLayer: async () => ({ pipes: 0, components: 0, skipped: 0 }), pickPipeObjectAt: () => null,
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of ['applyViewModeLayers', 'applyMotionResolution', 'applyDeviceEffects', 'applyDeviceReactions',
|
|
|
|
|
+ 'clearDeviceLead', 'syncDeviceLead', 'setOverlapPresentation', 'clearOverlapNudge', 'noteFrameInterval', 'tickAdaptiveResolution']) gpu[name] = () => {}
|
|
|
|
|
+ globalThis.__VIDEO_GPU__ = gpu
|
|
|
|
|
+ return { frame, viewers, setVisible(value) { visible = value; frame() } }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function mountRoot({ positioned = false } = {}) {
|
|
|
|
|
+ const gpu = installGpu()
|
|
|
|
|
+ const location = positioned ? { state: 'VALID', renderable: true, reasons: [] } : coordinate
|
|
|
|
|
+ const selectedPoint = { ...point, pointCode: positioned ? 'P-FLOAT' : 'P-FIXED',
|
|
|
|
|
+ longitude: positioned ? 110.4 : null, latitude: positioned ? 28.4 : null, coordinateValidation: location }
|
|
|
|
|
+ const selectedCamera = { ...camera, longitude: selectedPoint.longitude, latitude: selectedPoint.latitude, coordinateValidation: location }
|
|
|
|
|
+ const requests = [], unexpected = [], errors = []
|
|
|
|
|
+ const fetchImpl = async (address, options = {}) => {
|
|
|
|
|
+ const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
|
|
|
|
|
+ const body = options.body ? JSON.parse(options.body) : null
|
|
|
|
|
+ requests.push({ url, path, body, options })
|
|
|
|
|
+ if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
|
|
|
|
|
+ assert.equal(new Headers(options.headers).get('authorization'), null)
|
|
|
|
|
+ if (path === '/map-snapshot') return envelope({ page: 1, pageSize: 100, snapshotAt: '2026-11-11T00:00:00Z',
|
|
|
|
|
+ batchUpdatedAt: '2026-11-11T00:00:00Z', total: 1, complete: true, monitoringPoints: [selectedPoint], unassociatedEquipment: [] })
|
|
|
|
|
+ if (path === '/monitoring/distribution') return envelope({ monitoringPoints: [selectedPoint], unassociatedEquipment: [],
|
|
|
|
|
+ snapshotAt: '2026-11-11T00:00:00Z', pagination: { page: 1, pageSize: 100, total: 1, totalPages: 1, hasMore: false } })
|
|
|
|
|
+ if (path === `/monitoring-points/${selectedPoint.pointCode}`) return envelope({ monitoringPointCode: selectedPoint.pointCode,
|
|
|
|
|
+ monitoringPointName: point.pointName, pointStatus: 'NORMAL', coordinateValidation: location,
|
|
|
|
|
+ deviceStatistics: { totalDeviceCount: 1, normalDeviceCount: 1 }, devices: [selectedCamera] })
|
|
|
|
|
+ if (path.startsWith('/equipments/') && path.endsWith('/detail-aggregate')) return envelope(aggregate)
|
|
|
|
|
+ if (path.startsWith('/equipments/') && path.endsWith('/collection-records')) return envelope({ deviceCode: cameraCode,
|
|
|
|
|
+ state: 'EMPTY', recordView: { columns: [], rows: [] } })
|
|
|
|
|
+ if (path.startsWith('/equipments/') && path.endsWith('/freshness')) return envelope({})
|
|
|
|
|
+ if (emptyEndpoints.has(path)) return envelope({})
|
|
|
|
|
+ if (path === '/video/device') { assert.equal(url.searchParams.get('deviceCode'), cameraCode); return envelope(device) }
|
|
|
|
|
+ if (path === '/video/sessions/prepare') {
|
|
|
|
|
+ assert.deepEqual(body, { deviceCode: cameraCode })
|
|
|
|
|
+ return envelope({ ...device, sessionId: 'session-host', sessionToken: 'owner-host', mode: 'LIVE', status: 'PREPARED' }, 201)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (path === '/video/sessions/session-host/live') {
|
|
|
|
|
+ assert.deepEqual(body, { sessionToken: 'owner-host' })
|
|
|
|
|
+ return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'ACTIVE',
|
|
|
|
|
+ media: { native: true, url: 'https://media.invalid/live.mp4' } })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (path === '/video/sessions/session-host/stop' || path === '/video/sessions/session-host/status') {
|
|
|
|
|
+ assert.deepEqual(body, { sessionToken: 'owner-host' })
|
|
|
|
|
+ return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'STOPPED' })
|
|
|
|
|
+ }
|
|
|
|
|
+ unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
|
|
|
|
|
+ }
|
|
|
|
|
+ globalThis.fetch = fetchImpl
|
|
|
|
|
+ globalThis.__COCKPIT_CONFIG__ = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' },
|
|
|
|
|
+ map: { cesium: { enabled: positioned }, intro: { enabled: false }, fog: { enabled: false }, grade: { enabled: false }, handover: { enabled: false }, effects: { enabled: false } }, runtime: { polling: false }, ws: { url: '' } }
|
|
|
|
|
+ resetCockpitConfigForTest()
|
|
|
|
|
+ const { config } = await loadCockpitConfig()
|
|
|
|
|
+ const pinia = createPinia(); setActivePinia(pinia)
|
|
|
|
|
+ const video = useVideoStore(); video.configure({ config, fetchImpl })
|
|
|
|
|
+ const host = document.createElement('main'); document.body.appendChild(host)
|
|
|
|
|
+ const app = createApp(CockpitRoot); app.use(pinia)
|
|
|
|
|
+ app.config.errorHandler = (error) => { errors.push(error) }
|
|
|
|
|
+ app.mount(host); await settle()
|
|
|
|
|
+ return { host, requests, unexpected, errors, video, gpu, selectedPoint, async dispose() {
|
|
|
|
|
+ app.unmount(); await video.close(); host.remove(); delete globalThis.__COCKPIT_CONFIG__; await settle()
|
|
|
|
|
+ globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
|
|
|
|
|
+ } }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 真root→实际Shell固定态→既有点位设备卡→Latest TAB→监控;不直接调用视频store.open。
|
|
|
|
|
+for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地图位置固定'}:真实台账设备详情打开唯一根监控面板`, async () => {
|
|
|
|
|
+ const page = await mountRoot({ positioned })
|
|
|
|
|
+ try {
|
|
|
|
|
+ const mode = [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === '运行监测')
|
|
|
|
|
+ assert.ok(mode); mode.click(); await settle()
|
|
|
|
|
+ const ledger = page.host.querySelector(`[data-ledger-card="${page.selectedPoint.pointCode}"]`)
|
|
|
|
|
+ assert.ok(ledger, '使用真实监测点台账卡片')
|
|
|
|
|
+ ledger.click(); await settle()
|
|
|
|
|
+ const card = page.host.querySelector('[data-device-card][data-device-code="CAM/03 空格"]')
|
|
|
|
|
+ assert.ok(card); card.click(); await settle(); page.gpu.frame(); await settle()
|
|
|
|
|
+ const detail = page.host.querySelector('.cockpit-shell__detail')
|
|
|
|
|
+ assert.equal(detail.getAttribute('data-anchor-state'), positioned ? 'visible' : 'fixed')
|
|
|
|
|
+ assert.equal(Boolean(detail.querySelector('[data-point-no-location]')), !positioned)
|
|
|
|
|
+ detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
|
|
|
|
|
+ const monitor = detail.querySelector('[data-video-open]')
|
|
|
|
|
+ assert.ok(monitor); assert.equal(monitor.disabled, false); monitor.click(); await settle()
|
|
|
|
|
+ const panels = document.querySelectorAll('[data-video-monitor]')
|
|
|
|
|
+ assert.equal(panels.length, 1)
|
|
|
|
|
+ const dialog = panels[0]
|
|
|
|
|
+ assert.ok(dialog.textContent.includes(cameraCode))
|
|
|
|
|
+ assert.ok(dialog.querySelector('video'), JSON.stringify({ text: dialog.textContent, unexpected: page.unexpected, errors: page.errors.map((error) => error.message) }))
|
|
|
|
|
+ dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
|
|
|
|
|
+ assert.ok(dialog.textContent.includes('正在播放'))
|
|
|
|
|
+ dialog.querySelector('[aria-label="关闭监控"]').click(); await settle()
|
|
|
|
|
+ assert.equal(document.querySelector('[data-video-monitor]'), null)
|
|
|
|
|
+ assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
|
|
|
|
|
+ assert.deepEqual(page.errors, [])
|
|
|
|
|
+ assert.deepEqual(page.unexpected, [])
|
|
|
|
|
+ } finally { await page.dispose() }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 地下管线宿主:真实 PipeEnhancement→其内设备详情→Latest TAB→监控;只替换物理渲染器。
|
|
|
|
|
+async function mountUnderground() {
|
|
|
|
|
+ const requests = [], unexpected = [], errors = []
|
|
|
|
|
+ const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' } }
|
|
|
|
|
+ const fetchImpl = async (address, options = {}) => {
|
|
|
|
|
+ const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
|
|
|
|
|
+ const body = options.body ? JSON.parse(options.body) : null
|
|
|
|
|
+ requests.push({ url, path, body, options })
|
|
|
|
|
+ if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
|
|
|
|
|
+ if (path === '/video/device') return envelope(device)
|
|
|
|
|
+ if (path === '/video/sessions/prepare') return envelope({ ...device, sessionId: 'session-under', sessionToken: 'owner-under', mode: 'LIVE', status: 'PREPARED' }, 201)
|
|
|
|
|
+ if (path === '/video/sessions/session-under/live') return envelope({ sessionId: 'session-under', deviceCode: cameraCode, mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: 'https://media.invalid/under.mp4' } })
|
|
|
|
|
+ if (path === '/video/sessions/session-under/stop' || path === '/video/sessions/session-under/status') return envelope({ sessionId: 'session-under', status: 'STOPPED' })
|
|
|
|
|
+ unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
|
|
|
|
|
+ }
|
|
|
|
|
+ globalThis.fetch = fetchImpl
|
|
|
|
|
+ const pinia = createPinia(); setActivePinia(pinia)
|
|
|
|
|
+ const video = useVideoStore(); video.configure({ config, fetchImpl })
|
|
|
|
|
+ const enhancement = useEnhancementDevicesStore()
|
|
|
|
|
+ await enhancement.open({ code: cameraCode, name: camera.deviceName, placementText: '地下清单' },
|
|
|
|
|
+ { fetchDetail: async () => ({ state: 'success', data: aggregate }) })
|
|
|
|
|
+ const plan = { visible: true, radiusM: 50, disclaimer: '显示示意', connectionNote: '仅供导引',
|
|
|
|
|
+ object: { title: '重点管段 P-1', code: 'PIPE-1', sourceKind: 'pipe', renderKind: 'pipe', colorKey: 'UNKNOWN', diameterM: 0.3, path: [[110.4, 28.4], [110.4008, 28.4006], [110.4016, 28.4012]] },
|
|
|
|
|
+ objects: [], devices: [], deviceList: [], coverage: null, deviceCoverage: null }
|
|
|
|
|
+ const fakeRenderer = () => ({ setPixelRatio() {}, setSize() {}, render() {}, dispose() {},
|
|
|
|
|
+ domElement: document.createElement('canvas') })
|
|
|
|
|
+ const host = document.createElement('main'); document.body.appendChild(host)
|
|
|
|
|
+ const app = createApp({ render: () => h('div', [
|
|
|
|
|
+ h(PipeEnhancement, { plan, deviceState: enhancement, rendererOptions: { rendererFactory: fakeRenderer } }),
|
|
|
|
|
+ h(VideoMonitorPanel)]) })
|
|
|
|
|
+ app.config.errorHandler = (error) => { errors.push(error) }
|
|
|
|
|
+ app.use(pinia).mount(host); await settle()
|
|
|
|
|
+ return { host, requests, unexpected, errors, video, enhancement, async dispose() {
|
|
|
|
|
+ app.unmount(); await video.close(); host.remove(); await settle()
|
|
|
|
|
+ globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
|
|
|
|
|
+ } }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+test('地下管线详情:真实设备详情打开唯一根监控面板且来源为 enhancement', async () => {
|
|
|
|
|
+ const page = await mountUnderground()
|
|
|
|
|
+ try {
|
|
|
|
|
+ const detail = page.host.querySelector('[data-enhancement-device-detail]')
|
|
|
|
|
+ assert.ok(detail, JSON.stringify({ text: page.host.textContent, unexpected: page.unexpected, errors: page.errors.map((error) => error.message) }))
|
|
|
|
|
+ detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
|
|
|
|
|
+ const monitor = detail.querySelector('[data-video-open]')
|
|
|
|
|
+ assert.ok(monitor); monitor.click(); await settle()
|
|
|
|
|
+ assert.equal(page.video.state.ownerSource, 'enhancement')
|
|
|
|
|
+ const panels = document.querySelectorAll('[data-video-monitor]')
|
|
|
|
|
+ assert.equal(panels.length, 1)
|
|
|
|
|
+ assert.ok(panels[0].querySelector('video'), JSON.stringify({ text: panels[0].textContent, unexpected: page.unexpected }))
|
|
|
|
|
+ panels[0].querySelector('[aria-label="关闭监控"]').click(); await settle()
|
|
|
|
|
+ assert.equal(document.querySelector('[data-video-monitor]'), null)
|
|
|
|
|
+ assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
|
|
|
|
|
+ assert.deepEqual(page.unexpected, [])
|
|
|
|
|
+ assert.deepEqual(page.errors, [])
|
|
|
|
|
+ } finally { await page.dispose() }
|
|
|
|
|
+})
|