This commit is contained in:
2026-10-18 04:59:27 +07:00
parent 7a97b9b506
commit f5e645ec00
7 changed files with 267 additions and 51 deletions
+83 -6
View File
@@ -37,6 +37,8 @@ async function fixture(t: TestContext, secure = true) {
offline: false,
server: 'https://server.test',
introURL: 'https://media.test/intro.wav',
omitScenario: false,
omitStory: false,
musicOverride: null as Record<string, unknown> | null,
mediaHandler: null as ((url: string) => Promise<Response>) | null,
failMedia: false,
@@ -51,6 +53,7 @@ async function fixture(t: TestContext, secure = true) {
roles: ['organizer'],
remote: {
gameID: 7,
introAudio: 'https://media.test/intro.wav',
mode: 2,
command: 1,
stateVersion: '9007199254740993',
@@ -139,12 +142,16 @@ async function fixture(t: TestContext, secure = true) {
game: {
ID: 7,
name: model.title,
scenario: {
story: {
introduction: { audio: model.introURL },
conclusion: { video: '' },
},
},
scenario: model.omitScenario
? null
: {
story: model.omitStory
? null
: {
introduction: { audio: model.introURL },
conclusion: { video: '' },
},
},
},
})
if (path === '/api/games/7/screen-plan') {
@@ -1255,3 +1262,73 @@ test('некорректное серверное состояние отобр
assert.ok(message.includes('получено object'))
assert.ok(!message.includes('SECRET'))
})
for (const missing of ['scenario', 'story'] as const) {
test(`подключение без ${missing}: медиа состояния скачиваются и воспроизводятся локально`, async (t) => {
const f = await fixture(t)
f.model.omitScenario = missing === 'scenario'
f.model.omitStory = missing === 'story'
await f.login()
await f.online.act({ action: 'connect', gameId: 7 })
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(f.controller.state.outputOpen, false)
f.controller.output(true)
await f.online.poll()
const state = f.controller.state
const pkg = f.store.description(state.catalog.key!)!
assert.ok(pkg.package.media.intro)
assert.equal(state.channels.main.desired, 'playing')
assert.equal(
state.channels.main.source?.url,
`screen-app://app/packages/${pkg.key}/${pkg.package.media.intro}`,
)
assert.ok((await f.store.localFile(pkg.key, pkg.package.media.intro!)).path)
const restored = new PackageStore(join(f.directory, 'packages'))
await restored.initialize()
assert.equal(restored.description(pkg.key)?.package.media.intro, pkg.package.media.intro)
assert.ok((await restored.localFile(pkg.key, pkg.package.media.intro!)).path)
})
}
test('без истории и медиа текст, таймер и музыка работают; отсутствие вступления не мешает связи', async (t) => {
const f = await fixture(t)
f.model.omitScenario = true
f.model.plan = parsePlan({
steps: [
{ mode: 8, text: 'Текст без истории' },
{ mode: 7, timerDurationSeconds: 30, timerLabel: 'Перерыв' },
],
})
f.model.remote = {
...f.model.remote,
mode: 8,
text: 'Текст без истории',
command: 0,
introAudio: '',
music: objectMusic(1),
}
await f.login()
await f.online.act({ action: 'connect', gameId: 7 })
f.controller.output(true)
await f.online.poll()
assert.equal(f.controller.state.scene.text, 'Текст без истории')
assert.equal(f.controller.state.channels.music.desired, 'playing')
f.model.remote = {
...f.model.remote,
mode: 7,
timerDurationSeconds: 30,
timerRemainingSeconds: 30,
timerState: 1,
stateVersion: '9007199254740994',
}
await f.online.poll()
assert.equal(f.controller.state.scene.mode, 7)
assert.equal(f.controller.state.timer.durationMs, 30000)
f.model.remote = { ...f.model.remote, mode: 2, stateVersion: '9007199254740995' }
await f.online.poll()
assert.equal(f.controller.state.scene.mode, 2)
assert.equal(f.controller.state.channels.main.source, null)
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.equal(f.online.snapshot().connection, 'connected')
assert.equal(f.online.snapshot().preparation.error, '')
})
+2
View File
@@ -109,6 +109,7 @@ export class Online {
{
server: this.credentials.server,
gameId,
resources: this.received?.gameId === gameId ? this.received.resources : undefined,
token: this.credentials.access,
signal: this.materialAbort.signal,
onRequestFailure: this.onRequestFailure,
@@ -1171,6 +1172,7 @@ export class Online {
{
server: this.state.server,
gameId,
resources: this.received?.gameId === gameId ? this.received.resources : undefined,
token: this.credentials.access,
signal: this.cancellation.signal,
onRequestFailure: this.onRequestFailure,
+61 -4
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { ApiError, SessionChangedError } from './request-error.js'
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
import { buildScreenPackage, prepareScreenPackage, parseScreenPackage } from './screen-package.js'
const server = 'https://example.test'
function fixture() {
@@ -151,7 +151,7 @@ test('неполные или некорректные ответы не ста
assert.throws(() => build(data))
}
assert.throws(() => buildScreenPackage(server, 7, { game: {}, plan: {}, music: {} }))
assert.throws(() =>
assert.doesNotThrow(() =>
buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: {} } }, plan: {}, music: {} }),
)
})
@@ -306,8 +306,6 @@ test('таймаут и отмена подготовки различаются
test('ошибка структуры описания называет запрос, поле и тип, не раскрывая значения', async () => {
for (const [badValue, expectedType] of [
[null, 'null'],
[undefined, 'поле отсутствует'],
[[], 'массив'],
['SECRET', 'string'],
] as const) {
@@ -366,3 +364,62 @@ test('ошибки элементов плана и треков сохраня
)
}
})
test('scenario и story могут отсутствовать или быть null; неверные типы остаются ошибкой', () => {
for (const scenario of [undefined, null, {}, { story: null }, { story: undefined }]) {
const pkg = buildScreenPackage(server, 7, {
game: { game: { ID: 7, scenario } },
plan: {
plan: {
steps: [
{ mode: 8, text: 'Текст' },
{ mode: 7, timerDurationSeconds: 60 },
],
},
},
music: {},
})
assert.deepEqual(pkg.assets, [])
assert.deepEqual(pkg.media, { cover: null, background: null, intro: null, conclusion: null })
assert.equal(pkg.steps.length, 2)
}
for (const scenario of [[], 'SECRET', { story: [] }, { story: 'SECRET' }]) {
assert.throws(
() =>
buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario } }, plan: {}, music: {} }),
/game.scenario/,
)
}
})
test('ссылки состояния экрана определяют медиа без истории, включая явное отсутствие файла', () => {
const resources = {
cover: 'cover.png',
background: 'background.png',
intro: 'intro.wav',
conclusion: 'end.mp4',
}
const minimal = { game: { game: { ID: 7 } }, plan: {}, music: {} }
const pkg = buildScreenPackage(server, 7, minimal, resources)
assert.equal(pkg.assets.length, 4)
for (const [name, ref] of Object.entries(resources)) {
const id = pkg.media[name as keyof typeof pkg.media]
assert.equal(pkg.assets.find((a) => a.id === id)?.url, `${server}/api/files/${ref}`)
}
const cleared = buildScreenPackage(server, 7, fixture(), {
cover: '',
background: '',
intro: '',
conclusion: '',
})
assert.deepEqual(cleared.media, { cover: null, background: null, intro: null, conclusion: null })
assert.ok(!cleared.assets.some((a) => a.url.endsWith('final.mp4')))
const coverOnly = buildScreenPackage(server, 7, minimal, {
cover: 'cover.png',
background: '',
intro: '',
conclusion: '',
})
assert.equal(coverOnly.media.background, null)
for (const value of [pkg, cleared, coverOnly]) assert.deepEqual(parseScreenPackage(value), value)
})
+64 -37
View File
@@ -37,6 +37,7 @@ export interface PackageRequest {
server: string
gameId: number
token: string
resources?: { cover: string; background: string; intro: string; conclusion: string }
signal?: AbortSignal
onRequestFailure?: (failure: {
path: string
@@ -149,6 +150,7 @@ export function buildScreenPackage(
server: string,
gameId: number,
responses: { game: unknown; plan: unknown; music: unknown },
resources?: PackageRequest['resources'],
): ScreenPackage {
server = serverURL(server)
id(gameId)
@@ -160,8 +162,8 @@ export function buildScreenPackage(
game.ID,
'API вернул другую игру',
)
const scenario = object(game.scenario, 'game.scenario')
const story = object(scenario.story, 'game.scenario.story')
const scenario = optionalObject(game.scenario, 'game.scenario')
const story = optionalObject(scenario.story, 'game.scenario.story')
const intro = optionalObject(story.introduction, 'game.scenario.story.introduction')
const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion')
const assets = new Map<string, { id: string; url: string }>()
@@ -183,12 +185,20 @@ export function buildScreenPackage(
assets.set(assetId, { id: assetId, url })
return assetId
}
const cover = asset(scenario.image, 'game.scenario.image')
const cover = resources
? asset(resources.cover, 'state.coverImage')
: asset(scenario.image, 'game.scenario.image')
const media = {
cover,
background: asset(story.background, 'game.scenario.story.background') ?? cover,
intro: asset(intro.audio, 'game.scenario.story.introduction.audio'),
conclusion: asset(conclusion.video, 'game.scenario.story.conclusion.video'),
background: resources
? asset(resources.background, 'state.backgroundImage')
: (asset(story.background, 'game.scenario.story.background') ?? cover),
intro: resources
? asset(resources.intro, 'state.introAudio')
: asset(intro.audio, 'game.scenario.story.introduction.audio'),
conclusion: resources
? asset(resources.conclusion, 'state.conclusionVideo')
: asset(conclusion.video, 'game.scenario.story.conclusion.video'),
}
const plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan')
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
@@ -374,9 +384,10 @@ export async function prepareScreenPackage(
client.GetGameMusic({ gameID: request.gameId }),
])
try {
return buildScreenPackage(server, request.gameId, { game, plan, music })
return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources)
} catch (error) {
const field = error instanceof ResponseFieldError ? error.field : 'game'
const state = field.startsWith('state.')
const suffix = field.startsWith('plan')
? '/screen-plan'
: field.startsWith('music')
@@ -384,9 +395,15 @@ export async function prepareScreenPackage(
: ''
throw requestErrorContext(
error,
suffix === '/music' ? 'GetGameMusic' : suffix ? 'GetScreenPlan' : 'GetGame',
'GET',
`/api/games/${request.gameId}${suffix}`,
state
? 'GetScreenState'
: suffix === '/music'
? 'GetGameMusic'
: suffix
? 'GetScreenPlan'
: 'GetGame',
state ? 'POST' : 'GET',
state ? '/api/screens/state' : `/api/games/${request.gameId}${suffix}`,
)
}
}
@@ -431,41 +448,51 @@ export function parseScreenPackage(value: unknown): ScreenPackage {
const selectedId = integer(music.selectedPlaylistId)
const selected = selectedId ? playlists.find((playlist) => playlist.ID === selectedId) : undefined
if (selectedId && !selected) throw new Error('Отсутствует выбранный плейлист')
const result = buildScreenPackage(text(data.server), id(game.id), {
game: {
const result = buildScreenPackage(
text(data.server),
id(game.id),
{
game: {
ID: game.id,
name: text(game.name),
scenario: {
image: ref(media.cover),
story: {
background: ref(media.background),
introduction: { audio: ref(media.intro) },
conclusion: { video: ref(media.conclusion) },
game: {
ID: game.id,
name: text(game.name),
scenario: {
image: ref(media.cover),
story: {
background: ref(media.background),
introduction: { audio: ref(media.intro) },
conclusion: { video: ref(media.conclusion) },
},
},
},
},
},
plan: {
plan: {
steps: list(data.steps).map((value) => {
const step = object(value)
return {
mode: step.mode,
text: step.text,
timerLabel: step.timerLabel,
timerDurationSeconds: step.timerDurationSeconds,
}
}),
plan: {
steps: list(data.steps).map((value) => {
const step = object(value)
return {
mode: step.mode,
text: step.text,
timerLabel: step.timerLabel,
timerDurationSeconds: step.timerDurationSeconds,
}
}),
},
},
music: {
scenarioPlaylistId: integer(music.scenarioPlaylistId),
gamePlaylistId: integer(music.gamePlaylistId),
state: { playlist: selected },
playlists,
},
},
music: {
scenarioPlaylistId: integer(music.scenarioPlaylistId),
gamePlaylistId: integer(music.gamePlaylistId),
state: { playlist: selected },
playlists,
{
cover: ref(media.cover),
background: ref(media.background),
intro: ref(media.intro),
conclusion: ref(media.conclusion),
},
})
)
if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size)
throw new Error('Повреждены метаданные пакета')
return result