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-')) t.after(() => 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, failMedia: false, expired: false, refreshExpired: false, accessExpired: false, refreshed: false, refreshCalls: 0, advanceCalls: 0, resultError: false, title: 'Дело', roles: ['organizer'], remote: { gameID: 7, 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.failMedia) return new Response(null, { status: 404 }) return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' }, }) } assert.equal(url.origin, 'https://server.test') 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(2, 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: { story: { introduction: { audio: 'https://media.test/intro.wav' }, 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') 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.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() 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, } } function objectMusic(version: number, index = 0) { return { stateVersion: String(version), playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] }, trackIndex: index, playback: 'MUSIC_PLAYBACK_PLAYING', 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, 'stopped') 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, 'paused') f.model.remote.stateVersion = '9007199254740996' await f.online.poll() assert.equal(c.state.channels.main.desired, 'playing') c.output(false) c.output(true) await f.online.poll() assert.equal(c.state.channels.main.desired, 'stopped') }) 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, 'stopped') }) 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, 'stopped') 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, 'stopped') 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, 'stopped') }) 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 new Promise((resolve) => setTimeout(resolve, 10)) assert.equal(f.model.advanceCalls, 1) assert.equal(f.controller.state.channels.music.trackIndex, 1) send(f.controller, { action: 'seek', channel: 'music', position: 2 }) f.model.remote.music = objectMusic(2, 1) 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) 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) })