refactor(vtt-thumbnail): [PKG-VTT-THUMB-03] validate cues and separate pure lookup

This commit is contained in:
Harvey Zhao committed 2026-09-13 08:50:33 +08:00
1 parent 524ddf784f
commit 80588a3046
19 files changed
+3049 -169

No files matched your search

+28 -1
View File
@@ -5,10 +5,11 @@ import { expect, test } from './fixtures.js'
const implementation = await vttThumbnailCandidate()
const cues = 'WEBVTT\n\n00:00.000 --> 00:04.000\nvtt-sprite.svg#xywh=0,0,80,45\n\n00:04.000 --> 00:08.000\nvtt-sprite.svg#xywh=80,0,80,45\n'
const sprite = '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="45"><path fill="red" d="M0 0h80v45H0z"/><path fill="blue" d="M80 0h80v45H80z"/></svg>'
const extendedCues = '\uFEFFWEBVTT thumbnails\r\n\r\nNOTE source metadata\r\nignored\r\n\r\nSTYLE\r\n::cue { color: red }\r\n\r\nREGION\r\nid:unused\r\n\r\nfirst\r\n00:00.000\t-->\t00:04.000 align:start\r\nvtt-sprite.svg#xywh=0,0,80,45\r\n\r\nsecond\r\n00:04.000 --> 00:08.000\r\nvtt-sprite.svg#xywh=80,0,80,45\r\n'
for (const core of ['published', 'candidate']) {
test(`VTT native ${core} core: real progress hover selects sprite regions and destroy removes owned UI`, async ({ page }, testInfo) => {
await page.route('**/test/vtt-cues.vtt', route => route.fulfill({ contentType: 'text/vtt', body: cues }))
await page.route('**/test/vtt-cues.vtt', route => route.fulfill({ contentType: 'text/vtt', body: extendedCues }))
await page.route('**/test/vtt-sprite.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: sprite }))
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
@@ -45,6 +46,32 @@ for (const core of ['published', 'candidate']) {
await testInfo.attach('vtt-candidate', { body: JSON.stringify({ core, implementation: hash(implementation.code), pixels }), contentType: 'application/json' })
})
test(`VTT native ${core} core: malformed sprite rejects before mounting and a corrected registration recovers`, async ({ page }, testInfo) => {
await page.route('**/test/vtt-invalid.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:00.000 --> 00:08.000\nvtt-sprite.svg#xywh=0,0,bad,45\n' }))
await page.route('**/test/vtt-cues.vtt', route => route.fulfill({ contentType: 'text/vtt', body: extendedCues }))
await page.route('**/test/vtt-sprite.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: sprite }))
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
const error = await page.evaluate(async () => {
window.createPlayer('/test/pattern.mp4')
try {
await window.art.plugins.add(window.artplayerPluginVttThumbnail({ vtt: '/test/vtt-invalid.vtt' }))
return null
}
catch (error) {
return { name: error.name, message: error.message }
}
})
expect(error.name).toBe('TypeError')
expect(error.message).toContain('line 4:')
await expect(page.locator('.art-control-vtt-thumbnail')).toHaveCount(0)
await page.evaluate(() => window.art.plugins.add(window.artplayerPluginVttThumbnail({ vtt: '/test/vtt-cues.vtt' })))
await expect(page.locator('.art-control-vtt-thumbnail')).toHaveCount(1)
await page.evaluate(() => window.art.destroy(false))
await expect(page.locator('.art-control-vtt-thumbnail')).toHaveCount(0)
await testInfo.attach('vtt-candidate', { body: JSON.stringify({ core, implementation: hash(implementation.code), recoveredAfter: error }), contentType: 'application/json' })
})
test(`VTT native ${core} core: destroy aborts a pending native fetch and settles registration`, async ({ page }, testInfo) => {
let release
let requested
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Pure parser regression runner.
import test from 'node:test'
import { loadModules } from './helpers/load.js'
import { vttThumbnailCandidate, vttThumbnailEnvironment } from './helpers/vtt-thumbnail.js'
const baseline = process.env.ARTPLAYER_VTT_PARSER_BASELINE === '1'
const module = baseline
? await import(`data:text/javascript,${encodeURIComponent(execFileSync('git', ['show', '524ddf784fdc143d3020040e4de1a9e0921698ce:packages/artplayer-plugin-vtt-thumbnail/src/parseVtt.js'], { encoding: 'utf8' }))}`)
: await loadModules({ defaultParser: 'packages/artplayer-plugin-vtt-thumbnail/src/parseVtt' })
const parse = baseline ? module.default : module.defaultParser
const implementation = await vttThumbnailCandidate()
const cue = (time = '00:00.900 --> 00:05.900', image = 'sprite.jpg#xywh=10,20,80,45') => `${time}\n${image}`
test('VTT parser retains floored boundaries, raw xywh fields and lexical relative URL joining', () => {
assert.deepEqual(parse(`WEBVTT\n\n${cue()}`, 'https://cdn.test/path/cues.vtt'), [{ start: 0, end: 5, url: 'https://cdn.test/path/sprite.jpg', x: '10', y: '20', w: '80', h: '45' }])
assert.equal(parse(`WEBVTT\n${cue(undefined, '../sprite.jpg#yxhw=20,10,45,80')}`, '/folder/cues.vtt')[0].url, '/folder/../sprite.jpg')
})
test('VTT parser accepts BOM, CRLF, header annotations, cue IDs, timing settings and NOTE blocks', () => {
const text = `\uFEFFWEBVTT thumbnails\r\n\r\nNOTE author\r\nignored comment\r\n\r\nfirst-id\r\n${cue('00:00.900\t-->\t00:05.900 align:start')}`
assert.equal(parse(text)[0].w, '80')
assert.equal(parse(text)[0].end, 5)
})
test('VTT parser ignores STYLE and REGION blocks without applying caption CSS', () => {
const text = `WEBVTT\n\nSTYLE\n::cue { color: red }\n\nREGION\nid:region1\nwidth:50%\n\n${cue()}`
assert.equal(parse(text).length, 1)
})
test('VTT parser preserves compact arrows, seconds-only legacy timestamps and dense cue pairs', () => {
assert.deepEqual(parse(`WEBVTT\n${cue('00-->05')}\n${cue('05-->10')}`).map(item => [item.start, item.end]), [[0, 5], [5, 10]])
assert.equal(parse(`WEBVTT\n${cue('00:70-->01:20')}`)[0].start, 70, 'Keep previously accepted numeric time arithmetic outside strict subtitle syntax')
})
test('VTT parser reads complete long-hour timestamps instead of truncating to their last two digits', () => {
const parsed = parse(`WEBVTT\n${cue('123:00:00.100 --> 123:00:05.900')}`)
assert.equal(parsed[0].start, 442800)
assert.equal(parsed[0].end, 442805)
})
test('VTT parser keeps empty documents, empty header and comment-only documents empty', () => {
for (const text of ['', ' \n\t', '\uFEFFWEBVTT', 'WEBVTT\n\nNOTE empty\ncomment\n'])
assert.deepEqual(parse(text), [])
})
test('VTT parser retains valid decimal coordinates and all four-key permutations', () => {
for (const keys of ['xywh', 'yxhw', 'whxy', 'hyxw']) {
const values = { x: '0.5', y: '2.25', w: '80.5', h: '45.25' }
const parsed = parse(`WEBVTT\n${cue(undefined, `sprite.jpg#${keys}=${[...keys].map(key => values[key]).join(',')}`)}`)[0]
for (const key of keys)
assert.equal(parsed[key], values[key])
}
})
test('VTT parser preserves legacy zero-length cues and blank separators inside dense pairs', () => {
const parsed = parse('WEBVTT\n00:05.000-->00:05.000\n\nsprite.jpg#xywh=0,0,80,45')
assert.equal(parsed[0].start, 5)
assert.equal(parsed[0].end, 5)
})
test('VTT parser refuses non-text and numeric overflow without returning partial cues', () => {
for (const input of [null, undefined, 12, {}])
assert.throws(() => parse(input), error => error instanceof TypeError && /line 1:/.test(error.message))
const overflow = '9'.repeat(400)
assert.throws(() => parse(`WEBVTT\n${cue(`${overflow}:00:00.000 --> ${overflow}:00:05.000`)}`), /numeric range/)
assert.throws(() => parse(`WEBVTT\n${cue()}\n\n${cue(undefined, `sprite.jpg#xywh=0,0,${overflow},45`)}`), /finite/)
})
for (const [label, text, line] of [
['invalid header', `WRONG\n\n${cue()}`, 1],
['missing timing after cue id', 'WEBVTT\n\nfirst-id', 3],
['missing image', 'WEBVTT\n\n00:00.000 --> 00:05.000', 3],
['bad timestamp separator', `WEBVTT\n\n${cue('00:00x100 --> 00:05.000')}`, 3],
['bad fractional timestamp', `WEBVTT\n\n${cue('00:00.1 --> 00:05.000')}`, 3],
['reverse interval', `WEBVTT\n\n${cue('00:05.000 --> 00:00.000')}`, 3],
['missing rectangle', `WEBVTT\n\n${cue(undefined, 'sprite.jpg')}`, 4],
['duplicate rectangle keys', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xxwh=0,0,80,45')}`, 4],
['unknown rectangle keys', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#abcd=0,0,80,45')}`, 4],
['missing coordinate', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xywh=0,0,80')}`, 4],
['extra coordinate', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xywh=0,0,80,45,5')}`, 4],
['negative position', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xywh=-1,0,80,45')}`, 4],
['non-finite width', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xywh=0,0,Infinity,45')}`, 4],
['zero width', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xywh=0,0,0,45')}`, 4],
['non-numeric width', `WEBVTT\n\n${cue(undefined, 'sprite.jpg#xywh=0,0,bad,45')}`, 4],
]) {
test(`VTT parser reports ${label} as a TypeError with the original line`, async () => {
assert.throws(() => parse(text), error => error instanceof TypeError && error.message.includes(`line ${line}:`))
const env = vttThumbnailEnvironment(implementation, { text })
await assert.rejects(env.factory({})(env.art), error => error.name === 'TypeError' && error.message.includes(`line ${line}:`))
assert.equal(env.controls.length, 0)
assert([...env.listeners.values()].every(items => items.length === 0))
})
}
test('VTT parser handles every real demo cue identically to the frozen parser', async () => {
const old = await import(`data:text/javascript,${encodeURIComponent(execFileSync('git', ['show', '524ddf784fdc143d3020040e4de1a9e0921698ce:packages/artplayer-plugin-vtt-thumbnail/src/parseVtt.js'], { encoding: 'utf8' }))}`)
const text = fs.readFileSync('docs/assets/sample/bbb-thumbnails.vtt', 'utf8')
const actual = parse(text, '/assets/sample/bbb-thumbnails.vtt')
assert.equal(actual.length, 120)
assert.deepEqual(actual, old.default(text, '/assets/sample/bbb-thumbnails.vtt'))
})
test('VTT candidate registers extended VTT and retains first-match selection for overlapping unsorted cues', async () => {
const env = vttThumbnailEnvironment(implementation, { text: `WEBVTT\n\nNOTE comment\n\nlate\n${cue('00:04.100 --> 00:08.900', 'first.jpg#xywh=0,0,80,45')}\n\nearly\n${cue('00:00.900 --> 00:05.900', 'second.jpg#xywh=80,0,80,45')}` })
await env.factory({})(env.art)
await env.hover(0.4)
assert.equal(env.styles.backgroundImage, 'url(first.jpg)')
await env.hover(0.3)
assert.equal(env.styles.backgroundImage, 'url(second.jpg)')
await env.emit('destroy')
})