mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 22:53:21 -08:00
refactor(core): CORE-18 type builtins and own prompt lifetimes
This commit is contained in:
1 parent
a842058136
commit
98a1800032
55 files changed
+26511
-650
No files matched your search
@@ -0,0 +1,319 @@
|
||||
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 { autoPlayback, Emitter, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry } = await loadModules({
|
||||
autoPlayback: 'packages/artplayer/src/plugins/autoPlayback',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
||||
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
|
||||
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
|
||||
})
|
||||
|
||||
function fixture(t, stored = { first: 12 }) {
|
||||
class Element {
|
||||
style = {}
|
||||
textContent = ''
|
||||
children = new Map()
|
||||
querySelector(name) {
|
||||
if (!this.children.has(name))
|
||||
this.children.set(name, new Element())
|
||||
return this.children.get(name)
|
||||
}
|
||||
|
||||
insertAdjacentHTML() {
|
||||
this.lastElementChild = new Element()
|
||||
}
|
||||
}
|
||||
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 listeners = new Set()
|
||||
const calls = []
|
||||
const layer = new Element()
|
||||
let records = stored
|
||||
const storage = {
|
||||
get(key) {
|
||||
assert.equal(key, 'times')
|
||||
return records
|
||||
},
|
||||
set(key, value) {
|
||||
assert.equal(key, 'times')
|
||||
records = value
|
||||
calls.push(['save', value])
|
||||
},
|
||||
del(key) {
|
||||
assert.equal(key, 'times')
|
||||
records = undefined
|
||||
},
|
||||
}
|
||||
const art = Object.assign(new Emitter(), {
|
||||
constructor: { AUTO_PLAYBACK_MAX: 2, AUTO_PLAYBACK_MIN: 5, AUTO_PLAYBACK_TIMEOUT: 100 },
|
||||
option: { id: 'first', url: 'first-url' },
|
||||
template: { $player: new Element(), $poster: new Element() },
|
||||
i18n: { get: key => key },
|
||||
icons: { close: '<svg></svg>' },
|
||||
storage,
|
||||
currentTime: 3,
|
||||
playing: false,
|
||||
play() {
|
||||
calls.push(['play'])
|
||||
return Promise.resolve()
|
||||
},
|
||||
layers: { add(option) {
|
||||
assert.equal(option.name, 'auto-playback')
|
||||
ownEntry(art, layer)
|
||||
return layer
|
||||
} },
|
||||
})
|
||||
Object.defineProperty(art, 'seek', {
|
||||
configurable: true,
|
||||
get: () => art.currentTime,
|
||||
set: value => calls.push(['seek', value]),
|
||||
})
|
||||
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)
|
||||
beginSource(art)
|
||||
const plugin = autoPlayback(art)
|
||||
const jump = layer.querySelector('.art-auto-playback-jump')
|
||||
const close = layer.querySelector('.art-auto-playback-close')
|
||||
const last = layer.querySelector('.art-auto-playback-last')
|
||||
const click = (target = jump) => {
|
||||
for (const listener of [...listeners]) {
|
||||
if (listener.target === target && listener.name === 'click')
|
||||
listener.callback(new Event('click'))
|
||||
}
|
||||
}
|
||||
const flush = () => {
|
||||
const pending = [...timers.values()]
|
||||
timers.clear()
|
||||
for (const timer of pending)
|
||||
timer.callback()
|
||||
}
|
||||
return { art, plugin, storage, layer, last, jump, close, click, flush, timers, calls, listeners, scope: getScope(art) }
|
||||
}
|
||||
|
||||
test('autoPlayback retains result names, storage key, ID precedence, pruning and management methods', (t) => {
|
||||
const f = fixture(t, { a: 1, b: 2, c: 3 })
|
||||
assert.deepEqual(Object.keys(f.plugin), ['name', 'times', 'clear', 'delete'])
|
||||
assert.equal(f.plugin.name, 'auto-playback')
|
||||
f.art.playing = true
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.deepEqual(f.plugin.times, { b: 2, c: 3, first: 3 })
|
||||
f.art.option.id = ''
|
||||
f.art.currentTime = 9
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.deepEqual(f.plugin.times, { 'c': 3, 'first': 3, 'first-url': 9 })
|
||||
assert.deepEqual(f.plugin.delete('first'), { 'c': 3, 'first-url': 9 })
|
||||
assert.equal(f.plugin.clear(), undefined)
|
||||
assert.deepEqual(f.plugin.times, {})
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('resume prompt preserves threshold, text, seek/play order and first-update timeout', (t) => {
|
||||
const f = fixture(t, { first: 4 })
|
||||
f.art.emit('ready')
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
f.storage.set('times', { first: 5 })
|
||||
f.art.emit('restart')
|
||||
assert.equal(f.layer.style.display, 'flex')
|
||||
assert.equal(f.last.textContent, 'Last Seen 00:05')
|
||||
assert.equal(f.jump.textContent, 'Jump Play')
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.click()
|
||||
assert.deepEqual(f.calls.slice(-2), [['seek', 5], ['play']])
|
||||
assert.equal(f.art.template.$poster.style.display, 'none')
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.equal(f.timers.size, 1)
|
||||
assert.equal([...f.timers.values()][0].delay, 100)
|
||||
f.flush()
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('restarts replace click targets and only the latest resume time is played', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
f.storage.set('times', { first: 18 })
|
||||
f.art.emit('restart')
|
||||
f.storage.set('times', { first: 24 })
|
||||
f.art.emit('restart')
|
||||
f.click()
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'seek' || call[0] === 'play'), [['seek', 24], ['play']])
|
||||
assert.equal(f.listeners.size, 2)
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.scope.dispose()
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('restarting below threshold cancels old clicks and pending first-update subscription', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
f.storage.set('times', { first: 1 })
|
||||
f.art.emit('restart')
|
||||
f.click()
|
||||
assert.equal(f.calls.some(call => call[0] === 'seek'), false)
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('removing the layer releases prompt resources but keeps playing-time storage', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
f.art.emit('video:timeupdate')
|
||||
releaseEntry(f.layer)
|
||||
f.click()
|
||||
f.art.emit('restart')
|
||||
assert.equal(f.calls.some(call => call[0] === 'seek'), false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
assert.equal(f.listeners.size, 0)
|
||||
f.art.playing = true
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.deepEqual(f.plugin.times, { first: 3 })
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('source replacement hides stale prompts before restart and cancels their pending work', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
beginSource(f.art)
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
f.click()
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.equal(f.calls.some(call => call[0] === 'play'), false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('destroy during seek stops play and subsequent DOM writes', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
Object.defineProperty(f.art, 'seek', {
|
||||
get: () => 0,
|
||||
set() {
|
||||
f.scope.dispose()
|
||||
},
|
||||
})
|
||||
f.click()
|
||||
assert.equal(f.calls.some(call => call[0] === 'play'), false)
|
||||
assert.equal(f.art.template.$poster.style.display, undefined)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('destruction stops recording and first-update scheduling', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
f.scope.dispose()
|
||||
f.art.playing = true
|
||||
f.art.emit('video:timeupdate')
|
||||
f.art.emit('restart')
|
||||
assert.equal(f.calls.length, 0)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('old timeout cannot hide a newer prompt and live timeout constants remain effective', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
f.art.emit('video:timeupdate')
|
||||
const stale = [...f.timers.values()][0].callback
|
||||
f.storage.set('times', { first: 18 })
|
||||
f.art.emit('restart')
|
||||
stale()
|
||||
assert.equal(f.layer.style.display, 'flex')
|
||||
f.art.constructor.AUTO_PLAYBACK_TIMEOUT = 200
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.equal(f.timers.size, 1)
|
||||
assert.equal([...f.timers.values()][0].delay, 200)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('a restart inside seek retains the new prompt and stops the old play continuation', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
Object.defineProperty(f.art, 'seek', {
|
||||
get: () => 0,
|
||||
set() {
|
||||
f.storage.set('times', { first: 25 })
|
||||
f.art.emit('restart')
|
||||
},
|
||||
})
|
||||
f.click()
|
||||
assert.equal(f.calls.some(call => call[0] === 'play'), false)
|
||||
assert.equal(f.last.textContent, 'Last Seen 00:25')
|
||||
assert.equal(f.layer.style.display, 'flex')
|
||||
assert.equal(f.listeners.size, 2)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('destroying in localization callbacks prevents later DOM writes', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.i18n.get = () => {
|
||||
f.scope.dispose()
|
||||
return 'late'
|
||||
}
|
||||
f.art.emit('ready')
|
||||
assert.equal(f.last.textContent, '')
|
||||
assert.equal(f.jump.textContent, '')
|
||||
assert.equal(f.listeners.size, 0)
|
||||
})
|
||||
|
||||
test('destroying in storage callbacks prevents later record writes', (t) => {
|
||||
const f = fixture(t)
|
||||
f.storage.get = () => {
|
||||
f.scope.dispose()
|
||||
return { first: 12 }
|
||||
}
|
||||
f.art.playing = true
|
||||
f.art.emit('video:timeupdate')
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
|
||||
test('invalid minimum retains the original comparison rather than showing a prompt', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.constructor.AUTO_PLAYBACK_MIN = Number.NaN
|
||||
f.art.emit('ready')
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
assert.equal(f.listeners.size, 0)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('resume close and internally rejected playback preserve the existing UI contract', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.emit('ready')
|
||||
f.click(f.close)
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
assert.equal(f.calls.length, 0)
|
||||
f.art.emit('restart')
|
||||
f.art.play = () => Promise.reject(new Error('play refused'))
|
||||
f.click()
|
||||
await Promise.resolve()
|
||||
assert.equal(f.layer.style.display, 'none')
|
||||
assert.equal(f.art.template.$poster.style.display, 'none')
|
||||
f.scope.dispose()
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, time = 1) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate((time) => {
|
||||
window.Artplayer.AUTO_PLAYBACK_MIN = 0.5
|
||||
window.Artplayer.AUTO_PLAYBACK_TIMEOUT = 24735
|
||||
localStorage.setItem('artplayer_settings', JSON.stringify({ volume: 0.4, times: { example: time } }))
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', id: 'example', autoPlayback: true, muted: true })
|
||||
window.resume = window.art.plugins['auto-playback']
|
||||
window.promptLayer = window.art.layers['auto-playback']
|
||||
}, time)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.promptLayer.style.display)).toBe('flex')
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: resume close and playback rejection preserve the UI contract`, async ({ page }, testInfo) => {
|
||||
await setup(page, core)
|
||||
await page.locator('.art-auto-playback-close').click()
|
||||
expect(await page.evaluate(() => window.promptLayer.style.display)).toBe('none')
|
||||
await page.evaluate((published) => {
|
||||
window.art.emit('restart', window.art.url)
|
||||
const failure = new Error('controlled play rejection')
|
||||
window.resumeRejections = []
|
||||
if (published) {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (event.reason === failure) {
|
||||
event.preventDefault()
|
||||
window.resumeRejections.push({ name: event.reason.name, message: event.reason.message, same: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
window.art.template.$video.play = () => Promise.reject(failure)
|
||||
}, core === 'published')
|
||||
await page.locator('.art-auto-playback-jump').click()
|
||||
expect(await page.evaluate(() => ({ layer: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ layer: 'none', poster: 'none' })
|
||||
if (core === 'published')
|
||||
await expect.poll(() => page.evaluate(() => window.resumeRejections.length)).toBe(2)
|
||||
const rejections = await page.evaluate(() => window.resumeRejections)
|
||||
expect(rejections).toEqual(core === 'published' ? Array.from({ length: 2 }, () => ({ name: 'Error', message: 'controlled play rejection', same: true })) : [])
|
||||
await testInfo.attach('known-resume-rejections', { contentType: 'application/json', body: JSON.stringify({ core, rejections }) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: resume API retains storage envelope, ID lookup and deletion`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const plugin = window.resume
|
||||
const before = plugin.times
|
||||
const removed = plugin.delete('example')
|
||||
const cleared = plugin.clear()
|
||||
const envelope = JSON.parse(localStorage.getItem('artplayer_settings'))
|
||||
window.art.destroy()
|
||||
return { keys: Object.keys(plugin), name: plugin.name, before, removed, cleared, times: plugin.times, volume: envelope.volume, removedTimes: !Object.hasOwn(envelope, 'times') }
|
||||
})).toEqual({ keys: ['name', 'times', 'clear', 'delete'], name: 'auto-playback', before: { example: 1 }, removed: {}, cleared: undefined, times: {}, volume: 0.4, removedTimes: true })
|
||||
})
|
||||
|
||||
test(`${core}: resume button seeks and plays actual video`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.locator('.art-auto-playback-jump').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
|
||||
expect(await page.evaluate(() => ({ prompt: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ prompt: 'none', poster: 'none' })
|
||||
await expect.poll(() => page.evaluate(() => window.resume.times.example)).toBeGreaterThan(1)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: repeated restart leaves one effective click for the current resume target`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const seeks = []
|
||||
let plays = 0
|
||||
art.on('seek', (_time, requested) => seeks.push(requested))
|
||||
art.template.$video.play = () => {
|
||||
plays++
|
||||
return Promise.resolve()
|
||||
}
|
||||
art.storage.set('times', { example: 1.5 })
|
||||
art.emit('restart', art.url)
|
||||
art.storage.set('times', { example: 2 })
|
||||
art.emit('restart', art.url)
|
||||
window.promptLayer.querySelector('.art-auto-playback-jump').click()
|
||||
const label = window.promptLayer.querySelector('.art-auto-playback-last').textContent
|
||||
art.destroy()
|
||||
return { seeks, plays, label }
|
||||
})
|
||||
expect(result.seeks).toEqual(core === 'candidate' ? [2] : [1, 1.5, 2])
|
||||
expect(result.plays).toBe(core === 'candidate' ? 1 : 3)
|
||||
expect(result.label).toBe('Last Seen 00:02')
|
||||
})
|
||||
|
||||
test(`${core}: restart below threshold invalidates the old prompt click`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
let plays = 0
|
||||
art.template.$video.play = () => {
|
||||
plays++
|
||||
return Promise.resolve()
|
||||
}
|
||||
art.storage.set('times', { example: 0.1 })
|
||||
art.emit('restart', art.url)
|
||||
window.promptLayer.querySelector('.art-auto-playback-jump').click()
|
||||
const result = { hidden: window.promptLayer.style.display, plays }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ hidden: 'none', plays: core === 'candidate' ? 0 : 1 })
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['restart', 'source', 'remove', 'destroy']) {
|
||||
test(`candidate: ${action} cancels obsolete resume timers and subscriptions`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate((action) => {
|
||||
const art = window.art
|
||||
const layer = window.promptLayer
|
||||
const set = window.setTimeout
|
||||
const clear = window.clearTimeout
|
||||
const pending = new Set()
|
||||
const callbacks = []
|
||||
let plays = 0
|
||||
art.template.$video.play = () => {
|
||||
plays++
|
||||
return Promise.resolve()
|
||||
}
|
||||
window.setTimeout = function (callback, delay, ...args) {
|
||||
const timer = set.call(this, callback, delay, ...args)
|
||||
if (delay === 24735) {
|
||||
callbacks.push(callback)
|
||||
pending.add(timer)
|
||||
}
|
||||
return timer
|
||||
}
|
||||
window.clearTimeout = function (timer) {
|
||||
pending.delete(timer)
|
||||
return clear.call(this, timer)
|
||||
}
|
||||
art.emit('video:timeupdate', new Event('timeupdate'))
|
||||
const scheduled = pending.size
|
||||
if (action === 'restart') {
|
||||
art.storage.set('times', { example: 2 })
|
||||
art.emit('restart', art.url)
|
||||
}
|
||||
else if (action === 'source') {
|
||||
art.url = '/test/pattern.mp4?resume=2'
|
||||
}
|
||||
else if (action === 'remove') {
|
||||
art.layers.remove('auto-playback')
|
||||
}
|
||||
else {
|
||||
art.destroy(false)
|
||||
}
|
||||
for (const callback of callbacks)
|
||||
callback()
|
||||
if (action !== 'restart')
|
||||
layer.querySelector('.art-auto-playback-jump').click()
|
||||
const result = { scheduled, remaining: pending.size, display: layer.style.display, plays }
|
||||
window.setTimeout = set
|
||||
window.clearTimeout = clear
|
||||
art.destroy()
|
||||
return result
|
||||
}, action)).toEqual({ scheduled: 1, remaining: 0, display: action === 'restart' ? 'flex' : 'none', plays: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: destruction inside seek prevents resume playback continuation', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
let plays = 0
|
||||
art.template.$video.play = () => {
|
||||
plays++
|
||||
return Promise.resolve()
|
||||
}
|
||||
art.on('seek', () => art.destroy(false))
|
||||
window.promptLayer.querySelector('.art-auto-playback-jump').click()
|
||||
return { destroyed: art.isDestroy, plays }
|
||||
})).toEqual({ destroyed: true, plays: 0 })
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, mobile = true, live = false) {
|
||||
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android builtin fixture' : 'ArtPlayer Desktop builtin fixture' }), mobile)
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate((live) => {
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: '/test/pattern.mp4',
|
||||
muted: true,
|
||||
isLive: live,
|
||||
miniProgressBar: true,
|
||||
lock: true,
|
||||
autoPlayback: true,
|
||||
autoOrientation: true,
|
||||
fastForward: true,
|
||||
})
|
||||
}, live)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const mobile of [true, false]) {
|
||||
for (const live of [true, false]) {
|
||||
test(`${core}: builtin availability keeps mobile=${mobile} live=${live} conditions`, async ({ page }) => {
|
||||
await setup(page, core, mobile, live)
|
||||
expect(await page.evaluate(() => {
|
||||
const names = ['mini-progress-bar', 'lock', 'auto-playback', 'autoOrientation', 'fastForward']
|
||||
const result = names.map(name => Boolean(window.art.plugins[name]))
|
||||
window.art.destroy()
|
||||
return result
|
||||
})).toEqual([!live, mobile, !live, mobile, mobile && !live])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test(`${core}: lock keeps public state, repeated events, DOM classes and icon switching`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const plugin = art.plugins.lock
|
||||
const events = []
|
||||
art.on('lock', state => events.push([state, art.isLock, plugin.state]))
|
||||
plugin.state = true
|
||||
plugin.state = true
|
||||
art.layers.lock.click()
|
||||
const result = {
|
||||
keys: Object.keys(plugin),
|
||||
events,
|
||||
lock: art.layers.lock.querySelector('.art-icon-lock').style.display,
|
||||
unlock: art.layers.lock.querySelector('.art-icon-unlock').style.display,
|
||||
}
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ keys: ['name', 'state'], events: [[true, true, true], [true, true, true], [false, false, false]], lock: 'none', unlock: 'inline-flex' })
|
||||
})
|
||||
|
||||
test(`${core}: removed lock icons stop observing the still-usable plugin state API`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const icon = art.layers.lock.querySelector('.art-icon-lock')
|
||||
art.layers.remove('lock')
|
||||
art.plugins.lock.state = true
|
||||
const result = { flag: art.isLock, state: art.plugins.lock.state, icon: icon.style.display }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ flag: true, state: true, icon: core === 'candidate' ? 'none' : 'inline-flex' })
|
||||
})
|
||||
|
||||
test(`${core}: miniature progress follows the control event without adding public state`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const player = art.template.$player
|
||||
art.emit('control', false)
|
||||
const hiddenControls = player.classList.contains('art-mini-progress-bar')
|
||||
art.emit('control', true)
|
||||
const shownControls = player.classList.contains('art-mini-progress-bar')
|
||||
const keys = Object.keys(art.plugins['mini-progress-bar'])
|
||||
art.destroy()
|
||||
return { hiddenControls, shownControls, keys }
|
||||
})).toEqual({ hiddenControls: true, shownControls: false, keys: ['name'] })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: retained builtin setters and events do not mutate a destroyed player', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.emit('control', true)
|
||||
art.destroy(false)
|
||||
let events = 0
|
||||
art.on('lock', () => events++)
|
||||
art.plugins.lock.state = true
|
||||
art.emit('control', false)
|
||||
return { lock: art.isLock, state: art.plugins.lock.state, mini: art.template.$player.classList.contains('art-mini-progress-bar'), events }
|
||||
})).toEqual({ lock: false, state: false, mini: false, events: 0 })
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android long-press fixture' }))
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.Artplayer.FAST_FORWARD_TIME = 20
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, fastForward: true })
|
||||
window.touch = (type, count = 1) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier, pageX: 50, pageY: 50, clientX: 50, clientY: 50 })) })
|
||||
window.art.template.$video.dispatchEvent(event)
|
||||
}
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate(() => window.art.play())
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.evaluate(() => {
|
||||
window.art.playbackRate = 1.5
|
||||
})
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: native source reset retains its default playback rate behavior`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
window.art.url = '/test/pattern.mp4?reset=1'
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.playbackRate)).toBe(1)
|
||||
expect(await page.evaluate(() => window.art.template.$video.defaultPlaybackRate)).toBe(1)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: long press accelerates real playback and restores its prior rate`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => Object.keys(window.art.plugins.fastForward))).toEqual(['name', 'state'])
|
||||
await page.evaluate(() => window.touch('touchstart'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
|
||||
expect(await page.evaluate(() => window.art.playbackRate)).toBe(3)
|
||||
await page.evaluate(() => window.touch('touchend', 0))
|
||||
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: 1.5 })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: touchcancel releases an active long press`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => window.touch('touchstart'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
|
||||
await page.evaluate(() => window.touch('touchcancel', 0))
|
||||
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: core === 'published', rate: core === 'published' ? 3 : 1.5 })
|
||||
await page.evaluate(() => {
|
||||
window.touch('touchend', 0)
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['lock', 'pause', 'source', 'destroy']) {
|
||||
test(`candidate: ${action} restores the active long-press rate`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => window.touch('touchstart'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
|
||||
await page.evaluate((action) => {
|
||||
const art = window.art
|
||||
if (action === 'lock') {
|
||||
art.isLock = true
|
||||
art.emit('lock', true)
|
||||
}
|
||||
else if (action === 'pause') {
|
||||
art.pause()
|
||||
}
|
||||
else if (action === 'source') {
|
||||
const video = art.template.$video
|
||||
const rate = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate')
|
||||
window.restoredRates = []
|
||||
Object.defineProperty(video, 'playbackRate', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return rate.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
window.restoredRates.push(value)
|
||||
rate.set.call(this, value)
|
||||
},
|
||||
})
|
||||
art.url = '/test/pattern.mp4?replacement=1'
|
||||
}
|
||||
else {
|
||||
art.destroy(false)
|
||||
}
|
||||
}, action)
|
||||
await expect.poll(() => page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: action === 'source' ? 1 : 1.5 })
|
||||
if (action === 'source')
|
||||
expect(await page.evaluate(() => window.restoredRates)).toEqual([1.5])
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: repeated pending touches own a single timer which destruction cancels', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.constructor.FAST_FORWARD_TIME = 24734
|
||||
const set = window.setTimeout
|
||||
const clear = window.clearTimeout
|
||||
const pending = new Set()
|
||||
const callbacks = []
|
||||
window.setTimeout = function (callback, delay, ...args) {
|
||||
const timer = set.call(this, callback, delay, ...args)
|
||||
if (delay === 24734) {
|
||||
pending.add(timer)
|
||||
callbacks.push(callback)
|
||||
}
|
||||
return timer
|
||||
}
|
||||
window.clearTimeout = function (timer) {
|
||||
pending.delete(timer)
|
||||
return clear.call(this, timer)
|
||||
}
|
||||
window.touch('touchstart')
|
||||
window.touch('touchstart')
|
||||
const scheduled = pending.size
|
||||
art.destroy(false)
|
||||
const resetRate = art.playbackRate
|
||||
for (const callback of callbacks)
|
||||
callback()
|
||||
const result = { scheduled, remaining: pending.size, state: art.plugins.fastForward.state, resetRate, rate: art.playbackRate }
|
||||
window.setTimeout = set
|
||||
window.clearTimeout = clear
|
||||
return result
|
||||
})).toEqual({ scheduled: 1, remaining: 0, state: false, resetRate: 1, rate: 1 })
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.Artplayer.NOTICE_TIME = 24733
|
||||
window.art.notice.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: notice updates do not call a consumer override of destroy`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const notice = window.art.notice
|
||||
const original = notice.destroy
|
||||
let calls = 0
|
||||
notice.destroy = function () {
|
||||
calls++
|
||||
return original.call(this)
|
||||
}
|
||||
notice.show = 'first'
|
||||
notice.show = 'second'
|
||||
const implicit = calls
|
||||
notice.destroy()
|
||||
const explicit = calls
|
||||
window.art.destroy()
|
||||
return { implicit, explicit }
|
||||
})).toEqual({ implicit: 0, explicit: 1 })
|
||||
})
|
||||
|
||||
test(`${core}: notice preserves public shape, Error text and false hide semantics`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const notice = art.notice
|
||||
notice.show = new Error(' trimmed ')
|
||||
const visible = notice.show
|
||||
const text = art.template.$noticeInner.textContent
|
||||
const timer = notice.timer
|
||||
notice.show = false
|
||||
const result = {
|
||||
fields: Object.keys(notice),
|
||||
prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(notice)),
|
||||
visible,
|
||||
text,
|
||||
hidden: !notice.show,
|
||||
retained: art.template.$noticeInner.textContent,
|
||||
sameTimer: timer === notice.timer,
|
||||
}
|
||||
notice.destroy()
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ fields: ['art', 'timer'], prototype: ['constructor', 'destroy', 'show'], visible: true, text: 'trimmed', hidden: true, retained: 'trimmed', sameTimer: true })
|
||||
})
|
||||
|
||||
test(`${core}: notice expires naturally during real playback`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => window.art.play())
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.evaluate(() => {
|
||||
window.Artplayer.NOTICE_TIME = 20
|
||||
window.art.notice.show = 'transient'
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => ({ visible: window.art.notice.show, text: window.art.template.$noticeInner.textContent }))).toEqual({ visible: false, text: '' })
|
||||
expect(await page.evaluate(() => window.art.playing)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: manual notice destroy permits reuse and does not hide its DOM`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const notice = window.art.notice
|
||||
notice.show = 'visible'
|
||||
notice.destroy()
|
||||
const visible = notice.show
|
||||
const timer = notice.timer
|
||||
notice.show = 'reused'
|
||||
const reused = window.art.template.$noticeInner.textContent
|
||||
notice.destroy()
|
||||
window.art.destroy()
|
||||
return { visible, timer, reused }
|
||||
})).toEqual({ visible: true, timer: null, reused: 'reused' })
|
||||
})
|
||||
|
||||
test(`${core}: destruction during notice text assignment cannot schedule a late timer`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const notice = art.notice
|
||||
const inner = art.template.$noticeInner
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
|
||||
Object.defineProperty(inner, 'textContent', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return descriptor.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
descriptor.set.call(this, value)
|
||||
art.destroy(false)
|
||||
},
|
||||
})
|
||||
notice.show = 'destroy during write'
|
||||
const result = { destroyed: art.isDestroy, timer: notice.timer !== null, visible: notice.show }
|
||||
notice.destroy()
|
||||
return result
|
||||
})).toEqual({ destroyed: true, timer: core === 'published', visible: core === 'published' })
|
||||
})
|
||||
|
||||
test(`${core}: expiry does not hide a notice installed during the clearing write`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const inner = art.template.$noticeInner
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
|
||||
const original = window.setTimeout
|
||||
let expire
|
||||
window.setTimeout = function (callback, delay, ...args) {
|
||||
if (delay === 24733)
|
||||
expire = callback
|
||||
return original.call(this, callback, delay, ...args)
|
||||
}
|
||||
art.notice.show = 'old'
|
||||
Object.defineProperty(inner, 'textContent', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return descriptor.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
descriptor.set.call(this, value)
|
||||
if (value === '')
|
||||
art.notice.show = 'nested'
|
||||
},
|
||||
})
|
||||
expire()
|
||||
const result = { text: inner.textContent, visible: art.notice.show }
|
||||
window.setTimeout = original
|
||||
art.notice.destroy()
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ text: 'nested', visible: core === 'candidate' })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: retained callbacks and assignments cannot revive destroyed notice resources', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const original = window.setTimeout
|
||||
const callbacks = []
|
||||
window.setTimeout = function (callback, delay, ...args) {
|
||||
if (delay === 24733)
|
||||
callbacks.push(callback)
|
||||
return original.call(this, callback, delay, ...args)
|
||||
}
|
||||
art.notice.show = 'old'
|
||||
art.notice.show = 'new'
|
||||
callbacks[0]()
|
||||
const before = art.template.$noticeInner.textContent
|
||||
art.destroy(false)
|
||||
callbacks[1]()
|
||||
art.notice.show = 'ignored'
|
||||
window.setTimeout = original
|
||||
return { before, after: art.template.$noticeInner.textContent, timer: art.notice.timer, calls: callbacks.length }
|
||||
})).toEqual({ before: 'new', after: 'new', timer: null, calls: 2 })
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, mobile = false) {
|
||||
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android info fixture' : 'ArtPlayer Desktop info fixture' }), mobile)
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: mask preserves observations before and after its destroy handler`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
expect(await page.evaluate(() => {
|
||||
const seen = []
|
||||
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
|
||||
name: 'observer',
|
||||
html: '',
|
||||
mounted() {
|
||||
this.on('destroy', () => seen.push(['before', this.template.$state.querySelector('.art-icon-error').style.display]))
|
||||
},
|
||||
}] })
|
||||
art.on('destroy', () => seen.push(['after', art.template.$state.querySelector('.art-icon-error').style.display]))
|
||||
art.destroy(false)
|
||||
return seen
|
||||
})).toEqual([['before', 'none'], ['after', '']])
|
||||
})
|
||||
|
||||
test(`${core}: info preserves fields, native media text and close visibility`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.template.$video.volume = 0.4
|
||||
art.info.init()
|
||||
const volume = art.template.$infoPanel.querySelector('[data-video="volume"]').textContent
|
||||
const source = art.template.$infoPanel.querySelector('[data-video="currentSrc"]').textContent === art.template.$video.currentSrc
|
||||
art.info.show = true
|
||||
art.template.$infoClose.click()
|
||||
const result = { fields: Object.keys(art.info), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(art.info)), volume, source, visible: art.info.show }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ fields: ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'], prototype: ['constructor', 'init'], volume: '0.40', source: true, visible: false })
|
||||
})
|
||||
|
||||
test(`${core}: info retains native nullable string conversion for custom media fields`, async ({ page }) => {
|
||||
await setup(page, core, true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const item = document.createElement('span')
|
||||
item.dataset.video = 'fixtureValue'
|
||||
art.template.$infoPanel.append(item)
|
||||
const values = [null, undefined, true, 1.234, Number.NaN, Infinity, { toString: () => 'custom' }]
|
||||
const result = values.map((value) => {
|
||||
art.template.$video.fixtureValue = value
|
||||
art.info.init()
|
||||
return item.textContent
|
||||
})
|
||||
art.template.$video.fixtureValue = Symbol('not text')
|
||||
let failed
|
||||
try {
|
||||
art.info.init()
|
||||
}
|
||||
catch (error) {
|
||||
failed = error.name
|
||||
}
|
||||
art.destroy()
|
||||
return { result, failed }
|
||||
})).toEqual({ result: ['', '', 'true', '1.23', 'NaN', 'Infinity', 'custom'], failed: 'TypeError' })
|
||||
})
|
||||
|
||||
test(`${core}: repeated info init owns only one new timer and close handler`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const set = window.setTimeout
|
||||
const clear = window.clearTimeout
|
||||
const pending = new Set()
|
||||
window.setTimeout = function (callback, delay, ...args) {
|
||||
const timer = set.call(this, callback, delay, ...args)
|
||||
if (delay === 24736)
|
||||
pending.add(timer)
|
||||
return timer
|
||||
}
|
||||
window.clearTimeout = function (timer) {
|
||||
pending.delete(timer)
|
||||
return clear.call(this, timer)
|
||||
}
|
||||
art.constructor.INFO_LOOP_TIME = 24736
|
||||
art.info.init()
|
||||
art.info.init()
|
||||
let closed = 0
|
||||
art.on('info', state => !state && closed++)
|
||||
art.info.show = true
|
||||
art.template.$infoClose.click()
|
||||
const timers = pending.size
|
||||
art.destroy()
|
||||
const result = { timers, closed, remaining: pending.size }
|
||||
window.setTimeout = set
|
||||
window.clearTimeout = clear
|
||||
return result
|
||||
})).toEqual({ timers: core === 'candidate' ? 1 : 2, closed: core === 'candidate' ? 1 : 3, remaining: 0 })
|
||||
})
|
||||
|
||||
test(`${core}: info mobile construction waits for an explicit init`, async ({ page }) => {
|
||||
await setup(page, core, true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
|
||||
const before = node.textContent
|
||||
art.template.$video.volume = 0.4
|
||||
art.info.init()
|
||||
const after = node.textContent
|
||||
art.destroy()
|
||||
return { before, after }
|
||||
})).toEqual({ before: '', after: '0.40' })
|
||||
})
|
||||
|
||||
test(`${core}: mask click plays native video and destruction displays the terminal icon`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => window.art.template.$state.click())
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const state = art.template.$state.querySelector('.art-icon-state')
|
||||
const error = art.template.$state.querySelector('.art-icon-error')
|
||||
art.loading.show = true
|
||||
const loading = art.loading.show
|
||||
const icon = Boolean(art.template.$loading.querySelector('.art-icon-loading'))
|
||||
art.destroy(false)
|
||||
return { loading, icon, state: state.style.display, error: error.style.display }
|
||||
})).toEqual({ loading: true, icon: true, state: 'none', error: '' })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: info destruction during a media getter prevents text writes and late init', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
|
||||
const before = node.textContent
|
||||
Object.defineProperty(art.template.$video, 'volume', { configurable: true, get() {
|
||||
art.destroy(false)
|
||||
return 0.8
|
||||
} })
|
||||
art.info.init()
|
||||
const after = node.textContent
|
||||
art.info.init()
|
||||
return { before, after, destroyed: art.isDestroy }
|
||||
})
|
||||
expect(result.after).toBe(result.before)
|
||||
expect(result.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
test('candidate: reentrant info text conversion cannot write after player destruction', async ({ page }) => {
|
||||
await setup(page, 'candidate', true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const item = document.createElement('span')
|
||||
item.dataset.video = 'fixtureValue'
|
||||
item.textContent = 'before'
|
||||
art.template.$infoPanel.append(item)
|
||||
art.template.$video.fixtureValue = { toString() {
|
||||
art.destroy(false)
|
||||
return 'late'
|
||||
} }
|
||||
art.info.init()
|
||||
return { text: item.textContent, destroyed: art.isDestroy }
|
||||
})).toEqual({ text: 'before', destroyed: true })
|
||||
})
|
||||
|
||||
test('candidate: mask finalization follows an earlier throwing observer without replacing its error', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
expect(await page.evaluate(() => {
|
||||
const failure = new Error('early observer failed')
|
||||
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
|
||||
name: 'observer',
|
||||
html: '',
|
||||
mounted() {
|
||||
this.on('destroy', () => {
|
||||
throw failure
|
||||
})
|
||||
},
|
||||
}] })
|
||||
let original = false
|
||||
try {
|
||||
art.destroy(false)
|
||||
}
|
||||
catch (error) {
|
||||
original = error === failure
|
||||
}
|
||||
return { original, destroyed: art.isDestroy, state: art.template.$state.querySelector('.art-icon-state').style.display, error: art.template.$state.querySelector('.art-icon-error').style.display }
|
||||
})).toEqual({ original: true, destroyed: true, state: 'none', error: '' })
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
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 { lock, miniProgressBar, Emitter, beginLifecycle, getScope, ownEntry, releaseEntry } = await loadModules({
|
||||
lock: 'packages/artplayer/src/plugins/lock',
|
||||
miniProgressBar: 'packages/artplayer/src/plugins/miniProgressBar',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
|
||||
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
|
||||
})
|
||||
|
||||
function fixture(t) {
|
||||
class Element {
|
||||
style = {}
|
||||
children = []
|
||||
appendChild(child) {
|
||||
this.children.push(child)
|
||||
this.lastElementChild = child
|
||||
}
|
||||
}
|
||||
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'))
|
||||
const classes = new Set()
|
||||
const classList = { add: value => classes.add(value), remove: value => classes.delete(value), contains: value => classes.has(value) }
|
||||
const element = new Element()
|
||||
let option
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $player: { classList } },
|
||||
isLock: false,
|
||||
icons: { lock: new Element(), unlock: new Element() },
|
||||
layers: { add(value) {
|
||||
option = value
|
||||
ownEntry(art, element)
|
||||
option.mounted(element)
|
||||
return element
|
||||
} },
|
||||
})
|
||||
beginLifecycle(art)
|
||||
const plugin = lock(art)
|
||||
return { art, plugin, classes, classList, element, click: () => option.click(), scope: getScope(art) }
|
||||
}
|
||||
|
||||
test('lock preserves state/class/flag ordering, repeated notifications and icon visibility', (t) => {
|
||||
const f = fixture(t)
|
||||
assert.deepEqual(Object.keys(f.plugin), ['name', 'state'])
|
||||
const seen = []
|
||||
f.art.on('lock', state => seen.push([state, f.plugin.state, f.art.isLock]))
|
||||
f.plugin.state = true
|
||||
f.plugin.state = true
|
||||
assert.equal(f.art.icons.lock.style.display, 'inline-flex')
|
||||
assert.equal(f.art.icons.unlock.style.display, 'none')
|
||||
f.click()
|
||||
assert.equal(f.art.icons.lock.style.display, 'none')
|
||||
assert.equal(f.art.icons.unlock.style.display, 'inline-flex')
|
||||
assert.deepEqual(seen, [[true, true, true], [true, true, true], [false, false, false]])
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('lock icon subscriptions are removed with the layer but the live state API remains usable', (t) => {
|
||||
const f = fixture(t)
|
||||
releaseEntry(f.element)
|
||||
f.plugin.state = true
|
||||
assert.equal(f.plugin.state, true)
|
||||
assert.equal(f.art.isLock, true)
|
||||
assert.equal(f.art.icons.lock.style.display, 'none')
|
||||
assert.equal(f.art.e.lock, undefined)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('destroyed lock does not emit or mutate state through retained setters and clicks', (t) => {
|
||||
const f = fixture(t)
|
||||
f.scope.dispose()
|
||||
let events = 0
|
||||
f.art.on('lock', () => events++)
|
||||
f.plugin.state = true
|
||||
f.click()
|
||||
assert.equal(f.plugin.state, false)
|
||||
assert.equal(f.art.isLock, false)
|
||||
assert.equal(events, 0)
|
||||
})
|
||||
|
||||
test('destruction during lock class mutation stops the subsequent flag and event', (t) => {
|
||||
const f = fixture(t)
|
||||
const original = f.classList.add
|
||||
f.classList.add = (name) => {
|
||||
original(name)
|
||||
f.scope.dispose()
|
||||
}
|
||||
let events = 0
|
||||
f.art.on('lock', () => events++)
|
||||
f.plugin.state = true
|
||||
assert.equal(f.art.isLock, false)
|
||||
assert.equal(events, 0)
|
||||
})
|
||||
|
||||
test('miniProgressBar preserves its control-state class and releases the subscription', (t) => {
|
||||
const f = fixture(t)
|
||||
assert.deepEqual(miniProgressBar(f.art), { name: 'mini-progress-bar' })
|
||||
f.art.emit('control', false)
|
||||
assert.equal(f.classes.has('art-mini-progress-bar'), true)
|
||||
f.art.emit('control', true)
|
||||
assert.equal(f.classes.has('art-mini-progress-bar'), false)
|
||||
f.scope.dispose()
|
||||
f.art.emit('control', false)
|
||||
assert.equal(f.classes.has('art-mini-progress-bar'), false)
|
||||
assert.equal(f.art.e.control, undefined)
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
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 { fastForward, Emitter, beginLifecycle, getScope, beginSource } = await loadModules({
|
||||
fastForward: 'packages/artplayer/src/plugins/fastForward',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
||||
})
|
||||
|
||||
function fixture(t) {
|
||||
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 video = new EventTarget()
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $video: video, $player: { classList: {
|
||||
add: value => classes.add(value),
|
||||
remove: value => classes.delete(value),
|
||||
contains: value => classes.has(value),
|
||||
} } },
|
||||
constructor: { FAST_FORWARD_TIME: 100, FAST_FORWARD_VALUE: 3 },
|
||||
playing: true,
|
||||
isLock: false,
|
||||
playbackRate: 1.5,
|
||||
proxy(target, name, callback) {
|
||||
target.addEventListener(name, callback)
|
||||
const remove = () => target.removeEventListener(name, callback)
|
||||
getScope(art).add(() => {
|
||||
remove()
|
||||
})
|
||||
return remove
|
||||
},
|
||||
})
|
||||
beginLifecycle(art)
|
||||
beginSource(art)
|
||||
const plugin = fastForward(art)
|
||||
const start = (count = 1) => {
|
||||
const event = new Event('touchstart')
|
||||
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier })) })
|
||||
video.dispatchEvent(event)
|
||||
}
|
||||
const flush = () => {
|
||||
const callbacks = [...timers.values()]
|
||||
timers.clear()
|
||||
for (const { callback } of callbacks)
|
||||
callback()
|
||||
}
|
||||
return { art, plugin, start, flush, timers, video, scope: getScope(art) }
|
||||
}
|
||||
|
||||
test('fastForward preserves name, state, live constants and previous playback rate', (t) => {
|
||||
const f = fixture(t)
|
||||
assert.deepEqual(Object.keys(f.plugin), ['name', 'state'])
|
||||
assert.equal(f.plugin.name, 'fastForward')
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.art.constructor.FAST_FORWARD_TIME = 200
|
||||
f.start()
|
||||
assert.equal([...f.timers.values()][0].delay, 200)
|
||||
f.art.constructor.FAST_FORWARD_VALUE = 4
|
||||
f.flush()
|
||||
assert.equal(f.art.playbackRate, 4)
|
||||
assert.equal(f.plugin.state, true)
|
||||
f.art.emit('document:touchend', new Event('touchend'))
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('fastForward only schedules a single playing unlocked touch', (t) => {
|
||||
const f = fixture(t)
|
||||
f.start(2)
|
||||
f.art.playing = false
|
||||
f.start()
|
||||
f.art.playing = true
|
||||
f.art.isLock = true
|
||||
f.start()
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('repeated touch starts do not leave timers or overwrite the original rate', (t) => {
|
||||
const f = fixture(t)
|
||||
f.start()
|
||||
f.start()
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.flush()
|
||||
f.start(2)
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
for (const event of ['document:touchmove', 'document:touchend', 'document:touchcancel', 'video:pause', 'destroy']) {
|
||||
test(`fastForward cancels pending and active presses on ${event}`, (t) => {
|
||||
const f = fixture(t)
|
||||
f.start()
|
||||
const stale = [...f.timers.values()][0].callback
|
||||
f.art.emit(event, new Event(event))
|
||||
assert.equal(f.timers.size, 0)
|
||||
stale()
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
f.start()
|
||||
f.flush()
|
||||
assert.equal(f.plugin.state, true)
|
||||
f.art.emit(event, new Event(event))
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.scope.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
test('native touchcancel, lock, source replacement and destruction release the press', (t) => {
|
||||
const f = fixture(t)
|
||||
f.start()
|
||||
f.video.dispatchEvent(new Event('touchcancel'))
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.start()
|
||||
f.flush()
|
||||
f.art.isLock = true
|
||||
f.art.emit('lock', true)
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
f.art.isLock = false
|
||||
f.start()
|
||||
f.flush()
|
||||
beginSource(f.art)
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.start()
|
||||
f.flush()
|
||||
f.scope.dispose()
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.start()
|
||||
f.art.emit('lock', false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('destruction inside the playback rate setter cannot leave an active class', (t) => {
|
||||
const f = fixture(t)
|
||||
let rate = 1.5
|
||||
Object.defineProperty(f.art, 'playbackRate', {
|
||||
get: () => rate,
|
||||
set(value) {
|
||||
rate = value
|
||||
if (value === 3)
|
||||
f.scope.dispose()
|
||||
},
|
||||
})
|
||||
f.start()
|
||||
f.flush()
|
||||
assert.equal(rate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('failed rate assignments release press state and preserve the thrown value', (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('rate refused')
|
||||
Object.defineProperty(f.art, 'playbackRate', {
|
||||
get: () => 1.5,
|
||||
set(value) {
|
||||
if (value === 3)
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
f.start()
|
||||
assert.throws(f.flush, error => error === failure)
|
||||
assert.equal(f.plugin.state, false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('source replacement cancels pending work and callback-time flags are rechecked', (t) => {
|
||||
const f = fixture(t)
|
||||
f.start()
|
||||
const stale = [...f.timers.values()][0].callback
|
||||
beginSource(f.art)
|
||||
assert.equal(f.timers.size, 0)
|
||||
stale()
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
f.start()
|
||||
f.art.playing = false
|
||||
f.flush()
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.art.playing = true
|
||||
f.start()
|
||||
f.art.isLock = true
|
||||
f.flush()
|
||||
assert.equal(f.art.playbackRate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('a new press started during rate restoration supersedes the outer restart', (t) => {
|
||||
const f = fixture(t)
|
||||
f.start()
|
||||
f.flush()
|
||||
let rate = 3
|
||||
let reentered = false
|
||||
Object.defineProperty(f.art, 'playbackRate', {
|
||||
get: () => rate,
|
||||
set(value) {
|
||||
rate = value
|
||||
if (value === 1.5 && !reentered) {
|
||||
reentered = true
|
||||
f.start()
|
||||
f.flush()
|
||||
}
|
||||
},
|
||||
})
|
||||
f.start()
|
||||
assert.equal(f.timers.size, 0)
|
||||
assert.equal(f.plugin.state, true)
|
||||
assert.equal(rate, 3)
|
||||
f.art.emit('document:touchend', new Event('touchend'))
|
||||
assert.equal(rate, 1.5)
|
||||
assert.equal(f.plugin.state, false)
|
||||
f.scope.dispose()
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { Notice, Emitter, beginLifecycle, getScope } = await loadModules({
|
||||
Notice: 'packages/artplayer/src/notice',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture(t) {
|
||||
const timers = new Map()
|
||||
let id = 0
|
||||
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 classList = {
|
||||
add: value => classes.add(value),
|
||||
remove: value => classes.delete(value),
|
||||
contains: value => classes.has(value),
|
||||
}
|
||||
const inner = { textContent: '' }
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $player: { classList }, $noticeInner: inner },
|
||||
constructor: { NOTICE_TIME: 100 },
|
||||
})
|
||||
beginLifecycle(art)
|
||||
const scope = getScope(art)
|
||||
const notice = new Notice(art)
|
||||
return { art, scope, notice, inner, classList, timers }
|
||||
}
|
||||
|
||||
test('notice preserves fields, descriptors, text, live delay and hide semantics', (t) => {
|
||||
const f = fixture(t)
|
||||
assert.deepEqual(Object.keys(f.notice), ['art', 'timer'])
|
||||
assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(f.notice)), ['constructor', 'destroy', 'show'])
|
||||
assert.equal(f.notice.show, false)
|
||||
assert.equal(f.notice.timer, null)
|
||||
f.notice.show = new Error(' example ')
|
||||
assert.equal(f.inner.textContent, 'example')
|
||||
assert.equal(f.notice.show, true)
|
||||
assert.equal(f.timers.get(f.notice.timer).delay, 100)
|
||||
f.art.constructor.NOTICE_TIME = 200
|
||||
f.notice.show = ' next '
|
||||
assert.equal(f.inner.textContent, ' next ')
|
||||
assert.equal(f.timers.size, 1)
|
||||
assert.equal(f.timers.get(f.notice.timer).delay, 200)
|
||||
f.notice.show = ''
|
||||
assert.equal(f.notice.show, false)
|
||||
assert.equal(f.inner.textContent, ' next ')
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.timers.get(f.notice.timer).callback()
|
||||
assert.equal(f.inner.textContent, '')
|
||||
})
|
||||
|
||||
test('manual notice destroy cancels its timer and permits reuse while alive', (t) => {
|
||||
const f = fixture(t)
|
||||
f.notice.show = 'visible'
|
||||
assert.equal(f.notice.destroy(), undefined)
|
||||
assert.equal(f.notice.timer, null)
|
||||
assert.equal(f.timers.size, 0)
|
||||
assert.equal(f.notice.show, true)
|
||||
assert.equal(f.inner.textContent, 'visible')
|
||||
f.notice.destroy()
|
||||
f.notice.show = 'reused'
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.art.emit('destroy')
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.notice.show = 'again'
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.scope.dispose()
|
||||
f.notice.show = 'ignored'
|
||||
assert.equal(f.timers.size, 0)
|
||||
assert.equal(f.inner.textContent, 'again')
|
||||
})
|
||||
|
||||
test('notice does not allocate a timer after destruction during DOM text writes', (t) => {
|
||||
const f = fixture(t)
|
||||
Object.defineProperty(f.inner, 'textContent', {
|
||||
get: () => '',
|
||||
set() {
|
||||
f.scope.dispose()
|
||||
},
|
||||
})
|
||||
f.notice.show = 'destroy during setter'
|
||||
assert.equal(f.notice.timer, null)
|
||||
assert.equal(f.timers.size, 0)
|
||||
assert.equal(f.notice.show, false)
|
||||
})
|
||||
|
||||
test('obsolete notice timer callbacks cannot erase a newer message', (t) => {
|
||||
const f = fixture(t)
|
||||
f.notice.show = 'old'
|
||||
const stale = f.timers.get(f.notice.timer).callback
|
||||
f.notice.show = 'new'
|
||||
stale()
|
||||
assert.equal(f.inner.textContent, 'new')
|
||||
assert.equal(f.notice.show, true)
|
||||
f.scope.dispose()
|
||||
stale()
|
||||
assert.equal(f.inner.textContent, 'new')
|
||||
})
|
||||
|
||||
test('notice expiry does not hide a message installed by a nested DOM write', (t) => {
|
||||
const f = fixture(t)
|
||||
f.notice.show = 'old'
|
||||
const expire = f.timers.get(f.notice.timer).callback
|
||||
let value = 'old'
|
||||
Object.defineProperty(f.inner, 'textContent', {
|
||||
get: () => value,
|
||||
set(next) {
|
||||
value = next
|
||||
if (next === '')
|
||||
f.notice.show = 'nested'
|
||||
},
|
||||
})
|
||||
expire()
|
||||
assert.equal(f.inner.textContent, 'nested')
|
||||
assert.equal(f.notice.show, true)
|
||||
assert.equal(f.timers.size, 1)
|
||||
})
|
||||
|
||||
test('nested notice writes keep only the newest timer', (t) => {
|
||||
const f = fixture(t)
|
||||
let value = ''
|
||||
Object.defineProperty(f.inner, 'textContent', {
|
||||
get: () => value,
|
||||
set(next) {
|
||||
value = next
|
||||
if (next === 'outer')
|
||||
f.notice.show = 'inner'
|
||||
},
|
||||
})
|
||||
f.notice.show = 'outer'
|
||||
assert.equal(f.inner.textContent, 'inner')
|
||||
assert.equal(f.notice.show, true)
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.scope.dispose()
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('notice message updates do not invoke a consumer override of the public destroy method', (t) => {
|
||||
const f = fixture(t)
|
||||
let calls = 0
|
||||
const original = f.notice.destroy
|
||||
f.notice.destroy = function () {
|
||||
calls++
|
||||
return original.call(this)
|
||||
}
|
||||
f.notice.show = 'first'
|
||||
f.notice.show = 'second'
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(f.inner.textContent, 'second')
|
||||
f.notice.destroy()
|
||||
assert.equal(calls, 1)
|
||||
f.scope.dispose()
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
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) {
|
||||
class Element {
|
||||
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, $infoPanel: panel, $infoClose: new Element(), $video: { currentTime: 1.234, src: 'first' }, $loading: new Element(), $state: new Element() },
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AutoPlaybackHost } from '../../packages/artplayer/src/plugins/autoPlayback'
|
||||
import autoPlayback from '../../packages/artplayer/src/plugins/autoPlayback'
|
||||
|
||||
declare const host: AutoPlaybackHost
|
||||
const plugin = autoPlayback(host)
|
||||
const times: Record<string, number> = plugin.times
|
||||
const remaining: Record<string, number> = plugin.delete('example')
|
||||
const cleared: void = plugin.clear()
|
||||
// @ts-expect-error Record deletion takes the persisted string key.
|
||||
plugin.delete({ id: 'example' })
|
||||
// @ts-expect-error Playback times contain numbers, not text.
|
||||
const invalid: Record<string, string> = plugin.times
|
||||
// @ts-expect-error The plugin needs both recording and prompt resources.
|
||||
autoPlayback({ storage: host.storage })
|
||||
export { cleared, invalid, remaining, times }
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LockHost } from '../../packages/artplayer/src/plugins/lock'
|
||||
import type { MiniProgressHost } from '../../packages/artplayer/src/plugins/miniProgressBar'
|
||||
import lock from '../../packages/artplayer/src/plugins/lock'
|
||||
import miniProgressBar from '../../packages/artplayer/src/plugins/miniProgressBar'
|
||||
|
||||
declare const host: LockHost & MiniProgressHost
|
||||
const plugin = lock(host)
|
||||
plugin.state = true
|
||||
const state: boolean = plugin.state
|
||||
const mini: { name: string } = miniProgressBar(host)
|
||||
// @ts-expect-error Lock state takes a boolean.
|
||||
plugin.state = 'locked'
|
||||
// @ts-expect-error The miniature progress plugin has no public state accessor.
|
||||
miniProgressBar(host).state = true
|
||||
export { mini, state }
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FastForwardHost } from '../../packages/artplayer/src/plugins/fastForward'
|
||||
import fastForward from '../../packages/artplayer/src/plugins/fastForward'
|
||||
|
||||
declare const host: FastForwardHost
|
||||
const plugin = fastForward(host)
|
||||
const visible: boolean = plugin.state
|
||||
const name: string = plugin.name
|
||||
// @ts-expect-error Fast-forward state is read-only.
|
||||
plugin.state = true
|
||||
// @ts-expect-error A playing flag does not provide the plugin's media/event host.
|
||||
fastForward({ playing: true })
|
||||
export { name, visible }
|
||||
@@ -0,0 +1,22 @@
|
||||
import type Notice from '../../packages/artplayer/src/notice'
|
||||
import type Artplayer from '../../packages/artplayer/types/artplayer'
|
||||
|
||||
declare const notice: Notice
|
||||
declare const art: Artplayer
|
||||
const visible: boolean = notice.show
|
||||
notice.show = 'message'
|
||||
notice.show = new Error('message')
|
||||
notice.show = false
|
||||
notice.show = ''
|
||||
const timer: number | null = notice.timer
|
||||
const destroyed: void = notice.destroy()
|
||||
// @ts-expect-error The runtime getter returns visibility, not message text.
|
||||
const text: string = notice.show
|
||||
// @ts-expect-error True is not a declared message input.
|
||||
notice.show = true
|
||||
// @ts-expect-error Arbitrary objects are not declared message inputs.
|
||||
notice.show = {}
|
||||
// Preserve old legal consumers until CORE-21 coordinates an explicit precise view.
|
||||
const legacy: string | Error | false = art.notice.show
|
||||
art.notice.show = false
|
||||
export { destroyed, legacy, text, timer, visible }
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { InfoHost } from '../../packages/artplayer/src/info/poll'
|
||||
import type { LoadingHost } from '../../packages/artplayer/src/loading'
|
||||
import type { MaskHost } from '../../packages/artplayer/src/mask'
|
||||
import Info from '../../packages/artplayer/src/info'
|
||||
import Loading from '../../packages/artplayer/src/loading'
|
||||
import Mask from '../../packages/artplayer/src/mask'
|
||||
|
||||
declare const host: InfoHost & LoadingHost & MaskHost
|
||||
const info = new Info(host)
|
||||
const initialized: void = info.init()
|
||||
const loading = new Loading(host)
|
||||
const mask = new Mask(host)
|
||||
loading.show = true
|
||||
mask.show = false
|
||||
const visible: boolean = info.show
|
||||
// @ts-expect-error A polling host needs its media and panel resources.
|
||||
const invalid = new Info({ constructor: { INFO_LOOP_TIME: 1 } })
|
||||
// @ts-expect-error Component visibility is boolean.
|
||||
loading.show = 'visible'
|
||||
export { initialized, invalid, visible }
|
||||
Reference in new issue
Block a user