import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' import { nextTick } 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() } })) type Mode = 'real' | 'playback' const modes: Mode[] = ['real', 'playback'] const connectionError = '未能连接本机视频服务,请启动或重新启动视频插件后重试' const instances: MockPlayer[] = [] let constructorError: { code: number; message: string } | null = null class MockPlayer { setting: any closeVideo = vi.fn(() => Promise.resolve()) dispose = vi.fn() 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) if (constructorError) options.createError(constructorError) } manualClose(all: boolean) { this.setting.channelList = all ? [] : this.setting.channelList.slice(1) this.options.closeWindowSuccess({ isAll: all, snum: 0, channelList: this.setting.channelList }) } } function deferred() { let resolve!: (value: T) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } const wrappers: VueWrapper[] = [] function createPlayer() { const wrapper = mount(VideoPlugin, { global: { stubs: { Yt: true, 'el-pagination': true, 'el-icon': true }, directives: { loading: () => undefined } }, }) document.body.appendChild(wrapper.element) wrappers.push(wrapper) return wrapper } function requests(mode: Mode, names = ['camera-a']) { return names.map((channelId, snum) => ({ channelId, snum, streamType: mode === 'real' ? 2 : 0, ...(mode === 'playback' ? { startTime: '2026-11-04 08:00:00', endTime: '2026-11-04 09:00:00', recordSource: 2 } : {}), })) } function start(wrapper: VueWrapper, mode: Mode, items = requests(mode)) { return mode === 'real' ? wrapper.vm.startReal(items) : wrapper.vm.startPlayback(items) } function startSpy(player: MockPlayer, mode: Mode) { return mode === 'real' ? player.startReal : player.startPlayback } async function ready(player: MockPlayer) { player.options.createSuccess({}) await vi.advanceTimersByTimeAsync(500) } async function play(wrapper: VueWrapper, mode: Mode, items = requests(mode)) { const task = start(wrapper, mode, items) await vi.advanceTimersByTimeAsync(0) const player = instances[instances.length - 1] await ready(player) await task expect(startSpy(player, mode)).toHaveBeenCalledTimes(1) return player } async function failInitial(wrapper: VueWrapper, mode: Mode, code = 1001) { const task = start(wrapper, mode) await vi.advanceTimersByTimeAsync(0) const player = instances[instances.length - 1] player.options.createError({ code, message: '插件未安装' }) await task await nextTick() expect(player.dispose).toHaveBeenCalledTimes(1) return player } function retryButton(wrapper: VueWrapper) { const button = wrapper.findAll('button').find((item) => item.text() === '重新连接') expect(button, '连接失败时应提供重新连接按钮').toBeDefined() return button! } function setHidden(hidden: boolean) { Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) document.dispatchEvent(new Event('visibilitychange')) } 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 constructorError = null mocks.getSetting.mockReset().mockResolvedValue({ code: 0, data: { dhIccSetting: {} } }) }) afterEach(() => { wrappers.splice(0).forEach((wrapper) => { if (wrapper.exists()) wrapper.unmount() }) vi.clearAllTimers() vi.useRealTimers() vi.restoreAllMocks() document.body.innerHTML = '' }) describe.each(modes)('%s 视频连接恢复', (mode) => { it.each([1001, 1003])('终态错误 %s 显示连接故障,点击重试恢复当前请求', async (code) => { const wrapper = createPlayer() await failInitial(wrapper, mode, code) expect(wrapper.text()).toContain(connectionError) expect(wrapper.text()).not.toContain('插件未安装') await retryButton(wrapper).trigger('click') await vi.advanceTimersByTimeAsync(0) expect(instances).toHaveLength(2) await ready(instances[1]) expect(startSpy(instances[1], mode)).toHaveBeenCalledTimes(1) expect(startSpy(instances[1], mode)).toHaveBeenCalledWith(requests(mode)) expect(wrapper.text()).not.toContain(connectionError) }) it('连接中状态不是终态,不销毁实例或显示未安装提示', async () => { const wrapper = createPlayer() const task = start(wrapper, mode) await vi.advanceTimersByTimeAsync(0) const player = instances[0] player.options.connectionStateChange({ state: 'connecting' }) await nextTick() expect(player.dispose).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('插件未安装') await ready(player) await task expect(startSpy(player, mode)).toHaveBeenCalledTimes(1) }) it('运行中的连接恢复复用实例,并绕过800ms重复播放保护重新开流', async () => { const wrapper = createPlayer() const player = await play(wrapper, mode) const disconnectedAt = Date.now() player.options.connectionStateChange({ state: 'reconnecting' }) expect(player.dispose).not.toHaveBeenCalled() await ready(player) expect(Date.now() - disconnectedAt).toBeLessThan(800) expect(instances).toHaveLength(1) expect(startSpy(player, mode)).toHaveBeenCalledTimes(2) expect(startSpy(player, mode)).toHaveBeenLastCalledWith(requests(mode)) }) it.each([false, true])('重连时不会重新播放手动关闭的通道(全部关闭:%s)', async (all) => { const wrapper = createPlayer() const items = requests(mode, ['camera-a', 'camera-b']) const player = await play(wrapper, mode, items) player.manualClose(all) player.options.connectionStateChange({ state: 'reconnecting' }) await ready(player) expect(player.dispose).not.toHaveBeenCalled() expect(startSpy(player, mode)).toHaveBeenCalledTimes(all ? 1 : 2) if (!all) expect(startSpy(player, mode)).toHaveBeenLastCalledWith([items[1]]) }) it('后台重连成功时先不播放,回到前台才恢复有效请求', async () => { const wrapper = createPlayer() const player = await play(wrapper, mode) setHidden(true) await vi.advanceTimersByTimeAsync(0) player.options.connectionStateChange({ state: 'reconnecting' }) await ready(player) expect(startSpy(player, mode)).toHaveBeenCalledTimes(1) expect(player.setting.visible).toBe(false) setHidden(false) await vi.advanceTimersByTimeAsync(500) expect(startSpy(player, mode)).toHaveBeenCalledTimes(2) expect(startSpy(player, mode)).toHaveBeenLastCalledWith(requests(mode)) expect(player.setting.visible).toBe(true) }) }) describe('重试和页面关闭的竞态', () => { it.each(modes)('切换模式失败后返回正常缓存的 %s 模式会清除错误遮罩和重试按钮', async (mode) => { const wrapper = createPlayer() const cached = await play(wrapper, mode) const otherMode = mode === 'real' ? 'playback' : 'real' await failInitial(wrapper, otherMode) expect(wrapper.text()).toContain(connectionError) retryButton(wrapper) await start(wrapper, mode) await nextTick() expect(instances).toHaveLength(2) expect(cached.setting.visible).toBe(true) expect(wrapper.text()).not.toContain(connectionError) expect(wrapper.findAll('button').some((button) => button.text() === '重新连接')).toBe(false) expect(wrapper.find('.loading-mask').exists()).toBe(false) // 配置查询失败也不能把错误状态带回仍可用的缓存播放器。 mocks.getSetting.mockResolvedValueOnce({ code: 1, data: null }) await start(wrapper, otherMode) await nextTick() expect(wrapper.text()).toContain('配置') retryButton(wrapper) await start(wrapper, mode) await nextTick() expect(instances).toHaveLength(2) expect(cached.setting.visible).toBe(true) expect(wrapper.text()).not.toContain('配置') expect(wrapper.findAll('button').some((button) => button.text() === '重新连接')).toBe(false) expect(wrapper.find('.loading-mask').exists()).toBe(false) }) it('关闭命令一直不回包时,重连会结束关闭等待供父组件继续执行', async () => { const wrapper = createPlayer() const player = await play(wrapper, 'real') const noResponse = deferred() player.closeVideo.mockReturnValueOnce(noResponse.promise) let closed = false const closing = wrapper.vm.closeVideo().then(() => { closed = true }) await vi.advanceTimersByTimeAsync(0) expect(closed).toBe(false) player.options.connectionStateChange({ state: 'reconnecting' }) await vi.advanceTimersByTimeAsync(0) expect(closed).toBe(true) await closing await ready(player) expect(player.startReal).toHaveBeenCalledTimes(1) }) it('快速重复点击重试只查询一次配置并创建一个替换实例', async () => { const wrapper = createPlayer() await failInitial(wrapper, 'real') const pending = deferred() mocks.getSetting.mockReturnValueOnce(pending.promise) const button = retryButton(wrapper).element button.dispatchEvent(new MouseEvent('click', { bubbles: true })) button.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() button.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(mocks.getSetting).toHaveBeenCalledTimes(2) expect(instances).toHaveLength(1) pending.resolve({ code: 0, data: { dhIccSetting: {} } }) await vi.advanceTimersByTimeAsync(0) expect(instances).toHaveLength(2) await ready(instances[1]) expect(instances[1].startReal).toHaveBeenCalledTimes(1) }) it('非当前缓存模式重连成功不抢占当前回放', async () => { const wrapper = createPlayer() const preview = await play(wrapper, 'real') const replay = await play(wrapper, 'playback') preview.options.connectionStateChange({ state: 'reconnecting' }) await ready(preview) expect(preview.dispose).not.toHaveBeenCalled() expect(preview.startReal).toHaveBeenCalledTimes(1) expect(preview.setting.visible).toBe(false) expect(replay.startPlayback).toHaveBeenCalledTimes(1) expect(replay.setting.visible).toBe(true) }) it('重连过程中切换模式,只恢复最新回放请求', async () => { const wrapper = createPlayer() const preview = await play(wrapper, 'real') preview.options.connectionStateChange({ state: 'reconnecting' }) const task = start(wrapper, 'playback') await vi.advanceTimersByTimeAsync(0) const replay = instances[1] await ready(replay) await task await ready(preview) expect(preview.startReal).toHaveBeenCalledTimes(1) expect(replay.startPlayback).toHaveBeenCalledTimes(1) expect(replay.startPlayback).toHaveBeenCalledWith(requests('playback')) }) it('重连取消旧关闭等待,旧回包不能清空已恢复的新通道', async () => { const wrapper = createPlayer() const player = await play(wrapper, 'real') const close = deferred() player.closeVideo.mockReturnValueOnce(close.promise) const closing = wrapper.vm.closeVideo() let waitingSettled = false const items = requests('real', ['new-camera']) const waiting = start(wrapper, 'real', items).then(() => { waitingSettled = true }) await vi.advanceTimersByTimeAsync(0) expect(player.startReal).toHaveBeenCalledTimes(1) player.options.connectionStateChange({ state: 'reconnecting' }) await vi.advanceTimersByTimeAsync(0) expect(waitingSettled).toBe(true) await ready(player) expect(player.startReal).toHaveBeenCalledTimes(2) expect(player.startReal).toHaveBeenLastCalledWith(items) close.resolve() await closing await waiting expect(player.setting.channelList).toEqual(items) }) it.each(['close', 'unmount', 'inactive'])('手动重试后%s,晚到就绪不能重新播放', async (action) => { const wrapper = createPlayer() const failed = await failInitial(wrapper, 'real') await retryButton(wrapper).trigger('click') await vi.advanceTimersByTimeAsync(0) const replacement = instances[1] expect(replacement).toBeDefined() if (action === 'close') await wrapper.vm.closeVideo() if (action === 'unmount') wrapper.unmount() if (action === 'inactive') await wrapper.setProps({ active: false }) await ready(failed) await ready(replacement) expect(failed.startReal).not.toHaveBeenCalled() expect(replacement.startReal).not.toHaveBeenCalled() expect(instances).toHaveLength(2) }) it('重试配置仍在加载时关闭,不再构造替换播放器', async () => { const wrapper = createPlayer() await failInitial(wrapper, 'real') const config = deferred() mocks.getSetting.mockReturnValueOnce(config.promise) await retryButton(wrapper).trigger('click') wrapper.unmount() config.resolve({ code: 0, data: { dhIccSetting: {} } }) await vi.advanceTimersByTimeAsync(1000) expect(instances).toHaveLength(1) }) it('SDK构造期间同步报错也会清理实例,后续手动重试可成功', async () => { constructorError = { code: 1001, message: '插件未安装' } const wrapper = createPlayer() let settled = false const task = start(wrapper, 'real').then(() => { settled = true }) await vi.advanceTimersByTimeAsync(0) expect(settled).toBe(true) expect(instances[0].dispose).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain(connectionError) constructorError = null await retryButton(wrapper).trigger('click') await vi.advanceTimersByTimeAsync(0) expect(instances).toHaveLength(2) await ready(instances[1]) await task expect(instances[1].startReal).toHaveBeenCalledTimes(1) }) })