|
@@ -18,7 +18,8 @@ const point = { pointCode: 'P-FIXED', pointName: '无位置摄像机监测点',
|
|
|
deviceCodes: [cameraCode], longitude: null, latitude: null, coordinateValidation: coordinate }
|
|
deviceCodes: [cameraCode], longitude: null, latitude: null, coordinateValidation: coordinate }
|
|
|
const camera = { deviceCode: cameraCode, deviceName: '无位置现场摄像机', deviceType: '20', deviceTypeName: '视频监控设备',
|
|
const camera = { deviceCode: cameraCode, deviceName: '无位置现场摄像机', deviceType: '20', deviceTypeName: '视频监控设备',
|
|
|
deviceStatus: 'NORMAL', industry: 'DRAINAGE', longitude: null, latitude: null, coordinateValidation: coordinate }
|
|
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 device = { deviceCode: cameraCode, deviceName: camera.deviceName, typeCode: '20', bound: true,
|
|
|
|
|
+ capabilities: { live: true, records: true, playback: true, playbackControl: false } }
|
|
|
const aggregate = { deviceCode: cameraCode, aggregateState: 'READY',
|
|
const aggregate = { deviceCode: cameraCode, aggregateState: 'READY',
|
|
|
archive: { state: 'SUCCESS', data: camera }, metrics: { state: 'EMPTY', data: null },
|
|
archive: { state: 'SUCCESS', data: camera }, metrics: { state: 'EMPTY', data: null },
|
|
|
warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] }, workOrders: { state: 'EMPTY', data: null },
|
|
warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] }, workOrders: { state: 'EMPTY', data: null },
|
|
@@ -128,6 +129,21 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
|
|
|
if (path.startsWith('/equipments/') && path.endsWith('/freshness')) return envelope({})
|
|
if (path.startsWith('/equipments/') && path.endsWith('/freshness')) return envelope({})
|
|
|
if (emptyEndpoints.has(path)) 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/device') { assert.equal(url.searchParams.get('deviceCode'), cameraCode); return envelope(device) }
|
|
|
|
|
+ if (path === '/video/records') {
|
|
|
|
|
+ assert.equal(url.searchParams.get('deviceCode'), cameraCode)
|
|
|
|
|
+ const rows = server.recordings ?? []
|
|
|
|
|
+ return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
|
|
|
|
|
+ startTime: url.searchParams.get('startTime'), endTime: url.searchParams.get('endTime'),
|
|
|
|
|
+ records: rows, empty: rows.length === 0 })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (path === '/video/playback') {
|
|
|
|
|
+ assert.equal(body.deviceCode, cameraCode)
|
|
|
|
|
+ if (server.playbackBusy) return { ok: false, status: 409, json: async () => ({ code: 409, data: { category: 'PLAYBACK_OCCUPIED', message: '此通道已有录像回放,请结束后重试' } }) }
|
|
|
|
|
+ return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
|
|
|
|
|
+ sessionId: 'session-pb', sessionToken: 'owner-pb', mode: 'PLAYBACK',
|
|
|
|
|
+ startTime: body.startTime, endTime: body.endTime,
|
|
|
|
|
+ media: { native: true, url: 'https://media.invalid/playback.mp4' } })
|
|
|
|
|
+ }
|
|
|
if (path === '/video/sessions/prepare') {
|
|
if (path === '/video/sessions/prepare') {
|
|
|
assert.deepEqual(body, { deviceCode: cameraCode })
|
|
assert.deepEqual(body, { deviceCode: cameraCode })
|
|
|
return envelope({ ...device, sessionId: 'session-host', sessionToken: 'owner-host', mode: 'LIVE', status: 'PREPARED' }, 201)
|
|
return envelope({ ...device, sessionId: 'session-host', sessionToken: 'owner-host', mode: 'LIVE', status: 'PREPARED' }, 201)
|
|
@@ -146,6 +162,10 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
|
|
|
assert.deepEqual(body, { sessionToken: 'owner-host' })
|
|
assert.deepEqual(body, { sessionToken: 'owner-host' })
|
|
|
return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'STOPPED' })
|
|
return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'STOPPED' })
|
|
|
}
|
|
}
|
|
|
|
|
+ if (path === '/video/sessions/session-pb/stop' || path === '/video/sessions/session-pb/status') {
|
|
|
|
|
+ assert.deepEqual(body, { sessionToken: 'owner-pb' })
|
|
|
|
|
+ return envelope({ sessionId: 'session-pb', deviceCode: cameraCode, mode: 'PLAYBACK', status: 'STOPPED' })
|
|
|
|
|
+ }
|
|
|
unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
|
|
unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
|
|
|
}
|
|
}
|
|
|
globalThis.fetch = fetchImpl
|
|
globalThis.fetch = fetchImpl
|
|
@@ -251,6 +271,58 @@ test('真实Root旅程中临时失败展示重试状态,关闭后不再自动
|
|
|
} finally { await page.dispose() }
|
|
} finally { await page.dispose() }
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+test('历史录像TAB不关闭面板,列片后才起流,占用不抢停别人', async () => {
|
|
|
|
|
+ const page = await mountRoot()
|
|
|
|
|
+ try {
|
|
|
|
|
+ page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
|
|
|
|
|
+ const detail = await openMainDetail(page)
|
|
|
|
|
+ detail.querySelector('[data-video-open]').click(); await settle()
|
|
|
|
|
+ const dialog = () => document.querySelector('[data-video-monitor]')
|
|
|
|
|
+ assert.ok(dialog(), '监控面板应已打开')
|
|
|
|
|
+ // Switching to the history tab must end the live stream but keep the monitor mounted.
|
|
|
|
|
+ const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
|
|
|
|
|
+ assert.equal(tabs.length, 2, '两个TAB都应存在')
|
|
|
|
|
+ tabs[1].click(); await settle()
|
|
|
|
|
+ assert.ok(dialog(), '切到历史TAB后面板不得消失')
|
|
|
|
|
+ assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, 1, '应查询录像')
|
|
|
|
|
+ // Listing alone must never start playback.
|
|
|
|
|
+ assert.equal(page.requests.filter((request) => request.path.endsWith('/playback')).length, 0)
|
|
|
|
|
+ assert.ok(dialog().textContent.includes('CAM/03'), dialog().textContent)
|
|
|
|
|
+ // Choosing a recording starts the owned playback session.
|
|
|
|
|
+ const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
|
|
|
|
|
+ assert.ok(play); play.click(); await settle()
|
|
|
|
|
+ assert.equal(page.requests.filter((request) => request.path.endsWith('/playback')).length, 1)
|
|
|
|
|
+ assert.ok(dialog().querySelector('video[aria-label="历史录像画面"]'), '应展示回放画面')
|
|
|
|
|
+ // Closing releases the playback session and never leaves the panel half-open.
|
|
|
|
|
+ dialog().querySelector('[aria-label="关闭监控"]').click(); await settle()
|
|
|
|
|
+ assert.equal(document.querySelector('[data-video-monitor]'), null)
|
|
|
|
|
+ assert.ok(page.requests.some((request) => request.path.includes('session-pb') && request.path.endsWith('/stop')), '回放必须被释放')
|
|
|
|
|
+ assert.deepEqual(page.errors, [])
|
|
|
|
|
+ assert.deepEqual(page.unexpected, [])
|
|
|
|
|
+ } finally { await page.dispose() }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test('历史回放占用时提示占用且不释放他人回放', async () => {
|
|
|
|
|
+ const page = await mountRoot()
|
|
|
|
|
+ try {
|
|
|
|
|
+ page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
|
|
|
|
|
+ page.server.playbackBusy = true
|
|
|
|
|
+ const detail = await openMainDetail(page)
|
|
|
|
|
+ detail.querySelector('[data-video-open]').click(); await settle()
|
|
|
|
|
+ const dialog = () => document.querySelector('[data-video-monitor]')
|
|
|
|
|
+ const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
|
|
|
|
|
+ tabs[1].click(); await settle()
|
|
|
|
|
+ const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
|
|
|
|
|
+ play.click(); await settle()
|
|
|
|
|
+ // The occupancy must surface as its own message, not a generic failure.
|
|
|
|
|
+ assert.ok(dialog().textContent.includes('已有录像回放'), dialog().textContent)
|
|
|
|
|
+ assert.equal(page.requests.filter((request) => request.path.includes('session-pb')).length, 0,
|
|
|
|
|
+ '占用时不得停止或触碰他人的回放会话')
|
|
|
|
|
+ assert.deepEqual(page.errors, [])
|
|
|
|
|
+ assert.deepEqual(page.unexpected, [])
|
|
|
|
|
+ } finally { await page.dispose() }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
|
|
test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
|
|
|
const page = await mountRoot()
|
|
const page = await mountRoot()
|
|
|
try {
|
|
try {
|