Files
ArtPlayer/test/danmuku-heatmap.test.js

425 lines
18 KiB
JavaScript

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)
}
function verticalCoordinates(path) {
const values = path.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/giu).map(Number)
return values.filter((_, index) => index % 2 === 1)
}
test('heatmap #958: 16000 uniform, clustered and mixed rows stay in a 25px band without losing peaks', () => {
for (const distribution of ['uniform', 'clustered', 'mixed']) {
const queue = Array.from({ length: 16000 }, (_, index) => ({
time: distribution === 'clustered' ? 30 : distribution === 'mixed' && index < 8000 ? 30 : (index + 0.5) * 60 / 16000,
}))
for (const width of [400, 640, 1920]) {
const shape = heatmapGeometry({ width, height: 100, duration: 60, queue, option: true })
const coordinates = verticalCoordinates(shape.path)
assert(Math.min(...coordinates) >= 75, `${distribution}/${width}: top ${Math.min(...coordinates)}`)
assert(Math.max(...coordinates) <= 100, `${distribution}/${width}: bottom ${Math.max(...coordinates)}`)
if (distribution === 'mixed') {
const endpoints = [...shape.path.matchAll(/C [^Cz]+/gu)].map(segment => verticalCoordinates(segment[0]).at(-1))
assert(new Set(endpoints.map(value => value.toFixed(3))).size > 2, 'Dense peaks must remain distinct from the background and zero bin')
}
}
}
})
test('heatmap #958: automatic scaling bounds Bezier controls around adjacent peaks', () => {
const queue = Array.from({ length: 172 }, (_, index) => ({ time: index < 86 ? 0.9 : 1.5 }))
const shape = heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option: true })
const coordinates = verticalCoordinates(shape.path)
assert(Math.min(...coordinates) >= 75)
assert(Math.max(...coordinates) <= 100)
})
test('heatmap #958: axis overrides read own enumerable getters once and ignore inherited fields', () => {
const queue = Array.from({ length: 16000 }, () => ({ time: 30 }))
const geometry = option => heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option })
let reads = 0
const option = {
get yMax() {
assert.equal(++reads, 1)
return 128
},
}
assert.equal(geometry(option).path, geometry({ yMax: 128 }).path)
assert.equal(reads, 1)
assert.equal(geometry(Object.create({ yMax: 500 })).path, geometry(true).path)
assert.equal(geometry(Object.defineProperty({}, 'yMax', { value: 500 })).path, geometry(true).path)
})
test('heatmap #958: non-array points retain queue fallback and density fitting', () => {
const queue = Array.from({ length: 16000 }, () => ({ time: 30 }))
const geometry = points => heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option: true, points })
for (const points of [null, false, 'points', { length: 1 }, {}, []])
assert.equal(geometry(points).path, geometry(undefined).path)
})
test('heatmap #958: explicit axes retain the historical dense-data mapping', async () => {
const queue = Array.from({ length: 16000 }, (_, index) => ({ time: (index + 0.5) * 60 / 16000 }))
for (const option of [{ yMax: 128 }, { yMin: 0 }, { yMin: -10, yMax: 500, scale: 0.4 }]) {
const candidate = fixture({ queue })
candidate.install(option)
candidate.art.emit('ready')
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()
}
}
})
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)
})