playback.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import test from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { createVideoPlaybackSession, defaultHistoryWindow } from '../../src/modules/cockpit/services/videoPlaybackSession.js'
  4. const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` })
  5. const started = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`,
  6. mode: 'PLAYBACK', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', media: media(code) })
  7. const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
  8. class Clock {
  9. now = 0; next = 1; jobs = new Map()
  10. setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
  11. clearTimeout(id) { this.jobs.delete(id) }
  12. setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
  13. clearInterval(id) { this.clearTimeout(id) }
  14. async advance(duration) {
  15. const end = this.now + duration
  16. while (true) {
  17. const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
  18. if (!due) break
  19. const [id, job] = due; this.now = job.at
  20. if (job.interval) job.at += job.interval; else this.jobs.delete(id)
  21. job.fn(); await new Promise(setImmediate)
  22. }
  23. this.now = end; await new Promise(setImmediate)
  24. }
  25. }
  26. /** UTC+8 wall clock, independent of the machine zone. */
  27. test('the default history window is today 00:00 to now in UTC+8', async () => {
  28. const at = Date.parse('2026-11-10T03:30:00Z') // 11:30 in UTC+8
  29. const window = defaultHistoryWindow(() => at)
  30. assert.deepEqual(window, { startTime: '2026-11-10 00:00:00', endTime: '2026-11-10 11:30:00' })
  31. })
  32. /** The 7-day ceiling is a query bound, not a retention promise. */
  33. test('a window longer than seven days is refused before any request', async () => {
  34. const asked = []
  35. let visible = {}
  36. const playback = createVideoPlaybackSession({
  37. records: async (...args) => { asked.push(args); return { records: [] } },
  38. playback: async () => started('A'), stop: async (session) => stopped(session),
  39. }, (patch) => { visible = { ...visible, ...patch } })
  40. try {
  41. // Refused locally and surfaced as an error state, never sent to the server.
  42. assert.deepEqual(await playback.query('A', '2026-11-01 00:00:00', '2026-11-10 00:00:00'), [])
  43. assert.deepEqual(asked, [], 'an illegal window must never reach the server')
  44. assert.equal(visible.phase, 'failed')
  45. assert.ok(visible.error.includes('7 天'), visible.error)
  46. } finally { await playback.dispose() }
  47. })
  48. test('an inverted or malformed window is refused locally', async () => {
  49. const asked = []
  50. let visible = {}
  51. const playback = createVideoPlaybackSession({
  52. records: async (...args) => { asked.push(args); return { records: [] } },
  53. playback: async () => started('A'), stop: async (session) => stopped(session),
  54. }, (patch) => { visible = { ...visible, ...patch } })
  55. try {
  56. for (const [from, to] of [['2026-11-10 02:00:00', '2026-11-10 01:00:00'], ['2026-13-10 00:00:00', '2026-11-10 01:00:00']]) {
  57. assert.deepEqual(await playback.query('A', from, to), [])
  58. assert.equal(visible.phase, 'failed')
  59. }
  60. assert.deepEqual(asked, [], 'an illegal window must never reach the server')
  61. } finally { await playback.dispose() }
  62. })
  63. test('querying publishes the returned list and distinguishes empty from failure', async () => {
  64. const updates = []
  65. let visible = {}
  66. let reply = { records: [{ name: 'CAM/01', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00' }] }
  67. const playback = createVideoPlaybackSession({
  68. records: async () => reply, playback: async () => started('A'), stop: async (session) => stopped(session),
  69. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } })
  70. try {
  71. await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
  72. assert.equal(visible.phase, 'listed')
  73. assert.equal(visible.records.length, 1)
  74. assert.equal(visible.empty, false)
  75. reply = { records: [] }
  76. await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
  77. assert.equal(visible.empty, true, 'an empty list is a result, not an error')
  78. assert.equal(visible.phase, 'listed')
  79. // A genuine failure is shown as such and never retried automatically.
  80. const failing = createVideoPlaybackSession({
  81. records: async () => { throw Object.assign(new Error('查询失败'), { category: 'VIDEO_READ_FAILED' }) },
  82. playback: async () => started('A'), stop: async (session) => stopped(session),
  83. }, (patch) => { visible = { ...visible, ...patch } })
  84. try {
  85. await failing.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
  86. assert.equal(visible.phase, 'failed')
  87. assert.equal(visible.empty, false, 'a failure is not an empty result')
  88. } finally { await failing.dispose() }
  89. } finally { await playback.dispose() }
  90. })
  91. test('playback starts only for a chosen recording and never stops another viewer', async () => {
  92. const calls = []
  93. const updates = []
  94. let visible = {}
  95. const playback = createVideoPlaybackSession({
  96. records: async () => ({ records: [] }),
  97. playback: async (code, from, to) => { calls.push([code, from, to]); return started(code) },
  98. stop: async (session) => stopped(session),
  99. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } })
  100. try {
  101. // Choosing a recording is what starts playback; listing alone never does.
  102. await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
  103. assert.deepEqual(calls, [], 'a query must not start playback')
  104. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  105. assert.deepEqual(calls, [['A', '2026-11-10 01:00:00', '2026-11-10 02:00:00']])
  106. assert.equal(visible.phase, 'playing')
  107. assert.equal(visible.media.url.includes('A.mp4'), true)
  108. } finally { await playback.dispose() }
  109. })
  110. test('an occupied channel is reported without stopping the existing playback', async () => {
  111. const stops = [], updates = []
  112. let visible = {}
  113. const playback = createVideoPlaybackSession({
  114. records: async () => ({ records: [] }),
  115. playback: async () => { throw Object.assign(new Error('此通道已有录像回放'), { category: 'PLAYBACK_OCCUPIED' }) },
  116. stop: async (session) => { stops.push(session); return stopped(session) },
  117. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } })
  118. try {
  119. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  120. assert.equal(visible.phase, 'failed')
  121. assert.ok(visible.error.includes('已有录像回放'), visible.error)
  122. assert.deepEqual(stops, [], 'a losing request must never release somebody else playback')
  123. } finally { await playback.dispose() }
  124. })
  125. test('an unconfirmed playback control is refused locally without a request', async () => {
  126. const sent = []
  127. const playback = createVideoPlaybackSession({
  128. records: async () => ({ records: [] }), playback: async () => started('A'),
  129. control: async (session, action, value) => { sent.push([action, value]); return {} },
  130. stop: async (session) => stopped(session),
  131. }, () => {})
  132. try {
  133. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  134. // The default capability set confirms nothing, so every control must be refused locally.
  135. for (const key of ['pause', 'resume', 'seek', 'speed-1', 'speed-4']) {
  136. assert.equal(await playback.control(key), false, `${key} 未确认时不得发送`)
  137. }
  138. assert.deepEqual(sent, [], 'no unconfirmed control may reach the server')
  139. // An unrecognised action is rejected too, never forwarded blindly.
  140. assert.equal(await playback.control('reboot'), false)
  141. assert.deepEqual(sent, [])
  142. } finally { await playback.dispose() }
  143. })
  144. test('a confirmed control is forwarded with its action and rate, and a speed rate maps to one capability', async () => {
  145. const sent = []
  146. let visible = {}
  147. const playback = createVideoPlaybackSession({
  148. records: async () => ({ records: [] }),
  149. // A device that proved pause and speed support, but not seek.
  150. playback: async () => ({ ...started('A'), controls: { pause: true, resume: true, speed: true, seek: false } }),
  151. control: async (session, action, value) => { sent.push([action, value]); return { sessionId: session.sessionId, status: 'ACTIVE' } },
  152. stop: async (session) => stopped(session),
  153. }, (patch) => { visible = { ...visible, ...patch } })
  154. try {
  155. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  156. assert.equal(visible.controls.pause, true, 'a confirmed capability must reach the panel')
  157. assert.equal(await playback.control('pause'), true)
  158. // Each rate key is one capability on the server but carries its own rate value.
  159. assert.equal(await playback.control('speed-0.25'), true)
  160. assert.equal(await playback.control('speed-4'), true)
  161. assert.equal(await playback.control('seek'), false, 'an unconfirmed seek must still be refused')
  162. assert.deepEqual(sent, [['pause', undefined], ['speed', 0.25], ['speed', 4]])
  163. } finally { await playback.dispose() }
  164. })
  165. test('stopping resets the playback capability set so it cannot leak into the next session', async () => {
  166. let visible = {}
  167. const playback = createVideoPlaybackSession({
  168. records: async () => ({ records: [] }),
  169. playback: async () => ({ ...started('A'), controls: { pause: true, resume: true, speed: true, seek: true } }),
  170. stop: async (session) => stopped(session),
  171. }, (patch) => { visible = { ...visible, ...patch } })
  172. try {
  173. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  174. assert.equal(visible.controls.pause, true)
  175. await playback.stop()
  176. assert.deepEqual(visible.controls, {}, 'a released session must not keep its capabilities')
  177. } finally { await playback.dispose() }
  178. })
  179. test('stopping releases the owned playback session and reports unconfirmed cleanup truthfully', async () => {
  180. const clock = new Clock(), updates = []
  181. let visible = {}
  182. let stopReply = stopped({ sessionId: 'session-A' })
  183. const playback = createVideoPlaybackSession({
  184. records: async () => ({ records: [] }), playback: async () => started('A'),
  185. stop: async () => stopReply,
  186. status: async () => ({ sessionId: 'session-A', status: 'STOPPED' }),
  187. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
  188. try {
  189. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  190. assert.equal(await playback.stop(), true)
  191. assert.equal(visible.media, null)
  192. assert.equal(visible.cleanupError, '')
  193. // An unconfirmed release must not be reported as success.
  194. stopReply = { sessionId: 'session-A', status: 'CLEANUP_PENDING' }
  195. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  196. assert.equal(await playback.stop(), false, 'an unconfirmed cleanup is not a clean stop')
  197. assert.notEqual(visible.cleanupError, '')
  198. } finally { await playback.dispose() }
  199. })
  200. test('switching back to live ends the old playback before live starts', async () => {
  201. const events = []
  202. const playback = createVideoPlaybackSession({
  203. records: async () => ({ records: [] }),
  204. playback: async () => { events.push('playback-start'); return started('A') },
  205. stop: async (session) => { events.push('playback-stop'); return stopped(session) },
  206. }, () => {})
  207. try {
  208. await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
  209. await playback.stop()
  210. assert.deepEqual(events, ['playback-start', 'playback-stop'], 'the old stream ends before anything replaces it')
  211. } finally { await playback.dispose() }
  212. })