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
+25
View File
@@ -418,3 +418,28 @@
- Границы: изменения только evening_detective_screen. Сервер и сгенерированный - Границы: изменения только evening_detective_screen. Сервер и сгенерированный
клиент не менялись. Production-ошибка не воспроизводилась; установочные клиент не менялись. Production-ошибка не воспроизводилась; установочные
архивы не пересобирались, commit/push не выполнялись. архивы не пересобирались, commit/push не выполнялись.
## SCREEN-021 — Подключение игры без обязательного story
- Статус: `DONE`.
- Дата: 2026-10-18.
- Результат: scenario и story могут отсутствовать или быть null. Игра и её ID
остаются обязательными; неверные типы присутствующих полей дают подробную ошибку.
После получения GetScreenState обложка, фон, вступление и видео берутся из его
ссылок, включая пустые значения как отсутствие файла. До получения состояния
используются доступные материалы GetGame; полный сценарий отдельно не запрашивается.
- Ссылки включаются в существующее описание и автоматически скачиваются локально.
Сохранены защита от устаревших загрузок, прежний показ до готовности замены,
чтение кеша после перезапуска и формат пакета. Явно отсутствующий фон сохраняется
при наличии обложки, без восстановления неявного fallback при чтении манифеста.
Без медиа работают текст, таймер и музыка; вступление/видео показывают «не задано».
- Проверки: 118 тестов, type-check, lint, lint:css, сборка, Electron GUI smoke,
локальные Markdown-ссылки и git diff --check — PASS. Проверены отсутствие/null
scenario/story, неверные типы, приоритет ссылок состояния и очистка медиа,
восстановление кеша, подключение/локальное воспроизведение без истории и показ
текста/таймера/музыки без основного файла. GUI использует GetGame со story:null,
получает вступление из GetScreenState, затем проверяет явное отсутствие вступления.
Ожидание готовности списка игр в smoke уточнено после раннего нажатия выбора.
- Границы: изменения только evening_detective_screen. Сервер, proto и клиент
генерации не менялись. Production-подключение не проверялось; установочные
архивы не пересобирались, commit/push не выполнялись.
+7 -1
View File
@@ -247,5 +247,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
Подробности ошибки видны прямо в пульте: операция, метод и путь API, статус HTTP Подробности ошибки видны прямо в пульте: операция, метод и путь API, статус HTTP
или безопасный код сетевого сбоя. Если JSON имеет неверную структуру, сообщение или безопасный код сетевого сбоя. Если JSON имеет неверную структуру, сообщение
показывает поле (включая индекс элемента), ожидаемый и фактический тип, например: показывает поле (включая индекс элемента), ожидаемый и фактический тип, например:
`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено null`. `Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено массив`.
Содержимое ответа, значения полей, токены и stack trace не отображаются. Содержимое ответа, значения полей, токены и stack trace не отображаются.
Отсутствие истории сценария в ответе игры не мешает подключению. После подключения
ссылки на обложку, фон, вступление и видео берутся из состояния экрана и автоматически
сохраняются локально. До получения состояния используются доступные ссылки игры.
Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает
«Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага.
+83 -6
View File
@@ -37,6 +37,8 @@ async function fixture(t: TestContext, secure = true) {
offline: false, offline: false,
server: 'https://server.test', server: 'https://server.test',
introURL: 'https://media.test/intro.wav', introURL: 'https://media.test/intro.wav',
omitScenario: false,
omitStory: false,
musicOverride: null as Record<string, unknown> | null, musicOverride: null as Record<string, unknown> | null,
mediaHandler: null as ((url: string) => Promise<Response>) | null, mediaHandler: null as ((url: string) => Promise<Response>) | null,
failMedia: false, failMedia: false,
@@ -51,6 +53,7 @@ async function fixture(t: TestContext, secure = true) {
roles: ['organizer'], roles: ['organizer'],
remote: { remote: {
gameID: 7, gameID: 7,
introAudio: 'https://media.test/intro.wav',
mode: 2, mode: 2,
command: 1, command: 1,
stateVersion: '9007199254740993', stateVersion: '9007199254740993',
@@ -139,12 +142,16 @@ async function fixture(t: TestContext, secure = true) {
game: { game: {
ID: 7, ID: 7,
name: model.title, name: model.title,
scenario: { scenario: model.omitScenario
story: { ? null
introduction: { audio: model.introURL }, : {
conclusion: { video: '' }, story: model.omitStory
}, ? null
}, : {
introduction: { audio: model.introURL },
conclusion: { video: '' },
},
},
}, },
}) })
if (path === '/api/games/7/screen-plan') { if (path === '/api/games/7/screen-plan') {
@@ -1255,3 +1262,73 @@ test('некорректное серверное состояние отобр
assert.ok(message.includes('получено object')) assert.ok(message.includes('получено object'))
assert.ok(!message.includes('SECRET')) 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, server: this.credentials.server,
gameId, gameId,
resources: this.received?.gameId === gameId ? this.received.resources : undefined,
token: this.credentials.access, token: this.credentials.access,
signal: this.materialAbort.signal, signal: this.materialAbort.signal,
onRequestFailure: this.onRequestFailure, onRequestFailure: this.onRequestFailure,
@@ -1171,6 +1172,7 @@ export class Online {
{ {
server: this.state.server, server: this.state.server,
gameId, gameId,
resources: this.received?.gameId === gameId ? this.received.resources : undefined,
token: this.credentials.access, token: this.credentials.access,
signal: this.cancellation.signal, signal: this.cancellation.signal,
onRequestFailure: this.onRequestFailure, onRequestFailure: this.onRequestFailure,
+61 -4
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { test } from 'node:test' import { test } from 'node:test'
import { ApiError, SessionChangedError } from './request-error.js' 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' const server = 'https://example.test'
function fixture() { function fixture() {
@@ -151,7 +151,7 @@ test('неполные или некорректные ответы не ста
assert.throws(() => build(data)) assert.throws(() => build(data))
} }
assert.throws(() => buildScreenPackage(server, 7, { game: {}, plan: {}, music: {} })) assert.throws(() => buildScreenPackage(server, 7, { game: {}, plan: {}, music: {} }))
assert.throws(() => assert.doesNotThrow(() =>
buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: {} } }, plan: {}, music: {} }), buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: {} } }, plan: {}, music: {} }),
) )
}) })
@@ -306,8 +306,6 @@ test('таймаут и отмена подготовки различаются
test('ошибка структуры описания называет запрос, поле и тип, не раскрывая значения', async () => { test('ошибка структуры описания называет запрос, поле и тип, не раскрывая значения', async () => {
for (const [badValue, expectedType] of [ for (const [badValue, expectedType] of [
[null, 'null'],
[undefined, 'поле отсутствует'],
[[], 'массив'], [[], 'массив'],
['SECRET', 'string'], ['SECRET', 'string'],
] as const) { ] 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 server: string
gameId: number gameId: number
token: string token: string
resources?: { cover: string; background: string; intro: string; conclusion: string }
signal?: AbortSignal signal?: AbortSignal
onRequestFailure?: (failure: { onRequestFailure?: (failure: {
path: string path: string
@@ -149,6 +150,7 @@ export function buildScreenPackage(
server: string, server: string,
gameId: number, gameId: number,
responses: { game: unknown; plan: unknown; music: unknown }, responses: { game: unknown; plan: unknown; music: unknown },
resources?: PackageRequest['resources'],
): ScreenPackage { ): ScreenPackage {
server = serverURL(server) server = serverURL(server)
id(gameId) id(gameId)
@@ -160,8 +162,8 @@ export function buildScreenPackage(
game.ID, game.ID,
'API вернул другую игру', 'API вернул другую игру',
) )
const scenario = object(game.scenario, 'game.scenario') const scenario = optionalObject(game.scenario, 'game.scenario')
const story = object(scenario.story, 'game.scenario.story') const story = optionalObject(scenario.story, 'game.scenario.story')
const intro = optionalObject(story.introduction, 'game.scenario.story.introduction') const intro = optionalObject(story.introduction, 'game.scenario.story.introduction')
const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion') const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion')
const assets = new Map<string, { id: string; url: string }>() const assets = new Map<string, { id: string; url: string }>()
@@ -183,12 +185,20 @@ export function buildScreenPackage(
assets.set(assetId, { id: assetId, url }) assets.set(assetId, { id: assetId, url })
return assetId 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 = { const media = {
cover, cover,
background: asset(story.background, 'game.scenario.story.background') ?? cover, background: resources
intro: asset(intro.audio, 'game.scenario.story.introduction.audio'), ? asset(resources.background, 'state.backgroundImage')
conclusion: asset(conclusion.video, 'game.scenario.story.conclusion.video'), : (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 plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan')
const steps = list(plan.steps, 'plan.steps').map((value, index) => { const steps = list(plan.steps, 'plan.steps').map((value, index) => {
@@ -374,9 +384,10 @@ export async function prepareScreenPackage(
client.GetGameMusic({ gameID: request.gameId }), client.GetGameMusic({ gameID: request.gameId }),
]) ])
try { try {
return buildScreenPackage(server, request.gameId, { game, plan, music }) return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources)
} catch (error) { } catch (error) {
const field = error instanceof ResponseFieldError ? error.field : 'game' const field = error instanceof ResponseFieldError ? error.field : 'game'
const state = field.startsWith('state.')
const suffix = field.startsWith('plan') const suffix = field.startsWith('plan')
? '/screen-plan' ? '/screen-plan'
: field.startsWith('music') : field.startsWith('music')
@@ -384,9 +395,15 @@ export async function prepareScreenPackage(
: '' : ''
throw requestErrorContext( throw requestErrorContext(
error, error,
suffix === '/music' ? 'GetGameMusic' : suffix ? 'GetScreenPlan' : 'GetGame', state
'GET', ? 'GetScreenState'
`/api/games/${request.gameId}${suffix}`, : 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 selectedId = integer(music.selectedPlaylistId)
const selected = selectedId ? playlists.find((playlist) => playlist.ID === selectedId) : undefined const selected = selectedId ? playlists.find((playlist) => playlist.ID === selectedId) : undefined
if (selectedId && !selected) throw new Error('Отсутствует выбранный плейлист') if (selectedId && !selected) throw new Error('Отсутствует выбранный плейлист')
const result = buildScreenPackage(text(data.server), id(game.id), { const result = buildScreenPackage(
game: { text(data.server),
id(game.id),
{
game: { game: {
ID: game.id, game: {
name: text(game.name), ID: game.id,
scenario: { name: text(game.name),
image: ref(media.cover), scenario: {
story: { image: ref(media.cover),
background: ref(media.background), story: {
introduction: { audio: ref(media.intro) }, background: ref(media.background),
conclusion: { video: ref(media.conclusion) }, introduction: { audio: ref(media.intro) },
conclusion: { video: ref(media.conclusion) },
},
}, },
}, },
}, },
},
plan: {
plan: { plan: {
steps: list(data.steps).map((value) => { plan: {
const step = object(value) steps: list(data.steps).map((value) => {
return { const step = object(value)
mode: step.mode, return {
text: step.text, mode: step.mode,
timerLabel: step.timerLabel, text: step.text,
timerDurationSeconds: step.timerDurationSeconds, timerLabel: step.timerLabel,
} timerDurationSeconds: step.timerDurationSeconds,
}), }
}),
},
},
music: {
scenarioPlaylistId: integer(music.scenarioPlaylistId),
gamePlaylistId: integer(music.gamePlaylistId),
state: { playlist: selected },
playlists,
}, },
}, },
music: { {
scenarioPlaylistId: integer(music.scenarioPlaylistId), cover: ref(media.cover),
gamePlaylistId: integer(music.gamePlaylistId), background: ref(media.background),
state: { playlist: selected }, intro: ref(media.intro),
playlists, conclusion: ref(media.conclusion),
}, },
}) )
if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size) if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size)
throw new Error('Повреждены метаданные пакета') throw new Error('Повреждены метаданные пакета')
return result return result
+25 -3
View File
@@ -15,6 +15,7 @@ module.exports = async function onlineSmoke({
let gamesCalls = 0 let gamesCalls = 0
let varyGames = false let varyGames = false
let invalidStory = false let invalidStory = false
let introAudio = 'intro.wav'
let version = 1 let version = 1
let offline = false let offline = false
let polls = 0 let polls = 0
@@ -64,7 +65,7 @@ module.exports = async function onlineSmoke({
game: { game: {
ID: 6, ID: 6,
name: 'HTTP smoke', name: 'HTTP smoke',
scenario: { story: invalidStory ? null : { introduction: { audio: 'intro.wav' } } }, scenario: { story: invalidStory ? [] : null },
}, },
}) })
if (path === '/api/games/6/screen-plan') { if (path === '/api/games/6/screen-plan') {
@@ -127,7 +128,7 @@ module.exports = async function onlineSmoke({
state: { state: {
gameID: 6, gameID: 6,
music, music,
introAudio: 'intro.wav', introAudio,
mode: remoteMode, mode: remoteMode,
text: remoteText, text: remoteText,
timerLabel, timerLabel,
@@ -229,6 +230,14 @@ module.exports = async function onlineSmoke({
) )
varyGames = false varyGames = false
await act({ action: 'games' }) await act({ action: 'games' })
await poll(
() =>
evaluate(`(() => {
const select = Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки'))
return !!select && !select.classList.contains('n-base-selection--disabled') && !document.body.textContent.includes('Список игр пока пуст')
})()`),
'updated game list rendered',
)
await evaluate( await evaluate(
`Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`, `Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`,
) )
@@ -264,7 +273,7 @@ module.exports = async function onlineSmoke({
`document.querySelector('[data-testid="connection-error"]').textContent`, `document.querySelector('[data-testid="connection-error"]').textContent`,
) )
assert.ok(errorText.includes('Описание игры · GET /api/games/6')) assert.ok(errorText.includes('Описание игры · GET /api/games/6'))
assert.ok(errorText.includes('получено null')) assert.ok(errorText.includes('получено массив'))
assert.ok(!errorText.includes('TEST-ACCESS')) assert.ok(!errorText.includes('TEST-ACCESS'))
assert.equal( assert.equal(
await evaluate( await evaluate(
@@ -418,6 +427,19 @@ module.exports = async function onlineSmoke({
) )
assert.match((await state()).channels.music.source.url, /^screen-app:/) assert.match((await state()).channels.music.source.url, /^screen-app:/)
assert.notEqual((await state()).channels.music.source.url, oldMusic) assert.notEqual((await state()).channels.music.source.url, oldMusic)
introAudio = ''
remoteMode = 2
currentCommand = 0
version++
await poll(
async () => (await state()).scene.mode === 2 && !(await state()).channels.main.source,
'screen without intro accepted',
)
await poll(
() => inspect(`document.body.textContent.includes('Вступление не задано')`),
'missing intro message',
)
assert.equal((await state()).channels.music.desired, 'playing')
console.log('SCREEN_LIVE_MATERIALS_SMOKE_PASS') console.log('SCREEN_LIVE_MATERIALS_SMOKE_PASS')
// Настоящий редактор: черновик, ошибка сохранения, порядок и переходы. // Настоящий редактор: черновик, ошибка сохранения, порядок и переходы.
const clickPlan = async (label) => { const clickPlan = async (label) => {