Files
ArtPlayer/test/subtitle.test.js
T

173 lines
6.8 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Exercise resource promises with Node's test runner.
import { test as it } from 'node:test'
import { loadModules } from './helpers/load.js'
const { ResourceScope, SubtitleRequest, parseSubtitle, subtitleOffsetMix, beginLifecycle, initSubtitleState, replaceSubtitleTrack } = await loadModules({
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
SubtitleRequest: 'packages/artplayer/src/subtitle/request',
parseSubtitle: { file: 'packages/artplayer/src/subtitle/parse', name: 'parseSubtitle' },
subtitleOffsetMix: 'packages/artplayer/src/player/subtitleOffsetMix',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
initSubtitleState: { file: 'packages/artplayer/src/subtitle/state', name: 'initSubtitleState' },
replaceSubtitleTrack: { file: 'packages/artplayer/src/subtitle/track', name: 'replaceSubtitleTrack' },
})
it('subtitle track attribute failure releases its new scope and preserves the original error', () => {
const original = globalThis.document
const error = new Error('label getter failed')
const view = { art: { option: { subtitle: { get name() {
throw error
} } }, template: { $track: { parentNode: null, nextSibling: null } } } }
beginLifecycle(view.art)
const state = initSubtitleState(view, view.art)
const child = state.scope.child.bind(state.scope)
let open = 0
state.scope.child = () => {
const scope = child()
open += 1
scope.add(() => {
open -= 1
})
return scope
}
try {
globalThis.document = { createElement: () => ({ track: {}, remove() {} }) }
for (let attempt = 0; attempt < 3; attempt++) {
assert.throws(() => replaceSubtitleTrack(view, 'metadata', '/test.vtt'), caught => caught === error)
assert.equal(open, 0)
}
}
finally {
state.scope.dispose()
globalThis.document = original
}
})
it('subtitle requests settle immediately on cancel, ignore late resolution and abort transport', async () => {
const root = new ResourceScope()
const request = new SubtitleRequest(root)
let resolve
const result = request.run(() => new Promise((done) => {
resolve = done
}))
request.cancel()
assert.equal(await result, undefined)
assert.equal(request.signal.aborted, true)
resolve('late')
root.dispose()
})
it('subtitle cancellation observes late rejection without rejecting the cancelled caller', async () => {
const root = new ResourceScope()
const request = new SubtitleRequest(root)
let reject
const result = request.run(() => new Promise((_resolve, fail) => {
reject = fail
}))
root.dispose()
assert.equal(await result, undefined)
reject(new Error('late failure'))
await new Promise(resolve => setImmediate(resolve))
})
it('subtitle successful value and active rejection retain their identity', async () => {
const root = new ResourceScope()
const value = {}
assert.equal(await new SubtitleRequest(root).run(async () => value), value)
const error = new Error('active failure')
await assert.rejects(new SubtitleRequest(root).run(async () => {
throw error
}), caught => caught === error)
root.dispose()
})
it('subtitle closed roots never execute work and repeated cancellation is harmless', async () => {
const root = new ResourceScope()
root.dispose()
const request = new SubtitleRequest(root)
let calls = 0
assert.equal(await request.run(async () => {
calls += 1
}), undefined)
request.cancel()
request.cancel()
assert.equal(calls, 0)
})
it('subtitle requests still cancel without AbortController', async () => {
const original = globalThis.AbortController
try {
globalThis.AbortController = undefined
const root = new ResourceScope()
const request = new SubtitleRequest(root)
assert.equal(request.signal, undefined)
const result = request.run(() => new Promise(() => {}))
root.dispose()
assert.equal(await result, undefined)
}
finally {
globalThis.AbortController = original
}
})
it('subtitle conversion keeps callback this, encoded text and inferred extension', () => {
const option = { url: '/sub.SRT?token=1', encoding: 'utf-8', type: '', onVttLoad(vtt) {
assert.equal(this, option)
return `${vtt}NOTE transformed\n`
} }
const buffer = new TextEncoder().encode('1\n00:00:00,1 --> 00:00:01,23\nHello\n').buffer
const result = parseSubtitle(buffer, option)
assert.match(result.vtt, /00:00:00\.100 --> 00:00:01\.230/)
assert.match(result.vtt, /NOTE transformed/)
const latin = parseSubtitle(Uint8Array.from([0x63, 0x61, 0x66, 0xE9]).buffer, { ...option, type: 'vtt', encoding: 'windows-1252', onVttLoad: value => value })
assert.equal(latin.vtt, 'café')
})
it('subtitle pass-through skips conversion callback; invalid decoder and callback errors propagate', () => {
const buffer = new ArrayBuffer(0)
assert.equal(parseSubtitle(buffer, { url: '/subtitle.unknown', encoding: 'utf-8', onVttLoad() {
assert.fail('unexpected callback')
} }), undefined)
assert.throws(() => parseSubtitle(buffer, { url: '/subtitle.vtt', encoding: 'invalid-encoding' }), RangeError)
const error = new Error('callback failure')
assert.throws(() => parseSubtitle(buffer, { url: '/subtitle.vtt', onVttLoad() {
throw error
} }), caught => caught === error)
// Unknown formats and invalid JS callback return values must remain distinct paths.
assert.deepEqual(parseSubtitle(buffer, { url: '/subtitle.vtt', onVttLoad() {} }), { vtt: undefined })
})
it('subtitle offsets preserve cue identity and original bounds across repeated changes', () => {
const cue = { startTime: 1, endTime: 5 }
const events = []
const art = { template: { $track: {} }, duration: 8, subtitle: { cues: [cue], update() {
events.push('update')
} }, notice: {}, i18n: { get: key => key }, emit: (...args) => events.push(args) }
subtitleOffsetMix(art)
art.subtitleOffset = 2
assert.deepEqual(cue, { startTime: 3, endTime: 7, originalStartTime: 1, originalEndTime: 5 })
art.subtitleOffset = -3
assert.deepEqual(cue, { startTime: 0, endTime: 2, originalStartTime: 1, originalEndTime: 5 })
assert.equal(art.subtitleOffset, -3)
assert.equal(art.notice.show, 'Subtitle Offset: -3s')
assert.deepEqual(events, ['update', ['subtitleOffset', 2], 'update', ['subtitleOffset', -3]])
art.subtitleOffset = 20
assert.equal(art.subtitleOffset, 10)
assert.equal(art.notice.show, 'Subtitle Offset: 20s')
})
it('subtitle offsets with no cues retain the default state and do not notify', () => {
const art = { template: {}, subtitle: { cues: [], update() {
assert.fail()
} }, notice: {}, i18n: { get() {
assert.fail()
} }, emit() {
assert.fail()
} }
subtitleOffsetMix(art)
art.subtitleOffset = 1
assert.equal(art.subtitleOffset, 0)
assert.equal(Object.hasOwn(art.notice, 'show'), false)
})