import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mount, type VueWrapper } from '@vue/test-utils'; import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue'; import VideoPlugin from '../src/components/rtsp-plugin-video/video-plugin.vue'; const mocks = vi.hoisted(() => ({ getSetting: vi.fn() })); vi.mock('../src/components/rtsp-plugin-video/lib/videoPlayer', () => ({})); vi.mock('../src/api/com/comDhSetting', () => ({ getObj: mocks.getSetting })); vi.mock('element-plus', () => ({ ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } })); function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } const instances: MockPlayer[] = []; class MockPlayer { setting: any; disposed = false; ready = false; closeVideo = vi.fn(() => Promise.resolve()); dispose = vi.fn(() => { this.disposed = true; }); hide = vi.fn(); show = vi.fn(); startReal = vi.fn((items) => { this.setting.channelList = items; }); startPlayback = vi.fn((items) => { this.setting.channelList = items; }); controlPlayback = vi.fn(); setTabControlBtn = vi.fn(); changeDivision = vi.fn((division) => { this.setting.division = division; }); constructor(public options: any) { this.setting = { ...options, hwnd: instances.length + 10, channelList: [] }; instances.push(this); } } const wrappers: VueWrapper[] = []; const mountOptions = { global: { stubs: { Yt: true, 'el-pagination': true, 'el-icon': true }, directives: { loading: () => undefined } }, }; function createPlayer(props = {}) { const wrapper = mount(VideoPlugin, { ...mountOptions, props }); document.body.appendChild(wrapper.element); wrappers.push(wrapper); return wrapper; } const channels = (name = 'camera-a') => [{ channelId: name, snum: 0, streamType: 2 }]; const playback = () => [{ ...channels()[0], startTime: '2026-10-27 08:00:00', endTime: '2026-10-27 09:00:00' }]; async function ready(player: MockPlayer) { player.ready = true; player.options.createSuccess({}); await vi.advanceTimersByTimeAsync(500); } async function play(wrapper: VueWrapper, mode = 'real') { const task = mode === 'real' ? wrapper.vm.startReal(channels()) : wrapper.vm.startPlayback(playback()); await vi.advanceTimersByTimeAsync(0); const player = instances.at(-1)!; if (!player.ready) await ready(player); await task; return player; } beforeEach(() => { vi.useFakeTimers(); vi.spyOn(console, 'log').mockImplementation(() => undefined); Object.defineProperty(document, 'hidden', { configurable: true, value: false }); (window as any).VideoPlayer = MockPlayer; instances.length = 0; mocks.getSetting.mockReset().mockResolvedValue({ code: 0, data: { dhIccSetting: {} } }); }); afterEach(() => { wrappers.splice(0).forEach((wrapper) => { if (wrapper.exists()) wrapper.unmount(); }); vi.clearAllTimers(); vi.useRealTimers(); document.body.innerHTML = ''; }); describe('原生播放器组件的关闭时序', () => { it('九宫格编号在未安装插件时仍然显示,原生播放器为编号保留遮挡区域', async () => { const labels = Array.from({ length: 9 }, (_, snum) => ({ snum, label: `${Math.floor(snum / 3) + 1}-${snum % 3 + 1}`, ...(snum === 0 ? { channelId: 'camera-a', channelName: '南门球机' } : {}), })); const wrapper = createPlayer({ division: 9, divisionSettingList: [9], windowLabels: labels }); const task = wrapper.vm.startReal(channels()); await vi.advanceTimersByTimeAsync(0); expect(instances[0].options.division).toBe(9); expect(instances[0].options.shieldClass).toContain('video-window-label'); instances[0].options.createError({ code: 1001, message: '插件未安装' }); await task; await nextTick(); expect(wrapper.findAll('.video-window-label').map(item => item.text())).toEqual(labels.map(item => item.label)); expect(wrapper.text()).toContain('南门球机'); expect(wrapper.text()).toContain('未能连接本机视频服务'); expect(wrapper.find('.loading-mask').exists()).toBe(false); }); it('插件就绪后只保留角标,双击放大只显示对应窗口编号,恢复后还原九格', async () => { const labels = Array.from({ length: 9 }, (_, snum) => ({ snum, label: `${Math.floor(snum / 3) + 1}-${snum % 3 + 1}`, channelId: `camera-${snum}`, })); const wrapper = createPlayer({ division: 9, divisionSettingList: [9], windowLabels: labels }); const player = await play(wrapper); await nextTick(); expect(wrapper.findAll('.video-window-placeholder')).toHaveLength(0); expect(wrapper.findAll('.video-window-label')).toHaveLength(9); player.options.dbClickWindow(4); await nextTick(); expect(wrapper.findAll('.video-window-label').map(item => item.text())).toEqual(['2-2']); player.options.dbClickWindow(4); await nextTick(); expect(wrapper.findAll('.video-window-label')).toHaveLength(9); player.options.dbClickWindow(4); player.options.changeDivision(9); await nextTick(); expect(wrapper.findAll('.video-window-label')).toHaveLength(9); }); it('仅在超过每页窗口数时显示分页,切换选择能重置到第一页', async () => { const wrapper = createPlayer({ division: 9, divisionSettingList: [9] }); wrapper.vm.setTotal(9, 1); await nextTick(); expect(wrapper.find('.video-container-page').exists()).toBe(false); wrapper.vm.setTotal(11, 2); await nextTick(); expect(wrapper.find('.video-container-page').exists()).toBe(true); expect(wrapper.findComponent({ name: 'el-pagination' }).attributes('current-page')).toBe('2'); wrapper.vm.setTotal(11, 1); await nextTick(); expect(wrapper.findComponent({ name: 'el-pagination' }).attributes('current-page')).toBe('1'); }); it('正常实时预览和回放仍调用对应 SDK 接口', async () => { const wrapper = createPlayer(); const real = await play(wrapper); expect(real.startReal).toHaveBeenCalledWith(channels()); const replay = await play(wrapper, 'playback'); expect(replay.startPlayback).toHaveBeenCalledWith(playback()); wrapper.vm.destroy(); expect(real.dispose).toHaveBeenCalledTimes(1); expect(replay.dispose).toHaveBeenCalledTimes(1); }); it('同类型并发初始化只查询和构造一次,只执行最后一次播放请求', async () => { const wrapper = createPlayer(); const first = wrapper.vm.startReal(channels('first')); const last = wrapper.vm.startReal(channels('last')); await vi.advanceTimersByTimeAsync(0); expect(mocks.getSetting).toHaveBeenCalledTimes(1); expect(instances).toHaveLength(1); await ready(instances[0]); await Promise.all([first, last]); expect(instances[0].startReal).toHaveBeenCalledTimes(1); expect(instances[0].startReal).toHaveBeenCalledWith(channels('last')); }); it('配置请求返回之前卸载,不再构造播放器', async () => { const config = deferred(); mocks.getSetting.mockReturnValue(config.promise); const wrapper = createPlayer(); const task = wrapper.vm.startReal(channels()); wrapper.unmount(); config.resolve({ code: 0, data: { dhIccSetting: {} } }); await vi.advanceTimersByTimeAsync(0); await task; expect(instances).toHaveLength(0); }); it('原生创建回包晚于卸载,不恢复已取消的播放', async () => { const wrapper = createPlayer(); const task = wrapper.vm.startReal(channels()); await vi.advanceTimersByTimeAsync(0); const player = instances[0]; wrapper.unmount(); await ready(player); await task; expect(player.dispose).toHaveBeenCalledTimes(1); expect(player.startReal).not.toHaveBeenCalled(); }); it('创建成功后的 500ms 延迟也能被卸载取消', async () => { const wrapper = createPlayer(); const task = wrapper.vm.startReal(channels()); await vi.advanceTimersByTimeAsync(0); const player = instances[0]; player.options.createSuccess({}); wrapper.unmount(); await vi.advanceTimersByTimeAsync(1000); await task; expect(player.startReal).not.toHaveBeenCalled(); }); it('软关闭会取消等待中的播放,切回浏览器也不会自行恢复', async () => { const wrapper = createPlayer(); const task = wrapper.vm.startReal(channels()); await vi.advanceTimersByTimeAsync(0); const player = instances[0]; await wrapper.vm.closeVideo(); await ready(player); await task; document.dispatchEvent(new Event('visibilitychange')); await vi.advanceTimersByTimeAsync(0); expect(player.startReal).not.toHaveBeenCalled(); }); it('原生工具栏关闭的通道,不会在浏览器重新可见时复播', async () => { const wrapper = createPlayer(); const player = await play(wrapper); player.setting.channelList = []; player.options.closeWindowSuccess({ isAll: true, channelList: [] }); Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange')); Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await vi.advanceTimersByTimeAsync(0); expect(player.startReal).toHaveBeenCalledTimes(1); await wrapper.vm.startReal(channels()); expect(player.startReal).toHaveBeenCalledTimes(2); }); it('未 await 的关闭也会在新播放前等待旧 SDK 关闭确认', async () => { const wrapper = createPlayer(); const player = await play(wrapper); const closed = deferred(); player.closeVideo.mockReturnValue(closed.promise); const closeTask = wrapper.vm.closeVideo(); const playTask = wrapper.vm.startReal(channels('next')); await vi.advanceTimersByTimeAsync(0); expect(player.startReal).toHaveBeenCalledTimes(1); closed.resolve(); await Promise.all([closeTask, playTask]); expect(player.startReal).toHaveBeenLastCalledWith(channels('next')); }); it('快速切出再切回浏览器,仍恢复正在观看的预览,不会被防重入间隔拦掉', async () => { const wrapper = createPlayer(); const player = await play(wrapper); Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange')); Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await vi.advanceTimersByTimeAsync(0); expect(player.closeVideo).toHaveBeenCalledTimes(1); expect(player.startReal).toHaveBeenCalledTimes(2); }); it('同时关闭多个缓存实例,单个 SDK 不回包也不妨碍销毁其他窗口', async () => { const wrapper = createPlayer(); const real = await play(wrapper); const replay = await play(wrapper, 'playback'); const closed = deferred(); real.closeVideo.mockReturnValue(closed.promise); const closing = wrapper.vm.closeVideo(); expect(real.closeVideo).toHaveBeenCalledTimes(1); expect(replay.closeVideo).toHaveBeenCalledTimes(1); wrapper.vm.destroy(); expect(real.dispose).toHaveBeenCalledTimes(1); expect(replay.dispose).toHaveBeenCalledTimes(1); closed.resolve(); await closing; }); it('重叠关闭不会重复给同一实例发送关闭指令', async () => { const wrapper = createPlayer(); const player = await play(wrapper); const closed = deferred(); player.closeVideo.mockReturnValue(closed.promise); const first = wrapper.vm.closeVideo(); const second = wrapper.vm.closeVideo(); expect(player.closeVideo).toHaveBeenCalledTimes(1); closed.resolve(); await Promise.all([first, second]); }); it('销毁会中断对旧关闭回包的等待,旧 SDK 永不回包也能重新打开', async () => { const wrapper = createPlayer(); const old = await play(wrapper); old.closeVideo.mockReturnValue(new Promise(() => undefined)); void wrapper.vm.closeVideo(); const waiting = wrapper.vm.startReal(channels('waiting')); await vi.advanceTimersByTimeAsync(0); wrapper.vm.destroy(); await waiting; expect(old.startReal).toHaveBeenCalledTimes(1); const replacement = await play(wrapper); expect(instances).toHaveLength(2); expect(replacement.startReal).toHaveBeenCalledWith(channels()); }); it('两个组件的容器 ID 不重叠,关闭其中一个不会销毁另一个', async () => { const first = createPlayer(); const second = createPlayer(); const a = await play(first); const b = await play(second); expect(a.options.videoId).not.toEqual(b.options.videoId); first.unmount(); expect(a.dispose).toHaveBeenCalledTimes(1); expect(b.dispose).not.toHaveBeenCalled(); expect(document.getElementById(b.options.videoId)).not.toBeNull(); }); it('配置请求失败时播放 Promise 正常结束,之后可重新初始化', async () => { mocks.getSetting.mockRejectedValueOnce(new Error('configuration unavailable')); const wrapper = createPlayer(); await wrapper.vm.startReal(channels()); expect(instances).toHaveLength(0); const player = await play(wrapper); expect(player.startReal).toHaveBeenCalledTimes(1); }); it('SDK 创建失败会清掉失败实例,允许重新打开', async () => { const wrapper = createPlayer(); const task = wrapper.vm.startReal(channels()); await vi.advanceTimersByTimeAsync(0); instances[0].options.createError({ code: 1001, message: 'not installed' }); await task; expect(instances[0].dispose).toHaveBeenCalledTimes(1); const replacement = await play(wrapper); expect(instances).toHaveLength(2); expect(replacement.startReal).toHaveBeenCalledTimes(1); }); it('SDK 在构造函数内同步报告失败时,同样销毁失败实例并结束等待', async () => { (window as any).VideoPlayer = class extends MockPlayer { constructor(options: any) { super(options); options.createError({ code: 1002, message: 'unsupported system' }); } }; const wrapper = createPlayer(); await wrapper.vm.startReal(channels()); expect(instances[0].dispose).toHaveBeenCalledTimes(1); expect(instances[0].startReal).not.toHaveBeenCalled(); }); it('正常播放仍保留辅码流失败后只重试一次主码流的行为', async () => { const wrapper = createPlayer(); const player = await play(wrapper); player.options.realError(channels()[0], { code: 408 }); player.options.realError(channels()[0], { code: 408 }); expect(player.startReal).toHaveBeenCalledTimes(2); expect(player.startReal).toHaveBeenLastCalledWith([{ ...channels()[0], streamType: 1 }]); }); it('弹窗 active=false 会立即销毁,离场期间的晚到播放调用被忽略', async () => { const wrapper = createPlayer(); const player = await play(wrapper); await wrapper.setProps({ active: false }); expect(player.dispose).toHaveBeenCalledTimes(1); await wrapper.vm.startReal(channels('late-response')); expect(instances).toHaveLength(1); await wrapper.setProps({ active: true }); const replacement = await play(wrapper); expect(instances).toHaveLength(2); expect(replacement.startReal).toHaveBeenCalledTimes(1); }); it('销毁后晚到的错误回调不会触发主码流重试', async () => { const wrapper = createPlayer(); const player = await play(wrapper); wrapper.vm.destroy(); player.options.realError(channels()[0], { code: 408 }); expect(player.startReal).toHaveBeenCalledTimes(1); }); it('KeepAlive 停用时销毁窗口,重新激活时只重建当前播放', async () => { const visible = ref(true); const host = mount(defineComponent({ render: () => h(KeepAlive, {}, () => visible.value ? h(VideoPlugin) : null) }), mountOptions); document.body.appendChild(host.element); wrappers.push(host); const child = host.findComponent(VideoPlugin); const oldPlayer = await play(child); visible.value = false; await nextTick(); expect(oldPlayer.dispose).toHaveBeenCalledTimes(1); await child.vm.startReal(channels('inactive')); expect(instances).toHaveLength(1); visible.value = true; await nextTick(); await vi.advanceTimersByTimeAsync(0); await ready(instances[1]); expect(instances[1].startReal).toHaveBeenCalledWith(channels()); }); it('当前预览已手动关闭时,重新激活不能误恢复此前缓存的回放', async () => { const visible = ref(true); const host = mount(defineComponent({ render: () => h(KeepAlive, {}, () => visible.value ? h(VideoPlugin) : null) }), mountOptions); document.body.appendChild(host.element); wrappers.push(host); const child = host.findComponent(VideoPlugin); const real = await play(child); await play(child, 'playback'); await child.vm.startReal(channels()); real.setting.channelList = []; real.options.closeWindowSuccess({ isAll: true, channelList: [] }); visible.value = false; await nextTick(); visible.value = true; await nextTick(); await vi.advanceTimersByTimeAsync(0); expect(instances).toHaveLength(2); }); });