up
This commit is contained in:
+217
-3
@@ -8,7 +8,10 @@ import type {
|
||||
Intent,
|
||||
Report,
|
||||
State,
|
||||
ScreenMode,
|
||||
TimerState,
|
||||
} from '../src/types/screen.js'
|
||||
import { parseResults } from './results.js'
|
||||
import type { LocalPackage } from './package-store.js'
|
||||
|
||||
function number(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number {
|
||||
@@ -27,6 +30,34 @@ export function parseCommand(value: unknown): Command {
|
||||
let intent: Intent
|
||||
if (c.action === 'mute' && typeof c.enabled === 'boolean')
|
||||
intent = { action: 'mute', enabled: c.enabled }
|
||||
else if (c.action === 'mode' && Number.isInteger(c.mode) && number(c.mode, 1, 8))
|
||||
intent = { action: 'mode', mode: c.mode as ScreenMode }
|
||||
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
|
||||
intent = { action: 'step', index: c.index }
|
||||
else if (
|
||||
c.action === 'text' &&
|
||||
typeof c.text === 'string' &&
|
||||
c.text.trim() &&
|
||||
c.text.length <= 10000
|
||||
)
|
||||
intent = { action: 'text', text: c.text.trim() }
|
||||
else if (
|
||||
c.action === 'timer-set' &&
|
||||
typeof c.label === 'string' &&
|
||||
c.label.length <= 200 &&
|
||||
Number.isInteger(c.durationSeconds) &&
|
||||
number(c.durationSeconds, 1, 86400)
|
||||
)
|
||||
intent = {
|
||||
action: 'timer-set',
|
||||
label: c.label.trim() || 'Перерыв',
|
||||
durationSeconds: c.durationSeconds,
|
||||
}
|
||||
else if (
|
||||
typeof c.action === 'string' &&
|
||||
['timer-start', 'timer-pause', 'timer-resume', 'timer-reset'].includes(c.action)
|
||||
)
|
||||
intent = { action: c.action as 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
|
||||
else {
|
||||
if (c.channel !== 'main' && c.channel !== 'music') throw new Error('Некорректный канал')
|
||||
const channel = c.channel
|
||||
@@ -84,6 +115,9 @@ const fixtureCatalog = (): Catalog => ({
|
||||
key: null,
|
||||
name: 'Тестовый режим SCREEN-001',
|
||||
playlists: [],
|
||||
cover: null,
|
||||
background: null,
|
||||
steps: [],
|
||||
media: [
|
||||
{
|
||||
id: 'fixture-audio',
|
||||
@@ -115,6 +149,15 @@ function initialChannel(): ChannelState {
|
||||
loop: false,
|
||||
}
|
||||
}
|
||||
function initialTimer(): TimerState {
|
||||
return {
|
||||
revision: 0,
|
||||
label: 'Перерыв',
|
||||
durationMs: 300000,
|
||||
remainingMs: 300000,
|
||||
status: 'stopped',
|
||||
}
|
||||
}
|
||||
export class Controller {
|
||||
state: State = {
|
||||
session: randomUUID(),
|
||||
@@ -123,11 +166,18 @@ export class Controller {
|
||||
outputOpen: false,
|
||||
catalog: fixtureCatalog(),
|
||||
muted: false,
|
||||
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
|
||||
timer: initialTimer(),
|
||||
results: null,
|
||||
channels: { main: initialChannel(), music: initialChannel() },
|
||||
samples: [],
|
||||
}
|
||||
private measured = new Map<Channel, { operation: number; startedAt: number }>()
|
||||
constructor(private publish: (state: State) => void) {
|
||||
private timerDeadline: number | null = null
|
||||
constructor(
|
||||
private publish: (state: State) => void,
|
||||
private now: () => number = () => performance.now(),
|
||||
) {
|
||||
this.state.channels.main.source = this.state.catalog.media[0]!
|
||||
}
|
||||
private broadcast() {
|
||||
@@ -146,8 +196,10 @@ export class Controller {
|
||||
this.measured.delete(channel)
|
||||
}
|
||||
private newSession() {
|
||||
this.updateTimer()
|
||||
this.state.session = randomUUID()
|
||||
this.state.sequence = 0
|
||||
this.invalidateScene()
|
||||
for (const channel of ['main', 'music'] as const)
|
||||
this.change(channel, {
|
||||
desired: 'stopped',
|
||||
@@ -158,6 +210,7 @@ export class Controller {
|
||||
output(open: boolean) {
|
||||
this.newSession()
|
||||
this.state.outputOpen = open
|
||||
this.state.scene.status = open ? 'pending' : 'idle'
|
||||
if (!open)
|
||||
for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle'
|
||||
this.broadcast()
|
||||
@@ -182,6 +235,13 @@ export class Controller {
|
||||
key: pkg.key,
|
||||
name: pkg.package.game.name,
|
||||
media,
|
||||
cover: pkg.package.media.cover
|
||||
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.cover}`
|
||||
: null,
|
||||
background: pkg.package.media.background
|
||||
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}`
|
||||
: null,
|
||||
steps: pkg.package.steps.map((step) => ({ ...step, mode: step.mode as ScreenMode })),
|
||||
playlists: pkg.package.playlists.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -194,6 +254,17 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
this.state.catalog = catalog
|
||||
this.state.scene = {
|
||||
revision: this.state.scene.revision,
|
||||
mode: 1,
|
||||
stepIndex: null,
|
||||
text: '',
|
||||
status: 'idle',
|
||||
error: '',
|
||||
}
|
||||
this.state.timer = initialTimer()
|
||||
this.timerDeadline = null
|
||||
this.state.results = null
|
||||
this.state.channels = {
|
||||
main: { ...initialChannel(), source: catalog.media[0] ?? null },
|
||||
music: initialChannel(),
|
||||
@@ -214,6 +285,134 @@ export class Controller {
|
||||
this.newSession()
|
||||
this.broadcast()
|
||||
}
|
||||
private invalidateScene() {
|
||||
this.state.scene.revision++
|
||||
this.state.scene.status = this.state.outputOpen ? 'pending' : 'idle'
|
||||
this.state.scene.error = ''
|
||||
}
|
||||
private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) {
|
||||
this.state.scene = { ...this.state.scene, mode, stepIndex, text }
|
||||
const c = this.state.channels.main
|
||||
const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null
|
||||
const source = kind ? (this.state.catalog.media.find((s) => s.kind === kind) ?? null) : c.source
|
||||
this.change('main', {
|
||||
source,
|
||||
desired: 'stopped',
|
||||
position: 0,
|
||||
duration: source?.id === c.source?.id ? c.duration : null,
|
||||
status: source ? 'pending' : 'idle',
|
||||
})
|
||||
this.invalidateScene()
|
||||
}
|
||||
private configureTimer(label: string, seconds: number) {
|
||||
this.timerDeadline = null
|
||||
this.state.timer = {
|
||||
revision: this.state.timer.revision + 1,
|
||||
label,
|
||||
durationMs: seconds * 1000,
|
||||
remainingMs: seconds * 1000,
|
||||
status: 'stopped',
|
||||
}
|
||||
}
|
||||
private updateTimer() {
|
||||
if (this.timerDeadline === null) return false
|
||||
const t = this.state.timer
|
||||
const remaining = Math.max(0, this.timerDeadline - this.now())
|
||||
if (remaining === t.remainingMs) return false
|
||||
t.remainingMs = remaining
|
||||
t.revision++
|
||||
if (!remaining) {
|
||||
t.status = 'finished'
|
||||
this.timerDeadline = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
tick() {
|
||||
if (this.updateTimer()) this.broadcast()
|
||||
}
|
||||
setResults(value: unknown) {
|
||||
const key = this.state.catalog.key
|
||||
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
||||
const snapshot = value === null ? null : parseResults(value, key!)
|
||||
this.state.results = snapshot
|
||||
if (this.state.scene.mode === 6) this.invalidateScene()
|
||||
this.broadcast()
|
||||
}
|
||||
reportScene(value: unknown) {
|
||||
if (!value || typeof value !== 'object') throw new Error('Некорректное подтверждение сцены')
|
||||
const r = value as Record<string, unknown>
|
||||
if (
|
||||
typeof r.session !== 'string' ||
|
||||
!Number.isSafeInteger(r.revision) ||
|
||||
(r.status !== 'ready' && r.status !== 'error') ||
|
||||
(r.error !== undefined && typeof r.error !== 'string')
|
||||
)
|
||||
throw new Error('Некорректное подтверждение сцены')
|
||||
if (
|
||||
!this.state.outputOpen ||
|
||||
r.session !== this.state.session ||
|
||||
r.revision !== this.state.scene.revision
|
||||
)
|
||||
return
|
||||
const mediaError = [2, 4].includes(this.state.scene.mode) ? this.state.channels.main.error : ''
|
||||
const status = mediaError ? 'error' : r.status
|
||||
const error = String(mediaError || r.error || '').slice(0, 500)
|
||||
if (this.state.scene.status === status && this.state.scene.error === error) return
|
||||
this.state.scene.status = status
|
||||
this.state.scene.error = error
|
||||
this.broadcast()
|
||||
}
|
||||
private presentation(command: Command): boolean {
|
||||
switch (command.action) {
|
||||
case 'mode':
|
||||
this.mode(command.mode)
|
||||
return true
|
||||
case 'text':
|
||||
this.mode(8, null, command.text)
|
||||
return true
|
||||
case 'step': {
|
||||
const step = this.state.catalog.steps[command.index]
|
||||
if (!step) throw new Error('Шаг плана не найден')
|
||||
if (step.mode === 7)
|
||||
this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds)
|
||||
this.mode(step.mode, command.index, step.text)
|
||||
return true
|
||||
}
|
||||
case 'timer-set':
|
||||
this.configureTimer(command.label, command.durationSeconds)
|
||||
this.mode(7)
|
||||
return true
|
||||
case 'timer-start':
|
||||
case 'timer-pause':
|
||||
case 'timer-resume':
|
||||
case 'timer-reset': {
|
||||
this.updateTimer()
|
||||
const t = this.state.timer
|
||||
if (command.action === 'timer-reset') {
|
||||
this.configureTimer(t.label, t.durationMs / 1000)
|
||||
return true
|
||||
}
|
||||
if (command.action === 'timer-pause') {
|
||||
if (t.status === 'running') {
|
||||
this.timerDeadline = null
|
||||
t.status = 'paused'
|
||||
t.revision++
|
||||
}
|
||||
} else if (
|
||||
t.status !== 'running' &&
|
||||
(command.action === 'timer-start' || t.status === 'paused')
|
||||
) {
|
||||
if (t.remainingMs <= 0) t.remainingMs = t.durationMs
|
||||
t.status = 'running'
|
||||
t.revision++
|
||||
this.timerDeadline = this.now() + t.remainingMs
|
||||
}
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
private track(index: number) {
|
||||
const c = this.state.channels.music
|
||||
const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[index]
|
||||
@@ -243,13 +442,16 @@ export class Controller {
|
||||
const command = parseCommand(input)
|
||||
if (command.session !== this.state.session || command.sequence <= this.state.sequence) return
|
||||
if (command.action === 'mute') this.state.muted = command.enabled
|
||||
else {
|
||||
else if (this.presentation(command)) {
|
||||
/* Режимы и таймер не зависят от транспортных каналов. */
|
||||
} else if ('channel' in command) {
|
||||
const channel = command.channel
|
||||
const c = this.state.channels[channel]
|
||||
let c = this.state.channels[channel]
|
||||
switch (command.action) {
|
||||
case 'select': {
|
||||
const source = this.state.catalog.media.find((s) => s.id === command.sourceId)
|
||||
if (!source) throw new Error('Ресурс не найден в выбранном пакете')
|
||||
this.mode(source.kind === 'audio' ? 2 : 4)
|
||||
this.change(channel, {
|
||||
source,
|
||||
desired: 'stopped',
|
||||
@@ -287,6 +489,14 @@ export class Controller {
|
||||
break
|
||||
default: {
|
||||
if (!c.source) throw new Error('Для канала не выбран ресурс')
|
||||
if (channel === 'main' && ['play', 'resume', 'replay'].includes(command.action)) {
|
||||
const mode = c.source.kind === 'audio' ? 2 : 4
|
||||
if (this.state.scene.status === 'error') this.invalidateScene()
|
||||
if (this.state.scene.mode !== mode) {
|
||||
this.mode(mode)
|
||||
c = this.state.channels[channel]
|
||||
}
|
||||
}
|
||||
const patch: Partial<ChannelState> = { status: 'pending' }
|
||||
if (command.action === 'play' || command.action === 'replay') {
|
||||
patch.position = 0
|
||||
@@ -361,6 +571,10 @@ export class Controller {
|
||||
}
|
||||
if (report.status === 'error') {
|
||||
c.desired = 'paused'
|
||||
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
|
||||
this.state.scene.status = 'error'
|
||||
this.state.scene.error = c.error
|
||||
}
|
||||
this.measured.delete(report.channel)
|
||||
}
|
||||
if (report.status === 'ended') {
|
||||
|
||||
+61
-2
@@ -98,7 +98,11 @@ app
|
||||
.then(async () => {
|
||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||
await packages.initialize()
|
||||
app.on('before-quit', () => packages.cancel())
|
||||
const timerTick = setInterval(() => controller.tick(), 250)
|
||||
app.on('before-quit', () => {
|
||||
clearInterval(timerTick)
|
||||
packages.cancel()
|
||||
})
|
||||
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||
callback(false),
|
||||
)
|
||||
@@ -107,7 +111,7 @@ app
|
||||
const url = new URL(request.url)
|
||||
const headers = {
|
||||
'Content-Security-Policy':
|
||||
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; connect-src 'self'; frame-ancestors 'none'",
|
||||
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; img-src 'self' screen-app: data:; connect-src 'self'; frame-ancestors 'none'",
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
||||
@@ -220,6 +224,61 @@ app
|
||||
throw new Error('Сначала откройте окно показа')
|
||||
controller.command(parsed)
|
||||
})
|
||||
let capturing = false
|
||||
let lastCapture = -Infinity
|
||||
ipcMain.handle('preview', async (event) => {
|
||||
requireControl(event)
|
||||
const window = output
|
||||
const state = controller.state
|
||||
if (
|
||||
!window ||
|
||||
window.isDestroyed() ||
|
||||
window.webContents.isLoading() ||
|
||||
!control?.isVisible() ||
|
||||
control.isMinimized() ||
|
||||
state.scene.status !== 'ready' ||
|
||||
capturing ||
|
||||
performance.now() - lastCapture < 500
|
||||
)
|
||||
return null
|
||||
const session = state.session
|
||||
const revision = state.scene.revision
|
||||
capturing = true
|
||||
lastCapture = performance.now()
|
||||
try {
|
||||
const image = await window.webContents.capturePage()
|
||||
if (
|
||||
window !== output ||
|
||||
window.isDestroyed() ||
|
||||
session !== controller.state.session ||
|
||||
revision !== controller.state.scene.revision ||
|
||||
controller.state.scene.status !== 'ready' ||
|
||||
image.isEmpty()
|
||||
)
|
||||
return null
|
||||
return {
|
||||
session,
|
||||
revision,
|
||||
dataURL: image.resize({ width: Math.min(960, image.getSize().width) }).toDataURL(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
capturing = false
|
||||
}
|
||||
})
|
||||
ipcMain.on('scene-report', (event, report: unknown) => {
|
||||
if (
|
||||
event.sender !== output?.webContents ||
|
||||
event.senderFrame !== output.webContents.mainFrame
|
||||
)
|
||||
return
|
||||
try {
|
||||
controller.reportScene(report)
|
||||
} catch (error) {
|
||||
console.error('Ошибка подтверждения сцены', error)
|
||||
}
|
||||
})
|
||||
ipcMain.on('report', (event, report: unknown) => {
|
||||
if (
|
||||
event.sender !== output?.webContents ||
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { Command, Report, State } from '../src/types/screen.js'
|
||||
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
||||
|
||||
contextBridge.exposeInMainWorld('screenBridge', {
|
||||
command: (command: Command) => ipcRenderer.invoke('command', command),
|
||||
report: (report: Report) => ipcRenderer.send('report', report),
|
||||
reportScene: (report: SceneReport) => ipcRenderer.send('scene-report', report),
|
||||
preview: () => ipcRenderer.invoke('preview'),
|
||||
state: () => ipcRenderer.invoke('state'),
|
||||
subscribe: (listener: (state: State) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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<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: 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<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)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ResultsSnapshot } from '../src/types/screen.js'
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Некорректный снимок результатов')
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
function list(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error('Некорректный список результатов')
|
||||
return value
|
||||
}
|
||||
function text(value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('Некорректный текст результатов')
|
||||
return value
|
||||
}
|
||||
function integer(value: unknown, minimum = -2147483648): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum)
|
||||
throw new Error('Некорректное число результатов')
|
||||
return value
|
||||
}
|
||||
// Сохраняем только поля публичной таблицы, без участников, паролей и токенов.
|
||||
export function parseResults(value: unknown, packageKey: string): ResultsSnapshot {
|
||||
const data = object(value)
|
||||
if (data.packageKey !== packageKey || !/^[a-f0-9]{64}$/.test(packageKey))
|
||||
throw new Error('Результаты относятся к другой игре')
|
||||
const captured = Date.parse(text(data.capturedAt))
|
||||
if (!Number.isFinite(captured)) throw new Error('Некорректное время снимка результатов')
|
||||
const questions = list(data.questions).map((value) => {
|
||||
const q = object(value)
|
||||
return { code: text(q.code), text: text(q.text) }
|
||||
})
|
||||
const codes = new Set(questions.map((q) => q.code))
|
||||
if (codes.size !== questions.length || codes.has(''))
|
||||
throw new Error('Повтор или отсутствие кода вопроса')
|
||||
const teams = list(data.teams).map((value) => {
|
||||
const team = object(value)
|
||||
const answers = list(team.answers).map((value) => {
|
||||
const a = object(value)
|
||||
return { questionCode: text(a.questionCode), score: integer(a.score) }
|
||||
})
|
||||
if (
|
||||
new Set(answers.map((a) => a.questionCode)).size !== answers.length ||
|
||||
answers.some((a) => !codes.has(a.questionCode))
|
||||
)
|
||||
throw new Error('Некорректные ответы результатов')
|
||||
return {
|
||||
id: text(team.id),
|
||||
name: text(team.name),
|
||||
place: integer(team.place, 1),
|
||||
totalScore: integer(team.totalScore),
|
||||
actionsCount: integer(team.actionsCount, 0),
|
||||
answers,
|
||||
}
|
||||
})
|
||||
if (teams.some((team) => !team.id) || new Set(teams.map((t) => t.id)).size !== teams.length)
|
||||
throw new Error('Повтор или отсутствие команды')
|
||||
return { packageKey, capturedAt: new Date(captured).toISOString(), questions, teams }
|
||||
}
|
||||
Reference in New Issue
Block a user