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_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',
@@ -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";
+9 -2
View File
@@ -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,
+4 -2
View File
@@ -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' })
+47
View File
@@ -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)
}
})
+9 -5
View File
@@ -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) {
+4 -2
View File
@@ -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,
+17
View File
@@ -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: 'Спасибо за игру!' },
])
})
+8 -4
View File
@@ -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,
+3 -3
View File
@@ -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',
-2
View File
@@ -80,11 +80,9 @@ function imageURL(url: string, version: string) {
</svg>
</div>
</section>
<p v-else-if="mode === 3" class="message">Успехов</p>
<p v-else-if="mode === 4 && !hasMainSource" class="message">
Видео закрывашки не задано
</p>
<p v-else-if="mode === 5" class="message">Спасибо за игру!</p>
<ResultsTable
v-else-if="mode === 6"
:snapshot="results"
+1 -1
View File
@@ -30,7 +30,7 @@ export interface Playlist {
name: string
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 {
mode: ScreenMode
text: string
-2
View File
@@ -3,9 +3,7 @@ import type { ScreenMode, TimerState } from '../types/screen.js'
export const modeLabels: Record<ScreenMode, string> = {
1: 'Обложка',
2: 'Введение',
3: 'Успехов',
4: 'Закрывашка',
5: 'Спасибо за игру',
6: 'Результаты',
7: 'Таймер',
8: 'Текст',