diff --git a/docs/SCREEN-005_ACCEPTANCE.md b/docs/SCREEN-005_ACCEPTANCE.md index 8845f5d..532f105 100644 --- a/docs/SCREEN-005_ACCEPTANCE.md +++ b/docs/SCREEN-005_ACCEPTANCE.md @@ -16,8 +16,8 @@ SCREEN-001/SCREEN-004 остаётся открытой; эта инструкц ## Полный план -- Пройдите восемь шагов по порядку и назад: обложка, вступление, «Успехов», - заключительное видео, «Спасибо за игру!», результаты, таймер и текст. +- Пройдите восемь шагов по порядку и назад: обложка, вступление, текст «Успехов», + заключительное видео, текст «Спасибо за игру!», результаты, таймер и текст. - Убедитесь, что шаги не запускают звук или таймер сами. Запустите вступление/видео явной кнопкой, переключитесь в другой режим: основное медиа останавливается. - Включите музыку и переключайте сцены: музыка остаётся независимой. Предпросмотр diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d9c914d..3bfae34 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -90,8 +90,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова ## Проведение показа В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг» -для переходов по плану. В редакторе шагов доступны восемь режимов: «Обложка», -«Введение», «Успехов», «Закрывашка», «Спасибо за игру», +для переходов по плану. В редакторе шагов доступны шесть режимов: «Обложка», +«Введение», «Закрывашка», «Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает успешного воспроизведения — смотрите статус и фактическое изображение/звук. diff --git a/electron/api-values.ts b/electron/api-values.ts index 53df23d..2c61009 100644 --- a/electron/api-values.ts +++ b/electron/api-values.ts @@ -8,13 +8,13 @@ const modes = [ 'SCREEN_MODE_UNSPECIFIED', 'SCREEN_MODE_COVER', 'SCREEN_MODE_INTRO', - 'SCREEN_MODE_INTRO_DONE', + undefined, 'SCREEN_MODE_CONCLUSION', - 'SCREEN_MODE_CONCLUSION_DONE', + undefined, 'SCREEN_MODE_RESULTS', 'SCREEN_MODE_TIMER', 'SCREEN_MODE_TEXT', -] as const satisfies readonly ScreenMode[] +] as const satisfies readonly (ScreenMode | undefined)[] const commands = [ 'SCREEN_COMMAND_UNSPECIFIED', 'SCREEN_COMMAND_PLAY', diff --git a/electron/api/generated/crabs/evening_detective_server/index.ts b/electron/api/generated/crabs/evening_detective_server/index.ts index a64f8f9..f004ae8 100644 --- a/electron/api/generated/crabs/evening_detective_server/index.ts +++ b/electron/api/generated/crabs/evening_detective_server/index.ts @@ -6,9 +6,7 @@ export type ScreenMode = | "SCREEN_MODE_UNSPECIFIED" | "SCREEN_MODE_COVER" | "SCREEN_MODE_INTRO" - | "SCREEN_MODE_INTRO_DONE" | "SCREEN_MODE_CONCLUSION" - | "SCREEN_MODE_CONCLUSION_DONE" | "SCREEN_MODE_RESULTS" | "SCREEN_MODE_TIMER" | "SCREEN_MODE_TEXT"; diff --git a/electron/controller.ts b/electron/controller.ts index b02345f..0467793 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -13,6 +13,7 @@ import type { TimerState, ScreenStep, } from '../src/types/screen.js' +import { parsePlan } from './screen-plan.js' import { parseResults } from './results.js' import type { LocalPackage } from './package-store.js' @@ -32,7 +33,12 @@ 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)) + else if ( + c.action === 'mode' && + Number.isInteger(c.mode) && + number(c.mode, 1, 8) && + [1, 2, 4, 6, 7, 8].includes(c.mode) + ) 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 } @@ -259,7 +265,8 @@ export class Controller { 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 })), + // Преобразуем старые режимы после проверки хеша исходного пакета. + steps: parsePlan({ steps: pkg.package.steps }).steps, playlists: pkg.package.playlists.map((p) => ({ id: p.id, name: p.name, diff --git a/electron/online.test.ts b/electron/online.test.ts index 3b51777..7c21993 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -1180,6 +1180,8 @@ test('таймер синхронизируется один раз на вер applyRemote(c, { ...next, sound: false }, next) assert.equal(c.state.muted, false) assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' })) + for (const mode of [3, 5, 'SCREEN_MODE_INTRO_DONE', 'SCREEN_MODE_CONCLUSION_DONE']) + assert.throws(() => parseRemote({ mode })) assert.throws(() => parseRemote({ mode: 99 })) }) @@ -1834,8 +1836,8 @@ test('локальные режим, музыка и таймер публику await f.prepare() f.controller.output(true) await f.online.poll() - await f.online.planCommand({ action: 'mode', mode: 3 }) - assert.equal(f.model.remote.mode, 'SCREEN_MODE_INTRO_DONE') + await f.online.planCommand({ action: 'mode', mode: 1 }) + assert.equal(f.model.remote.mode, 'SCREEN_MODE_COVER') await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 }) assert.equal(f.model.remote.timerDurationSeconds, 60) await f.online.planCommand({ action: 'play', channel: 'music' }) diff --git a/electron/package-store.test.ts b/electron/package-store.test.ts index 3cba836..3731278 100644 --- a/electron/package-store.test.ts +++ b/electron/package-store.test.ts @@ -6,6 +6,7 @@ import { readdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, type TestContext } from 'node:test' +import { Controller } from './controller.js' import { buildScreenPackage, parseScreenPackage } from './screen-package.js' import { PackageStore, @@ -570,3 +571,49 @@ test('инкрементальный кеш: ошибка и отмена не await assert.rejects(f.store.ensureAsset(f.pkg, second!.id, abort.signal)) await assert.rejects(f.store.localFile(f.key, second!.id)) }) + +test('старый пакет проходит проверку хеша после перезапуска и показывает текст без сети', async (t) => { + const f = await fixture(t) + f.pkg.steps = [3, 5].map((mode) => ({ + mode, + text: '', + timerLabel: '', + timerDurationSeconds: 0, + })) + f.pkg.metadataVersion = createHash('sha256') + .update( + JSON.stringify(f.pkg, (key, value: unknown) => + key === 'metadataVersion' ? undefined : value, + ), + ) + .digest('hex') + const downloaded = await f.store.download(f.pkg) + const restored = new PackageStore(f.root, { + ...f.dependencies, + fetch: async () => { + throw new Error('Нет сети') + }, + }) + await restored.initialize() + const local = restored.list().find((pkg) => pkg.key === f.key)! + assert.equal(local.version, downloaded.version) + const controller = new Controller(() => {}) + controller.selectPackage(local) + assert.deepEqual( + controller.state.catalog.steps.map(({ mode, text }) => ({ mode, text })), + [ + { mode: 8, text: 'Успехов' }, + { mode: 8, text: 'Спасибо за игру!' }, + ], + ) + for (const [index, text] of ['Успехов', 'Спасибо за игру!'].entries()) { + controller.command({ + session: controller.state.session, + sequence: controller.state.sequence + 1, + action: 'step', + index, + }) + assert.equal(controller.state.scene.mode, 8) + assert.equal(controller.state.scene.text, text) + } +}) diff --git a/electron/presentation.test.ts b/electron/presentation.test.ts index 86cdadd..adfba80 100644 --- a/electron/presentation.test.ts +++ b/electron/presentation.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { Controller, parseCommand } from './controller.js' -import { buildScreenPackage } from './screen-package.js' +import { buildScreenPackage, parseScreenPackage } 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' @@ -53,7 +53,7 @@ function fixture() { formatVersion: 1, key: packageKey(pkg.server, 7), version: 'a'.repeat(64), - package: pkg, + package: parseScreenPackage(pkg), files: [], } c.selectPackage(local) @@ -79,7 +79,9 @@ test('проходит восемь шагов, подставляет лока 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.mode, [1, 2, 8, 4, 8, 6, 7, 8][i]) + if (i === 2) assert.equal(f.c.state.scene.text, 'Успехов') + if (i === 4) assert.equal(f.c.state.scene.text, 'Спасибо за игру!') 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') @@ -104,7 +106,7 @@ test('смена сцены останавливает основное меди }) assert.equal(f.c.state.scene.stepIndex, 1) assert.equal(f.c.state.scene.mode, 2) - f.send({ action: 'mode', mode: 3 }) + f.send({ action: 'text', text: 'Успехов' }) 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) @@ -209,13 +211,15 @@ test('не допускает пустой текст и некорректны { 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: 3 }, + { action: 'mode', mode: 5 }, { 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[]) + for (const mode of [1, 2, 4, 6, 7, 8] as ScreenMode[]) assert.equal(parseCommand({ ...base, action: 'mode', mode }).action, 'mode') }) function snapshot(key: string) { diff --git a/electron/remote-state.ts b/electron/remote-state.ts index b429617..a50e203 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -93,15 +93,17 @@ export function parseRemote(value: unknown): RemoteState { 'UNSPECIFIED', 'COVER', 'INTRO', - 'INTRO_DONE', + '', 'CONCLUSION', - 'CONCLUSION_DONE', + '', 'RESULTS', 'TIMER', 'TEXT', ], 'state.mode', ) || 1 + if (![1, 2, 4, 6, 7, 8].includes(mode)) + throw fieldError('state.mode', 'актуальный режим экрана', s.mode, 'Некорректный режим экрана') const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds') const remaining = integer( s.timerRemainingSeconds ?? 0, diff --git a/electron/screen-plan.test.ts b/electron/screen-plan.test.ts index 7235c38..acd8072 100644 --- a/electron/screen-plan.test.ts +++ b/electron/screen-plan.test.ts @@ -22,3 +22,20 @@ test('план: режимы protobuf, значения по умолчанию ]) assert.throws(() => parsePlan(value)) }) + +test('старые текстовые режимы преобразуются без изменения порядка и текущего шага', () => { + const plan = parsePlan({ + steps: [3, 'SCREEN_MODE_INTRO_DONE', 5, 'SCREEN_MODE_CONCLUSION_DONE'].map((mode) => ({ + mode, + text: 'Неиспользуемый текст', + })), + currentStep: 2, + }) + assert.equal(plan.currentStep, 2) + assert.deepEqual(plan.steps.map(({ mode, text }) => ({ mode, text })), [ + { mode: 8, text: 'Успехов' }, + { mode: 8, text: 'Успехов' }, + { mode: 8, text: 'Спасибо за игру!' }, + { mode: 8, text: 'Спасибо за игру!' }, + ]) +}) diff --git a/electron/screen-plan.ts b/electron/screen-plan.ts index 84b1bdd..1c1dcfd 100644 --- a/electron/screen-plan.ts +++ b/electron/screen-plan.ts @@ -22,7 +22,7 @@ export function parsePlan(value: unknown): ScreenPlan { const steps = list.map((value, index) => { const field = `plan.steps[${index}]` const step = object(value, field) - let mode: ScreenMode + let mode: number try { mode = integer( typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_') @@ -31,7 +31,7 @@ export function parsePlan(value: unknown): ScreenPlan { 1, 8, `${field}.mode`, - ) as ScreenMode + ) } catch { throw new ResponseFieldError( `${field}.mode`, @@ -40,7 +40,11 @@ export function parsePlan(value: unknown): ScreenPlan { 'Некорректный режим экрана', ) } - const content = text(step.text ?? '', `${field}.text`).trim() + let content = text(step.text ?? '', `${field}.text`).trim() + if (mode === 3 || mode === 5) { + content = mode === 3 ? 'Успехов' : 'Спасибо за игру!' + mode = 8 + } const label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim() const seconds = integer( step.timerDurationSeconds ?? 0, @@ -63,7 +67,7 @@ export function parsePlan(value: unknown): ScreenPlan { 'Укажите таймер от 1 до 86400 секунд и подпись до 200 байт', ) return { - mode, + mode: mode as ScreenMode, text: content, timerLabel: mode === 7 ? label || 'Перерыв' : label, timerDurationSeconds: seconds, diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 595755b..0e92a1a 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -575,11 +575,11 @@ app.whenReady().then(async () => { }, `scene ${mode} ready`) for (let index = 0; index < 8; index++) { await command({ action: 'step', index }) - const s = await sceneReady(index + 1) + const s = await sceneReady([1, 2, 8, 4, 8, 6, 7, 8][index]) modes.push(s.scene.mode) assert.equal(s.scene.stepIndex, index) assert.equal(s.channels.main.desired, 'stopped') - assert.equal(s.channels.music.desired, 'playing') + assert.equal(s.channels.music.desired, [2, 4].includes(s.scene.mode) ? 'paused' : 'playing') assert.equal( await inspect('document.querySelectorAll("button,input,textarea,select").length'), 0, @@ -615,7 +615,7 @@ app.whenReady().then(async () => { } await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume') assert.equal((await state()).channels.main.desired, 'paused') - assert.equal((await state()).channels.music.desired, 'playing') + assert.equal((await state()).channels.music.desired, 'paused') await clickMedia('Продолжить') await poll( async () => (await state()).channels.main.status === 'playing', diff --git a/src/components/SceneView.vue b/src/components/SceneView.vue index ab422b8..fbc7917 100644 --- a/src/components/SceneView.vue +++ b/src/components/SceneView.vue @@ -80,11 +80,9 @@ function imageURL(url: string, version: string) { -

Успехов

Видео закрывашки не задано

-

Спасибо за игру!

= { 1: 'Обложка', 2: 'Введение', - 3: 'Успехов', 4: 'Закрывашка', - 5: 'Спасибо за игру', 6: 'Результаты', 7: 'Таймер', 8: 'Текст',