mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 14:41:20 -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,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: '' })
|
||||
})
|
||||
Reference in new issue
Block a user