up
This commit is contained in:
@@ -16,8 +16,8 @@ SCREEN-001/SCREEN-004 остаётся открытой; эта инструкц
|
||||
|
||||
## Полный план
|
||||
|
||||
- Пройдите восемь шагов по порядку и назад: обложка, вступление, «Успехов»,
|
||||
заключительное видео, «Спасибо за игру!», результаты, таймер и текст.
|
||||
- Пройдите восемь шагов по порядку и назад: обложка, вступление, текст «Успехов»,
|
||||
заключительное видео, текст «Спасибо за игру!», результаты, таймер и текст.
|
||||
- Убедитесь, что шаги не запускают звук или таймер сами. Запустите вступление/видео
|
||||
явной кнопкой, переключитесь в другой режим: основное медиа останавливается.
|
||||
- Включите музыку и переключайте сцены: музыка остаётся независимой. Предпросмотр
|
||||
|
||||
+2
-2
@@ -90,8 +90,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
## Проведение показа
|
||||
|
||||
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
|
||||
для переходов по плану. В редакторе шагов доступны восемь режимов: «Обложка»,
|
||||
«Введение», «Успехов», «Закрывашка», «Спасибо за игру»,
|
||||
для переходов по плану. В редакторе шагов доступны шесть режимов: «Обложка»,
|
||||
«Введение», «Закрывашка»,
|
||||
«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает
|
||||
медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает
|
||||
успешного воспроизведения — смотрите статус и фактическое изображение/звук.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: 'Спасибо за игру!' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: 'Текст',
|
||||
|
||||
Reference in New Issue
Block a user