Files
ArtPlayer/test/danmuku-scheduler.test.js
T

1146 lines
42 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Deterministic candidate scheduling uses the Node runner.
import test from 'node:test'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment, danmukuWorkerCode } from './helpers/danmuku-candidate.js'
import { deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const prefix = `Danmuku scheduler ${implementation.name}`
const output = (env, event) => env.events.filter(item => item.name === `artplayerPluginDanmuku:${event}`)
function measuredNode(node) {
node.clientWidth = 100
node.clientHeight = 20
Object.defineProperty(node, 'offsetTop', { get: () => Number.parseFloat(node.style.top) || 0 })
return node
}
async function fixture(options = {}, { automatic = false } = {}) {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory({ danmuku: [], ...options })(env.art)
const owner = plugin.show()
const createElement = env.context.document.createElement
env.context.document.createElement = tag => measuredNode(createElement(tag))
const layer = env.art.template.$danmuku
let content = layer.textContent
Object.defineProperty(layer, 'textContent', {
get: () => content,
set(value) {
content = value
for (const child of layer.children)
child.parentElement = null
layer.children.length = 0
},
})
layer.appendChild = (child) => {
const siblings = child.parentElement?.children
const index = siblings?.indexOf(child) ?? -1
if (index >= 0)
siblings.splice(index, 1)
layer.children.push(child)
child.parentElement = layer
return child
}
const worker = env.workers[0]
const workerCode = await danmukuWorkerCode(implementation, env, worker)
const responses = []
const workerContext = vm.createContext({ postMessage: data => responses.push(structuredClone(data)), URL: { revokeObjectURL() {} }, location: { href: worker.url } })
workerContext.self = workerContext
vm.runInContext(workerCode, workerContext, { timeout: 1000 })
function calculate(message) {
workerContext.input = structuredClone(message)
const count = responses.length
vm.runInContext('onmessage({ data: input })', workerContext, { timeout: 1000 })
assert.equal(responses.length, count + 1, 'The actual Worker algorithm must produce the response')
return responses.at(-1)
}
function deliver(data) {
const event = { data }
worker.onmessage?.(event)
for (const callback of [...worker.listeners.get('message') || []])
callback(event)
}
function reply(index) {
const message = worker.messages[index].message
deliver(calculate(message))
}
const post = worker.postMessage.bind(worker)
worker.postMessage = (message) => {
post(structuredClone(message))
if (automatic)
queueMicrotask(() => deliver(calculate(message)))
}
return { env, plugin, owner, worker, layer, calculate, deliver, reply }
}
function geometry(mode = 1) {
return { type: 'getDanmuTop', target: { mode, height: 20, speed: 148 }, visibles: [], antiOverlap: true, clientWidth: 640, clientHeight: 360, marginTop: 10, marginBottom: 90 }
}
test(`${prefix}: non-overlap-disabled placement does not queue a Worker round trip and keeps callbacks serial`, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ antiOverlap: false, beforeVisible(row) {
calls.push(row.text)
return row.text === 'first' ? gate.promise : true
} })
t.after(() => {
env.destroy()
gate.resolve(false)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'second', time: 10, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'])
gate.resolve(true)
await env.flush()
assert.deepEqual(calls, ['first', 'second'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'second'])
assert.equal(worker.messages.length, 0)
assert.deepEqual([...owner.queue].map(row => row.$ref.offsetTop), [owner.marginTop, owner.marginTop + 20])
})
for (const boundary of ['continuous', 'seek', 'stop', 'hide', 'visibility']) {
test(`${prefix}: CPU gap recovery respects ${boundary} boundaries and keeps public readys unchanged`, async (t) => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
t.after(() => env.destroy())
const listeners = new Set()
const doc = {
visibilityState: 'visible',
addEventListener(name, fn) {
assert.equal(name, 'visibilitychange')
listeners.add(fn)
},
removeEventListener(name, fn) {
assert.equal(name, 'visibilitychange')
listeners.delete(fn)
},
}
owner.$player.ownerDocument = doc
await plugin.load([{ text: 'gap', time: 10.4, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(800)
env.art.currentTime = 10.8
if (boundary === 'seek')
env.art.emit('video:seeking')
if (boundary === 'stop') {
owner.stop()
owner.start()
}
if (boundary === 'hide') {
owner.hide()
owner.show()
}
if (boundary === 'visibility') {
for (const listener of listeners) listener()
}
assert.equal(owner.readys.length, 0)
env.frame()
await env.flush()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), boundary === 'continuous' ? ['gap'] : [])
env.destroy()
assert.equal(listeners.size, 0)
})
}
test(`${prefix}: document adoption drops the old catch-up window and transfers visibility ownership`, async (t) => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
t.after(() => env.destroy())
function document() {
const listeners = new Set()
return {
visibilityState: 'visible',
listeners,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
}
}
const original = document()
const adopted = document()
owner.$player.ownerDocument = original
await plugin.load([{ text: 'before adoption', time: 10.4, mode: 1 }, { text: 'after adoption', time: 11.2, mode: 1 }])
env.art.playing = true
owner.start()
await env.frame()
assert.equal(original.listeners.size, 1)
owner.$player.ownerDocument = adopted
env.tick(800)
env.art.currentTime = 10.8
await env.frame()
assert.equal(original.listeners.size, 0)
assert.equal(adopted.listeners.size, 1)
assert.equal(output(env, 'visible').length, 0, 'Do not replay rows crossed while adopting another document')
env.tick(800)
env.art.currentTime = 11.6
await env.frame()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['after adoption'])
env.destroy()
assert.equal(adopted.listeners.size, 0)
})
test(`${prefix}: CPU gap recovery ignores newly appended past rows and does not retry an already sampled false callback`, async (t) => {
const calls = []
const { env, plugin, owner } = await fixture({ beforeVisible(row) {
calls.push(row.text)
return row.text !== 'declined'
} }, { automatic: true })
t.after(() => env.destroy())
await plugin.load([{ text: 'declined', time: 10, mode: 1 }, { text: 'gap', time: 10.4, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(800)
env.art.currentTime = 10.8
await plugin.load([{ text: 'appended-past', time: 10.3, mode: 1 }])
env.frame()
await env.flush()
assert.deepEqual(calls, ['declined', 'gap'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['gap'])
})
for (const boundary of ['stop', 'hide', 'reset', 'destroy']) {
test(`${prefix}: inline relaxed placement honors ${boundary} from the first visible callback`, async (t) => {
const { env, plugin, owner, worker } = await fixture({ antiOverlap: false })
t.after(() => env.destroy())
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'cancelled', time: 10, mode: 1 }])
env.art.on('artplayerPluginDanmuku:visible', () => owner[boundary]())
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first'])
assert.equal(worker.messages.length, 0)
assert.equal(owner.queue[1].$ref, null)
})
}
test(`${prefix}: repeated play/playing and direct starts preserve events and internal return with only one queued frame`, async () => {
const { env, owner } = await fixture()
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
assert.equal(owner.start(), owner)
assert.equal(output(env, 'start').length, 3)
assert.equal(env.frames.size, 1)
assert.equal(owner.stop(), owner)
assert.equal(owner.stop(), owner)
assert.equal(output(env, 'stop').length, 2)
assert.equal(env.frames.size, 0)
env.destroy()
})
test(`${prefix}: additional starts keep one sampling frame without overlapping beforeVisible or Worker dispatch`, async (t) => {
const gate = deferred()
let calls = 0
const { env, plugin, owner, worker } = await fixture({ beforeVisible: () => {
calls++
return gate.promise
} })
t.after(() => {
env.destroy()
gate.resolve(true)
})
await plugin.emit({ text: 'waiting callback', time: 10 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
owner.start()
env.art.emit('video:playing')
assert.equal(env.frames.size, 1, 'One sampler continues while the single dispatcher waits')
assert.equal(calls, 1)
assert.equal(worker.messages.length, 0)
env.frame()
await env.flush()
assert.equal(env.frames.size, 1)
assert.equal(calls, 1, 'Sampling and additional starts cannot duplicate the pending callback')
assert.equal(worker.messages.length, 0, 'No placement starts before the pending callback allows it')
})
test(`${prefix}: sampling dispatcher retains a middle timestamp while the first callback waits`, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ beforeVisible: (row) => {
calls.push(row.text)
return row.text === 'first' ? gate.promise : true
} }, { automatic: true })
t.after(() => {
env.destroy()
gate.resolve(true)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'middle', time: 10.5, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(500)
env.art.currentTime = 10.5
// Drive only callbacks the implementation actually scheduled; the old single
// asynchronous frame has none here and consequently loses this timestamp.
if (env.frames.size)
env.frame()
await env.flush()
if (env.frames.size)
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'], 'Sampling must not run another user callback concurrently')
assert.equal(worker.messages.length, 0)
assert.equal(owner.queue[1].$state, 'wait', 'Sampling must not rewrite the public state pool')
env.tick(500)
env.art.currentTime = 11
if (env.frames.size)
env.frame()
await env.flush()
assert.equal(owner.readys.includes(owner.queue[1]), false, 'The public getter retains its current-time window')
gate.resolve(true)
await env.flush()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'middle'], 'The observed middle timestamp must be dispatched exactly once after the first batch')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'middle'])
assert.equal(worker.messages.length, 2)
assert.equal(new Set(worker.messages.map(item => item.message.id)).size, 2)
})
test(`${prefix}: sampling dispatcher expires a visible row while another callback remains pending`, async (t) => {
const gate = deferred()
const { env, plugin, owner, worker } = await fixture({ speed: 1, beforeVisible: row => row.text === 'blocked' ? gate.promise : true }, { automatic: true })
t.after(() => {
env.destroy()
gate.resolve(false)
})
await plugin.emit({ text: 'visible', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
const ref = row.$ref
assert.equal(row.$state, 'emit')
await plugin.emit({ text: 'blocked', time: 10.05, mode: 1 })
env.tick(50)
env.art.currentTime = 10.05
env.frame()
await env.flush()
assert.equal(worker.messages.length, 1)
env.tick(1000)
env.art.currentTime = 11.05
if (env.frames.size)
env.frame()
await env.flush()
assert.equal(row.$state, 'wait', 'A different pending hook must not extend an already visible row lifetime')
assert.equal(row.$ref, null)
assert.equal(ref.style.visibility, 'hidden')
assert.deepEqual(Array.from(owner.$refs), [ref])
assert.equal(worker.messages.length, 1, 'The unresolved callback still owns the only dispatcher')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['visible'])
})
test(`${prefix}: sampling dispatcher discards buffered observations on lifecycle invalidation`, async (t) => {
for (const action of ['seek', 'hide', 'stop', 'reset', 'destroy']) {
await t.test(action, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ beforeVisible: (row) => {
calls.push(row.text)
return row.text === 'first' ? gate.promise : true
} }, { automatic: true })
t.after(() => {
if (!env.art.isDestroy)
env.destroy()
gate.resolve(true)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'buffered', time: 10.5, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(500)
env.art.currentTime = 10.5
if (env.frames.size)
env.frame()
await env.flush()
env.tick(500)
env.art.currentTime = 11
if (action === 'destroy')
env.destroy()
else owner[action]()
gate.resolve(true)
await env.flush()
if (env.frames.size)
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'])
assert.equal(worker.messages.length, 0, 'Invalidated observations must not allocate or place a late row')
assert.equal(output(env, 'visible').length, 0)
if (action !== 'destroy') {
if (action === 'hide')
owner.show()
owner.start()
await plugin.emit({ text: 'fresh', time: 11, mode: 1 })
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'fresh'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['fresh'])
assert.equal(worker.messages.length, 1)
}
})
}
})
test(`${prefix}: sampling dispatcher does not let a full ready track starve a later wait row in its batch`, async (t) => {
const calls = []
const { env, plugin, owner, worker } = await fixture({ margin: [10, 10], beforeVisible: (row) => {
calls.push(row.text)
return true
} }, { automatic: true })
t.after(() => env.destroy())
owner.$player.clientHeight = 40
await plugin.emit({ text: 'occupant', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await plugin.emit({ text: 'full-ready', time: 10, mode: 1 })
env.frame()
await env.flush()
assert.equal(owner.queue[1].$state, 'ready', 'The real positioning algorithm must defer the occupied track')
await plugin.emit({ text: 'later-wait', time: 10, mode: 2 })
const previousCalls = calls.length
const requests = worker.messages.length
env.frame()
await env.flush()
await env.flush()
assert.deepEqual(calls.slice(previousCalls), ['full-ready', 'later-wait'])
assert.equal(worker.messages.length, requests + 2)
assert.equal(owner.queue[1].$state, 'ready')
assert.equal(owner.queue[2].$state, 'emit')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['occupant', 'later-wait'])
})
test(`${prefix}: an old finalizer preserves the new generation dispatcher and its sampled pending row`, async (t) => {
const oldGate = deferred()
const newGate = deferred()
const calls = []
let firstCalls = 0
const { env, plugin, owner, worker } = await fixture({ beforeVisible: (row) => {
calls.push(row.text)
if (row.text === 'first')
return ++firstCalls === 1 ? oldGate.promise : newGate.promise
return true
} }, { automatic: true })
t.after(() => {
env.destroy()
oldGate.resolve(true)
newGate.resolve(true)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'new-middle', time: 10.5, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'])
owner.seek()
// These are actually queued RAF callbacks. Run the new generation and sample
// its next row before yielding to the cancelled old dispatcher's microtasks.
env.frame()
env.tick(500)
env.art.currentTime = 10.5
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'first'])
assert.equal(worker.messages.length, 0)
env.tick(500)
env.art.currentTime = 11
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'first'], 'The old finalizer must not release the new pending callback ownership')
assert.equal(worker.messages.length, 0)
newGate.resolve(true)
await env.flush()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'first', 'new-middle'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'new-middle'])
assert.equal(worker.messages.length, 2)
oldGate.resolve(true)
await env.flush()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'new-middle'])
assert.equal(worker.messages.length, 2)
})
test(`${prefix}: sampling dispatcher prioritizes ready over a wait row captured in an earlier frame`, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ margin: [10, 10], beforeVisible: (row) => {
calls.push(row.text)
return row.text === 'blocking' ? gate.promise : true
} }, { automatic: true })
t.after(() => {
env.destroy()
gate.resolve(false)
})
owner.$player.clientHeight = 40
await plugin.emit({ text: 'occupant', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await plugin.emit({ text: 'full-ready', time: 10, mode: 1 })
env.frame()
await env.flush()
assert.equal(owner.queue[1].$state, 'ready')
await plugin.emit({ text: 'blocking', time: 10.3, mode: 2 })
env.tick(300)
env.art.currentTime = 10.3
env.frame()
await env.flush()
await env.flush()
assert.equal(calls.at(-1), 'blocking')
await plugin.emit({ text: 'earlier-captured-wait', time: 10.8, mode: 2 })
env.tick(500)
env.art.currentTime = 10.8
env.frame()
await env.flush()
assert.equal(calls.at(-1), 'blocking', 'Sampling must preserve the active batch until its callback settles')
assert.equal(owner.queue[3].$state, 'wait')
gate.resolve(false)
await env.flush()
const previousCalls = calls.length
const previousRequests = worker.messages.length
env.frame()
await env.flush()
await env.flush()
assert.deepEqual(calls.slice(previousCalls), ['full-ready', 'earlier-captured-wait'], 'The next batch retains ready priority even when wait was captured first')
assert.equal(worker.messages.length, previousRequests + 2)
assert.equal(owner.queue[1].$state, 'ready')
assert.equal(owner.queue[3].$state, 'emit')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['occupant', 'earlier-captured-wait'])
})
for (const action of ['pause', 'destroy', 'reset', 'replace', 'seek', 'hide']) {
for (const stage of ['beforeVisible', 'worker']) {
test(`${prefix}: ${action} invalidates the pending ${stage} continuation without modifying an obsolete row`, async () => {
const gate = deferred()
const state = await fixture(stage === 'beforeVisible' ? { beforeVisible: () => gate.promise } : {})
const { env, plugin, owner, worker, layer } = state
await plugin.emit({ text: 'old', time: 10, mode: 1 })
const row = owner.queue[0]
env.art.playing = true
owner.start()
env.frame()
await env.flush()
let response
if (stage === 'worker') {
assert.equal(worker.messages.length, 1)
response = state.calculate(worker.messages[0].message)
}
if (action === 'pause') {
env.art.playing = false
owner.stop()
}
else if (action === 'destroy') {
env.destroy()
}
else if (action === 'reset') {
plugin.reset()
}
else if (action === 'replace') {
plugin.config({ danmuku: [{ text: 'new', time: 20 }] })
await plugin.load()
}
else if (action === 'hide') {
plugin.hide()
}
else {
env.art.currentTime = 20
env.art.emit('video:seeking')
}
const snapshot = { rowState: row.$state, rowRef: row.$ref, nodes: [...layer.children], events: output(env, 'visible').length, messages: worker.messages.length }
if (stage === 'worker')
state.deliver(response)
else
gate.resolve(true)
await env.flush()
assert.equal(row.$state, snapshot.rowState)
assert.equal(row.$ref, snapshot.rowRef)
assert.deepEqual(layer.children, snapshot.nodes)
assert.equal(output(env, 'visible').length, snapshot.events)
assert.equal(worker.messages.length, snapshot.messages)
if (action !== 'destroy')
env.destroy()
})
}
}
test(`${prefix}: pausing a never-settled callback does not block a fresh generation`, async () => {
const gate = deferred()
let calls = 0
const { env, plugin, owner } = await fixture({ beforeVisible: () => ++calls === 1 ? gate.promise : true }, { automatic: true })
await plugin.emit({ text: 'resumable', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
owner.stop()
owner.start()
await env.flush()
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.equal(output(env, 'visible').length, 1)
assert.equal(calls, 2)
env.destroy()
gate.resolve(true)
await env.flush()
assert.equal(output(env, 'visible').length, 1)
})
test(`${prefix}: an already delivered Worker reply cannot resume its JavaScript continuation after destroy`, async () => {
const { env, plugin, owner, layer, reply } = await fixture()
await plugin.emit({ text: 'queued microtask', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
reply(0)
env.destroy()
const row = owner.queue[0]
const snapshot = { state: row.$state, ref: row.$ref, nodes: [...layer.children] }
await env.flush()
assert.equal(row.$state, snapshot.state)
assert.equal(row.$ref, snapshot.ref)
assert.deepEqual(layer.children, snapshot.nodes)
assert.equal(output(env, 'visible').length, 0)
assert.equal(env.frames.size, 0)
})
test(`${prefix}: beforeVisible rejection reports the original error once, skips that row until start, and allows other rows`, async () => {
const error = new Error('visibility hook rejected')
const calls = []
let retry = false
const { env, plugin, owner } = await fixture({
beforeVisible(row) {
calls.push(row.text)
return row.text === 'failed' && !retry ? Promise.reject(error) : true
},
}, { automatic: true })
await plugin.load([{ text: 'failed', time: 10, mode: 1 }, { text: 'healthy', time: 10, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await env.flush()
assert.deepEqual(output(env, 'error').map(event => event.args[0]), [error])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['healthy'])
env.frame()
await env.flush()
assert.deepEqual(calls, ['failed', 'healthy'])
retry = true
assert.equal(owner.start(), owner)
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.deepEqual(calls, ['failed', 'healthy', 'failed'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['healthy', 'failed'])
assert.equal(output(env, 'error').length, 1)
env.destroy()
})
test(`${prefix}: a cancelled beforeVisible rejection is observed without reporting a stale error`, async () => {
const gate = deferred()
const { env, plugin, owner } = await fixture({ beforeVisible: () => gate.promise })
await plugin.emit({ text: 'cancelled', time: 10 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.destroy()
gate.reject(new Error('rejected after destroy'))
await env.flush()
assert.equal(output(env, 'error').length, 0)
assert.equal(output(env, 'visible').length, 0)
assert.equal(env.frames.size, 0)
})
test(`${prefix}: beforeVisible false remains eligible for the next frame`, async () => {
let calls = 0
const { env, plugin, owner } = await fixture({ beforeVisible: () => ++calls > 1 }, { automatic: true })
await plugin.emit({ text: 'retry false', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.equal(output(env, 'visible').length, 0)
env.frame()
await env.flush()
assert.equal(calls, 2)
assert.equal(output(env, 'visible').length, 1)
assert.equal(output(env, 'error').length, 0)
env.destroy()
})
test(`${prefix}: hide preserves an already visible row and show permits fresh scheduling`, async () => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'visible before hide', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
const ref = row.$ref
const resets = output(env, 'reset').length
assert.equal(plugin.hide(), owner)
assert.equal(row.$state, 'emit')
assert.equal(row.$ref, ref)
assert.equal(output(env, 'reset').length, resets)
assert.equal(plugin.show(), owner)
await plugin.emit({ text: 'visible after show', time: 10, mode: 1 })
env.frame()
await env.flush()
assert.equal(output(env, 'visible').length, 2)
env.destroy()
})
test(`${prefix}: replacing beforeVisible releases an unresolved old callback without resetting displayed rows`, async () => {
const gate = deferred()
const calls = []
const { env, plugin, owner } = await fixture({
beforeVisible(row) {
calls.push(`old:${row.text}`)
return row.text === 'pending' ? gate.promise : true
},
}, { automatic: true })
await plugin.load([{ text: 'displayed', time: 10, mode: 1 }, { text: 'pending', time: 10, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await env.flush()
const displayed = owner.queue[0]
const ref = displayed.$ref
const resets = output(env, 'reset').length
assert.deepEqual(calls, ['old:displayed', 'old:pending'])
plugin.config({
beforeVisible(row) {
calls.push(`new:${row.text}`)
return true
},
})
assert.equal(displayed.$state, 'emit')
assert.equal(displayed.$ref, ref)
assert.equal(output(env, 'reset').length, resets)
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.deepEqual(calls, ['old:displayed', 'old:pending', 'new:pending'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['displayed', 'pending'])
gate.resolve(true)
await env.flush()
assert.equal(output(env, 'visible').length, 2)
env.destroy()
})
test(`${prefix}: configuring the same beforeVisible callback preserves the pending operation`, async () => {
const gate = deferred()
let calls = 0
const beforeVisible = () => {
calls++
return gate.promise
}
const { env, plugin, owner } = await fixture({ beforeVisible }, { automatic: true })
await plugin.emit({ text: 'same callback', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const configurations = output(env, 'config').length
plugin.config({ beforeVisible })
assert.equal(output(env, 'config').length, configurations)
assert.equal(env.frames.size, 1)
await env.frame()
assert.equal(calls, 1, 'Sampling cannot start a second callback while the first is pending')
gate.resolve(true)
await env.flush()
assert.equal(calls, 1)
assert.equal(output(env, 'visible').length, 1)
assert.equal(env.frames.size, 1)
env.destroy()
})
for (const action of ['destroy', 'stop']) {
test(`${prefix}: ${action} inside a Worker rebuild error listener prevents the outer start from reviving stopped rows`, async () => {
const { env, plugin, owner, worker } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'already displayed', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
assert.equal(row.$state, 'emit')
const workerError = new Error('initial worker fault')
worker.onerror({ error: workerError, preventDefault() {} })
owner.stop()
assert.equal(row.$state, 'stop')
const rebuildError = new Error('rebuild original error')
// This is an explicit constructor-failure seam, not a native Worker assertion.
owner.createWorker = () => {
throw rebuildError
}
env.art.on('artplayerPluginDanmuku:error', (error) => {
if (error === rebuildError) {
if (action === 'destroy')
env.destroy()
else
owner.stop()
}
})
const starts = output(env, 'start').length
assert.equal(owner.start(), owner)
await env.flush()
assert.equal(row.$state, 'stop')
assert.equal(owner.isStop, true)
assert.equal(output(env, 'start').length, starts)
assert.equal(env.frames.size, 0)
assert.deepEqual(output(env, 'error').map(event => event.args[0]), [workerError, rebuildError])
if (action !== 'destroy')
env.destroy()
})
}
for (const action of ['reset', 'hide', 'start']) {
test(`${prefix}: ${action} reentry from a rebuild error owns the recovery without a second outer start`, async () => {
const { env, plugin, owner, worker } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'displayed before recovery', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
assert.equal(row.$state, 'emit')
const workerError = new Error('worker failed before reentry')
worker.onerror({ error: workerError, preventDefault() {} })
owner.stop()
const createWorker = owner.createWorker
const rebuildError = new Error('one rebuild failure')
owner.createWorker = () => {
throw rebuildError
}
let recoveries = 0
env.art.on('artplayerPluginDanmuku:error', (error) => {
if (error !== rebuildError)
return
recoveries++
assert.equal(recoveries, 1, 'The recovery listener must not retry itself indefinitely')
owner.createWorker = createWorker
owner[action]()
})
const starts = output(env, 'start').length
const resets = output(env, 'reset').length
assert.equal(owner.start(), owner)
await env.flush()
assert.equal(recoveries, 1)
assert.deepEqual(output(env, 'error').map(event => event.args[0]), [workerError, rebuildError])
assert.equal(output(env, 'start').length, starts + (action === 'start' ? 1 : 0))
assert.equal(output(env, 'reset').length, resets + (action === 'reset' ? 1 : 0))
assert.equal(row.$state, action === 'reset' ? 'wait' : action === 'start' ? 'emit' : 'stop')
assert.equal(owner.isHide, action === 'hide')
assert.equal(env.frames.size, action === 'start' ? 1 : 0)
if (action === 'reset')
assert.equal(row.$ref, null)
env.destroy()
})
}
test(`${prefix}: requests in the same millisecond have distinct ids and reverse replies settle the right promises`, async () => {
const { env, owner, worker, reply } = await fixture()
let first
let second
owner.postMessage(geometry(1)).then(value => first = value)
owner.postMessage(geometry(2)).then(value => second = value)
assert.equal(worker.messages.length, 2)
assert.notEqual(worker.messages[0].message.id, worker.messages[1].message.id)
reply(1)
await env.flush()
assert.equal(first, undefined)
assert.equal(second.result, 250)
assert.equal(second.id, worker.messages[1].message.id)
reply(0)
await env.flush()
assert.equal(first.result, 10)
assert.equal(first.id, worker.messages[0].message.id)
env.destroy()
})
test(`${prefix}: a synchronous real algorithm response cannot arrive before its resolver is installed`, async () => {
const { env, owner, worker, calculate, deliver } = await fixture()
const post = worker.postMessage.bind(worker)
worker.postMessage = (message) => {
post(message)
deliver(calculate(message))
}
let response
owner.postMessage(geometry(2)).then(value => response = value)
await env.flush()
assert.equal(response?.result, 250)
env.destroy()
})
test(`${prefix}: the actual algorithm places three same-time fixed rows on distinct tracks in one frame`, async () => {
const { env, plugin, owner, layer } = await fixture({}, { automatic: true })
await plugin.load([0, 1, 2].map(index => ({ text: `fixed-${index}`, time: 10, mode: 1 })))
env.art.playing = true
owner.start()
env.frame()
for (let step = 0; step < 3; step++)
await env.flush()
assert.deepEqual(Array.from(owner.queue, row => row.$state), ['emit', 'emit', 'emit'])
assert.deepEqual(Array.from(owner.queue, row => row.$ref.offsetTop), [10, 30, 50])
assert.equal(layer.children.length, 3)
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['fixed-0', 'fixed-1', 'fixed-2'])
assert.equal(env.frames.size, 1)
env.destroy()
})
for (const mode of [0, 1, 2]) {
test(`${prefix}: delayed Worker reply starts visible lifetime at placement for mode ${mode}`, async (t) => {
const { env, plugin, owner, worker, layer, reply } = await fixture({ speed: 1 })
t.after(() => env.destroy())
await plugin.emit({ text: `delayed-${mode}`, time: 10, mode })
const row = owner.queue[0]
env.art.playing = true
owner.start()
const pendingFrame = env.frame()
await env.flush()
assert.equal(worker.messages.length, 1)
const ref = row.$ref
assert.equal(layer.children.length, 1)
assert.equal(output(env, 'visible').length, 0, 'Measurement before the Worker reply is not visible lifetime')
assert.notEqual(ref.style.visibility, 'visible')
// The reply uses the real positioning algorithm; only its delivery and the
// wall/media clocks are controlled. No browser animation timing is claimed.
env.tick(3000)
env.art.currentTime += 3
const placedAt = env.context.Date.now()
reply(0)
await pendingFrame
assert.equal(row.$state, 'emit')
assert.equal(row.$ref, ref)
assert.equal(ref.style.visibility, 'visible')
assert.deepEqual(output(env, 'visible').map(event => event.args[0]), [row])
assert.equal(row.$restTime, 1)
assert.equal(row.$lastStartTime, placedAt, 'Hidden Worker waiting must not shorten the visible duration')
if (mode === 0)
assert.equal(ref.style.transition, 'transform 1s linear 0s')
env.tick(250)
env.art.currentTime += 0.25
await env.frame()
assert.equal(row.$state, 'emit')
assert.equal(row.$ref, ref)
assert.equal(row.$restTime, 0.75, 'Only elapsed time since visible placement consumes lifetime')
assert.equal(owner.$refs.length, 0)
env.tick(750)
env.art.currentTime += 0.75
await env.frame()
assert.equal(row.$state, 'wait')
assert.equal(row.$ref, null)
assert.equal(ref.style.visibility, 'hidden')
assert.deepEqual(Array.from(owner.$refs), [ref], 'The expired node returns to its pool exactly once')
await env.frame()
assert.deepEqual(Array.from(owner.$refs), [ref])
assert.equal(worker.messages.length, 1)
assert.equal(output(env, 'visible').length, 1)
assert.equal(output(env, 'error').length, 0)
})
}
test(`${prefix}: a sampled three-hour simulated clock preserves batch order, pooled nodes, pause and seek state`, async (t) => {
// Advance a controlled clock at lifecycle boundaries; this is not a real-time
// endurance, frame-rate, CSS animation or browser memory measurement.
const { env, plugin, owner, worker, layer } = await fixture({}, { automatic: true })
t.after(() => env.destroy())
const batchCount = 180
const batchSize = 4
const firstTime = 10
const horizon = 3 * 60 * 60
const rows = Array.from({ length: batchCount * batchSize }, (_, index) => ({
text: `long-${index}`,
time: firstTime + Math.floor(index / batchSize) * 60,
mode: 1,
}))
await plugin.load(rows)
const queue = owner.queue
const expectedOrder = Array.from(queue, row => row.text)
let advanced = 0
const tick = (milliseconds) => {
advanced += milliseconds
env.tick(milliseconds)
}
const membership = () => {
const items = Object.entries(owner.states).flatMap(([state, items]) => {
for (const item of items)
assert.equal(item.$state, state)
return Array.from(items)
})
assert.equal(items.length, queue.length)
assert.equal(new Set(items).size, queue.length, 'Every row belongs to exactly one state pool')
assert(items.every(item => queue.includes(item)))
}
let pooledNodes
let pauses = 0
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
for (let batch = 0; batch < batchCount; batch++) {
const time = firstTime + batch * 60
tick((time - env.art.currentTime) * 1000)
env.art.currentTime = time
assert.equal(env.frames.size, 1)
const requests = worker.messages.length
await env.frame()
const active = Array.from(queue.slice(batch * batchSize, (batch + 1) * batchSize))
assert.deepEqual(output(env, 'visible').slice(-batchSize).map(event => event.args[0]), active)
assert.equal(worker.messages.length, requests + batchSize, 'One position request per row in this batch')
assert.deepEqual(worker.messages.slice(requests).map(item => item.message.visibles.length), [0, 1, 2, 3])
assert.deepEqual(active.map(row => row.$state), ['emit', 'emit', 'emit', 'emit'])
assert.deepEqual(active.map(row => row.$ref.offsetTop), [10, 30, 50, 70])
if (!pooledNodes)
pooledNodes = new Set(layer.children)
assert.equal(layer.children.length, batchSize)
assert(active.every(row => pooledNodes.has(row.$ref)), 'Later batches reuse the original measured nodes')
membership()
tick(1000)
env.art.currentTime++
await env.frame()
assert(active.every(row => row.$restTime === 4))
if (batch % 12 === 11) {
pauses++
const refs = active.map(row => row.$ref)
const resetCount = output(env, 'reset').length
env.art.playing = false
env.art.emit('video:pause')
assert.equal(env.frames.size, 0)
tick(120000)
env.art.currentTime = time + 20
env.art.emit('video:seeking')
env.art.emit('video:seeked')
assert.equal(output(env, 'reset').length, resetCount)
assert(active.every(row => row.$state === 'stop' && row.$restTime === 4))
assert.deepEqual(active.map(row => row.$ref), refs)
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
assert.equal(env.frames.size, 1)
await env.frame()
assert(active.every(row => row.$state === 'emit' && row.$restTime === 4))
assert.deepEqual(active.map(row => row.$ref), refs)
assert.equal(worker.messages.length, requests + batchSize, 'Resume and seek must not allocate duplicate rows')
}
tick(5000)
env.art.currentTime += 5
await env.frame()
assert(active.every(row => row.$state === 'wait' && row.$ref === null))
assert.equal(owner.$refs.length, batchSize)
assert.equal(new Set(owner.$refs).size, batchSize, 'Each reusable node returns to the pool once')
assert.equal(layer.children.length, batchSize)
assert.equal(env.frames.size, 1)
membership()
}
tick((firstTime + horizon - env.art.currentTime) * 1000)
env.art.currentTime = firstTime + horizon
await env.frame()
assert.equal(pauses, 15)
assert(advanced > horizon * 1000, 'The simulated wall clock also includes the paused intervals')
assert.equal(owner.queue, queue)
assert(queue.every(row => row.$state === 'wait' && row.$ref === null))
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), expectedOrder)
assert.equal(worker.messages.length, rows.length)
assert.equal(new Set(worker.messages.map(item => item.message.id)).size, rows.length)
assert.equal(output(env, 'error').length, 0)
assert.equal(env.workers.length, 1)
owner.stop()
assert.equal(env.frames.size, 0)
})
test(`${prefix}: paused wall clock does not consume remaining lifetime and expiration returns a row to wait`, async () => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'clock', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
assert.equal(row.$restTime, 5)
env.tick(1000)
env.art.currentTime = 11
env.frame()
await env.flush()
assert.equal(row.$restTime, 4)
env.art.playing = false
owner.stop()
assert.equal(row.$state, 'stop')
env.tick(5000)
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.equal(row.$restTime, 4)
env.tick(500)
env.art.currentTime = 11.5
env.frame()
await env.flush()
assert.equal(row.$restTime, 3.5)
env.tick(4000)
env.art.currentTime = 15.5
env.frame()
await env.flush()
assert.equal(row.$state, 'wait')
assert.equal(row.$ref, null)
assert.equal(owner.queue.length, 1)
assert.equal(output(env, 'visible').length, 1)
env.destroy()
})
test(`${prefix}: playbackRate is sampled for new rows while active lifetime and seek reset-event semantics stay compatible`, async () => {
const { env, plugin, owner } = await fixture({ synchronousPlayback: true }, { automatic: true })
await plugin.load([{ text: 'already-visible', time: 10, mode: 1 }, { text: 'after-seek', time: 20, mode: 1 }])
env.art.playbackRate = 2
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const existing = owner.queue[0]
assert.equal(existing.$restTime, 2.5)
env.art.playbackRate = 4
const resets = output(env, 'reset').length
env.art.currentTime = 20
env.art.emit('video:seeking')
assert.equal(existing.$state, 'emit')
assert.equal(existing.$restTime, 2.5)
assert.equal(output(env, 'reset').length, resets)
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.equal(owner.queue[1].$restTime, 1.25)
assert.equal(existing.$restTime, 2.5)
env.destroy()
})