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