fix(multiple-subtitles): [PKG-MULTI-SUB-08] preserve entity text and semantic cue wrappers

This commit is contained in:
Harvey Zhao committed 2026-09-14 12:14:21 +08:00
1 parent 4acf85ea69
commit 675b095b27
22 files changed
+744 -59

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Tests and fixture ownership
`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
entity-table option. Historical malformed-tag and stray-semicolon tests continue
to assert old defects; corrected cases have explicit rendering expectations instead
of treating those old serialized bytes as a required contract. Browser entity
tests compare native VTT fragments and actual HTML captions on both core variants.
`yarn test:library` runs actual library builds, build-input freshness checks and the
scaffold's generated consumers. The new build modules live in `scripts/library/`;
`yarn typecheck:library` checks them and the retained JS/MJS entrypoints. Browser
@@ -0,0 +1,42 @@
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 literal = 'A & < > \u200E \u200F \u00A0 | <b>literal</b> | &lt;'
const vtt = text => `WEBVTT\n\n00:00.000 --> 00:08.000\n${text}\n`
for (const core of ['published', 'candidate']) {
test(`Multiple subtitles ${core} core decodes entities once and preserves literal and semantic tags`, async ({ page, browser }, testInfo) => {
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/test/entities-a.vtt', route => route.fulfill({ body: vtt('A &amp; &lt; &gt; &lrm; &rlm; &nbsp; | &lt;b&gt;literal&lt;/b&gt; | &amp;lt;'), contentType: 'text/vtt' }))
await page.route('**/test/entities-b.vtt', route => route.fulfill({ body: vtt('<b>bold &amp; safe</b> tail &lt;i&gt;'), contentType: 'text/vtt' }))
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(async () => {
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ name: 'a', url: '/test/entities-a.vtt' }, { name: 'b', url: '/test/entities-b.vtt' }] }))
})
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.poll(() => page.locator('.art-subtitle-a').textContent()).toBe(literal)
await expect(page.locator('.art-subtitle-a b')).toHaveCount(0)
await expect(page.locator('.art-subtitle-b b')).toHaveText('bold & safe')
await expect(page.locator('.art-subtitle-b i')).toHaveCount(0)
expect(await page.locator('.art-subtitle-b').allTextContents()).toEqual(['bold & safe', ' tail <i>'])
const native = await page.evaluate(() => window.art.subtitle.cues.map(cue => cue.getCueAsHTML().textContent))
expect(native).toEqual([literal, 'bold & safe tail <i>'])
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['a']))
await expect(page.locator('.art-subtitle-b')).toHaveCount(0)
await expect.poll(() => page.locator('.art-subtitle-a').textContent()).toBe(literal)
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
await expect(page.locator('.art-subtitle-b b')).toHaveText('bold & safe')
await testInfo.attach('entities.json', { contentType: 'application/json', body: JSON.stringify({ core, browser: browser.version(), pluginSha256: hash(implementation.code), native, literal, scope: 'Actual native VTT and HTML caption display, playback, selection/reset; desktop browser engines only' }) })
await page.evaluate(() => window.art.destroy(false))
expect(errors).toEqual([])
})
}
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Text entity regressions use real built plugin serialization.
import test from 'node:test'
import { parseHTML } from 'linkedom'
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, multipleSubtitlesHistorical, subtitleVtt } from './helpers/multiple-subtitles.js'
const implementation = await multipleSubtitlesCandidate()
for (const [input, expected] of [
['&amp; &lt; &gt; &lrm; &rlm; &nbsp;', '& < > \u200E \u200F \u00A0'],
['Literal &lt;00:02.000&gt; and &lt;b&gt;text&lt;/b&gt;', 'Literal <00:02.000> and <b>text</b>'],
['&amp;lt; &amp;amp; &amp;gt;', '&lt; &amp; &gt;'],
['One &amp;; two &lt;;', 'One &; two <;'],
['<b>bold &amp; safe</b> tail &lt;i&gt;', 'bold & safe tail <i>'],
['<c.red>color</c> <i>italic</i>', 'color italic'],
['<v Speaker>voice</v>', 'voice'],
['<ruby>text<rt>note</rt></ruby>', 'textnote'],
['&unknown; &amplitude; &amp', '&unknown; &litude; &'],
['&#65; &#x41;', 'A A'],
]) {
test(`Multiple subtitles entity text remains literal after selection/reset: ${input}`, async () => {
const env = multipleSubtitlesEnvironment(implementation, { responses: { 'a.vtt': subtitleVtt(input), 'b.vtt': subtitleVtt('Other') } })
const result = await env.factory({ subtitles: [{ name: 'a', url: 'a.vtt' }, { name: 'b', url: 'b.vtt' }] })(env.art)
for (const names of [['a'], ['b', 'a'], ['a', 'a']]) {
result.tracks(names)
const vtt = env.utils.unescape(await env.latestText())
const text = vtt.split(/\n\d[^\n]*-->[^\n]*\n/).slice(1).join('')
const { document } = parseHTML(`<html><body>${text}</body></html>`)
assert.equal(document.body.textContent.trim(), names.map(name => name === 'a' ? expected : 'Other').join('\n\n').trim())
if (input.includes('&lt;i&gt;'))
assert.equal(document.querySelectorAll('i').length, 0)
if (input.startsWith('<b>'))
assert.equal(document.querySelector('b').textContent, 'bold & safe')
}
result.reset()
assert((env.utils.unescape(await env.latestText())).includes('Other'))
env.emit('destroy')
})
}
test('Multiple subtitles raw ampersand and malformed angle text retain historical visible output', async () => {
const historical = (await multipleSubtitlesHistorical()).find(item => item.name === 'published-1.2.0-main')
const output = []
for (const code of [historical, implementation]) {
const env = multipleSubtitlesEnvironment(code, { responses: { 'a.vtt': subtitleVtt('A & B < C > D') } })
await env.factory({ subtitles: [{ name: 'a', url: 'a.vtt' }] })(env.art)
const { document } = parseHTML(`<html><body>${env.utils.unescape(await env.latestText())}</body></html>`)
output.push(document.body.textContent)
env.emit('destroy')
}
assert.equal(output[1], output[0])
})
+6
View File
@@ -269,4 +269,10 @@ for (const implementation of implementations) {
await env.factory(options(subtitle()))(env.art)
assert(env.utils.unescape(await env.latestText()).includes('Literal <;00:02.000>; example &;'))
})
test(`Multiple-subtitles ${implementation.name} failure baseline: semantic tag wrappers become malformed attributes`, async () => {
const env = create({ responses: { 'a.vtt': subtitleVtt('<b>bold</b>') } })
await env.factory(options(subtitle()))(env.art)
assert(env.utils.unescape(await env.latestText()).includes('<b <div class="art-subtitle-a">undefined</div>>bold</b>'))
})
}
+2 -6
View File
@@ -31,13 +31,9 @@ test('Multiple subtitles merge: repeated selection serializes frozen trees witho
assert.equal(JSON.stringify(trees), original)
})
test('Multiple subtitles merge: actual released serializer output is preserved for text, markup and timing cases', async () => {
test('Multiple subtitles merge: released serializer output is preserved outside corrected text escaping and tag wrappers', 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('Plain text'),
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'),