test(audio): [PKG-AUDIO-02] cover released behavior and real media

This commit is contained in:
Harvey Zhao committed 2026-09-12 02:08:57 +08:00
1 parent 8c0a2feed3
commit 803e87252e
18 files changed
+8519 -13

No files matched your search

+5
View File
@@ -40,3 +40,8 @@ Blob URL contents and cleanup when a controlled click fails.
See [browser/README.md](browser/README.md) for `yarn test:browser`, browser installation,
published/current combinations, media fixtures, candidate mapping and failure reports.
These tests are separate from the fast Node suite and run in the Browser playback smoke CI job.
`test/audio-track.test.js` compares source and published Audio Track 1.1.0 in all three
formats with a controlled Audio object: timing intent, offsets, source updates, rejection,
volume/rate, independent instances and cleanup. Published-only tests retain defect evidence.
See [audio validation](../refactor/audio-validation.md) for actual media tests and limitations.
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Controlled media contracts use Node's runner.
import { test } from 'node:test'
import { audioHost, audioImplementations, createAudioArt } from './helpers/audio-track.js'
const implementations = await audioImplementations()
for (const { name, factory } of implementations) {
test(`${name}: synchronous lazy installation, exposed audio and initial media properties`, (t) => {
const { art, instances } = audioHost(t)
const option = { url: 'first.aac' }
const attach = factory(option)
assert.equal(instances.length, 0)
option.url = 'before-install.aac'
const plugin = attach(art)
assert.equal(plugin.name, 'artplayerPluginAudioTrack')
assert.deepEqual(Object.keys(plugin).sort(), ['audio', 'name', 'update'])
assert.equal(plugin.audio, instances[0])
assert.deepEqual(plugin.audio.calls, [['preload', 'auto'], ['src', 'before-install.aac'], ['volume', 0.6], ['muted', false], ['playbackRate', 1.25]])
assert.equal(plugin.update({}), undefined)
assert.equal(art.muted, false, 'Plugin must not mute the main video')
})
test(`${name}: default threshold is strict and offset applies in both directions`, (t) => {
const { art } = audioHost(t)
const { audio, update } = factory({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.currentTime = 0.3
art.emit('seek')
assert.deepEqual(audio.calls, [])
art.currentTime = 0.31
art.emit('seek')
assert.deepEqual(audio.calls, [['currentTime', 0.31]])
update({ offset: 2, sync: 0 })
art.currentTime = 4
art.emit('seek')
assert.equal(audio.currentTime, 6)
update({ offset: -1 })
art.emit('seek')
assert.equal(audio.currentTime, 3)
})
test(`${name}: play synchronizes before playback and timeupdate only follows playing video`, (t) => {
const { art } = audioHost(t)
const { audio } = factory({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.currentTime = 4
art.emit('video:timeupdate')
assert.deepEqual(audio.calls, [])
art.emit('play')
assert.deepEqual(audio.calls, [['currentTime', 4], ['play']])
art.playing = true
art.currentTime = 5
art.emit('video:timeupdate')
assert.equal(audio.currentTime, 5)
art.video = null
audio.calls.length = 0
art.emit('seek')
assert.deepEqual(audio.calls, [])
})
test(`${name}: waiting pauses, playing resumes conditionally, pause always stops audio`, (t) => {
const { art } = audioHost(t)
const { audio } = factory({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.emit('video:waiting').emit('video:playing')
assert.deepEqual(audio.calls, [['pause']])
art.playing = true
art.emit('video:playing').emit('pause')
assert.deepEqual(audio.calls, [['pause'], ['play'], ['pause']])
})
test(`${name}: independent volume mute and rate changes follow the host`, (t) => {
const { art } = audioHost(t)
const { audio } = factory({ url: 'audio.aac' })(art)
art.volume = 0
art.muted = true
art.video.playbackRate = 2
art.emit('video:volumechange').emit('video:ratechange')
assert.equal(audio.volume, 0)
assert.equal(audio.muted, true)
assert.equal(audio.playbackRate, 2)
art.video = undefined
assert.equal(factory({ url: '' })(art).audio.playbackRate, 1)
})
test(`${name}: same and empty URLs do not reload; changed source preserves audio identity`, (t) => {
const { art, instances } = audioHost(t)
const plugin = factory({ url: 'audio.aac' })(art)
plugin.audio.calls.length = 0
plugin.update({ url: 'audio.aac' })
plugin.update({ url: '' })
assert.deepEqual(plugin.audio.calls, [])
assert.equal(plugin.update({ url: 'paused.aac' }), undefined)
assert.deepEqual(plugin.audio.calls, [['src', 'paused.aac']])
art.playing = true
plugin.audio.calls.length = 0
plugin.update({ url: 'playing.aac' })
assert.deepEqual(plugin.audio.calls, [['src', 'playing.aac'], ['play']])
assert.equal(plugin.audio, instances[0])
assert.equal(instances.length, 1)
})
test(`${name}: an empty initial URL stays inert until configured`, (t) => {
const { art } = audioHost(t)
const { audio, update } = factory({ url: '' })(art)
art.currentTime = 3
art.playing = true
audio.calls.length = 0
art.emit('play').emit('seek').emit('video:timeupdate').emit('video:playing')
assert.deepEqual(audio.calls, [])
update({ url: 'configured.aac' })
assert.deepEqual(audio.calls, [['src', 'configured.aac'], ['play']])
})
test(`${name}: rejection at all three play sites is observed as the original warning`, async (t) => {
const { art, setPlay, warnings } = audioHost(t)
const plugin = factory({ url: 'audio.aac' })(art)
const rejection = new Error('Controlled NotAllowedError')
setPlay(() => Promise.reject(rejection))
art.playing = true
art.emit('play')
await Promise.resolve()
art.emit('video:playing')
await Promise.resolve()
assert.equal(plugin.update({ url: 'next.aac' }), undefined)
await Promise.resolve()
assert.deepEqual(warnings, [rejection, rejection, rejection])
})
test(`${name}: destroy releases the exposed media and multiple hosts stay independent`, (t) => {
const first = audioHost(t)
const second = createAudioArt()
const left = factory({ url: 'left.aac' })(first.art)
const right = factory({ url: 'right.aac' })(second.art)
assert.notEqual(left.audio, right.audio)
left.audio.calls.length = 0
right.audio.calls.length = 0
first.art.emit('destroy')
assert.deepEqual(left.audio.calls, [['pause'], ['src', ''], ['load']])
assert.deepEqual(right.audio.calls, [])
second.art.currentTime = 2
second.art.emit('seek')
assert.equal(right.audio.currentTime, 2)
})
}
const historical = implementations.find(item => item.name === 'published-main').factory
test('published defect AUDIO-LIFE-01: retained update reloads audio after destroy', (t) => {
const { art, listeners } = audioHost(t)
const plugin = historical({ url: 'audio.aac' })(art)
art.emit('destroy')
plugin.audio.calls.length = 0
plugin.update({ url: 'revived.aac' })
assert.deepEqual(plugin.audio.calls, [['src', 'revived.aac']])
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 9)
})
test('published observation: offset update is deferred, negative target is written without clamping', (t) => {
const { art } = audioHost(t)
const plugin = historical({ url: 'audio.aac' })(art)
plugin.audio.calls.length = 0
plugin.update({ offset: -2 })
assert.deepEqual(plugin.audio.calls, [])
art.currentTime = 0
art.emit('seek')
assert.deepEqual(plugin.audio.calls, [['currentTime', -2]])
})
+5
View File
@@ -1,5 +1,10 @@
# Browser regression entry
Audio Track tests use the source and frozen 1.1.0 plugin on both cores. The local AAC fixture
has a pinned hash and generation command in media/audio-tone.json and uses the existing Range
server. The suite retains published cleanup/native-pause defects as explicit observations.
See [audio validation](../../refactor/audio-validation.md) for passed scope and device limitations.
Use the pinned Node and Yarn versions, then run:
```sh
+206
View File
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let publishedCode
let sourceCode
let evidence
const samples = 16000 * 16
const tone = fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))
const toneManifest = JSON.parse(fs.readFileSync(new URL('./media/audio-tone.json', import.meta.url)))
const wav = Buffer.alloc(44 + samples * 2)
wav.write('RIFF', 0)
wav.writeUInt32LE(wav.length - 8, 4)
wav.write('WAVEfmt ', 8)
wav.writeUInt32LE(16, 16)
wav.writeUInt16LE(1, 20)
wav.writeUInt16LE(1, 22)
wav.writeUInt32LE(16000, 24)
wav.writeUInt32LE(32000, 28)
wav.writeUInt16LE(2, 32)
wav.writeUInt16LE(16, 34)
wav.write('data', 36)
wav.writeUInt32LE(samples * 2, 40)
for (let index = 0; index < samples; index++)
wav.writeInt16LE(index % 80 < 40 ? 2000 : -2000, 44 + index * 2)
test.beforeAll(async () => {
assert.equal(hash(tone), toneManifest.sha256)
assert.equal(tone.length, toneManifest.bytes)
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Audio source tests cannot represent installed artifacts')
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
const member = 'package/dist/artplayer-plugin-audio-track.js'
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
publishedCode = bytes.toString()
sourceCode = await compilePackage(release.name, 'umd')
evidence = { release, sourceSHA256: hash(sourceCode), audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source browser baseline; no physical device or installed package acceptance' }
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => {
const audio = window.audioPlugin?.audio
const video = window.art?.video
const media = element => element && ({ src: element.currentSrc, attribute: element.getAttribute('src'), time: element.currentTime, paused: element.paused, readyState: element.readyState, networkState: element.networkState, error: element.error?.code, rate: element.playbackRate, seekable: Array.from({ length: element.seekable.length }, (_, index) => [element.seekable.start(index), element.seekable.end(index)]) })
return { audio: media(audio), video: media(video), events: window.audioEvents, warnings: window.audioWarnings }
}).catch(error => ({ error: error.message }))
await testInfo.attach('audio-state', { contentType: 'application/json', body: JSON.stringify(state) })
})
async function openAudio(page, core, plugin, testInfo, url = '/test/audio-tone.m4a?source=first') {
await testInfo.attach('audio-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode })
await page.evaluate((url) => {
window.audioEvents = []
window.audioWarnings = []
const warn = console.warn.bind(console)
console.warn = (...args) => {
window.audioWarnings.push(args.map(String))
warn(...args)
}
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
})
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
document.querySelector('#play').onclick = () => window.art.play()
}, url)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
if (url)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBeGreaterThanOrEqual(2)
}
test('native WAV diagnostic isolates sample decoding from ArtPlayer and plugin', async ({ page }, testInfo) => {
await page.route('**/native-wave.wav', route => route.fulfill({ contentType: 'audio/wav', body: wav }))
await page.goto('/test/player.html')
await page.evaluate(() => {
window.nativeAudio = new Audio()
window.nativeAudio.muted = true
window.nativeAudio.src = '/native-wave.wav'
})
await expect.poll(() => page.evaluate(() => window.nativeAudio.readyState >= 2 || Boolean(window.nativeAudio.error))).toBe(true)
const state = await page.evaluate(() => ({ canPlay: window.nativeAudio.canPlayType('audio/wav'), readyState: window.nativeAudio.readyState, error: window.nativeAudio.error?.code || null, playerCreated: Boolean(window.art) }))
expect(state.playerCreated).toBe(false)
await testInfo.attach('native-wav-capability', { contentType: 'application/json', body: JSON.stringify(state) })
if (!state.error) {
await page.evaluate(() => window.nativeAudio.play())
await expect.poll(() => page.evaluate(() => window.nativeAudio.currentTime)).toBeGreaterThan(0.2)
}
else {
expect(state.error).toBe(4)
}
await page.evaluate(() => {
window.nativeAudio.pause()
window.nativeAudio.removeAttribute('src')
window.nativeAudio.load()
})
})
test('published AUDIO-LIFE-01: empty src cleanup produces a native media error', async ({ page }, testInfo) => {
await openAudio(page, 'published', 'published', testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.error?.code)).toBe(4)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
expect(await page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
})
for (const core of ['published', 'candidate']) {
test(`${core} core + published audio: native pause and end leave external audio playing`, async ({ page }, testInfo) => {
await openAudio(page, core, 'published', testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => window.art.video.pause())
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(false)
await page.evaluate(() => {
window.art.seek = 7.8
window.art.play()
})
await expect.poll(() => page.evaluate(() => window.art.video.ended)).toBe(true)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(false)
await testInfo.attach('published-native-pause-end', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ videoEnded: window.art.video.ended, videoPaused: window.art.video.paused, audioPaused: window.audioPlugin.audio.paused, audioTime: window.audioPlugin.audio.currentTime }))) })
await page.evaluate(() => window.art.destroy())
})
for (const plugin of ['published', 'source']) {
test(`${core} core + ${plugin} audio: decoded play, offset seek, rate, source and cleanup`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.3)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
expect(await page.evaluate(() => window.audioPlugin.audio.duration)).toBe(16)
// Windows WebKit may expose layout size; decoded pixels are the playback evidence.
const pixel = await page.evaluate(() => {
const canvas = document.createElement('canvas')
canvas.width = 80
canvas.height = 45
const context = canvas.getContext('2d')
context.drawImage(window.art.video, 0, 0, 80, 45)
return [...context.getImageData(30, 2, 1, 1).data]
})
expect(pixel[0]).toBeGreaterThan(180)
expect(pixel[1]).toBeGreaterThan(180)
expect(pixel[2]).toBeLessThan(80)
await testInfo.attach('decoded-frame-pixel', { contentType: 'application/json', body: JSON.stringify(pixel) })
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.seekable.length && window.audioPlugin.audio.seekable.end(0))).toBeGreaterThan(2.25)
await page.evaluate(() => {
window.art.pause()
window.audioPlugin.update({ offset: 0.25 })
window.art.seek = 2
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(2.25, 1)
await page.evaluate(() => {
window.art.playbackRate = 1.5
window.art.volume = 0.4
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.playbackRate)).toBe(1.5)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.volume)).toBeCloseTo(0.4)
await page.locator('#play').click()
await page.evaluate(() => {
window.originalAudio = window.audioPlugin.audio
window.audioPlugin.update({ url: '/test/audio-tone.m4a?source=second' })
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentSrc)).toContain('source=second')
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(false)
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime - 0.25))).toBeLessThan(0.5)
expect(await page.evaluate(() => window.originalAudio === window.audioPlugin.audio)).toBe(true)
await page.evaluate(() => {
window.art.pause()
window.art.destroy()
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
expect(await page.locator('.art-video-player').count()).toBe(0)
})
test(`${core} core + ${plugin} audio: real load error can recover with a new source`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo, '')
await page.route('**/audio-fixture/broken.m4a', route => route.fulfill({ status: 503, body: 'Intentional media failure' }))
await page.evaluate(() => window.audioPlugin.update({ url: '/audio-fixture/broken.m4a' }))
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.error?.code)).toBe(4)
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
await page.evaluate(() => window.audioPlugin.update({ url: '/test/audio-tone.m4a?source=recovered' }))
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBeGreaterThanOrEqual(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.3)
expect(await page.evaluate(() => window.audioPlugin.audio.error)).toBeNull()
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
})
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"file": "audio-tone.m4a",
"sha256": "83e6672b880b5d1c97093b1e26bdc40221c0b279615f40e99dbf9d2ade7b4d56",
"bytes": 132436,
"command": "ffmpeg -hide_banner -loglevel error -f lavfi -i sine=frequency=440:sample_rate=48000:duration=16 -ac 1 -c:a aac -b:a 64k -map_metadata -1 -movflags +faststart test/browser/media/audio-tone.m4a",
"ffmpeg": "ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers",
"probe": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "aac",
"codec_type": "audio",
"sample_rate": "48000",
"channels": 1
}
],
"format": {
"duration": "16.000000"
}
}
}
Binary file not shown.
+2 -1
View File
@@ -59,8 +59,9 @@ for (const extension of ['mp4', 'webm']) {
const name = `test/browser/media/pattern.${extension}`
add(`/test/pattern.${extension}`, fs.readFileSync(path.join(workspace, name)), { kind: 'generated-media', file: name })
}
add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/media/audio-tone.m4a')), { kind: 'generated-media', file: 'test/browser/media/audio-tone.m4a' })
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
add('/test/legacy-safe-area.js', fs.readFileSync(path.join(workspace, 'test/helpers/legacy-safe-area.js')), { kind: 'frozen-own-source', file: 'test/helpers/legacy-safe-area.js', commit: 'ccf77c4e' })
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import vm from 'node:vm'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { loadPackage } from './load.js'
export async function audioImplementations() {
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
const archive = await ensureArchive(release)
const implementations = [{ name: 'source', factory: (await loadPackage(release.name)).default }]
for (const format of ['main', 'legacy', 'module']) {
const member = `package/${release.manifest[format].replace(/^\.\//, '')}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), release.files[member])
let factory
if (format === 'module') {
factory = (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
}
else {
const context = {
module: { exports: {} },
Audio: function Audio() {
return new globalThis.Audio()
},
console,
}
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
factory = context.module.exports
}
implementations.push({ name: `published-${format}`, factory })
}
return implementations
}
// Records plugin intent only. It does not decode media or emulate browser timing.
export function audioHost(t) {
const instances = []
const warnings = []
let play = () => Promise.resolve()
class Audio {
constructor() {
instances.push(this)
this.calls = []
this.paused = true
for (const [name, initial] of Object.entries({ src: '', preload: '', currentTime: 0, playbackRate: 1, volume: 1, muted: false })) {
let value = initial
Object.defineProperty(this, name, {
get: () => value,
set: (next) => {
this.calls.push([name, next])
value = next
},
})
}
}
play() {
this.calls.push(['play'])
return play(this)
}
pause() { this.calls.push(['pause']) }
load() { this.calls.push(['load']) }
}
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Audio')
Object.defineProperty(globalThis, 'Audio', { configurable: true, value: Audio })
t.after(() => descriptor ? Object.defineProperty(globalThis, 'Audio', descriptor) : Reflect.deleteProperty(globalThis, 'Audio'))
t.mock.method(console, 'warn', error => warnings.push(error))
return { ...createAudioArt(), instances, warnings, setPlay: callback => play = callback }
}
export function createAudioArt() {
const listeners = new Map()
const art = {
video: { playbackRate: 1.25 },
currentTime: 0,
playing: false,
volume: 0.6,
muted: false,
on(event, callback) {
if (!listeners.has(event))
listeners.set(event, new Set())
listeners.get(event).add(callback)
return art
},
off(event, callback) {
listeners.get(event)?.delete(callback)
return art
},
emit(event, ...args) {
for (const callback of [...(listeners.get(event) || [])])
callback(...args)
return art
},
}
return { art, listeners }
}