mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-25 08:01:20 -08:00
refactor(danmuku): [PKG-DANMUKU-05] own renderer settings and heatmap resources
This commit is contained in:
1 parent
12332df221
commit
053d211fb0
38 files changed
+65630
-2420
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user