mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-19 19:51:20 -08:00
refactor(core): CORE-14 split typed settings and scope tree lifetimes
This commit is contained in:
1 parent
697817eceb
commit
64d1939daa
51 files changed
+28144
-1519
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: native setting keyboard and pointer actions retain range and switch callbacks`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.actions = []
|
||||
const range = { name: 'range', html: 'Range', tooltip: 'Initial', range: [5, 0, 10, 1] }
|
||||
const toggle = { name: 'toggle', html: 'Switch', switch: false }
|
||||
const record = function (type, item, element, event) {
|
||||
window.actions.push({ type, value: item.range?.[0] ?? item.switch, receiver: this === window.art, element: element === item.$item, trusted: event.isTrusted })
|
||||
}
|
||||
range.onChange = function (item, element, event) {
|
||||
record.call(this, 'input', item, element, event)
|
||||
return `input:${item.range[0]}`
|
||||
}
|
||||
range.onRange = function (item, element, event) {
|
||||
record.call(this, 'change', item, element, event)
|
||||
return `change:${item.range[0]}`
|
||||
}
|
||||
toggle.onSwitch = function (item, element, event) {
|
||||
record.call(this, 'switch', item, element, event)
|
||||
return !item.switch
|
||||
}
|
||||
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [range, toggle] })
|
||||
window.art.setting.show = true
|
||||
})
|
||||
const range = page.locator('[data-name="range"] input')
|
||||
await range.focus()
|
||||
for (let index = 0; index < 3; index++)
|
||||
await range.press('ArrowRight')
|
||||
await page.locator('[data-name="toggle"]').click()
|
||||
await expect.poll(() => page.evaluate(() => ({ range: window.art.setting.find('range').range[0], tooltip: window.art.setting.find('range').tooltip, switch: window.art.setting.find('toggle').switch }))).toEqual({ range: 8, tooltip: 'change:8', switch: true })
|
||||
expect(await page.evaluate(() => window.actions)).toEqual([
|
||||
...[6, 7, 8].flatMap(value => ['input', 'change'].map(type => ({ type, value, receiver: true, element: true, trusted: true }))),
|
||||
{ type: 'switch', value: false, receiver: true, element: true, trusted: true },
|
||||
])
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: long setting labels leave native controls inside the row`, async ({ page }, testInfo) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.player')
|
||||
container.style.width = '320px'
|
||||
container.style.height = '240px'
|
||||
window.longLabel = 'Long setting label '.repeat(20)
|
||||
window.longTooltip = 'Detailed value '.repeat(20)
|
||||
window.art = new window.Artplayer({ container, url: '', setting: true, settings: [
|
||||
{ name: 'range', html: window.longLabel, tooltip: window.longTooltip, range: [5, 0, 10, 1], onRange() {
|
||||
return window.longTooltip
|
||||
} },
|
||||
{ name: 'switch', html: window.longLabel, tooltip: window.longTooltip, switch: false, onSwitch(item) {
|
||||
return !item.switch
|
||||
} },
|
||||
] })
|
||||
window.art.setting.show = true
|
||||
window.art.setting.render()
|
||||
})
|
||||
await expect(page.locator('.art-settings')).toBeVisible()
|
||||
const result = await page.evaluate(() => ['range', 'switch'].map((name) => {
|
||||
const item = window.art.setting.find(name)
|
||||
const row = item.$item.getBoundingClientRect()
|
||||
const control = (item.$range || item.$switch).getBoundingClientRect()
|
||||
return { name, inside: control.left >= row.left && control.right <= row.right && control.top >= row.top && control.bottom <= row.bottom, fullLabel: item.$html.textContent === window.longLabel, fullTooltip: item.$tooltip.textContent === window.longTooltip }
|
||||
}))
|
||||
await testInfo.attach('setting-long-labels', { contentType: 'image/png', body: await page.locator('.player').screenshot() })
|
||||
expect(result).toEqual(['range', 'switch'].map(name => ({ name, inside: core === 'candidate', fullLabel: true, fullTooltip: true })))
|
||||
if (core === 'candidate') {
|
||||
await page.locator('[data-name="range"] input').focus()
|
||||
await page.locator('[data-name="range"] input').press('ArrowRight')
|
||||
await page.locator('[data-name="switch"]').click()
|
||||
await expect.poll(() => page.evaluate(() => [window.art.setting.find('range').range[0], window.art.setting.find('switch').switch])).toEqual([6, true])
|
||||
}
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('mobile UA and real touch events (not physical-device validation)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36' })
|
||||
for (const rotate of [false, true]) {
|
||||
test(`candidate: nested settings remain selectable with autoOrientation=${rotate}`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.player')
|
||||
container.style.width = '320px'
|
||||
container.style.height = '240px'
|
||||
window.selected = null
|
||||
const choices = Array.from({ length: 12 }, (_, index) => ({ name: `choice-${index}`, html: `Choice ${index}`, value: index }))
|
||||
window.Artplayer.AUTO_ORIENTATION_TIME = 0
|
||||
window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, setting: true, autoOrientation: true, fullscreenWeb: true, settings: [{ name: 'group', html: 'Choices', width: 420, selector: choices, onSelect(item, element, event) {
|
||||
window.selected = { value: item.value, receiver: this === window.art, element: element === item.$item, trusted: event.isTrusted }
|
||||
return item.html
|
||||
} }] })
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.videoWidth)).toBeGreaterThan(0)
|
||||
if (rotate) {
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isRotate)).toBe(true)
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.art.setting.show = true
|
||||
})
|
||||
await page.locator('[data-name="group"]').tap()
|
||||
const last = page.locator('[data-name="choice-11"]')
|
||||
await last.scrollIntoViewIfNeeded()
|
||||
await last.tap()
|
||||
await expect.poll(() => page.evaluate(() => window.selected)).toEqual({ value: 11, receiver: true, element: true, trusted: true })
|
||||
const bounds = await page.evaluate(() => {
|
||||
const { $player, $setting } = window.art.template
|
||||
const player = $player.getBoundingClientRect()
|
||||
const panel = $setting.getBoundingClientRect()
|
||||
return { mobile: $player.classList.contains('art-mobile'), rotated: window.art.isRotate, inside: panel.left >= player.left - 1 && panel.top >= player.top - 1 && panel.right <= player.right + 1 && panel.bottom <= player.bottom + 1 }
|
||||
})
|
||||
expect(bounds).toEqual({ mobile: true, rotated: rotate, inside: true })
|
||||
await testInfo.attach('setting-mobile-layout', { contentType: 'image/png', body: await page.screenshot() })
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = false
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const action of ['failure', 'navigate', 'remove', 'destroy']) {
|
||||
test(`candidate: cached panel failure preserves navigation after ${action}`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate((action) => {
|
||||
const first = { name: 'first', html: 'First', selector: [{ name: 'one', html: 'One' }] }
|
||||
const second = { name: 'second', html: 'Second', selector: [{ name: 'two', html: 'Two' }] }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [first, second] })
|
||||
const setting = art.setting
|
||||
setting.show = true
|
||||
setting.render(second.selector)
|
||||
setting.render(first.selector)
|
||||
const panel = setting.cache.get(first.selector)
|
||||
const callbacks = [...first.$events, ...first.selector[0].$events]
|
||||
const resize = setting.resize.bind(setting)
|
||||
const layout = setting.$parent.style.cssText
|
||||
const failure = new Error('cached layout failed')
|
||||
setting.resize = () => {
|
||||
setting.resize = resize
|
||||
resize()
|
||||
if (action === 'navigate')
|
||||
setting.render()
|
||||
if (action === 'remove')
|
||||
setting.remove('second')
|
||||
if (action === 'destroy')
|
||||
art.destroy()
|
||||
throw failure
|
||||
}
|
||||
let originalError = false
|
||||
try {
|
||||
setting.render(second.selector)
|
||||
}
|
||||
catch (error) {
|
||||
originalError = error === failure
|
||||
}
|
||||
const result = {
|
||||
originalError,
|
||||
active: setting.active === first.selector ? 'first' : setting.active === setting.option ? 'root' : 'second',
|
||||
panels: setting.cache.size,
|
||||
firstVisible: panel.classList.contains('art-current'),
|
||||
connected: panel.isConnected,
|
||||
layoutRestored: setting.$parent.style.cssText === layout,
|
||||
listeners: callbacks.every(callback => art.events.destroyEvents.has(callback)),
|
||||
}
|
||||
if (action !== 'destroy')
|
||||
art.destroy()
|
||||
return result
|
||||
}, action)
|
||||
expect(result).toEqual({
|
||||
originalError: true,
|
||||
active: action === 'failure' ? 'first' : action === 'destroy' ? 'second' : 'root',
|
||||
panels: action === 'remove' ? 2 : action === 'destroy' ? 0 : 3,
|
||||
firstVisible: action === 'failure',
|
||||
connected: action !== 'destroy',
|
||||
layoutRestored: action !== 'remove',
|
||||
listeners: action !== 'destroy',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: sharing an active setting has an explicit ownership baseline`, async ({ page }, testInfo) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const calls = []
|
||||
const item = { name: 'shared', html: 'Shared', onClick() {
|
||||
calls.push(this.id)
|
||||
} }
|
||||
const first = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
|
||||
const row = item.$item
|
||||
const firstOption = item.$option
|
||||
let second
|
||||
let error
|
||||
try {
|
||||
second = new window.Artplayer({ container, url: '', setting: true, settings: [item] })
|
||||
}
|
||||
catch (failure) {
|
||||
error = failure.message
|
||||
}
|
||||
row.click()
|
||||
item.$item.click()
|
||||
await Promise.resolve()
|
||||
const result = { error: error || null, firstOption: item.$option === firstOption, firstHandle: item.$item === row, firstCalls: calls.filter(id => id === first.id).length, secondCalls: calls.filter(id => id === second?.id).length, firstRows: first.setting.$parent.querySelectorAll('.art-setting-item').length, secondRows: second?.setting.$parent.querySelectorAll('.art-setting-item').length ?? null }
|
||||
second?.destroy()
|
||||
first.destroy()
|
||||
container.remove()
|
||||
return result
|
||||
})
|
||||
await testInfo.attach('setting-ownership', { contentType: 'application/json', body: JSON.stringify(result) })
|
||||
expect(result).toEqual(core === 'published'
|
||||
? { error: null, firstOption: true, firstHandle: true, firstCalls: 2, secondCalls: 0, firstRows: 1, secondRows: 0 }
|
||||
: { error: 'Setting item [shared] already belongs to another active player', firstOption: true, firstHandle: true, firstCalls: 2, secondCalls: 0, firstRows: 1, secondRows: null })
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['remove', 'destroy']) {
|
||||
test(`candidate: a setting subtree can move to another instance after ${action}`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async (action) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const calls = []
|
||||
const child = { name: 'child', html: 'Child', onClick() {
|
||||
calls.push(this.id)
|
||||
} }
|
||||
const parent = { name: 'parent', html: 'Parent', selector: [child] }
|
||||
const first = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
|
||||
first.setting.render(parent.selector)
|
||||
const oldRow = child.$item
|
||||
const events = child.$events
|
||||
const getter = Object.getOwnPropertyDescriptor(child, '$parents').get
|
||||
const second = new window.Artplayer({ container, url: '', setting: true })
|
||||
let rejected = false
|
||||
try {
|
||||
second.setting.add(parent)
|
||||
}
|
||||
catch (error) {
|
||||
rejected = error.name === 'ArtPlayerError'
|
||||
}
|
||||
oldRow.click()
|
||||
await Promise.resolve()
|
||||
const protectedOwner = calls.length === 1 && calls[0] === first.id && second.setting.option.length === 0
|
||||
calls.length = 0
|
||||
if (action === 'remove')
|
||||
first.setting.remove('parent')
|
||||
else
|
||||
first.destroy()
|
||||
const returned = second.setting.add(parent) === parent
|
||||
second.setting.render(parent.selector)
|
||||
first.destroy()
|
||||
oldRow.click()
|
||||
child.$item.click()
|
||||
await Promise.resolve()
|
||||
const result = {
|
||||
rejected,
|
||||
protectedOwner,
|
||||
returned,
|
||||
getter: Object.getOwnPropertyDescriptor(child, '$parents').get === getter,
|
||||
eventArray: child.$events === events,
|
||||
parent: child.$parent === parent,
|
||||
root: child.$parents === second.setting.option,
|
||||
oldConnected: oldRow.isConnected,
|
||||
newConnected: child.$item.isConnected,
|
||||
newOwnerOnly: calls.length === 1 && calls[0] === second.id,
|
||||
}
|
||||
second.destroy()
|
||||
container.remove()
|
||||
return result
|
||||
}, action)
|
||||
expect(result).toEqual({ rejected: true, protectedOwner: true, returned: true, getter: true, eventArray: true, parent: true, root: true, oldConnected: false, newConnected: true, newOwnerOnly: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const phase of ['assign', 'format', 'item', 'render']) {
|
||||
test(`candidate: setting update restores the original range row after ${phase} failure`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async (phase) => {
|
||||
const label = document.createElement('span')
|
||||
label.textContent = 'Original'
|
||||
let mounts = 0
|
||||
const calls = []
|
||||
let art
|
||||
const item = { name: 'target', html: label, tooltip: 'Original tooltip', range: [7, 0, 10, 1], mounted() {
|
||||
mounts++
|
||||
}, onRange(owner, element, event) {
|
||||
calls.push({ value: owner.range[0], owner: owner === item, receiver: this === art, element: element === owner.$item, event: event.target === owner.$range })
|
||||
return 'Range result'
|
||||
} }
|
||||
art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item, { name: 'existing', html: 'Existing' }] })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const row = item.$item
|
||||
const input = item.$range
|
||||
const range = item.range
|
||||
const events = item.$events
|
||||
const callbacks = [...events]
|
||||
const listeners = art.events.destroyEvents.size
|
||||
const failure = new Error(`failed ${phase}`)
|
||||
const target = { name: 'target', html: 'Replacement', tooltip: 'Pending', range: [0, 0, 1, 1], extra: 'new' }
|
||||
const createItem = art.setting.createItem.bind(art.setting)
|
||||
const render = art.setting.render.bind(art.setting)
|
||||
if (phase === 'assign') {
|
||||
Object.defineProperty(target, 'extra', { enumerable: true, get() {
|
||||
throw failure
|
||||
} })
|
||||
}
|
||||
if (phase === 'format')
|
||||
target.selector = [{ name: 'existing', html: 'Duplicate' }]
|
||||
if (phase === 'item') {
|
||||
art.setting.createItem = (...args) => {
|
||||
createItem(...args)
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
if (phase === 'render') {
|
||||
art.setting.render = (...args) => {
|
||||
render(...args)
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
let originalError = false
|
||||
try {
|
||||
art.setting.update(target)
|
||||
}
|
||||
catch (error) {
|
||||
originalError = phase === 'format' ? error.name === 'ArtPlayerError' && error.message === 'The [existing] already exists in [setting]' : error === failure
|
||||
}
|
||||
art.setting.createItem = createItem
|
||||
art.setting.render = render
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const restored = {
|
||||
originalError,
|
||||
row: item.$item === row && row.isConnected,
|
||||
input: item.$range === input,
|
||||
label: item.$html.firstChild === label,
|
||||
range: item.range === range,
|
||||
values: [...item.range],
|
||||
native: [input.value, input.min, input.max, input.step],
|
||||
tooltip: item.tooltip,
|
||||
events: item.$events === events && events.length === callbacks.length && events.every((callback, index) => callback === callbacks[index]),
|
||||
listeners: art.events.destroyEvents.size - listeners,
|
||||
mounts,
|
||||
extra: Object.hasOwn(item, 'extra'),
|
||||
selector: Object.hasOwn(item, 'selector'),
|
||||
}
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await Promise.resolve()
|
||||
const returned = art.setting.update({ name: 'target', html: 'Success', range: [8, 0, 20, 2] }) === item
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
input.dispatchEvent(new Event('change'))
|
||||
item.$range.dispatchEvent(new Event('change'))
|
||||
await Promise.resolve()
|
||||
const result = { restored, returned, replaced: item.$item !== row && !row.isConnected, calls, mounts }
|
||||
art.destroy()
|
||||
return result
|
||||
}, phase)
|
||||
expect(result).toEqual({
|
||||
restored: { originalError: true, row: true, input: true, label: true, range: true, values: [7, 0, 10, 1], native: ['7', '0', '10', '1'], tooltip: 'Original tooltip', events: true, listeners: 0, mounts: 1, extra: false, selector: false },
|
||||
returned: true,
|
||||
replaced: true,
|
||||
calls: [
|
||||
{ value: 7, owner: true, receiver: true, element: true, event: true },
|
||||
{ value: 8, owner: true, receiver: true, element: true, event: true },
|
||||
],
|
||||
mounts: 2,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: failed switch updates restore the original closure and displayed icon', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async () => {
|
||||
const item = { name: 'switch', html: 'Original', switch: false, onSwitch(owner) {
|
||||
return !owner.switch
|
||||
} }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
|
||||
const row = item.$item
|
||||
const before = item.$switch.innerHTML
|
||||
const failure = new Error('switch assign')
|
||||
let originalError = false
|
||||
try {
|
||||
art.setting.update({ name: 'switch', switch: true, get html() {
|
||||
throw failure
|
||||
} })
|
||||
}
|
||||
catch (error) {
|
||||
originalError = error === failure
|
||||
}
|
||||
const restored = { originalError, sameRow: item.$item === row, value: item.switch, icon: item.$switch.innerHTML === before }
|
||||
row.click()
|
||||
await Promise.resolve()
|
||||
const result = { restored, clicked: item.switch }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ restored: { originalError: true, sameRow: true, value: false, icon: true }, clicked: true })
|
||||
})
|
||||
|
||||
for (const action of ['update', 'remove', 'destroy', 'child-update']) {
|
||||
test(`candidate: reentrant ${action} supersedes an unfinished setting update`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async (action) => {
|
||||
let calls = 0
|
||||
const child = { name: 'child', html: 'Child', onClick() {
|
||||
calls++
|
||||
} }
|
||||
const item = { name: 'parent', html: 'Parent', tooltip: 'Original', selector: [child] }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
|
||||
art.setting.render(item.selector)
|
||||
const oldRow = item.$item
|
||||
const oldChild = child.$item
|
||||
const target = { name: 'parent', html: 'Outer', get extra() {
|
||||
oldChild.click()
|
||||
if (action === 'update')
|
||||
art.setting.update({ name: 'parent', html: 'Winner' })
|
||||
else if (action === 'remove')
|
||||
art.setting.remove('parent')
|
||||
else if (action === 'destroy')
|
||||
art.destroy()
|
||||
else
|
||||
art.setting.update({ name: 'child', html: 'Child winner' })
|
||||
return 'obsolete'
|
||||
}, tooltip: 'Obsolete' }
|
||||
const returned = art.setting.update(target) === item
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const result = {
|
||||
returned,
|
||||
calls,
|
||||
extra: Object.hasOwn(item, 'extra'),
|
||||
tooltip: item.tooltip,
|
||||
html: item.html,
|
||||
childHtml: child.html,
|
||||
rowConnected: item.$item.isConnected,
|
||||
oldRowConnected: oldRow.isConnected,
|
||||
oldChildConnected: oldChild.isConnected,
|
||||
childCached: art.setting.cache.has(item.selector),
|
||||
}
|
||||
if (action !== 'destroy')
|
||||
art.destroy()
|
||||
return result
|
||||
}, action)
|
||||
expect(result).toEqual({
|
||||
returned: true,
|
||||
calls: 0,
|
||||
extra: false,
|
||||
tooltip: 'Original',
|
||||
html: action === 'update' ? 'Winner' : action === 'destroy' ? 'Outer' : 'Parent',
|
||||
childHtml: action === 'child-update' ? 'Child winner' : 'Child',
|
||||
rowConnected: action === 'update' || action === 'child-update',
|
||||
oldRowConnected: action === 'child-update',
|
||||
oldChildConnected: false,
|
||||
childCached: action === 'child-update',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const throwing of [false, true]) {
|
||||
test(`candidate: reentry inside a render getter retains the winning row with throwing=${throwing}`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async (throwing) => {
|
||||
let calls = 0
|
||||
const item = { name: 'target', html: 'Original', onClick() {
|
||||
calls++
|
||||
} }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
|
||||
let triggered = false
|
||||
const failure = new Error('outer getter failed after winner')
|
||||
Object.defineProperty(item, 'icon', { configurable: true, enumerable: true, get() {
|
||||
if (!triggered) {
|
||||
triggered = true
|
||||
art.setting.update({ name: 'target', html: 'Winner' })
|
||||
if (throwing)
|
||||
throw failure
|
||||
}
|
||||
return '<span>Icon</span>'
|
||||
} })
|
||||
let originalError = false
|
||||
try {
|
||||
art.setting.update({ name: 'target', html: 'Outer' })
|
||||
}
|
||||
catch (error) {
|
||||
originalError = error === failure
|
||||
}
|
||||
item.$item.click()
|
||||
await Promise.resolve()
|
||||
const result = { originalError, triggered, calls, html: item.html, connected: item.$item.isConnected, rows: art.setting.$parent.querySelectorAll('.art-setting-item').length, events: item.$events.length }
|
||||
art.destroy()
|
||||
return result
|
||||
}, throwing)
|
||||
expect(result).toEqual({ originalError: throwing, triggered: true, calls: 1, html: 'Winner', connected: true, rows: 1, events: 1 })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: removing an ancestor cancels its pending descendant update', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const child = { name: 'child', html: 'Child' }
|
||||
const parent = { name: 'parent', html: 'Parent', selector: [child] }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
|
||||
art.setting.render(parent.selector)
|
||||
art.setting.update({ name: 'child', html: 'Outer', get extra() {
|
||||
art.setting.remove('parent')
|
||||
return 'obsolete'
|
||||
} })
|
||||
const result = { html: child.html, extra: Object.hasOwn(child, 'extra'), connected: child.$item.isConnected, found: art.setting.find('child'), cached: art.setting.cache.has(parent.selector), events: child.$events.length }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ html: 'Child', extra: false, connected: false, found: null, cached: false, events: 0 })
|
||||
})
|
||||
|
||||
for (const kind of ['switch', 'range']) {
|
||||
test(`candidate: a reentrant ${kind} getter cannot replace the winning control handles`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate((kind) => {
|
||||
const value = kind === 'switch' ? false : [7, 0, 10, 1]
|
||||
const item = { name: 'target', html: 'Original', [kind]: value }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
|
||||
let triggered = false
|
||||
Object.defineProperty(item, kind, { configurable: true, enumerable: true, get() {
|
||||
if (!triggered) {
|
||||
triggered = true
|
||||
art.setting.update({ name: 'target', html: 'Winner' })
|
||||
}
|
||||
return value
|
||||
} })
|
||||
art.setting.update({ name: 'target', html: 'Outer' })
|
||||
const control = item[kind === 'switch' ? '$switch' : '$range']
|
||||
const result = { triggered, html: item.html, connected: control.isConnected, owner: control.closest('.art-setting-item') === item.$item, value: item[kind], rows: art.setting.$parent.querySelectorAll('.art-setting-item').length }
|
||||
art.destroy()
|
||||
return result
|
||||
}, kind)
|
||||
expect(result).toEqual({ triggered: true, html: 'Winner', connected: true, owner: true, value: kind === 'switch' ? false : [7, 0, 10, 1], rows: 1 })
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['add', 'update']) {
|
||||
test(`candidate: a failed outer add preserves a reentrant ${action} of the same object`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async (action) => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true })
|
||||
const failure = new Error('obsolete outer add')
|
||||
let triggered = false
|
||||
let html = 'Winner'
|
||||
let clicks = 0
|
||||
const item = { name: 'target', get html() {
|
||||
if (!triggered) {
|
||||
triggered = true
|
||||
if (action === 'add') {
|
||||
art.setting.remove('target')
|
||||
art.setting.add(item)
|
||||
}
|
||||
else {
|
||||
art.setting.update({ name: 'target', html: 'Winner' })
|
||||
}
|
||||
throw failure
|
||||
}
|
||||
return html
|
||||
}, set html(value) {
|
||||
html = value
|
||||
}, onClick() {
|
||||
clicks++
|
||||
} }
|
||||
let originalError = false
|
||||
try {
|
||||
art.setting.add(item)
|
||||
}
|
||||
catch (error) {
|
||||
originalError = error === failure
|
||||
}
|
||||
item.$item.click()
|
||||
await Promise.resolve()
|
||||
const result = { originalError, found: art.setting.find('target') === item, html: item.html, connected: item.$item.isConnected, clicks, events: item.$events.length, rows: art.setting.$parent.querySelectorAll('.art-setting-item').length }
|
||||
art.destroy()
|
||||
return result
|
||||
}, action)
|
||||
expect(result).toEqual({ originalError: true, found: true, html: 'Winner', connected: true, clicks: 1, events: 1, rows: 1 })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: builtin setting subscriptions are replaced and removed with their row`, 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: '', setting: true, playbackRate: true })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
art.setting.update({ name: 'playback-rate', html: 'Speed' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
let calls = 0
|
||||
const check = art.setting.check.bind(art.setting)
|
||||
art.setting.check = (target) => {
|
||||
calls++
|
||||
check(target)
|
||||
}
|
||||
art.emit('video:ratechange')
|
||||
const afterUpdate = calls
|
||||
calls = 0
|
||||
art.setting.remove('playback-rate')
|
||||
art.emit('video:ratechange')
|
||||
const result = { afterUpdate, afterRemove: calls }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual(core === 'candidate' ? { afterUpdate: 1, afterRemove: 0 } : { afterUpdate: 2, afterRemove: 2 })
|
||||
})
|
||||
|
||||
test(`${core}: deferred setting mounts belong to the rendered item generation`, 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: '', setting: true })
|
||||
const calls = []
|
||||
art.setting.add({ name: 'removed', html: 'Removed', onClick() {}, mounted() {
|
||||
calls.push('removed')
|
||||
} })
|
||||
art.setting.remove('removed')
|
||||
art.setting.add({ name: 'updated', html: 'Before', onClick() {}, mounted() {
|
||||
calls.push('before')
|
||||
} })
|
||||
art.setting.update({ name: 'updated', html: 'After', mounted(element, item) {
|
||||
calls.push(`${element.textContent}:${this === art}:${item === art.setting.find('updated')}`)
|
||||
} })
|
||||
// Barrier after the previously scheduled zero-delay mounted callbacks.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
art.destroy()
|
||||
return calls
|
||||
})
|
||||
expect(result).toEqual(core === 'candidate' ? ['After:true:true'] : ['removed', 'After:true:true', 'After:true:true'])
|
||||
})
|
||||
|
||||
test(`${core}: removed setting subtrees release child listeners and cached panels`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
let calls = 0
|
||||
const child = { name: 'child', html: 'Child', onClick() {
|
||||
calls++
|
||||
} }
|
||||
const parent = { name: 'group', html: 'Group', selector: [child] }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
|
||||
art.setting.render(parent.selector)
|
||||
const node = child.$item
|
||||
art.setting.remove('group')
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
const result = { calls, cached: art.setting.cache.has(parent.selector), childListeners: child.$events.length, attached: node.isConnected }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual(core === 'candidate' ? { calls: 0, cached: false, childListeners: 0, attached: false } : { calls: 1, cached: true, childListeners: 1, attached: true })
|
||||
})
|
||||
|
||||
test(`${core}: setting button results cannot overwrite an updated or newer item`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(async () => {
|
||||
const pending = []
|
||||
const item = { name: 'async', html: 'Async', tooltip: 'initial', onClick() {
|
||||
return new Promise(resolve => pending.push(resolve))
|
||||
} }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
|
||||
item.$item.dispatchEvent(new MouseEvent('click'))
|
||||
art.setting.update({ name: 'async', tooltip: 'updated' })
|
||||
pending[0]('obsolete')
|
||||
await Promise.resolve()
|
||||
const afterUpdate = item.tooltip
|
||||
item.$item.dispatchEvent(new MouseEvent('click'))
|
||||
item.$item.dispatchEvent(new MouseEvent('click'))
|
||||
pending[2]('latest')
|
||||
await Promise.resolve()
|
||||
pending[1]('earlier')
|
||||
await Promise.resolve()
|
||||
const result = { afterUpdate, afterReorder: item.tooltip }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual(core === 'candidate' ? { afterUpdate: 'updated', afterReorder: 'latest' } : { afterUpdate: 'obsolete', afterReorder: 'earlier' })
|
||||
})
|
||||
|
||||
test(`${core}: sibling setting selections share the parent result generation`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(async () => {
|
||||
const pending = []
|
||||
const calls = []
|
||||
let art
|
||||
const items = [{ name: 'a', html: 'A' }, { name: 'b', html: 'B' }]
|
||||
const parent = { name: 'group', html: 'Group', selector: items, onSelect(item, node, event) {
|
||||
calls.push({ name: item.name, sameThis: this === art, sameNode: node === item.$item, event: event.type })
|
||||
return new Promise(resolve => pending.push(resolve))
|
||||
} }
|
||||
art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
|
||||
art.setting.render(items)
|
||||
items[0].$item.dispatchEvent(new MouseEvent('click'))
|
||||
items[1].$item.dispatchEvent(new MouseEvent('click'))
|
||||
pending[1]('B result')
|
||||
await Promise.resolve()
|
||||
pending[0]('A result')
|
||||
await Promise.resolve()
|
||||
const result = { tooltip: parent.tooltip, defaults: items.map(item => item.default), calls }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ tooltip: core === 'candidate' ? 'B result' : 'A result', defaults: [false, true], calls: [{ name: 'a', sameThis: true, sameNode: true, event: 'click' }, { name: 'b', sameThis: true, sameNode: true, event: 'click' }] })
|
||||
})
|
||||
|
||||
test(`${core}: setting callback rejection retains its original failure identity`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
const failure = new Error('setting failure')
|
||||
window.settingErrors = { warnings: [], rejections: [] }
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault()
|
||||
window.settingErrors.rejections.push(event.reason === failure)
|
||||
})
|
||||
const warn = console.warn
|
||||
console.warn = (message, error) => {
|
||||
window.settingErrors.warnings.push({ message, same: error === failure })
|
||||
warn.call(console, message, error)
|
||||
}
|
||||
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [{ name: 'failure', html: 'Fail', tooltip: 'keep', onClick() {
|
||||
return Promise.reject(failure)
|
||||
} }] })
|
||||
window.art.setting.find('failure').$item.dispatchEvent(new MouseEvent('click'))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.settingErrors.rejections.length + window.settingErrors.warnings.length)).toBe(1)
|
||||
expect(await page.evaluate(() => window.settingErrors)).toEqual(core === 'candidate' ? { warnings: [{ message: 'ArtPlayer setting callback failed:', same: true }], rejections: [] } : { warnings: [], rejections: [true] })
|
||||
expect(await page.evaluate(() => window.art.setting.find('failure').tooltip)).toBe('keep')
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: a wide nested setting panel fits the narrow player and remains selectable`, async ({ page }, testInfo) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.player')
|
||||
container.style.width = '320px'
|
||||
container.style.height = '180px'
|
||||
window.art = new window.Artplayer({ container, url: '', setting: true, settings: [{ name: 'wide', html: 'Wide choices', width: 420, selector: Array.from({ length: 12 }, (_, value) => ({ name: `choice-${value}`, html: `Choice ${value}`, value })), onSelect(item) {
|
||||
window.settingSelected = item.value
|
||||
return item.html
|
||||
} }] })
|
||||
window.art.controls.show = true
|
||||
})
|
||||
await page.locator('.art-control-setting').click()
|
||||
await page.locator('.art-setting-panel.art-current [data-name="wide"]').click()
|
||||
await expect.poll(() => page.locator('.art-settings').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBe(core === 'candidate' ? 300 : 420)
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const player = window.art.template.$player.getBoundingClientRect()
|
||||
const panel = window.art.template.$setting.getBoundingClientRect()
|
||||
return panel.left >= player.left - 1 && panel.right <= player.right + 1 && panel.top >= player.top - 1 && panel.bottom <= player.bottom + 1
|
||||
})).toBe(core === 'candidate')
|
||||
await page.locator('.player').screenshot({ path: testInfo.outputPath('narrow-setting.png') })
|
||||
if (core === 'candidate') {
|
||||
await page.locator('.art-setting-panel.art-current [data-name="choice-11"]').click()
|
||||
await expect.poll(() => page.evaluate(() => window.settingSelected)).toBe(11)
|
||||
await expect.poll(() => page.evaluate(() => window.art.setting.find('wide').tooltip)).toBe('Choice 11')
|
||||
expect(await page.evaluate(() => window.art.setting.active === window.art.setting.option)).toBe(true)
|
||||
}
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: setting registry retains item returns and hidden tree bindings`, 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: '', setting: true })
|
||||
const { add, update, remove } = art.setting
|
||||
const item = { name: 'custom', html: 'Original', onClick() {} }
|
||||
const added = add(item)
|
||||
const originalNode = item.$item
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, '$option')
|
||||
const updated = update({ name: 'custom', html: 'Updated' })
|
||||
const result = {
|
||||
sameAdd: added === item,
|
||||
sharedBase: Object.getPrototypeOf(Object.getPrototypeOf(art.setting)) === Object.getPrototypeOf(Object.getPrototypeOf(art.controls)),
|
||||
sameUpdate: updated === item,
|
||||
replaced: originalNode !== item.$item,
|
||||
found: art.setting.find('custom') === item,
|
||||
missing: art.setting.find('missing'),
|
||||
html: item.html,
|
||||
root: item.$option === art.setting.option,
|
||||
parentAbsent: item.$parent === undefined && item.$parents === undefined,
|
||||
descriptor: { enumerable: descriptor.enumerable, configurable: descriptor.configurable, getter: typeof descriptor.get, setter: descriptor.set === undefined },
|
||||
removeUndefined: remove('custom') === undefined,
|
||||
removed: art.setting.find('custom') === null,
|
||||
}
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ sameAdd: true, sharedBase: true, sameUpdate: true, replaced: true, found: true, missing: null, html: 'Updated', root: true, parentAbsent: true, descriptor: { enumerable: false, configurable: false, getter: 'function', setter: true }, removeUndefined: true, removed: true })
|
||||
})
|
||||
|
||||
test(`${core}: moved setting items bind to their current parent and panel`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
const child = { name: 'child', html: 'Child' }
|
||||
const first = { name: 'first', html: 'First', selector: [child] }
|
||||
const second = { name: 'second', html: 'Second', selector: [] }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [first, second] })
|
||||
window.art = art
|
||||
window.settingMoved = { child, first, second, getter: Object.getOwnPropertyDescriptor(child, '$parent').get, events: child.$events }
|
||||
art.setting.remove('child')
|
||||
second.selector.push(child)
|
||||
art.setting.format()
|
||||
art.setting.show = true
|
||||
art.setting.resize()
|
||||
})
|
||||
await page.locator('.art-setting-panel.art-current [data-name="second"]').click()
|
||||
const result = await page.evaluate(() => {
|
||||
const { child, first, second, getter, events } = window.settingMoved
|
||||
const setting = window.art.setting
|
||||
return { parent: child.$parent === second ? 'second' : child.$parent === first ? 'first' : 'other', option: child.$option === second.selector, sameGetter: getter === Object.getOwnPropertyDescriptor(child, '$parent').get, sameEvents: events === child.$events, active: setting.active === second.selector, rendered: Boolean(setting.cache.get(second.selector).querySelector('[data-name="child"]')) }
|
||||
})
|
||||
expect(result).toEqual({ parent: core === 'candidate' ? 'second' : 'first', option: core === 'candidate', sameGetter: true, sameEvents: true, active: true, rendered: core === 'candidate' })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: automatic setting names and explicit nested names have an explicit baseline`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const first = { html: 'Auto', onClick() {} }
|
||||
const explicit = { name: 'setting-0', html: 'Explicit' }
|
||||
const nested = { html: 'Nested' }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [first, { name: 'group', html: 'Group', selector: [explicit, nested] }] })
|
||||
const names = []
|
||||
art.setting.traverse(item => names.push(item.name))
|
||||
const result = { names, unique: new Set(names).size, id: art.setting.id, explicitFound: art.setting.find('setting-0') === explicit }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual(core === 'candidate'
|
||||
? { names: ['setting-1', 'group', 'setting-0', 'setting-2'], unique: 4, id: 3, explicitFound: true }
|
||||
: { names: ['setting-0', 'group', 'setting-0', 'setting-1'], unique: 3, id: 2, explicitFound: true })
|
||||
})
|
||||
}
|
||||
|
||||
for (const fallback of [false, true]) {
|
||||
test(`candidate: open settings follow container and control height changes with 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(() => {
|
||||
const choices = Array.from({ length: 12 }, (_, value) => ({ name: `choice-${value}`, html: `Choice ${value}` }))
|
||||
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }], settings: [{ name: 'group', html: 'Group', width: 420, selector: choices }] })
|
||||
window.art.setting.show = true
|
||||
window.art.setting.render(choices)
|
||||
})
|
||||
await expect.poll(() => page.locator('.art-settings').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBe(420)
|
||||
await page.evaluate((fallback) => {
|
||||
const container = document.querySelector('.player')
|
||||
container.style.width = '320px'
|
||||
container.style.height = '180px'
|
||||
if (fallback)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}, fallback)
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const { $player, $setting, $controls } = window.art.template
|
||||
const player = $player.getBoundingClientRect()
|
||||
const panel = $setting.getBoundingClientRect()
|
||||
return { width: Math.round(panel.width), height: Math.round(panel.height), controls: $controls.offsetHeight, inside: panel.top >= player.top - 1 && panel.left >= player.left - 1 && panel.right <= player.right + 1 && panel.bottom <= player.bottom + 1 }
|
||||
})).toEqual({ width: 300, height: 78, controls: 92, inside: true })
|
||||
const late = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
art.destroy(false)
|
||||
let calls = 0
|
||||
art.setting.resize = () => calls++
|
||||
art.emit('resize')
|
||||
art.template.$player.style.width = '200px'
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return calls
|
||||
})
|
||||
expect(late).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: failed setting additions leave existing rows usable and allow retry', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async () => {
|
||||
let clicks = 0
|
||||
let mounts = 0
|
||||
const original = { name: 'original', html: 'Original', onClick() {
|
||||
clicks++
|
||||
} }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [original] })
|
||||
const element = original.$item
|
||||
const events = original.$events
|
||||
const errors = []
|
||||
for (const item of [original, { name: 'original', html: 'Duplicate' }, { name: 'shared', selector: [original] }, Object.preventExtensions({ name: 'sealed' })]) {
|
||||
try {
|
||||
art.setting.add(item)
|
||||
}
|
||||
catch (error) {
|
||||
errors.push(error.name)
|
||||
}
|
||||
}
|
||||
const failure = new Error('html getter failed')
|
||||
const invalid = { name: 'retry', get html() {
|
||||
throw failure
|
||||
} }
|
||||
let sameError = false
|
||||
try {
|
||||
art.setting.add(invalid)
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
const render = art.setting.render.bind(art.setting)
|
||||
const renderFailure = new Error('render failed')
|
||||
art.setting.render = () => {
|
||||
throw renderFailure
|
||||
}
|
||||
let sameRenderError = false
|
||||
const mounted = { name: 'mounted', html: 'Mounted', mounted() {
|
||||
mounts++
|
||||
} }
|
||||
try {
|
||||
art.setting.add(mounted)
|
||||
}
|
||||
catch (error) {
|
||||
sameRenderError = error === renderFailure
|
||||
}
|
||||
art.setting.render = render
|
||||
const afterFailures = {
|
||||
names: art.setting.option.map(item => item.name),
|
||||
rows: art.setting.$parent.querySelectorAll('.art-setting-item').length,
|
||||
originalNode: original.$item === element,
|
||||
originalEvents: original.$events === events,
|
||||
failedMountRemoved: !mounted.$item.isConnected,
|
||||
}
|
||||
element.click()
|
||||
const retry = { name: 'retry', html: 'Retry', onClick() {
|
||||
clicks++
|
||||
} }
|
||||
const returned = art.setting.add(retry) === retry
|
||||
retry.$item.click()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const result = { errors, sameError, sameRenderError, afterFailures, returned, clicks, mounts, retryFound: art.setting.find('retry') === retry }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ errors: ['Error', 'ArtPlayerError', 'Error', 'TypeError'], sameError: true, sameRenderError: true, afterFailures: { names: ['original'], rows: 1, originalNode: true, originalEvents: true, failedMountRemoved: true }, returned: true, clicks: 2, mounts: 0, retryFound: true })
|
||||
})
|
||||
|
||||
test('candidate: setting removal finishes DOM cleanup when the event-removal hook throws', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async () => {
|
||||
let clicks = 0
|
||||
let laterCleanup = 0
|
||||
const item = { name: 'removed', html: 'Remove', onClick() {
|
||||
clicks++
|
||||
} }
|
||||
const sibling = { name: 'sibling', html: 'Sibling', onClick() {
|
||||
clicks++
|
||||
} }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item, sibling] })
|
||||
const element = item.$item
|
||||
const failure = new Error('cleanup failed')
|
||||
const first = art.proxy(element, 'probe-first', () => {})
|
||||
const last = art.proxy(element, 'probe-last', () => {})
|
||||
item.$events.push(first, last)
|
||||
const remove = art.events.remove.bind(art.events)
|
||||
art.events.remove = (cleanup) => {
|
||||
remove(cleanup)
|
||||
if (cleanup === first)
|
||||
throw failure
|
||||
if (cleanup === last)
|
||||
laterCleanup++
|
||||
}
|
||||
let sameError = false
|
||||
try {
|
||||
art.setting.remove('removed')
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error.name === 'ResourceCleanupError' && error.errors.length === 1 && error.errors[0] === failure
|
||||
}
|
||||
element.click()
|
||||
sibling.$item.click()
|
||||
await Promise.resolve()
|
||||
const result = { sameError, laterCleanup, clicks, events: item.$events.length, found: art.setting.find('removed'), connected: element.isConnected, rows: art.setting.$parent.querySelectorAll('.art-setting-item').length }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ sameError: true, laterCleanup: 1, clicks: 1, events: 0, found: null, connected: false, rows: 1 })
|
||||
})
|
||||
|
||||
for (const phase of ['header', 'item', 'layout']) {
|
||||
test(`candidate: failed ${phase} rendering releases its panel and restores reusable inputs`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async (phase) => {
|
||||
const placeholder = document.createElement('div')
|
||||
const label = document.createElement('span')
|
||||
label.textContent = 'First'
|
||||
placeholder.append(label)
|
||||
document.body.append(placeholder)
|
||||
let mounts = 0
|
||||
let clicks = 0
|
||||
const first = { name: 'first', html: label, mounted() {
|
||||
mounts++
|
||||
}, onClick() {
|
||||
clicks++
|
||||
} }
|
||||
const second = { name: 'second', html: 'Second' }
|
||||
const children = [first, second]
|
||||
const parent = { name: 'parent', html: 'Parent', selector: children }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
|
||||
art.setting.show = true
|
||||
const setting = art.setting
|
||||
const beforeEvents = art.events.destroyEvents.size
|
||||
const beforeParentEvents = parent.$events.length
|
||||
const beforeStyle = setting.$parent.style.cssText
|
||||
const failure = new Error(`failed ${phase}`)
|
||||
const createHeader = setting.createHeader.bind(setting)
|
||||
const resize = setting.resize.bind(setting)
|
||||
if (phase === 'header') {
|
||||
setting.createHeader = (item) => {
|
||||
createHeader(item)
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
if (phase === 'item') {
|
||||
Object.defineProperty(second, 'html', { configurable: true, get() {
|
||||
throw failure
|
||||
} })
|
||||
}
|
||||
if (phase === 'layout') {
|
||||
setting.resize = () => {
|
||||
resize()
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
let sameError = false
|
||||
try {
|
||||
setting.render(children)
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
const afterRollback = setting.$parent.style.cssText
|
||||
setting.createHeader = createHeader
|
||||
setting.resize = resize
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const afterTick = setting.$parent.style.cssText
|
||||
const failed = {
|
||||
sameError,
|
||||
mounts,
|
||||
rootActive: setting.active === setting.option,
|
||||
cache: setting.cache.size,
|
||||
panels: setting.$parent.querySelectorAll('.art-setting-panel').length,
|
||||
rootVisible: setting.cache.get(setting.option).classList.contains('art-current'),
|
||||
rowRestored: first.$item === undefined,
|
||||
inputRestored: first.html === label,
|
||||
originalParent: label.parentNode === placeholder,
|
||||
listeners: art.events.destroyEvents.size - beforeEvents,
|
||||
parentEvents: parent.$events.length - beforeParentEvents,
|
||||
layoutRestored: afterRollback === beforeStyle,
|
||||
}
|
||||
if (phase === 'item')
|
||||
Object.defineProperty(second, 'html', { configurable: true, enumerable: true, writable: true, value: 'Second' })
|
||||
setting.render(children)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
first.$item.click()
|
||||
const panel = setting.cache.get(children)
|
||||
const header = panel.querySelector('.art-setting-item-back')
|
||||
header.click()
|
||||
const returned = setting.active === setting.option
|
||||
setting.remove('parent')
|
||||
let late = 0
|
||||
setting.render = () => late++
|
||||
header.click()
|
||||
first.$item.click()
|
||||
const result = { layout: { beforeStyle, afterRollback, afterTick }, failed, mounts, clicks, returned, late, headerConnected: header.isConnected, cached: setting.cache.has(children), parentEvents: parent.$events.length }
|
||||
art.destroy()
|
||||
placeholder.remove()
|
||||
return result
|
||||
}, phase)
|
||||
const { layout, ...state } = result
|
||||
await testInfo.attach('setting-panel-layout', { contentType: 'application/json', body: JSON.stringify(layout) })
|
||||
expect(state).toEqual({
|
||||
failed: { sameError: true, mounts: 0, rootActive: true, cache: 1, panels: 1, rootVisible: true, rowRestored: true, inputRestored: true, originalParent: true, listeners: 0, parentEvents: 0, layoutRestored: true },
|
||||
mounts: 1,
|
||||
clicks: 1,
|
||||
returned: true,
|
||||
late: 0,
|
||||
headerConnected: false,
|
||||
cached: false,
|
||||
parentEvents: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['remove', 'destroy']) {
|
||||
test(`candidate: panel creation stops after reentrant ${action}`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate((action) => {
|
||||
const children = [{ name: 'child', html: 'Child' }]
|
||||
const parent = { name: 'parent', html: 'Parent', selector: children }
|
||||
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
|
||||
const setting = art.setting
|
||||
const createHeader = setting.createHeader.bind(setting)
|
||||
const createItem = setting.createItem.bind(setting)
|
||||
let rows = 0
|
||||
let header
|
||||
setting.createItem = (...args) => {
|
||||
rows++
|
||||
createItem(...args)
|
||||
}
|
||||
setting.createHeader = (item) => {
|
||||
createHeader(item)
|
||||
header = setting.cache.get(children).querySelector('.art-setting-item-back')
|
||||
if (action === 'remove')
|
||||
setting.remove('parent')
|
||||
else
|
||||
art.destroy()
|
||||
}
|
||||
setting.render(children)
|
||||
let late = 0
|
||||
setting.render = () => late++
|
||||
header.click()
|
||||
const result = { rows, late, childCached: setting.cache.has(children), headerConnected: header.isConnected, events: parent.$events.length, childRendered: Boolean(children[0].$item), remainingPanels: setting.cache.size }
|
||||
if (action === 'remove')
|
||||
art.destroy()
|
||||
return result
|
||||
}, action)
|
||||
expect(result).toEqual({ rows: 0, late: 0, childCached: false, headerConnected: false, events: 0, childRendered: false, remainingPanels: action === 'remove' ? 1 : 0 })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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 { calculateSettingLayout } = await loadModules({ calculateSettingLayout: { file: 'packages/artplayer/src/setting/layout', name: 'calculateSettingLayout' } })
|
||||
const standard = { containerWidth: 640, containerHeight: 360, requestedWidth: 200, rows: 4, rowHeight: 35, controlCenter: 600, bottom: 46, padding: 10 }
|
||||
|
||||
test('settings retain requested dimensions where they fit and anchor inside both edges', () => {
|
||||
assert.deepEqual(calculateSettingLayout(standard), { width: 200, height: 140, left: 430 })
|
||||
assert.deepEqual(calculateSettingLayout({ ...standard, controlCenter: 250 }), { width: 200, height: 140, left: 150 })
|
||||
assert.deepEqual(calculateSettingLayout({ ...standard, controlCenter: 20 }), { width: 200, height: 140, left: 10 })
|
||||
})
|
||||
|
||||
test('oversized nested panels fit a narrow 16:9 player above multiple control rows', () => {
|
||||
assert.deepEqual(calculateSettingLayout({ ...standard, containerWidth: 320, containerHeight: 180, requestedWidth: 420, rows: 13, bottom: 92 }), { width: 300, height: 78, left: 10 })
|
||||
})
|
||||
|
||||
test('hidden or undersized hosts never produce negative layout dimensions', () => {
|
||||
assert.deepEqual(calculateSettingLayout({ ...standard, containerWidth: 0, containerHeight: 0 }), { width: 0, height: 0, left: 0 })
|
||||
assert.deepEqual(calculateSettingLayout({ ...standard, containerWidth: 12, containerHeight: 30, requestedWidth: -5 }), { width: 0, height: 0, left: 6 })
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
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 file = 'packages/artplayer/src/setting/model'
|
||||
const { formatTree, findItem, traverseTree, treeBinding, registerTreeOwner, releaseTreeOwner } = await loadModules(Object.fromEntries(['formatTree', 'findItem', 'traverseTree', 'treeBinding', 'registerTreeOwner', 'releaseTreeOwner'].map(name => [name, { file, name }])))
|
||||
|
||||
test('setting format preserves node/array identity, descriptor flags and preorder names', () => {
|
||||
const child = { html: 'child' }
|
||||
const parent = { name: 'parent', selector: [child] }
|
||||
const root = [parent, { name: 'last' }]
|
||||
const owner = { id: 0 }
|
||||
const names = ['reserved']
|
||||
assert.equal(formatTree(owner, root, undefined, undefined, names), root)
|
||||
assert.equal(owner.id, 1)
|
||||
assert.equal(child.name, 'setting-0')
|
||||
assert.deepEqual(names, ['reserved', 'parent', 'last'])
|
||||
assert.equal(child.$parent, parent)
|
||||
assert.equal(child.$parents, root)
|
||||
assert.equal(child.$option, parent.selector)
|
||||
assert.equal(parent.$parent, undefined)
|
||||
assert.equal(parent.$parents, undefined)
|
||||
assert.equal(parent.$option, root)
|
||||
assert.equal(child.$formatted, true)
|
||||
for (const key of ['$parent', '$parents', '$option', '$events', '$formatted']) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(child, key)
|
||||
assert.equal(descriptor.enumerable, false)
|
||||
assert.equal(descriptor.configurable, false)
|
||||
assert.equal(typeof descriptor.get, 'function')
|
||||
assert.equal(descriptor.set, undefined)
|
||||
}
|
||||
assert.deepEqual(Object.keys(child), ['html', 'name'])
|
||||
const visited = []
|
||||
traverseTree(root, item => visited.push(item))
|
||||
assert.deepEqual(visited, [parent, child, root[1]])
|
||||
assert.equal(findItem(root, child.name), child)
|
||||
assert.equal(findItem(root, 'missing'), null)
|
||||
})
|
||||
|
||||
test('setting tree rebinding updates parent arrays without redefining immutable getters or events', () => {
|
||||
const child = { name: 'child' }
|
||||
const previous = { name: 'previous', selector: [child] }
|
||||
const next = { name: 'next', selector: [] }
|
||||
const owner = { id: 0 }
|
||||
const root = [previous, next]
|
||||
formatTree(owner, root)
|
||||
const getter = Object.getOwnPropertyDescriptor(child, '$parent').get
|
||||
const events = child.$events
|
||||
const cleanup = () => {}
|
||||
events.push(cleanup)
|
||||
previous.selector.splice(0)
|
||||
next.selector.push(child)
|
||||
formatTree(owner, root)
|
||||
assert.equal(child.$parent, next)
|
||||
assert.equal(child.$parents, root)
|
||||
assert.equal(child.$option, next.selector)
|
||||
assert.equal(child.$events, events)
|
||||
assert.deepEqual(events, [cleanup])
|
||||
assert.equal(Object.getOwnPropertyDescriptor(child, '$parent').get, getter)
|
||||
assert.equal(treeBinding(child).parent, next)
|
||||
assert.equal(owner.id, 0)
|
||||
})
|
||||
|
||||
test('generated setting names avoid explicit names across the entire tree', () => {
|
||||
const root = [{}, { name: 'group', selector: [{ name: 'setting-0' }, {}] }]
|
||||
const owner = { id: 0 }
|
||||
formatTree(owner, root)
|
||||
assert.equal(root[0].name, 'setting-1')
|
||||
assert.equal(root[1].selector[1].name, 'setting-2')
|
||||
assert.equal(owner.id, 3)
|
||||
formatTree(owner, root)
|
||||
assert.equal(owner.id, 3)
|
||||
assert.equal(findItem(root, 'setting-0'), root[1].selector[0])
|
||||
})
|
||||
|
||||
test('duplicate names and cyclic or shared items fail before names and bindings are installed', () => {
|
||||
const fresh = {}
|
||||
const duplicate = [fresh, { name: 'same' }, { name: 'same' }]
|
||||
const owner = { id: 7 }
|
||||
const names = []
|
||||
assert.throws(() => formatTree(owner, duplicate, undefined, undefined, names), { name: 'ArtPlayerError', message: 'The [same] already exists in [setting]' })
|
||||
assert.deepEqual(fresh, {})
|
||||
assert.equal(fresh.$formatted, undefined)
|
||||
assert.equal(owner.id, 7)
|
||||
assert.deepEqual(names, [])
|
||||
const cycle = { selector: [] }
|
||||
cycle.selector.push(cycle)
|
||||
assert.throws(() => formatTree(owner, [cycle]), /only once/)
|
||||
assert.equal(cycle.$formatted, undefined)
|
||||
assert.throws(() => formatTree(owner, [fresh, fresh]), /only once/)
|
||||
assert.equal(fresh.$formatted, undefined)
|
||||
})
|
||||
|
||||
test('invalid property bindings leave earlier entries untouched', () => {
|
||||
const fresh = {}
|
||||
const foreign = {}
|
||||
Object.defineProperty(foreign, '$parent', { get: () => ({}) })
|
||||
assert.throws(() => formatTree({ id: 0 }, [fresh, foreign]), /Cannot format setting item property/)
|
||||
assert.equal(fresh.name, undefined)
|
||||
assert.equal(fresh.$formatted, undefined)
|
||||
assert.throws(() => treeBinding(fresh), /has not been formatted/)
|
||||
})
|
||||
|
||||
test('formatting an already-bound frozen node does not rewrite its existing name', () => {
|
||||
const child = { name: 'child' }
|
||||
const root = [child]
|
||||
const owner = { id: 0 }
|
||||
formatTree(owner, root)
|
||||
Object.freeze(child)
|
||||
assert.equal(formatTree(owner, root), root)
|
||||
assert.equal(child.$option, root)
|
||||
})
|
||||
|
||||
test('nonextensible metadata and unwritable automatic names fail before binding earlier entries', () => {
|
||||
const readonlyName = {}
|
||||
Object.defineProperty(readonlyName, 'name', { value: '', writable: false })
|
||||
const inheritedName = Object.create(Object.defineProperty({}, 'name', { value: '', writable: false }))
|
||||
for (const invalid of [Object.preventExtensions({ name: 'sealed' }), readonlyName, inheritedName]) {
|
||||
const fresh = {}
|
||||
const owner = { id: 12 }
|
||||
const names = []
|
||||
assert.throws(() => formatTree(owner, [fresh, invalid], undefined, undefined, names), TypeError)
|
||||
assert.deepEqual(Reflect.ownKeys(fresh), [])
|
||||
assert.equal(owner.id, 12)
|
||||
assert.deepEqual(names, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('traversal retains mutation timing and find retains last-match behavior on raw arrays', () => {
|
||||
const first = { name: 'same' }
|
||||
const last = { name: 'same' }
|
||||
const root = [first, last]
|
||||
assert.equal(findItem(root, 'same'), last)
|
||||
const child = { name: 'added' }
|
||||
const visited = []
|
||||
traverseTree(root, (item) => {
|
||||
visited.push(item)
|
||||
if (item === first)
|
||||
item.selector = [child]
|
||||
})
|
||||
assert.deepEqual(visited, [first, child, last])
|
||||
})
|
||||
|
||||
test('active owner conflicts are rejected before mutating either tree', () => {
|
||||
const item = { name: 'shared' }
|
||||
const first = { id: 0 }
|
||||
const second = { id: 0 }
|
||||
const scope = { closed: false }
|
||||
registerTreeOwner(first, scope)
|
||||
const original = [item]
|
||||
formatTree(first, original)
|
||||
const events = item.$events
|
||||
const fresh = {}
|
||||
assert.throws(() => formatTree(second, [fresh, item]), { name: 'ArtPlayerError', message: 'Setting item [shared] already belongs to another active player' })
|
||||
assert.deepEqual(Reflect.ownKeys(fresh), [])
|
||||
assert.equal(item.$option, original)
|
||||
assert.equal(item.$events, events)
|
||||
assert.equal(second.id, 0)
|
||||
scope.closed = true
|
||||
const replacement = [item]
|
||||
formatTree(second, replacement)
|
||||
assert.equal(item.$option, replacement)
|
||||
assert.equal(item.$events, events)
|
||||
releaseTreeOwner(first)
|
||||
assert.throws(() => formatTree(first, original), /another active player/)
|
||||
})
|
||||
|
||||
test('detaching a parent or releasing its owner allows a descendant to be reused', () => {
|
||||
const child = { name: 'child' }
|
||||
const parent = { name: 'parent', selector: [child] }
|
||||
const root = [parent]
|
||||
const first = { id: 0 }
|
||||
const second = { id: 0 }
|
||||
formatTree(first, root)
|
||||
const getter = Object.getOwnPropertyDescriptor(child, '$parent').get
|
||||
assert.throws(() => formatTree(second, [child]), /another active player/)
|
||||
root.splice(0)
|
||||
const replacement = [child]
|
||||
formatTree(second, replacement)
|
||||
assert.equal(child.$parent, undefined)
|
||||
assert.equal(Object.getOwnPropertyDescriptor(child, '$parent').get, getter)
|
||||
releaseTreeOwner(second)
|
||||
root.push(parent)
|
||||
formatTree(first, root)
|
||||
assert.equal(child.$parent, parent)
|
||||
assert.equal(child.$parents, root)
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
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/setting/resources'
|
||||
const lifecycle = 'packages/artplayer/src/lifecycle/instance'
|
||||
const { formatTree, bindSettingActions, ownSettingItem, releaseSettingItem, releaseSettingTree, proxySetting, settingScope, suspendSettingItem, beginLifecycle, getScope } = await loadModules({
|
||||
formatTree: { file: 'packages/artplayer/src/setting/model', name: 'formatTree' },
|
||||
bindSettingActions: { file: 'packages/artplayer/src/setting/selection', name: 'bindSettingActions' },
|
||||
...Object.fromEntries(['ownSettingItem', 'releaseSettingItem', 'releaseSettingTree', 'proxySetting', 'settingScope', 'suspendSettingItem'].map(name => [name, { file: resources, name }])),
|
||||
...Object.fromEntries(['beginLifecycle', 'getScope'].map(name => [name, { file: lifecycle, name }])),
|
||||
})
|
||||
|
||||
function fixture(items) {
|
||||
const listeners = new Set()
|
||||
const art = {
|
||||
events: {
|
||||
remove(cleanup) {
|
||||
cleanup()
|
||||
listeners.delete(cleanup)
|
||||
},
|
||||
},
|
||||
proxy(target, name, callback) {
|
||||
target.addEventListener(name, callback)
|
||||
const cleanup = () => target.removeEventListener(name, callback)
|
||||
listeners.add(cleanup)
|
||||
return cleanup
|
||||
},
|
||||
}
|
||||
beginLifecycle(art)
|
||||
formatTree({ id: 0 }, items)
|
||||
return { art, listeners, setting: { art, cache: new Map(), option: items } }
|
||||
}
|
||||
|
||||
test('setting callback results use latest operation and stop after item replacement', async () => {
|
||||
const pending = []
|
||||
const item = { name: 'button', tooltip: 'initial' }
|
||||
const { art, setting, listeners } = fixture([item])
|
||||
const target = new EventTarget()
|
||||
item.onClick = function (owner, element, event) {
|
||||
assert.equal(this, art)
|
||||
assert.equal(owner, item)
|
||||
assert.equal(element, target)
|
||||
assert.equal(event.type, 'click')
|
||||
return new Promise(resolve => pending.push(resolve))
|
||||
}
|
||||
ownSettingItem(art, item)
|
||||
bindSettingActions(setting, item, target, 'button')
|
||||
target.dispatchEvent(new Event('click'))
|
||||
target.dispatchEvent(new Event('click'))
|
||||
pending[1]('new')
|
||||
await Promise.resolve()
|
||||
pending[0]('old')
|
||||
await Promise.resolve()
|
||||
assert.equal(item.tooltip, 'new')
|
||||
target.dispatchEvent(new Event('click'))
|
||||
const previous = settingScope(item)
|
||||
ownSettingItem(art, item)
|
||||
assert.equal(previous.closed, true)
|
||||
pending[2]('removed')
|
||||
await Promise.resolve()
|
||||
assert.equal(item.tooltip, 'new')
|
||||
assert.equal(listeners.size, 0)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('setting callback rejection is reported with the original error and no UI write', async (t) => {
|
||||
const failure = new Error('selection')
|
||||
const warnings = []
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
const item = { tooltip: 'retained', onClick() {
|
||||
return Promise.reject(failure)
|
||||
} }
|
||||
const { art, setting } = fixture([item])
|
||||
const target = new EventTarget()
|
||||
ownSettingItem(art, item)
|
||||
bindSettingActions(setting, item, target, 'button')
|
||||
target.dispatchEvent(new Event('click'))
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(warnings, [['ArtPlayer setting callback failed:', failure]])
|
||||
assert.equal(item.tooltip, 'retained')
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('removing a setting tree releases descendants and their panel caches', () => {
|
||||
const child = { name: 'child' }
|
||||
const parent = { name: 'parent', selector: [child] }
|
||||
const other = { name: 'other' }
|
||||
const { art, setting, listeners } = fixture([parent, other])
|
||||
const target = new EventTarget()
|
||||
let calls = 0
|
||||
let removedPanels = 0
|
||||
for (const item of [parent, child, other]) {
|
||||
ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls++)
|
||||
}
|
||||
setting.cache.set(parent.selector, { remove() {
|
||||
removedPanels++
|
||||
} })
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.equal(calls, 3)
|
||||
releaseSettingTree(setting, parent)
|
||||
assert.equal(removedPanels, 1)
|
||||
assert.equal(setting.cache.has(parent.selector), false)
|
||||
assert.equal(parent.$events.length, 0)
|
||||
assert.equal(child.$events.length, 0)
|
||||
assert.equal(other.$events.length, 1)
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.equal(calls, 4)
|
||||
assert.equal(listeners.size, 1)
|
||||
getScope(art).dispose()
|
||||
assert.equal(listeners.size, 0)
|
||||
})
|
||||
|
||||
test('setting listener cleanup attempts every callback even when one fails', () => {
|
||||
const item = {}
|
||||
const { art } = fixture([item])
|
||||
ownSettingItem(art, item)
|
||||
const failure = new Error('cleanup')
|
||||
const calls = []
|
||||
item.$events.push(() => {
|
||||
calls.push('first')
|
||||
throw failure
|
||||
}, () => {
|
||||
calls.push('last')
|
||||
})
|
||||
assert.throws(() => releaseSettingItem(art, item), error => error.errors.length === 1 && error.errors[0] === failure)
|
||||
assert.deepEqual(calls, ['first', 'last'])
|
||||
assert.deepEqual(item.$events, [])
|
||||
releaseSettingItem(art, item)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('registration that closes the owner releases the just-returned listener', () => {
|
||||
const item = {}
|
||||
const { art, listeners } = fixture([item])
|
||||
ownSettingItem(art, item)
|
||||
const proxy = art.proxy
|
||||
art.proxy = (...args) => {
|
||||
const result = proxy(...args)
|
||||
getScope(art).dispose()
|
||||
return result
|
||||
}
|
||||
let calls = 0
|
||||
const target = new EventTarget()
|
||||
proxySetting(art, item, target, 'click', () => calls++)
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(listeners.size, 0)
|
||||
assert.equal(item.$events.length, 0)
|
||||
})
|
||||
|
||||
test('synchronous setting navigation errors are reported at the owned listener boundary', (t) => {
|
||||
const item = {}
|
||||
const { art } = fixture([item])
|
||||
ownSettingItem(art, item)
|
||||
const failure = new Error('navigation render')
|
||||
const warnings = []
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
const target = new EventTarget()
|
||||
proxySetting(art, item, target, 'click', () => {
|
||||
throw failure
|
||||
})
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.deepEqual(warnings, [['ArtPlayer setting callback failed:', failure]])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('a failed replacement resumes the original scope and event array without registering it again', () => {
|
||||
const item = {}
|
||||
const { art, listeners } = fixture([item])
|
||||
const oldNode = new EventTarget()
|
||||
const newNode = new EventTarget()
|
||||
let oldCalls = 0
|
||||
let newCalls = 0
|
||||
const original = ownSettingItem(art, item)
|
||||
proxySetting(art, item, oldNode, 'click', () => oldCalls++)
|
||||
const events = item.$events
|
||||
const cleanup = events[0]
|
||||
const suspended = suspendSettingItem(art, item)
|
||||
assert.equal(original.closed, false)
|
||||
assert.equal(events.length, 0)
|
||||
assert.throws(() => suspendSettingItem(art, item), /already in progress/)
|
||||
oldNode.dispatchEvent(new Event('click'))
|
||||
const replacement = ownSettingItem(art, item)
|
||||
proxySetting(art, item, newNode, 'click', () => newCalls++)
|
||||
newNode.dispatchEvent(new Event('click'))
|
||||
suspended.resume()
|
||||
suspended.resume()
|
||||
suspended.dispose()
|
||||
assert.equal(replacement.closed, true)
|
||||
assert.equal(settingScope(item), original)
|
||||
assert.equal(item.$events, events)
|
||||
assert.deepEqual(events, [cleanup])
|
||||
assert.equal(listeners.size, 1)
|
||||
oldNode.dispatchEvent(new Event('click'))
|
||||
newNode.dispatchEvent(new Event('click'))
|
||||
assert.deepEqual([oldCalls, newCalls], [1, 1])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('successful replacement disposes only the suspended listeners', () => {
|
||||
const item = {}
|
||||
const { art, listeners } = fixture([item])
|
||||
const target = new EventTarget()
|
||||
const calls = []
|
||||
const original = ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls.push('old'))
|
||||
const suspended = suspendSettingItem(art, item)
|
||||
const replacement = ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls.push('new'))
|
||||
const cleanup = item.$events[0]
|
||||
suspended.dispose()
|
||||
suspended.resume()
|
||||
assert.equal(original.closed, true)
|
||||
assert.equal(replacement.closed, false)
|
||||
assert.deepEqual(item.$events, [cleanup])
|
||||
assert.equal(listeners.size, 1)
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.deepEqual(calls, ['new'])
|
||||
getScope(art).dispose()
|
||||
assert.equal(listeners.size, 0)
|
||||
})
|
||||
|
||||
test('destroying a suspended replacement releases both generations and cannot resume either', () => {
|
||||
const item = {}
|
||||
const { art, listeners } = fixture([item])
|
||||
const target = new EventTarget()
|
||||
let calls = 0
|
||||
ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls++)
|
||||
const suspended = suspendSettingItem(art, item)
|
||||
ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls++)
|
||||
getScope(art).dispose()
|
||||
suspended.resume()
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(item.$events.length, 0)
|
||||
assert.equal(listeners.size, 0)
|
||||
})
|
||||
|
||||
test('replacement cleanup failure still resumes the original registered listener', () => {
|
||||
const item = {}
|
||||
const { art, listeners } = fixture([item])
|
||||
const target = new EventTarget()
|
||||
let calls = 0
|
||||
ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls++)
|
||||
const suspended = suspendSettingItem(art, item)
|
||||
ownSettingItem(art, item)
|
||||
proxySetting(art, item, target, 'click', () => calls += 10)
|
||||
const replacement = item.$events[0]
|
||||
const failure = new Error('replacement cleanup')
|
||||
const remove = art.events.remove
|
||||
art.events.remove = (cleanup) => {
|
||||
remove(cleanup)
|
||||
if (cleanup === replacement)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => suspended.resume(), error => error.errors.length === 1 && error.errors[0] === failure)
|
||||
target.dispatchEvent(new Event('click'))
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(listeners.size, 1)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type Setting from '../../packages/artplayer/src/setting'
|
||||
import type { SettingItem } from '../../packages/artplayer/src/setting/types'
|
||||
|
||||
declare const setting: Setting
|
||||
const input = { name: 'custom', html: 'Custom', customValue: 42 }
|
||||
const added = setting.add(input)
|
||||
const value: number = added.customValue
|
||||
const updated: SettingItem = setting.update(input)
|
||||
const found: SettingItem | null = setting.find('absent')
|
||||
const removed: void = setting.remove('custom')
|
||||
// @ts-expect-error Runtime find can return null.
|
||||
const definite: SettingItem = setting.find('absent')
|
||||
// @ts-expect-error Setting add returns the item rather than its DOM row.
|
||||
const element: HTMLDivElement = setting.add(input)
|
||||
// @ts-expect-error Range values must be numeric.
|
||||
setting.add({ html: 'Range', range: ['invalid'] })
|
||||
void [value, updated, found, removed, definite, element]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { findItem, formatTree, traverseTree, treeBinding } from '../../packages/artplayer/src/setting/model'
|
||||
|
||||
const item = { name: 'custom', payload: 42, selector: [{ name: 'child' }] }
|
||||
const result = formatTree({ id: 0 }, [item])
|
||||
const payload: number = result[0]!.payload
|
||||
const binding = treeBinding(item)
|
||||
const events: (() => void)[] = binding.events
|
||||
const found = findItem(result, 'absent')
|
||||
const nullable: typeof found | null = null
|
||||
traverseTree(result, (node) => {
|
||||
const name: string | undefined = node.name
|
||||
void name
|
||||
})
|
||||
// @ts-expect-error A tree item can be missing; find must not promise a DOM node.
|
||||
const element: HTMLElement = found
|
||||
// @ts-expect-error Item names are strings.
|
||||
formatTree({ id: 0 }, [{ name: 12 }])
|
||||
// @ts-expect-error The generated-name counter is numeric.
|
||||
formatTree({ id: '0' }, [item])
|
||||
void [payload, events, nullable, element]
|
||||
Reference in new issue
Block a user