build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
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<T = void>() {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((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<any>()
|
|
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<any>()
|
|
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)
|
|
})
|
|
})
|