up
This commit is contained in:
+263
-19
@@ -12,7 +12,11 @@ import { applyRemote, parseRemote, send } from './remote-state.js'
|
||||
|
||||
async function fixture(t: TestContext, secure = true) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'screen-online-'))
|
||||
t.after(() => rm(directory, { recursive: true, force: true }))
|
||||
let shutdown = async () => {}
|
||||
t.after(async () => {
|
||||
await shutdown()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
const key = randomBytes(32)
|
||||
const secrets: SecretStorage = {
|
||||
available: () => secure,
|
||||
@@ -31,6 +35,9 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
const model = {
|
||||
offline: false,
|
||||
introURL: 'https://media.test/intro.wav',
|
||||
musicOverride: null as Record<string, unknown> | null,
|
||||
mediaHandler: null as ((url: string) => Promise<Response>) | null,
|
||||
failMedia: false,
|
||||
expired: false,
|
||||
refreshExpired: false,
|
||||
@@ -69,6 +76,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
assert.equal(init?.credentials, 'omit')
|
||||
if (url.origin === 'https://media.test') {
|
||||
assert.equal(auth, null)
|
||||
if (model.mediaHandler) return model.mediaHandler(url.href)
|
||||
if (model.failMedia) return new Response(null, { status: 404 })
|
||||
return new Response(Buffer.from('RIFFtest-WAVE'), {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
@@ -92,7 +100,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
if (path === '/api/screens/music/advance') {
|
||||
model.advanceCalls++
|
||||
return json({ state: objectMusic(2, 1) })
|
||||
return json({ state: objectMusic(3, 1) })
|
||||
}
|
||||
if (path === '/api/screens/results') {
|
||||
if (model.resultError) return json({}, 400)
|
||||
@@ -132,7 +140,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
name: model.title,
|
||||
scenario: {
|
||||
story: {
|
||||
introduction: { audio: 'https://media.test/intro.wav' },
|
||||
introduction: { audio: model.introURL },
|
||||
conclusion: { video: '' },
|
||||
},
|
||||
},
|
||||
@@ -175,7 +183,20 @@ async function fixture(t: TestContext, secure = true) {
|
||||
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
|
||||
return json({})
|
||||
}
|
||||
if (path === '/api/games/7/music')
|
||||
if (path === '/api/games/7/music') {
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>
|
||||
const state = objectMusic(20, Number(body.trackIndex ?? 0))
|
||||
state.playback = Number(body.playback ?? 1)
|
||||
model.remote.music = {
|
||||
...state,
|
||||
volume: body.volume,
|
||||
loop: body.loop,
|
||||
positionMs: body.positionMs,
|
||||
}
|
||||
return json({ state: model.remote.music })
|
||||
}
|
||||
if (model.musicOverride) return json(model.musicOverride)
|
||||
return json({
|
||||
gamePlaylistId: 1,
|
||||
playlists: [
|
||||
@@ -191,6 +212,19 @@ async function fixture(t: TestContext, secure = true) {
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (path === '/api/games/7/screens/mode' || path === '/api/games/7/screens/timer') {
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
model.remote = {
|
||||
...model.remote,
|
||||
mode: body.mode ?? 7,
|
||||
command: 0,
|
||||
timerLabel: body.label,
|
||||
timerDurationSeconds: body.durationSeconds,
|
||||
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
return json({})
|
||||
}
|
||||
if (path.endsWith('/bind') || path.endsWith('/sound')) return json({})
|
||||
throw new Error(`Unexpected request ${path}`)
|
||||
}
|
||||
@@ -203,6 +237,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
const controller = new Controller(() => {})
|
||||
const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI)
|
||||
await online.initialize()
|
||||
shutdown = () => online.close()
|
||||
const login = () =>
|
||||
online.act({
|
||||
action: 'login',
|
||||
@@ -234,7 +269,7 @@ function objectMusic(version: number, index = 0) {
|
||||
stateVersion: String(version),
|
||||
playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
|
||||
trackIndex: index,
|
||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||
playback: 2,
|
||||
volume: 40,
|
||||
loop: true,
|
||||
}
|
||||
@@ -324,13 +359,13 @@ test('обновление запрещено при показе, ошибка
|
||||
await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/)
|
||||
})
|
||||
|
||||
test('пропускает исходную и накопленную версии; повторная версия не отменяет локальную паузу', async (t) => {
|
||||
test('принимает исходную и накопленную версии сервера; одинаковая версия не повторяется', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const c = f.controller
|
||||
c.output(true)
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
f.model.remote.stateVersion = '9007199254740994'
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
@@ -342,14 +377,14 @@ test('пропускает исходную и накопленную верси
|
||||
f.model.offline = false
|
||||
f.model.remote.stateVersion = '9007199254740995'
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'paused')
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
f.model.remote.stateVersion = '9007199254740996'
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
c.output(false)
|
||||
c.output(true)
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => {
|
||||
@@ -369,7 +404,7 @@ test('недействительная сессия и смена серверн
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('не применяет ответ от предыдущего окна и не допускает параллельный опрос', async (t) => {
|
||||
test('обновляет серверное состояние после открытия окна и не допускает параллельный опрос', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
@@ -385,7 +420,7 @@ test('не применяет ответ от предыдущего окна и
|
||||
f.controller.output(true)
|
||||
release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } }))
|
||||
await pending
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => {
|
||||
@@ -454,11 +489,11 @@ test('музыкальная громкость не возвращает ста
|
||||
next,
|
||||
parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }),
|
||||
),
|
||||
/Музыка изменилась/,
|
||||
/Музыка ожидает подготовки/,
|
||||
)
|
||||
})
|
||||
|
||||
test('перестановка прежних треков останавливает музыку до повторной подготовки', async (t) => {
|
||||
test('перестановка треков сохраняет старую музыку до готовности нового каталога', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
@@ -467,8 +502,8 @@ test('перестановка прежних треков останавлив
|
||||
const next = parseRemote({
|
||||
music: { ...objectMusic(2), playlist: { ID: 1, tracks: [{ ID: 2 }, { ID: 1 }] } },
|
||||
})
|
||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка ожидает подготовки/)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.deepEqual(
|
||||
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
|
||||
[1, 2],
|
||||
@@ -496,7 +531,7 @@ test('назначение звука восстанавливается пос
|
||||
await f.online.poll()
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
|
||||
await f.online.act({ action: 'sound' })
|
||||
f.model.remote.soundEnabled = true
|
||||
@@ -507,7 +542,7 @@ test('назначение звука восстанавливается пос
|
||||
f.model.remote.soundEnabled = false
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => {
|
||||
@@ -601,11 +636,11 @@ test('окончание серверного трека обновляет се
|
||||
}
|
||||
f.controller.report(report)
|
||||
f.controller.report(report)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await eventually(() => f.controller.state.channels.music.trackIndex === 1, 'следующий трек готов')
|
||||
assert.equal(f.model.advanceCalls, 1)
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
||||
send(f.controller, { action: 'seek', channel: 'music', position: 2 })
|
||||
f.model.remote.music = objectMusic(2, 1)
|
||||
f.model.remote.music = objectMusic(3, 1)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.position, 2)
|
||||
})
|
||||
@@ -840,6 +875,7 @@ test('план: изменения с сайта загружаются при
|
||||
})
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.outputOpen, false)
|
||||
await f.online.refreshPlan()
|
||||
assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps)
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
@@ -945,3 +981,211 @@ test('план: первое подтверждение собственной
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
})
|
||||
|
||||
async function eventually(check: () => boolean, label: string) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (check()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
assert.fail(label)
|
||||
}
|
||||
|
||||
test('постоянная связь: подключение без пакета и подготовка при закрытом показе, сервер запускается после открытия', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
assert.equal(f.store.list().length, 0)
|
||||
await f.online.act({ action: 'connect', gameId: 7 })
|
||||
assert.equal(f.controller.state.catalog.name, 'Дело')
|
||||
const session = f.controller.state.session
|
||||
await f.online.poll()
|
||||
await eventually(
|
||||
() => !f.online.state.preparation.active && f.online.state.preparation.total > 0,
|
||||
'фоновая подготовка завершена',
|
||||
)
|
||||
assert.equal(f.online.state.connection, 'connected')
|
||||
assert.equal(f.controller.state.session, session)
|
||||
assert.equal(f.controller.state.outputOpen, false)
|
||||
const downloads = f.requests.filter((q) => q.path.endsWith('.wav')).length
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.mode, 2)
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
assert.match(f.controller.state.channels.main.source!.url, /^screen-app:/)
|
||||
const operation = f.controller.state.channels.main.operation
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.main.operation, operation)
|
||||
assert.equal(f.requests.filter((q) => q.path.endsWith('.wav')).length, downloads)
|
||||
})
|
||||
|
||||
test('дозагрузка нового плейлиста сохраняет музыку; смена текста не ждёт файл; новый трек запускается только локально', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.model.remote.music = objectMusic(1)
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
const old = f.controller.state.channels.music.source!.url
|
||||
let release!: (response: Response) => void
|
||||
let started = false
|
||||
f.model.mediaHandler = async (url) => {
|
||||
if (url.endsWith('new.wav')) {
|
||||
started = true
|
||||
return new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } })
|
||||
}
|
||||
const playlist = {
|
||||
ID: 99,
|
||||
name: 'Новый плейлист',
|
||||
tracks: [{ ID: 9, name: 'Новый', filename: 'https://media.test/new.wav' }],
|
||||
}
|
||||
const music = { ...objectMusic(2), playlist, trackIndex: 0 }
|
||||
f.model.remote.music = music
|
||||
f.model.musicOverride = { gamePlaylistId: 99, state: music, playlists: [playlist] }
|
||||
const pending = f.online.poll()
|
||||
await eventually(() => started, 'новый файл запрошен')
|
||||
assert.equal(f.controller.state.channels.music.source!.url, old)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
const channel = f.controller.state.channels.music
|
||||
const ended = f.online.musicEnded({
|
||||
session: f.controller.state.session,
|
||||
channel: 'music',
|
||||
operation: channel.operation,
|
||||
status: 'ended',
|
||||
position: 4,
|
||||
duration: 4,
|
||||
})
|
||||
await new Promise((done) => setImmediate(done))
|
||||
assert.equal(f.model.advanceCalls, 0, 'старый трек не переключает новый серверный трек')
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
mode: 8,
|
||||
text: 'Новая сцена',
|
||||
command: 0,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
const newer = f.online.poll()
|
||||
await eventually(() => f.controller.state.scene.text === 'Новая сцена', 'текст не ждёт музыку')
|
||||
release(new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }))
|
||||
await Promise.all([pending, newer, ended])
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.playlistId, 99)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
||||
assert.notEqual(f.controller.state.channels.music.source!.url, old)
|
||||
})
|
||||
|
||||
test('устаревшая дозагрузка не применяет прежнюю серверную сцену', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
const original = f.controller.state.channels.main.source!.url
|
||||
let release!: (response: Response) => void
|
||||
let started = false
|
||||
f.model.introURL = 'https://media.test/new-intro.wav'
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
introAudio: f.model.introURL,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
f.model.mediaHandler = async () => {
|
||||
started = true
|
||||
return new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
const first = f.online.poll()
|
||||
await eventually(() => started, 'загрузка нового вступления')
|
||||
assert.equal(f.controller.state.channels.main.source!.url, original)
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
mode: 8,
|
||||
text: 'Актуальный текст',
|
||||
command: 0,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
const second = f.online.poll()
|
||||
release(new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }))
|
||||
await Promise.all([first, second])
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.mode, 8)
|
||||
assert.equal(f.controller.state.scene.text, 'Актуальный текст')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
})
|
||||
|
||||
test('локальные режим, музыка и таймер публикуются на сервер без повторного сброса', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
await f.online.planCommand({ action: 'mode', mode: 3 })
|
||||
assert.equal(f.model.remote.mode, 3)
|
||||
await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 })
|
||||
assert.equal(f.model.remote.timerDurationSeconds, 60)
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
const music = f.model.remote.music as { playback: number }
|
||||
assert.equal(music.playback, 2)
|
||||
const operation = f.controller.state.channels.music.operation
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.operation, operation)
|
||||
})
|
||||
|
||||
test('конкурентная серверная музыка во время подтверждения локальной команды применяется', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
f.model.stateResponse = async () => {
|
||||
const music = { ...objectMusic(21, 1), volume: 70 }
|
||||
f.model.remote.music = music
|
||||
return Response.json({ state: f.model.remote })
|
||||
}
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
||||
assert.equal(f.controller.state.channels.music.volume, 70)
|
||||
})
|
||||
|
||||
test('подтверждение музыкальной команды учитывает замену файла у прежнего трека', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
f.model.remote.music = objectMusic(1)
|
||||
await f.online.poll()
|
||||
const old = f.controller.state.channels.music.source!.url
|
||||
f.model.stateResponse = async () => {
|
||||
const playlist = {
|
||||
ID: 1,
|
||||
name: 'Музыка',
|
||||
tracks: [{ ID: 1, name: 'Замена', filename: 'https://media.test/replaced.wav' }],
|
||||
}
|
||||
const music = { ...objectMusic(21), playlist }
|
||||
f.model.remote.music = music
|
||||
f.model.musicOverride = { gamePlaylistId: 1, state: music, playlists: [playlist] }
|
||||
return Response.json({ state: f.model.remote })
|
||||
}
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
assert.notEqual(f.controller.state.channels.music.source!.url, old)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
||||
})
|
||||
|
||||
test('публикация текста после смены игры не отправляет устаревшую мутацию', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
let release!: (response: Response) => void
|
||||
f.model.planResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const pending = f.online.planCommand({ action: 'text', text: 'Старый текст' })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
await f.online.select(null)
|
||||
release(Response.json({ plan: { steps: [] } }))
|
||||
await pending
|
||||
assert.equal(f.model.planSaves, 0)
|
||||
assert.equal(f.model.planTransitions, 0)
|
||||
assert.equal(f.controller.state.catalog.key, null)
|
||||
assert.equal(f.controller.state.scene.stepIndex, null)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user