mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
|
import { test } from 'node:test'
|
|
import { loadModules } from './helpers/load.js'
|
|
|
|
const { thumbnailsMix, Emitter, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry, loadImg, loadThumbnailImage } = await loadModules({
|
|
thumbnailsMix: 'packages/artplayer/src/player/thumbnailsMix',
|
|
Emitter: 'packages/artplayer/src/utils/emitter',
|
|
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
|
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
|
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
|
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
|
|
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
|
|
loadImg: { file: 'packages/artplayer/src/image/load', name: 'loadImg' },
|
|
loadThumbnailImage: { file: 'packages/artplayer/src/image/load', name: 'loadThumbnailImage' },
|
|
})
|
|
|
|
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
|
|
function fixture(t, overrides = {}) {
|
|
const images = []
|
|
const callbacks = []
|
|
const errors = []
|
|
const revoked = []
|
|
const warnings = []
|
|
class FakeImage {
|
|
width = 1000
|
|
height = 600
|
|
naturalWidth = 1000
|
|
src = ''
|
|
onload = null
|
|
onerror = null
|
|
constructor() {
|
|
images.push(this)
|
|
}
|
|
|
|
removeAttribute(name) {
|
|
if (name === 'src')
|
|
this.src = ''
|
|
}
|
|
}
|
|
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toBlob: callback => callbacks.push(callback) }
|
|
for (const [name, value] of Object.entries({ Image: FakeImage, document: { createElement: () => canvas } })) {
|
|
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
|
|
Object.defineProperty(globalThis, name, { configurable: true, value })
|
|
t.after(() => previous ? Object.defineProperty(globalThis, name, previous) : delete globalThis[name])
|
|
}
|
|
t.mock.method(URL, 'createObjectURL', () => 'blob:thumbnail')
|
|
t.mock.method(URL, 'revokeObjectURL', url => revoked.push(url))
|
|
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
|
const art = Object.assign(new Emitter(), {
|
|
option: { thumbnails: { url: 'first.png', number: 100, column: 10, width: 100, height: 60, scale: 1, ...overrides }, isLive: false },
|
|
template: { $progress: { clientWidth: 1000 }, $video: { videoWidth: 320, videoHeight: 180 } },
|
|
controls: { thumbnails: { style: {} } },
|
|
})
|
|
beginLifecycle(art)
|
|
ownEntry(art, art.controls.thumbnails)
|
|
thumbnailsMix(art)
|
|
function bar(kind, percentage, event) {
|
|
for (const listener of art.e?.setBar || []) {
|
|
const result = listener.fn(kind, percentage, event)
|
|
if (result?.catch)
|
|
result.catch(error => errors.push(error))
|
|
}
|
|
}
|
|
return { art, canvas, images, callbacks, errors, revoked, warnings, scope: getScope(art), bar, hover: percentage => bar('hover', percentage) }
|
|
}
|
|
|
|
test('thumbnail setter preserves option identity, live restrictions and immutable descriptor', (t) => {
|
|
const f = fixture(t)
|
|
const descriptor = Object.getOwnPropertyDescriptor(f.art, 'thumbnails')
|
|
assert.equal(descriptor.enumerable, false)
|
|
assert.equal(descriptor.configurable, false)
|
|
const option = { url: 'next.png', number: 4 }
|
|
f.art.thumbnails = option
|
|
assert.equal(f.art.thumbnails, option)
|
|
f.art.thumbnails = { url: '' }
|
|
assert.equal(f.art.thumbnails, option)
|
|
f.art.option.isLive = true
|
|
f.art.thumbnails = { url: 'live.png' }
|
|
assert.equal(f.art.thumbnails, option)
|
|
})
|
|
|
|
test('thumbnail layout matches generated sprite cells and preserves progress endpoint handling', async (t) => {
|
|
const f = fixture(t)
|
|
f.hover(0.1)
|
|
f.images[0].onload()
|
|
await flush()
|
|
const style = f.art.controls.thumbnails.style
|
|
assert.deepEqual(style, { backgroundImage: 'url(first.png)', height: '60px', width: '100px', backgroundPosition: '-0px -60px', left: '50px' })
|
|
f.hover(0.11)
|
|
assert.equal(style.backgroundPosition, '-100px -60px')
|
|
const before = { ...style }
|
|
f.hover(0)
|
|
f.hover(1)
|
|
f.hover(Number.NaN)
|
|
assert.deepEqual(style, before)
|
|
f.hover(0.001)
|
|
assert.equal(style.backgroundPosition, '-0px -0px')
|
|
assert.equal(style.left, 0)
|
|
f.hover(0.999)
|
|
assert.equal(style.backgroundPosition, '-900px -540px')
|
|
assert.equal(style.left, '900px')
|
|
})
|
|
|
|
test('replaced thumbnail configuration cannot be overwritten by an older completed load', async (t) => {
|
|
const f = fixture(t)
|
|
f.hover(0.2)
|
|
const oldLoad = f.images[0].onload
|
|
f.art.thumbnails = { ...f.art.thumbnails, url: 'second.png' }
|
|
f.hover(0.4)
|
|
f.images[1].onload()
|
|
await flush()
|
|
oldLoad()
|
|
await flush()
|
|
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(second.png)')
|
|
})
|
|
|
|
test('failed thumbnail load can retry and does not escape the internal event handler', async (t) => {
|
|
const f = fixture(t)
|
|
f.hover(0.2)
|
|
f.images[0].onerror()
|
|
await flush()
|
|
f.hover(0.3)
|
|
assert.equal(f.images.length, 2)
|
|
f.images[1].onload()
|
|
await flush()
|
|
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(first.png)')
|
|
assert.deepEqual(f.errors, [])
|
|
})
|
|
|
|
test('destroy cancels pending thumbnail rendering and removes its setBar subscription', async (t) => {
|
|
const f = fixture(t)
|
|
f.hover(0.2)
|
|
const loaded = f.images[0].onload
|
|
f.scope.dispose()
|
|
loaded()
|
|
await flush()
|
|
assert.deepEqual(f.art.controls.thumbnails.style, {})
|
|
assert.equal(f.art.e?.setBar?.length || 0, 0)
|
|
})
|
|
|
|
test('scaled thumbnail Blob URL is released once on configuration replacement', async (t) => {
|
|
const f = fixture(t, { scale: 0.5 })
|
|
f.hover(0.2)
|
|
f.images[0].onload()
|
|
f.callbacks[0](new Blob(['image']))
|
|
f.images[1].onload()
|
|
await flush()
|
|
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(blob:thumbnail)')
|
|
f.art.thumbnails = { ...f.art.thumbnails, url: 'next.png' }
|
|
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
|
f.scope.dispose()
|
|
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
|
})
|
|
|
|
test('pending load renders only the latest hover and suppresses stale source coordinates', async (t) => {
|
|
const f = fixture(t)
|
|
f.hover(0.2)
|
|
f.hover(0.7)
|
|
f.images[0].onload()
|
|
await flush()
|
|
assert.equal(f.art.controls.thumbnails.style.left, '650px')
|
|
f.art.thumbnails = { ...f.art.thumbnails, url: 'next.png' }
|
|
const before = { ...f.art.controls.thumbnails.style }
|
|
f.hover(0.4)
|
|
beginSource(f.art)
|
|
f.images[1].onload()
|
|
await flush()
|
|
assert.deepEqual(f.art.controls.thumbnails.style, before)
|
|
f.hover(0.5)
|
|
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(next.png)')
|
|
})
|
|
|
|
test('removing the thumbnail control releases its image and a replacement starts a new request', async (t) => {
|
|
const f = fixture(t, { scale: 0.5 })
|
|
f.hover(0.2)
|
|
f.images[0].onload()
|
|
f.callbacks[0](new Blob(['image']))
|
|
f.images[1].onload()
|
|
await flush()
|
|
releaseEntry(f.art.controls.thumbnails)
|
|
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
|
const next = { style: {} }
|
|
f.art.controls.thumbnails = next
|
|
ownEntry(f.art, next)
|
|
f.hover(0.4)
|
|
assert.equal(f.images.length, 3)
|
|
f.scope.dispose()
|
|
})
|
|
|
|
test('cancelled scaling cannot allocate a Blob URL from a late encoder callback', async (t) => {
|
|
const f = fixture(t, { scale: 0.5 })
|
|
f.hover(0.2)
|
|
f.images[0].onload()
|
|
f.scope.dispose()
|
|
f.callbacks[0](new Blob(['image']))
|
|
await flush()
|
|
assert.equal(f.images.length, 1)
|
|
assert.deepEqual(f.revoked, [])
|
|
assert.deepEqual(f.art.controls.thumbnails.style, {})
|
|
})
|
|
|
|
test('configuration replacement during rendering stops remaining stale style writes', async (t) => {
|
|
const f = fixture(t)
|
|
let background = ''
|
|
Object.defineProperty(f.art.controls.thumbnails.style, 'backgroundImage', {
|
|
get: () => background,
|
|
set(value) {
|
|
background = value
|
|
f.art.thumbnails = { ...f.art.thumbnails, url: 'new.png' }
|
|
},
|
|
})
|
|
f.hover(0.2)
|
|
f.images[0].onload()
|
|
await flush()
|
|
assert.equal(background, 'url(first.png)')
|
|
assert.equal(f.art.controls.thumbnails.style.width, undefined)
|
|
assert.equal(f.art.thumbnails.url, 'new.png')
|
|
})
|
|
|
|
test('public loadImg preserves function name, arity, original image and no-scaling inputs', async (t) => {
|
|
const f = fixture(t)
|
|
assert.equal(loadImg.name, 'loadImg')
|
|
assert.equal(loadImg.length, 2)
|
|
for (const scale of [undefined, 0, 1, Number.NaN]) {
|
|
const pending = loadImg('direct.png', scale)
|
|
const image = f.images.at(-1)
|
|
image.onload()
|
|
assert.equal(await pending, image)
|
|
assert.equal(image.src, 'direct.png')
|
|
assert.equal(image.onload, null)
|
|
assert.equal(image.onerror, null)
|
|
}
|
|
assert.equal(f.callbacks.length, 0)
|
|
})
|
|
|
|
test('public scaled loadImg transfers a successfully decoded Blob URL without revoking it', async (t) => {
|
|
const f = fixture(t)
|
|
const pending = loadImg('public.png', 0.5)
|
|
f.images[0].onload()
|
|
assert.deepEqual([f.canvas.width, f.canvas.height], [500, 300])
|
|
f.callbacks[0](new Blob(['image']))
|
|
f.images[1].onload()
|
|
assert.equal(await pending, f.images[1])
|
|
f.scope.dispose()
|
|
assert.deepEqual(f.revoked, [])
|
|
assert.equal(f.images[1].src, 'blob:thumbnail')
|
|
})
|
|
|
|
test('internal image cancellation settles undefined and detaches the image handlers', async (t) => {
|
|
const f = fixture(t)
|
|
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
|
|
const image = f.images[0]
|
|
const staleLoad = image.onload
|
|
f.scope.dispose()
|
|
assert.equal(await pending, undefined)
|
|
assert.equal(image.onload, null)
|
|
assert.equal(image.onerror, null)
|
|
assert.equal(image.src, '')
|
|
staleLoad()
|
|
assert.equal(f.callbacks.length, 0)
|
|
assert.equal(await loadThumbnailImage('closed.png', 1, f.scope), undefined)
|
|
assert.equal(f.images.length, 1)
|
|
})
|
|
|
|
test('null encoding result rejects public image loading without a callback escape', async (t) => {
|
|
const f = fixture(t)
|
|
const pending = loadImg('empty.png', 0.5)
|
|
const rejected = assert.rejects(pending, /Unable to encode thumbnail image/)
|
|
f.images[0].onload()
|
|
assert.doesNotThrow(() => f.callbacks[0](null))
|
|
await rejected
|
|
assert.equal(f.images.length, 1)
|
|
})
|
|
|
|
test('scaled image failure releases its Blob exactly once and preserves the original URL in errors', async (t) => {
|
|
const f = fixture(t)
|
|
const pending = loadThumbnailImage('failed.png', 0.5, f.scope)
|
|
const rejected = assert.rejects(pending, { message: 'Image load failed: failed.png' })
|
|
f.images[0].onload()
|
|
f.callbacks[0](new Blob(['image']))
|
|
f.images[1].onerror()
|
|
await rejected
|
|
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
|
f.scope.dispose()
|
|
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
|
})
|
|
|
|
test('image scaling preserves thrown draw errors and rejects a missing canvas context', async (t) => {
|
|
const f = fixture(t)
|
|
const failure = new Error('draw failed')
|
|
f.canvas.getContext = () => ({
|
|
drawImage() {
|
|
throw failure
|
|
},
|
|
})
|
|
const first = loadImg('first.png', 0.5)
|
|
const rejected = assert.rejects(first, error => error === failure)
|
|
assert.doesNotThrow(() => f.images[0].onload())
|
|
await rejected
|
|
f.canvas.getContext = () => null
|
|
const second = loadImg('second.png', 0.5)
|
|
const missing = assert.rejects(second, { name: 'TypeError', message: 'Canvas 2D context is unavailable' })
|
|
assert.doesNotThrow(() => f.images[1].onload())
|
|
await missing
|
|
})
|
|
|
|
test('destruction during object URL allocation revokes the URL without starting a scaled image', async (t) => {
|
|
const f = fixture(t)
|
|
t.mock.method(URL, 'createObjectURL', () => {
|
|
f.scope.dispose()
|
|
return 'blob:cancelled'
|
|
})
|
|
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
|
|
f.images[0].onload()
|
|
f.callbacks[0](new Blob(['image']))
|
|
assert.equal(await pending, undefined)
|
|
assert.deepEqual(f.revoked, ['blob:cancelled'])
|
|
assert.equal(f.images.length, 1)
|
|
})
|
|
|
|
test('destruction inside the scaled image constructor does not start a late image request', async (t) => {
|
|
const f = fixture(t)
|
|
const ImageClass = globalThis.Image
|
|
Object.defineProperty(globalThis, 'Image', { configurable: true, value: class extends ImageClass {
|
|
constructor() {
|
|
super()
|
|
if (f.images.length === 2)
|
|
f.scope.dispose()
|
|
}
|
|
} })
|
|
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
|
|
f.images[0].onload()
|
|
f.callbacks[0](new Blob(['image']))
|
|
assert.equal(await pending, undefined)
|
|
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
|
assert.equal(f.images[1].src, '')
|
|
assert.equal(f.images[1].onload, null)
|
|
})
|