From f5e645ec002995fe872751f4b28216eab548d6a9 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 04:59:27 +0700 Subject: [PATCH] up --- COMPLETED_TASKS.md | 25 ++++++++ docs/USER_GUIDE.md | 8 ++- electron/online.test.ts | 89 ++++++++++++++++++++++++++-- electron/online.ts | 2 + electron/screen-package.test.ts | 65 ++++++++++++++++++-- electron/screen-package.ts | 101 ++++++++++++++++++++------------ scripts/online-smoke.cjs | 28 ++++++++- 7 files changed, 267 insertions(+), 51 deletions(-) diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index e96acc9..4c820cb 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -418,3 +418,28 @@ - Границы: изменения только evening_detective_screen. Сервер и сгенерированный клиент не менялись. Production-ошибка не воспроизводилась; установочные архивы не пересобирались, 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 не выполнялись. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index df0aec5..acc43c5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -247,5 +247,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова Подробности ошибки видны прямо в пульте: операция, метод и путь API, статус HTTP или безопасный код сетевого сбоя. Если JSON имеет неверную структуру, сообщение показывает поле (включая индекс элемента), ожидаемый и фактический тип, например: -`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено null`. +`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено массив`. Содержимое ответа, значения полей, токены и stack trace не отображаются. + +Отсутствие истории сценария в ответе игры не мешает подключению. После подключения +ссылки на обложку, фон, вступление и видео берутся из состояния экрана и автоматически +сохраняются локально. До получения состояния используются доступные ссылки игры. +Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает +«Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага. diff --git a/electron/online.test.ts b/electron/online.test.ts index 242602d..bbe5a05 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -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 | null, mediaHandler: null as ((url: string) => Promise) | 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, '') +}) diff --git a/electron/online.ts b/electron/online.ts index f758f8e..1ba0b9b 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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, diff --git a/electron/screen-package.test.ts b/electron/screen-package.test.ts index 2637aae..4132707 100644 --- a/electron/screen-package.test.ts +++ b/electron/screen-package.test.ts @@ -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) +}) diff --git a/electron/screen-package.ts b/electron/screen-package.ts index c6b4d38..2e3b423 100644 --- a/electron/screen-package.ts +++ b/electron/screen-package.ts @@ -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() @@ -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 diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index fc6e015..78d1aee 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -15,6 +15,7 @@ module.exports = async function onlineSmoke({ let gamesCalls = 0 let varyGames = false let invalidStory = false + let introAudio = 'intro.wav' let version = 1 let offline = false let polls = 0 @@ -64,7 +65,7 @@ module.exports = async function onlineSmoke({ game: { ID: 6, name: 'HTTP smoke', - scenario: { story: invalidStory ? null : { introduction: { audio: 'intro.wav' } } }, + scenario: { story: invalidStory ? [] : null }, }, }) if (path === '/api/games/6/screen-plan') { @@ -127,7 +128,7 @@ module.exports = async function onlineSmoke({ state: { gameID: 6, music, - introAudio: 'intro.wav', + introAudio, mode: remoteMode, text: remoteText, timerLabel, @@ -229,6 +230,14 @@ module.exports = async function onlineSmoke({ ) varyGames = false 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( `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`, ) assert.ok(errorText.includes('Описание игры · GET /api/games/6')) - assert.ok(errorText.includes('получено null')) + assert.ok(errorText.includes('получено массив')) assert.ok(!errorText.includes('TEST-ACCESS')) assert.equal( await evaluate( @@ -418,6 +427,19 @@ module.exports = async function onlineSmoke({ ) assert.match((await state()).channels.music.source.url, /^screen-app:/) 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') // Настоящий редактор: черновик, ошибка сохранения, порядок и переходы. const clickPlan = async (label) => {