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 = { library: [ { ID: 1, name: 'Музыка', tracks: [ { ID: 1, name: 'Один' }, { ID: 2, name: 'Два' }, ], }, ], playlistError: 0, upload: null as { filename: string; data: Buffer } | null, bindings: [] as Record[], unbindError: 0, createScreenError: 0, offline: false, server: 'https://server.test', introURL: 'https://media.test/intro.wav', omitScenario: false, omitStory: false, musicOverride: null as Record | null, mediaHandler: null as ((url: string) => Promise) | 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, plan: parsePlan({ steps: [{ mode: 2 }] }), planError: 0, planSaves: 0, planTransitions: 0, planResponse: null as (() => Promise) | null, stepResponse: null as (() => Promise) | null, stateResponse: null as (() => Promise) | 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 model.createScreenError ? json({}, model.createScreenError) : json({ screenID: '12', accessToken: 'SCREEN-SECRET' }) if (path.endsWith('/unbind')) { assert.equal(auth, 'Bearer ACCESS-SECRET') return json({}, model.unbindError || 200) } 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' || path === '/api/games/8') return json({ game: { ID: Number(path.split('/').at(-1)), 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/8/screen-plan') return json({ plan: null }) if (path === '/api/games/8/music') return json({ playlists: [] }) 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 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 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')) { model.bindings.push(JSON.parse(String(init?.body))) return json({}) } if (path.endsWith('/sound')) return json({}) if (path.startsWith('/api/playlists')) { if (model.playlistError) return json({}, model.playlistError) const data = init?.body ? JSON.parse(String(init.body)) : {} if (path === '/api/playlists') { if (init?.method === 'POST') model.library.push({ ID: 2, name: data.name, tracks: [] }) return json({ playlists: model.library }) } if (path === '/api/playlists/1' && init?.method === 'PUT') model.library[0]!.name = data.name if (path === '/api/playlists/2' && init?.method === 'DELETE') model.library.pop() if (path === '/api/playlists/1/tracks/order') { const tracks = model.library[0]!.tracks model.library[0]!.tracks = data.trackIDs.map((id: number) => tracks.find((t) => t.ID === id), ) } if (path === '/api/playlists/1/tracks' && init?.method === 'POST') { assert.equal(Buffer.from(data.data, 'base64').toString(), 'ID3test') model.library[0]!.tracks.push({ ID: 3, name: data.name }) } if (path === '/api/playlists/1/tracks/3' && init?.method === 'DELETE') model.library[0]!.tracks.pop() 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, undefined, async () => model.upload, ) 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) await f.login() await f.online.act({ action: 'select-game', gameId: 7 }) const selected = f.online.snapshot() assert.equal(selected.currentGameId, 7) assert.equal(selected.screenId, null) assert.equal(selected.connection, 'disabled') assert.ok(selected.materials.length > 0) assert.ok(selected.materials.every((m) => m.status === 'ready')) assert.equal(f.model.bindings.length, 0) assert.ok(!f.requests.some((r) => r.path === '/api/screens')) const saved = JSON.parse(await readFile(join(f.directory, 'connection', 'selection.json'), 'utf8')) assert.equal(saved.key, f.controller.state.catalog.key) await f.online.act({ action: 'connect' }) assert.equal(f.online.snapshot().screenId, '12') assert.equal(f.model.bindings.at(-1)!.gameID, 7) }) test('ошибка подготовки и открытый показ не подтверждают выбор игры', async (t) => { const f = await fixture(t) await f.login() f.controller.output(true) await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 }), /Закройте показ/) f.controller.output(false) f.model.failMedia = true await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 })) assert.equal(f.controller.state.catalog.key, null) assert.equal(f.online.snapshot().currentGameId, null) assert.equal(f.model.bindings.length, 0) }) test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => { const f = await fixture(t) assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art') }) test('список материалов показывает отдельную загрузку, ошибку, повтор и кеш после перезапуска', async (t) => { const f = await fixture(t) await f.login() await f.online.act({ action: 'connect', gameId: 7 }) const key = f.controller.state.catalog.key! const materials = f.online.snapshot().materials assert.ok(materials.length >= 3) assert.ok(materials.every((m) => m.status === 'missing')) const intro = materials.find((m) => m.name === 'Вступление')! assert.ok(intro) assert.deepEqual(intro.usages, [ { groupId: 'game', groupName: 'Игра', name: 'Вступление', order: 2 }, ]) const music = materials.filter((m) => m.usages.some((u) => u.groupId.startsWith('playlist-'))) assert.equal(music.length, 2) assert.deepEqual(music.flatMap((m) => m.usages.map((u) => u.order)).sort(), [0, 1]) f.model.failMedia = true await assert.rejects(f.online.act({ action: 'download-material', key, assetId: intro.id })) assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'error') f.model.failMedia = false let release!: () => void let requested!: () => void const started = new Promise((resolve) => { requested = resolve }) f.model.mediaHandler = async () => { requested() await new Promise((resolve) => { release = resolve }) return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }) } const download = f.online.act({ action: 'download-material', key, assetId: intro.id }) await started assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'loading') release() await download assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready') assert.ok(f.online.snapshot().materials.some((m) => m.status === 'missing')) assert.equal(f.controller.state.catalog.media.find((m) => m.id === 'intro')!.ready, true) assert.deepEqual(await f.store.folders(key), [join(f.directory, 'packages', key, 'cache')]) await assert.rejects(f.store.folders('../'), /Некорректный/) await assert.rejects( f.online.act({ action: 'download-material', key: 'other', assetId: intro.id }), ) await assert.rejects(f.online.act({ action: 'download-material', key, assetId: 'unknown' })) await f.online.close() await f.store.initialize() await f.online.initialize() assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready') const file = await f.store.localFile(key, intro.id) await rm(file.path) await f.online.select(key) assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'missing') await f.online.select(null) assert.deepEqual(f.online.snapshot().materials, []) }) test('готовый пакет показывает все материалы загруженными без входа', async (t) => { const f = await fixture(t) await f.prepare() await f.online.act({ action: 'logout' }) assert.ok(f.online.snapshot().materials.length > 0) assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready')) }) test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => { const f = await fixture(t) await f.login() f.model.musicOverride = { gamePlaylistId: 1, playlists: [ { ID: 1, name: 'Общий плейлист', tracks: [{ ID: 1, name: 'Общий трек', filename: f.model.introURL, position: 0 }], }, ], } await f.online.act({ action: 'connect', gameId: 7 }) const id = f.online .snapshot() .materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!.id const materials = f.online.snapshot().materials assert.equal(materials.filter((m) => m.id === id).length, 1) assert.deepEqual( materials.find((m) => m.id === id)!.usages.map((u) => u.groupId), ['game', 'playlist-1'], ) }) for (const gameId of [7, 8]) { test(`переподключение к игре ${gameId} сначала отвязывает прежний экран`, async (t) => { const f = await fixture(t) await f.prepare() assert.equal( f.requests.some((r) => r.path.endsWith('/unbind')), false, ) const start = f.requests.length await f.online.act({ action: 'connect', gameId }) const paths = f.requests.slice(start).map((r) => r.path) assert.equal(paths[0], '/api/screens/12/unbind') assert.ok(paths.indexOf('/api/screens') > 0) assert.ok(paths.indexOf('/api/screens/12/bind') > paths.indexOf('/api/screens')) assert.equal(f.model.bindings.at(-1)!.gameID, gameId) assert.equal(f.online.snapshot().connection, 'waiting') }) } for (const status of [200, 404]) { test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => { const f = await fixture(t) await f.prepare() const key = f.controller.state.catalog.key const materials = f.online.snapshot().materials f.model.unbindError = status === 200 ? 0 : status await f.online.act({ action: 'disconnect' }) assert.equal(f.requests.at(-1)!.path, '/api/screens/12/unbind') assert.equal(f.online.snapshot().screenId, null) assert.equal(f.online.snapshot().connection, 'disabled') assert.equal(f.controller.state.catalog.key, key) assert.deepEqual(f.online.snapshot().materials, materials) const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin')))) assert.equal(saved.screen, null) const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI) t.after(() => restored.close()) await restored.initialize() assert.equal(restored.snapshot().screenId, null) assert.equal(restored.snapshot().currentGameId, 7) const count = f.requests.length await f.online.act({ action: 'disconnect' }) assert.equal(f.requests.length, count) }) } for (const failure of ['server', 'network'] as const) { test(`ошибка отключения сохраняет сессию для повтора: ${failure}`, async (t) => { const f = await fixture(t) await f.prepare() const saved = await readFile(join(f.directory, 'connection', 'credentials.bin')) f.model.unbindError = failure === 'server' ? 500 : 0 f.model.offline = failure === 'network' await assert.rejects(f.online.act({ action: 'disconnect' })) assert.equal(f.online.snapshot().screenId, '12') assert.equal(f.online.snapshot().connection, 'waiting') assert.deepEqual(await readFile(join(f.directory, 'connection', 'credentials.bin')), saved) f.model.unbindError = 0 f.model.offline = false await f.online.act({ action: 'disconnect' }) assert.equal(f.online.snapshot().screenId, null) }) } test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => { const f = await fixture(t) await f.prepare() f.model.unbindError = 500 const start = f.requests.length await assert.rejects(f.online.act({ action: 'connect' }), /Отключение экрана/) assert.equal(f.online.snapshot().screenId, '12') assert.equal( f.requests.slice(start).some((r) => r.path === '/api/screens'), false, ) f.model.unbindError = 0 await f.online.act({ action: 'connect' }) assert.equal(f.online.snapshot().connection, 'waiting') }) test('отсутствующий прежний экран не мешает переподключению', async (t) => { const f = await fixture(t) await f.prepare() f.model.unbindError = 404 await f.online.act({ action: 'connect' }) assert.equal(f.online.snapshot().connection, 'waiting') }) test('ошибка создания после отвязки оставляет экран отключённым после перезапуска', async (t) => { const f = await fixture(t) await f.prepare() f.model.createScreenError = 500 await assert.rejects(f.online.act({ action: 'connect' })) assert.equal(f.online.snapshot().connection, 'disabled') assert.equal(f.online.snapshot().screenId, null) await f.online.close() await f.online.initialize() assert.equal(f.online.snapshot().screenId, null) assert.equal(f.online.snapshot().connection, 'disabled') }) test('сброс текущей игры отключает управление, сохраняет материалы и вход', async (t) => { const f = await fixture(t) await f.prepare() const packages = f.store.list().map((pkg) => pkg.key) await f.online.select(null) assert.equal(f.controller.state.catalog.key, null) assert.equal(f.online.snapshot().currentGameId, null) assert.equal(f.online.snapshot().connection, 'disabled') assert.equal(f.online.snapshot().screenId, null) assert.equal(f.online.snapshot().authenticated, true) assert.deepEqual( f.store.list().map((pkg) => pkg.key), packages, ) await f.online.close() await f.online.initialize() assert.equal(f.controller.state.catalog.key, null) assert.equal(f.online.snapshot().currentGameId, null) }) test('статус запроса виден во время загрузки и очищается после успеха или ошибки', async (t) => { const f = await fixture(t) await f.login() const loading = f.online.act({ action: 'playlists' }) assert.equal(f.online.snapshot().busy, true) assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…') await loading assert.equal(f.online.snapshot().busyMessage, '') f.model.playlistError = 500 const failing = f.online.act({ action: 'playlists' }) assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…') await assert.rejects(failing) assert.equal(f.online.snapshot().busy, false) assert.equal(f.online.snapshot().busyMessage, '') }) test('состояние сообщает ID выбранной игры только для текущего сервера', async (t) => { const f = await fixture(t) assert.equal(f.online.snapshot().currentGameId, null) await f.login() await f.online.act({ action: 'download', gameId: 7 }) await f.online.select(f.store.list()[0]!.key) assert.equal(f.online.snapshot().currentGameId, 7) f.online.state.server = 'https://other.test' assert.equal(f.online.snapshot().currentGameId, null) }) 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((resolve) => { started = resolve }) f.store.download = async (description, options) => { started() await new Promise((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, true) 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, true) await f.online.poll() const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length await f.online.act({ action: 'disconnect' }) assert.equal(f.online.state.soundEnabled, null) assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect + 1) }) 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.mode = 1 f.model.remote.command = 0 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, 'paused') assert.equal(f.online.snapshot().connection, 'connected') assert.equal(f.online.snapshot().preparation.error, '') }) test('явное подключение включает оба канала на 100%, дальнейшие команды и reconnect сохраняют власть сервера', async (t) => { const f = await fixture(t) await f.prepare() assert.equal(f.model.bindings[0]!.initializeAudio, true) assert.equal(f.controller.state.muted, false) assert.equal(f.controller.state.channels.main.volume, 100) assert.equal(f.controller.state.channels.music.volume, 100) f.controller.output(true) f.model.remote.volume = 27 f.model.remote.soundEnabled = false await f.online.poll() assert.equal(f.controller.state.channels.main.volume, 27) assert.equal(f.controller.state.muted, true) f.model.offline = true await f.online.poll() f.model.offline = false await f.online.poll() assert.equal(f.model.bindings.length, 1) assert.equal(f.controller.state.channels.main.volume, 27) assert.equal(f.controller.state.muted, true) }) test('редактор музыки: CRUD, native MP3, порядок, валидация и конфликт не меняют текущий канал', async (t) => { const f = await fixture(t) await f.login() await f.online.act({ action: 'playlists' }) assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2) const before = structuredClone(f.controller.state.channels.music) await f.online.act({ action: 'playlist-create', name: 'Новый' }) assert.equal(f.online.state.playlists[1]!.name, 'Новый') await f.online.act({ action: 'playlist-rename', playlistId: 1, name: 'Новое имя' }) await f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [2, 1] }) assert.deepEqual( f.online.state.playlists[0]!.tracks.map((t) => t.id), [2, 1], ) assert.deepEqual(f.controller.state.channels.music, before) await assert.rejects( f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 1] }), /повторяться/, ) await assert.rejects( f.online.act({ action: 'playlist-upload', playlistId: '../etc/passwd' }), /число|Некоррект/, ) f.model.upload = { filename: 'wrong.wav', data: Buffer.from('ID3test') } await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /MP3/) f.model.upload = { filename: 'large.mp3', data: Buffer.alloc(64 * 1048576 + 1) } await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /64 МиБ/) f.model.upload = { filename: 'test.mp3', data: Buffer.from('ID3test') } await f.online.act({ action: 'playlist-upload', playlistId: 1 }) assert.equal(f.online.state.playlists[0]!.tracks.length, 3) await f.online.act({ action: 'playlist-track-delete', playlistId: 1, trackId: 3 }) await f.online.act({ action: 'playlist-delete', playlistId: 2 }) assert.equal(f.online.state.playlists.length, 1) f.model.playlistError = 409 await assert.rejects( f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 2] }), /Состав треков изменился/, ) assert.deepEqual( f.online.state.playlists[0]!.tracks.map((t) => t.id), [2, 1], ) const conflict = f.online.state.error f.model.playlistError = 0 await f.online.act({ action: 'playlists' }) assert.equal(f.online.state.error, conflict, 'background refresh preserves the action error') f.model.playlistError = 403 await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/) })