Files
evening_detective_screen/electron/presentation.test.ts
T
2026-10-19 02:34:50 +07:00

345 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})