hosts.mjs 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { createApp, nextTick } from 'vue'
  4. import { createPinia, setActivePinia } from 'pinia'
  5. import CockpitRoot from '../../src/pages/cockpit/index.vue'
  6. import { loadCockpitConfig, resetCockpitConfigForTest } from '../../src/modules/cockpit/services/cockpitConfig.js'
  7. import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js'
  8. const settle = async () => { for (let i = 0; i < 8; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick() } }
  9. async function until(predicate, message) {
  10. for (let i = 0; i < 20; i++) { if (predicate()) return; await settle() }
  11. assert.ok(predicate(), message)
  12. }
  13. const envelope = (data, status = 200) => Response.json({ code: 200, data }, { status })
  14. const cameraCode = 'CAM/03 空格'
  15. const coordinate = { state: 'INVALID', renderable: false, reasons: ['MISSING_LONGITUDE'], longitude: null, latitude: null }
  16. const point = { pointCode: 'P-FIXED', pointName: '无位置摄像机监测点', pointStatus: 'NORMAL', industry: 'DRAINAGE',
  17. deviceCodes: [cameraCode], longitude: null, latitude: null, coordinateValidation: coordinate }
  18. const camera = { deviceCode: cameraCode, deviceName: '无位置现场摄像机', deviceType: '20', deviceTypeName: '视频监控设备',
  19. deviceStatus: 'NORMAL', industry: 'DRAINAGE', longitude: null, latitude: null, coordinateValidation: coordinate }
  20. const device = { deviceCode: cameraCode, deviceName: camera.deviceName, typeCode: '20', bound: true,
  21. capabilities: { live: true, records: true, playback: true, playbackControl: false } }
  22. const aggregate = { deviceCode: cameraCode, aggregateState: 'READY',
  23. archive: { state: 'SUCCESS', data: camera }, metrics: { state: 'EMPTY', data: null },
  24. warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] }, workOrders: { state: 'EMPTY', data: null },
  25. timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null } }
  26. const emptyEndpoints = new Set(['/overview/perception', '/overview/pipe-network', '/overview/warning-study', '/overview/alarm-disposal',
  27. '/monitoring/overview', '/monitoring/situation', '/events/current', '/infrastructure/operating-information', '/infrastructure/base-archives'])
  28. function installGpu() {
  29. const viewers = []
  30. let visible = true, time = 0, pipeHit = null
  31. const frame = () => { for (const viewer of viewers) for (const callback of [...viewer.frames]) callback() }
  32. const projection = () => visible ? { x: 800, y: 450, visible: true, iconSize: 20 } : null
  33. const gpu = {
  34. createViewer(host) {
  35. const canvas = document.createElement('canvas'); host.appendChild(canvas)
  36. const frames = new Set()
  37. const viewer = { canvas, frames, points: [], devices: [],
  38. camera: { positionWC: { x: 0, y: 0, z: 6371000 }, directionWC: { x: 0, y: 0, z: -1 }, upWC: { x: 0, y: 1, z: 0 } },
  39. scene: { canvas, globe: { ellipsoid: {} }, requestRender() {},
  40. postRender: { addEventListener(callback) { frames.add(callback); return () => frames.delete(callback) },
  41. removeEventListener(callback) { frames.delete(callback) } } } }
  42. viewers.push(viewer); return viewer
  43. },
  44. destroyViewer(viewer) { viewer.frames.clear(); viewer.canvas.remove(); viewers.splice(viewers.indexOf(viewer), 1) },
  45. resizeViewer() {}, applyOverlays: () => [], sampleTerrainHeight: async () => 0,
  46. sampleTerrainHeights: async (_viewer, points) => points.map(() => 0),
  47. groundHeightAt: () => 0, markerGroundHeight: () => 0,
  48. createCameraEngine(viewer) { return {
  49. applyView() {}, readView: () => ({ heading: 0, pitch: -45, range: 1000 }),
  50. pickGroundAtCenter: () => ({ lon: 110.4, lat: 28.4, height: 0 }), projectPivot: projection,
  51. inputTarget: () => viewer.canvas, viewport: () => ({ width: 1600, height: 900 }), fovY: () => Math.PI / 3,
  52. now: () => time, requestFrame: (callback) => setTimeout(() => { time += 100; callback(time) }, 0),
  53. } },
  54. createOverlayReadiness: () => ({ state: { ready: true, pending: 0, failed: 0, lastError: '' }, refresh() {}, detach() {} }),
  55. hasFogStage: () => false,
  56. syncPointMarkers(viewer, rows) { viewer.points = rows; return { rendered: rows.length, unchanged: 0, skipped: 0, complete: true } },
  57. syncAssociatedDevices(viewer, rows) { viewer.devices = rows; return { rendered: rows.length, skipped: 0 } },
  58. clearAssociatedDevices(viewer) { viewer.devices = [] }, clearPointMarkers(viewer) { viewer.points = [] },
  59. hasPointMarker: (viewer, kind, code) => viewer.points.some((row) => row.kind === kind && row.code === code),
  60. hasDeviceMarker: (viewer, code) => viewer.devices.some((row) => row.code === code),
  61. projectPointMarker: (viewer, kind, code) => gpu.hasPointMarker(viewer, kind, code) ? projection() : null,
  62. projectDeviceMarker: (viewer, code) => gpu.hasDeviceMarker(viewer, code) ? projection() : null,
  63. projectDeviceMarkers: (viewer) => viewer.devices.map((row) => ({ ...row, ...projection() })),
  64. projectOverlapSymbols: () => [], readPointMarkers: (viewer) => viewer.points,
  65. readAssociatedDevices: (viewer) => viewer.devices, readDeviceEffects: () => [], readOverlapTargets: () => [],
  66. highlightEntities: () => 1, syncPipeLayer: async () => ({ pipes: 0, components: 0, skipped: 0 }), pickPipeObjectAt: () => pipeHit,
  67. }
  68. for (const name of ['applyViewModeLayers', 'applyMotionResolution', 'applyDeviceEffects', 'applyDeviceReactions',
  69. 'clearDeviceLead', 'syncDeviceLead', 'setOverlapPresentation', 'clearOverlapNudge', 'noteFrameInterval', 'tickAdaptiveResolution']) gpu[name] = () => {}
  70. globalThis.__VIDEO_GPU__ = gpu
  71. return { frame, viewers, setVisible(value) { visible = value; frame() },
  72. pickPipe(code = 'PIPE-1') {
  73. pipeHit = { objectKind: 'pipe', code }
  74. for (const type of ['pointerdown', 'pointerup']) {
  75. const event = new Event(type, { bubbles: true })
  76. Object.assign(event, { button: 0, pointerId: 1, clientX: 800, clientY: 450 })
  77. viewers[0].canvas.dispatchEvent(event)
  78. }
  79. pipeHit = null
  80. } }
  81. }
  82. async function mountRoot({ positioned = false, underground = false } = {}) {
  83. const gpu = installGpu()
  84. globalThis.__VIDEO_THREE_OPTIONS__ = {
  85. rendererFactory: ({ canvas }) => ({ domElement: canvas, setPixelRatio() {}, setSize() {}, render() {}, dispose() {} }),
  86. iconResolver: async () => null, loadTexture: () => null,
  87. now: () => 1000, scheduleFrame: () => {},
  88. }
  89. const location = positioned ? { state: 'VALID', renderable: true, reasons: [] } : coordinate
  90. const selectedPoint = { ...point, pointCode: positioned ? 'P-FLOAT' : 'P-FIXED',
  91. longitude: positioned ? 110.4 : null, latitude: positioned ? 28.4 : null, coordinateValidation: location }
  92. const selectedCamera = { ...camera, longitude: selectedPoint.longitude, latitude: selectedPoint.latitude, coordinateValidation: location }
  93. const requests = [], unexpected = [], errors = []
  94. const server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true, liveFailures: 0,
  95. recordings: [], recordsFail: false, playbackBusy: false }
  96. const fetchImpl = async (address, options = {}) => {
  97. const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
  98. const body = options.body ? JSON.parse(options.body) : null
  99. requests.push({ url, path, body, options })
  100. if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
  101. assert.equal(new Headers(options.headers).get('authorization'), null)
  102. if (path === '/map-snapshot') {
  103. if (server.snapshotGate) await server.snapshotGate
  104. return envelope({ page: 1, pageSize: 100, snapshotAt: '2026-11-11T00:00:00Z',
  105. batchUpdatedAt: '2026-11-11T00:00:00Z', total: 1, complete: true,
  106. monitoringPoints: [{ ...selectedPoint, deviceCodes: server.cameraPresent ? [cameraCode] : [] }], unassociatedEquipment: [] })
  107. }
  108. if (path === '/monitoring/distribution') return envelope({ monitoringPoints: [selectedPoint], unassociatedEquipment: [],
  109. snapshotAt: '2026-11-11T00:00:00Z', pagination: { page: 1, pageSize: 100, total: 1, totalPages: 1, hasMore: false } })
  110. if (path === `/monitoring-points/${selectedPoint.pointCode}`) {
  111. if (server.pointGate) await server.pointGate
  112. if (server.pointFails) return envelope({}, 503)
  113. return envelope({ monitoringPointCode: selectedPoint.pointCode,
  114. monitoringPointName: point.pointName, pointStatus: 'NORMAL', coordinateValidation: location,
  115. deviceStatistics: { totalDeviceCount: server.cameraPresent ? 1 : 0, normalDeviceCount: server.cameraPresent ? 1 : 0 },
  116. devices: server.cameraPresent ? [selectedCamera] : [] })
  117. }
  118. if (path === '/infrastructure/geometry') return envelope({ type: 'FeatureCollection', features: underground ? [{
  119. type: 'Feature', id: 'PIPE-1', properties: { code: 'PIPE-1', name: '地下测试管段', industry: 'DRAINAGE',
  120. diameter: 300, material: '钢', coordinateValidation: { renderable: true } },
  121. geometry: { type: 'LineString', coordinates: [[110.3998, 28.4], [110.4008, 28.4]] },
  122. }] : [] })
  123. if (path === '/device-facility-relations') return envelope({ rows: [] })
  124. if (path.startsWith('/equipments/') && path.endsWith('/detail-aggregate')) return envelope({ ...aggregate, archive: { state: 'SUCCESS', data: selectedCamera } })
  125. if (path.startsWith('/equipments/') && path.endsWith('/collection-records')) return envelope({ deviceCode: cameraCode,
  126. state: 'EMPTY', recordView: { columns: [], rows: [] } })
  127. if (path.startsWith('/equipments/') && path.endsWith('/freshness')) return envelope({})
  128. if (emptyEndpoints.has(path)) return envelope({})
  129. if (path === '/video/device') { assert.equal(url.searchParams.get('deviceCode'), cameraCode); return envelope(device) }
  130. if (path === '/video/records') {
  131. assert.equal(url.searchParams.get('deviceCode'), cameraCode)
  132. if (server.recordsFail) return { ok: false, status: 502, json: async () => ({ code: 502, data: { category: 'VIDEO_RECORDS_FAILED', message: '录像查询失败,请稍后重试' } }) }
  133. const rows = server.recordings ?? []
  134. return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
  135. startTime: url.searchParams.get('startTime'), endTime: url.searchParams.get('endTime'),
  136. records: rows, empty: rows.length === 0 })
  137. }
  138. if (path === '/video/playback') {
  139. assert.equal(body.deviceCode, cameraCode)
  140. if (server.playbackBusy) return { ok: false, status: 409, json: async () => ({ code: 409, data: { category: 'PLAYBACK_OCCUPIED', message: '此通道已有录像回放,请结束后重试' } }) }
  141. return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
  142. sessionId: 'session-pb', sessionToken: 'owner-pb', mode: 'PLAYBACK',
  143. startTime: body.startTime, endTime: body.endTime,
  144. media: { native: true, url: 'https://media.invalid/playback.mp4' } })
  145. }
  146. if (path === '/video/sessions/prepare') {
  147. assert.deepEqual(body, { deviceCode: cameraCode })
  148. return envelope({ ...device, sessionId: 'session-host', sessionToken: 'owner-host', mode: 'LIVE', status: 'PREPARED' }, 201)
  149. }
  150. if (path === '/video/sessions/session-host/live') {
  151. assert.deepEqual(body, { sessionToken: 'owner-host' })
  152. // A controllable temporary failure lets the real journey exercise bounded reconnection.
  153. if (server.liveFailures > 0) {
  154. server.liveFailures -= 1
  155. return { ok: false, status: 503, json: async () => ({ code: 503, message: '视频暂时不可用' }) }
  156. }
  157. return envelope({ sessionId: 'session-host', mode: 'LIVE', status: 'ACTIVE',
  158. media: { native: true, url: 'https://media.invalid/live.mp4' } })
  159. }
  160. if (path === '/video/sessions/session-host/stop' || path === '/video/sessions/session-host/status') {
  161. assert.deepEqual(body, { sessionToken: 'owner-host' })
  162. return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'STOPPED' })
  163. }
  164. if (path === '/video/sessions/session-pb/stop' || path === '/video/sessions/session-pb/status') {
  165. assert.deepEqual(body, { sessionToken: 'owner-pb' })
  166. return envelope({ sessionId: 'session-pb', deviceCode: cameraCode, mode: 'PLAYBACK', status: 'STOPPED' })
  167. }
  168. unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
  169. }
  170. globalThis.fetch = fetchImpl
  171. globalThis.__COCKPIT_CONFIG__ = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' },
  172. map: { cesium: { enabled: positioned }, intro: { enabled: false }, fog: { enabled: false }, grade: { enabled: false }, handover: { enabled: false }, effects: { enabled: false } }, runtime: { polling: false }, ws: { url: '' } }
  173. resetCockpitConfigForTest()
  174. const { config } = await loadCockpitConfig()
  175. const pinia = createPinia(); setActivePinia(pinia)
  176. const video = useVideoStore(); video.configure({ config, fetchImpl })
  177. const host = document.createElement('main'); document.body.appendChild(host)
  178. const app = createApp(CockpitRoot); app.use(pinia)
  179. app.config.errorHandler = (error) => { errors.push(error) }
  180. app.mount(host); await settle()
  181. return { host, requests, unexpected, errors, video, gpu, selectedPoint, server, async dispose() {
  182. app.unmount(); await video.close(); host.remove(); delete globalThis.__COCKPIT_CONFIG__; delete globalThis.__VIDEO_THREE_OPTIONS__; await settle()
  183. globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
  184. assert.deepEqual(errors, [], '包含根卸载期间的组件错误')
  185. assert.deepEqual(unexpected, [], '包含卸载期间的未配置请求')
  186. } }
  187. }
  188. async function openMainDetail(page) {
  189. const mode = [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === '运行监测')
  190. assert.ok(mode); mode.click(); await settle()
  191. const ledger = page.host.querySelector(`[data-ledger-card="${page.selectedPoint.pointCode}"]`)
  192. assert.ok(ledger, '使用真实监测点台账卡片')
  193. ledger.click(); await settle()
  194. const card = page.host.querySelector('.cockpit-shell__detail [data-device-card][data-device-code="CAM/03 空格"]')
  195. assert.ok(card); card.click(); await settle(); page.gpu.frame(); await settle()
  196. const detail = page.host.querySelector('.cockpit-shell__detail')
  197. detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
  198. return detail
  199. }
  200. async function openMainMonitor(page) {
  201. const detail = await openMainDetail(page)
  202. detail.querySelector('[data-video-open]').click(); await settle()
  203. return document.querySelector('[data-video-monitor]')
  204. }
  205. // 真root→实际Shell固定态→既有点位设备卡→Latest TAB→监控;不直接调用视频store.open。
  206. for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地图位置固定'}:真实台账设备详情打开唯一根监控面板`, async () => {
  207. const page = await mountRoot({ positioned })
  208. try {
  209. const detail = await openMainDetail(page)
  210. assert.equal(detail.getAttribute('data-anchor-state'), positioned ? 'visible' : 'fixed')
  211. assert.equal(Boolean(detail.querySelector('[data-point-no-location]')), !positioned)
  212. detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
  213. const monitor = detail.querySelector('[data-video-open]')
  214. assert.ok(monitor); assert.equal(monitor.disabled, false); monitor.click(); await settle()
  215. const panels = document.querySelectorAll('[data-video-monitor]')
  216. assert.equal(panels.length, 1)
  217. const dialog = panels[0]
  218. assert.ok(dialog.textContent.includes(cameraCode))
  219. assert.ok(dialog.querySelector('video'), JSON.stringify({ text: dialog.textContent, unexpected: page.unexpected, errors: page.errors.map((error) => error.message) }))
  220. dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
  221. assert.ok(dialog.textContent.includes('正在播放'))
  222. dialog.querySelector('[aria-label="关闭监控"]').click(); await settle()
  223. assert.equal(document.querySelector('[data-video-monitor]'), null)
  224. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  225. assert.deepEqual(page.errors, [])
  226. assert.deepEqual(page.unexpected, [])
  227. } finally { await page.dispose() }
  228. })
  229. test('真实Root旅程中临时失败展示重试状态,关闭后不再自动重连', async () => {
  230. const page = await mountRoot()
  231. try {
  232. // Two temporary live failures: the journey must show a bounded retry and then recover by itself.
  233. page.server.liveFailures = 2
  234. await openMainMonitor(page)
  235. const status = () => document.querySelector('[data-video-monitor]')?.textContent || ''
  236. const liveCalls = () => page.requests.filter((request) => request.path.endsWith('/live')).length
  237. const waitFor = async (predicate, message, timeout = 15000) => {
  238. const deadline = Date.now() + timeout
  239. while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 100)) }
  240. assert.ok(predicate(), message)
  241. }
  242. await waitFor(() => liveCalls() >= 1, 'the first live attempt must run')
  243. // The retry state itself must be visible, not merely skipped over on the way to recovery.
  244. await waitFor(() => status().includes('自动重连'), 'the retry state must be observable')
  245. // Once the retried attempt produces media, the panel mounts a real video element again.
  246. await waitFor(() => Boolean(document.querySelector('[data-video-monitor] video')), 'the retried attempt must present media again')
  247. document.querySelector('[data-video-monitor] video').dispatchEvent(new Event('playing'))
  248. await settle()
  249. await waitFor(() => status().includes('正在播放'), 'the journey must recover without a manual click')
  250. assert.equal(liveCalls(), 3, 'exactly the two failed attempts plus the recovered one')
  251. // Each attempt owns its own prepared identity, and no two are ever in flight at once.
  252. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 3,
  253. 'every attempt prepares exactly one identity')
  254. assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1,
  255. 'a failed attempt releases its identity before the next one starts')
  256. // Closing during a later outage must cancel the pending attempt.
  257. page.server.liveFailures = 4
  258. document.querySelector('[data-video-monitor] video')?.dispatchEvent(new Event('error'))
  259. await new Promise((resolve) => setTimeout(resolve, 300))
  260. document.querySelector('[aria-label="关闭监控"]').click(); await settle()
  261. const settled = liveCalls()
  262. await new Promise((resolve) => setTimeout(resolve, 4000))
  263. assert.equal(liveCalls(), settled, 'no retry may fire after the monitor closed')
  264. assert.equal(document.querySelector('[data-video-monitor]'), null)
  265. assert.deepEqual(page.errors, [])
  266. assert.deepEqual(page.unexpected, [])
  267. } finally { await page.dispose() }
  268. })
  269. test('历史录像TAB不关闭面板,列片后才起流,占用不抢停别人', async () => {
  270. const page = await mountRoot()
  271. try {
  272. page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
  273. const detail = await openMainDetail(page)
  274. detail.querySelector('[data-video-open]').click(); await settle()
  275. const dialog = () => document.querySelector('[data-video-monitor]')
  276. assert.ok(dialog(), '监控面板应已打开')
  277. // Switching to the history tab must end the live stream but keep the monitor mounted.
  278. const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
  279. assert.equal(tabs.length, 2, '两个TAB都应存在')
  280. tabs[1].click(); await settle()
  281. assert.ok(dialog(), '切到历史TAB后面板不得消失')
  282. assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, 1, '应查询录像')
  283. // Listing alone must never start playback.
  284. assert.equal(page.requests.filter((request) => request.path.endsWith('/playback')).length, 0)
  285. assert.ok(dialog().textContent.includes('CAM/03'), dialog().textContent)
  286. // Choosing a recording starts the owned playback session.
  287. const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
  288. assert.ok(play); play.click(); await settle()
  289. assert.equal(page.requests.filter((request) => request.path.endsWith('/playback')).length, 1)
  290. assert.ok(dialog().querySelector('video[aria-label="历史录像画面"]'), '应展示回放画面')
  291. // Closing releases the playback session and never leaves the panel half-open.
  292. dialog().querySelector('[aria-label="关闭监控"]').click(); await settle()
  293. assert.equal(document.querySelector('[data-video-monitor]'), null)
  294. assert.ok(page.requests.some((request) => request.path.includes('session-pb') && request.path.endsWith('/stop')), '回放必须被释放')
  295. assert.deepEqual(page.errors, [])
  296. assert.deepEqual(page.unexpected, [])
  297. } finally { await page.dispose() }
  298. })
  299. test('历史回放占用时提示占用且不释放他人回放', async () => {
  300. const page = await mountRoot()
  301. try {
  302. page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
  303. page.server.playbackBusy = true
  304. const detail = await openMainDetail(page)
  305. detail.querySelector('[data-video-open]').click(); await settle()
  306. const dialog = () => document.querySelector('[data-video-monitor]')
  307. const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
  308. tabs[1].click(); await settle()
  309. const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
  310. play.click(); await settle()
  311. // The occupancy must surface as its own message, not a generic failure.
  312. assert.ok(dialog().textContent.includes('已有录像回放'), dialog().textContent)
  313. assert.equal(page.requests.filter((request) => request.path.includes('session-pb')).length, 0,
  314. '占用时不得停止或触碰他人的回放会话')
  315. assert.deepEqual(page.errors, [])
  316. assert.deepEqual(page.unexpected, [])
  317. } finally { await page.dispose() }
  318. })
  319. test('历史回放控制未确认时全部灰色且带原因,不发任何控制请求', async () => {
  320. const page = await mountRoot()
  321. try {
  322. page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
  323. const detail = await openMainDetail(page)
  324. detail.querySelector('[data-video-open]').click(); await settle()
  325. const dialog = () => document.querySelector('[data-video-monitor]')
  326. const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
  327. tabs[1].click(); await settle()
  328. const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
  329. play.click(); await settle()
  330. // Unconfirmed controls must be present but grey, each with a reason, not silently absent.
  331. const controls = [...dialog().querySelectorAll('[data-playback-control]')]
  332. assert.ok(controls.length >= 6, 'pause/resume and 0.25/0.5/1/2/4x plus seek must all be rendered')
  333. for (const control of controls) {
  334. assert.equal(control.disabled, true, `${control.dataset.playbackControl} 未确认时必须灰色`)
  335. assert.ok((control.getAttribute('title') || '').trim().length > 0, `${control.dataset.playbackControl} 必须说明为何不可用`)
  336. }
  337. // A grey control must never send a request even if activated programmatically.
  338. for (const control of controls) control.click()
  339. await settle()
  340. assert.equal(page.requests.filter((request) => request.path.includes('/control')).length, 0,
  341. '灰色控件不得发出任何控制请求')
  342. assert.deepEqual(page.errors, [])
  343. assert.deepEqual(page.unexpected, [])
  344. } finally { await page.dispose() }
  345. })
  346. test('历史页区分无录像、查询失败与非法时间范围三种结果', async () => {
  347. const page = await mountRoot()
  348. try {
  349. const detail = await openMainDetail(page)
  350. detail.querySelector('[data-video-open]').click(); await settle()
  351. const dialog = () => document.querySelector('[data-video-monitor]')
  352. const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
  353. page.server.recordings = []
  354. tabs[1].click(); await settle()
  355. // No recordings is a legitimate answer that must not look like a failure.
  356. assert.ok(dialog().textContent.includes('没有录像'), dialog().textContent)
  357. // A failed query is a different visible state and is never auto-retried.
  358. page.server.recordsFail = true
  359. const queryButton = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '查询录像')
  360. queryButton.click(); await settle()
  361. assert.ok(dialog().textContent.includes('查询失败'), dialog().textContent)
  362. const after = page.requests.filter((request) => request.path.endsWith('/records')).length
  363. await new Promise((resolve) => setTimeout(resolve, 2500))
  364. assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, after,
  365. '查询失败不得自动重试')
  366. assert.deepEqual(page.errors, [])
  367. assert.deepEqual(page.unexpected, [])
  368. } finally { await page.dispose() }
  369. })
  370. test('历史页非法时间范围在本地拒绝且不发查询请求', async () => {
  371. const page = await mountRoot()
  372. try {
  373. const detail = await openMainDetail(page)
  374. detail.querySelector('[data-video-open]').click(); await settle()
  375. const dialog = () => document.querySelector('[data-video-monitor]')
  376. const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
  377. page.server.recordings = []
  378. tabs[1].click(); await settle()
  379. const before = page.requests.filter((request) => request.path.endsWith('/records')).length
  380. const inputs = [...dialog().querySelectorAll('.video-monitor__range input')]
  381. assert.equal(inputs.length, 2)
  382. // A 7-day+ span is refused before any request is made.
  383. inputs[0].value = '2026-11-01 00:00:00'; inputs[0].dispatchEvent(new Event('input'))
  384. inputs[1].value = '2026-11-10 00:00:00'; inputs[1].dispatchEvent(new Event('input'))
  385. await settle()
  386. const queryButton = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '查询录像')
  387. queryButton.click(); await settle()
  388. assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, before,
  389. '超 7 天的范围不得发到服务端')
  390. assert.ok(dialog().textContent.includes('7 天'), dialog().textContent)
  391. assert.deepEqual(page.errors, [])
  392. assert.deepEqual(page.unexpected, [])
  393. } finally { await page.dispose() }
  394. })
  395. test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
  396. const page = await mountRoot()
  397. try {
  398. const detail = await openMainDetail(page)
  399. const monitor = detail.querySelector('[data-video-open]')
  400. detail.querySelector('[data-detail-close]').click()
  401. monitor.click(); await settle()
  402. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
  403. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 0, '失效来源不能先prepare再补关')
  404. assert.deepEqual(page.errors, [])
  405. assert.deepEqual(page.unexpected, [])
  406. } finally { await page.dispose() }
  407. })
  408. test('一级模式同轮离开再返回仍释放旧观看,返回须手动恢复', async () => {
  409. for (const underground of [false, true]) {
  410. const page = await mountRoot({ positioned: underground, underground })
  411. try {
  412. const mode = (text) => [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === text)
  413. mode('运行监测').click(); await settle()
  414. if (underground) await openUnderground(page)
  415. else await openMainMonitor(page)
  416. mode('总体概览').click()
  417. mode('运行监测').click()
  418. await settle()
  419. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, '切模式必须释放旧观看,即使同轮返回')
  420. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  421. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  422. assert.deepEqual(page.errors, [])
  423. assert.deepEqual(page.unexpected, [])
  424. } finally { await page.dispose() }
  425. }
  426. })
  427. // 真Root/Shell:地图物理拾取→实际管线计划→地下设备列表点击→Latest TAB→唯一根面板。
  428. async function openUnderground(page, { monitor: open = true } = {}) {
  429. await until(() => page.gpu.viewers.length && page.host.querySelector('[data-pipe-legend-count="1"]'), '等待真实管线几何进入图例后再点选')
  430. page.gpu.pickPipe(); await settle()
  431. const underground = page.host.querySelector('[data-pipe-enhancement]')
  432. assert.ok(underground, JSON.stringify({ unexpected: page.unexpected, errors: page.errors.map((e) => e.message) }))
  433. assert.ok(underground.querySelector('[data-pipe-enhancement-viewport] canvas'), '真实 mountPipeEnhancement 挂载物理渲染替身')
  434. assert.equal(underground.querySelector('[data-equipment-tab="metrics"]'), null, '必须先从地下列表选择设备')
  435. const card = underground.querySelector('[data-device-list] [data-device-code="CAM/03 空格"]')
  436. assert.ok(card, underground.textContent)
  437. card.click(); await settle()
  438. const latest = underground.querySelector('[data-equipment-tab="metrics"]')
  439. assert.ok(latest, underground.textContent)
  440. latest.click(); await settle()
  441. const monitor = underground.querySelector('[data-video-open]')
  442. assert.ok(monitor); assert.equal(monitor.disabled, false)
  443. if (!open) return monitor
  444. monitor.click(); await settle()
  445. return document.querySelector('[data-video-monitor]')
  446. }
  447. test('三宿主同设备刷新保持同一媒体;浮动锚点暂隐不释放观看', async () => {
  448. for (const host of ['fixed', 'floating', 'underground']) {
  449. const page = await mountRoot({ positioned: host !== 'fixed', underground: host === 'underground' })
  450. try {
  451. const dialog = host === 'underground' ? await openUnderground(page) : await openMainMonitor(page)
  452. const media = dialog.querySelector('video')
  453. media.dispatchEvent(new Event('playing')); await settle()
  454. let releaseSnapshot, releasePoint
  455. page.server.snapshotGate = new Promise((resolve) => { releaseSnapshot = resolve })
  456. page.server.pointGate = new Promise((resolve) => { releasePoint = resolve })
  457. const before = page.requests.filter((request) => request.path === '/map-snapshot').length
  458. const beforeDetails = page.requests.filter((request) => request.path.endsWith('/detail-aggregate')).length
  459. page.host.querySelector('[data-map-refresh]').click(); await settle()
  460. assert.equal(page.requests.filter((request) => request.path === '/map-snapshot').length, before + 1)
  461. assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 摘要刷新等待期保留媒体`)
  462. releaseSnapshot(); page.server.snapshotGate = null; await settle()
  463. assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 设备清单等待期保留媒体`)
  464. releasePoint(); page.server.pointGate = null; await settle()
  465. await until(() => page.requests.filter((request) => request.path.endsWith('/detail-aggregate')).length > beforeDetails,
  466. `${host}: 确认真实详情重新取数,而非只等待空刷新`)
  467. assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 新数据不能重挂播放器`)
  468. if (host === 'floating') {
  469. page.gpu.setVisible(false); await settle()
  470. assert.notEqual(page.host.querySelector('.cockpit-shell__detail').getAttribute('data-anchor-state'), 'visible')
  471. assert.ok(document.querySelector('[data-video-monitor] video') === media)
  472. page.gpu.setVisible(true); await settle()
  473. assert.equal(page.host.querySelector('.cockpit-shell__detail').getAttribute('data-anchor-state'), 'visible')
  474. }
  475. assert.ok(dialog.textContent.includes('正在播放'))
  476. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  477. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
  478. assert.deepEqual(page.errors, [])
  479. assert.deepEqual(page.unexpected, [])
  480. } finally { await page.dispose() }
  481. }
  482. })
  483. test('同码合法换来源复用媒体,非所属详情关闭不误停', async () => {
  484. const page = await mountRoot({ positioned: true, underground: true })
  485. try {
  486. const dialog = await openMainMonitor(page)
  487. const media = dialog.querySelector('video')
  488. await openUnderground(page)
  489. assert.ok(document.querySelector('[data-video-monitor] video') === media)
  490. page.host.querySelector('.cockpit-shell__detail [data-detail-close]').click(); await settle()
  491. assert.ok(document.querySelector('[data-video-monitor] video') === media, '地下接管后关闭主详情不误停')
  492. await openMainMonitor(page)
  493. assert.ok(document.querySelector('[data-video-monitor] video') === media)
  494. page.host.querySelector('[data-pipe-enhancement-close]').click(); await settle()
  495. assert.ok(document.querySelector('[data-video-monitor] video') === media, '主详情接管后关闭地下不误停')
  496. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  497. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
  498. page.host.querySelector('.cockpit-shell__detail [data-detail-close]').click(); await settle()
  499. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
  500. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  501. assert.deepEqual(page.errors, [])
  502. assert.deepEqual(page.unexpected, [])
  503. } finally { await page.dispose() }
  504. })
  505. test('地下刷新失败保留观看,完整快照确认设备移除才关闭且返回不自动重开', async () => {
  506. const page = await mountRoot({ positioned: true, underground: true })
  507. try {
  508. const dialog = await openUnderground(page)
  509. const media = dialog.querySelector('video')
  510. page.server.pointFails = true
  511. page.host.querySelector('[data-map-refresh]').click(); await settle()
  512. assert.ok(document.querySelector('[data-video-monitor] video') === media, '失败不证明设备失效')
  513. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
  514. page.server.pointFails = false
  515. page.server.cameraPresent = false
  516. page.host.querySelector('[data-map-refresh]').click(); await settle()
  517. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, '完整数据确认移除需释放观看')
  518. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  519. page.server.cameraPresent = true
  520. page.host.querySelector('[data-map-refresh]').click(); await settle()
  521. assert.ok(page.host.querySelector('[data-pipe-enhancement] [data-device-code="CAM/03 空格"]'))
  522. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
  523. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  524. assert.deepEqual(page.errors, [])
  525. assert.deepEqual(page.unexpected, [])
  526. } finally { await page.dispose() }
  527. })
  528. test('失效的地下同码入口不能抢走主详情正在观看的资源', async () => {
  529. const page = await mountRoot({ positioned: true, underground: true })
  530. try {
  531. const dialog = await openMainMonitor(page)
  532. const media = dialog.querySelector('video')
  533. const staleEntry = await openUnderground(page, { monitor: false })
  534. page.host.querySelector('[data-pipe-enhancement-close]').click()
  535. staleEntry.click(); await settle()
  536. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), true, '失效来源不应关闭合法来源的观看')
  537. assert.equal(document.querySelector('[data-video-monitor] video'), media)
  538. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  539. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
  540. assert.deepEqual(page.errors, [])
  541. assert.deepEqual(page.unexpected, [])
  542. } finally { await page.dispose() }
  543. })
  544. test('地下管线详情:真实Root列表点设备经Latest TAB打开唯一监控面板', async () => {
  545. const page = await mountRoot({ positioned: true, underground: true })
  546. try {
  547. const dialog = await openUnderground(page)
  548. assert.equal(document.querySelectorAll('[data-video-monitor]').length, 1)
  549. assert.ok(dialog.textContent.includes(cameraCode))
  550. assert.ok(dialog.querySelector('video'))
  551. dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
  552. assert.ok(dialog.textContent.includes('正在播放'))
  553. page.host.querySelector('[data-pipe-enhancement-close]').click(); await settle()
  554. assert.equal(document.querySelector('[data-video-monitor]'), null)
  555. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  556. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  557. assert.deepEqual(page.unexpected, [])
  558. assert.deepEqual(page.errors, [])
  559. } finally { await page.dispose() }
  560. })