fix(multiple-subtitles): render overlapping cues on legacy hosts [PKG-MULTI-SUB-10]

This commit is contained in:
Harvey Zhao committed 2026-09-14 13:26:42 +08:00
1 parent 31f9024bff
commit 8ae2762a23
24 files changed
+3465 -56

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Tests and fixture ownership
`test/multiple-subtitles-caption.test.js` checks the old-host view adapter with the
actual plugin bundle: native order, original update identity/scalar event payload,
escape changes, timestamp cleanup, modern-host isolation and destroy/registration
failure cleanup. It runs in `test:unit` and `test:multiple-subtitles`, without new
dependencies. Native cue timing is separately verified in the browser suite.
`test/multiple-subtitles-entities.test.js` checks named entities, literal tags,
double encoding, nested semantic tags and selection/reset through the real plugin.
The vendor parity suite stays unchanged: the plugin uses the parser's existing
+11
View File
@@ -266,3 +266,14 @@ syntax failures remain in the historical parser tests. Mobile cases use an Andro
UA and synthetic DOM touches through the real core handlers, not trusted hardware
input. Fullscreen mouse placement first waits for the progress control to be stable;
the earlier hidden-preview screenshot timeout remains in the checkpoint evidence.
`multiple-subtitles-legacy.spec.js` uses real 5.1.2/5.1.7 cores and the candidate
plugin to check three independently timed captions, VTT/SRT, semantic HTML/CSS,
the original update method and native cue identities, scalar event payloads,
repeated updates, selection/reset and empty intervals. It uses ASS for the third
track on5.1.7; the5.1.2 published converter's malformed VTT remains a separate
task11 failure and is not covered by substituting a VTT fixture. Use
`ARTPLAYER_MULTIPLE_SUBTITLES_ARTIFACT` for actual main/legacy package bytes.
The combination suite retains explicit published1.1/1.2 overlap-loss observations
while requiring candidate captions to contain both languages. A passing historical
observation is not a compatibility pass.
@@ -172,18 +172,19 @@ for (const core of ['published-5.1.2', 'published-5.1.7']) {
await window.art.plugins.add(window.multipleFactory({ subtitles: [{ name: 'a', url: '/test/combination.vtt' }, { name: 'b', url: '/test/combination.srt' }] }))
})
const indexMerge = implementation.version === '1.0.0'
const displaysBoth = indexMerge || implementation.name === 'candidate'
await expect.poll(() => page.evaluate(() => window.art.template.$track.track.cues?.length)).toBe(indexMerge ? 1 : 2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await seek(page, 1.5)
await expect(page.locator('.art-subtitle-a')).toHaveText('First')
// This is a deficiency probe, not successful simultaneous-caption acceptance.
await expect(page.locator('.art-subtitle-b')).toHaveCount(indexMerge ? 1 : 0)
// Historical 1.1/1.2 failures remain observations; candidate must display both.
await expect(page.locator('.art-subtitle-b')).toHaveCount(displaysBoth ? 1 : 0)
const state = await page.evaluate(() => ({ native: Array.from(window.art.template.$track.track.activeCues, cue => cue.getCueAsHTML().textContent), displayed: window.art.template.$subtitle.textContent }))
expect(state.native).toEqual(indexMerge ? ['FirstTranslation'] : ['First', 'Translation'])
expect(state.displayed).toBe(indexMerge ? 'FirstTranslation' : 'First')
await testInfo.attach('single-active-cue-boundary', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, state, simultaneousCaptionsAccepted: indexMerge, unresolvedCandidateGap: !indexMerge, explanation: '1.0.0 combines by array index; 1.1.0 onward retains separately timed cues. Old core displays only its first active cue. Candidate gap remains open, even when this observation test passes.' }) })
expect(state.displayed).toBe(displaysBoth ? 'FirstTranslation' : 'First')
await testInfo.attach('single-active-cue-boundary', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, state, simultaneousCaptionsAccepted: displaysBoth, historicalCaptionLoss: !displaysBoth, explanation: '1.0.0 combines by array index; 1.1/1.2 retain separate cues but the old core displays only its first. Candidate adapts the old view while retaining separate native timings.' }) })
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['b']))
await expect(page.locator('.art-subtitle-b')).toHaveText('Translation')
await expect(page.locator('.art-subtitle-a')).toHaveCount(0)
@@ -0,0 +1,76 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { multipleSubtitlesCandidate } from '../helpers/multiple-subtitles.js'
import { expect, test } from './fixtures.js'
const candidate = await multipleSubtitlesCandidate()
for (const core of ['published-5.1.2', 'published-5.1.7']) {
test(`Multiple subtitles legacy host ${core}: independently timed captions overlap and clear`, async ({ page, browser }, testInfo) => {
await page.route('**/test/legacy-a.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:01.000 --> 00:03.000\n<b>A &amp; safe</b>\n' }))
await page.route('**/test/legacy-b.srt', route => route.fulfill({ contentType: 'text/plain', body: '1\n00:00:02,000 --> 00:00:04,000\nB\n' }))
await page.route('**/test/legacy-c.ass', route => route.fulfill({ contentType: 'text/plain', body: 'Dialogue: 0,0:00:02.25,0:00:02.75,Default,,0,0,0,,C' }))
await page.route('**/test/legacy-c.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:02.250 --> 00:02.750\nC\n' }))
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: candidate.code })
await page.addStyleTag({ content: '.art-subtitle-a { color: rgb(255, 0, 0); } .art-subtitle-b { color: rgb(0, 0, 255); }' })
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.originalSubtitleUpdate = window.art.subtitle.update
window.legacyPayloads = []
window.art.on('subtitleUpdate', text => window.legacyPayloads.push(text))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const thirdFormat = core === 'published-5.1.2' ? 'vtt' : 'ass'
await page.evaluate(async (thirdFormat) => {
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ name: 'a', url: '/test/legacy-a.vtt' }, { name: 'b', url: '/test/legacy-b.srt' }, { name: 'c', url: `/test/legacy-c.${thirdFormat}` }] }))
}, thirdFormat)
await expect.poll(() => page.evaluate(() => window.art.template.$track.track.cues?.length)).toBe(3)
await page.evaluate(() => {
window.cueIdentities = Array.from(window.art.template.$track.track.cues)
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
async function seek(time) {
await page.evaluate(time => new Promise((resolve) => {
window.art.once('video:seeked', resolve)
window.art.seek = time
}), time)
}
await seek(1.5)
await expect(page.locator('.art-subtitle-a b')).toHaveText('A & safe')
await expect(page.locator('.art-subtitle-b')).toHaveCount(0)
await seek(2.5)
await expect(page.locator('.art-subtitle-a b')).toHaveText('A & safe')
await expect(page.locator('.art-subtitle-b')).toHaveText('B')
await expect(page.locator('.art-subtitle-c')).toHaveText('C')
await expect(page.locator('.art-subtitle-a')).toHaveCSS('color', 'rgb(255, 0, 0)')
await expect(page.locator('.art-subtitle-b')).toHaveCSS('color', 'rgb(0, 0, 255)')
const state = await page.evaluate(() => {
const art = window.art
art.subtitle.update()
art.subtitle.update()
const cues = Array.from(art.template.$track.track.cues)
return { sameUpdate: art.subtitle.update === window.originalSubtitleUpdate, sameCues: cues.every((cue, index) => cue === window.cueIdentities[index]), times: cues.map(cue => [cue.startTime, cue.endTime]), legacyPayload: window.legacyPayloads.at(-1), firstText: art.template.$track.track.activeCues[0].text, counts: ['a', 'b', 'c'].map(name => art.template.$subtitle.querySelectorAll(`.art-subtitle-${name}`).length) }
})
expect(state.sameUpdate).toBe(true)
expect(state.sameCues).toBe(true)
expect(state.times).toEqual([[1, 3], [2, 4], [2.25, 2.75]])
expect(state.legacyPayload).toBe(state.firstText)
expect(state.counts).toEqual([1, 1, 1])
await seek(3.5)
await expect(page.locator('.art-subtitle-a')).toHaveCount(0)
await expect(page.locator('.art-subtitle-b')).toHaveText('B')
await expect(page.locator('.art-subtitle-c')).toHaveCount(0)
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks([]))
await expect(page.locator('.art-subtitle-b')).toHaveCount(0)
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
await expect(page.locator('.art-subtitle-b')).toHaveText('B')
await seek(2.5)
await expect(page.locator('.art-subtitle-c')).toHaveText('C')
await seek(4.5)
await expect(page.locator('.art-subtitle-a, .art-subtitle-b, .art-subtitle-c')).toHaveCount(0)
await testInfo.attach('legacy-captions', { contentType: 'application/json', body: JSON.stringify({ core, browser: browser.version(), pluginSha256: hash(candidate.code), thirdFormat, state, scope: 'Native VTT/SRT overlaps (ASS additionally on 5.1.7), semantic HTML/CSS, legacy scalar event payload, original host method and cue identities, repeated update/selection/reset/empty intervals', limitation: '5.1.2 published ASS converter loses required VTT line breaks; tracked separately in PKG-MULTI-SUB-11.' }) })
await page.evaluate(() => window.art.destroy())
})
}
+136
View File
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Caption adapters use the repository Node runner.
import test from 'node:test'
import { parseHTML } from 'linkedom'
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment } from './helpers/multiple-subtitles.js'
const candidate = await multipleSubtitlesCandidate()
function host({ modern = false } = {}) {
const env = multipleSubtitlesEnvironment(candidate)
const { document } = parseHTML('<html><body><div id="subtitle"></div></body></html>')
const target = document.getElementById('subtitle')
let active = []
env.art.template = { $subtitle: target }
Object.defineProperty(env.art.subtitle, 'activeCue', {
get() { throw new Error('Capability checks must not read a cue') },
})
Object.defineProperty(env.art.subtitle, 'textTrack', { get: () => ({ activeCues: active }) })
if (modern)
Object.defineProperty(env.art.subtitle, 'activeCues', { get() { throw new Error('Modern rendering belongs to the core') } })
env.art.subtitle.update = () => {
target.innerHTML = active[0]?.text || ''
if (active.length)
env.emit('subtitleUpdate', active[0].text)
}
return {
...env,
target,
setActive(cues) { active = cues },
}
}
test('Multiple subtitles legacy caption view preserves cue order, event payload and original update', async () => {
const env = host()
const update = env.art.subtitle.update
const payloads = []
const views = []
env.art.on('subtitleUpdate', (value) => {
payloads.push(value)
views.push(['before', env.target.textContent])
})
await env.factory({ subtitles: [] })(env.art)
env.art.on('subtitleUpdate', () => views.push(['after', env.target.textContent]))
const cues = Object.freeze([
Object.freeze({ text: '<div class="a"><b>A &amp; safe</b></div>', startTime: 1, endTime: 3 }),
Object.freeze({ text: '<div class="b">B</div>', startTime: 2, endTime: 4 }),
Object.freeze({ text: '<div class="c">C</div>', startTime: 2.25, endTime: 2.75 }),
])
env.setActive(cues)
for (let index = 0; index < 3; index++) {
update()
assert.equal(env.target.textContent, 'A & safeBC')
assert.deepEqual(Array.from(env.target.children, node => node.className), ['a', 'b', 'c'])
}
assert.equal(env.art.subtitle.update, update)
assert.deepEqual(payloads, [cues[0].text, cues[0].text, cues[0].text])
assert.deepEqual(views, Array.from({ length: 3 }, () => [['before', 'A & safe'], ['after', 'A & safeBC']]).flat())
assert.equal(env.listeners.get('subtitleAfterUpdate'), undefined)
env.setActive([cues[1]])
update()
assert.equal(env.target.innerHTML, cues[1].text)
env.setActive([])
update()
assert.equal(env.target.innerHTML, '')
assert.equal(payloads.length, 4)
env.emit('destroy')
})
test('Multiple subtitles legacy view respects later escape changes and blank CRLF lines', async () => {
const env = host()
await env.factory({ subtitles: [] })(env.art)
env.art.option.subtitle.escape = true
env.setActive([{ text: '<b>A & B</b>\r\n\r\nTail' }, { text: '<img src="bad" onerror="bad()">' }])
env.art.subtitle.update()
assert.equal(env.target.querySelectorAll('b, img').length, 0)
assert.deepEqual(Array.from(env.target.children, node => node.textContent), ['<b>A & B</b>', '', 'Tail', '<img src="bad" onerror="bad()">'])
assert(Array.from(env.target.children).every(node => node.className === 'art-subtitle-line'))
env.emit('destroy')
})
test('Multiple subtitles legacy view cleans timestamp markers for both single and overlapping cues', async () => {
const env = host()
await env.factory({ subtitles: [] })(env.art)
const cue = { text: '<div class="a">A<c.artplayer-multiple-subtitles-timestamp><00:02.000></c><b>B</b></div>' }
for (const cues of [[cue], [cue, { text: '<div>C</div>' }]]) {
env.setActive(cues)
env.art.subtitle.update()
assert.equal(env.target.querySelector('b').textContent, 'B')
assert.equal(env.target.textContent, cues.length === 1 ? 'AB' : 'ABC')
assert.equal(env.target.getElementsByTagName('c.artplayer-multiple-subtitles-timestamp').length, 0)
}
env.emit('destroy')
})
test('Multiple subtitles modern hosts retain their own view and never install the legacy listener', async () => {
const env = host({ modern: true })
await env.factory({ subtitles: [] })(env.art)
env.target.innerHTML = '<span>Core view</span>'
env.setActive([{ text: 'A' }, { text: 'B' }])
env.emit('subtitleUpdate', 'A')
env.emit('subtitleAfterUpdate', ['A', 'B'])
assert.equal(env.target.innerHTML, '<span>Core view</span>')
assert.equal(env.listeners.get('subtitleUpdate'), undefined)
assert.equal(env.listeners.get('subtitleAfterUpdate').size, 1)
env.emit('destroy')
assert.equal(env.listeners.get('subtitleAfterUpdate').size, 0)
})
test('Multiple subtitles legacy listener is removed on destroy and captured late callbacks are inert', async () => {
const env = host()
await env.factory({ subtitles: [] })(env.art)
const callback = [...env.listeners.get('subtitleUpdate')][0]
env.setActive([{ text: 'A' }, { text: 'B' }])
env.emit('destroy')
env.target.innerHTML = '<span>After destroy</span>'
callback('A')
assert.equal(env.target.textContent, 'After destroy')
assert.equal(env.listeners.get('subtitleUpdate').size, 0)
assert.equal(env.listeners.get('destroy').size, 0)
})
test('Multiple subtitles legacy listener registration failure unwinds both listeners', async () => {
const env = host()
const on = env.art.on
const error = new Error('Listener registration failed')
env.art.on = (name, callback) => {
on(name, callback)
if (name === 'subtitleUpdate')
throw error
return env.art
}
await assert.rejects(env.factory({ subtitles: [] })(env.art), value => value === error)
assert.equal(env.listeners.get('subtitleUpdate').size, 0)
assert.equal(env.listeners.get('destroy').size, 0)
assert.equal(env.liveBlobs.size, 0)
})