refactor(chapter): PKG-CHAPTER-03 split runtime into typed lifecycle and view modules

This commit is contained in:
Harvey Zhao committed 2026-09-10 23:12:56 +08:00
1 parent a0286c4e95
commit 48660c4e12
23 files changed
+1207 -497

No files matched your search

+100
View File
@@ -153,3 +153,103 @@ test('published-only observations: blank title, NaN and once-only metadata are h
expect(await page.locator('.art-chapter').first().evaluate(node => node.dataset.start)).toBe('NaN')
await testInfo.attach('chapter-known-defects', { body: JSON.stringify({ staleTitle, endAfterSourceChange: 8, acceptsNaN: true }), contentType: 'application/json' })
})
test('candidate clears titles on blank chapters, zero hover and replacement', async ({ page }) => {
await openChapter(page, 'candidate', [{ start: 0, end: 2, title: 'Previous' }, { start: 4, end: 8, title: 'Next' }])
const title = page.locator('.art-chapter-title')
await hover(page, 0.1)
await expect(title).toHaveText('Previous')
await hover(page, 0.375)
await expect(title).toHaveText('')
await expect(title).toHaveCSS('visibility', 'hidden')
await hover(page, 0.75)
await expect(title).toHaveText('Next')
await hover(page, 0)
await expect(title).toHaveText('')
await hover(page, 0.1)
await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Replacement' }] }))
await expect(title).toHaveText('')
await hover(page, 0.5)
await expect(title).toHaveText('Replacement')
await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({}))
await expect(title).toHaveText('')
})
test('candidate rejects NaN and can recover after an invalid update', async ({ page }) => {
await openChapter(page, 'candidate', [{ start: 0, end: 8, title: 'Valid' }])
expect(await page.evaluate(() => {
try {
window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: Number.NaN, end: 3, title: 'Invalid' }] })
}
catch (error) { return error.message }
})).toBe('Illegal chapter time point')
await expect(page.locator('.art-chapter')).toHaveCount(0)
await expect(page.locator('.art-video-player')).not.toHaveClass(/artplayer-plugin-chapter/)
await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Recovered' }] }))
await expect(page.locator('.art-chapter')).toHaveCount(1)
await hover(page, 0.5)
await expect(page.locator('.art-chapter-title')).toHaveText('Recovered')
})
test('candidate releases only its listeners and DOM across multiple instances and early destruction', async ({ page }) => {
await openChapter(page, 'candidate', [{ start: 0, end: 8, title: 'First' }])
expect(await page.evaluate(() => {
const art = window.art
const plugin = art.plugins.artplayerPluginChapter
let observerCalls = 0
const observer = () => observerCalls++
art.on('setBar', observer)
const listenersBefore = art.e.setBar.length
const container = document.createElement('div')
container.className = 'second-player'
document.body.appendChild(container)
const other = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, plugins: [window.artplayerPluginChapter()] })
other.plugins.artplayerPluginChapter.update({ chapters: [] })
art.destroy(false)
const entry = { start: 0, end: Infinity, title: 'Ignored' }
plugin.update({ chapters: [entry] })
const listenersAfter = art.e.setBar.length
art.emit('setBar', 'loaded', 0.5)
const result = {
ownDom: art.template.$player.querySelectorAll('.art-chapters, .art-chapter-title').length,
classRemoved: !art.template.$player.classList.contains('artplayer-plugin-chapter'),
listenersRemoved: listenersBefore - listenersAfter,
observerCalls,
inputUntouched: entry.end === Infinity,
otherDom: container.querySelectorAll('.art-chapters, .art-chapter-title').length,
otherAlive: !other.isDestroy,
}
other.destroy()
return result
})).toEqual({ ownDom: 0, classRemoved: true, listenersRemoved: 1, observerCalls: 1, inputUntouched: true, otherDom: 2, otherAlive: true })
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1)
})
test('candidate ignores pending metadata initialization after immediate destroy', async ({ page }) => {
await page.goto('/test/player.html?core=published&chapter=candidate')
expect(await page.evaluate(() => {
const original = window.artplayerPluginChapter
let initialize
let plugin
const chapters = [{ start: 0, end: Infinity, title: 'Pending' }]
const art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [(art) => {
const count = art.e['video:loadedmetadata']?.length || 0
plugin = original({ chapters })(art)
initialize = art.e['video:loadedmetadata'][count].fn._
return plugin
}] })
const before = art.e['video:loadedmetadata'].length
art.destroy()
const after = art.e['video:loadedmetadata']?.length || 0
initialize()
plugin.update({ chapters })
return { removed: before - after, untouched: chapters[0].end === Infinity, children: document.querySelector('.player').childElementCount }
})).toEqual({ removed: 1, untouched: true, children: 0 })
})
test('candidate deduplicates stylesheet injection while the document is loading', async ({ page, baseURL }) => {
await page.setContent(`<html><head><script src="${baseURL}/candidate/artplayer-plugin-chapter.js"></script><script src="${baseURL}/candidate/artplayer-plugin-chapter.js"></script></head><body></body></html>`)
await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1)
expect(await page.locator('style#artplayer-plugin-chapter').textContent()).toContain('.art-chapters')
})
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Pure chapter logic uses the repository Node runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { normalize } = await loadModules({ normalize: { file: 'packages/artplayer-plugin-chapter/src/chapters', name: 'normalizeChapters' } })
test('Chapter normalization preserves caller array and objects while filling every gap', () => {
const first = { start: 1, end: 2, title: ' First ' }
const last = { start: 4, end: 6, title: 'Last' }
const input = [last, first]
assert.equal(normalize(input, 8), input)
assert.equal(input[1], first)
assert.equal(input[3], last)
assert.deepEqual(input, [
{ start: 0, end: 1, title: '' },
first,
{ start: 2, end: 4, title: '' },
last,
{ start: 6, end: 8, title: '' },
])
assert.equal(normalize(input, 8), input)
assert.equal(input.length, 5, 'Repeated updates must not duplicate gaps')
})
test('Chapter endpoints, adjacency and Infinity normalization retain historical behavior', () => {
const input = [{ start: 0, end: 2, title: 'A' }, { start: 2, end: Infinity, title: 'B' }]
assert.equal(normalize(input, 8), input)
assert.equal(input[1].end, 8)
assert.equal(input.length, 2)
})
test('No usable timeline leaves input untouched and produces no chapters', () => {
for (const duration of [0, -1, Infinity, Number.NaN]) {
const input = [{ start: 3, end: Infinity, title: 'Pending' }]
assert.deepEqual(normalize(input, duration), [])
assert.equal(input[0].end, Infinity)
}
for (const input of [undefined, null, false, {}, []])
assert.deepEqual(normalize(input, 8), [])
})
test('Invalid chapter types and times are rejected including non-finite values', () => {
for (const input of [[null], [{ start: '0', end: 8, title: 'Bad' }], [{ start: 0, end: 8, title: 42 }]])
assert.throws(() => normalize(input, 8), TypeError)
for (const [start, end] of [[Number.NaN, 3], [0, Number.NaN], [-Infinity, 2], [Infinity, Infinity], [0, -Infinity], [-1, 2], [2, 2], [3, 2], [0, 9]])
assert.throws(() => normalize([{ start, end, title: 'Bad' }], 8), { name: 'Error', message: 'Illegal chapter time point' })
assert.throws(() => normalize([{ start: 0, end: 4, title: 'A' }, { start: 3, end: 6, title: 'B' }], 8), /Illegal chapter time point/)
})