345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
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<script>не HTML</script> ' })
|
||
f.c.reportScene(old)
|
||
assert.equal(f.c.state.scene.status, 'pending')
|
||
assert.equal(f.c.state.scene.text, 'Привет\n<script>не HTML</script>')
|
||
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: 9 },
|
||
{ 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<typeof snapshot>) => {
|
||
d.capturedAt = 'invalid'
|
||
},
|
||
(d: ReturnType<typeof snapshot>) => {
|
||
d.teams[0]!.totalScore = NaN
|
||
},
|
||
(d: ReturnType<typeof snapshot>) => {
|
||
d.teams[0]!.answers[0]!.questionCode = 'unknown'
|
||
},
|
||
(d: ReturnType<typeof snapshot>) => {
|
||
d.teams.push(d.teams[0]!)
|
||
},
|
||
(d: ReturnType<typeof snapshot>) => {
|
||
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)
|
||
})
|