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

468 lines
16 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'
import { applyRemote, parseRemote } from './remote-state.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: pkg.assets.map((asset) => ({
id: asset.id,
size: 1,
sha256: 'b'.repeat(64),
mime: 'audio/wav',
})),
}
}
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')
})
test('DOM: следующий трек не предзагружается до завершения локального файла', async (t) => {
const f = await fixture(t)
const next = f.c.state.catalog.playlists[0]!.tracks[1]!.source
next.ready = false
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.equal(spare.src, '')
f.c.markAssetReady(next.url.split('/').at(-1)!)
f.apply()
await setImmediate()
assert.equal(spare.src, next.url)
assert.equal(spare.playCalls, 0)
send(f.c, { action: 'next', channel: 'music' })
f.apply()
await setImmediate()
assert.equal(spare.paused, false)
assert.equal(playing.paused, true)
})
test('DOM: пауза музыки затихает за секунду, snapshots не возвращают громкость и позицию', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'volume', channel: 'music', volume: 80 })
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
const media = [f.a, f.b].find((m) => !m.paused)!
media.currentTime = 44
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
send(f.c, { action: 'pause', channel: 'music' })
f.apply()
await setImmediate()
assert.equal(media.paused, false)
t.mock.timers.tick(500)
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
assert.equal(f.c.state.channels.music.volume, 80)
f.c.state.revision++
f.apply()
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
// Повтор команды тоже не продлевает затухание.
send(f.c, { action: 'pause', channel: 'music' })
f.apply()
await setImmediate()
media.currentTime = 45
t.mock.timers.tick(500)
await setImmediate()
assert.equal(media.paused, true)
assert.equal(media.volume, 0.8)
assert.equal(media.currentTime, 45)
const report = f.reports.at(-1)!
assert.equal(report.status, 'paused')
assert.equal(report.position, 45)
f.c.report(report)
assert.equal(f.c.state.channels.music.position, 45)
})
test('DOM: продолжение отменяет затухание, изменение громкости не сбрасывает его', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
const media = [f.a, f.b].find((m) => !m.paused)!
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
send(f.c, { action: 'pause', channel: 'music' })
f.apply()
await setImmediate()
t.mock.timers.tick(500)
send(f.c, { action: 'volume', channel: 'music', volume: 60 })
f.apply()
assert.ok(Math.abs(media.volume - 0.3) < 0.01)
send(f.c, { action: 'resume', channel: 'music' })
f.apply()
await setImmediate()
assert.equal(media.volume, 0.6)
assert.equal(media.paused, false)
t.mock.timers.tick(1500)
assert.equal(media.paused, false)
assert.equal(media.volume, 0.6)
})
for (const action of ['stop', 'next', 'error', 'session', 'destroy'] as const) {
test(`DOM: затухание отменяется при ${action}, поздняя пауза не затрагивает новый звук`, async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
const media = [f.a, f.b].find((m) => !m.paused)!
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
send(f.c, { action: 'pause', channel: 'music' })
f.apply()
await setImmediate()
t.mock.timers.tick(400)
if (action === 'destroy') f.player.destroy()
else if (action === 'error') {
media.error = { code: 3 } as MediaError
media.dispatchEvent(new Event('error'))
} else if (action === 'session') {
f.c.selectPackage(testPackage())
f.apply()
} else {
send(f.c, { action, channel: 'music' })
f.apply()
}
await setImmediate()
assert.equal(media.paused, true)
if (action === 'next' || action === 'stop') {
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
const playing = [f.a, f.b].find((m) => !m.paused)!
assert.ok(playing)
t.mock.timers.tick(1500)
assert.equal(playing.paused, false)
assert.equal(playing.volume, f.c.state.channels.music.volume / 100)
} else {
const reports = f.reports.length
t.mock.timers.tick(1500)
assert.equal(f.reports.length, reports)
assert.equal(f.a.paused, true)
assert.equal(f.b.paused, true)
}
})
}
for (const silent of ['muted', 'zero', 'stopped'] as const) {
test(`DOM: беззвучная музыка ставится на паузу сразу: ${silent}`, async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
const media = [f.a, f.b].find((m) => !m.paused)!
if (silent === 'muted') send(f.c, { action: 'mute', enabled: true })
if (silent === 'zero') send(f.c, { action: 'volume', channel: 'music', volume: 0 })
if (silent === 'stopped') {
send(f.c, { action: 'stop', channel: 'music' })
f.apply()
await setImmediate()
}
send(f.c, { action: 'pause', channel: 'music' })
f.apply()
await setImmediate()
assert.equal(media.paused, true)
assert.equal(f.reports.at(-1)!.status, 'paused')
})
}
for (const mode of [2, 4]) {
test(`DOM: серверная автопауза при режиме ${mode} затихает и возобновляется без остановки основного канала`, async (t) => {
const f = await fixture(t)
const music = (version: number, playback: number, positionMs: number) => ({
stateVersion: String(version), playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
trackIndex: 0, playback, positionMs, volume: 80, loop: true,
})
const playing = parseRemote({ mode: 1, stateVersion: '1', music: music(1, 2, 0) })
applyRemote(f.c, parseRemote({}), playing)
f.apply()
await setImmediate()
const media = [f.a, f.b].find((m) => !m.paused)!
media.currentTime = 12
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
const paused = parseRemote({ mode, command: 1, stateVersion: '2', music: music(2, 3, 11500) })
const seekOperation = f.c.state.channels.music.seekOperation
applyRemote(f.c, playing, paused)
assert.equal(f.c.state.channels.music.seekOperation, seekOperation)
f.apply()
await setImmediate()
assert.equal(media.paused, false)
t.mock.timers.tick(500)
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
assert.equal((mode === 2 ? f.audio : f.video).paused, false)
media.currentTime = 13
t.mock.timers.tick(500)
await setImmediate()
assert.equal(media.paused, true)
assert.equal(media.currentTime, 13)
const resumed = parseRemote({ mode: 1, stateVersion: '3', music: music(3, 2, 11500) })
applyRemote(f.c, paused, resumed)
f.apply()
await setImmediate()
assert.equal(media.paused, false)
assert.equal(media.volume, 0.8)
assert.equal(media.currentTime, 11.5)
// A separate seek in the paused state still synchronizes position.
applyRemote(f.c, resumed, parseRemote({ mode: 1, stateVersion: '4', music: music(4, 3, 14000) }))
const previous = parseRemote({ mode: 1, stateVersion: '4', music: music(4, 3, 14000) })
applyRemote(f.c, previous, parseRemote({ mode: 1, stateVersion: '5', music: music(5, 3, 15000) }))
f.apply()
await setImmediate()
assert.equal(media.currentTime, 15)
assert.equal(media.paused, true)
})
}