mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-20 20:21:20 -08:00
refactor(multiple-subtitles): [PKG-MULTI-SUB-03] own requests and subtitle resources
This commit is contained in:
1 parent
36cd151095
commit
beaa93d6cc
25 files changed
+7908
-250
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { multipleSubtitlesCandidate } from '../helpers/multiple-subtitles.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await multipleSubtitlesCandidate()
|
||||
const vtt = 'WEBVTT\n\n00:00.000 --> 00:08.000\nCached caption\n'
|
||||
const srt = text => `1\n00:00:00,000 --> 00:00:08,000\n${text}\n`
|
||||
|
||||
async function prepare(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.multipleProbe = { initialized: [], revoked: [], signal: null, errors: [], requests: [] }
|
||||
const fetch = window.fetch.bind(window)
|
||||
window.multipleNativeFetch = fetch
|
||||
window.fetch = (url, options) => {
|
||||
if (typeof url === 'string' && url.startsWith('/test/multiple-'))
|
||||
window.multipleProbe.requests.push(url)
|
||||
if (url === '/test/multiple-held.vtt')
|
||||
window.multipleProbe.signal = options?.signal
|
||||
return fetch(url, options)
|
||||
}
|
||||
const revoke = URL.revokeObjectURL.bind(URL)
|
||||
URL.revokeObjectURL = (url) => {
|
||||
window.multipleProbe.revoked.push(url)
|
||||
revoke(url)
|
||||
}
|
||||
const init = window.art.subtitle.init.bind(window.art.subtitle)
|
||||
window.art.subtitle.init = (option) => {
|
||||
window.multipleProbe.initialized.push({ url: option.url, destroyed: window.art.isDestroy })
|
||||
const result = init(option)
|
||||
Promise.resolve(result).catch(error => window.multipleProbe.errors.push({ name: error.name, message: error.message }))
|
||||
return result
|
||||
}
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
async function evidence(page, testInfo, core, observation) {
|
||||
const probe = await page.evaluate(() => ({ ...window.multipleProbe, signal: window.multipleProbe.signal ? { aborted: window.multipleProbe.signal.aborted } : null }))
|
||||
expect(probe.errors).toEqual([])
|
||||
await testInfo.attach('multiple-subtitles-lifecycle', { body: JSON.stringify({ core, implementation: { name: implementation.name, sha256: hash(implementation.code) }, observation, probe }), contentType: 'application/json' })
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`Multiple subtitles candidate ${core} core: SRT display and selectors survive the resource refactor`, async ({ page }, testInfo) => {
|
||||
await page.route('**/test/multiple-en.srt', route => route.fulfill({ body: srt('English'), contentType: 'text/plain' }))
|
||||
await page.route('**/test/multiple-jp.srt', route => route.fulfill({ body: srt('日本語'), contentType: 'text/plain' }))
|
||||
await prepare(page, core)
|
||||
await page.evaluate(async () => {
|
||||
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ name: 'en', url: '/test/multiple-en.srt' }, { name: 'jp', url: '/test/multiple-jp.srt' }] }))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(2)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await expect(page.locator('.art-subtitle-en')).toHaveText('English')
|
||||
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['jp']))
|
||||
await expect(page.locator('.art-subtitle-en')).toHaveCount(0)
|
||||
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks([]))
|
||||
await expect(page.locator('.art-subtitle-jp')).toHaveCount(0)
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
|
||||
await expect(page.locator('.art-subtitle-en')).toHaveText('English')
|
||||
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
|
||||
const released = await page.evaluate(() => {
|
||||
window.art.destroy(false)
|
||||
return window.multipleProbe.initialized.every(item => window.multipleProbe.revoked.includes(item.url))
|
||||
})
|
||||
expect(released).toBe(true)
|
||||
await evidence(page, testInfo, core, { nativeSrtDisplay: true, released })
|
||||
})
|
||||
|
||||
test(`Multiple subtitles candidate ${core} core: destroy settles a pending native request before it responds`, async ({ page }, testInfo) => {
|
||||
let requested
|
||||
let release
|
||||
const started = new Promise((resolve) => {
|
||||
requested = resolve
|
||||
})
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await page.route('**/test/multiple-held.vtt', async (route) => {
|
||||
requested()
|
||||
await held
|
||||
await route.fulfill({ body: vtt, contentType: 'text/vtt' })
|
||||
})
|
||||
try {
|
||||
await prepare(page, core)
|
||||
await page.evaluate(() => {
|
||||
window.multiplePending = window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-held.vtt', name: 'held' }] })(window.art)
|
||||
})
|
||||
await started
|
||||
const state = await page.evaluate(async () => {
|
||||
window.art.destroy(false)
|
||||
const result = await window.multiplePending
|
||||
return { name: result.name, voidReset: result.reset() === undefined, initialized: window.multipleProbe.initialized.length, aborted: window.multipleProbe.signal.aborted }
|
||||
})
|
||||
expect(state).toEqual({ name: 'multipleSubtitles', voidReset: true, initialized: 0, aborted: true })
|
||||
release()
|
||||
await evidence(page, testInfo, core, state)
|
||||
}
|
||||
finally { release() }
|
||||
})
|
||||
|
||||
test(`Multiple subtitles candidate ${core} core: destroy revokes the Blob and retained reset stays inert`, async ({ page }, testInfo) => {
|
||||
await page.route('**/test/multiple-ready.vtt', route => route.fulfill({ body: vtt, contentType: 'text/vtt' }))
|
||||
await prepare(page, core)
|
||||
await page.evaluate(async () => {
|
||||
window.multipleResult = await window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-ready.vtt', name: 'ready' }] })(window.art)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
|
||||
const state = await page.evaluate(async () => {
|
||||
const url = window.multipleProbe.initialized[0].url
|
||||
window.art.destroy(false)
|
||||
const voidReset = window.multipleResult.reset() === undefined
|
||||
let inaccessible = false
|
||||
try {
|
||||
await window.multipleNativeFetch(url)
|
||||
}
|
||||
catch (error) {
|
||||
inaccessible = error.name === 'TypeError'
|
||||
}
|
||||
return { voidReset, inaccessible, initialized: window.multipleProbe.initialized.length, revoked: window.multipleProbe.revoked.includes(url) }
|
||||
})
|
||||
expect(state).toEqual({ voidReset: true, inaccessible: true, initialized: 1, revoked: true })
|
||||
await evidence(page, testInfo, core, state)
|
||||
})
|
||||
|
||||
test(`Multiple subtitles candidate ${core} core: native HTTP error rejects without installing subtitles`, async ({ page }, testInfo) => {
|
||||
await page.route('**/test/multiple-error.vtt', route => route.fulfill({ status: 404, body: vtt, contentType: 'text/vtt' }))
|
||||
await prepare(page, core)
|
||||
const state = await page.evaluate(async () => {
|
||||
let message
|
||||
try {
|
||||
await window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-error.vtt', name: 'error' }] })(window.art)
|
||||
}
|
||||
catch (error) {
|
||||
message = error.message
|
||||
}
|
||||
window.art.destroy(false)
|
||||
return { message, initialized: window.multipleProbe.initialized.length }
|
||||
})
|
||||
expect(state).toEqual({ message: 'Failed to fetch multiple subtitles: HTTP 404', initialized: 0 })
|
||||
await evidence(page, testInfo, core, state)
|
||||
})
|
||||
|
||||
test(`Multiple subtitles candidate ${core} core: video source switching retains cached subtitle selection`, async ({ page }, testInfo) => {
|
||||
await page.route('**/test/multiple-restart.vtt', route => route.fulfill({ body: vtt, contentType: 'text/vtt' }))
|
||||
await prepare(page, core)
|
||||
await page.evaluate(async () => {
|
||||
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-restart.vtt', name: 'restart' }] }))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
|
||||
await page.evaluate(() => window.art.switchUrl('/test/pattern.mp4?multiple-restart=1'))
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
|
||||
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await expect(page.locator('.art-subtitle-restart')).toHaveText('Cached caption')
|
||||
const requests = await page.evaluate(() => window.multipleProbe.requests)
|
||||
expect(requests).toEqual(['/test/multiple-restart.vtt'])
|
||||
await page.evaluate(() => window.art.destroy(false))
|
||||
await evidence(page, testInfo, core, { requests, nativeSourceSwitch: true })
|
||||
})
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function multipleSubtitlesHistorical() {
|
||||
return implementations
|
||||
}
|
||||
|
||||
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {}, fetchResponse, coreUtils, onInit, onCreate } = {}) {
|
||||
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {}, fetchResponse, coreUtils, onInit, onCreate, abortController = true } = {}) {
|
||||
const requests = []
|
||||
const requestOptions = []
|
||||
const blobs = new Map()
|
||||
@@ -38,6 +38,7 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
|
||||
const initStates = []
|
||||
const converted = []
|
||||
const listeners = new Map()
|
||||
const warnings = []
|
||||
const module = { exports: {} }
|
||||
const utils = {
|
||||
getExt: url => url.split('.').pop(),
|
||||
@@ -80,7 +81,8 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
|
||||
const context = {
|
||||
Blob,
|
||||
TextDecoder,
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
AbortController: abortController ? AbortController : undefined,
|
||||
console: { log() {}, warn(...args) { warnings.push(args) }, error() {} },
|
||||
URL: {
|
||||
createObjectURL(blob) {
|
||||
onCreate?.(blob)
|
||||
@@ -115,5 +117,5 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
|
||||
for (const callback of [...(listeners.get(name) || [])])
|
||||
callback(...args)
|
||||
}
|
||||
return { factory, exported, art, utils, requests, requestOptions, blobs, liveBlobs, revoked, initialized, initStates, converted, listeners, emit, responseFor, latestText }
|
||||
return { factory, exported, art, utils, requests, requestOptions, blobs, liveBlobs, revoked, initialized, initStates, converted, listeners, warnings, emit, responseFor, latestText }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Lifecycle regressions use the repository Node runner.
|
||||
import test from 'node:test'
|
||||
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, subtitleVtt } from './helpers/multiple-subtitles.js'
|
||||
|
||||
const candidate = await multipleSubtitlesCandidate()
|
||||
const create = options => multipleSubtitlesEnvironment(candidate, options)
|
||||
const options = { subtitles: [{ url: 'a.vtt', name: 'a' }] }
|
||||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function flush() {
|
||||
for (let index = 0; index < 20; index++)
|
||||
await Promise.resolve()
|
||||
}
|
||||
function empty(env) {
|
||||
assert.equal(env.liveBlobs.size, 0)
|
||||
assert.equal(env.listeners.get('destroy')?.size || 0, 0)
|
||||
}
|
||||
|
||||
for (const abortController of [true, false]) {
|
||||
for (const phase of ['fetch', 'body']) {
|
||||
test(`Multiple subtitles candidate: destroy cancels ${phase}, AbortController=${abortController}`, async () => {
|
||||
const held = deferred()
|
||||
const started = deferred()
|
||||
const env = create({ abortController, fetchResponse: (_url, _options, responseFor) => {
|
||||
if (phase === 'fetch') {
|
||||
started.resolve()
|
||||
return held.promise
|
||||
}
|
||||
return { ...responseFor('a.vtt'), arrayBuffer() {
|
||||
started.resolve()
|
||||
return held.promise
|
||||
} }
|
||||
} })
|
||||
const pending = env.factory(options)(env.art)
|
||||
await started.promise
|
||||
env.emit('destroy')
|
||||
const result = await pending
|
||||
assert.equal(result.name, 'multipleSubtitles')
|
||||
assert.equal(result.reset(), undefined)
|
||||
assert.equal(result.tracks(['missing']), undefined)
|
||||
assert.equal(env.requestOptions[0]?.signal.aborted, abortController ? true : undefined)
|
||||
assert.equal(env.initialized.length, 0)
|
||||
empty(env)
|
||||
held.reject(new Error('late rejected operation'))
|
||||
await flush()
|
||||
assert.equal(env.initialized.length, 0)
|
||||
assert.equal(env.warnings.length, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('Multiple subtitles candidate: already destroyed hosts do not request or subscribe', async () => {
|
||||
const env = create()
|
||||
env.emit('destroy')
|
||||
const result = await env.factory(options)(env.art)
|
||||
result.reset()
|
||||
assert.equal(env.requests.length, 0)
|
||||
assert.equal(env.initialized.length, 0)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: failed request preserves error identity and cancels a sibling body', async () => {
|
||||
const failed = deferred()
|
||||
const body = deferred()
|
||||
const started = deferred()
|
||||
const error = new Error('network failed')
|
||||
const env = create({ fetchResponse: url => url === 'a.vtt'
|
||||
? failed.promise
|
||||
: { ok: true, arrayBuffer() {
|
||||
started.resolve()
|
||||
return body.promise
|
||||
} } })
|
||||
const pending = env.factory({ subtitles: [...options.subtitles, { url: 'b.vtt', name: 'b' }] })(env.art)
|
||||
await started.promise
|
||||
failed.reject(error)
|
||||
await assert.rejects(pending, caught => caught === error)
|
||||
assert.equal(env.requestOptions[1].signal.aborted, true)
|
||||
body.resolve(new TextEncoder().encode(subtitleVtt('late')).buffer)
|
||||
await flush()
|
||||
assert.equal(env.initialized.length, 0)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: HTTP failure rejects without reading or installing its body', async () => {
|
||||
let read = false
|
||||
const env = create({ fetchResponse: () => ({ ok: false, status: 404, arrayBuffer() {
|
||||
read = true
|
||||
} }) })
|
||||
await assert.rejects(env.factory(options)(env.art), { message: 'Failed to fetch multiple subtitles: HTTP 404' })
|
||||
assert.equal(read, false)
|
||||
assert.equal(env.requestOptions[0].signal.aborted, true)
|
||||
assert.equal(env.initialized.length, 0)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: body failure and bad encoding release registration resources', async () => {
|
||||
const error = new Error('body failed')
|
||||
const env = create({ fetchResponse: () => ({ ok: true, arrayBuffer: () => Promise.reject(error) }) })
|
||||
await assert.rejects(env.factory(options)(env.art), caught => caught === error)
|
||||
empty(env)
|
||||
const invalid = create()
|
||||
await assert.rejects(invalid.factory({ subtitles: [{ ...options.subtitles[0], encoding: 'invalid-label' }] })(invalid.art), { name: 'RangeError' })
|
||||
empty(invalid)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: destroy releases the last URL and makes retained methods inert', async () => {
|
||||
const env = create()
|
||||
const result = await env.factory(options)(env.art)
|
||||
env.emit('destroy')
|
||||
env.emit('destroy')
|
||||
assert.equal(result.reset(), undefined)
|
||||
assert.equal(result.tracks(), undefined)
|
||||
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
|
||||
assert.equal(env.initialized.length, 1)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: allocation failure preserves the old URL and reset recovers', async () => {
|
||||
let calls = 0
|
||||
const error = new Error('allocation failed')
|
||||
const env = create({ onCreate() {
|
||||
if (++calls === 2)
|
||||
throw error
|
||||
} })
|
||||
const result = await env.factory(options)(env.art)
|
||||
assert.throws(() => result.reset(), caught => caught === error)
|
||||
assert.equal(env.liveBlobs.has('blob:subtitle-1'), true)
|
||||
assert.equal(env.revoked.length, 0)
|
||||
result.reset()
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: synchronous initial host failure rolls back escape and frees the new URL', async () => {
|
||||
const error = new Error('init failed')
|
||||
const env = create({ onInit() {
|
||||
throw error
|
||||
} })
|
||||
await assert.rejects(env.factory(options)(env.art), caught => caught === error)
|
||||
assert.equal(env.art.option.subtitle.escape, true)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: a failed synchronous replacement retains the previous resource', async () => {
|
||||
let calls = 0
|
||||
const error = new Error('replacement failed')
|
||||
const env = create({ onInit() {
|
||||
if (++calls === 2)
|
||||
throw error
|
||||
} })
|
||||
const result = await env.factory(options)(env.art)
|
||||
assert.throws(() => result.reset(), caught => caught === error)
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
|
||||
result.reset()
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: host media loading does not delay registration or change void selection', async () => {
|
||||
const held = deferred()
|
||||
const env = create({ onInit: () => held.promise })
|
||||
const result = await env.factory(options)(env.art)
|
||||
assert.equal(result.name, 'multipleSubtitles')
|
||||
assert.equal(result.reset(), undefined)
|
||||
assert.equal(env.initialized.length, 2)
|
||||
held.resolve()
|
||||
await flush()
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: asynchronous rejection is observed and only frees its own URL', async () => {
|
||||
const operations = [deferred(), deferred()]
|
||||
let calls = 0
|
||||
const env = create({ onInit: () => operations[calls++].promise })
|
||||
const result = await env.factory(options)(env.art)
|
||||
result.reset()
|
||||
operations[0].reject(new Error('stale failure'))
|
||||
await flush()
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
|
||||
operations[1].reject(new Error('current failure'))
|
||||
await flush()
|
||||
assert.equal(env.liveBlobs.size, 0)
|
||||
assert.equal(env.warnings.length, 2)
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: destroy in host init does not install resources again', async () => {
|
||||
const env = create({ onInit() {
|
||||
env.emit('destroy')
|
||||
} })
|
||||
const result = await env.factory(options)(env.art)
|
||||
result.reset()
|
||||
assert.equal(env.initialized.length, 1)
|
||||
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: reentrant selection keeps only the newest resource', async () => {
|
||||
let result
|
||||
let reenter = false
|
||||
const env = create({ onInit() {
|
||||
if (reenter) {
|
||||
reenter = false
|
||||
result.tracks([])
|
||||
}
|
||||
} })
|
||||
result = await env.factory(options)(env.art)
|
||||
reenter = true
|
||||
result.reset()
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
|
||||
assert.equal(await env.latestText(), 'WEBVTT\n\n')
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: unknown names throw before allocation and do not corrupt later resets', async () => {
|
||||
const env = create()
|
||||
const result = await env.factory(options)(env.art)
|
||||
assert.throws(() => result.tracks(['missing']), { name: 'TypeError' })
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
|
||||
result.reset()
|
||||
assert.equal((env.utils.unescape(await env.latestText()).match(/<div/g) || []).length, 1)
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: destroy during allocation releases the returned URL immediately', async () => {
|
||||
const env = create({ onCreate() {
|
||||
env.emit('destroy')
|
||||
} })
|
||||
await env.factory(options)(env.art)
|
||||
assert.equal(env.initialized.length, 0)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: throwing live configuration preserves the old resource', async () => {
|
||||
const env = create()
|
||||
const result = await env.factory(options)(env.art)
|
||||
const old = env.art.option.subtitle
|
||||
const error = new Error('configuration getter failed')
|
||||
Object.defineProperty(env.art.option, 'subtitle', { configurable: true, get() {
|
||||
throw error
|
||||
} })
|
||||
assert.throws(() => result.reset(), caught => caught === error)
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
|
||||
Object.defineProperty(env.art.option, 'subtitle', { value: old })
|
||||
result.reset()
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: destroy from live configuration stops the following init', async () => {
|
||||
const env = create()
|
||||
const result = await env.factory(options)(env.art)
|
||||
Object.defineProperty(env.art.option.subtitle, 'style', { enumerable: true, get() {
|
||||
env.emit('destroy')
|
||||
return {}
|
||||
} })
|
||||
result.reset()
|
||||
assert.equal(env.initialized.length, 1)
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: reentrant successful selection survives the outer host error', async () => {
|
||||
let result
|
||||
let reenter = false
|
||||
const error = new Error('outer init failed')
|
||||
const env = create({ onInit() {
|
||||
if (reenter) {
|
||||
reenter = false
|
||||
result.tracks([])
|
||||
throw error
|
||||
}
|
||||
} })
|
||||
result = await env.factory(options)(env.art)
|
||||
reenter = true
|
||||
assert.throws(() => result.reset(), caught => caught === error)
|
||||
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
|
||||
env.emit('destroy')
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: late host rejection after destroy is handled without another write', async () => {
|
||||
const host = deferred()
|
||||
const env = create({ onInit: () => host.promise })
|
||||
await env.factory(options)(env.art)
|
||||
env.emit('destroy')
|
||||
host.reject(new Error('late host failure'))
|
||||
await flush()
|
||||
assert.equal(env.warnings.length, 0)
|
||||
assert.equal(env.initialized.length, 1)
|
||||
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
|
||||
empty(env)
|
||||
})
|
||||
|
||||
test('Multiple subtitles candidate: destroy from a URL getter stops the request before dispatch', async () => {
|
||||
const env = create()
|
||||
const entry = { name: 'a', get url() {
|
||||
env.emit('destroy')
|
||||
return 'a.vtt'
|
||||
} }
|
||||
await env.factory({ subtitles: [entry] })(env.art)
|
||||
assert.equal(env.requests.length, 0)
|
||||
assert.equal(env.initialized.length, 0)
|
||||
empty(env)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Merge and decoder regressions use the repository Node runner.
|
||||
import test from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, multipleSubtitlesHistorical, subtitleVtt } from './helpers/multiple-subtitles.js'
|
||||
|
||||
const { parseTracks, serializeTracks, ...coreUtils } = await loadModules({
|
||||
parseTracks: { file: 'packages/artplayer-plugin-multiple-subtitles/src/merge', name: 'parseTracks' },
|
||||
serializeTracks: { file: 'packages/artplayer-plugin-multiple-subtitles/src/merge', name: 'serializeTracks' },
|
||||
getExt: { file: 'packages/artplayer/src/utils/file', name: 'getExt' },
|
||||
srtToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'srtToVtt' },
|
||||
assToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'assToVtt' },
|
||||
})
|
||||
const candidate = await multipleSubtitlesCandidate()
|
||||
const historical = (await multipleSubtitlesHistorical()).find(item => item.name === 'published-1.2.0-main')
|
||||
function freeze(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.freeze(value)
|
||||
for (const child of Object.values(value)) freeze(child)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
test('Multiple subtitles merge: repeated selection serializes frozen trees without accumulating wrappers', () => {
|
||||
const trees = freeze(parseTracks([subtitleVtt('A'), subtitleVtt('B')], [{ name: 'a' }, { name: 'b' }]))
|
||||
const original = JSON.stringify(trees)
|
||||
const first = serializeTracks(trees)
|
||||
serializeTracks([trees[1], trees[0], trees[1]])
|
||||
assert.equal(serializeTracks(trees), first)
|
||||
assert.equal(JSON.stringify(trees), original)
|
||||
})
|
||||
|
||||
test('Multiple subtitles merge: actual released serializer output is preserved for text, markup and timing cases', async () => {
|
||||
const samples = [
|
||||
subtitleVtt('A & B < C > D'),
|
||||
subtitleVtt('<b>bold</b> tail'),
|
||||
subtitleVtt('<c.red>color</c> <i>italic</i>'),
|
||||
subtitleVtt('<v Speaker>voice</v>'),
|
||||
subtitleVtt('<ruby>text<rt>note</rt></ruby>'),
|
||||
subtitleVtt('line one\nline two'),
|
||||
'WEBVTT\n\n00:02.000 --> 00:05.000\nLater\n\n00:00.000 --> 00:03.000\nEarlier\n',
|
||||
subtitleVtt('best effort').replace('WEBVTT', 'BAD HEADER'),
|
||||
'WEBVTT\n\n',
|
||||
]
|
||||
for (const body of samples) {
|
||||
const environments = [historical, candidate].map(implementation => multipleSubtitlesEnvironment(implementation, { responses: { 'a.vtt': body, 'b.vtt': subtitleVtt('second') } }))
|
||||
const results = await Promise.all(environments.map(env => env.factory({ subtitles: [{ url: 'a.vtt', name: 'a' }, { url: 'b.vtt', name: 'b' }] })(env.art)))
|
||||
for (const selection of [null, ['b', 'a', 'b'], [], ['a']]) {
|
||||
if (selection)
|
||||
results.forEach(result => result.tracks(selection))
|
||||
assert.equal(await environments[1].latestText(), await environments[0].latestText(), body)
|
||||
}
|
||||
results.forEach(result => result.reset())
|
||||
assert.equal(await environments[1].latestText(), await environments[0].latestText(), body)
|
||||
environments[1].emit('destroy')
|
||||
}
|
||||
})
|
||||
|
||||
test('Multiple subtitles request: real core SRT/ASS conversion and encoded text retain released output', async () => {
|
||||
const responses = {
|
||||
'a.SRT?track=1': '1\n00:00:00,000 --> 00:00:02,000\nSRT text\n',
|
||||
'b.ass': 'Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,ASS text',
|
||||
'c.vtt': Buffer.from(subtitleVtt('café'), 'latin1'),
|
||||
'd.vtt': Buffer.from(subtitleVtt('日本語'), 'utf16le'),
|
||||
}
|
||||
const options = { subtitles: [{ url: 'a.SRT?track=1', name: 'srt' }, { url: 'b.ass', name: 'ass' }, { url: 'c.vtt', name: 'latin', encoding: 'windows-1252' }, { url: 'd.vtt', name: 'utf16', encoding: 'utf-16le' }] }
|
||||
const old = multipleSubtitlesEnvironment(historical, { responses, coreUtils })
|
||||
const current = multipleSubtitlesEnvironment(candidate, { responses, coreUtils })
|
||||
await old.factory(options)(old.art)
|
||||
await current.factory(options)(current.art)
|
||||
const text = await current.latestText()
|
||||
assert.equal(text, await old.latestText())
|
||||
for (const value of ['SRT text', 'ASS text', 'café', '日本語']) assert(text.includes(value))
|
||||
current.emit('destroy')
|
||||
})
|
||||
@@ -64,11 +64,11 @@ for (const implementation of implementations) {
|
||||
assert.equal(env.initialized[0].onVttLoad, env.utils.unescape)
|
||||
assert.equal(env.initialized[0].style, env.art.option.subtitle.style)
|
||||
assert.equal(env.initialized[0].type, 'vtt')
|
||||
assert.deepEqual(env.revoked, [''])
|
||||
assert.deepEqual(env.revoked, implementation.version === 'candidate' ? [] : [''])
|
||||
env.art.option.subtitle.encoding = 'utf-16le'
|
||||
result.reset()
|
||||
assert.equal(env.initialized[1].encoding, 'utf-16le')
|
||||
assert.deepEqual(env.revoked, ['', 'blob:subtitle-1'])
|
||||
assert.deepEqual(env.revoked, implementation.version === 'candidate' ? ['blob:subtitle-1'] : ['', 'blob:subtitle-1'])
|
||||
assert.equal(env.blobs.get('blob:subtitle-2').type, 'text/vtt')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user