This commit is contained in:
2026-10-19 02:34:50 +07:00
parent 06af1e047d
commit e2f8a3302e
15 changed files with 109 additions and 32 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ SCREEN-001/SCREEN-004 остаётся открытой; эта инструкц
## Полный план ## Полный план
- Пройдите восемь шагов по порядку и назад: обложка, вступление, «Успехов», - Пройдите восемь шагов по порядку и назад: обложка, вступление, текст «Успехов»,
заключительное видео, «Спасибо за игру!», результаты, таймер и текст. заключительное видео, текст «Спасибо за игру!», результаты, таймер и текст.
- Убедитесь, что шаги не запускают звук или таймер сами. Запустите вступление/видео - Убедитесь, что шаги не запускают звук или таймер сами. Запустите вступление/видео
явной кнопкой, переключитесь в другой режим: основное медиа останавливается. явной кнопкой, переключитесь в другой режим: основное медиа останавливается.
- Включите музыку и переключайте сцены: музыка остаётся независимой. Предпросмотр - Включите музыку и переключайте сцены: музыка остаётся независимой. Предпросмотр
+2 -2
View File
@@ -90,8 +90,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
## Проведение показа ## Проведение показа
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг» В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
для переходов по плану. В редакторе шагов доступны восемь режимов: «Обложка», для переходов по плану. В редакторе шагов доступны шесть режимов: «Обложка»,
«Введение», «Успехов», «Закрывашка», «Спасибо за игру», «Введение», «Закрывашка»,
«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает «Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает
медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает
успешного воспроизведения — смотрите статус и фактическое изображение/звук. успешного воспроизведения — смотрите статус и фактическое изображение/звук.
+3 -3
View File
@@ -8,13 +8,13 @@ const modes = [
'SCREEN_MODE_UNSPECIFIED', 'SCREEN_MODE_UNSPECIFIED',
'SCREEN_MODE_COVER', 'SCREEN_MODE_COVER',
'SCREEN_MODE_INTRO', 'SCREEN_MODE_INTRO',
'SCREEN_MODE_INTRO_DONE', undefined,
'SCREEN_MODE_CONCLUSION', 'SCREEN_MODE_CONCLUSION',
'SCREEN_MODE_CONCLUSION_DONE', undefined,
'SCREEN_MODE_RESULTS', 'SCREEN_MODE_RESULTS',
'SCREEN_MODE_TIMER', 'SCREEN_MODE_TIMER',
'SCREEN_MODE_TEXT', 'SCREEN_MODE_TEXT',
] as const satisfies readonly ScreenMode[] ] as const satisfies readonly (ScreenMode | undefined)[]
const commands = [ const commands = [
'SCREEN_COMMAND_UNSPECIFIED', 'SCREEN_COMMAND_UNSPECIFIED',
'SCREEN_COMMAND_PLAY', 'SCREEN_COMMAND_PLAY',
@@ -6,9 +6,7 @@ export type ScreenMode =
| "SCREEN_MODE_UNSPECIFIED" | "SCREEN_MODE_UNSPECIFIED"
| "SCREEN_MODE_COVER" | "SCREEN_MODE_COVER"
| "SCREEN_MODE_INTRO" | "SCREEN_MODE_INTRO"
| "SCREEN_MODE_INTRO_DONE"
| "SCREEN_MODE_CONCLUSION" | "SCREEN_MODE_CONCLUSION"
| "SCREEN_MODE_CONCLUSION_DONE"
| "SCREEN_MODE_RESULTS" | "SCREEN_MODE_RESULTS"
| "SCREEN_MODE_TIMER" | "SCREEN_MODE_TIMER"
| "SCREEN_MODE_TEXT"; | "SCREEN_MODE_TEXT";
+9 -2
View File
@@ -13,6 +13,7 @@ import type {
TimerState, TimerState,
ScreenStep, ScreenStep,
} from '../src/types/screen.js' } from '../src/types/screen.js'
import { parsePlan } from './screen-plan.js'
import { parseResults } from './results.js' import { parseResults } from './results.js'
import type { LocalPackage } from './package-store.js' import type { LocalPackage } from './package-store.js'
@@ -32,7 +33,12 @@ export function parseCommand(value: unknown): Command {
let intent: Intent let intent: Intent
if (c.action === 'mute' && typeof c.enabled === 'boolean') if (c.action === 'mute' && typeof c.enabled === 'boolean')
intent = { action: 'mute', enabled: c.enabled } 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 } intent = { action: 'mode', mode: c.mode as ScreenMode }
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0)) else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
intent = { action: 'step', index: c.index } intent = { action: 'step', index: c.index }
@@ -259,7 +265,8 @@ export class Controller {
background: pkg.package.media.background background: pkg.package.media.background
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}` ? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}`
: null, : 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) => ({ playlists: pkg.package.playlists.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
+4 -2
View File
@@ -1180,6 +1180,8 @@ test('таймер синхронизируется один раз на вер
applyRemote(c, { ...next, sound: false }, next) applyRemote(c, { ...next, sound: false }, next)
assert.equal(c.state.muted, false) assert.equal(c.state.muted, false)
assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' })) 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 })) assert.throws(() => parseRemote({ mode: 99 }))
}) })
@@ -1834,8 +1836,8 @@ test('локальные режим, музыка и таймер публику
await f.prepare() await f.prepare()
f.controller.output(true) f.controller.output(true)
await f.online.poll() await f.online.poll()
await f.online.planCommand({ action: 'mode', mode: 3 }) await f.online.planCommand({ action: 'mode', mode: 1 })
assert.equal(f.model.remote.mode, 'SCREEN_MODE_INTRO_DONE') assert.equal(f.model.remote.mode, 'SCREEN_MODE_COVER')
await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 }) await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 })
assert.equal(f.model.remote.timerDurationSeconds, 60) assert.equal(f.model.remote.timerDurationSeconds, 60)
await f.online.planCommand({ action: 'play', channel: 'music' }) await f.online.planCommand({ action: 'play', channel: 'music' })
+47
View File
@@ -6,6 +6,7 @@ import { readdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { test, type TestContext } from 'node:test' import { test, type TestContext } from 'node:test'
import { Controller } from './controller.js'
import { buildScreenPackage, parseScreenPackage } from './screen-package.js' import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
import { import {
PackageStore, PackageStore,
@@ -570,3 +571,49 @@ test('инкрементальный кеш: ошибка и отмена не
await assert.rejects(f.store.ensureAsset(f.pkg, second!.id, abort.signal)) await assert.rejects(f.store.ensureAsset(f.pkg, second!.id, abort.signal))
await assert.rejects(f.store.localFile(f.key, second!.id)) 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)
}
})
+9 -5
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 { Controller, parseCommand } from './controller.js' 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 { packageKey, type LocalPackage } from './package-store.js'
import { parseResults } from './results.js' import { parseResults } from './results.js'
import { formatTimer, remainingTime } from '../src/utils/presentation.js' import { formatTimer, remainingTime } from '../src/utils/presentation.js'
@@ -53,7 +53,7 @@ function fixture() {
formatVersion: 1, formatVersion: 1,
key: packageKey(pkg.server, 7), key: packageKey(pkg.server, 7),
version: 'a'.repeat(64), version: 'a'.repeat(64),
package: pkg, package: parseScreenPackage(pkg),
files: [], files: [],
} }
c.selectPackage(local) c.selectPackage(local)
@@ -79,7 +79,9 @@ test('проходит восемь шагов, подставляет лока
assert.notEqual(f.c.state.catalog.cover, f.c.state.catalog.background) assert.notEqual(f.c.state.catalog.cover, f.c.state.catalog.background)
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
f.send({ action: 'step', index: 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.scene.stepIndex, i)
assert.equal(f.c.state.channels.main.desired, 'stopped') assert.equal(f.c.state.channels.main.desired, 'stopped')
assert.equal(f.c.state.timer.status, '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.stepIndex, 1)
assert.equal(f.c.state.scene.mode, 2) 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.main.desired, 'stopped')
assert.equal(f.c.state.channels.music.desired, 'playing') assert.equal(f.c.state.channels.music.desired, 'playing')
assert.equal(f.c.state.scene.stepIndex, null) assert.equal(f.c.state.scene.stepIndex, null)
@@ -209,13 +211,15 @@ test('не допускает пустой текст и некорректны
{ action: 'timer-set', label: '', durationSeconds: 0 }, { action: 'timer-set', label: '', durationSeconds: 0 },
{ action: 'timer-set', label: '', durationSeconds: 1.5 }, { action: 'timer-set', label: '', durationSeconds: 1.5 },
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 }, { action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
{ action: 'mode', mode: 3 },
{ action: 'mode', mode: 5 },
{ action: 'mode', mode: 9 }, { action: 'mode', mode: 9 },
{ action: 'mode', mode: NaN }, { action: 'mode', mode: NaN },
{ action: 'step', index: -1 }, { action: 'step', index: -1 },
{ action: ['timer-start'] }, { action: ['timer-start'] },
]) ])
assert.throws(() => parseCommand({ ...base, ...intent })) 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') assert.equal(parseCommand({ ...base, action: 'mode', mode }).action, 'mode')
}) })
function snapshot(key: string) { function snapshot(key: string) {
+4 -2
View File
@@ -93,15 +93,17 @@ export function parseRemote(value: unknown): RemoteState {
'UNSPECIFIED', 'UNSPECIFIED',
'COVER', 'COVER',
'INTRO', 'INTRO',
'INTRO_DONE', '',
'CONCLUSION', 'CONCLUSION',
'CONCLUSION_DONE', '',
'RESULTS', 'RESULTS',
'TIMER', 'TIMER',
'TEXT', 'TEXT',
], ],
'state.mode', 'state.mode',
) || 1 ) || 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 duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds')
const remaining = integer( const remaining = integer(
s.timerRemainingSeconds ?? 0, s.timerRemainingSeconds ?? 0,
+17
View File
@@ -22,3 +22,20 @@ test('план: режимы protobuf, значения по умолчанию
]) ])
assert.throws(() => parsePlan(value)) 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: 'Спасибо за игру!' },
])
})
+8 -4
View File
@@ -22,7 +22,7 @@ export function parsePlan(value: unknown): ScreenPlan {
const steps = list.map((value, index) => { const steps = list.map((value, index) => {
const field = `plan.steps[${index}]` const field = `plan.steps[${index}]`
const step = object(value, field) const step = object(value, field)
let mode: ScreenMode let mode: number
try { try {
mode = integer( mode = integer(
typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_') typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_')
@@ -31,7 +31,7 @@ export function parsePlan(value: unknown): ScreenPlan {
1, 1,
8, 8,
`${field}.mode`, `${field}.mode`,
) as ScreenMode )
} catch { } catch {
throw new ResponseFieldError( throw new ResponseFieldError(
`${field}.mode`, `${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 label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim()
const seconds = integer( const seconds = integer(
step.timerDurationSeconds ?? 0, step.timerDurationSeconds ?? 0,
@@ -63,7 +67,7 @@ export function parsePlan(value: unknown): ScreenPlan {
'Укажите таймер от 1 до 86400 секунд и подпись до 200 байт', 'Укажите таймер от 1 до 86400 секунд и подпись до 200 байт',
) )
return { return {
mode, mode: mode as ScreenMode,
text: content, text: content,
timerLabel: mode === 7 ? label || 'Перерыв' : label, timerLabel: mode === 7 ? label || 'Перерыв' : label,
timerDurationSeconds: seconds, timerDurationSeconds: seconds,
+3 -3
View File
@@ -575,11 +575,11 @@ app.whenReady().then(async () => {
}, `scene ${mode} ready`) }, `scene ${mode} ready`)
for (let index = 0; index < 8; index++) { for (let index = 0; index < 8; index++) {
await command({ action: 'step', 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) modes.push(s.scene.mode)
assert.equal(s.scene.stepIndex, index) assert.equal(s.scene.stepIndex, index)
assert.equal(s.channels.main.desired, 'stopped') 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( assert.equal(
await inspect('document.querySelectorAll("button,input,textarea,select").length'), await inspect('document.querySelectorAll("button,input,textarea,select").length'),
0, 0,
@@ -615,7 +615,7 @@ app.whenReady().then(async () => {
} }
await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume') 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.main.desired, 'paused')
assert.equal((await state()).channels.music.desired, 'playing') assert.equal((await state()).channels.music.desired, 'paused')
await clickMedia('Продолжить') await clickMedia('Продолжить')
await poll( await poll(
async () => (await state()).channels.main.status === 'playing', async () => (await state()).channels.main.status === 'playing',
-2
View File
@@ -80,11 +80,9 @@ function imageURL(url: string, version: string) {
</svg> </svg>
</div> </div>
</section> </section>
<p v-else-if="mode === 3" class="message">Успехов</p>
<p v-else-if="mode === 4 && !hasMainSource" class="message"> <p v-else-if="mode === 4 && !hasMainSource" class="message">
Видео закрывашки не задано Видео закрывашки не задано
</p> </p>
<p v-else-if="mode === 5" class="message">Спасибо за игру!</p>
<ResultsTable <ResultsTable
v-else-if="mode === 6" v-else-if="mode === 6"
:snapshot="results" :snapshot="results"
+1 -1
View File
@@ -30,7 +30,7 @@ export interface Playlist {
name: string name: string
tracks: { id: number; name: string; source: MediaSource }[] tracks: { id: number; name: string; source: MediaSource }[]
} }
export type ScreenMode = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 export type ScreenMode = 1 | 2 | 4 | 6 | 7 | 8
export interface ScreenStep { export interface ScreenStep {
mode: ScreenMode mode: ScreenMode
text: string text: string
-2
View File
@@ -3,9 +3,7 @@ import type { ScreenMode, TimerState } from '../types/screen.js'
export const modeLabels: Record<ScreenMode, string> = { export const modeLabels: Record<ScreenMode, string> = {
1: 'Обложка', 1: 'Обложка',
2: 'Введение', 2: 'Введение',
3: 'Успехов',
4: 'Закрывашка', 4: 'Закрывашка',
5: 'Спасибо за игру',
6: 'Результаты', 6: 'Результаты',
7: 'Таймер', 7: 'Таймер',
8: 'Текст', 8: 'Текст',