mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
254 lines
11 KiB
JavaScript
254 lines
11 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, refreshPausedCues } = 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' },
|
|
refreshPausedCues: { file: 'packages/artplayer/src/subtitle/timing', name: 'refreshPausedCues' },
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('paused offsets refresh stale native membership before rendering and notifying, preserving cue identity', () => {
|
|
const cues = [{ startTime: 1, endTime: 3, text: 'First' }, { startTime: 4, endTime: 6, text: 'Later' }]
|
|
const video = { paused: true, currentTime: 1.5 }
|
|
const events = []
|
|
let active = [cues[0]]
|
|
const indexed = [...cues]
|
|
const track = {
|
|
mode: 'hidden',
|
|
removeCue(cue) {
|
|
events.push(['remove', cue.text])
|
|
indexed.splice(indexed.indexOf(cue), 1)
|
|
active = active.filter(item => item !== cue)
|
|
},
|
|
addCue(cue) {
|
|
events.push(['add', cue.text])
|
|
indexed.push(cue)
|
|
active = indexed.filter(cue => cue.startTime <= video.currentTime && video.currentTime < cue.endTime)
|
|
},
|
|
get activeCues() { return active },
|
|
}
|
|
const art = { template: { $track: { track }, $video: video }, duration: 8, subtitle: { cues, update() {
|
|
events.push(active.map(cue => cue.text))
|
|
} }, notice: {}, i18n: { get: key => key }, emit: (...args) => events.push(args) }
|
|
subtitleOffsetMix(art)
|
|
art.subtitleOffset = 1
|
|
const reindex = [['remove', 'First'], ['remove', 'Later'], ['add', 'First'], ['add', 'Later']]
|
|
assert.deepEqual(events, [...reindex, [], ['subtitleOffset', 1]])
|
|
events.length = 0
|
|
art.subtitleOffset = -3
|
|
assert.deepEqual(events, [...reindex, ['Later'], ['subtitleOffset', -3]])
|
|
assert.equal(active[0], cues[1])
|
|
assert.equal(video.currentTime, 1.5)
|
|
assert.equal(video.paused, true)
|
|
assert.equal(track.mode, 'hidden')
|
|
assert.deepEqual(indexed, cues)
|
|
})
|
|
|
|
it('paused refresh detects wrong members even when active counts match and preserves showing mode', () => {
|
|
const expired = { startTime: 0, endTime: 1 }
|
|
const current = { startTime: 1, endTime: 2 }
|
|
const writes = []
|
|
const track = { mode: 'showing', activeCues: [expired], removeCue: cue => writes.push(['remove', cue]), addCue: cue => writes.push(['add', cue]) }
|
|
Object.defineProperty(track, 'mode', { get: () => 'showing', set: () => assert.fail('must retain mode') })
|
|
refreshPausedCues(track, { paused: true, currentTime: 1 }, [expired, current])
|
|
assert.deepEqual(writes, [['remove', expired], ['remove', current], ['add', expired], ['add', current]])
|
|
})
|
|
|
|
it('paused refresh preserves correct overlap membership without toggling a native track', () => {
|
|
const cues = [{ startTime: 0, endTime: 2 }, { startTime: 1, endTime: 3 }]
|
|
const track = { mode: 'hidden', removeCue() {
|
|
assert.fail('unnecessary native invalidation')
|
|
}, activeCues: cues }
|
|
refreshPausedCues(track, { paused: true, currentTime: 1.5 }, cues)
|
|
})
|
|
|
|
it('paused refresh reindexes tied cues when native membership is correct but order drifted', () => {
|
|
const first = { startTime: 0, endTime: 2 }
|
|
const second = { startTime: 0, endTime: 2 }
|
|
const added = []
|
|
const track = { mode: 'hidden', activeCues: [second, first], removeCue() {}, addCue: cue => added.push(cue) }
|
|
refreshPausedCues(track, { paused: true, currentTime: 1 }, [first, second])
|
|
assert.deepEqual(added, [first, second])
|
|
})
|
|
|
|
it('paused refresh does not enable disabled tracks or intervene in playing and limited media hosts', () => {
|
|
for (const [mode, video] of [['disabled', { paused: true, currentTime: 1.5 }], ['hidden', { paused: false, currentTime: 1.5 }], ['hidden', undefined], ['hidden', { paused: true }], ['hidden', { paused: true, currentTime: Number.NaN }]]) {
|
|
const track = {
|
|
get mode() { return mode },
|
|
set mode(_value) {
|
|
assert.fail('unexpected track change')
|
|
},
|
|
get activeCues() {
|
|
assert.fail('unexpected cue inspection')
|
|
},
|
|
}
|
|
refreshPausedCues(track, video, [])
|
|
}
|
|
refreshPausedCues(undefined, { paused: true, currentTime: 1 }, [])
|
|
})
|