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