mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 00:56:16 -08:00
refactor(danmuku): [PKG-DANMUKU-04] own scheduler and worker request lifecycle
This commit is contained in:
1 parent
0b2efb07f1
commit
12332df221
24 files changed
+23441
-461
No files matched your search
@@ -0,0 +1,444 @@
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { compilePackage } from '../helpers/load.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
let candidate
|
||||
test.beforeAll(async () => {
|
||||
candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
|
||||
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
|
||||
: await compilePackage('artplayer-plugin-danmuku', 'umd')
|
||||
})
|
||||
async function setup(page, core, format, scenario, testInfo) {
|
||||
const external = []
|
||||
const origin = new URL(testInfo.project.use.baseURL).origin
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== origin) {
|
||||
external.push(url.href)
|
||||
return route.abort()
|
||||
}
|
||||
// Same-origin blob URLs are actual inline Worker/media resources, not external requests.
|
||||
return route.continue()
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.evaluate(() => {
|
||||
window.danmukuEvidence = { workers: [], events: [], gates: [], sends: [], failures: [] }
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class ObservedNativeWorker extends NativeWorker {
|
||||
constructor(url, options) {
|
||||
super(url, options)
|
||||
this.evidence = { url: String(url), requests: [], replies: [], terminated: 0, errors: [] }
|
||||
window.danmukuEvidence.workers.push(this.evidence)
|
||||
this.addEventListener('message', ({ data }) => this.evidence.replies.push({ id: data.id, result: data.result ?? null }))
|
||||
this.addEventListener('error', event => this.evidence.errors.push(event.message))
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.evidence.terminated++
|
||||
return super.terminate()
|
||||
}
|
||||
|
||||
postMessage(message, ...args) {
|
||||
this.evidence.requests.push(structuredClone(message))
|
||||
return super.postMessage(message, ...args)
|
||||
}
|
||||
}
|
||||
window.danmukuPlayers = []
|
||||
window.danmukuPlugins = []
|
||||
window.danmukuNodes = []
|
||||
window.danmukuVisibility = new Promise((resolve) => {
|
||||
window.releaseDanmukuVisibility = resolve
|
||||
})
|
||||
window.danmukuSending = new Promise((resolve) => {
|
||||
window.releaseDanmukuSending = resolve
|
||||
})
|
||||
})
|
||||
const implementation = { code: candidate, sha256: hash(candidate) }
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(({ scenario }) => {
|
||||
const first = document.querySelector('.player')
|
||||
first.id = 'danmuku-player-0'
|
||||
first.style.cssText = 'width:640px;height:360px'
|
||||
window.createDanmukuPlayer = (index) => {
|
||||
const container = index === 0 ? first : document.body.appendChild(document.createElement('div'))
|
||||
container.id = `danmuku-player-${index}`
|
||||
container.style.cssText = 'width:640px;height:360px'
|
||||
const mount = document.createElement('div')
|
||||
mount.id = `danmuku-mount-${index}`
|
||||
mount.style.cssText = 'width:640px;height:36px;background:#333'
|
||||
container.after(mount)
|
||||
const art = new window.Artplayer({ container, url: `/test/pattern.mp4?danmuku=${index}`, muted: true, mutex: false, fullscreenWeb: true })
|
||||
window.danmukuPlayers[index] = art
|
||||
for (const event of ['loaded', 'visible', 'start', 'stop', 'reset', 'destroy', 'config', 'error']) {
|
||||
art.on(`artplayerPluginDanmuku:${event}`, (value) => {
|
||||
window.danmukuEvidence.events.push({ index, event, time: art.currentTime, id: value?.id, text: value?.text, mode: value?.mode, count: Array.isArray(value) ? value.length : undefined })
|
||||
if (event === 'error')
|
||||
window.danmukuEvidence.failures.push(String(value))
|
||||
})
|
||||
}
|
||||
const option = {
|
||||
danmuku: [],
|
||||
heatmap: false,
|
||||
speed: 8,
|
||||
margin: [10, 10],
|
||||
fontSize: 20,
|
||||
antiOverlap: true,
|
||||
synchronousPlayback: true,
|
||||
lockTime: 1,
|
||||
...(scenario === 'settings' ? { mount } : {}),
|
||||
filter: danmu => danmu.text !== 'FILTERED',
|
||||
beforeVisible: async (danmu) => {
|
||||
window.danmukuEvidence.gates.push({ index, id: danmu.id, text: danmu.text })
|
||||
if (danmu.text === 'GATED')
|
||||
return window.danmukuVisibility
|
||||
return danmu.text !== 'DENIED'
|
||||
},
|
||||
beforeEmit: async (danmu) => {
|
||||
window.danmukuEvidence.sends.push({ index, text: danmu.text, mode: danmu.mode })
|
||||
return scenario === 'settings' && index === 0 ? window.danmukuSending : true
|
||||
},
|
||||
}
|
||||
art.plugins.add(window.artplayerPluginDanmuku(option))
|
||||
window.danmukuPlugins[index] = art.plugins.artplayerPluginDanmuku
|
||||
return art
|
||||
}
|
||||
window.art = window.createDanmukuPlayer(0)
|
||||
document.querySelector('#play').onclick = () => window.danmukuPlayers[0].play()
|
||||
document.querySelector('#pause').onclick = () => window.danmukuPlayers[0].pause()
|
||||
}, { scenario })
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.template.$video.videoWidth > 0)).toBe(true)
|
||||
await testInfo.attach('danmuku-browser-inputs', { contentType: 'application/json', body: JSON.stringify({ core, plugin: 'artplayer-plugin-danmuku', format, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'in-memory source UMD', sha256: implementation.sha256, media: '/test/pattern.mp4', scenario, worker: 'Native Blob Worker, only constructor/message/termination observation; no Worker responses, media clocks, layout or RAF mocked', heatmap: false }) })
|
||||
return external
|
||||
}
|
||||
|
||||
async function geometry(page) {
|
||||
return page.evaluate(() => (window.danmukuPlayers || []).map((art, index) => {
|
||||
const player = art.template.$player.getBoundingClientRect()
|
||||
return {
|
||||
index,
|
||||
playing: art.playing,
|
||||
time: art.currentTime,
|
||||
rate: art.playbackRate,
|
||||
player: { x: player.x, y: player.y, width: player.width, height: player.height },
|
||||
nodes: [...art.template.$danmuku.children].map((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
const style = getComputedStyle(node)
|
||||
return { id: node.dataset.id, mode: node.dataset.mode, state: node.dataset.state, text: node.textContent, x: rect.x - player.x, y: rect.y - player.y, width: rect.width, height: rect.height, visibility: style.visibility, display: style.display, transition: node.style.transition, opacity: style.opacity }
|
||||
}),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const visibleIds = page => page.evaluate(() => [...new Set(window.danmukuEvidence.events.filter(item => item.event === 'visible' && item.id).map(item => item.id))])
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await testInfo.attach('danmuku-layout', { contentType: 'application/json', body: JSON.stringify(await geometry(page)) })
|
||||
await testInfo.attach('danmuku-native-page', { contentType: 'image/png', body: await page.screenshot() })
|
||||
await page.evaluate(() => {
|
||||
for (const art of window.danmukuPlayers || []) {
|
||||
if (!art.isDestroy)
|
||||
art.destroy()
|
||||
}
|
||||
})
|
||||
const evidence = await page.evaluate(() => ({ workers: [], failures: [], ...window.danmukuEvidence, expectedFailures: window.expectedDanmukuFailures || [], version: window.Artplayer?.version, retainedExternalSettings: [...document.querySelectorAll('[id^="danmuku-mount-"]')].map(node => node.querySelectorAll('.artplayer-plugin-danmuku').length) }))
|
||||
await testInfo.attach('danmuku-worker-and-lifecycle', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
expect(evidence.failures).toEqual(evidence.expectedFailures)
|
||||
for (const worker of evidence.workers) {
|
||||
expect(worker.url).toMatch(/^blob:/u)
|
||||
expect(worker.errors).toEqual(evidence.expectedFailures.length ? ['controlled track failure'] : [])
|
||||
expect(worker.terminated).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const format of ['candidate']) {
|
||||
const label = `${core} / Candidate Danmuku ${format}`
|
||||
|
||||
test(`${label}: accepted NaN timestamp keeps the historical current-time fallback`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'modes', testInfo)
|
||||
const numeric = await page.evaluate(async () => {
|
||||
const input = { id: 'nan-time', text: 'NAN FALLBACK', mode: 1, time: Number.NaN }
|
||||
const expected = window.art.currentTime + 0.5
|
||||
const owner = await window.danmukuPlugins[0].emit(input)
|
||||
return { expected, inputTime: input.time, queueTime: owner.queue[0].time, isNaN: Number.isNaN(input.time) }
|
||||
})
|
||||
expect(numeric.isNaN).toBe(false)
|
||||
expect(numeric.inputTime).toBe(numeric.expected)
|
||||
expect(numeric.queueTime).toBe(numeric.expected)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toContain('nan-time')
|
||||
expect(external).toEqual([])
|
||||
await testInfo.attach('danmuku-native-numeric-compatibility', { contentType: 'application/json', body: JSON.stringify(numeric) })
|
||||
})
|
||||
|
||||
for (const action of ['destroy', 'stop']) {
|
||||
test(`${label}: failed recovery respects synchronous ${action} from the error listener`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'modes', testInfo)
|
||||
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'recovery-row', text: 'RECOVERY', mode: 1, time: 0.5 }))
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toContain('recovery-row')
|
||||
const result = await page.evaluate((action) => {
|
||||
const owner = window.danmukuPlugins[0].config({})
|
||||
const row = owner.queue[0]
|
||||
const trackError = new Error('controlled track failure')
|
||||
const rebuildError = new Error('controlled track reconstruction failure')
|
||||
window.expectedDanmukuFailures = ['Error: controlled track failure', 'Error: controlled track reconstruction failure']
|
||||
owner.worker.dispatchEvent(new ErrorEvent('error', { error: trackError, message: trackError.message, cancelable: true }))
|
||||
owner.stop()
|
||||
owner.createWorker = () => {
|
||||
throw rebuildError
|
||||
}
|
||||
window.art.on('artplayerPluginDanmuku:error', (error) => {
|
||||
if (error === rebuildError) {
|
||||
if (action === 'destroy')
|
||||
window.art.destroy()
|
||||
else owner.stop()
|
||||
}
|
||||
})
|
||||
const before = window.danmukuEvidence.events.length
|
||||
const sameOwner = owner.start() === owner
|
||||
return { sameOwner, rowState: row.$state, isStop: owner.isStop, isDestroy: window.art.isDestroy, events: window.danmukuEvidence.events.slice(before), workers: window.danmukuEvidence.workers }
|
||||
}, action)
|
||||
expect(result.sameOwner).toBe(true)
|
||||
expect(result.rowState).toBe('stop')
|
||||
expect(result.isStop).toBe(true)
|
||||
expect(result.isDestroy).toBe(action === 'destroy')
|
||||
expect(result.events.filter(event => event.event === 'start')).toEqual([])
|
||||
expect(result.workers).toHaveLength(1)
|
||||
expect(result.workers[0].terminated).toBe(1)
|
||||
expect(external).toEqual([])
|
||||
await testInfo.attach('danmuku-native-recovery-reentry', { contentType: 'application/json', body: JSON.stringify({ action, ...result, fault: 'Actual video/DOM/track Worker; dispatch an explicit native ErrorEvent and inject a throwing createWorker recovery seam. This does not claim a real CSP or browser Worker construction failure.' }) })
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['pause', 'reset', 'seek', 'destroy']) {
|
||||
test(`${label}: native ${action} cancels pending visibility without blocking later work`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'modes', testInfo)
|
||||
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'obsolete-gate', text: 'GATED', mode: 1, time: 0.5 }))
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'obsolete-gate'))).toBe(true)
|
||||
if (action === 'pause') {
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
|
||||
}
|
||||
if (action === 'seek') {
|
||||
await page.evaluate(async () => {
|
||||
const sought = new Promise(resolve => window.art.once('video:seeked', resolve))
|
||||
window.art.currentTime = 2
|
||||
await sought
|
||||
})
|
||||
}
|
||||
else if (action === 'destroy') {
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
}
|
||||
else {
|
||||
await page.evaluate(() => window.danmukuPlugins[0].load())
|
||||
}
|
||||
if (action !== 'destroy') {
|
||||
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'fresh-after-cancel', text: 'FRESH', mode: 1, time: window.art.currentTime + 0.5 }))
|
||||
if (action === 'pause')
|
||||
await page.click('#play')
|
||||
// The old user Promise is still unresolved. A cancelled frame must
|
||||
// release scheduler ownership so this newer item can be displayed.
|
||||
await expect.poll(() => visibleIds(page)).toContain('fresh-after-cancel')
|
||||
}
|
||||
const before = await page.evaluate(() => ({ requests: window.danmukuEvidence.workers.map(worker => worker.requests.length), visible: window.danmukuEvidence.events.filter(event => event.event === 'visible').map(event => event.id) }))
|
||||
await page.evaluate(() => window.releaseDanmukuVisibility(true))
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
expect(await visibleIds(page)).not.toContain('obsolete-gate')
|
||||
if (action === 'destroy') {
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.requests.length))).toEqual(before.requests)
|
||||
expect(await page.evaluate(() => window.art.template.$danmuku.childElementCount)).toBe(0)
|
||||
}
|
||||
expect(external).toEqual([])
|
||||
await testInfo.attach('danmuku-native-cancel', { contentType: 'application/json', body: JSON.stringify({ action, before, after: await visibleIds(page), cancelledSource: 'An unresolved user beforeVisible Promise; native RAF, Worker and media remain active.' }) })
|
||||
})
|
||||
}
|
||||
|
||||
test(`${label}: native Worker renders three modes with synchronous filter and asynchronous beforeVisible`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'modes', testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load([
|
||||
{ id: 'scroll', text: 'SCROLL', mode: 0, time: 0.5 },
|
||||
{ id: 'top', text: 'TOP', mode: 1, time: 0.5 },
|
||||
{ id: 'bottom', text: 'BOTTOM', mode: 2, time: 0.5 },
|
||||
{ id: 'filtered', text: 'FILTERED', mode: 1, time: 0.5 },
|
||||
{ id: 'denied', text: 'DENIED', mode: 1, time: 0.5 },
|
||||
{ id: 'gated', text: 'GATED', mode: 1, time: 0.5 },
|
||||
])
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(['scroll', 'top', 'bottom']))
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'gated'))).toBe(true)
|
||||
expect(await visibleIds(page)).not.toContain('gated')
|
||||
expect(await visibleIds(page)).not.toContain('filtered')
|
||||
expect(await visibleIds(page)).not.toContain('denied')
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'filtered'))).toBe(false)
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.gates.filter(item => item.id === 'gated').length)).toBe(1)
|
||||
await page.evaluate(() => window.releaseDanmukuVisibility(true))
|
||||
await expect.poll(() => visibleIds(page)).toContain('gated')
|
||||
const [layout] = await geometry(page)
|
||||
for (const id of ['top', 'bottom']) {
|
||||
const item = layout.nodes.find(node => node.id === id && node.visibility === 'visible')
|
||||
expect(item).toBeTruthy()
|
||||
expect(Math.abs(item.x + item.width / 2 - layout.player.width / 2)).toBeLessThanOrEqual(2)
|
||||
expect(item.y).toBeGreaterThanOrEqual(9)
|
||||
expect(item.y + item.height).toBeLessThanOrEqual(layout.player.height - 8)
|
||||
}
|
||||
expect(layout.nodes.find(node => node.id === 'top').y).toBeLessThan(layout.nodes.find(node => node.id === 'bottom').y)
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.workers[0].replies.length)).toBeGreaterThanOrEqual(4)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
|
||||
test(`${label}: six simultaneous fixed comments preserve usable track geometry and input order`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'density', testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load(Array.from({ length: 6 }, (_, index) => ({ id: `dense-${index}`, text: `TRACK ${index}`, mode: 1, time: 0.5 })))
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(Array.from({ length: 6 }, (_, index) => `dense-${index}`)))
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
|
||||
const [layout] = await geometry(page)
|
||||
const rows = Array.from({ length: 6 }, (_, index) => layout.nodes.find(node => node.id === `dense-${index}` && node.visibility === 'visible'))
|
||||
expect(rows.every(Boolean)).toBe(true)
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
expect(rows[index].y).toBeGreaterThanOrEqual(9)
|
||||
expect(rows[index].y + rows[index].height).toBeLessThanOrEqual(layout.player.height - 8)
|
||||
if (index)
|
||||
expect(rows[index].y).toBeGreaterThanOrEqual(rows[index - 1].y + rows[index - 1].height - 1)
|
||||
}
|
||||
const order = await visibleIds(page)
|
||||
expect(order).toEqual(Array.from({ length: 6 }, (_, index) => `dense-${index}`))
|
||||
const worker = await page.evaluate(() => window.danmukuEvidence.workers[0])
|
||||
expect(worker.requests).toHaveLength(6)
|
||||
expect(new Set(worker.requests.map(request => request.id)).size).toBe(6)
|
||||
expect(worker.requests.map(request => request.visibles.length)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
expect(worker.replies.map(reply => reply.id)).toEqual(worker.requests.map(request => request.id))
|
||||
await testInfo.attach('danmuku-dense-tracks', { contentType: 'application/json', body: JSON.stringify(rows) })
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
|
||||
test(`${label}: native pause seek rate and public reset/load retain historical pool semantics`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'playback', testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toContain('moving')
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
|
||||
const frozen = await page.evaluate(async () => {
|
||||
const node = window.art.template.$danmuku.querySelector('[data-id="moving"]')
|
||||
window.danmukuNodes = [node]
|
||||
const left = node.getBoundingClientRect().left
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
return { left, next: node.getBoundingClientRect().left, state: node.dataset.state }
|
||||
})
|
||||
expect(frozen.state).toBe('stop')
|
||||
expect(Math.abs(frozen.left - frozen.next)).toBeLessThanOrEqual(1)
|
||||
await page.evaluate(async () => {
|
||||
window.art.playbackRate = 2
|
||||
const sought = new Promise(resolve => window.art.once('video:seeked', resolve))
|
||||
window.art.currentTime = 2
|
||||
await sought
|
||||
})
|
||||
expect(await page.evaluate(() => window.danmukuNodes[0].dataset.state)).toBe('stop')
|
||||
await page.evaluate(async () => {
|
||||
window.danmukuPlugins[0].reset()
|
||||
await window.danmukuPlugins[0].emit({ id: 'reused', text: 'REUSED', mode: 0, time: window.art.currentTime + 0.5 })
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toContain('reused')
|
||||
const reused = await page.evaluate(() => {
|
||||
const node = window.art.template.$danmuku.querySelector('[data-id="reused"]')
|
||||
const original = window.danmukuNodes[0]
|
||||
const nodes = [...window.art.template.$danmuku.children]
|
||||
return {
|
||||
poolReused: original.textContent === 'REUSED' && nodes.includes(original),
|
||||
visibleSameNode: node === original,
|
||||
original: { id: original.dataset.id, state: original.dataset.state, visibility: getComputedStyle(original).visibility, text: original.textContent },
|
||||
extraNodes: nodes.filter(item => item !== original).map(item => ({ id: item.dataset.id, state: item.dataset.state, visibility: getComputedStyle(item).visibility, text: item.textContent })),
|
||||
transition: node.style.transitionDuration,
|
||||
rate: window.art.playbackRate,
|
||||
}
|
||||
})
|
||||
// The historical competing loop reused a node and then hid it while
|
||||
// allocating another. A single scheduler must reuse the visible node.
|
||||
expect(reused.poolReused).toBe(true)
|
||||
expect(reused.visibleSameNode).toBe(true)
|
||||
expect(reused.extraNodes).toEqual([])
|
||||
expect(reused.transition).toBe('4s')
|
||||
expect(reused.rate).toBe(2)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load([{ id: 'appended', text: 'APPENDED', mode: 1, time: window.art.currentTime + 0.5 }])
|
||||
})
|
||||
await expect.poll(() => visibleIds(page)).toContain('appended')
|
||||
await page.click('#pause')
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load()
|
||||
})
|
||||
expect(await page.locator('#danmuku-player-0 .art-danmuku > div').count()).toBe(0)
|
||||
await testInfo.attach('danmuku-pool-and-rate', { contentType: 'application/json', body: JSON.stringify({ frozen, reused, loadWithoutArgument: 'replaces from original empty option; explicit array load appends' }) })
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
|
||||
test(`${label}: actual settings, asynchronous send lock, external fullscreen mount and two-player destruction`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'settings', testInfo)
|
||||
const mount = page.locator('#danmuku-mount-0')
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
|
||||
await mount.locator('.apd-config').hover()
|
||||
await mount.locator('.apd-config-mode [data-mode="2"]').click()
|
||||
expect(await page.evaluate(() => window.danmukuPlugins[0].option.modes)).toEqual([0, 1])
|
||||
await mount.locator('.apd-style').hover()
|
||||
await mount.locator('.apd-style-mode [data-mode="1"]').click()
|
||||
expect(await page.evaluate(() => window.danmukuPlugins[0].option.mode)).toBe(1)
|
||||
await mount.locator('.apd-input').fill('FIRST')
|
||||
await mount.locator('.apd-send').click()
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
|
||||
await mount.locator('.apd-input').press('Enter')
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
|
||||
await page.evaluate(() => window.releaseDanmukuSending(true))
|
||||
await expect(mount.locator('.apd-input')).toHaveValue('')
|
||||
await expect(mount.locator('.apd-send')).toHaveClass(/apd-lock/u)
|
||||
await mount.locator('.apd-input').fill('LOCKED')
|
||||
await mount.locator('.apd-input').press('Enter')
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
|
||||
await expect(mount.locator('.apd-send')).not.toHaveClass(/apd-lock/u)
|
||||
await page.evaluate(() => {
|
||||
window.danmukuEmitter = document.querySelector('#danmuku-mount-0 .artplayer-plugin-danmuku')
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
// FULLSCREEN_WEB_IN_BODY can move the player out of its original container.
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$controlsCenter.contains(window.danmukuEmitter))).toBe(true)
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(0)
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = false
|
||||
})
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
|
||||
expect(await page.evaluate(() => document.querySelector('#danmuku-mount-0').contains(window.danmukuEmitter))).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
const art = window.createDanmukuPlayer(1)
|
||||
const button = document.createElement('button')
|
||||
button.id = 'play-second-danmuku'
|
||||
button.textContent = 'Play second'
|
||||
button.onclick = () => art.play()
|
||||
document.body.appendChild(button)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlayers[1].isReady)).toBe(true)
|
||||
await page.evaluate(() => window.danmukuPlayers[0].destroy())
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.terminated))).toEqual([1, 0])
|
||||
// The released plugin does not dispose external Setting DOM; retain this observation.
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
|
||||
await page.locator('#danmuku-mount-1 .apd-input').fill('SECOND')
|
||||
await page.locator('#danmuku-mount-1 .apd-send').click()
|
||||
await page.click('#play-second-danmuku')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.events.some(item => item.index === 1 && item.event === 'visible' && item.text === 'SECOND'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.danmukuPlayers[1].isDestroy)).toBe(false)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises'
|
||||
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
|
||||
import { deferred } from './helpers/danmuku.js'
|
||||
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
|
||||
|
||||
const implementation = await danmukuCandidate()
|
||||
const prefix = `Danmuku ${implementation.name}`
|
||||
@@ -11,6 +11,23 @@ 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)
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
// 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 } 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 actualBlob = env.urls.get(worker.url)
|
||||
const workerCode = actualBlob
|
||||
? await actualBlob.text()
|
||||
: fs.readFileSync(new URL('../packages/artplayer-plugin-danmuku/src/worker.js', import.meta.url), 'utf8')
|
||||
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}: 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 while beforeVisible is pending do not schedule an overlapping frame`, async () => {
|
||||
const gate = deferred()
|
||||
const { env, plugin, owner } = await fixture({ beforeVisible: () => gate.promise })
|
||||
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, 0, 'The existing asynchronous frame owns this generation')
|
||||
env.destroy()
|
||||
gate.resolve(true)
|
||||
await env.flush()
|
||||
})
|
||||
|
||||
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, 0)
|
||||
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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Controlled client faults and real worker geometry use the Node runner.
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { WorkerClient } = await loadModules({ WorkerClient: 'packages/artplayer-plugin-danmuku/src/worker-client' })
|
||||
const implementation = await danmukuCandidate()
|
||||
|
||||
// This client double records protocol ownership; it does not simulate geometry.
|
||||
function clientFixture(onFailure = () => {}) {
|
||||
const messages = []
|
||||
const failures = []
|
||||
const worker = {
|
||||
terminateCalls: 0,
|
||||
postMessage(message) { messages.push({ ...message }) },
|
||||
terminate() { this.terminateCalls++ },
|
||||
}
|
||||
const client = new WorkerClient(() => worker, (error) => {
|
||||
failures.push(error)
|
||||
onFailure(error)
|
||||
})
|
||||
return { client, worker, messages, failures }
|
||||
}
|
||||
|
||||
function detached(worker) {
|
||||
assert.equal(worker.onmessage, null)
|
||||
assert.equal(worker.onerror, null)
|
||||
assert.equal(worker.onmessageerror, null)
|
||||
}
|
||||
|
||||
test('danmuku worker client: simultaneous requests have distinct IDs and reversed replies retain their owners', async () => {
|
||||
const { client, worker, messages } = clientFixture()
|
||||
const inputs = [{ type: 'getDanmuTop', tag: 'first' }, { type: 'getDanmuTop', tag: 'second' }]
|
||||
const first = client.request(inputs[0])
|
||||
const second = client.request(inputs[1])
|
||||
assert.notEqual(inputs[0].id, inputs[1].id)
|
||||
assert.deepEqual(messages.map(item => item.id), inputs.map(item => item.id))
|
||||
assert.equal(client.pending.size, 2)
|
||||
worker.onmessage({ data: { id: inputs[1].id, result: 33 } })
|
||||
assert.deepEqual(await second, { id: inputs[1].id, result: 33 })
|
||||
assert.equal(client.pending.size, 1)
|
||||
worker.onmessage({ data: { id: inputs[0].id, result: 10 } })
|
||||
assert.deepEqual(await first, { id: inputs[0].id, result: 10 })
|
||||
assert.equal(client.pending.size, 0)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
test('danmuku worker client: a synchronous reply is observed before postMessage returns', async () => {
|
||||
const { client, worker } = clientFixture()
|
||||
const callback = worker.onmessage
|
||||
worker.postMessage = message => callback({ data: { id: message.id, result: 72 } })
|
||||
const message = { type: 'getDanmuTop' }
|
||||
assert.deepEqual(await client.request(message), { id: message.id, result: 72 })
|
||||
assert.equal(worker.onmessage, callback, 'Requests must not replace the shared dispatcher')
|
||||
assert.equal(client.pending.size, 0)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
test('danmuku worker client: unrelated and repeated messages cannot settle another request', async () => {
|
||||
const { client, worker } = clientFixture()
|
||||
const a = { type: 'getDanmuTop' }
|
||||
const b = { type: 'getDanmuTop' }
|
||||
const first = client.request(a)
|
||||
const second = client.request(b)
|
||||
worker.onmessage({ data: { id: 'unrelated', result: 100 } })
|
||||
assert.equal(client.pending.size, 2)
|
||||
worker.onmessage({ data: { id: a.id, result: 10 } })
|
||||
worker.onmessage({ data: { id: a.id, result: 999 } })
|
||||
assert.deepEqual(await first, { id: a.id, result: 10 })
|
||||
assert.equal(client.pending.size, 1)
|
||||
worker.onmessage({ data: { id: b.id, result: 33 } })
|
||||
assert.deepEqual(await second, { id: b.id, result: 33 })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
test('danmuku worker client: cancellation settles every owner, ignores late replies and permits new work', async () => {
|
||||
const { client, worker, failures } = clientFixture()
|
||||
const messages = [{ type: 'getDanmuTop' }, { type: 'getDanmuTop' }]
|
||||
const pending = messages.map(message => client.request(message))
|
||||
client.cancel()
|
||||
assert.deepEqual(await Promise.all(pending), messages.map(message => ({ id: message.id, result: undefined })))
|
||||
assert.equal(client.pending.size, 0)
|
||||
assert.equal(worker.terminateCalls, 0)
|
||||
const next = { type: 'getDanmuTop' }
|
||||
const nextPending = client.request(next)
|
||||
for (const message of messages)
|
||||
worker.onmessage({ data: { id: message.id, result: 10 } })
|
||||
assert.equal(client.pending.size, 1)
|
||||
worker.onmessage({ data: { id: next.id, result: 56 } })
|
||||
assert.deepEqual(await nextPending, { id: next.id, result: 56 })
|
||||
assert.deepEqual(failures, [])
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
for (const eventName of ['error', 'messageerror']) {
|
||||
test(`danmuku worker client: ${eventName} rejects all pending requests with the original failure exactly once`, async () => {
|
||||
const { client, worker, messages, failures } = clientFixture()
|
||||
const error = new Error(`${eventName}-original`)
|
||||
const a = client.request({ type: 'getDanmuTop' })
|
||||
const b = client.request({ type: 'getDanmuTop' })
|
||||
const rejected = Promise.all([assert.rejects(a, value => value === error), assert.rejects(b, value => value === error)])
|
||||
let prevented = 0
|
||||
const handler = worker[`on${eventName}`]
|
||||
handler({ error, preventDefault() {
|
||||
prevented++
|
||||
} })
|
||||
handler({ error: new Error('duplicate') })
|
||||
await rejected
|
||||
assert.deepEqual(failures, [error])
|
||||
assert.equal(client.pending.size, 0)
|
||||
assert.equal(client.failed, true)
|
||||
if (eventName === 'error')
|
||||
assert.equal(prevented, 1)
|
||||
const ignored = { type: 'getDanmuTop' }
|
||||
assert.deepEqual(await client.request(ignored), { id: ignored.id, result: undefined })
|
||||
assert.equal(messages.length, 2)
|
||||
client.dispose()
|
||||
detached(worker)
|
||||
assert.equal(worker.terminateCalls, 1)
|
||||
})
|
||||
}
|
||||
|
||||
for (const error of [new Error('post-original'), undefined, null, false]) {
|
||||
test(`danmuku worker client: synchronous postMessage throw ${String(error)} retains the exact rejection value`, async () => {
|
||||
const { client, worker, failures } = clientFixture()
|
||||
const first = client.request({ type: 'getDanmuTop' })
|
||||
const rejected = assert.rejects(first, value => value === error)
|
||||
worker.postMessage = () => {
|
||||
throw error
|
||||
}
|
||||
await assert.rejects(client.request({ type: 'getDanmuTop' }), value => value === error)
|
||||
await rejected
|
||||
assert.deepEqual(failures, [error])
|
||||
assert.equal(client.pending.size, 0)
|
||||
client.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
test('danmuku worker client: dispose cancels pending work, detaches handlers and terminates exactly once', async () => {
|
||||
const { client, worker, messages, failures } = clientFixture()
|
||||
const message = { type: 'getDanmuTop' }
|
||||
const pending = client.request(message)
|
||||
const oldHandler = worker.onmessage
|
||||
client.dispose()
|
||||
client.dispose()
|
||||
assert.deepEqual(await pending, { id: message.id, result: undefined })
|
||||
detached(worker)
|
||||
assert.equal(worker.terminateCalls, 1)
|
||||
oldHandler({ data: { id: message.id, result: 100 } })
|
||||
const after = { type: 'getDanmuTop' }
|
||||
assert.deepEqual(await client.request(after), { id: after.id, result: undefined })
|
||||
assert.equal(messages.length, 1)
|
||||
assert.deepEqual(failures, [])
|
||||
})
|
||||
|
||||
test('danmuku worker client: termination failure still settles pending requests and detaches owned handlers', async () => {
|
||||
const { client, worker } = clientFixture()
|
||||
const error = new Error('terminate-original')
|
||||
worker.terminate = () => {
|
||||
worker.terminateCalls++
|
||||
throw error
|
||||
}
|
||||
const input = { type: 'getDanmuTop' }
|
||||
const pending = client.request(input)
|
||||
assert.throws(() => client.dispose(), value => value === error)
|
||||
assert.deepEqual(await pending, { id: input.id, result: undefined })
|
||||
detached(worker)
|
||||
client.dispose()
|
||||
assert.equal(worker.terminateCalls, 1)
|
||||
})
|
||||
|
||||
test('danmuku worker client: fault cleanup cannot replace the original request error', async () => {
|
||||
const { client, worker, failures } = clientFixture()
|
||||
const original = new Error('worker-original')
|
||||
const cleanup = new Error('terminate-secondary')
|
||||
worker.terminate = () => {
|
||||
worker.terminateCalls++
|
||||
throw cleanup
|
||||
}
|
||||
const pending = client.request({ type: 'getDanmuTop' })
|
||||
const rejected = assert.rejects(pending, error => error === original)
|
||||
worker.onerror({ error: original, preventDefault() {} })
|
||||
await rejected
|
||||
assert.deepEqual(failures, [original])
|
||||
detached(worker)
|
||||
assert.equal(worker.terminateCalls, 1)
|
||||
client.dispose()
|
||||
assert.equal(worker.terminateCalls, 1)
|
||||
})
|
||||
|
||||
test('danmuku worker client: initial handler setup failure terminates the worker and preserves the setup error', () => {
|
||||
const original = new Error('handler-setup-original')
|
||||
let terminated = 0
|
||||
const worker = {
|
||||
get onmessage() { return undefined },
|
||||
set onmessage(_value) { throw original },
|
||||
terminate() {
|
||||
terminated++
|
||||
throw new Error('terminate-secondary')
|
||||
},
|
||||
}
|
||||
assert.throws(() => new WorkerClient(() => worker, () => assert.fail('No initialized client may report a runtime fault')), error => error === original)
|
||||
assert.equal(terminated, 1)
|
||||
})
|
||||
|
||||
test('danmuku worker client: independent clients never share pending ownership', async () => {
|
||||
const first = clientFixture()
|
||||
const second = clientFixture()
|
||||
const a = { type: 'getDanmuTop' }
|
||||
const b = { type: 'getDanmuTop' }
|
||||
const pa = first.client.request(a)
|
||||
const pb = second.client.request(b)
|
||||
assert.notEqual(a.id, b.id)
|
||||
first.client.dispose()
|
||||
assert.deepEqual(await pa, { id: a.id, result: undefined })
|
||||
assert.equal(second.worker.terminateCalls, 0)
|
||||
assert.equal(second.client.pending.size, 1)
|
||||
second.worker.onmessage({ data: { id: b.id, result: 70 } })
|
||||
assert.deepEqual(await pb, { id: b.id, result: 70 })
|
||||
second.client.dispose()
|
||||
})
|
||||
|
||||
// Unlike the client double above, this executes the actual unmodified worker
|
||||
// script. Numeric geometry inputs are controlled; no browser layout is claimed.
|
||||
const workerCode = fs.readFileSync(new URL('../packages/artplayer-plugin-danmuku/src/worker.js', import.meta.url), 'utf8')
|
||||
|
||||
function calculateGeometry(input) {
|
||||
const replies = []
|
||||
const context = vm.createContext({ input, postMessage: data => replies.push(data) })
|
||||
vm.runInContext(workerCode, context, { timeout: 1000 })
|
||||
vm.runInContext('onmessage({ data: input })', context, { timeout: 1000 })
|
||||
assert.equal(replies.length, 1)
|
||||
assert.equal(replies[0].id, input.id)
|
||||
return replies[0].result
|
||||
}
|
||||
|
||||
test('danmuku actual worker: fixed tracks preserve mode isolation, margins, gap direction and full-track deferral', () => {
|
||||
const base = { id: 1, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: true }
|
||||
const target = mode => ({ mode, height: 20, speed: 20 })
|
||||
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [] }), 10)
|
||||
assert.equal(calculateGeometry({ ...base, target: target(2), visibles: [] }), 70)
|
||||
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 1, top: 10, height: 20 }] }), 30)
|
||||
assert.equal(calculateGeometry({ ...base, target: target(2), visibles: [{ mode: 2, top: 70, height: 20 }] }), 50)
|
||||
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 0, top: 10, height: 80 }] }), 10)
|
||||
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 1, top: 95, height: 20 }] }), 10)
|
||||
for (const mode of [1, 2]) {
|
||||
const visibles = Object.freeze([Object.freeze({ mode, top: 10, height: 40 }), Object.freeze({ mode, top: 50, height: 40 })])
|
||||
assert.equal(calculateGeometry({ ...base, target: target(mode), visibles }), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('danmuku actual worker: rolling collision decisions retain distance, remaining-time and relative-speed semantics', () => {
|
||||
const base = { id: 2, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: true }
|
||||
const visible = { mode: 0, top: 10, height: 80, left: 40, width: 40, distance: 80, right: 20, speed: 10, time: 8 }
|
||||
const calculate = (speed, changed = {}) => calculateGeometry({ ...base, target: { mode: 0, height: 20, speed }, visibles: [{ ...visible, ...changed }] })
|
||||
assert.equal(calculate(5), 10, 'A slower follower cannot catch the existing row')
|
||||
assert.equal(calculate(10), 10, 'Equal speed with a positive gap remains safe')
|
||||
assert.equal(calculate(11), 10, 'Catch-up happens after the existing remaining lifetime')
|
||||
assert.equal(calculate(20), undefined, 'Catch-up before expiration defers the follower')
|
||||
assert.equal(calculate(5, { distance: 110, right: -10 }), undefined, 'An existing row not fully inside blocks reuse')
|
||||
})
|
||||
|
||||
test('danmuku actual worker: disabled antiOverlap retains the historical occupancy ranking', () => {
|
||||
const base = { id: 3, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: false }
|
||||
for (const mode of [0, 1, 2]) {
|
||||
const visibles = [{ mode, top: 10, height: 40, width: 80, right: 10 }, { mode, top: 50, height: 40, width: 20, right: 30 }]
|
||||
assert.equal(calculateGeometry({ ...base, target: { mode, height: 20, speed: 20 }, visibles }), 50)
|
||||
}
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: owner requests made with the same clock tick remain distinct`, async (t) => {
|
||||
const env = danmukuCandidateEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
t.after(() => env.destroy())
|
||||
const owner = plugin.show()
|
||||
const first = owner.postMessage({ type: 'getDanmuTop' })
|
||||
const second = owner.postMessage({ type: 'getDanmuTop' })
|
||||
const worker = env.workers[0]
|
||||
assert.equal(worker.messages.length, 2)
|
||||
const [a, b] = worker.messages.map(item => item.message.id)
|
||||
assert.notEqual(a, b, 'The controlled Date.now value is unchanged between calls')
|
||||
worker.deliver({ id: b, result: 33 })
|
||||
assert.equal((await second).result, 33)
|
||||
worker.deliver({ id: a, result: 10 })
|
||||
assert.equal((await first).result, 10)
|
||||
})
|
||||
|
||||
for (const recovery of ['start', 'reset']) {
|
||||
test(`danmuku ${implementation.name}: a Worker fault stops the run once and ${recovery} rebuilds the client`, async (t) => {
|
||||
const env = danmukuCandidateEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
t.after(() => env.destroy())
|
||||
const owner = plugin.show()
|
||||
env.art.playing = true
|
||||
env.art.emit('video:play')
|
||||
assert.equal(env.frames.size, 1)
|
||||
const worker = env.workers[0]
|
||||
const error = new Error(`${recovery}-worker-original`)
|
||||
const first = owner.postMessage({ type: 'getDanmuTop' })
|
||||
const second = owner.postMessage({ type: 'getDanmuTop' })
|
||||
const rejected = Promise.all([assert.rejects(first, value => value === error), assert.rejects(second, value => value === error)])
|
||||
const fail = worker.onerror
|
||||
fail({ error, preventDefault() {} })
|
||||
fail({ error: new Error('duplicate'), preventDefault() {} })
|
||||
await rejected
|
||||
assert.deepEqual(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').map(event => event.args), [[error]])
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(worker.terminated, true)
|
||||
detached(worker)
|
||||
if (recovery === 'start')
|
||||
env.art.emit('video:play')
|
||||
else plugin.reset()
|
||||
assert.equal(env.workers.length, 2)
|
||||
assert.equal(env.frames.size, 1)
|
||||
const fresh = env.workers[1]
|
||||
assert.notEqual(fresh, worker)
|
||||
assert.equal(fresh.terminated, false)
|
||||
const pending = owner.postMessage({ type: 'getDanmuTop' })
|
||||
const { id } = fresh.messages[0].message
|
||||
fresh.deliver({ id, result: 10 })
|
||||
assert.equal((await pending).result, 10)
|
||||
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 1)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user