Files
ArtPlayer/test/prompt-components.test.js
T

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)
})