267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { setImmediate } from 'node:timers/promises'
|
|
import { test, type TestContext } from 'node:test'
|
|
import { Controller } from './controller.js'
|
|
import { MediaPlayer } from '../src/utils/media-player.js'
|
|
import { buildScreenPackage } from './screen-package.js'
|
|
import { packageKey, type LocalPackage } from './package-store.js'
|
|
import type { Intent, Report } from '../src/types/screen.js'
|
|
|
|
function send(c: Controller, intent: Intent) {
|
|
c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent })
|
|
}
|
|
function testPackage(): LocalPackage {
|
|
const pkg = buildScreenPackage('https://example.test', 7, {
|
|
game: {
|
|
game: {
|
|
ID: 7,
|
|
name: 'Тест',
|
|
scenario: {
|
|
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
|
|
},
|
|
},
|
|
},
|
|
plan: {},
|
|
music: {
|
|
gamePlaylistId: 1,
|
|
playlists: [
|
|
{
|
|
ID: 1,
|
|
name: 'Музыка',
|
|
tracks: [
|
|
{ ID: 1, name: 'Первый', filename: 'one.wav', position: 0 },
|
|
{ ID: 2, name: 'Второй', filename: 'two.wav', position: 1 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
return {
|
|
formatVersion: 1,
|
|
key: packageKey(pkg.server, 7),
|
|
version: 'a'.repeat(64),
|
|
package: pkg,
|
|
files: [],
|
|
}
|
|
}
|
|
|
|
class FakeMedia extends EventTarget {
|
|
src = ''
|
|
readyState = 3
|
|
duration = 180
|
|
paused = true
|
|
muted = false
|
|
volume = 1
|
|
preload = 'auto'
|
|
seeking = false
|
|
ended = false
|
|
error: MediaError | null = null
|
|
playCalls = 0
|
|
delayPlay = false
|
|
pending: (() => void)[] = []
|
|
private position = 0
|
|
get currentTime() {
|
|
return this.position
|
|
}
|
|
set currentTime(value: number) {
|
|
this.position = value
|
|
this.ended = false
|
|
queueMicrotask(() => this.dispatchEvent(new Event('seeked')))
|
|
}
|
|
getAttribute(name: string) {
|
|
return name === 'src' ? this.src || null : null
|
|
}
|
|
removeAttribute(name: string) {
|
|
if (name === 'src') this.src = ''
|
|
}
|
|
load() {
|
|
this.error = null
|
|
this.readyState = this.src ? 3 : 0
|
|
this.position = 0
|
|
this.ended = false
|
|
}
|
|
pause() {
|
|
this.paused = true
|
|
}
|
|
play() {
|
|
this.playCalls++
|
|
if (this.delayPlay)
|
|
return new Promise<void>((resolve) =>
|
|
this.pending.push(() => {
|
|
this.paused = false
|
|
resolve()
|
|
}),
|
|
)
|
|
this.paused = false
|
|
return Promise.resolve()
|
|
}
|
|
finish() {
|
|
this.ended = true
|
|
this.paused = true
|
|
this.dispatchEvent(new Event('ended'))
|
|
}
|
|
}
|
|
async function fixture(t: TestContext) {
|
|
const audio = new FakeMedia(),
|
|
video = new FakeMedia(),
|
|
a = new FakeMedia(),
|
|
b = new FakeMedia()
|
|
const reports: Report[] = []
|
|
const c = new Controller(() => {})
|
|
c.selectPackage(testPackage())
|
|
c.output(true)
|
|
const player = new MediaPlayer(
|
|
{
|
|
audio: audio as unknown as HTMLAudioElement,
|
|
video: video as unknown as HTMLVideoElement,
|
|
music: [a as unknown as HTMLAudioElement, b as unknown as HTMLAudioElement],
|
|
},
|
|
(report) => reports.push(report),
|
|
)
|
|
t.after(() => player.destroy())
|
|
const apply = () => player.apply(structuredClone(c.state))
|
|
apply()
|
|
await setImmediate()
|
|
return { c, player, audio, video, a, b, reports, apply }
|
|
}
|
|
|
|
test('DOM: pause не возвращает позицию к последнему отчёту, seek сохраняет паузу', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.currentTime = 44.5
|
|
send(f.c, { action: 'pause', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.audio.currentTime, 44.5)
|
|
f.c.report(f.reports.at(-1)!)
|
|
send(f.c, { action: 'seek', channel: 'main', position: 90 })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.currentTime, 90)
|
|
assert.equal(f.audio.paused, true)
|
|
send(f.c, { action: 'resume', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.currentTime, 90)
|
|
assert.equal(f.audio.paused, false)
|
|
send(f.c, { action: 'replay', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.currentTime, 0)
|
|
})
|
|
test('DOM: поздний play не оживляет приостановленный или сменившийся канал', async (t) => {
|
|
const f = await fixture(t)
|
|
f.audio.delayPlay = true
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
send(f.c, { action: 'pause', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.pending.shift()!()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
send(f.c, { action: 'select', channel: 'main', sourceId: 'conclusion' })
|
|
f.apply()
|
|
await setImmediate()
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.pending.shift()!()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.video.paused, false)
|
|
assert.equal(f.reports.filter((r) => r.status === 'playing' && r.channel === 'main').length, 1)
|
|
})
|
|
test('DOM: музыка независима, только следующий трек подготовлен без воспроизведения', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const playing = [f.a, f.b].find((media) => !media.paused)!
|
|
const spare = [f.a, f.b].find((media) => media !== playing)!
|
|
assert.ok(spare.src.includes(f.c.state.catalog.playlists[0]!.tracks[1]!.source.url))
|
|
assert.equal(spare.playCalls, 0)
|
|
assert.equal(spare.muted, true)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(playing.paused, false)
|
|
assert.equal(f.audio.paused, false)
|
|
f.audio.currentTime = 27
|
|
send(f.c, { action: 'volume', channel: 'main', volume: 80 })
|
|
send(f.c, { action: 'mute', enabled: true })
|
|
f.apply()
|
|
assert.equal(f.audio.volume, 0.8)
|
|
assert.equal(f.audio.currentTime, 27)
|
|
assert.equal(f.audio.muted, true)
|
|
assert.equal(playing.muted, true)
|
|
send(f.c, { action: 'next', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(playing.paused, true)
|
|
assert.equal(spare.paused, false)
|
|
assert.equal(f.audio.paused, false)
|
|
})
|
|
test('DOM: повторный snapshot не повторяет play, новая сессия не запускает звук', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const old = structuredClone(f.c.state)
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.playCalls, 1)
|
|
f.c.output(false)
|
|
f.c.output(true)
|
|
f.apply()
|
|
await setImmediate()
|
|
f.player.apply(old)
|
|
await setImmediate()
|
|
assert.equal(f.audio.playCalls, 1)
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.audio.currentTime, 0)
|
|
})
|
|
test('DOM: ошибка файла подтверждается и не мешает другому каналу', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.dispatchEvent(new Event('error'))
|
|
assert.ok(f.reports.some((r) => r.channel === 'main' && r.status === 'error'))
|
|
assert.equal([f.a, f.b].filter((media) => !media.paused).length, 1)
|
|
})
|
|
|
|
test('DOM: быстрые play → seek → pause не теряют ещё не применённую перемотку', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
send(f.c, { action: 'seek', channel: 'main', position: 90 })
|
|
f.apply()
|
|
send(f.c, { action: 'pause', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.audio.currentTime, 90)
|
|
assert.equal(f.reports.at(-1)!.status, 'paused')
|
|
})
|
|
|
|
test('DOM: повторный play перезагружает источник после ошибки чтения', async (t) => {
|
|
const f = await fixture(t)
|
|
f.audio.error = { code: 2, message: 'Read error' } as MediaError
|
|
f.audio.dispatchEvent(new Event('error'))
|
|
f.c.report(f.reports.at(-1)!)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.error, null)
|
|
assert.equal(f.audio.paused, false)
|
|
assert.equal(f.reports.at(-1)!.status, 'playing')
|
|
})
|