mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
332 lines
15 KiB
JavaScript
332 lines
15 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Controlled client faults and real worker geometry use the Node runner.
|
|
import test from 'node:test'
|
|
import vm from 'node:vm'
|
|
import { danmukuCandidate, danmukuCandidateEnvironment, danmukuWorkerCode } from './helpers/danmuku-candidate.js'
|
|
import { loadModules } from './helpers/load.js'
|
|
|
|
const { WorkerClient } = await loadModules({ WorkerClient: 'packages/artplayer-plugin-danmuku/src/worker-client' })
|
|
const implementation = await danmukuCandidate()
|
|
|
|
// This client double records protocol ownership; it does not simulate geometry.
|
|
function clientFixture(onFailure = () => {}) {
|
|
const messages = []
|
|
const failures = []
|
|
const worker = {
|
|
terminateCalls: 0,
|
|
postMessage(message) { messages.push({ ...message }) },
|
|
terminate() { this.terminateCalls++ },
|
|
}
|
|
const client = new WorkerClient(() => worker, (error) => {
|
|
failures.push(error)
|
|
onFailure(error)
|
|
})
|
|
return { client, worker, messages, failures }
|
|
}
|
|
|
|
function detached(worker) {
|
|
assert.equal(worker.onmessage, null)
|
|
assert.equal(worker.onerror, null)
|
|
assert.equal(worker.onmessageerror, null)
|
|
}
|
|
|
|
test('danmuku worker client: simultaneous requests have distinct IDs and reversed replies retain their owners', async () => {
|
|
const { client, worker, messages } = clientFixture()
|
|
const inputs = [{ type: 'getDanmuTop', tag: 'first' }, { type: 'getDanmuTop', tag: 'second' }]
|
|
const first = client.request(inputs[0])
|
|
const second = client.request(inputs[1])
|
|
assert.notEqual(inputs[0].id, inputs[1].id)
|
|
assert.deepEqual(messages.map(item => item.id), inputs.map(item => item.id))
|
|
assert.equal(client.pending.size, 2)
|
|
worker.onmessage({ data: { id: inputs[1].id, result: 33 } })
|
|
assert.deepEqual(await second, { id: inputs[1].id, result: 33 })
|
|
assert.equal(client.pending.size, 1)
|
|
worker.onmessage({ data: { id: inputs[0].id, result: 10 } })
|
|
assert.deepEqual(await first, { id: inputs[0].id, result: 10 })
|
|
assert.equal(client.pending.size, 0)
|
|
client.dispose()
|
|
})
|
|
|
|
test('danmuku worker client: a synchronous reply is observed before postMessage returns', async () => {
|
|
const { client, worker } = clientFixture()
|
|
const callback = worker.onmessage
|
|
worker.postMessage = message => callback({ data: { id: message.id, result: 72 } })
|
|
const message = { type: 'getDanmuTop' }
|
|
assert.deepEqual(await client.request(message), { id: message.id, result: 72 })
|
|
assert.equal(worker.onmessage, callback, 'Requests must not replace the shared dispatcher')
|
|
assert.equal(client.pending.size, 0)
|
|
client.dispose()
|
|
})
|
|
|
|
test('danmuku worker client: unrelated and repeated messages cannot settle another request', async () => {
|
|
const { client, worker } = clientFixture()
|
|
const a = { type: 'getDanmuTop' }
|
|
const b = { type: 'getDanmuTop' }
|
|
const first = client.request(a)
|
|
const second = client.request(b)
|
|
worker.onmessage({ data: { id: 'unrelated', result: 100 } })
|
|
assert.equal(client.pending.size, 2)
|
|
worker.onmessage({ data: { id: a.id, result: 10 } })
|
|
worker.onmessage({ data: { id: a.id, result: 999 } })
|
|
assert.deepEqual(await first, { id: a.id, result: 10 })
|
|
assert.equal(client.pending.size, 1)
|
|
worker.onmessage({ data: { id: b.id, result: 33 } })
|
|
assert.deepEqual(await second, { id: b.id, result: 33 })
|
|
client.dispose()
|
|
})
|
|
|
|
test('danmuku worker client: cancellation settles every owner, ignores late replies and permits new work', async () => {
|
|
const { client, worker, failures } = clientFixture()
|
|
const messages = [{ type: 'getDanmuTop' }, { type: 'getDanmuTop' }]
|
|
const pending = messages.map(message => client.request(message))
|
|
client.cancel()
|
|
assert.deepEqual(await Promise.all(pending), messages.map(message => ({ id: message.id, result: undefined })))
|
|
assert.equal(client.pending.size, 0)
|
|
assert.equal(worker.terminateCalls, 0)
|
|
const next = { type: 'getDanmuTop' }
|
|
const nextPending = client.request(next)
|
|
for (const message of messages)
|
|
worker.onmessage({ data: { id: message.id, result: 10 } })
|
|
assert.equal(client.pending.size, 1)
|
|
worker.onmessage({ data: { id: next.id, result: 56 } })
|
|
assert.deepEqual(await nextPending, { id: next.id, result: 56 })
|
|
assert.deepEqual(failures, [])
|
|
client.dispose()
|
|
})
|
|
|
|
for (const eventName of ['error', 'messageerror']) {
|
|
test(`danmuku worker client: ${eventName} rejects all pending requests with the original failure exactly once`, async () => {
|
|
const { client, worker, messages, failures } = clientFixture()
|
|
const error = new Error(`${eventName}-original`)
|
|
const a = client.request({ type: 'getDanmuTop' })
|
|
const b = client.request({ type: 'getDanmuTop' })
|
|
const rejected = Promise.all([assert.rejects(a, value => value === error), assert.rejects(b, value => value === error)])
|
|
let prevented = 0
|
|
const handler = worker[`on${eventName}`]
|
|
handler({ error, preventDefault() {
|
|
prevented++
|
|
} })
|
|
handler({ error: new Error('duplicate') })
|
|
await rejected
|
|
assert.deepEqual(failures, [error])
|
|
assert.equal(client.pending.size, 0)
|
|
assert.equal(client.failed, true)
|
|
if (eventName === 'error')
|
|
assert.equal(prevented, 1)
|
|
const ignored = { type: 'getDanmuTop' }
|
|
assert.deepEqual(await client.request(ignored), { id: ignored.id, result: undefined })
|
|
assert.equal(messages.length, 2)
|
|
client.dispose()
|
|
detached(worker)
|
|
assert.equal(worker.terminateCalls, 1)
|
|
})
|
|
}
|
|
|
|
for (const error of [new Error('post-original'), undefined, null, false]) {
|
|
test(`danmuku worker client: synchronous postMessage throw ${String(error)} retains the exact rejection value`, async () => {
|
|
const { client, worker, failures } = clientFixture()
|
|
const first = client.request({ type: 'getDanmuTop' })
|
|
const rejected = assert.rejects(first, value => value === error)
|
|
worker.postMessage = () => {
|
|
throw error
|
|
}
|
|
await assert.rejects(client.request({ type: 'getDanmuTop' }), value => value === error)
|
|
await rejected
|
|
assert.deepEqual(failures, [error])
|
|
assert.equal(client.pending.size, 0)
|
|
client.dispose()
|
|
})
|
|
}
|
|
|
|
test('danmuku worker client: dispose cancels pending work, detaches handlers and terminates exactly once', async () => {
|
|
const { client, worker, messages, failures } = clientFixture()
|
|
const message = { type: 'getDanmuTop' }
|
|
const pending = client.request(message)
|
|
const oldHandler = worker.onmessage
|
|
client.dispose()
|
|
client.dispose()
|
|
assert.deepEqual(await pending, { id: message.id, result: undefined })
|
|
detached(worker)
|
|
assert.equal(worker.terminateCalls, 1)
|
|
oldHandler({ data: { id: message.id, result: 100 } })
|
|
const after = { type: 'getDanmuTop' }
|
|
assert.deepEqual(await client.request(after), { id: after.id, result: undefined })
|
|
assert.equal(messages.length, 1)
|
|
assert.deepEqual(failures, [])
|
|
})
|
|
|
|
test('danmuku worker client: termination failure still settles pending requests and detaches owned handlers', async () => {
|
|
const { client, worker } = clientFixture()
|
|
const error = new Error('terminate-original')
|
|
worker.terminate = () => {
|
|
worker.terminateCalls++
|
|
throw error
|
|
}
|
|
const input = { type: 'getDanmuTop' }
|
|
const pending = client.request(input)
|
|
assert.throws(() => client.dispose(), value => value === error)
|
|
assert.deepEqual(await pending, { id: input.id, result: undefined })
|
|
detached(worker)
|
|
client.dispose()
|
|
assert.equal(worker.terminateCalls, 1)
|
|
})
|
|
|
|
test('danmuku worker client: fault cleanup cannot replace the original request error', async () => {
|
|
const { client, worker, failures } = clientFixture()
|
|
const original = new Error('worker-original')
|
|
const cleanup = new Error('terminate-secondary')
|
|
worker.terminate = () => {
|
|
worker.terminateCalls++
|
|
throw cleanup
|
|
}
|
|
const pending = client.request({ type: 'getDanmuTop' })
|
|
const rejected = assert.rejects(pending, error => error === original)
|
|
worker.onerror({ error: original, preventDefault() {} })
|
|
await rejected
|
|
assert.deepEqual(failures, [original])
|
|
detached(worker)
|
|
assert.equal(worker.terminateCalls, 1)
|
|
client.dispose()
|
|
assert.equal(worker.terminateCalls, 1)
|
|
})
|
|
|
|
test('danmuku worker client: initial handler setup failure terminates the worker and preserves the setup error', () => {
|
|
const original = new Error('handler-setup-original')
|
|
let terminated = 0
|
|
const worker = {
|
|
get onmessage() { return undefined },
|
|
set onmessage(_value) { throw original },
|
|
terminate() {
|
|
terminated++
|
|
throw new Error('terminate-secondary')
|
|
},
|
|
}
|
|
assert.throws(() => new WorkerClient(() => worker, () => assert.fail('No initialized client may report a runtime fault')), error => error === original)
|
|
assert.equal(terminated, 1)
|
|
})
|
|
|
|
test('danmuku worker client: independent clients never share pending ownership', async () => {
|
|
const first = clientFixture()
|
|
const second = clientFixture()
|
|
const a = { type: 'getDanmuTop' }
|
|
const b = { type: 'getDanmuTop' }
|
|
const pa = first.client.request(a)
|
|
const pb = second.client.request(b)
|
|
assert.notEqual(a.id, b.id)
|
|
first.client.dispose()
|
|
assert.deepEqual(await pa, { id: a.id, result: undefined })
|
|
assert.equal(second.worker.terminateCalls, 0)
|
|
assert.equal(second.client.pending.size, 1)
|
|
second.worker.onmessage({ data: { id: b.id, result: 70 } })
|
|
assert.deepEqual(await pb, { id: b.id, result: 70 })
|
|
second.client.dispose()
|
|
})
|
|
|
|
// Unlike the client double above, this executes the actual source or artifact
|
|
// worker. Numeric geometry inputs are controlled; no browser layout is claimed.
|
|
const workerEnvironment = danmukuCandidateEnvironment(implementation)
|
|
workerEnvironment.factory({ danmuku: [] })(workerEnvironment.art)
|
|
const actualWorker = workerEnvironment.workers[0]
|
|
const workerCode = await danmukuWorkerCode(implementation, workerEnvironment, actualWorker)
|
|
workerEnvironment.destroy()
|
|
|
|
function calculateGeometry(input) {
|
|
const replies = []
|
|
const context = vm.createContext({ input, postMessage: data => replies.push(data), URL: { revokeObjectURL() {} }, location: { href: actualWorker.url } })
|
|
context.self = context
|
|
vm.runInContext(workerCode, context, { timeout: 1000 })
|
|
vm.runInContext('onmessage({ data: input })', context, { timeout: 1000 })
|
|
assert.equal(replies.length, 1)
|
|
assert.equal(replies[0].id, input.id)
|
|
return replies[0].result
|
|
}
|
|
|
|
test('danmuku actual worker: fixed tracks preserve mode isolation, margins, gap direction and full-track deferral', () => {
|
|
const base = { id: 1, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: true }
|
|
const target = mode => ({ mode, height: 20, speed: 20 })
|
|
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [] }), 10)
|
|
assert.equal(calculateGeometry({ ...base, target: target(2), visibles: [] }), 70)
|
|
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 1, top: 10, height: 20 }] }), 30)
|
|
assert.equal(calculateGeometry({ ...base, target: target(2), visibles: [{ mode: 2, top: 70, height: 20 }] }), 50)
|
|
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 0, top: 10, height: 80 }] }), 10)
|
|
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 1, top: 95, height: 20 }] }), 10)
|
|
for (const mode of [1, 2]) {
|
|
const visibles = Object.freeze([Object.freeze({ mode, top: 10, height: 40 }), Object.freeze({ mode, top: 50, height: 40 })])
|
|
assert.equal(calculateGeometry({ ...base, target: target(mode), visibles }), undefined)
|
|
}
|
|
})
|
|
|
|
test('danmuku actual worker: rolling collision decisions retain distance, remaining-time and relative-speed semantics', () => {
|
|
const base = { id: 2, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: true }
|
|
const visible = { mode: 0, top: 10, height: 80, left: 40, width: 40, distance: 80, right: 20, speed: 10, time: 8 }
|
|
const calculate = (speed, changed = {}) => calculateGeometry({ ...base, target: { mode: 0, height: 20, speed }, visibles: [{ ...visible, ...changed }] })
|
|
assert.equal(calculate(5), 10, 'A slower follower cannot catch the existing row')
|
|
assert.equal(calculate(10), 10, 'Equal speed with a positive gap remains safe')
|
|
assert.equal(calculate(11), 10, 'Catch-up happens after the existing remaining lifetime')
|
|
assert.equal(calculate(20), undefined, 'Catch-up before expiration defers the follower')
|
|
assert.equal(calculate(5, { distance: 110, right: -10 }), undefined, 'An existing row not fully inside blocks reuse')
|
|
})
|
|
|
|
test('danmuku actual worker: disabled antiOverlap retains the historical occupancy ranking', () => {
|
|
const base = { id: 3, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: false }
|
|
for (const mode of [0, 1, 2]) {
|
|
const visibles = [{ mode, top: 10, height: 40, width: 80, right: 10 }, { mode, top: 50, height: 40, width: 20, right: 30 }]
|
|
assert.equal(calculateGeometry({ ...base, target: { mode, height: 20, speed: 20 }, visibles }), 50)
|
|
}
|
|
})
|
|
|
|
test(`danmuku ${implementation.name}: owner requests made with the same clock tick remain distinct`, async (t) => {
|
|
const env = danmukuCandidateEnvironment(implementation)
|
|
const plugin = env.factory({ danmuku: [] })(env.art)
|
|
t.after(() => env.destroy())
|
|
const owner = plugin.show()
|
|
const first = owner.postMessage({ type: 'getDanmuTop' })
|
|
const second = owner.postMessage({ type: 'getDanmuTop' })
|
|
const worker = env.workers[0]
|
|
assert.equal(worker.messages.length, 2)
|
|
const [a, b] = worker.messages.map(item => item.message.id)
|
|
assert.notEqual(a, b, 'The controlled Date.now value is unchanged between calls')
|
|
worker.deliver({ id: b, result: 33 })
|
|
assert.equal((await second).result, 33)
|
|
worker.deliver({ id: a, result: 10 })
|
|
assert.equal((await first).result, 10)
|
|
})
|
|
|
|
for (const recovery of ['start', 'reset']) {
|
|
test(`danmuku ${implementation.name}: a Worker fault stops the run once and ${recovery} rebuilds the client`, async (t) => {
|
|
const env = danmukuCandidateEnvironment(implementation)
|
|
const plugin = env.factory({ danmuku: [] })(env.art)
|
|
t.after(() => env.destroy())
|
|
const owner = plugin.show()
|
|
env.art.playing = true
|
|
env.art.emit('video:play')
|
|
assert.equal(env.frames.size, 1)
|
|
const worker = env.workers[0]
|
|
const error = new Error(`${recovery}-worker-original`)
|
|
const first = owner.postMessage({ type: 'getDanmuTop' })
|
|
const second = owner.postMessage({ type: 'getDanmuTop' })
|
|
const rejected = Promise.all([assert.rejects(first, value => value === error), assert.rejects(second, value => value === error)])
|
|
const fail = worker.onerror
|
|
fail({ error, preventDefault() {} })
|
|
fail({ error: new Error('duplicate'), preventDefault() {} })
|
|
await rejected
|
|
assert.deepEqual(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').map(event => event.args), [[error]])
|
|
assert.equal(env.frames.size, 0)
|
|
assert.equal(worker.terminated, true)
|
|
detached(worker)
|
|
if (recovery === 'start')
|
|
env.art.emit('video:play')
|
|
else plugin.reset()
|
|
assert.equal(env.workers.length, 2)
|
|
assert.equal(env.frames.size, 1)
|
|
const fresh = env.workers[1]
|
|
assert.notEqual(fresh, worker)
|
|
assert.equal(fresh.terminated, false)
|
|
const pending = owner.postMessage({ type: 'getDanmuTop' })
|
|
const { id } = fresh.messages[0].message
|
|
fresh.deliver({ id, result: 10 })
|
|
assert.equal((await pending).result, 10)
|
|
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 1)
|
|
})
|
|
}
|