+
{{ state.timerLabel || 'Перерыв' }}
{{ timerDisplay }}
-
+
+ {{ introAudio ? 'Аудиозапись введения' : 'Введение не задано' }}
+
Видео закрывашки не задано
-
{{ state.text }}
+
+ {{ state.text }}
+
Результаты
@@ -101,7 +118,9 @@ onUnmounted(() => {
{{ result.place }}
{{ result.team?.name }}
- {{ result.answers?.find((answer) => answer.questionCode === question.code)?.score ?? 0 }}
+ {{
+ result.answers?.find((answer) => answer.questionCode === question.code)?.score ?? 0
+ }}
{{ result.totalScore }}
{{ result.actionsCount }}
@@ -109,7 +128,13 @@ onUnmounted(() => {
-
+
+
{{ state.gameName || 'Обложка не задана' }}
@@ -117,10 +142,11 @@ onUnmounted(() => {
.screen-preview {
align-items: center;
aspect-ratio: 16 / 9;
- background-color: #111;
+ background-color: var(--brand-background);
background-position: center;
background-size: cover;
- color: #eee;
+ color: var(--brand-text);
+ container-type: inline-size;
display: flex;
justify-content: center;
overflow: hidden;
@@ -136,13 +162,13 @@ onUnmounted(() => {
.timer-preview h2,
.preview-message,
.preview-results h2 {
- font-size: clamp(16px, 3vw, 32px);
+ font-size: clamp(10px, 4cqw, 32px);
margin: 0 0 10px;
}
.timer-preview strong {
display: block;
- font-size: clamp(48px, 10vw, 120px);
+ font-size: clamp(20px, 16cqw, 120px);
font-variant-numeric: tabular-nums;
line-height: 0.9;
}
@@ -174,14 +200,26 @@ onUnmounted(() => {
.preview-results table {
border-collapse: collapse;
- font-size: 10px;
+ font-size: clamp(3px, 1.4cqw, 10px);
width: 100%;
}
.preview-results th,
.preview-results td {
- border-bottom: 1px solid rgb(255 255 255 / 18%);
+ border-bottom: 1px solid var(--brand-border);
padding: 3px;
text-align: left;
}
+
+.screen-thumbnail {
+ pointer-events: none;
+}
+
+.preview-message {
+ max-height: 90%;
+ max-width: 95%;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
diff --git a/tests/screens-control.mjs b/tests/screens-control.mjs
new file mode 100644
index 0000000..ecff5f2
--- /dev/null
+++ b/tests/screens-control.mjs
@@ -0,0 +1,322 @@
+// Run: node tests/screens-control.mjs
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { runInNewContext } from 'node:vm'
+
+import ts from 'typescript'
+import * as vue from 'vue'
+import { compileScript, parse } from 'vue/compiler-sfc'
+
+function source(name) {
+ return parse(readFileSync(new URL(`../src/components/${name}.vue`, import.meta.url), 'utf8'))
+ .descriptor
+}
+function setup(name, props) {
+ const script = compileScript(source(name), { id: name })
+ const context = { exports: {}, require: (name) => (name === 'vue' ? vue : {}) }
+ runInNewContext(ts.transpile(script.content, { module: ts.ModuleKind.CommonJS }), context)
+ const events = []
+ const scope = vue.effectScope()
+ const state = scope.run(() =>
+ context.exports.default.setup(props, {
+ expose() {},
+ emit: (...event) => events.push(structuredClone(event)),
+ }),
+ )
+ return { state, events, stop: () => scope.stop() }
+}
+const playlist = (id, ids) => ({
+ ID: id,
+ name: `Плейлист ${id}`,
+ tracks: ids.map((ID) => ({ ID, name: `Трек ${ID}` })),
+})
+const props = vue.reactive({
+ playlists: [playlist(1, [11, 12]), playlist(2, [21, 22])],
+ music: { playlist: playlist(1, [11, 12]), playback: 'MUSIC_PLAYBACK_PLAYING', trackIndex: 0 },
+ isBusy: false,
+})
+const panel = setup('GameMusicPlanPanel', props)
+panel.state.edit()
+await vue.nextTick()
+panel.state.selectedId.value = 2
+await vue.nextTick()
+assert.equal(panel.events.length, 0, 'Selecting an editor playlist must not change game music')
+panel.state.uploadTrack({ file: { file: { name: 'test.mp3' } } })
+assert.deepEqual(panel.events.at(-1), ['uploadTrack', 2, { name: 'test.mp3' }])
+panel.state.move(0, 1)
+assert.deepEqual(Array.from(panel.state.order.value), [22, 21])
+props.playlists = [playlist(1, [11, 12]), playlist(2, [21, 22])]
+await vue.nextTick()
+assert.deepEqual(Array.from(panel.state.order.value), [22, 21], 'Polling must preserve draft order')
+props.playlists = [playlist(1, [11, 12]), playlist(2, [21, 22, 23])]
+await vue.nextTick()
+assert.equal(panel.state.hasConflict.value, true)
+assert.deepEqual(Array.from(panel.state.order.value), [22, 21])
+panel.state.reset()
+assert.deepEqual(Array.from(panel.state.order.value), [21, 22, 23])
+panel.state.move(0, 1)
+props.playlists = [playlist(1, [11, 12]), playlist(2, [22, 21, 23])]
+await vue.nextTick()
+assert.equal(
+ panel.state.isOrderDirty.value,
+ false,
+ 'Successful save must accept the returned order',
+)
+panel.state.name.value = 'Несохранённое название'
+props.playlists = [playlist(1, [11, 12])]
+await vue.nextTick()
+assert.equal(
+ panel.state.name.value,
+ 'Несохранённое название',
+ 'Remote deletion must preserve the draft',
+)
+assert.equal(panel.state.selectedId.value, 2)
+panel.state.reset()
+assert.equal(panel.state.selectedId.value, null)
+assert.equal(panel.state.playbackLabel.value, 'Пауза')
+props.music.playback = 'MUSIC_PLAYBACK_PAUSED'
+assert.equal(panel.state.playbackLabel.value, 'Продолжить')
+props.music.playback = 'MUSIC_PLAYBACK_STOPPED'
+assert.equal(panel.state.playbackLabel.value, 'Старт')
+panel.state.newName.value = 'Черновик нового плейлиста'
+props.playlists = [playlist(1, [11, 12]), playlist(3, [31, 32])]
+await vue.nextTick()
+assert.equal(panel.state.newName.value, 'Черновик нового плейлиста', 'Background library updates must preserve a new playlist name')
+panel.stop()
+
+const screenProps = vue.reactive({
+ screens: [{ status: 'connected', timerState: 'SCREEN_TIMER_STATE_RUNNING' }],
+ previewQuestions: [],
+ previewTeams: [],
+ screenPlanDraft: [],
+ screenPlanSaving: false,
+ screenPlanMode: 'use',
+ screenVolume: 100,
+ isBusy: false,
+})
+const screens = setup('GameScreenPlanPanel', screenProps)
+for (const [timerState, command] of [
+ ['RUNNING', 'PAUSE'],
+ ['PAUSED', 'RESUME'],
+ ['STOPPED', 'START'],
+ ['FINISHED', 'START'],
+]) {
+ screenProps.screens[0].timerState = `SCREEN_TIMER_STATE_${timerState}`
+ assert.equal(screens.state.timerControl.value.command, `SCREEN_COMMAND_TIMER_${command}`)
+}
+screenProps.screens = []
+assert.equal(screens.state.hasConnectedScreen.value, false)
+assert.equal(
+ screens.state.thumbnail({ mode: 'SCREEN_MODE_TEXT', text: 'Offline draft' }).text,
+ 'Offline draft',
+)
+screens.stop()
+
+function functions(name, names) {
+ const text = source(name).scriptSetup.content
+ const ast = ts.createSourceFile(`${name}.ts`, text, ts.ScriptTarget.Latest, true)
+ return ast.statements
+ .filter((item) => ts.isFunctionDeclaration(item) && names.includes(item.name.text))
+ .map((item) => item.getText(ast))
+ .join('\n')
+}
+const errors = []
+const calls = []
+const state = {
+ Error,
+ music: vue.ref({
+ playlist: playlist(1, [11, 12]),
+ positionMs: 5000,
+ playback: 'MUSIC_PLAYBACK_PLAYING',
+ }),
+ playlists: vue.ref([]),
+ isMusicBusy: vue.ref(false),
+ musicRequestVersion: 0,
+ musicSyncedAt: Date.now(),
+ gameId: '7',
+ message: { error: (error) => errors.push(error), success() {} },
+ client: {
+ GetGameMusic: async () => ({
+ playlists: [playlist(2, [21, 22])],
+ state: { playlist: playlist(1, [11, 12]) },
+ }),
+ SetGameMusic: async (request) => {
+ calls.push(request)
+ return { state: { ...state.music.value, ...request } }
+ },
+ DeletePlaylistTrack: async (request) => {
+ calls.push(request)
+ },
+ UploadPlaylistTrack: async (request) => {
+ calls.push(request)
+ },
+ ReorderPlaylistTracks: async () => {
+ throw new Error('Save failed')
+ },
+ },
+ btoa: (value) => Buffer.from(value, 'binary').toString('base64'),
+}
+runInNewContext(
+ ts.transpile(
+ functions('GamePage', [
+ 'getGameMusic',
+ 'setGameMusic',
+ 'deleteTrack',
+ 'uploadTrack',
+ 'uint8ToBase64',
+ 'reorderTracks',
+ ]),
+ { target: ts.ScriptTarget.ES2022 },
+ ),
+ state,
+)
+await state.deleteTrack(2, 21)
+assert.deepEqual({ ...calls.at(-1) }, { playlistID: 2, trackID: 21 })
+await state.uploadTrack(2, {
+ name: 'test.mp3',
+ size: 3,
+ arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,
+})
+assert.equal(calls.at(-1).playlistID, 2)
+for (const file of [
+ { name: 'empty.mp3', size: 0 },
+ { name: 'large.mp3', size: 64 * 1024 * 1024 + 1 },
+]) {
+ const before = calls.length
+ await state.uploadTrack(2, file)
+ assert.equal(calls.length, before)
+}
+const count = calls.length
+await state.uploadTrack(2, { name: 'test.png', size: 3 })
+assert.equal(calls.length, count)
+await state.reorderTracks(2, [22, 21])
+assert.equal(errors.at(-1), 'Save failed')
+assert.equal(state.isMusicBusy.value, false)
+state.music.value = {
+ playlist: playlist(1, [11, 12]),
+ positionMs: 5000,
+ playback: 'MUSIC_PLAYBACK_PLAYING',
+}
+state.musicSyncedAt = Date.now() - 1500
+await state.setGameMusic({ playback: 'MUSIC_PLAYBACK_PAUSED' })
+assert.ok(calls.at(-1).positionMs >= 6500, 'Pausing must include time elapsed since the last poll')
+let resolvePoll
+state.client.GetGameMusic = () =>
+ new Promise((resolve) => {
+ resolvePoll = resolve
+ })
+const poll = state.getGameMusic()
+await state.setGameMusic({ volume: 40 })
+resolvePoll({ state: { volume: 1 }, playlists: [] })
+await poll
+assert.equal(state.music.value.volume, 40, 'An old poll must not overwrite a successful command')
+
+const planState = {
+ Error,
+ screenPlanSaving: vue.ref(false),
+ screenPlanRequestVersion: 0,
+ screenPlanDraft: vue.ref([{ mode: 'SCREEN_MODE_TIMER', timerDurationSeconds: 0 }]),
+ screenPlan: vue.ref(),
+ screenPlanDirty: vue.ref(true),
+ screenPlanMode: vue.ref('edit'),
+ gameId: '7',
+ message: state.message,
+ client: {
+ SaveScreenPlan: async () => {
+ throw new Error('Plan save failed')
+ },
+ },
+}
+runInNewContext(
+ ts.transpile(functions('GamePage', ['saveScreenPlan']), { target: ts.ScriptTarget.ES2022 }),
+ planState,
+)
+await planState.saveScreenPlan()
+assert.match(errors.at(-1), /шаге 1/)
+planState.screenPlanDraft.value[0].timerDurationSeconds = 60
+await planState.saveScreenPlan()
+assert.equal(errors.at(-1), 'Plan save failed')
+assert.equal(planState.screenPlanMode.value, 'edit')
+assert.equal(planState.screenPlanDirty.value, true)
+assert.equal(planState.screenPlanDraft.value[0].timerDurationSeconds, 60)
+assert.equal(planState.screenPlanSaving.value, false)
+
+function watcher(name, observed) {
+ const script = source(name).scriptSetup.content
+ const ast = ts.createSourceFile(`${name}.ts`, script, ts.ScriptTarget.Latest, true)
+ const statement = ast.statements.find(
+ (item) =>
+ ts.isExpressionStatement(item) &&
+ ts.isCallExpression(item.expression) &&
+ item.expression.expression.getText(ast) === 'watch' &&
+ item.expression.arguments[0].getText(ast) === observed,
+ )
+ return `const handler = ${statement.expression.arguments[1].getText(ast)}`
+}
+const media = {
+ currentTime: 23,
+ volume: 1,
+ readyState: 1,
+ play() {
+ this.played = true
+ return Promise.resolve()
+ },
+ pause() {
+ this.paused = true
+ },
+}
+const player = {
+ appliedCommandVersion: 0,
+ nextTick: vue.nextTick,
+ screenMode: vue.ref('SCREEN_MODE_INTRO'),
+ introAudioElement: vue.ref(media),
+ conclusionVideoElement: vue.ref(null),
+ mediaError: vue.ref(''),
+}
+runInNewContext(
+ ts.transpile(watcher('ScreenPage', 'state') + '\nthis.commandHandler = handler', {
+ target: ts.ScriptTarget.ES2022,
+ }),
+ player,
+)
+for (const [index, command] of ['VOLUME', 'PAUSE', 'RESUME'].entries()) {
+ await player.commandHandler({
+ command: `SCREEN_COMMAND_${command}`,
+ stateVersion: index + 1,
+ positionMs: 0,
+ volume: 30,
+ })
+ assert.equal(media.currentTime, 23, `${command} must preserve playback position`)
+}
+await player.commandHandler({ command: 'SCREEN_COMMAND_REPLAY', stateVersion: 4, positionMs: 0 })
+assert.equal(media.currentTime, 0)
+const musicPlayer = {
+ state: vue.ref({
+ music: { playlist: playlist(1, [11]), playback: 'MUSIC_PLAYBACK_PLAYING', positionMs: 0 },
+ }),
+ nextTick: vue.nextTick,
+ musicAudioElement: vue.ref(media),
+ waitForMusicMetadata: async () => {},
+ mediaError: vue.ref(''),
+}
+runInNewContext(
+ ts.transpile(watcher('ScreenPage', 'musicSyncState') + '\nthis.musicHandler = handler', {
+ target: ts.ScriptTarget.ES2022,
+ }),
+ musicPlayer,
+)
+media.currentTime = 23
+await musicPlayer.musicHandler(
+ [1, 0, 'MUSIC_PLAYBACK_PLAYING', 2, 0],
+ [1, 0, 'MUSIC_PLAYBACK_PLAYING', 1, 20000],
+)
+assert.equal(media.currentTime, 0, 'Restarting the current track must reset the player')
+media.currentTime = 23
+await musicPlayer.musicHandler(
+ [1, 0, 'MUSIC_PLAYBACK_PLAYING', 2, 21000],
+ [1, 0, 'MUSIC_PLAYBACK_PLAYING', 2, 20000],
+)
+assert.equal(media.currentTime, 23, 'Polling must not seek the player')
+console.log(
+ 'Screen controls: drafts, conflicts, explicit playlist IDs, timer/music controls, errors, stale polls and playback positions passed',
+)