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