import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Controller, parseCommand } from './controller.js'
import { buildScreenPackage, parseScreenPackage } 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: parseScreenPackage(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, [1, 2, 8, 4, 8, 6, 7, 8][i])
if (i === 2) assert.equal(f.c.state.scene.text, 'Успехов')
if (i === 4) assert.equal(f.c.state.scene.text, 'Спасибо за игру!')
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: 'text', text: 'Успехов' })
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: 3 },
{ action: 'mode', mode: 5 },
{ action: 'mode', mode: 11 },
{ action: 'mode', mode: NaN },
{ action: 'step', index: -1 },
{ action: ['timer-start'] },
])
assert.throws(() => parseCommand({ ...base, ...intent }))
for (const mode of [1, 2, 4, 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()
f.c.output(true)
f.send({ action: 'mode', mode: 6 })
const data = snapshot(f.key)
f.c.setResults(data)
const revision = f.c.state.scene.revision
f.c.reportScene({ session: f.c.state.session, revision, status: 'ready' })
data.capturedAt = '2026-10-17T10:00:05Z'
f.c.setResults(structuredClone(data))
assert.equal(f.c.state.results!.capturedAt, '2026-10-17T10:00:05.000Z')
assert.equal(f.c.state.scene.revision, revision)
assert.equal(f.c.state.scene.status, 'ready')
data.teams[0]!.totalScore = 10
f.c.setResults(data)
assert.equal(f.c.state.scene.revision, revision + 1)
assert.equal(f.c.state.results!.teams[0]!.totalScore, 10)
data.questions[0]!.text = 'Исправленный вопрос'
f.c.setResults(data)
assert.equal(f.c.state.scene.revision, revision + 2)
f.c.setResults(null)
assert.equal(f.c.state.scene.revision, revision + 3)
f.c.setResults(null)
assert.equal(f.c.state.scene.revision, revision + 3)
})
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)
})