This commit is contained in:
2026-10-18 03:11:10 +07:00
parent 8610f45a29
commit 3e2608eae3
23 changed files with 1460 additions and 302 deletions
+263 -19
View File
@@ -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)
})