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((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' }) send(f.c, { action: 'resume', channel: 'music' }) 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) }) } for (const mode of [2, 4] as const) { test(`DOM: локальная автопауза на ${mode} затихает и продолжается с фактической позиции`, 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 = 12 t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 }) send(f.c, { action: 'mode', mode }) send(f.c, { action: 'play', channel: 'main' }) f.apply() await setImmediate() t.mock.timers.tick(500) assert.equal(media.paused, false) 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) f.c.report(f.reports.filter(report => report.channel === 'music' && report.status === 'paused').at(-1)!) send(f.c, { action: 'mode', mode: 1 }) f.apply() await setImmediate() assert.equal(media.paused, false) assert.equal(media.currentTime, 13) assert.equal(media.volume, 0.8) }) } for (const endMode of ['repeat', 'stop', 'next'] as const) { test(`DOM: предзагрузка и окончание группы ${endMode}`, async (t) => { const f = await fixture(t) const playlist = f.c.state.catalog.playlists[0]! playlist.groups = [ { id: 1, name: 'First', position: 0, endMode }, { id: 2, name: 'Final', position: 1, endMode: 'stop' }, ] playlist.tracks[0]!.groupId = 1 playlist.tracks[1]!.groupId = 2 send(f.c, { action: 'group', channel: 'music', groupId: 1 }) f.apply() await setImmediate() const active = [f.a, f.b].find((media) => !media.paused)! const spare = [f.a, f.b].find((media) => media !== active)! assert.ok(active) assert.equal( spare.src, endMode === 'stop' ? '' : playlist.tracks[endMode === 'next' ? 1 : 0]!.source.url, ) active.finish() f.c.report(f.reports.at(-1)!) f.apply() await setImmediate() assert.equal(f.c.state.channels.music.groupId, endMode === 'next' ? 2 : 1) assert.equal(f.c.state.channels.music.desired, endMode === 'stop' ? 'stopped' : 'playing') assert.equal([f.a, f.b].filter((media) => !media.paused).length, endMode === 'stop' ? 0 : 1) }) } test('DOM: отсутствующий следующий файл останавливает выбранную группу с ошибкой', async (t) => { const f = await fixture(t) const playlist = f.c.state.catalog.playlists[0]! playlist.groups = [{ id: 1, name: 'First', position: 0, endMode: 'repeat' }] playlist.tracks[0]!.groupId = 1 playlist.tracks[1]!.groupId = 1 playlist.tracks[1]!.source.ready = false send(f.c, { action: 'group', channel: 'music', groupId: 1 }) f.apply() await setImmediate() const active = [f.a, f.b].find((media) => !media.paused)! active.finish() f.c.report(f.reports.at(-1)!) f.apply() await setImmediate() const failed = [f.a, f.b].find((media) => media.src === playlist.tracks[1]!.source.url)! failed.dispatchEvent(new Event('error')) assert.equal(f.reports.at(-1)!.status, 'error') f.c.report(f.reports.at(-1)!) assert.equal(f.c.state.channels.music.groupId, 1) assert.equal(f.c.state.channels.music.desired, 'stopped') })