Files
ArtPlayer/test/danmuku-input.test.js

373 lines
16 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate compatibility and failure tests use the Node runner.
import test from 'node:test'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const prefix = `Danmuku ${implementation.name}`
const names = env => env.events.map(event => event.name)
const loaded = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:loaded')
const failures = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:error')
for (const baseline of [...await danmukuHistorical(), implementation]) {
test(`Danmuku ${baseline.name}: NaN time retains the historical fallback while infinities retain their numeric behavior`, async () => {
const env = baseline === implementation ? danmukuCandidateEnvironment(baseline) : danmukuEnvironment(baseline)
const plugin = env.factory({ danmuku: [] })(env.art)
const internal = plugin.show()
for (const [input, expected] of [[Number.NaN, 10.5], [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY, 0]]) {
const row = { text: `numeric time ${input}`, time: input }
assert.equal(await plugin.emit(row), internal)
assert.equal(row.time, expected)
assert.equal(internal.queue.at(-1).time, expected)
assert.equal(internal.queue.at(-1).text, row.text)
}
assert.equal(failures(env).length, 0)
env.destroy()
})
}
function fixture(option = { danmuku: [] }) {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory(option)(env.art)
return { env, plugin }
}
test(`${prefix}: synchronous empty initialization and load retain method return identity and getter surface`, async () => {
const { env, plugin } = fixture()
assert.equal(typeof plugin.then, 'undefined')
assert.deepEqual(Object.keys(plugin), ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop'])
assert.deepEqual(names(env), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config', 'artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'])
const internal = plugin.show()
assert.notEqual(internal, plugin)
assert.equal(internal.name, undefined)
const start = env.events.length
const promise = plugin.load()
assert.equal(typeof promise.then, 'function')
assert.deepEqual(names(env).slice(start), ['artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'], 'Empty arrays finish the visible work before the first await')
assert.equal(await promise, internal)
const emit = plugin.emit
assert.equal(await emit({ text: 'bound', time: 12 }), internal)
assert.equal(plugin.hide(), internal)
assert.equal(plugin.reset(), internal)
env.destroy()
})
test(`${prefix}: nonempty array load inserts the first row synchronously and resolves to internal state`, async () => {
const { env, plugin } = fixture()
const internal = plugin.show()
const count = loaded(env).length
const pending = plugin.load([{ text: 'first', time: 1 }, { text: 'second', time: 2 }])
assert.deepEqual(Array.from(internal.queue, row => row.text), ['first'])
assert.equal(loaded(env).length, count)
assert.equal(await pending, internal)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['first', 'second'])
assert.equal(loaded(env).at(-1).args[0], internal.queue)
env.destroy()
})
test(`${prefix}: explicit targets append and omitted target replaces without changing configured input`, async () => {
const { env, plugin } = fixture()
const configured = [{ text: 'configured', time: 2 }]
plugin.config({ danmuku: configured })
const internal = await plugin.load()
await plugin.load([{ text: 'append', time: 3 }])
assert.equal(plugin.option.danmuku, configured)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['configured', 'append'])
await plugin.load(undefined)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['configured'])
env.destroy()
})
test(`${prefix}: independent asynchronous appends survive reversed completion and a replacement`, async () => {
const { env, plugin } = fixture()
const first = deferred()
const second = deferred()
const a = plugin.load(() => first.promise)
const b = plugin.load(() => second.promise)
plugin.config({ danmuku: [{ text: 'replacement', time: 1 }] })
const internal = await plugin.load()
second.resolve([{ text: 'second completed first', time: 2 }])
assert.equal(await b, internal)
first.resolve([{ text: 'first completed last', time: 3 }])
assert.equal(await a, internal)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['replacement', 'second completed first', 'first completed last'])
env.destroy()
})
test(`${prefix}: replacement supersedes only the older replacement and settles it before its input completes`, async () => {
const { env, plugin } = fixture()
const first = deferred()
const second = deferred()
const jobs = [first, second]
plugin.config({ danmuku: () => jobs.shift().promise })
const internal = plugin.show()
let oldSettled = false
const a = plugin.load().then((value) => {
oldSettled = true
return value
})
const b = plugin.load()
await env.flush()
assert.equal(oldSettled, true, 'Cancellation must settle the public Promise without awaiting a stale source')
assert.equal(await a, internal)
second.resolve([{ text: 'current', time: 1 }])
await b
const count = env.events.length
first.resolve([{ text: 'obsolete', time: 2 }])
await env.flush()
assert.deepEqual(Array.from(internal.queue, row => row.text), ['current'])
assert.equal(env.events.length, count)
env.destroy()
})
test(`${prefix}: destroyed pending replacement and appends settle without late state or error events`, async () => {
const { env, plugin } = fixture()
const replacement = deferred()
const append = deferred()
plugin.config({ danmuku: () => replacement.promise })
const internal = plugin.show()
const pending = [plugin.load(), plugin.load(() => append.promise)]
let settled = false
const all = Promise.all(pending).then((results) => {
settled = true
return results
})
env.destroy()
await env.flush()
assert.equal(settled, true, 'Destroy settles all owned loads without depending on their unresolved inputs')
assert.deepEqual(await all, [internal, internal])
const count = env.events.length
replacement.resolve([{ text: 'late', time: 1 }])
append.reject(new Error('late append failure'))
await nextTurn()
assert.equal(internal.queue.length, 0)
assert.equal(env.events.length, count)
assert.equal(failures(env).length, 0)
})
test(`${prefix}: reset event reentry can replace input without an older load clearing or adding rows`, async () => {
const { env, plugin } = fixture()
plugin.config({ danmuku: [{ text: 'old', time: 1 }] })
let nested
let entered = false
env.art.on('artplayerPluginDanmuku:reset', () => {
if (entered)
return
entered = true
plugin.config({ danmuku: [{ text: 'new', time: 2 }] })
nested = plugin.load()
})
const count = loaded(env).length
const internal = await plugin.load()
await nested
assert.deepEqual(Array.from(internal.queue, row => row.text), ['new'])
assert.equal(loaded(env).length, count + 1)
env.destroy()
})
for (const action of ['replace', 'destroy']) {
test(`${prefix}: filter synchronous ${action} reentry prevents the obsolete row from being pushed`, async () => {
const { env, plugin } = fixture()
let nested
plugin.config({
danmuku: [{ text: 'old1', time: 1 }, { text: 'old2', time: 2 }],
filter(row) {
if (row.text === 'old1') {
if (action === 'destroy') {
env.destroy()
}
else {
plugin.config({ danmuku: [{ text: 'new', time: 3 }] })
nested = plugin.load()
}
}
return true
},
})
const count = loaded(env).length
const internal = await plugin.load()
await nested
assert.deepEqual(Array.from(internal.queue, row => row.text), action === 'destroy' ? [] : ['new'])
assert.equal(loaded(env).length, count + (action === 'destroy' ? 0 : 1))
if (action !== 'destroy')
env.destroy()
})
}
test(`${prefix}: loader receiver, filter receiver, input mutation and extra fields retain the public behavior`, async () => {
let loaderReceiver = 'unset'
let filterReceiver
const { env, plugin } = fixture({
danmuku: [],
filter() {
filterReceiver = this
return true
},
})
const row = { text: 'zero', time: 0, id: 'custom-id', extension: { value: 1 } }
const internal = await plugin.load(function () {
loaderReceiver = this
return [row]
})
assert.equal(loaderReceiver, undefined)
assert.equal(filterReceiver, plugin.option)
assert.equal(row.time, 0, 'Accepted defect correction preserves an explicit zero timestamp')
assert.equal(row.mode, 0)
assert.equal(row.color, '#FFFFFF')
assert.deepEqual(Object.keys(row.style), [])
assert.equal(internal.queue[0].extension, row.extension)
assert.equal(internal.queue[0].id, row.id)
const missing = { text: 'default time' }
const negative = { text: 'negative', time: -2 }
await plugin.emit(missing)
await plugin.emit(negative)
assert.equal(missing.time, 10.5)
assert.equal(negative.time, 0)
env.destroy()
})
test(`${prefix}: invalid later rows preserve earlier insertions and reject with the single emitted original error`, async () => {
const { env, plugin } = fixture()
const internal = plugin.show()
let caught
const loadedCount = loaded(env).length
await assert.rejects(plugin.load([{ text: 'valid', time: 1 }, { text: 123 }]), (error) => {
caught = error
return error.message.includes('text')
})
assert.deepEqual(Array.from(internal.queue, row => row.text), ['valid'])
assert.equal(failures(env).length, 1)
assert.equal(failures(env)[0].args[0], caught)
assert.equal(loaded(env).length, loadedCount)
env.destroy()
})
test(`${prefix}: function configuration changes are applied while structurally equal plain values remain silent`, () => {
const { env, plugin } = fixture()
const original = plugin.option
const filter = () => false
const before = env.events.length
plugin.config({ filter })
assert.equal(plugin.option.filter, filter)
assert.notEqual(plugin.option, original)
assert.deepEqual(names(env).slice(before), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config'])
const updated = plugin.option
const count = env.events.length
plugin.config({ filter, margin: [10, '25%'], points: [], OPACITY: {} })
assert.equal(env.events.length, count)
assert.equal(plugin.option, updated)
env.destroy()
})
test(`${prefix}: invalid configuration cannot replace or partially mutate a valid option`, () => {
const { env, plugin } = fixture()
const original = plugin.option
const count = env.events.length
assert.throws(() => plugin.config({ opacity: 0.2, speed: 'invalid', visible: false }), /speed/iu)
assert.equal(plugin.option, original)
assert.equal(plugin.option.opacity, 1)
assert.equal(plugin.option.visible, true)
assert.equal(plugin.isHide, false)
assert.equal(env.events.length, count)
env.destroy()
})
test(`${prefix}: initial Promise source is accepted and successful completion reports loaded`, async () => {
const input = deferred()
const { env, plugin } = fixture({ danmuku: input.promise })
assert.equal(typeof plugin.then, 'undefined')
assert.equal(loaded(env).length, 0)
input.resolve([{ text: 'initial promise', time: 0 }])
await env.flush()
assert.equal(loaded(env).length, 1)
assert.equal(loaded(env)[0].args[0][0].text, 'initial promise')
assert.equal(loaded(env)[0].args[0][0].time, 0)
env.destroy()
})
test(`${prefix}: ignored constructor load rejection is locally observed while explicit load still rejects`, async () => {
const error = new Error('source failed')
const { env, plugin } = fixture({ danmuku: () => Promise.reject(error) })
await nextTurn()
assert.equal(failures(env).length, 1)
assert.equal(failures(env)[0].args[0], error)
await assert.rejects(plugin.load(), failure => failure === error)
assert.equal(failures(env).length, 2)
assert.equal(failures(env)[1].args[0], error)
env.destroy()
})
for (const event of ['show', 'config', 'loaded']) {
test(`${prefix}: synchronous initial ${event} destruction leaves a complete facade without later Worker or setting creation`, async () => {
const env = danmukuCandidateEnvironment(implementation)
const destroy = () => env.destroy()
env.art.on(`artplayerPluginDanmuku:${event}`, destroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
const plugin = env.factory({ danmuku: [], heatmap: true })(env.art)
assert.equal(env.art.isDestroy, true)
assert.equal(typeof plugin.then, 'undefined')
assert.deepEqual(Object.keys(plugin), ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop'])
assert.equal(plugin.name, 'artplayerPluginDanmuku')
for (const key of ['option', 'isHide', 'isStop']) {
const descriptor = Object.getOwnPropertyDescriptor(plugin, key)
assert.equal(typeof descriptor.get, 'function')
assert.equal(descriptor.set, undefined)
assert.equal(descriptor.enumerable, true)
assert.equal(descriptor.configurable, true)
}
assert.equal(plugin.option.heatmap, true)
assert.equal(plugin.isStop, true)
assert.equal(plugin.isHide, false)
assert.equal(plugin.mount('#external'), undefined)
assert.equal(plugin.mount(), undefined)
assert.equal(env.workers.length, event === 'loaded' ? 1 : 0)
assert(env.workers.every(worker => worker.terminated), 'A Worker created before loaded reentry must be terminated')
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before, 'Only the pre-existing user listener remains')
await env.flush()
assert.equal(env.consoleWarnings.length, 0)
})
}
test(`${prefix}: invalid initial configuration rolls back the early destroy listener before throwing`, () => {
const env = danmukuCandidateEnvironment(implementation)
const existingDestroy = () => {}
env.art.on('destroy', existingDestroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
assert.throws(() => env.factory({ danmuku: [], speed: 'invalid' })(env.art), /speed/iu)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before)
assert.deepEqual(env.listeners.get('destroy'), [existingDestroy])
assert.equal(env.workers.length, 0)
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
})
test(`${prefix}: initial scheduler Worker construction failure preserves the error and rolls back subscriptions`, () => {
const error = new Error('initial scheduler Worker failed')
const env = danmukuCandidateEnvironment(implementation, { initialWorkerError: error })
const existingDestroy = () => {}
env.art.on('destroy', existingDestroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
assert.throws(() => env.factory({ danmuku: [] })(env.art), failure => failure === error)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before)
assert.deepEqual(env.listeners.get('destroy'), [existingDestroy])
assert.equal(env.workers.length, 0)
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
})