import assert from 'node:assert/strict' import { test } from 'node:test' import { Controller, parseCommand } from './controller.js' import { buildScreenPackage } from './screen-package.js' import { packageKey, type LocalPackage } from './package-store.js' import { parseRange } from './range.js' import { applyRemote, parseRemote } from './remote-state.js' import type { Channel, Intent, Report } from '../src/types/screen.js' 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: 'Первый', position: 0, filename: 'one.wav' }, { ID: 2, name: 'Второй', position: 1, filename: 'two.wav' }, ], }, ], }, }) return { formatVersion: 1, key: packageKey(pkg.server, pkg.game.id), version: 'a'.repeat(64), package: pkg, files: [], } } function send(c: Controller, intent: Intent) { c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent }) } function ack( c: Controller, channel: Channel, status: Report['status'], position = 0, duration = 120, ) { c.report({ session: c.state.session, channel, operation: c.state.channels[channel].operation, status, position, duration, }) } const controller = () => { const c = new Controller(() => {}) c.output(true) return c } test('валидирует команды IPC, включая канал, номер, позицию и громкость', () => { const c = controller() const command = { session: c.state.session, sequence: 1, action: 'play', channel: 'main' } assert.deepEqual(parseCommand(command), command) for (const value of [ null, {}, { ...command, session: '../' }, { ...command, sequence: 1.5 }, { ...command, sequence: 0 }, { ...command, action: ['play'] }, { ...command, channel: '/etc/passwd' }, { ...command, action: 'seek', position: NaN }, { ...command, action: 'seek', position: -1 }, { ...command, action: 'volume', volume: 101 }, { ...command, action: 'next' }, { ...command, action: 'mute', enabled: 'true' }, ]) assert.throws(() => parseCommand(value)) assert.equal(parseCommand({ ...command, action: 'seek', position: 900 }).action, 'seek') }) test('не повторяет команды после 4096 операций, отклоняет старую сессию', () => { const c = controller() const old = { session: c.state.session, sequence: 1, action: 'play', channel: 'main' } c.command(old) for (let i = 0; i < 4100; i++) send(c, { action: 'volume', channel: 'main', volume: 35 }) const revision = c.state.revision c.command(old) assert.equal(c.state.revision, revision) c.output(false) c.output(true) const session = c.state.session c.command({ ...old, sequence: 999999 }) assert.equal(c.state.session, session) assert.equal(c.state.sequence, 0) assert.equal(c.state.channels.main.desired, 'stopped') }) test('устаревшие подтверждения не влияют на новую операцию или пакет', () => { const c = controller() send(c, { action: 'play', channel: 'main' }) const old = { session: c.state.session, channel: 'main', operation: c.state.channels.main.operation, status: 'playing', position: 9, duration: 120, } send(c, { action: 'pause', channel: 'main' }) c.report(old) assert.equal(c.state.channels.main.status, 'pending') ack(c, 'main', 'paused', 12) assert.equal(c.state.channels.main.position, 12) c.selectPackage(testPackage()) c.report(old) assert.equal(c.state.channels.main.desired, 'stopped') assert.equal(c.state.channels.main.position, 0) }) test('play/replay/stop сбрасывают позицию, resume сохраняет, seek использует длительность', () => { const c = controller() send(c, { action: 'play', channel: 'main' }) ack(c, 'main', 'playing', 42, 180) send(c, { action: 'pause', channel: 'main' }) ack(c, 'main', 'paused', 42.5, 180) send(c, { action: 'resume', channel: 'main' }) assert.equal(c.state.channels.main.position, 42.5) ack(c, 'main', 'playing', 43, 180) send(c, { action: 'seek', channel: 'main', position: 150 }) assert.equal(c.state.channels.main.position, 150) assert.equal(c.state.channels.main.desired, 'playing') send(c, { action: 'seek', channel: 'main', position: 999 }) assert.equal(c.state.channels.main.position, 180) send(c, { action: 'pause', channel: 'main' }) ack(c, 'main', 'paused', 100, 180) send(c, { action: 'seek', channel: 'main', position: 90 }) assert.equal(c.state.channels.main.desired, 'paused') send(c, { action: 'replay', channel: 'main' }) assert.equal(c.state.channels.main.position, 0) assert.equal(c.state.channels.main.desired, 'playing') send(c, { action: 'stop', channel: 'main' }) assert.equal(c.state.channels.main.position, 0) assert.equal(c.state.channels.main.desired, 'stopped') }) test('музыка независима от основного канала, mute и громкость не перематывают', () => { const c = controller() c.selectPackage(testPackage()) send(c, { action: 'play', channel: 'music' }) ack(c, 'music', 'playing', 15) send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' }) send(c, { action: 'play', channel: 'main' }) send(c, { action: 'resume', channel: 'music' }) ack(c, 'main', 'playing', 20) const operation = c.state.channels.main.operation send(c, { action: 'volume', channel: 'main', volume: 80 }) send(c, { action: 'mute', enabled: true }) assert.equal(c.state.channels.main.operation, operation) assert.equal(c.state.channels.main.position, 20) assert.equal(c.state.channels.music.desired, 'playing') assert.equal(c.state.channels.music.position, 15) assert.equal(c.state.channels.music.volume, 100) send(c, { action: 'mute', enabled: false }) assert.equal(c.state.channels.main.volume, 80) assert.throws( () => send(c, { action: 'select', channel: 'main', sourceId: 'arbitrary-file' }), /Ресурс/, ) }) test('музыка переходит вперёд ровно один раз; повторяет плейлист и сохраняет паузу', () => { const c = controller() c.selectPackage(testPackage()) send(c, { action: 'play', channel: 'music' }) ack(c, 'music', 'playing') const ended = { session: c.state.session, channel: 'music', operation: c.state.channels.music.operation, status: 'ended', position: 120, duration: 120, } c.report(ended) c.report(ended) assert.equal(c.state.channels.music.trackIndex, 1) assert.equal(c.state.channels.music.desired, 'playing') ack(c, 'music', 'playing') ack(c, 'music', 'ended', 120) assert.equal(c.state.channels.music.status, 'ended') assert.equal(c.state.channels.music.desired, 'paused') send(c, { action: 'loop', channel: 'music', enabled: true }) send(c, { action: 'next', channel: 'music' }) assert.equal(c.state.channels.music.trackIndex, 0) assert.equal(c.state.channels.music.desired, 'paused') send(c, { action: 'resume', channel: 'music' }) ack(c, 'music', 'playing') send(c, { action: 'track', channel: 'music', trackIndex: 1 }) ack(c, 'music', 'playing') ack(c, 'music', 'ended', 120) assert.equal(c.state.channels.music.trackIndex, 0) assert.equal(c.state.channels.music.desired, 'playing') }) test('ошибки и закрытие окна не возобновляют звук; замер playing записывается один раз', () => { const c = controller() send(c, { action: 'play', channel: 'main' }) ack(c, 'main', 'playing') ack(c, 'main', 'playing', 1) assert.equal(c.state.samples.length, 1) c.report({ session: c.state.session, channel: 'main', operation: c.state.channels.main.operation, status: 'error', position: 1, duration: null, error: 'Нет файла', }) assert.equal(c.state.channels.main.error, 'Нет файла') assert.equal(c.state.channels.main.desired, 'paused') c.output(false) c.output(true) assert.equal(c.state.channels.main.desired, 'stopped') assert.equal(c.state.channels.music.desired, 'stopped') assert.throws(() => c.report({ status: 'anything' })) }) test('Range: полный файл, границы, суффикс и отказ для недопустимых диапазонов', () => { assert.equal(parseRange(null, 0), null) assert.deepEqual(parseRange(null, 100), { start: 0, end: 99 }) assert.deepEqual(parseRange('bytes=20-29', 100), { start: 20, end: 29 }) assert.deepEqual(parseRange('bytes=90-', 100), { start: 90, end: 99 }) assert.deepEqual(parseRange('bytes=-10', 100), { start: 90, end: 99 }) assert.deepEqual(parseRange('bytes=90-999', 100), { start: 90, end: 99 }) for (const value of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-5', 'bytes=-', 'abc']) assert.equal(parseRange(value, 100), null) }) test('обновление порядка сохраняет текущий файл и операцию, следующий трек берётся из нового порядка', () => { const c = controller() const pkg = testPackage() c.selectPackage(pkg) send(c, { action: 'play', channel: 'music' }) ack(c, 'music', 'playing', 23) const before = structuredClone(c.state.channels.music) pkg.package.playlists[0]!.tracks.reverse() c.selectPackage(pkg, true) const after = c.state.channels.music assert.equal(after.trackIndex, 1) assert.equal(after.source?.url, before.source?.url) assert.equal(after.operation, before.operation) assert.equal(after.position, 23) assert.equal(after.desired, 'playing') send(c, { action: 'loop', channel: 'music', enabled: true }) send(c, { action: 'next', channel: 'music' }) assert.equal(c.state.channels.music.trackIndex, 0) }) for (const mode of [2, 4] as const) { test(`локальная автопауза: режим ${mode}, шаг и запуск медиа сохраняют позицию музыки`, () => { const entries: Intent[] = [ { action: 'mode', mode }, { action: 'step', index: 0 }, { action: 'select', channel: 'main', sourceId: mode === 2 ? 'intro' : 'conclusion' }, { action: 'play', channel: 'main' }, ] for (const entry of entries) { const c = controller() c.selectPackage(testPackage()) c.setPlan([{ mode, text: '', timerLabel: '', timerDurationSeconds: 30 }]) if (entry.action === 'play' && mode === 4) { send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' }) send(c, { action: 'mode', mode: 1 }) } send(c, { action: 'play', channel: 'music' }) ack(c, 'music', 'playing', 17) const seek = c.state.channels.music.seekOperation send(c, entry) assert.equal(c.state.channels.music.desired, 'paused') assert.equal(c.state.channels.music.position, 17) assert.equal(c.state.channels.music.seekOperation, seek) ack(c, 'music', 'paused', 18) send(c, { action: 'mode', mode: mode === 2 ? 4 : 2 }) assert.equal(c.state.channels.music.desired, 'paused') send(c, { action: 'text', text: 'Другой экран' }) assert.equal(c.state.channels.music.desired, 'playing') assert.equal(c.state.channels.music.position, 18) assert.equal(c.state.channels.music.seekOperation, seek) } }) } test('ручные команды музыки отменяют автопродолжение до следующего входа', () => { const commands: Intent[] = [ { action: 'pause', channel: 'music' }, { action: 'stop', channel: 'music' }, { action: 'volume', channel: 'music', volume: 40 }, { action: 'loop', channel: 'music', enabled: true }, { action: 'seek', channel: 'music', position: 20 }, { action: 'playlist', channel: 'music', playlistId: 1 }, { action: 'track', channel: 'music', trackIndex: 1 }, { action: 'next', channel: 'music' }, { action: 'resume', channel: 'music' }, { action: 'play', channel: 'music' }, { action: 'replay', channel: 'music' }, ] for (const command of commands) { const c = controller() c.selectPackage(testPackage()) send(c, { action: 'play', channel: 'music' }) send(c, { action: 'mode', mode: 2 }) send(c, command) const music = structuredClone(c.state.channels.music) send(c, { action: 'mode', mode: 4 }) send(c, { action: 'mode', mode: 1 }) assert.deepEqual(c.state.channels.music, music, command.action) send(c, { action: 'resume', channel: 'music' }) send(c, { action: 'mode', mode: 2 }) assert.equal(c.state.channels.music.desired, 'paused') } }) test('автопауза не запускает остановленную музыку и сбрасывается с сессией, игрой и ошибкой', () => { for (const initial of ['stop', 'pause'] as const) { const c = controller() c.selectPackage(testPackage()) send(c, { action: initial, channel: 'music' }) send(c, { action: 'mode', mode: 2 }) send(c, { action: 'mode', mode: 1 }) assert.equal(c.state.channels.music.desired, initial === 'stop' ? 'stopped' : 'paused') } for (const reset of ['session', 'game', 'error']) { const c = controller() c.selectPackage(testPackage()) send(c, { action: 'play', channel: 'music' }) send(c, { action: 'mode', mode: 2 }) if (reset === 'session') { c.output(false); c.output(true) } else if (reset === 'game') c.selectPackage(testPackage()) else ack(c, 'music', 'error') send(c, { action: 'mode', mode: 1 }) assert.notEqual(c.state.channels.music.desired, 'playing') } }) test('серверная синхронизация не отменяет локальную паузу; новая музыкальная команда имеет приоритет', () => { const music = { stateVersion: '1', playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] }, trackIndex: 0, playback: 2, positionMs: 10000, volume: 80, loop: true } for (const initialMode of [1, 2, 4]) { const snapshots: { mode: number; desired: string }[] = [] const c = new Controller((state) => snapshots.push({ mode: state.scene.mode, desired: state.channels.music.desired })) c.selectPackage(testPackage()) c.output(true) const start = parseRemote({ mode: initialMode, stateVersion: '1', music }) applyRemote(c, parseRemote({}), start) const intro = parseRemote({ mode: 2, stateVersion: '2', music }) applyRemote(c, start, intro) assert.equal(c.state.channels.music.desired, 'paused') ack(c, 'music', 'paused', 11) const operation = c.state.channels.music.operation applyRemote(c, intro, intro) assert.equal(c.state.channels.music.operation, operation) assert.equal(snapshots.some(s => [2, 4].includes(s.mode) && s.desired === 'playing'), false) const end = parseRemote({ mode: 1, stateVersion: '3', music }) applyRemote(c, intro, end) assert.equal(c.state.channels.music.desired, 'playing') assert.equal(c.state.channels.music.position, 11) const again = parseRemote({ mode: 4, stateVersion: '4', music }) applyRemote(c, end, again) const manualPause = parseRemote({ mode: 4, stateVersion: '4', music: { ...music, stateVersion: '2', playback: 3 } }) applyRemote(c, again, manualPause) const after = parseRemote({ mode: 1, stateVersion: '5', music: { ...music, stateVersion: '2', playback: 3 } }) applyRemote(c, manualPause, after) assert.equal(c.state.channels.music.desired, 'paused') } })