import assert from 'node:assert/strict' import test from 'node:test' import { createApp, h, nextTick } from 'vue' import { createPinia, setActivePinia } from 'pinia' import PointDetailCard from '../../src/modules/cockpit/components/PointDetailCard.vue' import VideoMonitorPanel from '../../src/modules/cockpit/components/VideoMonitorPanel.vue' import { useDetailStore } from '../../src/modules/cockpit/stores/detailStore.js' import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js' const code = 'CAM/01 空格' const device = { deviceCode: code, deviceName: '现场摄像机A', typeCode: '20', bound: true, capabilities: { live: true, records: false, playback: false, playbackControl: false, ptz: false, ptzStopGuaranteed: false }, unavailableReason: null } const envelope = (data, status = 200) => Response.json({ code: status, msg: status === 200 ? '操作成功' : '失败', data }, { status }) const aggregate = { deviceCode: code, aggregateState: 'READY', archive: { state: 'SUCCESS', data: { deviceCode: code, deviceName: '现场摄像机A', deviceType: '20' } }, 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 } } async function settle() { for (let i = 0; i < 4; i++) { await new Promise(setImmediate); await nextTick() } } async function mountJourney(overrides = {}) { const requests = [] const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1', token: 'must-not-send' } } const fetchImpl = async (url, options = {}) => { const path = new URL(url).pathname const body = options.body ? JSON.parse(options.body) : null requests.push({ url: new URL(url), path, options, body }) assert.equal(options.headers?.Authorization, undefined) if (path.endsWith('/detail-aggregate')) return envelope(aggregate) if (path.endsWith('/collection-records')) return overrides.records?.() || envelope({ deviceCode: code, state: 'EMPTY', recordView: { columns: [], rows: [] } }) if (path.endsWith('/video/device')) { assert.equal(new URL(url).searchParams.get('deviceCode'), code) return envelope(overrides.device || device) } if (path.endsWith('/video/live')) { assert.deepEqual(body, { deviceCode: code }) return overrides.live?.() || envelope({ deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', sessionToken: 'owner-a', mode: 'LIVE', media: { native: true, url: 'https://media.invalid/live.mp4' } }) } if (path.endsWith('/heartbeat')) return overrides.heartbeat?.() || envelope({ sessionId: 'session-a', status: 'ACTIVE' }) if (path.endsWith('/stop')) return overrides.stop?.() || envelope({ sessionId: 'session-a', status: 'STOPPED' }) throw new Error('Unexpected fixture request: ' + path) } const pinia = createPinia(); setActivePinia(pinia) const detail = useDetailStore(); detail.configure({ config, fetchImpl }) const video = useVideoStore(); video.configure({ config, fetchImpl, ...(overrides.sessionOptions || {}) }) await detail.openDevice(code) const host = document.createElement('main'); document.body.appendChild(host) const app = createApp({ render: () => h('div', [h(PointDetailCard), h(VideoMonitorPanel)]) }) app.use(pinia).mount(host) await settle() return { host, requests, video, detail, async dispose() { app.unmount(); await video.close(); host.remove() } } } const livePayload = (media) => ({ deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', sessionToken: 'owner-a', mode: 'LIVE', media }) async function openMonitor(page) { page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle() page.host.querySelector('[data-video-open]').click(); await settle() return document.querySelector('[data-video-monitor]') } test('a successful HTTP envelope for another session cannot confirm cleanup of the current viewer', async () => { let wrong = true const page = await mountJourney({ stop: () => envelope({ sessionId: wrong ? 'someone-else' : 'session-a', status: 'STOPPED' }) }) try { await openMonitor(page) document.querySelector('[aria-label="关闭监控"]').click(); await settle() assert.ok(page.host.querySelector('[data-video-entry]').textContent.includes('会话释放未确认')) assert.equal(page.host.querySelector('[data-video-open]').disabled, true) } finally { wrong = false; await page.dispose() } }) class TestClock { now = 0; next = 1; jobs = new Map() setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id } clearTimeout(id) { this.jobs.delete(id) } setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id } clearInterval(id) { this.clearTimeout(id) } async advance(duration) { const end = this.now + duration while (true) { const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0] if (!due) break const [id, job] = due; this.now = job.at if (job.interval) job.at += job.interval; else this.jobs.delete(id) job.fn(); await settle() } this.now = end; await settle() } } test('watching heartbeat preserves ownership but never establishes media readiness; failure releases the viewer', async () => { const clock = new TestClock(); let wrong = false const page = await mountJourney({ sessionOptions: { timers: clock }, heartbeat: () => envelope({ sessionId: wrong ? 'other' : 'session-a', status: 'ACTIVE' }) }) try { const dialog = await openMonitor(page) dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle() await clock.advance(30000) const beat = page.requests.find((request) => request.path.endsWith('/heartbeat')) assert.deepEqual(beat.body, { sessionToken: 'owner-a' }) wrong = true await clock.advance(30000) assert.equal(dialog.querySelector('video'), null) assert.ok(dialog.textContent.includes('播放会话已断开')) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) } finally { await page.dispose() } }) test('Escape closes the global modal and releases its viewing resource', async () => { const page = await mountJourney() try { await openMonitor(page) const key = new Event('keydown', { bubbles: true, cancelable: true }); key.key = 'Escape' document.dispatchEvent(key); await settle() assert.equal(document.querySelector('[data-video-monitor]'), null) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) } finally { await page.dispose() } }) test('collection-record failure does not remove the latest-tab monitoring entry', async () => { const page = await mountJourney({ records: () => envelope({}, 503) }) try { const dialog = await openMonitor(page) assert.ok(dialog.querySelector('video')) assert.ok(page.host.textContent.includes('其它分区不受影响')) } finally { await page.dispose() } }) test('non-camera has no monitor button; unbound and unknown live capability stay disabled without starting', async () => { for (const camera of [ { ...device, typeCode: '15', bound: false, capabilities: { live: false } }, { ...device, bound: false, capabilities: { live: false }, unavailableReason: '尚未绑定视频' }, { ...device, bound: true, capabilities: { live: false }, unavailableReason: '实时能力未确认' }, ]) { const page = await mountJourney({ device: camera }) try { page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle() const button = page.host.querySelector('[data-video-open]') if (camera.typeCode !== '20') assert.equal(button, null) else { assert.equal(button.disabled, true); assert.ok(page.host.textContent.includes(camera.unavailableReason)) } assert.equal(page.requests.some((request) => request.path.endsWith('/video/live')), false) } finally { await page.dispose() } } }) test('unsupported returned media releases its owned session and never presents playing', async () => { const page = await mountJourney({ live: () => envelope(livePayload({ native: false, flv: 'https://media.invalid/live.flv' })) }) try { const dialog = await openMonitor(page) assert.equal(dialog.querySelector('video'), null) assert.ok(dialog.textContent.includes('不支持')) assert.equal(dialog.textContent.includes('正在播放'), false) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) } finally { await page.dispose() } }) test('HTTP 200 HTML is a visible failure with unconfirmed resources, not an active video', async () => { const page = await mountJourney({ live: () => new Response('wrong route', { headers: { 'Content-Type': 'text/html' } }) }) try { const dialog = await openMonitor(page) assert.equal(dialog.querySelector('video'), null) assert.ok(dialog.textContent.includes('无效数据')) assert.ok(dialog.textContent.includes('未确认')) assert.equal(Array.from(dialog.querySelectorAll('button')).find((button) => button.textContent === '重新播放').disabled, true) } finally { await page.dispose() } }) test('a live start exceeding 35 seconds exits visibly and releases a later owned response without opening video', async () => { const clock = new TestClock(); let respond const delayed = new Promise((resolve) => { respond = resolve }) const page = await mountJourney({ live: () => delayed, sessionOptions: { timers: clock } }) try { const dialog = await openMonitor(page) await clock.advance(35000) assert.ok(dialog.textContent.includes('视频建立超时')) assert.equal(dialog.querySelector('video'), null) respond(envelope(livePayload({ native: true, url: 'https://media.invalid/live.mp4' }))) await settle() assert.equal(dialog.querySelector('video'), null) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/video/live')).length, 1) } finally { await page.dispose() } }) test('player readiness has a bound independent of HTTP success and cleans its owned session', async () => { const clock = new TestClock() const page = await mountJourney({ sessionOptions: { timers: clock } }) try { const dialog = await openMonitor(page) assert.ok(dialog.textContent.includes('等待视频画面')) await clock.advance(20000) assert.ok(dialog.textContent.includes('播放器就绪超时')) assert.equal(dialog.querySelector('video'), null) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) } finally { await page.dispose() } }) test('hiding the page closes video and does not reopen it when visible again', async () => { const page = await mountJourney() try { await openMonitor(page) Object.defineProperty(document, 'hidden', { configurable: true, value: true }) document.dispatchEvent(new Event('visibilitychange')); await settle() assert.equal(document.querySelector('[data-video-monitor]'), null) Object.defineProperty(document, 'hidden', { configurable: true, value: false }) document.dispatchEvent(new Event('visibilitychange')); await settle() assert.equal(page.requests.filter((request) => request.path.endsWith('/video/live')).length, 1) assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) } finally { delete document.hidden; await page.dispose() } }) test('latest-tab entry drives WebRTC signaling and only a video playing event establishes visible playback', async () => { const peers = [], signaling = [] class Emitter { handlers = new Map() addEventListener(type, fn) { const group = this.handlers.get(type) || new Set(); group.add(fn); this.handlers.set(type, group) } removeEventListener(type, fn) { this.handlers.get(type)?.delete(fn) } emit(type, event = {}) { for (const fn of this.handlers.get(type) || []) fn(event) } } const track = new Emitter(); track.kind = 'video'; track.readyState = 'live'; track.stop = () => { track.readyState = 'ended' } class Peer extends Emitter { iceGatheringState = 'complete'; connectionState = 'new'; closed = false constructor(options) { super(); assert.deepEqual(options, { iceServers: [] }); peers.push(this) } addTransceiver() {} async createOffer() { return { type: 'offer', sdp: 'v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n' } } async setLocalDescription(offer) { this.localDescription = offer } async setRemoteDescription(answer) { this.remoteDescription = answer } getReceivers() { return [{ track }] } close() { this.closed = true } } const page = await mountJourney({ live: () => envelope(livePayload({ native: false, webrtc: 'http://media.invalid/index/api/webrtc?app=rtp&stream=cam&type=play' })), sessionOptions: { playerOptions: { PeerConnection: Peer, fetchImpl: async (url, options) => { signaling.push({ url, options }); assert.equal(options.credentials, 'omit') return Response.json({ code: 0, type: 'answer', sdp: 'v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n' }) } } }, }) try { const dialog = await openMonitor(page) assert.equal(signaling.length, 1) assert.ok(signaling[0].options.body.startsWith('v=0')) assert.equal(dialog.textContent.includes('正在播放'), false) const stream = { getTracks: () => [track], addTrack() {} } peers[0].emit('track', { track, streams: [stream] }) dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle() assert.ok(dialog.textContent.includes('正在播放')) dialog.querySelector('[aria-label="关闭监控"]').click(); await settle() assert.equal(peers[0].closed, true) assert.equal(track.readyState, 'ended') assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1) } finally { await page.dispose() } }) test('device detail latest tab opens its own anonymous live video even with no collection records, then closes it', async () => { const page = await mountJourney() try { const latest = page.host.querySelector('[data-equipment-tab="metrics"]') assert.ok(latest, 'existing device-detail latest tab must be visible') latest.click(); await settle() const open = page.host.querySelector('[data-video-open]') assert.ok(open, 'the monitor entry must be inside the latest tab') assert.equal(open.disabled, false) open.click(); await settle() const dialog = document.querySelector('[role="dialog"][data-video-monitor]') assert.ok(dialog) assert.ok(dialog.textContent.includes('现场摄像机A')) assert.ok(dialog.textContent.includes(code)) assert.equal(dialog.textContent.includes('owner-a'), false) 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) const stops = page.requests.filter((request) => request.path.endsWith('/stop')) assert.equal(stops.length, 1) assert.deepEqual(stops[0].body, { sessionToken: 'owner-a' }) } finally { await page.dispose() } }) test('a failed cleanup is visible at the entry and blocks another live request until cleanup succeeds', async () => { let cleanupFails = true const page = await mountJourney({ stop: () => cleanupFails ? envelope({ category: 'CLEANUP_UNCONFIRMED' }, 503) : envelope({ sessionId: 'session-a', status: 'STOPPED' }) }) try { page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle() page.host.querySelector('[data-video-open]').click(); await settle() document.querySelector('[aria-label="关闭监控"]').click(); await settle() const entry = page.host.querySelector('[data-video-entry]') assert.ok(entry.textContent.includes('会话释放未确认')) assert.equal(entry.querySelector('[data-video-open]').disabled, true) assert.equal(page.requests.filter((request) => request.path.endsWith('/video/live')).length, 1) cleanupFails = false Array.from(entry.querySelectorAll('button')).find((button) => button.textContent === '重试清理').click(); await settle() assert.equal(page.host.querySelector('[data-video-open]').disabled, false) } finally { cleanupFails = false; await page.dispose() } })