Files
evening_detective_screen/electron/media-player.test.ts
T
2026-10-17 19:29:49 +07:00

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')
})