up
This commit is contained in:
@@ -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 не выполнялись.
|
||||
|
||||
+7
-1
@@ -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 не отображаются.
|
||||
|
||||
Отсутствие истории сценария в ответе игры не мешает подключению. После подключения
|
||||
ссылки на обложку, фон, вступление и видео берутся из состояния экрана и автоматически
|
||||
сохраняются локально. До получения состояния используются доступные ссылки игры.
|
||||
Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает
|
||||
«Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага.
|
||||
|
||||
+83
-6
@@ -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, '')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user