mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-20 04:01:20 -08:00
refactor(core): CORE-13 type component registries and own control lifetimes
This commit is contained in:
1 parent
9eb7c945ca
commit
697817eceb
68 files changed
+10100
-1925
No files matched your search
@@ -0,0 +1,378 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const fallback of [false, true]) {
|
||||
test(`candidate: control layout follows resize and removal with observer fallback=${fallback}`, async ({ page }) => {
|
||||
if (fallback) {
|
||||
await page.addInitScript(() => {
|
||||
window.ResizeObserver = undefined
|
||||
})
|
||||
}
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => {
|
||||
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, fullscreenWeb: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }] })
|
||||
})
|
||||
const heights = () => page.evaluate(() => ({ actual: window.art.template.$controls.offsetHeight, recorded: Number.parseFloat(window.art.template.$player.style.getPropertyValue('--art-controls-height')) }))
|
||||
await expect.poll(heights).toEqual({ actual: 46, recorded: 46 })
|
||||
await page.evaluate(() => {
|
||||
window.art.template.$container.style.width = '320px'
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
await expect.poll(heights).toEqual({ actual: 92, recorded: 92 })
|
||||
await expect.poll(() => page.evaluate(() => Number.parseFloat(getComputedStyle(window.art.template.$subtitle).bottom))).toBeGreaterThanOrEqual(92)
|
||||
await page.evaluate(() => window.art.controls.remove('probe'))
|
||||
await expect.poll(heights).toEqual({ actual: 46, recorded: 46 })
|
||||
await page.evaluate(() => {
|
||||
window.art.destroy(false)
|
||||
window.art.template.$controls.style.display = 'none'
|
||||
window.art.emit('resize')
|
||||
})
|
||||
await expect.poll(heights).toEqual({ actual: 0, recorded: 46 })
|
||||
})
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: selector background clicks and rejection ownership are explicit`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
const failure = new Error('selection failure')
|
||||
window.selectionReports = { warnings: [], rejections: [] }
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault()
|
||||
window.selectionReports.rejections.push({ name: event.reason.name, same: event.reason === failure })
|
||||
})
|
||||
const original = console.warn
|
||||
console.warn = (message, error) => {
|
||||
window.selectionReports.warnings.push({ message, same: error === failure })
|
||||
original.call(console, message, error)
|
||||
}
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
window.art = art
|
||||
art.controls.add({ name: 'selector', position: 'right', html: 'initial', selector: [{ html: 'A' }], onSelect() {
|
||||
return Promise.reject(failure)
|
||||
} })
|
||||
art.controls.selector.querySelector('.art-selector-list').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
if (core === 'published')
|
||||
await expect.poll(() => page.evaluate(() => window.selectionReports.rejections.length)).toBe(1)
|
||||
await page.evaluate(() => window.art.controls.selector.querySelector('.art-selector-item').dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
await expect.poll(() => page.evaluate(() => window.selectionReports.warnings.length + window.selectionReports.rejections.length)).toBe(core === 'published' ? 2 : 1)
|
||||
const reports = await page.evaluate(() => window.selectionReports)
|
||||
expect(reports).toEqual(core === 'published'
|
||||
? { warnings: [], rejections: [{ name: 'TypeError', same: false }, { name: 'Error', same: true }] }
|
||||
: { warnings: [{ message: 'ArtPlayer selector failed:', same: true }], rejections: [] })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: beforeUnmount errors remain retryable and detached nodes can be removed`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const failure = new Error('before unmount')
|
||||
const option = { name: 'entry', html: 'entry', beforeUnmount() {
|
||||
throw failure
|
||||
} }
|
||||
const element = art.layers.add(option)
|
||||
let same = false
|
||||
try {
|
||||
art.layers.remove('entry')
|
||||
}
|
||||
catch (error) { same = error === failure }
|
||||
const retained = art.layers.cache.has('entry') && element.isConnected
|
||||
option.beforeUnmount = () => {}
|
||||
element.remove()
|
||||
let error = null
|
||||
try {
|
||||
art.layers.remove('entry')
|
||||
}
|
||||
catch (caught) { error = caught.name }
|
||||
const result = { same, retained, error, removed: !art.layers.cache.has('entry') }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ same: true, retained: true, error: core === 'published' ? 'TypeError' : null, removed: true })
|
||||
})
|
||||
|
||||
test(`${core}: failed mounted reentry preserves its successful replacement`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const failure = new Error('outer failure')
|
||||
let same = false
|
||||
try {
|
||||
art.layers.add({ name: 'entry', html: 'outer', mounted() {
|
||||
art.layers.remove('entry')
|
||||
art.layers.add({ name: 'entry', html: 'replacement' })
|
||||
throw failure
|
||||
} })
|
||||
}
|
||||
catch (error) { same = error === failure }
|
||||
const result = { same, value: art.layers.entry.textContent, attached: art.layers.entry.isConnected, entries: art.layers.cache.size }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ same: true, value: 'replacement', attached: true, entries: 1 })
|
||||
})
|
||||
|
||||
test(`${core}: context menu builtin selection and owned updates preserve state`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', playbackRate: true, aspectRatio: true, flip: true })
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
for (const [name, value] of [['playbackRate', '1.5'], ['aspectRatio', '4:3'], ['flip', 'horizontal']]) {
|
||||
await page.locator('.art-video-player').click({ button: 'right', position: { x: 100, y: 80 } })
|
||||
await page.locator(`.art-contextmenu-${name} [data-value="${value}"]`).click()
|
||||
await expect.poll(() => page.evaluate(name => window.art.contextmenu[name].querySelector('.art-current')?.dataset.value, name)).toBe(value)
|
||||
expect(await page.evaluate(() => window.art.contextmenu.show)).toBe(false)
|
||||
}
|
||||
const result = await page.evaluate(() => {
|
||||
const { art } = window
|
||||
const menu = art.contextmenu.flip
|
||||
art.contextmenu.remove('flip')
|
||||
art.flip = 'normal'
|
||||
const result = { rate: art.playbackRate, aspect: art.aspectRatio, changedAfterRemove: menu.querySelector('[data-value="normal"]').classList.contains('art-current') }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ rate: 1.5, aspect: '4:3', changedAfterRemove: core === 'published' })
|
||||
})
|
||||
|
||||
test(`${core}: a prototype-named component keeps the registry prototype intact`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const prototype = Object.getPrototypeOf(art.layers)
|
||||
const element = art.layers.add({ name: '__proto__', html: 'named' })
|
||||
const alias = Reflect.get(art.layers, '__proto__') === element
|
||||
art.layers.remove('__proto__')
|
||||
const result = { alias, preserved: Object.getPrototypeOf(art.layers) === prototype }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ alias: true, preserved: core === 'candidate' })
|
||||
})
|
||||
|
||||
test(`${core}: component factories, ordering, bound methods and update hooks retain contracts`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const { add, update, remove } = art.layers
|
||||
const calls = []
|
||||
const first = { name: 'first', index: 10, html: 'first', mounted(element) {
|
||||
calls.push(['mount', this === art, element.textContent])
|
||||
} }
|
||||
add(first)
|
||||
add({ name: 'equal', index: 10, html: 'equal' })
|
||||
let factory
|
||||
add((owner) => {
|
||||
factory = owner === art
|
||||
return { name: 'zero', index: 0, html: 42 }
|
||||
})
|
||||
const disabled = { html: 0, disable: true }
|
||||
const before = art.layers.id
|
||||
const disabledResult = add(disabled)
|
||||
const order = Array.from(art.template.$layer.children, node => node.textContent)
|
||||
const updated = update({ name: 'first', html: 'changed', beforeUnmount(element) {
|
||||
calls.push(['unmount', this === art, element.textContent, first.html])
|
||||
} })
|
||||
const controlResult = art.controls.add({ name: 'probe', position: 'right', html: 'probe', click(component, event) {
|
||||
calls.push(['click', this === art, component === art.controls, event.defaultPrevented])
|
||||
} })
|
||||
art.controls.probe.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
const controlUpdate = art.controls.update({ name: 'probe', html: 'updated' })
|
||||
const result = { factory, order, disabled: disabledResult === undefined && art.layers.id === before + 1 && disabled.html === '', updated: updated === art.layers.first, controlReturns: [controlResult === undefined, controlUpdate === undefined], calls }
|
||||
remove('first')
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result.factory).toBe(true)
|
||||
expect(result.order).toEqual(['42', 'equal', 'first'])
|
||||
expect(result.disabled).toBe(true)
|
||||
expect(result.updated).toBe(true)
|
||||
expect(result.controlReturns).toEqual([true, true])
|
||||
expect(result.calls.slice(0, 4)).toEqual([['mount', true, 'first'], ['unmount', true, 'first', 'changed'], ['mount', true, 'changed'], ['click', true, true, true]])
|
||||
})
|
||||
|
||||
test(`${core}: removed builtin time and volume controls stop owned effects`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const time = art.controls.time
|
||||
const before = time.textContent
|
||||
const icon = art.controls.volume.querySelector('.art-icon-volume')
|
||||
art.controls.remove('time')
|
||||
art.controls.remove('volume')
|
||||
art.video.currentTime = 12
|
||||
art.emit('video:timeupdate', new Event('timeupdate'))
|
||||
art.muted = false
|
||||
icon.dispatchEvent(new MouseEvent('click'))
|
||||
const result = { changed: time.textContent !== before, muted: art.muted }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ changed: core === 'published', muted: core === 'published' })
|
||||
})
|
||||
|
||||
test(`${core}: selector items can be reused by an update without changing getter flags`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const selector = [{ html: 'A', value: 'a', default: true }, { html: 'B', value: 'b' }]
|
||||
art.controls.add({ name: 'selector', position: 'right', html: 'A', selector })
|
||||
const oldNode = selector[0].$control_item
|
||||
let error = null
|
||||
try {
|
||||
art.controls.update({ name: 'selector', html: 'updated' })
|
||||
}
|
||||
catch (caught) { error = caught.name }
|
||||
const descriptor = Object.getOwnPropertyDescriptor(selector[0], '$control_item')
|
||||
const result = { error, changed: selector[0].$control_item !== oldNode, flags: [descriptor.enumerable, descriptor.configurable, typeof descriptor.get], options: selector[0].$control_option === selector }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ error: core === 'candidate' ? null : 'TypeError', changed: core === 'candidate', flags: [false, false, 'function'], options: true })
|
||||
})
|
||||
|
||||
test(`${core}: async selector results cannot overwrite a newer choice or removed entry`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const selector = [{ html: 'A', value: 'a' }, { html: 'B', value: 'b' }]
|
||||
const pending = []
|
||||
const calls = []
|
||||
art.controls.add({ name: 'selector', position: 'right', html: 'initial', selector, onSelect(item, node, event) {
|
||||
calls.push([this === art, selector.includes(item), item.$control_item === node, event.type])
|
||||
return new Promise(resolve => pending.push(resolve))
|
||||
} })
|
||||
const click = item => item.$control_item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
click(selector[0])
|
||||
click(selector[1])
|
||||
pending[1]('new')
|
||||
await Promise.resolve()
|
||||
pending[0]('old')
|
||||
await Promise.resolve()
|
||||
const value = selector[0].$control_value
|
||||
const raced = value.innerHTML
|
||||
click(selector[0])
|
||||
const beforeRemove = value.innerHTML
|
||||
art.controls.remove('selector')
|
||||
pending[2]('removed')
|
||||
await Promise.resolve()
|
||||
const result = { raced, changedAfterRemove: value.innerHTML !== beforeRemove, flags: selector.map(item => item.default), calls }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result.raced).toBe(core === 'candidate' ? 'new' : 'old')
|
||||
expect(result.changedAfterRemove).toBe(core === 'published')
|
||||
expect(result.flags).toEqual([true, false])
|
||||
expect(result.calls).toEqual(Array.from({ length: 3 }, () => [true, true, true, 'click']))
|
||||
})
|
||||
|
||||
test(`${core}: a failed mounted hook rolls back its DOM alias and listeners`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const failure = new Error('mounted failure')
|
||||
let element
|
||||
let sameError = false
|
||||
let clicks = 0
|
||||
try {
|
||||
art.layers.add({ name: 'failed', html: 'failed', click() {
|
||||
clicks++
|
||||
}, mounted(node) {
|
||||
element = node
|
||||
throw failure
|
||||
} })
|
||||
}
|
||||
catch (error) { sameError = error === failure }
|
||||
element.dispatchEvent(new MouseEvent('click'))
|
||||
const result = { sameError, clicks, cached: art.layers.cache.has('failed'), alias: art.layers.failed === element, attached: art.template.$layer.contains(element) }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ sameError: true, clicks: core === 'candidate' ? 0 : 1, cached: core === 'published', alias: core === 'published', attached: core === 'published' })
|
||||
})
|
||||
|
||||
test(`${core}: reentrant beforeUnmount removes only once`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
let calls = 0
|
||||
art.layers.add({ name: 'reentrant', html: 'reentrant', beforeUnmount() {
|
||||
calls++
|
||||
if (calls === 1)
|
||||
art.layers.remove('reentrant')
|
||||
} })
|
||||
let error = null
|
||||
try {
|
||||
art.layers.remove('reentrant')
|
||||
}
|
||||
catch (caught) { error = caught.name }
|
||||
const result = { calls, error, cached: art.layers.cache.has('reentrant') }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ calls: core === 'candidate' ? 1 : 2, error: core === 'candidate' ? null : 'TypeError', cached: false })
|
||||
})
|
||||
|
||||
test(`${core}: narrow controls stay inside the player without hiding buttons`, async ({ page }, testInfo) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, fullscreenWeb: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }] })
|
||||
const container = art.template.$container
|
||||
const measure = (width) => {
|
||||
container.style.width = `${width}px`
|
||||
container.style.height = `${width * 9 / 16}px`
|
||||
const bounds = container.getBoundingClientRect()
|
||||
const controls = Array.from(container.querySelectorAll('.art-controls .art-control'))
|
||||
return { count: controls.length, hidden: controls.filter(node => node.getClientRects().length === 0).length, overflow: controls.filter((node) => {
|
||||
const box = node.getBoundingClientRect()
|
||||
return box.left < bounds.left - 0.75 || box.right > bounds.right + 0.75 || box.top < bounds.top - 0.75 || box.bottom > bounds.bottom + 0.75
|
||||
}).length }
|
||||
}
|
||||
const wide = measure(640)
|
||||
const narrow = measure(320)
|
||||
const smaller = measure(240)
|
||||
measure(320)
|
||||
window.layoutArt = art
|
||||
return { wide, narrow, smaller }
|
||||
})
|
||||
await page.locator('.player').screenshot({ path: testInfo.outputPath('narrow-controls.png') })
|
||||
await page.evaluate(() => window.layoutArt.destroy())
|
||||
expect(result.wide.overflow).toBe(0)
|
||||
expect(result.narrow.count).toBe(result.wide.count)
|
||||
expect(result.narrow.hidden).toBe(0)
|
||||
expect(result.smaller.hidden).toBe(0)
|
||||
if (core === 'candidate') {
|
||||
expect(result.narrow.overflow).toBe(0)
|
||||
expect(result.smaller.overflow).toBe(0)
|
||||
}
|
||||
else {
|
||||
expect(result.narrow.overflow).toBeGreaterThan(0)
|
||||
expect(result.smaller.overflow).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${core}: progress markers treat text as data and removed drags cannot seek`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.art.option.highlight = [{ time: 1, text: 'label" data-injected="yes' }]
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const marker = art.controls.progress.querySelector('.art-progress-highlight span')
|
||||
const text = marker.dataset.text
|
||||
const injected = marker.hasAttribute('data-injected')
|
||||
let seeks = 0
|
||||
art.on('seek', () => seeks++)
|
||||
art.template.$progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
|
||||
art.controls.remove('progress')
|
||||
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 100 }))
|
||||
art.destroy()
|
||||
return { text, injected, seeks }
|
||||
})
|
||||
expect(result).toEqual({ text: core === 'candidate' ? 'label" data-injected="yes' : 'label', injected: core === 'published', seeks: core === 'candidate' ? 0 : 1 })
|
||||
})
|
||||
}
|
||||
@@ -56,11 +56,32 @@ for (const core of ['published', 'candidate']) {
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }) => {
|
||||
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
// A real gesture starts WebKit's media pipeline before the paused seek setup.
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
|
||||
await testInfo.attach('initial-media-ranges', {
|
||||
body: JSON.stringify(await page.evaluate(() => {
|
||||
const { video } = window.art
|
||||
const ranges = value => Array.from({ length: value.length }, (_, index) => [value.start(index), value.end(index)])
|
||||
return { buffered: ranges(video.buffered), seekable: ranges(video.seekable), readyState: video.readyState, duration: video.duration, currentTime: video.currentTime }
|
||||
})),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const ranges = window.art.video.seekable
|
||||
return Array.from({ length: ranges.length }, (_, index) => index).some(index => ranges.start(index) <= 3 && ranges.end(index) >= 3)
|
||||
})).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.switchMediaEvents = []
|
||||
for (const name of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'pause', 'playing']) {
|
||||
window.art.video.addEventListener(name, () => window.switchMediaEvents.push({ name, time: window.art.currentTime, seeking: window.art.video.seeking, source: window.art.video.currentSrc }))
|
||||
}
|
||||
window.art.currentTime = 3
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
|
||||
@@ -76,11 +97,16 @@ test('candidate: quality switch restores position/rate and preserves real custom
|
||||
art.aspectRatio = '16:9'
|
||||
const restart = []
|
||||
art.on('restart', url => restart.push(url))
|
||||
const beforeSwitch = { time: art.currentTime, seeking: art.video.seeking, paused: art.video.paused }
|
||||
await art.switchQuality('/test/pattern.mp4?custom=next')
|
||||
const result = { calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
|
||||
const result = { beforeSwitch, mediaEvents: window.switchMediaEvents, calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
await testInfo.attach('quality-switch-media-state', { body: JSON.stringify(result), contentType: 'application/json' })
|
||||
expect(result.beforeSwitch.time).toBeCloseTo(3, 1)
|
||||
expect(result.beforeSwitch.seeking).toBe(false)
|
||||
expect(result.beforeSwitch.paused).toBe(true)
|
||||
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
|
||||
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
|
||||
expect(result.time).toBeCloseTo(3, 1)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 resources = 'packages/artplayer/src/component/resources'
|
||||
const lifecycle = 'packages/artplayer/src/lifecycle/instance'
|
||||
const { Emitter, Control, installControls, qualityMix, trackSelection, captureSelection, beginLifecycle, destroyInstance, getScope, ownEntry, entryScope, releaseEntry, proxyEntry, subscribeEntry } = await loadModules({
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
Control: 'packages/artplayer/src/control/index',
|
||||
installControls: { file: 'packages/artplayer/src/control/builtins', name: 'installControls' },
|
||||
qualityMix: 'packages/artplayer/src/player/qualityMix',
|
||||
...Object.fromEntries(['trackSelection', 'captureSelection'].map(name => [name, { file: 'packages/artplayer/src/component/selection', name }])),
|
||||
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'getScope'].map(name => [name, { file: lifecycle, name }])),
|
||||
...Object.fromEntries(['ownEntry', 'entryScope', 'releaseEntry', 'proxyEntry', 'subscribeEntry'].map(name => [name, { file: resources, name }])),
|
||||
})
|
||||
|
||||
test('deferred builtin quality initialization reports its original failure', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { art } = fixture()
|
||||
const failure = new Error('quality option getter')
|
||||
const warnings = []
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
let reads = 0
|
||||
art.option = {
|
||||
controls: [],
|
||||
get quality() {
|
||||
if (reads++ === 0)
|
||||
return [{ html: 'A', url: 'a' }]
|
||||
throw failure
|
||||
},
|
||||
}
|
||||
installControls({ art, add() {} })
|
||||
t.mock.timers.tick(0)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(warnings, [['ArtPlayer quality initialization failed:', failure]])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('control factories do not run during the reset portion of instance destruction', () => {
|
||||
const { art } = fixture()
|
||||
art.isDestroy = false
|
||||
art.template = { $video: {}, destroy() {} }
|
||||
let factories = 0
|
||||
art.reset = () => {
|
||||
assert.equal(art.isDestroy, false)
|
||||
assert.equal(getScope(art).closed, false)
|
||||
const result = Control.prototype.add.call({ art }, () => {
|
||||
factories++
|
||||
return { html: 'late' }
|
||||
})
|
||||
assert.equal(result, undefined)
|
||||
}
|
||||
destroyInstance(art, [art], true, true)
|
||||
assert.equal(factories, 0)
|
||||
assert.equal(art.isDestroy, true)
|
||||
})
|
||||
|
||||
test('quality callbacks keep return values while obsolete and removed selections stop notices', async () => {
|
||||
const { art, element } = fixture()
|
||||
let option
|
||||
art.controls = { update(value) {
|
||||
option = value
|
||||
} }
|
||||
art.notice = { show: '' }
|
||||
art.i18n = { get: key => key }
|
||||
const pending = []
|
||||
art.switchQuality = url => new Promise(resolve => pending.push({ url, resolve }))
|
||||
qualityMix(art)
|
||||
const quality = [{ html: 'A', url: 'a' }, { html: 'B', url: 'b' }]
|
||||
art.quality = quality
|
||||
let generation = 0
|
||||
const run = (item) => {
|
||||
const current = ++generation
|
||||
const scope = entryScope(element)
|
||||
const event = new Event('click')
|
||||
const release = scope.add(trackSelection(event, () => !scope.closed && current === generation))
|
||||
return option.onSelect.call(art, item, element, event).finally(release)
|
||||
}
|
||||
const first = run(quality[0])
|
||||
const second = run(quality[1])
|
||||
pending[1].resolve()
|
||||
assert.equal(await second, 'B')
|
||||
assert.equal(art.notice.show, 'Switch Video: B')
|
||||
pending[0].resolve()
|
||||
assert.equal(await first, 'A')
|
||||
assert.equal(art.notice.show, 'Switch Video: B')
|
||||
const removed = run(quality[0])
|
||||
releaseEntry(element)
|
||||
pending[2].resolve()
|
||||
assert.equal(await removed, 'A')
|
||||
assert.equal(art.notice.show, 'Switch Video: B')
|
||||
ownEntry(art, element)
|
||||
art.i18n.get = (key) => {
|
||||
releaseEntry(element)
|
||||
return key
|
||||
}
|
||||
const reentrant = run(quality[0])
|
||||
pending[3].resolve()
|
||||
assert.equal(await reentrant, 'A')
|
||||
assert.equal(art.notice.show, 'Switch Video: B')
|
||||
assert.deepEqual(pending.map(item => item.url), ['a', 'b', 'a', 'a'])
|
||||
})
|
||||
|
||||
test('quality rejection identity and captured selection closure survive disposal', async () => {
|
||||
const { art, element } = fixture()
|
||||
let option
|
||||
const failure = new Error('quality failed')
|
||||
art.controls = { update(value) {
|
||||
option = value
|
||||
} }
|
||||
art.notice = { show: '' }
|
||||
art.i18n = { get: key => key }
|
||||
art.switchQuality = () => Promise.reject(failure)
|
||||
qualityMix(art)
|
||||
const quality = [{ html: 'A', url: 'a' }]
|
||||
art.quality = quality
|
||||
const event = new Event('click')
|
||||
const scope = entryScope(element)
|
||||
scope.add(trackSelection(event, () => !scope.closed))
|
||||
const active = captureSelection(event)
|
||||
const result = option.onSelect.call(art, quality[0], element, event)
|
||||
releaseEntry(element)
|
||||
assert.equal(active(), false)
|
||||
await assert.rejects(result, error => error === failure)
|
||||
assert.equal(art.notice.show, '')
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const art = new Emitter()
|
||||
const registered = new Set()
|
||||
art.events = {
|
||||
proxy(target, name, callback) {
|
||||
target.addEventListener(name, callback)
|
||||
const cleanup = () => target.removeEventListener(name, callback)
|
||||
registered.add(cleanup)
|
||||
return cleanup
|
||||
},
|
||||
remove(cleanup) {
|
||||
cleanup()
|
||||
registered.delete(cleanup)
|
||||
},
|
||||
}
|
||||
beginLifecycle(art)
|
||||
const element = new EventTarget()
|
||||
ownEntry(art, element)
|
||||
return { art, element, registered }
|
||||
}
|
||||
|
||||
test('entry disposal removes DOM listeners and preserves unrelated entries', () => {
|
||||
const { art, element, registered } = fixture()
|
||||
const second = new EventTarget()
|
||||
ownEntry(art, second)
|
||||
const events = []
|
||||
proxyEntry(art, element, element, 'click', event => events.push(event))
|
||||
proxyEntry(art, second, second, 'click', event => events.push(event))
|
||||
const original = new Event('click')
|
||||
element.dispatchEvent(original)
|
||||
assert.equal(events[0], original)
|
||||
releaseEntry(element)
|
||||
element.dispatchEvent(new Event('click'))
|
||||
second.dispatchEvent(new Event('click'))
|
||||
assert.equal(events.length, 2)
|
||||
assert.equal(registered.size, 1)
|
||||
assert.equal(entryScope(element).closed, true)
|
||||
assert.equal(getScope(art).closed, false)
|
||||
getScope(art).dispose()
|
||||
assert.equal(registered.size, 0)
|
||||
})
|
||||
|
||||
test('entry listeners stop within an existing emitter snapshot while user listeners survive', () => {
|
||||
const { art, element } = fixture()
|
||||
const calls = []
|
||||
art.on('update', () => {
|
||||
calls.push('first')
|
||||
releaseEntry(element)
|
||||
})
|
||||
subscribeEntry(art, element, 'update', () => calls.push('owned'))
|
||||
art.on('update', () => calls.push('last'))
|
||||
art.emit('update')
|
||||
art.emit('update')
|
||||
assert.deepEqual(calls, ['first', 'last', 'first', 'last'])
|
||||
assert.equal(art.e.update.length, 2)
|
||||
})
|
||||
|
||||
test('root closure stops all entry resources and late registrations remain inert', () => {
|
||||
const { art, element, registered } = fixture()
|
||||
let calls = 0
|
||||
proxyEntry(art, element, element, 'click', () => calls++)
|
||||
subscribeEntry(art, element, 'update', () => calls++)
|
||||
getScope(art).dispose()
|
||||
proxyEntry(art, element, element, 'click', () => calls++)
|
||||
subscribeEntry(art, element, 'update', () => calls++)
|
||||
element.dispatchEvent(new Event('click'))
|
||||
art.emit('update')
|
||||
releaseEntry(element)
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(registered.size, 0)
|
||||
})
|
||||
|
||||
test('cleanup failure does not prevent releasing other component resources', () => {
|
||||
const { art, element } = fixture()
|
||||
const failures = [new Error('first'), new Error('second')]
|
||||
const attempts = []
|
||||
art.events.remove = () => {
|
||||
const error = failures[attempts.length]
|
||||
attempts.push(error)
|
||||
throw error
|
||||
}
|
||||
proxyEntry(art, element, element, 'click', () => {})
|
||||
proxyEntry(art, element, element, 'change', () => {})
|
||||
assert.throws(() => releaseEntry(element), (error) => {
|
||||
assert.deepEqual(error.errors, failures)
|
||||
return true
|
||||
})
|
||||
assert.deepEqual(attempts, failures)
|
||||
assert.equal(entryScope(element).closed, true)
|
||||
releaseEntry(element)
|
||||
assert.equal(attempts.length, 2)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ComponentHost } from '../../packages/artplayer/src/component/types'
|
||||
import type { ControlHost } from '../../packages/artplayer/src/control/types'
|
||||
import Control from '../../packages/artplayer/src/control'
|
||||
import Component from '../../packages/artplayer/src/utils/component'
|
||||
|
||||
declare const host: ComponentHost & { extra: number }
|
||||
const component = new Component(host)
|
||||
const added: HTMLDivElement | undefined = component.add({ html: 42, mounted(element) {
|
||||
const extra: number = this.extra
|
||||
const div: HTMLDivElement = element
|
||||
void [extra, div]
|
||||
} })
|
||||
const updated: HTMLDivElement | undefined = component.update({ name: 'entry', html: 'new' })
|
||||
declare const controlHost: ControlHost
|
||||
const control = new Control(controlHost)
|
||||
const empty: undefined = control.add({ position: 'right', html: 'entry' })
|
||||
const alsoEmpty: undefined = control.update({ name: 'entry', html: 'new' })
|
||||
// @ts-expect-error Control.add has always returned undefined at runtime.
|
||||
const invented: HTMLDivElement = control.add({ position: 'left', html: 'entry' })
|
||||
// @ts-expect-error Component indexes are numbers.
|
||||
component.add({ index: 'first' })
|
||||
void [added, updated, empty, alsoEmpty, invented]
|
||||
Reference in new issue
Block a user