mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 22:01:20 -08:00
refactor(core): CORE-15 type subtitles and own request and track lifetimes
This commit is contained in:
1 parent
64d1939daa
commit
1b83081e8f
28 files changed
+11286
-405
No files matched your search
@@ -0,0 +1,172 @@
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user