import assert from 'node:assert/strict' import { test } from 'node:test' import { Controller, parseCommand } from './controller.js' import { buildScreenPackage } from './screen-package.js' import { packageKey, type LocalPackage } from './package-store.js' import { parseResults } from './results.js' import { formatTimer, remainingTime } from '../src/utils/presentation.js' import type { Intent, ScreenMode } from '../src/types/screen.js' function fixture() { let now = 0 const c = new Controller( () => {}, () => now, ) const pkg = buildScreenPackage('https://example.test', 7, { game: { game: { ID: 7, name: 'Дело №7', scenario: { image: 'cover.png', story: { background: 'background.png', introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' }, }, }, }, }, plan: { plan: { steps: Array.from({ length: 8 }, (_, i) => ({ mode: i + 1, text: i === 7 ? 'Текст шага\nКириллица' : '', timerLabel: 'Перерыв', timerDurationSeconds: i === 6 ? '10' : '0', })), }, }, music: { gamePlaylistId: 1, playlists: [ { ID: 1, name: 'Музыка', tracks: [{ ID: 1, name: 'Трек', filename: 'one.wav', position: 0 }], }, ], }, }) const local: LocalPackage = { formatVersion: 1, key: packageKey(pkg.server, 7), version: 'a'.repeat(64), package: pkg, files: [], } c.selectPackage(local) c.output(true) const send = (intent: Intent) => c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent }) return { c, send, key: local.key, advance: (ms: number) => { now += ms }, } } test('проходит восемь шагов, подставляет локальные изображения, не запускает звук/таймер', () => { const f = fixture() assert.match( f.c.state.catalog.cover!, /^screen-app:\/\/app\/packages\/[a-f0-9]{64}\/[a-f0-9]{64}$/, ) assert.notEqual(f.c.state.catalog.cover, f.c.state.catalog.background) for (let i = 0; i < 8; i++) { f.send({ action: 'step', index: i }) assert.equal(f.c.state.scene.mode, i + 1) assert.equal(f.c.state.scene.stepIndex, i) assert.equal(f.c.state.channels.main.desired, 'stopped') assert.equal(f.c.state.timer.status, 'stopped') } assert.equal(f.c.state.scene.text, 'Текст шага\nКириллица') assert.equal(f.c.state.timer.remainingMs, 10000) assert.throws(() => f.send({ action: 'step', index: 8 }), /Шаг/) }) test('смена сцены останавливает основное медиа, музыка независима, автоматического шага нет', () => { const f = fixture() f.send({ action: 'play', channel: 'music' }) f.send({ action: 'step', index: 1 }) f.send({ action: 'play', channel: 'main' }) const c = f.c.state.channels.main f.c.report({ session: f.c.state.session, channel: 'main', operation: c.operation, status: 'ended', position: 60, duration: 60, }) assert.equal(f.c.state.scene.stepIndex, 1) assert.equal(f.c.state.scene.mode, 2) f.send({ action: 'mode', mode: 3 }) assert.equal(f.c.state.channels.main.desired, 'stopped') assert.equal(f.c.state.channels.music.desired, 'playing') assert.equal(f.c.state.scene.stepIndex, null) }) test('таймер не накапливает дрейф при редких tick, переживает смену сцены и окна', () => { const f = fixture() f.send({ action: 'timer-set', label: '', durationSeconds: 10 }) assert.equal(f.c.state.timer.label, 'Перерыв') assert.equal(f.c.state.timer.status, 'stopped') f.send({ action: 'timer-start' }) f.advance(3250) f.c.tick() assert.equal(f.c.state.timer.remainingMs, 6750) f.send({ action: 'mode', mode: 8 }) f.advance(1500) f.c.output(false) f.c.output(true) assert.equal(f.c.state.timer.remainingMs, 5250) f.advance(5250) f.c.tick() assert.equal(f.c.state.timer.status, 'finished') assert.equal(f.c.state.timer.remainingMs, 0) assert.equal(f.c.state.scene.mode, 8) assert.equal(f.c.state.channels.main.desired, 'stopped') f.advance(99999) f.c.tick() assert.equal(f.c.state.timer.remainingMs, 0) }) test('пауза/продолжение/сброс таймера используют точный остаток, шаг таймера сбрасывает его', () => { const f = fixture() f.send({ action: 'step', index: 6 }) f.send({ action: 'timer-start' }) f.advance(1234) f.send({ action: 'timer-pause' }) assert.equal(f.c.state.timer.remainingMs, 8766) f.advance(5000) f.c.tick() assert.equal(f.c.state.timer.remainingMs, 8766) f.send({ action: 'timer-resume' }) f.advance(1000) f.c.tick() assert.equal(f.c.state.timer.remainingMs, 7766) f.send({ action: 'timer-reset' }) assert.equal(f.c.state.timer.remainingMs, 10000) assert.equal(f.c.state.timer.status, 'stopped') f.send({ action: 'timer-start' }) f.advance(100) f.send({ action: 'step', index: 6 }) assert.equal(f.c.state.timer.remainingMs, 10000) assert.equal(f.c.state.timer.status, 'stopped') f.c.selectPackage(null) assert.equal(f.c.state.timer.status, 'stopped') assert.equal(f.c.state.timer.remainingMs, 300000) }) test('формат таймера и интерполяция не зависят от числа обновлений', () => { assert.equal(formatTimer(60000), '01:00') assert.equal(formatTimer(1), '00:01') assert.equal(formatTimer(-5), '00:00') const f = fixture() f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 10 }) f.send({ action: 'timer-start' }) assert.equal(remainingTime(f.c.state.timer, 11000), 0) assert.equal(remainingTime(f.c.state.timer, 1234), 8766) f.send({ action: 'timer-pause' }) assert.equal(remainingTime(f.c.state.timer, 1234), 10000) }) test('подтверждение сцены отклоняет старую ревизию и сессию, ошибка не попадает в текст показа', () => { const f = fixture() const old = { session: f.c.state.session, revision: f.c.state.scene.revision, status: 'ready' } f.send({ action: 'text', text: ' Привет\n ' }) f.c.reportScene(old) assert.equal(f.c.state.scene.status, 'pending') assert.equal(f.c.state.scene.text, 'Привет\n') const report = { session: f.c.state.session, revision: f.c.state.scene.revision, status: 'error', error: 'Нет изображения', } f.c.reportScene(report) assert.equal(f.c.state.scene.error, 'Нет изображения') assert.ok(!f.c.state.scene.text.includes('Нет изображения')) f.c.output(false) f.c.output(true) f.c.reportScene(report) assert.equal(f.c.state.scene.status, 'pending') f.c.reportScene({ session: f.c.state.session, revision: f.c.state.scene.revision, status: 'ready', }) assert.equal(f.c.state.scene.status, 'ready') assert.throws(() => f.c.reportScene({ ...old, status: 'fake' })) }) test('не допускает пустой текст и некорректные параметры таймера/режима на IPC', () => { const f = fixture() const base = { session: f.c.state.session, sequence: 1 } for (const intent of [ { action: 'text', text: '' }, { action: 'text', text: ' '.repeat(3) }, { action: 'text', text: 'x'.repeat(10001) }, { action: 'timer-set', label: '', durationSeconds: 0 }, { action: 'timer-set', label: '', durationSeconds: 1.5 }, { action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 }, { action: 'mode', mode: 9 }, { action: 'mode', mode: NaN }, { action: 'step', index: -1 }, { action: ['timer-start'] }, ]) assert.throws(() => parseCommand({ ...base, ...intent })) for (const mode of [1, 2, 3, 4, 5, 6, 7, 8] as ScreenMode[]) assert.equal(parseCommand({ ...base, action: 'mode', mode }).action, 'mode') }) function snapshot(key: string) { return { packageKey: key, capturedAt: '2026-10-17T10:00:00Z', questions: [{ code: 'К1', text: 'Вопрос' }], teams: [ { id: 'team', name: 'Детективы', place: 1, totalScore: -2, actionsCount: 4, answers: [{ questionCode: 'К1', score: -2 }], password: 'SECRET', participants: ['SECRET'], }, ], token: 'SECRET', } } test('снимок результатов ограничен текущей игрой, содержит только публичные поля и время', () => { const f = fixture() const data = snapshot(f.key) f.send({ action: 'mode', mode: 6 }) const revision = f.c.state.scene.revision f.c.setResults(data) assert.equal(f.c.state.scene.revision, revision + 1) assert.equal(f.c.state.results!.teams[0]!.totalScore, -2) assert.equal(f.c.state.results!.capturedAt, '2026-10-17T10:00:00.000Z') assert.ok(!JSON.stringify(f.c.state.results).includes('SECRET')) assert.throws(() => f.c.setResults(snapshot('a'.repeat(64))), /другой игре/) f.c.selectPackage(null) assert.equal(f.c.state.results, null) }) test('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => { const f = fixture() for (const mutate of [ (d: ReturnType) => { d.capturedAt = 'invalid' }, (d: ReturnType) => { d.teams[0]!.totalScore = NaN }, (d: ReturnType) => { d.teams[0]!.answers[0]!.questionCode = 'unknown' }, (d: ReturnType) => { d.teams.push(d.teams[0]!) }, (d: ReturnType) => { d.questions.push(d.questions[0]!) }, ]) { const data = snapshot(f.key) mutate(data) assert.throws(() => parseResults(data, f.key)) } const command = { session: f.c.state.session, sequence: 1, action: 'timer-start' } f.c.command(command) f.advance(500) f.c.command(command) f.c.tick() assert.equal(f.c.state.timer.remainingMs, 299500) }) test('аварийное закрытие сохраняет точный остаток таймера на паузе, повторное открытие не запускает его', () => { const f = fixture() f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 60 }) f.send({ action: 'timer-start' }) f.send({ action: 'play', channel: 'main' }) const oldSession = f.c.state.session f.advance(1234) f.c.output(false, true) assert.equal(f.c.state.timer.status, 'paused') assert.equal(f.c.state.timer.remainingMs, 58766) assert.equal(f.c.state.channels.main.desired, 'stopped') assert.equal(f.c.state.channels.music.desired, 'stopped') assert.notEqual(f.c.state.session, oldSession) f.advance(10000) f.c.tick() f.c.output(true) assert.equal(f.c.state.timer.status, 'paused') assert.equal(f.c.state.timer.remainingMs, 58766) f.send({ action: 'timer-resume' }) f.advance(1000) f.c.tick() assert.equal(f.c.state.timer.remainingMs, 57766) f.advance(60000) f.c.output(false, true) assert.equal(f.c.state.timer.status, 'finished') assert.equal(f.c.state.timer.remainingMs, 0) })