refactor(danmuku): [PKG-DANMUKU-05] own renderer settings and heatmap resources

This commit is contained in:
Harvey Zhao committed 2026-09-13 20:45:16 +08:00
1 parent 12332df221
commit 053d211fb0
38 files changed
+65630 -2420

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
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, scenario, testInfo) {
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
return new URL(route.request().url()).origin === origin ? route.continue() : route.abort()
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: candidate })
await page.evaluate(({ scenario }) => {
window.resourcePlayers = []
window.resourcePlugins = []
window.resourceOwners = []
window.resourceMessages = []
window.resourceSends = []
window.resourceErrors = []
window.resourceWorkers = []
window.resourceTimers = new Set()
const nativeSetTimeout = window.setTimeout.bind(window)
const nativeClearTimeout = window.clearTimeout.bind(window)
window.setTimeout = (callback, delay, ...args) => {
const id = nativeSetTimeout(() => {
window.resourceTimers.delete(id)
callback(...args)
}, delay)
if (window.captureNextResourceTimer && delay === 1000) {
window.resourceTimers.add(id)
window.captureNextResourceTimer = false
}
return id
}
window.clearTimeout = (id) => {
window.resourceTimers.delete(id)
nativeClearTimeout(id)
}
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { terminated: 0 }
window.resourceWorkers.push(this.record)
}
terminate() {
this.record.terminated++
super.terminate()
}
}
const originalError = console.error
console.error = (...args) => {
window.resourceErrors.push(args.map(arg => arg?.message || String(arg)))
originalError(...args)
}
window.sendGate = new Promise((resolve, reject) => {
window.resolveSend = resolve
window.rejectSend = reject
})
window.createResourcePlayer = (index, extra = {}) => {
const container = index === 0 ? document.querySelector('.player') : document.body.appendChild(document.createElement('div'))
container.style.cssText = 'width:640px;height:360px'
const mount = document.body.appendChild(document.createElement('div'))
mount.id = `resource-mount-${index}`
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, fullscreenWeb: true, setting: true })
window.resourcePlayers[index] = art
art.on('artplayerPluginDanmuku:visible', item => window.resourceMessages.push(item.text))
const option = {
danmuku: [],
mount,
heatmap: scenario === 'heatmap' ? { sampling: 0 } : false,
lockTime: 10,
margin: [10, 10],
fontSize: 20,
speed: 2,
beforeEmit(danmu) {
window.resourceSends.push({ index, text: danmu.text, receiver: this === window.resourcePlugins[index].option })
// Capture the lock scheduled in this send's await continuation, not
// unrelated browser/core one-second timers while typing or clicking.
if (window.observeResourceTimers)
window.captureNextResourceTimer = true
return scenario === 'pending' || scenario === 'reject' ? window.sendGate : true
},
...extra,
}
art.plugins.add(window.artplayerPluginDanmuku(option))
const plugin = art.plugins.artplayerPluginDanmuku
window.resourcePlugins[index] = plugin
window.resourceOwners[index] = plugin.show()
return art
}
window.art = window.createResourcePlayer(0)
document.querySelector('#play').onclick = () => window.art.play()
}, { scenario })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await testInfo.attach('danmuku-resources-inputs', { contentType: 'application/json', body: JSON.stringify({ core, scenario, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', sha256: hash(candidate), native: 'video, RAF, DOM, Worker; observe the first one-second timer after beforeEmit in the synchronous send continuation, no fake timer clock' }) })
}
test.afterEach(async ({ page }, testInfo) => {
await testInfo.attach('danmuku-resources-page', { contentType: 'image/png', body: await page.screenshot() })
const evidence = await page.evaluate(() => {
for (const art of window.resourcePlayers || []) {
if (!art.isDestroy)
art.destroy(false)
}
return { workers: window.resourceWorkers, sends: window.resourceSends, messages: window.resourceMessages, errors: window.resourceErrors, retainedSettings: document.querySelectorAll('[id^="resource-mount-"] .artplayer-plugin-danmuku').length, timers: window.resourceTimers?.size }
})
await testInfo.attach('danmuku-resources-evidence', { contentType: 'application/json', body: JSON.stringify(evidence) })
expect(evidence.retainedSettings).toBe(0)
for (const worker of evidence.workers || []) expect(worker.terminated).toBe(1)
})
async function renderResourceRow(page, text) {
await page.evaluate(async (text) => {
const owner = await window.resourcePlugins[0].emit({ text, time: 0.2, mode: 1 })
// Resource tests start with an eligible row, then require a real Worker and
// rendered DOM. Native timestamp delivery is covered by the scheduler spec.
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
}, text)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.resourceMessages)).toContain(text)
}
for (const core of ['published', 'candidate']) {
test(`${core}: shared setting mount preserves the surviving instance and later user changes`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
const result = await page.evaluate(() => {
const mount = document.querySelector('#resource-mount-0')
window.createResourcePlayer(1, { mount, theme: 'light' })
window.art.destroy(false)
const survivor = { panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length, theme: mount.dataset.danmukuTheme }
mount.dataset.danmukuTheme = 'user-value'
window.resourcePlayers[1].destroy(false)
return { survivor, theme: mount.dataset.danmukuTheme, ownedKeys: Object.keys(mount.dataset).filter(key => key.startsWith('danmuku') && key !== 'danmukuTheme'), panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length }
})
expect(result).toEqual({ survivor: { panels: 1, theme: 'light' }, theme: 'user-value', ownedKeys: [], panels: 0 })
})
test(`${core}: invalid setting mount rolls back plugin resources while preserving the original error`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
const result = await page.evaluate(() => {
const container = document.body.appendChild(document.createElement('div'))
container.style.cssText = 'width:640px;height:360px'
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
window.resourcePlayers.push(art)
art.template.$controlsCenter.style.setProperty('display', 'grid', 'important')
const display = art.template.$controlsCenter.style.display
let error
try {
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], mount: '#missing-setting-mount' }))
}
catch (failure) {
error = failure.message
}
return { error, panels: container.querySelectorAll('.artplayer-plugin-danmuku').length, worker: window.resourceWorkers.at(-1), display: art.template.$controlsCenter.style.display, priority: art.template.$controlsCenter.style.getPropertyPriority('display'), previousDisplay: display }
})
expect(result.error).toContain('Can not find the mount point: #missing-setting-mount')
expect(result.panels).toBe(0)
expect(result.worker.terminated).toBe(1)
expect(result.display).toBe(result.previousDisplay)
expect(result.priority).toBe('important')
})
test(`${core}: destroy cancels pending setting send without clearing retained input or starting a lock`, async ({ page }, testInfo) => {
await setup(page, core, 'pending', testInfo)
const mount = page.locator('#resource-mount-0')
await mount.locator('.apd-input').fill('PENDING')
await mount.locator('.apd-send').click()
expect(await page.evaluate(() => window.resourceSends)).toEqual([{ index: 0, text: 'PENDING', receiver: true }])
const result = await page.evaluate(async () => {
const input = document.querySelector('#resource-mount-0 .apd-input')
const send = document.querySelector('#resource-mount-0 .apd-send')
window.art.destroy(false)
window.resolveSend(true)
await new Promise(resolve => setTimeout(resolve, 30))
return { value: input.value, locked: send.classList.contains('apd-lock'), queue: window.resourceOwners[0].queue.map(item => item.text), connected: input.isConnected }
})
expect(result).toEqual({ value: 'PENDING', locked: false, queue: [], connected: false })
expect(await page.evaluate(() => window.resourceErrors)).toEqual([])
})
test(`${core}: destroy clears active send timer and leaves another mounted instance usable`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
await page.evaluate(() => window.observeResourceTimers = true)
await page.locator('#resource-mount-0 .apd-input').fill('LOCK')
await page.locator('#resource-mount-0 .apd-send').click()
await expect(page.locator('#resource-mount-0 .apd-send')).toHaveClass(/apd-lock/u)
expect(await page.evaluate(() => window.resourceTimers.size)).toBe(1)
await page.evaluate(() => {
window.observeResourceTimers = false
window.createResourcePlayer(1)
window.art.destroy(false)
})
expect(await page.evaluate(() => window.resourceTimers.size)).toBe(0)
await expect(page.locator('#resource-mount-0 .artplayer-plugin-danmuku')).toHaveCount(0)
await page.locator('#resource-mount-1 .apd-input').fill('SECOND')
await page.locator('#resource-mount-1 .apd-send').click()
await expect.poll(() => page.evaluate(() => window.resourceOwners[1].queue.map(row => row.text))).toEqual(['SECOND'])
expect(await page.locator('style#artplayer-plugin-danmuku').count()).toBe(1)
})
test(`${core}: rejected setting callback retains input and original console error without an unhandled rejection`, async ({ page }, testInfo) => {
await setup(page, core, 'reject', testInfo)
await page.locator('#resource-mount-0 .apd-input').fill('REJECT')
await page.locator('#resource-mount-0 .apd-send').click()
await page.evaluate(() => window.rejectSend(new Error('controlled setting rejection')))
await expect.poll(() => page.evaluate(() => window.resourceErrors)).toEqual([['Error emitting danmuku:', 'controlled setting rejection']])
await expect(page.locator('#resource-mount-0 .apd-input')).toHaveValue('REJECT')
await expect(page.locator('#resource-mount-0 .apd-send')).not.toHaveClass(/apd-lock/u)
expect(await page.evaluate(() => window.resourceOwners[0].queue.length)).toBe(0)
})
test(`${core}: destroy false releases actual renderer nodes while retaining the player shell`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
await renderResourceRow(page, 'VISIBLE')
const result = await page.evaluate(() => {
const layer = window.art.template.$danmuku
const node = [...layer.children].find(item => item.textContent === 'VISIBLE')
const owner = window.resourceOwners[0]
window.art.destroy(false)
return { connected: node.isConnected, nodes: layer.children.length, refs: owner.$refs.length, dangling: owner.queue.filter(item => item.$ref).length, shell: !!window.art.template.$player }
})
expect(result).toEqual({ connected: false, nodes: 0, refs: 0, dangling: 0, shell: true })
})
test(`${core}: replacement load removes a moved renderer node and preserves unrelated body content`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
await renderResourceRow(page, 'MOVED')
const result = await page.evaluate(async () => {
window.art.pause()
const layer = window.art.template.$danmuku
const node = [...layer.children].find(item => item.textContent === 'MOVED')
const foreign = document.createElement('aside')
document.body.append(node, foreign)
await window.resourcePlugins[0].load()
const replacement = { connected: node.isConnected, nodes: layer.childElementCount, foreign: foreign.isConnected, refs: window.resourceOwners[0].$refs.length }
window.art.destroy(false)
return { replacement, foreignAfterDestroy: foreign.isConnected }
})
expect(result).toEqual({ replacement: { connected: false, nodes: 0, foreign: true, refs: 0 }, foreignAfterDestroy: true })
})
test(`${core}: narrow heatmap invalid sampling stays responsive and two instances have independent gradients`, async ({ page }, testInfo) => {
await setup(page, core, 'heatmap', testInfo)
// A second evaluation has fresh module counters but shares this document.
await page.addScriptTag({ content: candidate })
await page.evaluate(() => {
window.createResourcePlayer(1)
const first = window.art.controls.heatmap
first.style.width = '99px'
window.art.emit('resize')
})
await expect.poll(() => page.evaluate(() => window.resourcePlayers[1].isReady)).toBe(true)
await expect.poll(() => page.locator('.art-control-heatmap svg').count()).toBe(2)
const result = await page.evaluate(() => {
const first = window.resourcePlayers[0].controls.heatmap
const second = window.resourcePlayers[1].controls.heatmap
const gradient = node => node.querySelector('linearGradient').id
window.resourcePlayers[0].emit('artplayerPluginDanmuku:points', [[0, 0], [49, 40], [99, 10]])
window.resourcePlayers[1].emit('artplayerPluginDanmuku:points', [[0, 20], [300, 0], [640, 35]])
window.resourcePlayers[0].emit('setBar', 'played', 0.25)
window.resourcePlayers[1].emit('setBar', 'played', 0.75)
return {
ids: [gradient(first), gradient(second)],
fills: [first.querySelector('path').getAttribute('fill'), second.querySelector('path').getAttribute('fill')],
offsets: [first.querySelector('#heatmap-start').getAttribute('offset'), second.querySelector('#heatmap-start').getAttribute('offset')],
validPaths: [first, second].every(node => !/NaN|Infinity/.test(node.querySelector('path').getAttribute('d'))),
}
})
expect(new Set(result.ids).size).toBe(2)
expect(result.ids[0]).toBe('heatmap-solids')
expect(result.fills).toEqual(result.ids.map(id => `url(#${id})`))
expect(result.offsets).toEqual(['25%', '75%'])
expect(result.validPaths).toBe(true)
await testInfo.attach('danmuku-resources-heatmap', { contentType: 'application/json', body: JSON.stringify(result) })
await testInfo.attach('danmuku-heatmap-live-page', { contentType: 'image/png', body: await page.screenshot({ fullPage: true }) })
await page.evaluate(() => window.art.destroy(false))
await expect(page.locator('.art-control-heatmap')).toHaveCount(1)
await page.evaluate(() => window.resourcePlayers[1].emit('resize'))
await expect(page.locator('.art-control-heatmap svg')).toHaveCount(1)
})
}
+33 -6
View File
@@ -133,6 +133,15 @@ async function geometry(page) {
const visibleIds = page => page.evaluate(() => [...new Set(window.danmukuEvidence.events.filter(item => item.event === 'visible' && item.id).map(item => item.id))])
async function queueReady(page, item) {
await page.evaluate(async (item) => {
const owner = await window.danmukuPlugins[0].emit(item)
// These cases verify cancellation/recovery after eligibility. Seed that
// precondition; the separate timing/geometry cases still use the media clock.
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
}, item)
}
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() })
@@ -158,6 +167,7 @@ for (const core of ['published', 'candidate']) {
test(`${label}: accepted NaN timestamp keeps the historical current-time fallback`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(() => window.art.playbackRate = 0.25)
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
@@ -176,7 +186,7 @@ for (const core of ['published', 'candidate']) {
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 queueReady(page, { 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) => {
@@ -216,7 +226,10 @@ for (const core of ['published', 'candidate']) {
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.evaluate(async () => {
window.art.playbackRate = 0.25
await 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') {
@@ -237,7 +250,7 @@ for (const core of ['published', 'candidate']) {
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 }))
await queueReady(page, { id: 'fresh-after-cancel', text: 'FRESH', mode: 1 })
if (action === 'pause')
await page.click('#play')
// The old user Promise is still unresolved. A cancelled frame must
@@ -325,7 +338,21 @@ for (const core of ['published', 'candidate']) {
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 })
const owner = await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys')
window.danmukuEvidence.eligibility = []
Object.defineProperty(owner, 'readys', {
configurable: true,
get() {
const before = owner.art.currentTime
const rows = descriptor.get.call(owner)
const samples = window.danmukuEvidence.eligibility
if (samples.length < 2000) {
samples.push({ wall: performance.now(), before, after: owner.art.currentTime, playing: owner.art.playing, paused: owner.art.template.$video.paused, seeking: owner.art.template.$video.seeking, rate: owner.art.playbackRate, stop: owner.isStop, generation: owner.scheduler.generation, fault: owner.scheduler.fault, ready: rows.map(row => row.id), queue: owner.queue.map(row => ({ id: row.id, time: row.time, state: row.$state })) })
}
return rows
},
})
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('moving')
@@ -431,8 +458,8 @@ for (const core of ['published', 'candidate']) {
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)
// The candidate owns external Setting DOM and removes it on destruction.
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(0)
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')
+352
View File
@@ -0,0 +1,352 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Heatmap compatibility and isolated historical hangs use Node.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { danmukuEnvironment, danmukuHistorical } from './helpers/danmuku.js'
import { loadModules } from './helpers/load.js'
const { heatmap, heatmapGeometry, sampleHeatmap } = await loadModules({
heatmap: 'packages/artplayer-plugin-danmuku/src/heatmap',
heatmapGeometry: { file: 'packages/artplayer-plugin-danmuku/src/heatmap-geometry', name: 'heatmapGeometry' },
sampleHeatmap: { file: 'packages/artplayer-plugin-danmuku/src/heatmap-sampling', name: 'sampleHeatmap' },
})
const historical = await danmukuHistorical()
function fixture({ width = 400, height = 100, duration = 60, queue = [] } = {}) {
const listeners = new Map()
const removed = []
const element = { offsetWidth: width, offsetHeight: height, writes: 0, nodes: new Map(), removed: 0 }
let html = ''
Object.defineProperty(element, 'innerHTML', {
get: () => html,
set(value) {
html = value
element.writes++
element.nodes = new Map(['#heatmap-start', '#heatmap-stop'].map(key => [key, {
attributes: {},
writes: 0,
setAttribute(name, value) {
this.attributes[name] = value
this.writes++
},
}]))
},
})
const art = {
duration,
played: 0.25,
option: { isLive: false },
constructor: { utils: { query: (selector, root) => root.nodes.get(selector) } },
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(item => item !== callback)) },
emit(name, ...args) {
for (const callback of [...listeners.get(name) || []])
callback(...args)
},
controls: {
add(option) {
this.option = option
this.heatmap = element
option.mounted(element)
return undefined
},
remove(name) {
assert.equal(name, 'heatmap')
this.option.beforeUnmount?.(this.heatmap)
this.heatmap.removed++
delete this.heatmap
removed.push(name)
},
},
}
const danmuku = { queue }
return {
art,
danmuku,
element,
listeners,
removed,
install: option => heatmap(art, danmuku, option),
destroy() {
art.isDestroy = true
art.emit('destroy')
},
}
}
function pathOf(element) {
return element.innerHTML.match(/<path[^>]+d="([^"]*)"/u)?.[1]
}
function listenerCount(env) {
return [...env.listeners.values()].reduce((sum, list) => sum + list.length, 0)
}
test('heatmap: sampled SVG path matches frozen source and actual 5.3.0 main/legacy for legal options', async () => {
const queue = [0, 0.6, 1, 2, 2, 2.1, 8, 15, 28, 59.9, 60, Number.NaN, Infinity, -Infinity].map(time => ({ time }))
for (const option of [true, { sampling: 2.5, smoothing: 0.3, flattening: 0.1, scale: 0.4, opacity: 0.7, minHeight: 2, xMin: -10, xMax: 450, yMin: -1, yMax: 40 }]) {
const candidate = fixture({ queue })
candidate.install(option)
candidate.art.emit('ready')
assert(pathOf(candidate.element))
for (const implementation of historical) {
const env = danmukuEnvironment(implementation, { heatmapWidth: 400 })
const plugin = env.factory({ danmuku: [], heatmap: option })(env.art)
await env.flush()
plugin.show().queue = queue
env.art.emit('ready')
assert.equal(pathOf(candidate.element), pathOf(env.controls[0].element), `${implementation.name} / ${JSON.stringify(option)}`)
env.destroy()
}
candidate.destroy()
}
})
test('heatmap: custom points retain nested mutation, repeated transformation and exact historical SVG curves', async () => {
const option = { sampling: 4, scale: 0.25, minHeight: 5, smoothing: 0.2, flattening: 0.2 }
const candidate = fixture()
candidate.install(option)
const points = [[0, 10], [200, 20], [300, 0]]
const references = [...points]
const snapshots = []
for (let index = 0; index < 2; index++) {
candidate.art.emit('artplayerPluginDanmuku:points', points)
snapshots.push({ points: structuredClone(points), path: pathOf(candidate.element) })
}
assert.deepEqual(snapshots[0].points, [[0, 12.5], [200, 30], [300, 5]])
assert.equal(points.length, 3, 'The closing point is appended only to the copied outer array')
assert.equal(points[1], references[1])
for (const implementation of historical) {
const env = danmukuEnvironment(implementation, { heatmapWidth: 400 })
env.factory({ danmuku: [], heatmap: option, points: [[0, 999], [400, 999]] })(env.art)
await env.flush()
const oldPoints = [[0, 10], [200, 20], [300, 0]]
for (const expected of snapshots) {
env.art.emit('artplayerPluginDanmuku:points', oldPoints)
assert.deepEqual(oldPoints, expected.points, implementation.name)
assert.equal(pathOf(env.controls[0].element), expected.path, implementation.name)
}
env.destroy()
}
candidate.art.emit('resize')
assert.notEqual(pathOf(candidate.element), snapshots[1].path, 'A later automatic update resamples the queue, not the previous custom points')
candidate.destroy()
})
test('heatmap: historical zero/default-narrow/negative sampling hangs are bounded in isolated processes', () => {
for (const implementation of historical) {
for (const scenario of [{ width: 99, option: true }, { width: 400, option: { sampling: 0 } }, { width: 400, option: { sampling: -1 } }]) {
const program = `
import fs from 'node:fs';
import vm from 'node:vm';
import { danmukuEnvironment } from './test/helpers/danmuku.js';
const { implementation, scenario } = JSON.parse(fs.readFileSync(0, 'utf8'));
const env = danmukuEnvironment(implementation, { heatmapWidth: scenario.width });
env.factory({ danmuku: [], heatmap: scenario.option })(env.art);
const sandbox = vm.createContext({ update: () => env.art.emit('ready') });
try { vm.runInContext('update()', sandbox, { timeout: 100 }); process.stdout.write('unexpected-completion'); }
catch (error) { if (error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error; process.stdout.write(error.code); }
`
const child = spawnSync(process.execPath, ['--input-type=module', '-e', program], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
input: JSON.stringify({ implementation, scenario }),
encoding: 'utf8',
timeout: 5000,
maxBuffer: 1024 * 1024,
})
assert.equal(child.error, undefined)
assert.equal(child.status, 0, child.stderr)
assert.equal(child.stdout, 'ERR_SCRIPT_EXECUTION_TIMEOUT')
}
}
})
test('heatmap: narrow widths and invalid sampling produce finite bounded curves without a hanging loop', () => {
for (const width of [1, 50, 99, 100, 640]) {
for (const sampling of [undefined, 0, -1, Number.NaN, Infinity, -Infinity]) {
const result = heatmapGeometry({ width, height: 100, duration: 60, queue: [{ time: 1 }], option: { sampling } })
assert(result, `${width}/${sampling}`)
assert.doesNotMatch(result.path, /NaN|Infinity/u)
assert(result.path.length < 100000)
}
}
})
test('heatmap: invalid dimensions, duration and coordinate ranges leave an empty chart', () => {
const base = { width: 400, height: 100, duration: 60, queue: [] }
for (const key of ['width', 'height', 'duration']) {
for (const value of [0, -1, Number.NaN, Infinity, -Infinity])
assert.equal(heatmapGeometry({ ...base, [key]: value }), null, `${key}/${value}`)
}
for (const option of [{ xMin: 1, xMax: 1 }, { yMin: 1, yMax: 1 }])
assert.equal(heatmapGeometry({ ...base, option }), null)
const env = fixture()
env.install(true)
env.art.emit('ready')
assert(pathOf(env.element))
env.art.duration = Infinity
env.art.emit('resize')
assert.equal(env.element.innerHTML, '')
env.art.duration = 60
env.art.option.isLive = true
env.art.emit('ready')
assert.equal(env.element.innerHTML, '')
env.destroy()
})
test('heatmap sampling: strict-left inclusive-right bins preserve fractional step boundaries and numeric edge values', () => {
const queue = [0, -1, 0.1, 0.2, 0.3, 0.6, 1, 60, Number.NaN, Infinity, -Infinity].map(time => ({ time }))
for (const [width, duration, sampling] of [[10, 1, 0.3], [400, 60, 2.5], [100, 60, 1], [1, 1e308, 1e308]]) {
const expected = []
const gap = duration / width
for (let x = 0; x <= width; x += sampling)
expected.push([x, queue.filter(({ time }) => time > x * gap && time <= (x + sampling) * gap).length])
assert.deepEqual(sampleHeatmap(queue, width, duration, sampling), expected)
}
})
test('heatmap sampling: only unrepresentable array lengths are rejected before scanning the queue', () => {
const queue = {
map() { assert.fail('Oversized sampling must stop before reading the queue') },
}
assert.deepEqual(sampleHeatmap(queue, 400, 60, Number.MIN_VALUE), [])
assert.deepEqual(sampleHeatmap(queue, 0xFFFFFFFF, 60, 1), [])
assert.equal(heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option: { sampling: Number.MIN_VALUE } }), null)
})
test('heatmap sampling: legal small steps retain more than 100000 exact historical bins', () => {
const queue = [0, 0.001, 1, 50, 100, 100.001, 101].map(time => ({ time }))
const expected = []
for (let x = 0; x <= 101; x += 0.001)
expected.push([x, queue.filter(({ time }) => time > x && time <= x + 0.001).length])
assert(expected.length > 100000)
const actual = sampleHeatmap(queue, 101, 101, 0.001)
assert.equal(actual.length, expected.length)
assert.deepEqual(actual, expected)
assert.equal(sampleHeatmap([], 100001, 60, 1).length, 100002)
})
test('heatmap geometry: more than 100000 custom points retain all segments and nested y writes', () => {
const points = Array.from({ length: 100001 }, (_, index) => [index, index % 2 ? 20 : 10])
const first = points[0]
const result = heatmapGeometry({ width: 100001, height: 100, duration: 60, queue: [], points })
assert(result)
assert.equal(points.length, 100001)
assert.equal(points[0], first)
assert.deepEqual(points[0], [0, 12.5])
assert.deepEqual(points[1], [1, 30])
assert.equal(result.path.match(/C /gu).length, 100001, 'Every caller segment and the closing width point remain')
assert.doesNotMatch(result.path, /NaN|Infinity/u)
assert(result.path.endsWith(' z'))
})
test('heatmap: malformed points do not generate NaN SVG or change caller data', () => {
for (const points of [[undefined], [[1]], [[0, Number.NaN]], [[Infinity, 2]], [{ time: 1, value: 2 }]]) {
const original = structuredClone(points)
assert.equal(heatmapGeometry({ width: 400, height: 100, duration: 60, queue: [], points }), null)
assert.deepEqual(points, original)
}
})
test('heatmap: instances own different gradients while preserving local stop hooks and progress updates', () => {
const a = fixture()
const b = fixture()
a.install(true)
b.install({ opacity: 0.7 })
a.art.emit('ready')
b.art.emit('ready')
const gradient = env => env.element.innerHTML.match(/linearGradient id="([^"]+)"/u)[1]
assert.notEqual(gradient(a), gradient(b))
for (const env of [a, b]) {
assert(env.element.innerHTML.includes(`fill="url(#${gradient(env)})"`))
assert.equal(env.element.nodes.get('#heatmap-start').attributes.offset, '25%')
assert.equal(env.art.controls.option.name, 'heatmap')
assert.equal(env.art.controls.option.position, 'top')
}
a.art.played = 0.5
a.art.emit('video:timeupdate')
assert.equal(a.element.nodes.get('#heatmap-start').attributes.offset, '50%')
assert.equal(b.element.nodes.get('#heatmap-start').attributes.offset, '25%')
a.art.emit('setBar', 'loaded', 0.9)
assert.equal(a.element.nodes.get('#heatmap-stop').attributes.offset, '50%')
a.art.emit('setBar', 'played', 0.9)
assert.equal(a.element.nodes.get('#heatmap-stop').attributes.offset, '90%')
a.destroy()
assert.equal(listenerCount(a), 0)
assert.deepEqual(a.removed, ['heatmap'])
b.art.emit('resize')
assert(pathOf(b.element))
assert.equal(b.removed.length, 0)
b.destroy()
})
test('heatmap: control unmount detaches all subscriptions and stale callbacks cannot write', () => {
const env = fixture()
const dispose = env.install(true)
env.art.emit('ready')
const oldCallbacks = [...env.listeners].map(([name, callbacks]) => [name, [...callbacks]])
const oldStops = [...env.element.nodes.values()]
env.art.controls.remove('heatmap')
assert.equal(listenerCount(env), 0)
const writes = env.element.writes
const stopWrites = oldStops.map(node => node.writes)
for (const [name, callbacks] of oldCallbacks) {
for (const callback of callbacks) {
if (name === 'artplayerPluginDanmuku:points')
callback([[0, 10], [400, 20]])
else if (name === 'setBar')
callback('played', 0.9)
else callback()
}
}
dispose()
assert.equal(env.element.writes, writes)
assert.deepEqual(oldStops.map(node => node.writes), stopWrites)
assert.deepEqual(env.removed, ['heatmap'])
})
test('heatmap: destroy preserves a later same-name control and cleanup attempts every owned removal', () => {
const env = fixture()
const dispose = env.install(true)
const replacement = { removed: 0 }
env.art.controls.heatmap = replacement
const originalOff = env.art.off.bind(env.art)
const failure = new Error('off-original')
let attempts = 0
env.art.off = (...args) => {
originalOff(...args)
attempts++
if (attempts === 1)
throw failure
}
assert.throws(dispose, error => error === failure)
assert.equal(attempts, 7)
assert.equal(listenerCount(env), 0)
assert.equal(env.art.controls.heatmap, replacement)
assert.equal(replacement.removed, 0)
dispose()
})
test('heatmap: setup and update failures clean subscriptions/control and preserve their original errors', () => {
const first = fixture()
const setupError = new Error('add-original')
first.art.controls.add = () => {
throw setupError
}
assert.throws(() => first.install(true), error => error === setupError)
assert.equal(listenerCount(first), 0)
const second = fixture()
second.install(true)
const geometryError = new Error('points-original')
const point = [0, 1]
Object.defineProperty(point, '1', {
get: () => 1,
set() { throw geometryError },
})
assert.throws(() => second.art.emit('artplayerPluginDanmuku:points', [point, [400, 2]]), error => error === geometryError)
assert.equal(listenerCount(second), 0)
assert.deepEqual(second.removed, ['heatmap'])
assert.equal(second.art.controls.heatmap, undefined)
})
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Deterministic owned-node tests use the Node runner.
import test from 'node:test'
import { parseHTML } from 'linkedom'
import Renderer from '../packages/artplayer-plugin-danmuku/src/renderer.js'
function fixture(t) {
const { document } = parseHTML('<html><body><div id="layer"><i id="foreign"></i></div></body></html>')
const previous = globalThis.document
globalThis.document = document
t.after(() => {
if (previous === undefined)
delete globalThis.document
else
globalThis.document = previous
})
const owner = {
constructor: { cssText: 'visibility:hidden;position:absolute' },
$danmuku: document.querySelector('#layer'),
$refs: [],
queue: [],
option: { opacity: 0.7 },
fontSize: 20,
utils: { setStyles: (node, styles) => Object.assign(node.style, styles) },
}
const renderer = new Renderer(owner)
Object.defineProperty(owner, '$ref', { get: () => renderer.acquire() })
return { owner, renderer, document }
}
test('Danmuku renderer destroys only owned nodes, including moved and idle nodes, and clears retained references', (t) => {
const { owner, renderer, document } = fixture(t)
const active = renderer.acquire()
const idle = renderer.acquire()
owner.$danmuku.append(active, idle)
owner.queue.push({ $ref: active })
owner.$refs.push(idle)
document.body.append(active)
renderer.destroy()
assert.equal(active.parentElement, null)
assert.equal(idle.parentElement, null)
assert.equal(owner.$danmuku.firstElementChild.id, 'foreign')
assert.equal(owner.queue[0].$ref, null)
assert.equal(owner.$refs.length, 0)
assert.equal(renderer.nodes.size, 0)
renderer.destroy()
assert.equal(owner.$danmuku.childElementCount, 1)
})
test('Danmuku renderer cancellation cannot recycle a different operation node', (t) => {
const { owner, renderer } = fixture(t)
const old = renderer.acquire()
const current = renderer.acquire()
const row = { $ref: current }
const operation = { danmu: row, ref: old }
renderer.release(operation)
assert.equal(row.$ref, current)
assert.equal(operation.ref, null)
assert.deepEqual(owner.$refs, [])
const actual = { danmu: row, ref: current }
renderer.release(actual)
renderer.release(actual)
assert.deepEqual(owner.$refs, [current])
assert.equal(row.$ref, null)
assert.equal(current.style.visibility, 'hidden')
})
test('Danmuku renderer reuse clears custom style and identity before preparing the next row', (t) => {
const { owner, renderer } = fixture(t)
const first = { text: 'first', color: '#fff', border: true, style: { letterSpacing: '3px' } }
const operation = {}
const ref = renderer.prepare(first, operation)
ref.dataset.id = 'old'
ref.className = 'old-class'
assert.equal(ref.style.letterSpacing, '3px')
renderer.release(operation)
const second = { text: 'second', color: '#000', style: {} }
const next = renderer.prepare(second, {})
assert.equal(next, ref)
assert.equal(next.textContent, 'second')
assert.equal(next.dataset.id, '')
assert.equal(next.className, '')
assert.equal(next.style.letterSpacing || '', '')
assert.equal(next.style.visibility, 'hidden')
assert.equal(owner.$danmuku.querySelectorAll('div').length, 1)
})
test('Danmuku renderer replacement clears the historical whole layer and moved owned nodes before starting a new pool', (t) => {
const { owner, renderer, document } = fixture(t)
const old = renderer.acquire()
const moved = renderer.acquire()
const foreign = document.createElement('aside')
owner.$danmuku.append(old)
document.body.append(moved, foreign)
owner.$refs.push(old)
renderer.clear()
assert.equal(owner.$danmuku.childElementCount, 0)
assert.equal(old.parentElement, null)
assert.equal(moved.isConnected, false)
assert.equal(foreign.isConnected, true)
assert.equal(renderer.nodes.size, 0)
assert.equal(owner.$refs.length, 0)
assert.notEqual(renderer.acquire(), old)
renderer.destroy()
assert.equal(foreign.isConnected, true)
})
test('Danmuku renderer left uses the existing auto-orientation axis', (t) => {
const { owner, renderer } = fixture(t)
const node = renderer.acquire()
node.getBoundingClientRect = () => ({ top: 31, left: 72 })
assert.equal(renderer.left(node), 72)
owner.isRotate = true
assert.equal(renderer.left(node), 31)
})
+411
View File
@@ -0,0 +1,411 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Real-template controlled DOM regressions use the Node runner.
import test from 'node:test'
import { danmukuCandidate } from './helpers/danmuku-candidate.js'
import { danmukuSettingEnvironment } from './helpers/danmuku-setting.js'
import { danmukuHistorical, deferred } from './helpers/danmuku.js'
const candidate = await danmukuCandidate()
const historical = await danmukuHistorical()
const emitted = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:emit')
function fixture(implementation = candidate, option = {}, environment = {}) {
const env = danmukuSettingEnvironment(implementation, environment)
const plugin = env.factory({ danmuku: [], mount: '#external', ...option })(env.art)
return { env, plugin, owner: plugin.show(), ui: env.query('.artplayer-plugin-danmuku') }
}
for (const implementation of [...historical, candidate]) {
test(`Danmuku Setting ${implementation.name}: static icons and the actual template retain published structure`, () => {
const { env, plugin, ui } = fixture(implementation)
const reference = danmukuSettingEnvironment(historical[0]).factory.icons
assert.deepEqual(Object.keys(env.factory.icons), Object.keys(reference))
for (const key of Object.keys(reference))
assert.equal(env.factory.icons[key], reference[key])
assert.equal(Object.getOwnPropertyDescriptor(env.factory, 'icons').writable, true)
assert.equal(ui.parentElement.id, 'external')
assert.equal(ui.querySelectorAll('.apd-config-mode .apd-mode').length, 3)
assert.equal(ui.querySelectorAll('.apd-style-mode .apd-mode').length, 3)
assert.equal(ui.querySelectorAll('.apd-color').length, 14)
assert.equal(ui.querySelectorAll('.apd-slider').length, 4)
assert.equal(ui.querySelectorAll('.apd-config-speed .apd-slider-step').length, 3)
assert.equal(ui.querySelector('.apd-input').getAttribute('maxLength'), String(plugin.option.maxLength))
assert.equal(env.query('#unrelated').textContent, 'Keep me')
env.destroy()
})
test(`Danmuku Setting ${implementation.name}: mount/fullscreen and responsive moves preserve one actual UI subtree`, () => {
const { env, plugin, ui } = fixture(implementation)
assert.equal(plugin.mount('#second'), undefined)
assert.equal(ui.parentElement.id, 'second')
assert.equal(env.query('#external').children.length, 1)
for (const type of ['fullscreen', 'fullscreenWeb']) {
env.art.emit(type, true)
assert.equal(ui.parentElement, env.art.template.$controlsCenter)
env.art.emit(type, false)
assert.equal(ui.parentElement.id, 'second')
}
assert.equal(plugin.mount(env.art.template.$controlsCenter), undefined)
env.art.width = 400
env.art.emit('resize')
assert.equal(ui.parentElement, env.art.template.$player)
env.art.width = 640
env.art.emit('resize')
assert.equal(ui.parentElement, env.art.template.$controlsCenter)
assert.equal(env.document.querySelectorAll('.artplayer-plugin-danmuku').length, 1)
env.destroy()
})
test(`Danmuku Setting ${implementation.name}: Slider clicks, pointer dragging and rotation update actual controls`, () => {
const { env, plugin, ui } = fixture(implementation)
const opacity = ui.querySelector('.apd-config-opacity .apd-slider')
opacity.testRect = { left: 20, top: 30, width: 100, height: 200 }
env.dispatch(opacity, 'click', { clientX: 45 })
assert.equal(plugin.option.opacity, 0.25)
assert.equal(ui.querySelector('.apd-config-opacity .apd-value').textContent, '25%')
assert.equal(opacity.querySelector('.apd-slider-dot').style.left, '25%')
env.dispatch(opacity, 'pointerdown', { button: 0 })
env.art.emit('document:pointermove', { clientX: 90 })
assert.equal(plugin.option.opacity, 0.7)
env.art.emit('document:pointerup', { clientX: 120 })
assert.equal(plugin.option.opacity, 1)
env.art.emit('document:pointermove', { clientX: 20 })
assert.equal(plugin.option.opacity, 1)
env.art.isRotate = true
env.dispatch(opacity, 'click', { clientY: 130 })
assert.equal(plugin.option.opacity, 0.5)
const speed = ui.querySelector('.apd-config-speed .apd-slider')
speed.testRect = { width: 100, height: 100 }
env.dispatch(speed, 'click', { clientY: 25 })
assert.equal(plugin.option.speed, 7.5, 'Hidden step labels do not remove numeric slider indices')
assert.equal(ui.querySelector('.apd-config-speed .apd-value').textContent, '\u8F83\u6162')
env.destroy()
})
test(`Danmuku Setting ${implementation.name}: beforeEmit receiver, strict true acceptance and local original rejection stay compatible`, async () => {
const gate = deferred()
let receiver
let inputDanmu
const { env, plugin, owner, ui } = fixture(implementation, {
beforeEmit(danmu) {
receiver = this
inputDanmu = danmu
return gate.promise
},
})
const input = ui.querySelector('.apd-input')
input.value = ' accepted text '
env.dispatch(ui.querySelector('.apd-send'), 'click')
assert.equal(receiver, plugin.option)
assert.equal(inputDanmu.text, 'accepted text')
assert.equal(inputDanmu.time, 10)
gate.resolve(true)
await env.flush()
assert.equal(owner.queue.length, 1)
assert.equal(owner.queue[0].border, true)
assert.equal(owner.queue[0].time, 10.5)
assert.equal(input.value, '')
assert.equal(env.timers.size, 1)
assert.equal(ui.querySelector('.apd-send').classList.contains('apd-lock'), true)
env.destroy()
const error = new Error('beforeEmit original')
const denied = fixture(implementation, { beforeEmit: () => Promise.reject(error) })
denied.ui.querySelector('.apd-input').value = 'retained'
denied.env.dispatch(denied.ui.querySelector('.apd-send'), 'click')
await denied.env.flush()
assert.equal(denied.owner.queue.length, 0)
assert.equal(denied.ui.querySelector('.apd-input').value, 'retained')
assert.equal(denied.env.timers.size, 0)
assert.deepEqual(denied.env.consoleErrors, [['Error emitting danmuku:', error]])
assert.equal(denied.env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 0)
denied.plugin.config({ beforeEmit: () => 1 })
denied.env.dispatch(denied.ui.querySelector('.apd-send'), 'click')
await denied.env.flush()
assert.equal(denied.owner.queue.length, 0)
assert.equal(denied.env.timers.size, 0)
denied.env.destroy()
})
}
test(`Danmuku Setting ${candidate.name}: destroy removes external UI, subscriptions, proxy handlers and pending lock work`, async () => {
const { env, ui } = fixture()
const input = ui.querySelector('.apd-input')
input.value = 'send once'
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
assert.equal(env.timers.size, 1)
assert.ok(env.listenerCount() > 0)
assert.ok(env.proxyCount() > 0)
env.destroy()
assert.equal(ui.parentElement, null)
assert.equal(env.query('#unrelated').textContent, 'Keep me')
assert.equal(env.timers.size, 0)
assert.equal(env.listenerCount(), 0)
assert.equal(env.proxyCount(), 0)
const events = env.events.length
env.dispatch(ui.querySelector('.apd-toggle'), 'click')
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
assert.equal(env.events.length, events)
assert.ok(env.workers.every(worker => worker.terminated))
})
test(`Danmuku Setting ${candidate.name}: destroy cancels pending beforeEmit without sending, clearing input or locking`, async () => {
const gate = deferred()
const { env, owner, ui } = fixture(candidate, { beforeEmit: () => gate.promise })
const input = ui.querySelector('.apd-input')
input.value = 'pending user text'
env.dispatch(ui.querySelector('.apd-send'), 'click')
env.destroy()
const count = emitted(env).length
gate.resolve(true)
await env.flush()
assert.equal(owner.queue.length, 0)
assert.equal(emitted(env).length, count)
assert.equal(input.value, 'pending user text')
assert.equal(ui.querySelector('.apd-send').classList.contains('apd-lock'), false)
assert.equal(env.timers.size, 0)
})
test(`Danmuku Setting ${candidate.name}: invalid initial mount rolls back template, Worker, subscriptions and proxies`, () => {
const env = danmukuSettingEnvironment(candidate)
assert.throws(() => env.factory({ danmuku: [], mount: '#missing' })(env.art), /Can not find the mount point: #missing/u)
assert.equal(env.listenerCount(), 0)
assert.equal(env.proxyCount(), 0)
assert.equal(env.timers.size, 0)
assert.equal(env.document.querySelectorAll('.artplayer-plugin-danmuku').length, 0)
assert.ok(env.workers.every(worker => worker.terminated))
assert.equal(env.query('#unrelated').textContent, 'Keep me')
})
test(`Danmuku Setting ${candidate.name}: late DOMContentLoaded installs only the shared style and releases its listener after instance destruction`, () => {
const { env, ui } = fixture(candidate, {}, { readyState: 'loading' })
assert.equal(env.document.getElementById('artplayer-plugin-danmuku'), null)
env.destroy()
env.dispatch(env.document, 'DOMContentLoaded')
const style = env.document.getElementById('artplayer-plugin-danmuku')
assert.ok(style)
assert.ok(style.textContent.includes('.artplayer-plugin-danmuku'))
assert.equal(ui.parentElement, null)
assert.equal(env.proxyCount(), 0)
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 0)
env.dispatch(env.document, 'DOMContentLoaded')
assert.equal(env.document.querySelectorAll('#artplayer-plugin-danmuku').length, 1)
})
test(`Danmuku Setting ${candidate.name}: repeated bundle evaluation shares pending style installation`, () => {
const env = danmukuSettingEnvironment(candidate, { readyState: 'loading' })
env.reloadPlugin()
env.reloadPlugin()
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 1)
env.dispatch(env.document, 'DOMContentLoaded')
assert.equal(env.document.querySelectorAll('#artplayer-plugin-danmuku').length, 1)
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 0)
env.reloadPlugin()
assert.equal(env.document.querySelectorAll('#artplayer-plugin-danmuku').length, 1)
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 0)
})
test(`Danmuku Setting ${candidate.name}: Enter starts one pending send and preserves a synchronous destroy decision`, async () => {
let calls = 0
const env = danmukuSettingEnvironment(candidate)
const plugin = env.factory({
danmuku: [],
beforeEmit() {
calls++
env.destroy()
return true
},
})(env.art)
const input = env.query('.apd-input')
const send = env.query('.apd-send')
input.value = 'retain on synchronous destroy'
const keypress = env.event('keypress', { key: 'Enter' })
input.dispatchEvent(keypress)
await env.flush()
assert.equal(keypress.defaultPrevented, true)
assert.equal(calls, 1)
assert.equal(plugin.show().queue.length, 0)
assert.equal(input.value, 'retain on synchronous destroy')
assert.equal(send.classList.contains('apd-lock'), false)
assert.equal(env.timers.size, 0)
assert.equal(env.proxyCount(), 0)
})
test(`Danmuku Setting ${candidate.name}: pending sends are deduplicated and a rejection after destroy is observed without late UI work`, async () => {
const gate = deferred()
let calls = 0
const { env, ui } = fixture(candidate, {
beforeEmit() {
calls++
return gate.promise
},
})
const input = ui.querySelector('.apd-input')
input.value = 'pending rejection'
env.dispatch(ui.querySelector('.apd-send'), 'click')
env.dispatch(input, 'keypress', { key: 'Enter' })
assert.equal(calls, 1)
env.destroy()
gate.reject(new Error('cancelled beforeEmit rejection'))
await env.flush()
assert.equal(input.value, 'pending rejection')
assert.equal(env.timers.size, 0)
assert.equal(emitted(env).length, 0)
assert.equal(env.consoleErrors.length, 0)
})
test(`Danmuku Setting ${candidate.name}: rejection from the invoked emit Promise is observed with the original error`, async () => {
const error = new Error('filter failed during accepted send')
const { env, ui } = fixture(candidate, {
filter() {
throw error
},
})
const input = ui.querySelector('.apd-input')
input.value = 'accepted but rejected by filter'
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
assert.deepEqual(env.consoleErrors, [['Error emitting danmuku:', error]])
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 0)
assert.equal(input.value, '', 'Sending still clears and locks immediately after invoking emit')
assert.equal(env.timers.size, 1)
env.destroy()
})
test(`Danmuku Setting ${candidate.name}: a complete lock countdown retains its existing delay and restores the actual send control`, async () => {
const { env, ui } = fixture(candidate, { lockTime: 1 })
ui.querySelector('.apd-input').value = 'countdown'
const send = ui.querySelector('.apd-send')
env.dispatch(send, 'click')
await env.flush()
assert.equal(send.textContent, '1')
assert.equal([...env.timers.values()][0].delay, 1000)
env.timeout()
assert.equal(send.textContent, '0')
assert.equal(send.classList.contains('apd-lock'), true)
env.timeout()
assert.equal(send.textContent, '\u53D1\u9001')
assert.equal(send.classList.contains('apd-lock'), false)
assert.equal(env.timers.size, 0)
env.destroy()
})
for (const first of ['older', 'newer']) {
test(`Danmuku Setting ${candidate.name}: shared mount restores remaining owner and original host data when ${first} owner is destroyed first`, () => {
const env = danmukuSettingEnvironment(candidate)
const external = env.query('#external')
external.dataset.danmukuMode = 'host mode'
env.art.template.$controlsCenter.style.display = 'grid'
const peer = env.createPeer()
peer.art.template.$controlsCenter.style.display = 'block'
env.factory({ danmuku: [], mount: '#external', mode: 0 })(env.art)
env.factory({ danmuku: [], mount: '#external', mode: 2 })(peer.art)
assert.equal(external.dataset.danmukuMode, '2')
assert.equal(external.querySelectorAll('.artplayer-plugin-danmuku').length, 2)
if (first === 'older') {
env.destroy()
assert.equal(external.dataset.danmukuMode, '2')
peer.destroy()
}
else {
peer.destroy()
assert.equal(external.dataset.danmukuMode, '0')
env.destroy()
}
assert.equal(external.dataset.danmukuMode, 'host mode')
assert.equal(external.hasAttribute('data-danmuku-visible'), false)
assert.equal(external.querySelectorAll('.artplayer-plugin-danmuku').length, 0)
assert.equal(env.art.template.$controlsCenter.style.display, 'grid')
assert.equal(peer.art.template.$controlsCenter.style.display, 'block')
assert.equal(env.query('#unrelated').textContent, 'Keep me')
})
}
test(`Danmuku Setting ${candidate.name}: destroy preserves host dataset and display values changed after initialization`, () => {
const env = danmukuSettingEnvironment(candidate)
const external = env.query('#external')
external.dataset.danmukuMode = 'original host value'
external.dataset.getPropertyPriority = 'a host data attribute, not a method'
env.factory({ danmuku: [], mount: '#external' })(env.art)
external.dataset.danmukuMode = 'new host value'
external.dataset.danmukuVisible = 'host visibility'
env.art.template.$controlsCenter.style.display = 'inline-grid'
env.destroy()
assert.equal(external.dataset.danmukuMode, 'new host value')
assert.equal(external.dataset.danmukuVisible, 'host visibility')
assert.equal(env.art.template.$controlsCenter.style.display, 'inline-grid')
assert.equal(external.hasAttribute('data-danmuku-color'), false)
assert.equal(external.dataset.getPropertyPriority, 'a host data attribute, not a method')
})
test(`Danmuku Setting ${candidate.name}: a disposer failure preserves the original initialization error and cleans other resources`, () => {
const env = danmukuSettingEnvironment(candidate)
const proxy = env.art.proxy
const cleanupError = new Error('one proxy disposer failed')
let shouldThrow = true
env.art.proxy = (...args) => {
const dispose = proxy(...args)
return () => {
dispose()
if (shouldThrow) {
shouldThrow = false
throw cleanupError
}
}
}
assert.throws(() => env.factory({ danmuku: [], mount: '#missing' })(env.art), /Can not find the mount point: #missing/u)
assert.equal(env.proxyCount(), 0)
assert.equal(env.listenerCount(), 0)
assert.ok(env.workers.every(worker => worker.terminated))
assert.ok(env.consoleWarnings.some(args => args.includes(cleanupError)))
})
test(`Danmuku Setting ${candidate.name}: synchronous destroy during proxy registration leaves an inert mount facade`, () => {
const env = danmukuSettingEnvironment(candidate)
const proxy = env.art.proxy
let first = true
env.art.proxy = (...args) => {
const dispose = proxy(...args)
if (first) {
first = false
env.destroy()
}
return dispose
}
const plugin = env.factory({ danmuku: [], mount: '#external' })(env.art)
assert.equal(plugin.mount('#second'), undefined)
assert.equal(env.document.querySelectorAll('.artplayer-plugin-danmuku').length, 0)
assert.equal(env.proxyCount(), 0)
assert.equal(env.listenerCount(), 0)
assert.equal(env.timers.size, 0)
assert.ok(env.workers.every(worker => worker.terminated))
})
for (const implementation of historical) {
test(`Danmuku Setting historical defect ${implementation.name}: destroy retains external UI, subscriptions, proxies and lock timer`, async () => {
const { env, ui } = fixture(implementation)
ui.querySelector('.apd-input').value = 'historical lock'
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
env.destroy()
assert.equal(ui.parentElement.id, 'external')
assert.ok(env.listenerCount() > 0)
assert.ok(env.proxyCount() > 0)
assert.equal(env.timers.size, 1)
})
test(`Danmuku Setting historical defect ${implementation.name}: late beforeEmit still clears input and locks after destroy`, async () => {
const gate = deferred()
const { env, ui } = fixture(implementation, { beforeEmit: () => gate.promise })
ui.querySelector('.apd-input').value = 'historical pending'
env.dispatch(ui.querySelector('.apd-send'), 'click')
env.destroy()
gate.resolve(true)
await env.flush()
assert.equal(ui.querySelector('.apd-input').value, '')
assert.equal(env.timers.size, 1)
assert.equal(ui.querySelector('.apd-send').classList.contains('apd-lock'), true)
})
}
+18
View File
@@ -33,6 +33,24 @@ export function danmukuCandidateEnvironment(implementation, settings = {}) {
? { ...implementation, code: `globalThis.Worker = class { constructor() { throw globalThis.__danmukuCandidateWorkerError } };\n${implementation.code}` }
: implementation
const env = danmukuEnvironment(initialWorker, settings)
const createElement = env.context.document.createElement.bind(env.context.document)
const removeChild = function (child) {
const index = this.children.indexOf(child)
if (index < 0)
throw new Error('Cannot remove a node from a different parent')
this.children.splice(index, 1)
child.parentElement = null
return child
}
env.context.document.createElement = (...args) => {
const element = createElement(...args)
element.removeChild = removeChild
return element
}
for (const element of Object.values(env.art.template)) {
if (element?.children)
element.removeChild = removeChild
}
if (settings.initialWorkerError)
env.context.__danmukuCandidateWorkerError = settings.initialWorkerError
const consoleWarnings = []
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict'
import vm from 'node:vm'
import { parseHTML } from 'linkedom'
import { danmukuCandidateEnvironment } from './danmuku-candidate.js'
export function danmukuSettingEnvironment(implementation, { readyState = 'complete' } = {}) {
const env = danmukuCandidateEnvironment(implementation)
// Load actual core utilities without initializing the core's browser banner/style.
delete env.context.document
const core = evaluate(implementation.coreCode)
const { document, window } = parseHTML('<!doctype html><html><head></head><body><div id="external"><span id="unrelated">Keep me</span></div><div id="second"></div><div id="player"><div id="controls"></div><div id="danmuku"></div></div></body></html>')
Object.defineProperty(document, 'readyState', { value: readyState, configurable: true })
// Linkedom parses the real templates and dispatches events, but has no layout.
Object.defineProperties(window.HTMLElement.prototype, {
clientWidth: { configurable: true, get() { return this.testRect?.width ?? 640 } },
clientHeight: { configurable: true, get() { return this.testRect?.height ?? 360 } },
offsetWidth: { configurable: true, get() { return this.clientWidth } },
offsetHeight: { configurable: true, get() { return this.clientHeight } },
})
window.HTMLElement.prototype.getBoundingClientRect = function () {
const { left = 0, top = 0, width = 640, height = 360 } = this.testRect || {}
return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height }
}
env.context.document = document
env.context.Element = window.Element
env.context.HTMLElement = window.HTMLElement
env.context.window.document = document
env.context.window.getComputedStyle = element => element.style
env.context.getComputedStyle = element => element.style
const documentListeners = []
const add = document.addEventListener.bind(document)
const remove = document.removeEventListener.bind(document)
document.addEventListener = (type, callback, options) => {
const record = { type, callback, active: true, options }
documentListeners.push(record)
record.wrapped = (...args) => {
if (options?.once)
record.active = false
return callback(...args)
}
add(type, record.wrapped, options)
}
document.removeEventListener = (type, callback, options) => {
for (const record of documentListeners) {
if (record.type === type && record.callback === callback && record.active) {
record.active = false
remove(type, record.wrapped, options)
}
}
}
function evaluate(code) {
env.context.__settingModule = { exports: {} }
vm.runInContext(`(function(module, exports) {\n${code}\n})(__settingModule, __settingModule.exports)`, env.context, { timeout: 1000 })
return env.context.__settingModule.exports
}
env.art.constructor.utils = core.utils
env.art.template = {
$player: document.querySelector('#player'),
$controlsCenter: document.querySelector('#controls'),
$danmuku: document.querySelector('#danmuku'),
}
const exported = evaluate(implementation.code)
env.factory = typeof exported === 'function' ? exported : exported.default
env.proxies.length = 0
env.art.proxy = (target, type, callback, options) => {
assert.ok(target?.addEventListener, `A real template target is required for ${type}`)
const record = { target, type, callback, options, active: true }
env.proxies.push(record)
target.addEventListener(type, callback, options)
record.dispose = () => {
record.active = false
target.removeEventListener(type, callback, options)
}
return record.dispose
}
env.art.events = { remove: dispose => dispose() }
const event = (type, values = {}) => Object.assign(new window.Event(type, { bubbles: true, cancelable: true }), values)
return {
...env,
document,
domWindow: window,
documentListeners,
event,
query: selector => document.querySelector(selector),
reloadPlugin() { return evaluate(implementation.code) },
createPeer() {
const peerListeners = new Map()
const player = document.createElement('div')
const controls = document.createElement('div')
const danmuku = document.createElement('div')
player.append(controls, danmuku)
document.body.appendChild(player)
const art = {
...env.art,
isDestroy: false,
template: { $player: player, $controlsCenter: controls, $danmuku: danmuku },
on(name, callback) { peerListeners.set(name, [...peerListeners.get(name) || [], callback]) },
off(name, callback) { peerListeners.set(name, (peerListeners.get(name) || []).filter(item => item !== callback)) },
emit(name, ...args) {
for (const callback of [...peerListeners.get(name) || []])
callback(...args)
},
}
return {
art,
destroy() {
art.isDestroy = true
art.emit('destroy')
},
}
},
listenerCount: () => [...env.listeners.values()].reduce((count, callbacks) => count + callbacks.length, 0),
proxyCount: () => env.proxies.filter(record => record.active).length,
dispatch(target, type, values) { return target.dispatchEvent(event(type, values)) },
timeout() {
const [id, timer] = env.timers.entries().next().value || []
assert.ok(timer, 'Expected a pending controlled timer')
env.timers.delete(id)
timer.callback()
},
}
}