mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 03:46:16 -08:00
293 lines
9.8 KiB
JavaScript
293 lines
9.8 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
|
import { test } from 'node:test'
|
|
import { loadModules } from './helpers/load.js'
|
|
|
|
const { Info, Loading, Mask, Emitter, beginLifecycle, getScope, destroyInstance } = await loadModules({
|
|
Info: 'packages/artplayer/src/info',
|
|
Loading: 'packages/artplayer/src/loading',
|
|
Mask: 'packages/artplayer/src/mask',
|
|
Emitter: 'packages/artplayer/src/utils/emitter',
|
|
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
|
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
|
destroyInstance: { file: 'packages/artplayer/src/lifecycle/instance', name: 'destroyInstance' },
|
|
})
|
|
|
|
function fixture(t) {
|
|
const document = { activeElement: null, body: null }
|
|
class Element extends EventTarget {
|
|
ownerDocument = document
|
|
attributes = new Map()
|
|
setAttribute(key, value) { this.attributes.set(key, String(value)) }
|
|
getAttribute(key) { return this.attributes.get(key) }
|
|
hasAttribute(key) { return this.attributes.has(key) }
|
|
contains(node) { return node === this || this.children.some(child => child.contains(node)) }
|
|
style = {}
|
|
children = []
|
|
textContent = ''
|
|
dataset = {}
|
|
appendChild(child) {
|
|
this.children.push(child)
|
|
this.lastElementChild = child
|
|
}
|
|
|
|
querySelectorAll() {
|
|
return this.children
|
|
}
|
|
}
|
|
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
|
|
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
|
|
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
|
|
let id = 0
|
|
const timers = new Map()
|
|
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
|
|
const key = ++id
|
|
timers.set(key, { callback, delay })
|
|
return key
|
|
})
|
|
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
|
|
const classes = new Set()
|
|
const player = new Element()
|
|
player.classList = { add: value => classes.add(value), remove: value => classes.delete(value), contains: value => classes.has(value) }
|
|
const panel = new Element()
|
|
const time = new Element()
|
|
time.dataset.video = 'currentTime'
|
|
const source = new Element()
|
|
source.dataset.video = 'src'
|
|
panel.children.push(time, source)
|
|
const listeners = new Set()
|
|
const art = Object.assign(new Emitter(), {
|
|
constructor: { INFO_LOOP_TIME: 100 },
|
|
template: { $player: player, $info: new Element(), $infoPanel: panel, $infoClose: new Element(), $video: { currentTime: 1.234, src: 'first' }, $loading: new Element(), $state: new Element() },
|
|
i18n: { get: key => key },
|
|
icons: { loading: new Element(), state: new Element(), error: new Element() },
|
|
play: () => Promise.resolve(),
|
|
})
|
|
art.events = {
|
|
proxy(target, name, callback) {
|
|
const listener = { target, name, callback }
|
|
listeners.add(listener)
|
|
const cleanup = () => listeners.delete(listener)
|
|
getScope(art).add(() => {
|
|
cleanup()
|
|
})
|
|
return cleanup
|
|
},
|
|
remove: cleanup => cleanup(),
|
|
}
|
|
art.proxy = art.events.proxy
|
|
beginLifecycle(art)
|
|
const click = (target) => {
|
|
for (const listener of [...listeners]) {
|
|
if (listener.target === target)
|
|
listener.callback(new Event('click'))
|
|
}
|
|
}
|
|
const flush = () => {
|
|
const pending = [...timers.values()]
|
|
timers.clear()
|
|
for (const timer of pending)
|
|
timer.callback()
|
|
}
|
|
return { art, time, source, timers, listeners, click, flush, scope: getScope(art), Element }
|
|
}
|
|
|
|
test('info retains component fields, immediate numeric formatting, live delay and close behavior', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
assert.deepEqual(Object.keys(info), ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'])
|
|
assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(info)), ['constructor', 'init'])
|
|
assert.equal(f.time.textContent, '1.23')
|
|
assert.equal(f.source.textContent, 'first')
|
|
assert.equal(f.timers.size, 1)
|
|
f.art.template.$video.currentTime = 3
|
|
f.art.constructor.INFO_LOOP_TIME = 200
|
|
f.flush()
|
|
assert.equal(f.time.textContent, '3.00')
|
|
assert.equal([...f.timers.values()][0].delay, 200)
|
|
info.show = true
|
|
f.click(f.art.template.$infoClose)
|
|
assert.equal(info.show, false)
|
|
f.scope.dispose()
|
|
assert.equal(f.timers.size, 0)
|
|
})
|
|
|
|
test('info reinitialization replaces its poll and listener rather than multiplying work', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
const stale = [...f.timers.values()][0].callback
|
|
info.init()
|
|
info.init()
|
|
assert.equal(f.timers.size, 1)
|
|
assert.equal(f.listeners.size, 1)
|
|
f.art.template.$video.currentTime = 5
|
|
stale()
|
|
assert.equal(f.time.textContent, '1.23')
|
|
f.flush()
|
|
assert.equal(f.time.textContent, '5.00')
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('closed info init cannot attach listeners or write media values', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
f.scope.dispose()
|
|
f.art.template.$video.currentTime = 9
|
|
info.init()
|
|
assert.equal(f.time.textContent, '1.23')
|
|
assert.equal(f.listeners.size, 0)
|
|
assert.equal(f.timers.size, 0)
|
|
})
|
|
|
|
test('destruction during info media reads stops all subsequent DOM writes and scheduling', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
Object.defineProperty(f.art.template.$video, 'currentTime', {
|
|
get() {
|
|
f.scope.dispose()
|
|
return 20
|
|
},
|
|
})
|
|
info.init()
|
|
assert.equal(f.time.textContent, '1.23')
|
|
assert.equal(f.timers.size, 0)
|
|
})
|
|
|
|
test('manual destroy emission stops info work, while a live manual init may restart it', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
f.art.emit('destroy')
|
|
assert.equal(f.timers.size, 0)
|
|
info.init()
|
|
assert.equal(f.timers.size, 1)
|
|
assert.equal(f.listeners.size, 1)
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('failed info reads propagate the original error and release the failed initialization', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
const failure = new Error('media getter failed')
|
|
Object.defineProperty(f.art.template.$video, 'currentTime', {
|
|
get() {
|
|
throw failure
|
|
},
|
|
})
|
|
assert.throws(() => info.init(), error => error === failure)
|
|
assert.equal(f.timers.size, 0)
|
|
assert.equal(f.listeners.size, 0)
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('loading and mask preserve component visibility and actual icon identity', async (t) => {
|
|
const f = fixture(t)
|
|
const loading = new Loading(f.art)
|
|
const mask = new Mask(f.art)
|
|
assert.equal(f.art.template.$loading.children[0], f.art.icons.loading)
|
|
assert.deepEqual(f.art.template.$state.children, [f.art.icons.state, f.art.icons.error])
|
|
assert.equal(f.art.icons.error.style.display, 'none')
|
|
loading.show = true
|
|
mask.show = true
|
|
assert.equal(loading.show, true)
|
|
assert.equal(mask.show, true)
|
|
let plays = 0
|
|
f.art.play = () => {
|
|
plays++
|
|
return Promise.reject(new Error('play refused'))
|
|
}
|
|
f.click(f.art.template.$state)
|
|
await Promise.resolve()
|
|
assert.equal(plays, 1)
|
|
f.art.emit('destroy')
|
|
assert.equal(f.art.icons.state.style.display, 'none')
|
|
assert.equal(f.art.icons.error.style.display, null)
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('mask root cleanup shows its terminal icon even when no destroy event is emitted', (t) => {
|
|
const f = fixture(t)
|
|
const mask = new Mask(f.art)
|
|
assert.equal(mask.name, 'mask')
|
|
const retained = [...f.listeners][0].callback
|
|
let plays = 0
|
|
f.art.play = () => {
|
|
plays++
|
|
}
|
|
f.scope.dispose()
|
|
retained(new Event('click'))
|
|
assert.equal(plays, 0)
|
|
assert.equal(f.art.icons.state.style.display, 'none')
|
|
assert.equal(f.art.icons.error.style.display, null)
|
|
assert.equal(f.art.e.destroy, undefined)
|
|
})
|
|
|
|
test('info reinitialization captures replacement panel and close nodes without retaining the old ones', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
const oldClose = f.art.template.$infoClose
|
|
const panel = new f.Element()
|
|
const item = new f.Element()
|
|
item.dataset.video = 'src'
|
|
panel.children.push(item)
|
|
f.art.template.$infoPanel = panel
|
|
f.art.template.$infoClose = new f.Element()
|
|
info.init()
|
|
assert.equal(item.textContent, 'first')
|
|
info.show = true
|
|
f.click(oldClose)
|
|
assert.equal(info.show, true)
|
|
f.click(f.art.template.$infoClose)
|
|
assert.equal(info.show, false)
|
|
assert.equal(f.listeners.size, 1)
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('info getter reentry leaves only the newer poll alive', (t) => {
|
|
const f = fixture(t)
|
|
const info = new Info(f.art)
|
|
let nested = false
|
|
Object.defineProperty(f.art.template.$video, 'currentTime', {
|
|
get() {
|
|
if (!nested) {
|
|
nested = true
|
|
info.init()
|
|
return 20
|
|
}
|
|
return 30
|
|
},
|
|
})
|
|
info.init()
|
|
assert.equal(f.time.textContent, '30.00')
|
|
assert.equal(f.timers.size, 1)
|
|
assert.equal(f.listeners.size, 1)
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('mask retains destroy observer order while still finalizing after an earlier observer throws', (t) => {
|
|
const f = fixture(t)
|
|
f.art.reset = () => {}
|
|
f.art.template.destroy = () => {}
|
|
const seen = []
|
|
f.art.on('destroy', () => seen.push(['before', f.art.icons.error.style.display]))
|
|
const mask = new Mask(f.art)
|
|
assert.equal(mask.name, 'mask')
|
|
f.art.on('destroy', () => seen.push(['after', f.art.icons.error.style.display]))
|
|
destroyInstance(f.art, [f.art], false, false)
|
|
assert.deepEqual(seen, [['before', 'none'], ['after', null]])
|
|
})
|
|
|
|
test('mask final cleanup survives a throwing earlier destroy observer', (t) => {
|
|
const f = fixture(t)
|
|
f.art.reset = () => {}
|
|
f.art.template.destroy = () => {}
|
|
const failure = new Error('early destroy observer')
|
|
f.art.on('destroy', () => {
|
|
throw failure
|
|
})
|
|
const mask = new Mask(f.art)
|
|
assert.equal(mask.name, 'mask')
|
|
assert.throws(() => destroyInstance(f.art, [f.art], false, false), error => error === failure)
|
|
assert.equal(f.art.icons.error.style.display, null)
|
|
assert.equal(f.art.e.destroy.length, 1)
|
|
})
|