408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
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')
|
|
}
|
|
})
|
|
|
|
for (const endMode of ['repeat', 'stop', 'next'] as const) {
|
|
test(`локальная группа: ${endMode}, ручной выбор и остановка`, () => {
|
|
const pkg = testPackage()
|
|
pkg.package.playlists[0]!.groups = [
|
|
{ id: 1, name: 'First', position: 0, endMode },
|
|
{ id: 3, name: 'Empty', position: 1, endMode: 'repeat' },
|
|
{ id: 2, name: 'Final', position: 2, endMode: 'stop' },
|
|
]
|
|
pkg.package.playlists[0]!.tracks[0]!.groupId = 1
|
|
pkg.package.playlists[0]!.tracks[1]!.groupId = 2
|
|
const c = new Controller(() => {})
|
|
c.selectPackage(pkg)
|
|
c.output(true)
|
|
send(c, { action: 'group', channel: 'music', groupId: 1 })
|
|
assert.equal(c.state.channels.music.desired, 'playing')
|
|
assert.equal(c.state.channels.music.groupId, 1)
|
|
assert.throws(() => send(c, { action: 'group', channel: 'music', groupId: 3 }), /пуста/)
|
|
ack(c, 'music', 'ended', 4, 4)
|
|
assert.equal(c.state.channels.music.groupId, endMode === 'next' ? 2 : 1)
|
|
assert.equal(c.state.channels.music.trackIndex, endMode === 'next' ? 1 : 0)
|
|
assert.equal(c.state.channels.music.desired, endMode === 'stop' ? 'stopped' : 'playing')
|
|
send(c, { action: 'track', channel: 'music', trackIndex: 1 })
|
|
assert.equal(c.state.channels.music.groupId, 2)
|
|
send(c, { action: 'group', channel: 'music', groupId: 0 })
|
|
assert.equal(c.state.channels.music.groupId, 0)
|
|
assert.equal(c.state.channels.music.trackIndex, 0)
|
|
assert.equal(c.state.channels.music.desired, 'playing')
|
|
})
|
|
}
|