Files
evening_detective_screen/electron/online.test.ts
T
2026-10-18 04:59:27 +07:00

1335 lines
53 KiB
TypeScript

import assert from 'node:assert/strict'
import { test, type TestContext } from 'node:test'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Controller } from './controller.js'
import { Online, type SecretStorage } from './online.js'
import { PackageStore } from './package-store.js'
import { parsePlan } from './screen-plan.js'
import { applyRemote, parseRemote, send } from './remote-state.js'
async function fixture(t: TestContext, secure = true) {
const directory = await mkdtemp(join(tmpdir(), 'screen-online-'))
let shutdown = async () => {}
t.after(async () => {
await shutdown()
await rm(directory, { recursive: true, force: true })
})
const key = randomBytes(32)
const secrets: SecretStorage = {
available: () => secure,
encrypt(value) {
assert.ok(secure)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const bytes = Buffer.concat([cipher.update(value), cipher.final()])
return Buffer.concat([iv, cipher.getAuthTag(), bytes])
},
decrypt(value) {
const cipher = createDecipheriv('aes-256-gcm', key, value.subarray(0, 12))
cipher.setAuthTag(value.subarray(12, 28))
return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]).toString()
},
}
const model = {
offline: false,
server: 'https://server.test',
introURL: 'https://media.test/intro.wav',
omitScenario: false,
omitStory: false,
musicOverride: null as Record<string, unknown> | null,
mediaHandler: null as ((url: string) => Promise<Response>) | null,
failMedia: false,
expired: false,
refreshExpired: false,
accessExpired: false,
refreshed: false,
refreshCalls: 0,
advanceCalls: 0,
resultError: false,
title: 'Дело',
roles: ['organizer'],
remote: {
gameID: 7,
introAudio: 'https://media.test/intro.wav',
mode: 2,
command: 1,
stateVersion: '9007199254740993',
soundEnabled: true,
positionMs: '0',
volume: 80,
} as Record<string, unknown>,
plan: parsePlan({ steps: [{ mode: 2 }] }),
planError: 0,
planSaves: 0,
planTransitions: 0,
planResponse: null as (() => Promise<Response>) | null,
stepResponse: null as (() => Promise<Response>) | null,
stateResponse: null as (() => Promise<Response>) | null,
}
const json = (value: unknown, status = 200) => Response.json(value, { status })
const requests: { path: string; auth: string | null }[] = []
const fetchAPI: typeof fetch = async (input, init) => {
if (model.offline) throw new Error('network secret-detail')
const url = new URL(String(input))
const auth = new Headers(init?.headers).get('Authorization')
const path = url.pathname
requests.push({ path, auth })
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' },
})
}
assert.equal(url.origin, model.server)
if (path === '/api/auth/login')
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
if (path === '/api/auth/refresh') {
model.refreshCalls++
await new Promise((resolve) => setTimeout(resolve, 5))
if (model.refreshExpired) return json({}, 401)
model.refreshed = true
return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
}
if (path === '/api/screens') return json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path === '/api/screens/state') {
assert.equal(auth, null)
if (model.stateResponse) return model.stateResponse()
return model.expired ? json({}, 401) : json({ state: model.remote })
}
if (path === '/api/screens/music/advance') {
model.advanceCalls++
return json({ state: objectMusic(3, 1) })
}
if (path === '/api/screens/results') {
if (model.resultError) return json({}, 400)
return json({
questions: [{ code: 'A', text: 'Вопрос' }],
teams: [
{
team: {
ID: 'team',
name: 'Команда',
password: 'TEAM-SECRET',
captainEmail: 'private@example.test',
applications: [{ name: 'PRIVATE' }],
},
place: 1,
totalScore: 5,
actionsCount: 2,
answers: [{ questionCode: 'A', score: 5, answer: 'ANSWER-SECRET' }],
},
],
})
}
if (auth === 'Bearer ACCESS-SECRET' && model.accessExpired && !model.refreshed)
return json({}, 401)
if (model.refreshExpired) return json({}, 401)
if (path === '/api/users/me') return json({ user: { roles: model.roles } })
if (path === '/api/games')
return json({
games: [
{ ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] },
],
})
if (path === '/api/games/7')
return json({
game: {
ID: 7,
name: model.title,
scenario: model.omitScenario
? null
: {
story: model.omitStory
? null
: {
introduction: { audio: model.introURL },
conclusion: { video: '' },
},
},
},
})
if (path === '/api/games/7/screen-plan') {
if (model.planResponse) return model.planResponse()
if (model.planError) return json({}, model.planError)
if (init?.method === 'PUT') {
model.planSaves++
model.plan = parsePlan(JSON.parse(String(init.body)))
}
return json({ plan: model.plan.steps.length ? model.plan : null })
}
if (path === '/api/games/7/screen-plan/step') {
if (model.stepResponse) return model.stepResponse()
model.planTransitions++
if (model.planError) return json({}, model.planError)
const data = JSON.parse(String(init?.body)) as { currentStep: number }
const step = model.plan.steps[data.currentStep]
if (!step) return json({}, 400)
model.plan.currentStep = data.currentStep
model.remote = {
...model.remote,
mode: step.mode,
text: step.text,
command: 0,
timerLabel: step.timerLabel,
timerDurationSeconds: step.timerDurationSeconds || 600,
timerState: 1,
timerRemainingSeconds: step.timerDurationSeconds,
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
}
return json({ plan: model.plan })
}
if (path === '/api/games/7/screens/command') {
if (model.planError) return json({}, model.planError)
const data = JSON.parse(String(init?.body)) as { command: number }
model.remote.command = data.command
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
return json({})
}
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))
model.remote.music = {
...state,
playback: body.playback,
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: [
{
ID: 1,
name: 'Музыка',
tracks: [0, 1].map((i) => ({
ID: i + 1,
name: `Трек ${i}`,
filename: `https://media.test/${i}.wav`,
position: i,
})),
},
],
})
}
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}`)
}
const store = new PackageStore(join(directory, 'packages'), {
fetch: fetchAPI,
freeBytes: async () => 1n << 40n,
wait: async () => {},
})
await store.initialize()
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',
server: 'https://server.test/',
email: 'test@example.test',
password: 'PASSWORD-SECRET',
})
const prepare = async () => {
await login()
await online.act({ action: 'download', gameId: 7 })
await online.select(store.list()[0]!.key)
await online.act({ action: 'connect' })
}
return {
directory,
store,
controller,
online,
secrets,
fetchAPI,
model,
requests,
login,
prepare,
}
}
test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => {
const f = await fixture(t)
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
})
for (const [input, expected] of [
['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'],
['http://127.0.0.1:8090', 'http://127.0.0.1:8090'],
]) {
test(`адрес входа ${input}: API и сохранённый адрес ${expected}`, async (t) => {
const f = await fixture(t, false)
f.model.server = expected!
await f.online.act({
action: 'login',
server: input!,
email: 'test@example.test',
password: 'PASSWORD-SECRET',
})
assert.equal(f.online.snapshot().authenticated, true)
assert.equal(f.online.snapshot().server, expected)
assert.ok(f.requests.some((r) => r.path === '/api/auth/login'))
await f.online.close()
const restored = new Online(
join(f.directory, 'connection'),
f.store,
new Controller(() => {}),
f.secrets,
f.fetchAPI,
)
try {
await restored.initialize()
assert.equal(restored.snapshot().server, expected)
} finally {
await restored.close()
}
})
}
function objectMusic(version: number, index = 0) {
return {
stateVersion: String(version),
playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
trackIndex: index,
playback: 2,
volume: 40,
loop: true,
}
}
test('вход, подготовка и зашифрованные секреты; после перезапуска игра и результаты работают офлайн', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.act({ action: 'results' })
const snapshot = f.online.snapshot()
assert.equal(snapshot.authenticated, true)
assert.equal(snapshot.remembered, true)
const files = await readdir(join(f.directory, 'connection'))
for (const file of files) {
const bytes = await readFile(join(f.directory, 'connection', file))
for (const secret of [
'ACCESS-SECRET',
'REFRESH-SECRET',
'SCREEN-SECRET',
'PASSWORD-SECRET',
'TEAM-SECRET',
'ANSWER-SECRET',
'private@example.test',
])
assert.equal(bytes.includes(secret), false)
}
assert.equal(JSON.stringify(snapshot).includes('SECRET'), false)
f.model.offline = true
const store = new PackageStore(join(f.directory, 'packages'))
await store.initialize()
const c = new Controller(() => {})
const online = new Online(join(f.directory, 'connection'), store, c, f.secrets, f.fetchAPI)
await online.initialize()
assert.equal(c.state.catalog.key, f.controller.state.catalog.key)
assert.equal(c.state.results?.teams[0]?.totalScore, 5)
assert.ok(c.state.results?.capturedAt)
assert.equal(c.state.outputOpen, false)
assert.equal(c.state.channels.main.desired, 'stopped')
c.output(true)
send(c, { action: 'play', channel: 'main' })
await online.poll()
assert.equal(online.state.connection, 'offline')
assert.equal(c.state.channels.main.desired, 'playing')
})
test('без хранилища ОС вход только в памяти; выход не останавливает показ', async (t) => {
const f = await fixture(t, false)
await f.prepare()
assert.equal(f.online.state.remembered, false)
await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), {
code: 'ENOENT',
})
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'main' })
await f.online.act({ action: 'logout' })
assert.equal(f.controller.state.channels.main.desired, 'playing')
assert.equal(f.online.state.screenId, null)
})
test('три параллельных запроса описания используют одно обновление токена', async (t) => {
const f = await fixture(t)
await f.login()
f.model.accessExpired = true
await f.online.act({ action: 'download', gameId: 7 })
assert.equal(f.model.refreshCalls, 1)
assert.equal(f.store.list().length, 1)
})
test('обновление запрещено при показе, ошибка сохраняет старый пакет, новая версия включается остановленной', async (t) => {
const f = await fixture(t)
await f.prepare()
const old = f.store.list()[0]!
f.controller.output(true)
await assert.rejects(f.online.act({ action: 'download', gameId: 7 }), /Закройте показ/)
f.controller.output(false)
f.model.title = 'Новое дело'
await f.online.act({ action: 'check', gameId: 7 })
assert.match(f.online.state.notice, /есть изменения/)
f.model.failMedia = true
await assert.rejects(f.online.act({ action: 'download', gameId: 7 }))
assert.equal(f.store.list()[0]?.version, old.version)
f.model.failMedia = false
await f.online.act({ action: 'download', gameId: 7 })
assert.notEqual(f.store.list()[0]?.version, old.version)
assert.equal(f.controller.state.catalog.name, 'Новое дело')
assert.equal(f.controller.state.channels.main.desired, 'stopped')
await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/)
})
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, 'playing')
f.model.remote.stateVersion = '9007199254740994'
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'playing')
send(c, { action: 'pause', channel: 'main' })
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'paused')
f.model.offline = true
await f.online.poll()
f.model.offline = false
f.model.remote.stateVersion = '9007199254740995'
await f.online.poll()
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, 'playing')
})
test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'main' })
f.model.expired = true
await f.online.poll()
assert.equal(f.online.state.connection, 'expired')
assert.equal(f.controller.state.channels.main.desired, 'playing')
f.model.expired = false
await f.online.act({ action: 'connect' })
f.model.remote.gameID = 8
await f.online.poll()
assert.equal(f.online.state.screenId, null)
assert.equal(f.controller.state.channels.main.desired, 'playing')
})
test('обновляет серверное состояние после открытия окна и не допускает параллельный опрос', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
await f.online.poll()
let release!: (response: Response) => void
f.model.stateResponse = () =>
new Promise((resolve) => {
release = resolve
})
const pending = f.online.poll()
await f.online.poll()
f.controller.output(false)
f.controller.output(true)
release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } }))
await pending
assert.equal(f.controller.state.channels.main.desired, 'playing')
})
test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.act({ action: 'results' })
const before = structuredClone(f.controller.state.results)
f.model.resultError = true
await assert.rejects(f.online.act({ action: 'results' }), /пока недоступно/)
assert.deepEqual(f.controller.state.results, before)
assert.equal(JSON.stringify(before).includes('SECRET'), false)
})
test('таймер синхронизируется один раз на версию; звук меняется без увеличения версии', () => {
let now = 0
const c = new Controller(
() => {},
() => now,
)
const previous = parseRemote({
gameID: 7,
mode: 7,
stateVersion: '1',
timerDurationSeconds: '60',
})
const next = parseRemote({
gameID: 7,
mode: 7,
stateVersion: '2',
soundEnabled: true,
timerDurationSeconds: '60',
timerRemainingSeconds: '40',
timerState: 2,
command: 8,
})
applyRemote(c, previous, next)
assert.equal(c.state.timer.remainingMs, 40000)
now += 3000
c.tick()
applyRemote(c, next, next)
assert.equal(c.state.timer.remainingMs, 37000)
send(c, { action: 'mute', enabled: true })
applyRemote(c, { ...next, sound: false }, next)
assert.equal(c.state.muted, false)
assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' }))
assert.throws(() => parseRemote({ mode: 99 }))
})
test('музыкальная громкость не возвращает старый трек; новый серверный трек использует локальные файлы', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
const previous = parseRemote({ music: objectMusic(1) })
send(f.controller, { action: 'next', channel: 'music' })
const next = parseRemote({ music: { ...objectMusic(2), volume: 70 } })
applyRemote(f.controller, previous, next)
assert.equal(f.controller.state.channels.music.trackIndex, 1)
assert.equal(f.controller.state.channels.music.volume, 70)
applyRemote(f.controller, next, parseRemote({ music: objectMusic(3, 1) }))
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
assert.throws(
() =>
applyRemote(
f.controller,
next,
parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }),
),
/Музыка ожидает подготовки/,
)
})
test('перестановка треков сохраняет старую музыку до готовности нового каталога', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'music' })
const previous = parseRemote({ music: objectMusic(1) })
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, 'playing')
assert.deepEqual(
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
[1, 2],
)
})
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
await f.online.poll()
assert.equal(f.controller.state.muted, false)
send(f.controller, { action: 'mute', enabled: true })
f.model.offline = true
await f.online.poll()
f.model.offline = false
await f.online.poll()
assert.equal(f.controller.state.muted, true)
send(f.controller, { action: 'mute', enabled: false })
f.model.offline = true
await f.online.poll()
f.model.remote.soundEnabled = false
f.model.offline = false
await f.online.poll()
await f.online.poll()
assert.equal(f.controller.state.muted, true)
assert.equal(f.controller.state.channels.main.desired, 'playing')
await f.online.act({ action: 'sound' })
f.model.remote.soundEnabled = true
await f.online.poll()
assert.equal(f.controller.state.muted, false)
f.controller.output(false)
f.controller.output(true)
f.model.remote.soundEnabled = false
await f.online.poll()
assert.equal(f.controller.state.muted, true)
assert.equal(f.controller.state.channels.main.desired, 'playing')
})
test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'main' })
f.model.accessExpired = true
f.model.refreshExpired = true
await assert.rejects(f.online.act({ action: 'games' }), /Сессия истекла/)
assert.equal(f.online.state.authenticated, false)
assert.equal(f.controller.state.channels.main.desired, 'playing')
assert.equal(f.controller.state.catalog.key, f.store.list()[0]?.key)
})
test('отмена подготовки не меняет готовую версию и снимает запрет открытия', async (t) => {
const f = await fixture(t)
await f.prepare()
const old = f.store.list()[0]!.version
const original = f.store.download.bind(f.store)
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
f.store.download = async (description, options) => {
started()
await new Promise<void>((resolve) =>
options!.signal!.addEventListener('abort', () => resolve(), { once: true }),
)
return original(description, options)
}
const pending = f.online.act({ action: 'download', gameId: 7 })
const failure = assert.rejects(pending)
await ready
assert.equal(f.online.blocksOutput, true)
await assert.rejects(f.online.select(null), /Подождите/)
await f.online.act({ action: 'cancel' })
await failure
assert.equal(f.online.blocksOutput, false)
assert.equal(f.store.list()[0]?.version, old)
})
test('одинаковая серверная сцена с новой версией отменяет локальное переключение', () => {
const c = new Controller(() => {})
const previous = parseRemote({ mode: 1, stateVersion: '1' })
send(c, { action: 'mode', mode: 8 })
applyRemote(c, previous, parseRemote({ mode: 1, stateVersion: '2' }))
assert.equal(c.state.scene.mode, 1)
send(c, { action: 'mode', mode: 8 })
applyRemote(
c,
parseRemote({ mode: 7, stateVersion: '2' }),
parseRemote({ mode: 7, stateVersion: '3', command: 8, timerState: 2 }),
)
assert.equal(c.state.scene.mode, 7)
assert.equal(c.state.timer.status, 'running')
})
test('откат версии не позволяет повторно выполнить уже полученную команду', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
await f.online.poll()
f.model.remote.stateVersion = '9007199254740995'
await f.online.poll()
send(f.controller, { action: 'pause', channel: 'main' })
f.model.remote.stateVersion = '9007199254740994'
await f.online.poll()
f.model.remote.stateVersion = '9007199254740995'
await f.online.poll()
assert.equal(f.controller.state.channels.main.desired, 'paused')
})
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()
f.model.remote.music = objectMusic(2)
f.model.remote.stateVersion = '9007199254740994'
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
const report = {
session: f.controller.state.session,
channel: 'music' as const,
operation: f.controller.state.channels.music.operation,
status: 'ended' as const,
position: 4,
duration: 4,
}
f.controller.report(report)
f.controller.report(report)
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(3, 1)
await f.online.poll()
assert.equal(f.controller.state.channels.music.position, 2)
})
test('protobuf JSON с опущенными нулями сохраняет тишину и завершённый таймер', () => {
const state = parseRemote({
mode: 7,
timerDurationSeconds: '60',
timerState: 'SCREEN_TIMER_STATE_FINISHED',
music: { stateVersion: '1' },
})
assert.equal(state.volume, 0)
assert.equal(state.music?.volume, 0)
assert.equal(state.timer.remainingMs, 0)
assert.equal(state.timer.status, 'finished')
assert.throws(() => parseRemote({ stateVersion: 9007199254740992 }))
})
test('вход без прав управления не сохраняет токены даже после их обновления', async (t) => {
const f = await fixture(t)
f.model.accessExpired = true
f.model.roles = ['player']
await assert.rejects(f.login(), /нужны права/)
assert.equal(f.online.state.authenticated, false)
assert.equal(f.online.state.remembered, false)
assert.equal(f.model.refreshCalls, 1)
await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), {
code: 'ENOENT',
})
})
test('назначение звука подтверждается сервером отдельно от локального mute и сбрасывается с сессией', async (t) => {
const f = await fixture(t)
await f.prepare()
assert.equal(f.online.state.soundEnabled, null)
f.controller.output(true)
f.model.remote.soundEnabled = false
await f.online.poll()
assert.equal(f.online.state.soundEnabled, false)
send(f.controller, { action: 'mute', enabled: false })
assert.equal(f.online.state.soundEnabled, false)
await f.online.act({ action: 'sound' })
assert.equal(f.online.state.soundEnabled, true)
f.model.remote.soundEnabled = true
await f.online.poll()
send(f.controller, { action: 'mute', enabled: true })
assert.equal(f.online.state.soundEnabled, true)
f.model.offline = true
await f.online.poll()
assert.equal(f.online.state.connection, 'offline')
assert.equal(f.online.state.soundEnabled, true)
f.model.offline = false
f.model.remote.soundEnabled = false
await f.online.poll()
assert.equal(f.online.state.soundEnabled, false)
await f.online.act({ action: 'connect' })
assert.equal(f.online.state.soundEnabled, null)
await f.online.poll()
await f.online.act({ action: 'disconnect' })
assert.equal(f.online.state.soundEnabled, null)
assert.equal(
f.requests.some((r) => r.path.endsWith('/unbind')),
false,
)
})
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
const f = await fixture(t)
await f.prepare()
const key = f.controller.state.catalog.key!
assert.equal(f.online.planStatus().editable, true)
const steps = parsePlan({
steps: [
{ mode: 'SCREEN_MODE_TEXT', text: ' Новый текст ' },
{ mode: 7, timerDurationSeconds: '60' },
],
}).steps
const sceneRevision = f.controller.state.scene.revision
await f.online.act({ action: 'plan-save', key, steps })
assert.deepEqual(f.model.plan.steps, steps)
assert.deepEqual(f.controller.state.catalog.steps, steps)
assert.equal(f.controller.state.scene.revision, sceneRevision)
assert.equal(f.model.planSaves, 1)
await assert.rejects(
f.online.act({ action: 'plan-save', key, steps: [{ mode: 8, text: ' ' }] }),
/Текст шага/,
)
await assert.rejects(
f.online.act({ action: 'plan-save', key: 'wrong', steps }),
/игра изменилась/,
)
assert.equal(f.model.planSaves, 1)
f.model.planError = 403
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Нет доступа/)
assert.deepEqual(f.controller.state.catalog.steps, steps)
f.model.planError = 0
f.model.offline = true
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Сервер недоступен/)
const restoredController = new Controller(() => {})
const restored = new Online(
join(f.directory, 'connection'),
f.store,
restoredController,
f.secrets,
f.fetchAPI,
)
await restored.initialize()
assert.deepEqual(restoredController.state.catalog.steps, steps)
assert.equal(restored.planStatus().key, key)
await restored.select(key)
f.model.offline = false
await f.online.act({ action: 'plan-save', key, steps: [] })
assert.deepEqual(f.controller.state.catalog.steps, [])
assert.deepEqual(
JSON.parse(await readFile(join(f.directory, 'connection', `plan-${key}.json`), 'utf8')).steps,
[],
)
})
test('план: онлайн-переход общий, без автозапуска и повторного применения; офлайн-переход не отправляется после возврата сети', async (t) => {
const f = await fixture(t)
await f.prepare()
const steps = parsePlan({
steps: [
{ mode: 7, timerLabel: 'Отдых', timerDurationSeconds: 60 },
{ mode: 8, text: 'Локальный шаг' },
],
}).steps
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
f.controller.output(true)
await f.online.poll()
await f.online.planCommand({ action: 'step', index: 0 })
assert.equal(f.model.planTransitions, 1)
assert.equal(f.model.plan.currentStep, 0)
assert.equal(f.controller.state.scene.stepIndex, 0)
assert.equal(f.controller.state.timer.status, 'stopped')
assert.equal(f.controller.state.channels.main.desired, 'stopped')
await f.online.planCommand({ action: 'timer-start' })
const timerRevision = f.controller.state.timer.revision
await f.online.poll()
assert.equal(f.controller.state.timer.revision, timerRevision)
assert.equal(f.controller.state.timer.status, 'running')
f.model.offline = true
await f.online.planCommand({ action: 'step', index: 1 })
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
assert.match(f.online.planStatus().notice, /локально/)
f.model.offline = false
await f.online.poll()
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
assert.equal(f.controller.state.scene.stepIndex, 1)
assert.equal(f.model.planTransitions, 1)
await f.online.planCommand({ action: 'step', index: 0 })
assert.equal(f.model.planTransitions, 2)
})
test('план: изменение на сайте между опросом и переходом применяет подтверждённое содержимое', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.refreshPlan()
f.model.plan = parsePlan({ steps: [{ mode: 8, text: 'Изменено на сайте' }] })
await f.online.planCommand({ action: 'step', index: 0 })
assert.equal(f.controller.state.scene.mode, 8)
assert.equal(f.controller.state.scene.text, 'Изменено на сайте')
assert.equal(f.controller.state.scene.stepIndex, 0)
})
test('план: поздний ответ старой игры и опрос до сохранения не заменяют новый план', async (t) => {
const f = await fixture(t)
await f.prepare()
const key = f.controller.state.catalog.key!
let resolve!: (response: Response) => void
f.model.planResponse = () =>
new Promise((done) => {
resolve = done
})
const pending = f.online.refreshPlan()
await new Promise((done) => setImmediate(done))
f.model.planResponse = null
const steps = parsePlan({ steps: [{ mode: 8, text: 'Сохранено позже' }] }).steps
await f.online.act({ action: 'plan-save', key, steps })
resolve(Response.json({ plan: { steps: [{ mode: 2 }] } }))
await pending
assert.deepEqual(f.controller.state.catalog.steps, steps)
f.model.planResponse = () =>
new Promise((done) => {
resolve = done
})
const oldGame = f.online.refreshPlan()
await new Promise((done) => setImmediate(done))
await f.online.select(null)
resolve(Response.json({ plan: { steps: [{ mode: 8, text: 'Старая игра' }] } }))
await oldGame
assert.deepEqual(f.controller.state.catalog.steps, [])
assert.equal(f.online.planStatus().editable, false)
})
test('план: локальные переходы не ждут незавершённый запрос и поздний ответ не меняет показ', async (t) => {
const f = await fixture(t)
await f.prepare()
const steps = parsePlan({
steps: [
{ mode: 8, text: 'Первый' },
{ mode: 8, text: 'Второй' },
],
}).steps
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
let finish!: (response: Response) => void
f.model.stepResponse = () =>
new Promise((resolve) => {
finish = resolve
})
const pending = f.online.planCommand({ action: 'step', index: 0 })
await new Promise((done) => setImmediate(done))
f.model.stepResponse = null
f.model.offline = true
const second = f.online.planCommand({ action: 'step', index: 1 })
assert.equal(f.controller.state.scene.text, 'Второй')
finish(Response.json({ plan: { steps: [{ mode: 8, text: 'Устаревший' }], currentStep: 0 } }))
await Promise.all([pending, second])
assert.equal(f.controller.state.scene.text, 'Второй')
assert.deepEqual(f.controller.state.catalog.steps, steps)
})
test('план: изменения с сайта загружаются при закрытом показе; удалённый переход выделяет актуальный шаг', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.plan = parsePlan({
steps: [
{ mode: 8, text: 'Один' },
{ mode: 8, text: 'Два' },
],
})
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()
f.model.plan.currentStep = 1
f.model.remote = {
...f.model.remote,
mode: 8,
text: 'Два',
command: 0,
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
}
await f.online.poll()
assert.equal(f.controller.state.scene.text, 'Два')
assert.equal(f.controller.state.scene.stepIndex, 1)
})
test('план: быстрые переходы сериализованы, неотправленные промежуточные шаги пропускаются', async (t) => {
const f = await fixture(t)
await f.prepare()
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
let finish!: (response: Response) => void
f.model.stepResponse = () =>
new Promise((resolve) => {
finish = resolve
})
const first = f.online.planCommand({ action: 'step', index: 0 })
await new Promise((done) => setImmediate(done))
const second = f.online.planCommand({ action: 'step', index: 1 })
const third = f.online.planCommand({ action: 'step', index: 2 })
assert.equal(f.controller.state.scene.text, 'Шаг 2')
assert.equal(f.model.planTransitions, 0)
f.model.stepResponse = null
finish(Response.json({ plan: { steps, currentStep: 0 } }))
await Promise.all([first, second, third])
assert.equal(f.model.planTransitions, 1)
assert.equal(f.model.plan.currentStep, 2)
assert.equal(f.controller.state.scene.stepIndex, 2)
})
test('план: конкурентный серверный таймер не теряется при подтверждении собственного перехода', async (t) => {
const f = await fixture(t)
await f.prepare()
const steps = parsePlan({
steps: [
{ mode: 7, timerLabel: 'Первый', timerDurationSeconds: 60 },
{ mode: 7, timerLabel: 'Второй', timerDurationSeconds: 120 },
],
}).steps
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
f.controller.output(true)
f.model.stateResponse = async () => {
f.model.plan.currentStep = 1
return Response.json({
state: {
gameID: 7,
mode: 7,
command: 0,
stateVersion: '99',
timerLabel: 'Второй',
timerDurationSeconds: 120,
timerRemainingSeconds: 120,
},
})
}
await f.online.planCommand({ action: 'step', index: 0 })
assert.equal(f.controller.state.timer.label, 'Второй')
assert.equal(f.controller.state.timer.durationMs, 120000)
assert.equal(f.controller.state.scene.stepIndex, 1)
})
test('план: сетевой сбой отбрасывает все ожидающие переходы, включая устаревшие промежуточные', async (t) => {
const f = await fixture(t)
await f.prepare()
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
let fail!: (error: Error) => void
f.model.stepResponse = () =>
new Promise((_resolve, reject) => {
fail = reject
})
const first = f.online.planCommand({ action: 'step', index: 0 })
await new Promise((done) => setImmediate(done))
const second = f.online.planCommand({ action: 'step', index: 1 })
const third = f.online.planCommand({ action: 'step', index: 2 })
f.model.stepResponse = null
fail(new Error('network'))
await Promise.all([first, second, third])
assert.equal(f.model.planTransitions, 0)
assert.equal(f.controller.state.scene.text, 'Шаг 2')
await f.online.planCommand({ action: 'step', index: 0 })
assert.equal(f.model.planTransitions, 1)
})
test('план: первое подтверждение собственной команды учитывает назначение звука', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
f.model.remote.soundEnabled = false
await f.online.planCommand({ action: 'step', index: 0 })
assert.equal(f.controller.state.muted, true)
assert.equal(f.online.state.soundEnabled, false)
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, 'SCREEN_MODE_INTRO_DONE')
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: string }
assert.equal(music.playback, 'MUSIC_PLAYBACK_PLAYING')
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)
})
test('истёкшая авторизация при подключении игры не маскируется сетевой ошибкой', async (t) => {
const f = await fixture(t)
await f.login()
f.model.accessExpired = true
f.model.refreshExpired = true
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
assert.equal(f.online.snapshot().authenticated, false)
})
test('некорректное серверное состояние отображает запрос и поле без содержимого ответа', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.stateResponse = async () =>
Response.json({
state: {
...f.model.remote,
music: { playlist: { tracks: [{ ID: { password: 'SECRET' } }] } },
},
})
await f.online.poll()
const message = f.online.snapshot().error
assert.match(message, /Состояние экрана · POST \/api\/screens\/state/)
assert.ok(message.includes('state.music.playlist.tracks[0].ID'))
assert.ok(message.includes('получено object'))
assert.ok(!message.includes('SECRET'))
})
for (const missing of ['scenario', 'story'] as const) {
test(`подключение без ${missing}: медиа состояния скачиваются и воспроизводятся локально`, async (t) => {
const f = await fixture(t)
f.model.omitScenario = missing === 'scenario'
f.model.omitStory = missing === 'story'
await f.login()
await f.online.act({ action: 'connect', gameId: 7 })
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(f.controller.state.outputOpen, false)
f.controller.output(true)
await f.online.poll()
const state = f.controller.state
const pkg = f.store.description(state.catalog.key!)!
assert.ok(pkg.package.media.intro)
assert.equal(state.channels.main.desired, 'playing')
assert.equal(
state.channels.main.source?.url,
`screen-app://app/packages/${pkg.key}/${pkg.package.media.intro}`,
)
assert.ok((await f.store.localFile(pkg.key, pkg.package.media.intro!)).path)
const restored = new PackageStore(join(f.directory, 'packages'))
await restored.initialize()
assert.equal(restored.description(pkg.key)?.package.media.intro, pkg.package.media.intro)
assert.ok((await restored.localFile(pkg.key, pkg.package.media.intro!)).path)
})
}
test('без истории и медиа текст, таймер и музыка работают; отсутствие вступления не мешает связи', async (t) => {
const f = await fixture(t)
f.model.omitScenario = true
f.model.plan = parsePlan({
steps: [
{ mode: 8, text: 'Текст без истории' },
{ mode: 7, timerDurationSeconds: 30, timerLabel: 'Перерыв' },
],
})
f.model.remote = {
...f.model.remote,
mode: 8,
text: 'Текст без истории',
command: 0,
introAudio: '',
music: objectMusic(1),
}
await f.login()
await f.online.act({ action: 'connect', gameId: 7 })
f.controller.output(true)
await f.online.poll()
assert.equal(f.controller.state.scene.text, 'Текст без истории')
assert.equal(f.controller.state.channels.music.desired, 'playing')
f.model.remote = {
...f.model.remote,
mode: 7,
timerDurationSeconds: 30,
timerRemainingSeconds: 30,
timerState: 1,
stateVersion: '9007199254740994',
}
await f.online.poll()
assert.equal(f.controller.state.scene.mode, 7)
assert.equal(f.controller.state.timer.durationMs, 30000)
f.model.remote = { ...f.model.remote, mode: 2, stateVersion: '9007199254740995' }
await f.online.poll()
assert.equal(f.controller.state.scene.mode, 2)
assert.equal(f.controller.state.channels.main.source, null)
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.equal(f.online.snapshot().connection, 'connected')
assert.equal(f.online.snapshot().preparation.error, '')
})