journey.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { createApp, h, nextTick } from 'vue'
  4. import { createPinia, setActivePinia } from 'pinia'
  5. import PointDetailCard from '../../src/modules/cockpit/components/PointDetailCard.vue'
  6. import VideoMonitorEntry from '../../src/modules/cockpit/components/VideoMonitorEntry.vue'
  7. import VideoMonitorPanel from '../../src/modules/cockpit/components/VideoMonitorPanel.vue'
  8. import { useDetailStore } from '../../src/modules/cockpit/stores/detailStore.js'
  9. import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js'
  10. import { useEnhancementDevicesStore } from '../../src/modules/cockpit/stores/enhancementDevicesStore.js'
  11. const code = 'CAM/01 空格'
  12. const device = { deviceCode: code, deviceName: '现场摄像机A', typeCode: '20', bound: true,
  13. capabilities: { live: true, records: false, playback: false, playbackControl: false, ptz: false, ptzStopGuaranteed: false }, unavailableReason: null }
  14. const envelope = (data, status = 200) => Response.json({ code: status, msg: status === 200 ? '操作成功' : '失败', data }, { status })
  15. const aggregate = { deviceCode: code, aggregateState: 'READY',
  16. archive: { state: 'SUCCESS', data: { deviceCode: code, deviceName: '现场摄像机A', deviceType: '20' } },
  17. metrics: { state: 'EMPTY', data: null }, warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] },
  18. workOrders: { state: 'EMPTY', data: null }, timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null } }
  19. async function settle() { for (let i = 0; i < 4; i++) { await new Promise(setImmediate); await nextTick() } }
  20. async function mountJourney(overrides = {}) {
  21. const requests = []
  22. const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1', token: 'must-not-send' } }
  23. const fetchImpl = async (url, options = {}) => {
  24. const path = new URL(url).pathname
  25. const body = options.body ? JSON.parse(options.body) : null
  26. requests.push({ url: new URL(url), path, options, body })
  27. assert.equal(options.headers?.Authorization, undefined)
  28. if (path.endsWith('/detail-aggregate')) return envelope(aggregate)
  29. if (path.endsWith('/collection-records')) return overrides.records?.() || envelope({ deviceCode: code, state: 'EMPTY', recordView: { columns: [], rows: [] } })
  30. if (path.endsWith('/video/device')) {
  31. assert.equal(new URL(url).searchParams.get('deviceCode'), code)
  32. return envelope(overrides.device || device)
  33. }
  34. if (path.endsWith('/video/sessions/prepare')) {
  35. assert.deepEqual(body, { deviceCode: code })
  36. return overrides.prepare?.() || new Response(JSON.stringify({ code: 200, data: {
  37. deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', sessionToken: 'owner-a', mode: 'LIVE', status: 'PREPARED',
  38. } }), { status: 201, headers: { 'Content-Type': 'application/json' } })
  39. }
  40. if (path.endsWith('/video/sessions/session-a/live')) {
  41. assert.deepEqual(body, { sessionToken: 'owner-a' })
  42. return overrides.live?.() || envelope(livePayload({ native: true, url: 'https://media.invalid/live.mp4' }))
  43. }
  44. if (path.endsWith('/status')) return overrides.status?.() || envelope({ sessionId: 'session-a', deviceCode: code, mode: 'LIVE', status: 'STOPPED' })
  45. if (path.endsWith('/heartbeat')) return overrides.heartbeat?.() || envelope({ sessionId: 'session-a', status: 'ACTIVE' })
  46. if (path.endsWith('/stop')) return overrides.stop?.() || envelope({ sessionId: 'session-a', status: 'STOPPED' })
  47. throw new Error('Unexpected fixture request: ' + path)
  48. }
  49. const pinia = createPinia(); setActivePinia(pinia)
  50. const detail = useDetailStore(); detail.configure({ config, fetchImpl })
  51. const video = useVideoStore(); video.configure({ config, fetchImpl, ...(overrides.sessionOptions || {}) })
  52. await detail.openDevice(code)
  53. const host = document.createElement('main'); document.body.appendChild(host)
  54. const app = createApp({ render: () => h('div', [overrides.entryOnly
  55. ? h(VideoMonitorEntry, { deviceCode: code, monitorSource: overrides.monitorSource || 'enhancement' })
  56. : h(PointDetailCard), h(VideoMonitorPanel)]) })
  57. app.use(pinia).mount(host)
  58. await settle()
  59. return { host, requests, video, detail, config, fetchImpl, async dispose() { app.unmount(); await video.close(); host.remove() } }
  60. }
  61. const livePayload = (media) => ({ deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', mode: 'LIVE', status: 'ACTIVE', media })
  62. async function openMonitor(page) {
  63. page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
  64. page.host.querySelector('[data-video-open]').click(); await settle()
  65. return document.querySelector('[data-video-monitor]')
  66. }
  67. test('closing the originating main detail releases video even if another host retains the same device code', async () => {
  68. const page = await mountJourney()
  69. try {
  70. await useEnhancementDevicesStore().open({ code, name: device.deviceName }, {
  71. fetchDetail: async () => ({ state: 'success', data: aggregate }),
  72. })
  73. await openMonitor(page)
  74. page.host.querySelector('[data-detail-close]').click(); await settle()
  75. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
  76. const stops = page.requests.filter((request) => request.path.endsWith('/stop'))
  77. assert.equal(stops.length, 1)
  78. assert.deepEqual(stops[0].body, { sessionToken: 'owner-a' })
  79. } finally { await page.dispose() }
  80. })
  81. test('selection invalidation and same-code restoration in one turn still ends the old viewing session', async () => {
  82. const page = await mountJourney()
  83. try {
  84. await openMonitor(page)
  85. page.detail.closeDetail()
  86. const restored = page.detail.openDevice(code)
  87. await restored; await settle()
  88. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, 'restoring selection must not resurrect its previous viewer')
  89. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  90. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  91. } finally { await page.dispose() }
  92. })
  93. test('the underground host owns its own viewing source and a main-detail close does not stop it', async () => {
  94. const page = await mountJourney({ entryOnly: true })
  95. try {
  96. await useEnhancementDevicesStore().open({ code, name: device.deviceName }, {
  97. fetchDetail: async () => ({ state: 'success', data: aggregate }),
  98. })
  99. page.host.querySelector('[data-video-open]').click(); await settle()
  100. assert.equal(page.video.state.ownerSource, 'enhancement')
  101. assert.ok(document.querySelector('[data-video-monitor]'))
  102. await page.detail.closeDetail(); await settle()
  103. assert.ok(document.querySelector('[data-video-monitor]'), 'main detail close must not stop the enhancement-owned video')
  104. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
  105. document.querySelector('[aria-label="关闭监控"]').click(); await settle()
  106. assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
  107. } finally { await page.dispose() }
  108. })
  109. test('a successful HTTP envelope for another session cannot confirm cleanup of the current viewer', async () => {
  110. let wrong = true
  111. const page = await mountJourney({ stop: () => envelope({ sessionId: wrong ? 'someone-else' : 'session-a', status: 'STOPPED' }) })
  112. try {
  113. await openMonitor(page)
  114. document.querySelector('[aria-label="关闭监控"]').click(); await settle()
  115. assert.ok(page.host.querySelector('[data-video-entry]').textContent.includes('会话释放未确认'))
  116. assert.equal(page.host.querySelector('[data-video-open]').disabled, true)
  117. } finally { wrong = false; await page.dispose() }
  118. })
  119. class TestClock {
  120. now = 0; next = 1; jobs = new Map()
  121. setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
  122. clearTimeout(id) { this.jobs.delete(id) }
  123. setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
  124. clearInterval(id) { this.clearTimeout(id) }
  125. async advance(duration) {
  126. const end = this.now + duration
  127. while (true) {
  128. const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
  129. if (!due) break
  130. const [id, job] = due; this.now = job.at
  131. if (job.interval) job.at += job.interval; else this.jobs.delete(id)
  132. job.fn(); await settle()
  133. }
  134. this.now = end; await settle()
  135. }
  136. }
  137. test('watching heartbeat preserves its session ownership and failure releases the viewer', async () => {
  138. const clock = new TestClock(); let wrong = false
  139. const page = await mountJourney({ sessionOptions: { timers: clock }, heartbeat: () => envelope({ sessionId: wrong ? 'other' : 'session-a', status: 'ACTIVE' }) })
  140. try {
  141. const dialog = await openMonitor(page)
  142. dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
  143. await clock.advance(30000)
  144. const beat = page.requests.find((request) => request.path.endsWith('/heartbeat'))
  145. assert.deepEqual(beat.body, { sessionToken: 'owner-a' })
  146. wrong = true
  147. await clock.advance(30000)
  148. // Ticket 04: a dropped session is temporary, so the bounded retry runs before the terminal reason.
  149. assert.ok(dialog.textContent.includes('自动重连'), dialog.textContent)
  150. await clock.advance(400000)
  151. // After the budget is spent the panel must show a terminal failure and drop the media.
  152. assert.ok(dialog.textContent.includes('播放失败') || dialog.textContent.includes('播放会话已断开'), dialog.textContent)
  153. assert.equal(dialog.querySelector('video'), null)
  154. assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1)
  155. } finally { await page.dispose() }
  156. })
  157. test('Escape closes the global modal and releases its viewing resource', async () => {
  158. const page = await mountJourney()
  159. try {
  160. await openMonitor(page)
  161. const key = new Event('keydown', { bubbles: true, cancelable: true }); key.key = 'Escape'
  162. document.dispatchEvent(key); await settle()
  163. assert.equal(document.querySelector('[data-video-monitor]'), null)
  164. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  165. } finally { await page.dispose() }
  166. })
  167. test('collection-record failure does not remove the latest-tab monitoring entry', async () => {
  168. const page = await mountJourney({ records: () => envelope({}, 503) })
  169. try {
  170. const dialog = await openMonitor(page)
  171. assert.ok(dialog.querySelector('video'))
  172. assert.ok(page.host.textContent.includes('其它分区不受影响'))
  173. } finally { await page.dispose() }
  174. })
  175. test('non-camera has no monitor button; unbound and unknown live capability stay disabled without starting', async () => {
  176. for (const camera of [
  177. { ...device, typeCode: '15', bound: false, capabilities: { live: false } },
  178. { ...device, bound: false, capabilities: { live: false }, unavailableReason: '尚未绑定视频' },
  179. { ...device, bound: true, capabilities: { live: false }, unavailableReason: '实时能力未确认' },
  180. ]) {
  181. const page = await mountJourney({ device: camera })
  182. try {
  183. page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
  184. const button = page.host.querySelector('[data-video-open]')
  185. if (camera.typeCode !== '20') assert.equal(button, null)
  186. else { assert.equal(button.disabled, true); assert.ok(page.host.textContent.includes(camera.unavailableReason)) }
  187. assert.equal(page.requests.some((request) => request.path.endsWith('/live')), false)
  188. } finally { await page.dispose() }
  189. }
  190. })
  191. test('unsupported returned media releases its owned session and never presents playing', async () => {
  192. const clock = new TestClock()
  193. const page = await mountJourney({ sessionOptions: { timers: clock }, live: () => envelope(livePayload({ native: false, flv: 'https://media.invalid/live.flv' })) })
  194. try {
  195. const dialog = await openMonitor(page)
  196. assert.equal(dialog.querySelector('video'), null)
  197. // Unsupported media is a capability outcome, never a temporary outage: no retry is spent.
  198. assert.ok(dialog.textContent.includes('不支持'), dialog.textContent)
  199. assert.equal(dialog.textContent.includes('正在播放'), false)
  200. assert.equal(page.requests.filter((request) => request.path.endsWith('/live')).length, 1, 'an unsupported payload must not be retried')
  201. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  202. } finally { await page.dispose() }
  203. })
  204. test('HTTP 200 HTML live response is a visible failure and releases the prepared viewing resource', async () => {
  205. const page = await mountJourney({ live: () => new Response('<html>wrong route</html>', { headers: { 'Content-Type': 'text/html' } }) })
  206. try {
  207. const dialog = await openMonitor(page)
  208. assert.equal(dialog.querySelector('video'), null)
  209. assert.ok(dialog.textContent.includes('无效数据'))
  210. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  211. assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
  212. } finally { await page.dispose() }
  213. })
  214. test('a live start exceeding 35 seconds exits visibly and releases a later owned response without opening video', async () => {
  215. const clock = new TestClock(); let respond
  216. const delayed = new Promise((resolve) => { respond = resolve })
  217. const page = await mountJourney({ live: () => delayed, sessionOptions: { timers: clock } })
  218. try {
  219. const dialog = await openMonitor(page)
  220. await clock.advance(35000)
  221. assert.ok(dialog.textContent.includes('自动重连'), dialog.textContent)
  222. await clock.advance(400000)
  223. assert.ok(dialog.textContent.includes('视频建立超时'), dialog.textContent)
  224. assert.equal(dialog.querySelector('video'), null)
  225. respond(envelope(livePayload({ native: true, url: 'https://media.invalid/live.mp4' })))
  226. await settle()
  227. assert.equal(dialog.querySelector('video'), null)
  228. assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1)
  229. assert.ok(page.requests.filter((request) => request.path.endsWith('/live')).length >= 1)
  230. } finally { await page.dispose() }
  231. })
  232. test('player readiness has a bound independent of HTTP success and cleans its owned session', async () => {
  233. const clock = new TestClock()
  234. const page = await mountJourney({ sessionOptions: { timers: clock } })
  235. try {
  236. const dialog = await openMonitor(page)
  237. assert.ok(dialog.textContent.includes('等待视频画面'))
  238. await clock.advance(20000)
  239. // Ticket 04: an unreachable player is a temporary failure, so the retry state shows first.
  240. assert.ok(dialog.textContent.includes('自动重连'), dialog.textContent)
  241. assert.equal(dialog.querySelector('video'), null)
  242. // Exhausting the bounded budget is what finally surfaces the terminal reason.
  243. await clock.advance(400000)
  244. assert.ok(dialog.textContent.includes('播放器就绪超时'), dialog.textContent)
  245. assert.equal(dialog.querySelector('video'), null)
  246. assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1)
  247. } finally { await page.dispose() }
  248. })
  249. test('hiding the page closes video and does not reopen it when visible again', async () => {
  250. const page = await mountJourney()
  251. try {
  252. await openMonitor(page)
  253. Object.defineProperty(document, 'hidden', { configurable: true, value: true })
  254. document.dispatchEvent(new Event('visibilitychange')); await settle()
  255. assert.equal(document.querySelector('[data-video-monitor]'), null)
  256. Object.defineProperty(document, 'hidden', { configurable: true, value: false })
  257. document.dispatchEvent(new Event('visibilitychange')); await settle()
  258. assert.equal(page.requests.filter((request) => request.path.endsWith('/live')).length, 1)
  259. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  260. } finally { delete document.hidden; await page.dispose() }
  261. })
  262. test('latest-tab entry drives WebRTC signaling and only a video playing event establishes visible playback', async () => {
  263. const peers = [], signaling = []
  264. class Emitter {
  265. handlers = new Map()
  266. addEventListener(type, fn) { const group = this.handlers.get(type) || new Set(); group.add(fn); this.handlers.set(type, group) }
  267. removeEventListener(type, fn) { this.handlers.get(type)?.delete(fn) }
  268. emit(type, event = {}) { for (const fn of this.handlers.get(type) || []) fn(event) }
  269. }
  270. const track = new Emitter(); track.kind = 'video'; track.readyState = 'live'; track.stop = () => { track.readyState = 'ended' }
  271. class Peer extends Emitter {
  272. iceGatheringState = 'complete'; connectionState = 'new'; closed = false
  273. constructor(options) { super(); assert.deepEqual(options, { iceServers: [] }); peers.push(this) }
  274. addTransceiver() {}
  275. async createOffer() { return { type: 'offer', sdp: 'v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n' } }
  276. async setLocalDescription(offer) { this.localDescription = offer }
  277. async setRemoteDescription(answer) { this.remoteDescription = answer }
  278. getReceivers() { return [{ track }] }
  279. close() { this.closed = true }
  280. }
  281. const page = await mountJourney({
  282. live: () => envelope(livePayload({ native: false, webrtc: 'http://media.invalid/index/api/webrtc?app=rtp&stream=cam&type=play' })),
  283. sessionOptions: { playerOptions: { PeerConnection: Peer, fetchImpl: async (url, options) => {
  284. signaling.push({ url, options }); assert.equal(options.credentials, 'omit')
  285. return Response.json({ code: 0, type: 'answer', sdp: 'v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n' })
  286. } } },
  287. })
  288. try {
  289. const dialog = await openMonitor(page)
  290. assert.equal(signaling.length, 1)
  291. assert.ok(signaling[0].options.body.startsWith('v=0'))
  292. assert.equal(dialog.textContent.includes('正在播放'), false)
  293. const stream = { getTracks: () => [track], addTrack() {} }
  294. peers[0].emit('track', { track, streams: [stream] })
  295. dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
  296. assert.ok(dialog.textContent.includes('正在播放'))
  297. dialog.querySelector('[aria-label="关闭监控"]').click(); await settle()
  298. assert.equal(peers[0].closed, true)
  299. assert.equal(track.readyState, 'ended')
  300. assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
  301. } finally { await page.dispose() }
  302. })
  303. test('device detail latest tab opens its own anonymous live video even with no collection records, then closes it', async () => {
  304. const page = await mountJourney()
  305. try {
  306. const latest = page.host.querySelector('[data-equipment-tab="metrics"]')
  307. assert.ok(latest, 'existing device-detail latest tab must be visible')
  308. latest.click(); await settle()
  309. const open = page.host.querySelector('[data-video-open]')
  310. assert.ok(open, 'the monitor entry must be inside the latest tab')
  311. assert.equal(open.disabled, false)
  312. open.click(); await settle()
  313. const dialog = document.querySelector('[role="dialog"][data-video-monitor]')
  314. assert.ok(dialog)
  315. assert.ok(dialog.textContent.includes('现场摄像机A'))
  316. assert.ok(dialog.textContent.includes(code))
  317. assert.equal(dialog.textContent.includes('owner-a'), false)
  318. dialog.querySelector('video').dispatchEvent(new Event('playing'))
  319. await settle()
  320. assert.ok(dialog.textContent.includes('正在播放'))
  321. dialog.querySelector('[aria-label="关闭监控"]').click(); await settle()
  322. assert.equal(document.querySelector('[data-video-monitor]'), null)
  323. const stops = page.requests.filter((request) => request.path.endsWith('/stop'))
  324. assert.equal(stops.length, 1)
  325. assert.deepEqual(stops[0].body, { sessionToken: 'owner-a' })
  326. } finally { await page.dispose() }
  327. })
  328. test('a failed cleanup is visible at the entry and blocks another live request until cleanup succeeds', async () => {
  329. let cleanupFails = true
  330. const page = await mountJourney({ stop: () => cleanupFails
  331. ? envelope({ category: 'CLEANUP_UNCONFIRMED' }, 503)
  332. : envelope({ sessionId: 'session-a', status: 'STOPPED' }) })
  333. try {
  334. page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
  335. page.host.querySelector('[data-video-open]').click(); await settle()
  336. document.querySelector('[aria-label="关闭监控"]').click(); await settle()
  337. const entry = page.host.querySelector('[data-video-entry]')
  338. assert.ok(entry.textContent.includes('会话释放未确认'))
  339. assert.equal(entry.querySelector('[data-video-open]').disabled, true)
  340. assert.equal(page.requests.filter((request) => request.path.endsWith('/live')).length, 1)
  341. cleanupFails = false
  342. Array.from(entry.querySelectorAll('button')).find((button) => button.textContent === '重试清理').click(); await settle()
  343. assert.equal(page.host.querySelector('[data-video-open]').disabled, false)
  344. } finally { cleanupFails = false; await page.dispose() }
  345. })