mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
1146 lines
42 KiB
JavaScript
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()
|
|
})
|