import assert from 'node:assert/strict' import test from 'node:test' import { createApp, nextTick } from 'vue' import { createPinia, setActivePinia } from 'pinia' import CockpitRoot from '../../src/pages/cockpit/index.vue' 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((resolve) => setTimeout(resolve, 0)); await nextTick() } } async function until(predicate, message) { for (let i = 0; i < 20; i++) { if (predicate()) return; await settle() } assert.ok(predicate(), message) } 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', '/infrastructure/operating-information', '/infrastructure/base-archives']) function installGpu() { const viewers = [] let visible = true, time = 0, pipeHit = null 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) }, removeEventListener(callback) { 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: () => pipeHit, } 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() }, pickPipe(code = 'PIPE-1') { pipeHit = { objectKind: 'pipe', code } for (const type of ['pointerdown', 'pointerup']) { const event = new Event(type, { bubbles: true }) Object.assign(event, { button: 0, pointerId: 1, clientX: 800, clientY: 450 }) viewers[0].canvas.dispatchEvent(event) } pipeHit = null } } } async function mountRoot({ positioned = false, underground = false } = {}) { const gpu = installGpu() globalThis.__VIDEO_THREE_OPTIONS__ = { rendererFactory: ({ canvas }) => ({ domElement: canvas, setPixelRatio() {}, setSize() {}, render() {}, dispose() {} }), iconResolver: async () => null, loadTexture: () => null, now: () => 1000, scheduleFrame: () => {}, } 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 server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true } 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') { if (server.snapshotGate) await server.snapshotGate return envelope({ page: 1, pageSize: 100, snapshotAt: '2026-11-11T00:00:00Z', batchUpdatedAt: '2026-11-11T00:00:00Z', total: 1, complete: true, monitoringPoints: [{ ...selectedPoint, deviceCodes: server.cameraPresent ? [cameraCode] : [] }], 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}`) { if (server.pointGate) await server.pointGate if (server.pointFails) return envelope({}, 503) return envelope({ monitoringPointCode: selectedPoint.pointCode, monitoringPointName: point.pointName, pointStatus: 'NORMAL', coordinateValidation: location, deviceStatistics: { totalDeviceCount: server.cameraPresent ? 1 : 0, normalDeviceCount: server.cameraPresent ? 1 : 0 }, devices: server.cameraPresent ? [selectedCamera] : [] }) } if (path === '/infrastructure/geometry') return envelope({ type: 'FeatureCollection', features: underground ? [{ type: 'Feature', id: 'PIPE-1', properties: { code: 'PIPE-1', name: '地下测试管段', industry: 'DRAINAGE', diameter: 300, material: '钢', coordinateValidation: { renderable: true } }, geometry: { type: 'LineString', coordinates: [[110.3998, 28.4], [110.4008, 28.4]] }, }] : [] }) if (path === '/device-facility-relations') return envelope({ rows: [] }) if (path.startsWith('/equipments/') && path.endsWith('/detail-aggregate')) return envelope({ ...aggregate, archive: { state: 'SUCCESS', data: selectedCamera } }) 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', 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, server, async dispose() { app.unmount(); await video.close(); host.remove(); delete globalThis.__COCKPIT_CONFIG__; delete globalThis.__VIDEO_THREE_OPTIONS__; await settle() globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') } assert.deepEqual(errors, [], '包含根卸载期间的组件错误') assert.deepEqual(unexpected, [], '包含卸载期间的未配置请求') } } } async function openMainDetail(page) { 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('.cockpit-shell__detail [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') detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle() return detail } async function openMainMonitor(page) { const detail = await openMainDetail(page) detail.querySelector('[data-video-open]').click(); await settle() return document.querySelector('[data-video-monitor]') } // 真root→实际Shell固定态→既有点位设备卡→Latest TAB→监控;不直接调用视频store.open。 for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地图位置固定'}:真实台账设备详情打开唯一根监控面板`, async () => { const page = await mountRoot({ positioned }) try { const detail = await openMainDetail(page) 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() } }) test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => { const page = await mountRoot() try { const detail = await openMainDetail(page) const monitor = detail.querySelector('[data-video-open]') detail.querySelector('[data-detail-close]').click() monitor.click(); await settle() assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false) assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 0, '失效来源不能先prepare再补关') assert.deepEqual(page.errors, []) assert.deepEqual(page.unexpected, []) } finally { await page.dispose() } }) test('一级模式同轮离开再返回仍释放旧观看,返回须手动恢复', async () => { for (const underground of [false, true]) { const page = await mountRoot({ positioned: underground, underground }) try { const mode = (text) => [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === text) mode('运行监测').click(); await settle() if (underground) await openUnderground(page) else await openMainMonitor(page) mode('总体概览').click() mode('运行监测').click() await settle() assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, '切模式必须释放旧观看,即使同轮返回') assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1) assert.deepEqual(page.errors, []) assert.deepEqual(page.unexpected, []) } finally { await page.dispose() } } }) // 真Root/Shell:地图物理拾取→实际管线计划→地下设备列表点击→Latest TAB→唯一根面板。 async function openUnderground(page, { monitor: open = true } = {}) { await until(() => page.gpu.viewers.length && page.host.querySelector('[data-pipe-legend-count="1"]'), '等待真实管线几何进入图例后再点选') page.gpu.pickPipe(); await settle() const underground = page.host.querySelector('[data-pipe-enhancement]') assert.ok(underground, JSON.stringify({ unexpected: page.unexpected, errors: page.errors.map((e) => e.message) })) assert.ok(underground.querySelector('[data-pipe-enhancement-viewport] canvas'), '真实 mountPipeEnhancement 挂载物理渲染替身') assert.equal(underground.querySelector('[data-equipment-tab="metrics"]'), null, '必须先从地下列表选择设备') const card = underground.querySelector('[data-device-list] [data-device-code="CAM/03 空格"]') assert.ok(card, underground.textContent) card.click(); await settle() const latest = underground.querySelector('[data-equipment-tab="metrics"]') assert.ok(latest, underground.textContent) latest.click(); await settle() const monitor = underground.querySelector('[data-video-open]') assert.ok(monitor); assert.equal(monitor.disabled, false) if (!open) return monitor monitor.click(); await settle() return document.querySelector('[data-video-monitor]') } test('三宿主同设备刷新保持同一媒体;浮动锚点暂隐不释放观看', async () => { for (const host of ['fixed', 'floating', 'underground']) { const page = await mountRoot({ positioned: host !== 'fixed', underground: host === 'underground' }) try { const dialog = host === 'underground' ? await openUnderground(page) : await openMainMonitor(page) const media = dialog.querySelector('video') media.dispatchEvent(new Event('playing')); await settle() let releaseSnapshot, releasePoint page.server.snapshotGate = new Promise((resolve) => { releaseSnapshot = resolve }) page.server.pointGate = new Promise((resolve) => { releasePoint = resolve }) const before = page.requests.filter((request) => request.path === '/map-snapshot').length const beforeDetails = page.requests.filter((request) => request.path.endsWith('/detail-aggregate')).length page.host.querySelector('[data-map-refresh]').click(); await settle() assert.equal(page.requests.filter((request) => request.path === '/map-snapshot').length, before + 1) assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 摘要刷新等待期保留媒体`) releaseSnapshot(); page.server.snapshotGate = null; await settle() assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 设备清单等待期保留媒体`) releasePoint(); page.server.pointGate = null; await settle() await until(() => page.requests.filter((request) => request.path.endsWith('/detail-aggregate')).length > beforeDetails, `${host}: 确认真实详情重新取数,而非只等待空刷新`) assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 新数据不能重挂播放器`) if (host === 'floating') { page.gpu.setVisible(false); await settle() assert.notEqual(page.host.querySelector('.cockpit-shell__detail').getAttribute('data-anchor-state'), 'visible') assert.ok(document.querySelector('[data-video-monitor] video') === media) page.gpu.setVisible(true); await settle() assert.equal(page.host.querySelector('.cockpit-shell__detail').getAttribute('data-anchor-state'), 'visible') } assert.ok(dialog.textContent.includes('正在播放')) assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0) assert.deepEqual(page.errors, []) assert.deepEqual(page.unexpected, []) } finally { await page.dispose() } } }) test('同码合法换来源复用媒体,非所属详情关闭不误停', async () => { const page = await mountRoot({ positioned: true, underground: true }) try { const dialog = await openMainMonitor(page) const media = dialog.querySelector('video') await openUnderground(page) assert.ok(document.querySelector('[data-video-monitor] video') === media) page.host.querySelector('.cockpit-shell__detail [data-detail-close]').click(); await settle() assert.ok(document.querySelector('[data-video-monitor] video') === media, '地下接管后关闭主详情不误停') await openMainMonitor(page) assert.ok(document.querySelector('[data-video-monitor] video') === media) page.host.querySelector('[data-pipe-enhancement-close]').click(); await settle() assert.ok(document.querySelector('[data-video-monitor] video') === media, '主详情接管后关闭地下不误停') assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0) page.host.querySelector('.cockpit-shell__detail [data-detail-close]').click(); await settle() assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) assert.deepEqual(page.errors, []) assert.deepEqual(page.unexpected, []) } finally { await page.dispose() } }) test('地下刷新失败保留观看,完整快照确认设备移除才关闭且返回不自动重开', async () => { const page = await mountRoot({ positioned: true, underground: true }) try { const dialog = await openUnderground(page) const media = dialog.querySelector('video') page.server.pointFails = true page.host.querySelector('[data-map-refresh]').click(); await settle() assert.ok(document.querySelector('[data-video-monitor] video') === media, '失败不证明设备失效') assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0) page.server.pointFails = false page.server.cameraPresent = false page.host.querySelector('[data-map-refresh]').click(); await settle() assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, '完整数据确认移除需释放观看') assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) page.server.cameraPresent = true page.host.querySelector('[data-map-refresh]').click(); await settle() assert.ok(page.host.querySelector('[data-pipe-enhancement] [data-device-code="CAM/03 空格"]')) assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false) assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1) assert.deepEqual(page.errors, []) assert.deepEqual(page.unexpected, []) } finally { await page.dispose() } }) test('失效的地下同码入口不能抢走主详情正在观看的资源', async () => { const page = await mountRoot({ positioned: true, underground: true }) try { const dialog = await openMainMonitor(page) const media = dialog.querySelector('video') const staleEntry = await openUnderground(page, { monitor: false }) page.host.querySelector('[data-pipe-enhancement-close]').click() staleEntry.click(); await settle() assert.equal(Boolean(document.querySelector('[data-video-monitor]')), true, '失效来源不应关闭合法来源的观看') assert.equal(document.querySelector('[data-video-monitor] video'), media) assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0) assert.deepEqual(page.errors, []) assert.deepEqual(page.unexpected, []) } finally { await page.dispose() } }) test('地下管线详情:真实Root列表点设备经Latest TAB打开唯一监控面板', async () => { const page = await mountRoot({ positioned: true, underground: true }) try { const dialog = await openUnderground(page) assert.equal(document.querySelectorAll('[data-video-monitor]').length, 1) assert.ok(dialog.textContent.includes(cameraCode)) assert.ok(dialog.querySelector('video')) dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle() assert.ok(dialog.textContent.includes('正在播放')) page.host.querySelector('[data-pipe-enhancement-close]').click(); await settle() assert.equal(document.querySelector('[data-video-monitor]'), null) assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) assert.deepEqual(page.unexpected, []) assert.deepEqual(page.errors, []) } finally { await page.dispose() } })