From 4e4e5c66df2803de4eec1b49c0ea456486886bbd Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Wed, 21 Oct 2026 02:55:40 +0700 Subject: [PATCH] up --- src/components/GameMusicPlanPanel.vue | 457 +++++++++++++++++++++++++ src/components/GamePage.vue | 330 +++++++++++++----- src/components/GameScreenPlanPanel.vue | 457 +++++++++++++------------ src/components/GameScreensPanel.vue | 264 ++------------ src/components/ScreenPage.vue | 12 +- src/components/ScreenPreview.vue | 70 +++- tests/screens-control.mjs | 322 +++++++++++++++++ 7 files changed, 1346 insertions(+), 566 deletions(-) create mode 100644 src/components/GameMusicPlanPanel.vue create mode 100644 tests/screens-control.mjs diff --git a/src/components/GameMusicPlanPanel.vue b/src/components/GameMusicPlanPanel.vue new file mode 100644 index 0000000..8fc0ed6 --- /dev/null +++ b/src/components/GameMusicPlanPanel.vue @@ -0,0 +1,457 @@ + + + + + diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index 24e31d4..e61e030 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -47,11 +47,13 @@ import type { } from '@/api/generated/crabs/evening_detective_server' import notificationSoundURL from '@/assets/icq-uh-oh.wav' import GameMapPanel from '@/components/GameMapPanel.vue' +import GameMusicPlanPanel from '@/components/GameMusicPlanPanel.vue' import GameScreenPlanPanel from '@/components/GameScreenPlanPanel.vue' import GameScreensPanel from '@/components/GameScreensPanel.vue' import GameStatePanel from '@/components/GameStatePanel.vue' import GameTeamsPanel from '@/components/GameTeamsPanel.vue' import HeaderMenu from '@/components/HeaderMenu.vue' +import ScreenPreview from '@/components/ScreenPreview.vue' import TeamReceiptModal from '@/components/TeamReceiptModal.vue' import TicketCheckModal from '@/components/TicketCheckModal.vue' import UiBlock from '@/components/UiBlock.vue' @@ -234,6 +236,12 @@ const activeTeamCount = computed( const screens = ref([]) const playlists = ref([]) const music = ref() +const isMusicBusy = ref(false) +let musicRequestVersion = 0 +let musicSyncedAt = Date.now() +const isScreenBusy = ref(false) +const isScreensLoading = ref(true) +const screensError = ref('') const screenPlan = ref() const screenPlanDraft = ref([]) const screenPlanDirty = ref(false) @@ -246,7 +254,15 @@ const mapItems = ref([]) const mapSaving = ref(false) const mapDragging = ref(false) const screenVolume = ref(100) -const screenPosition = ref(0) +const previewScreen = computed( + () => screens.value.find((screen) => screen.status === 'connected') || screens.value[0], +) +watch( + () => previewScreen.value?.volume, + (value) => { + screenVolume.value = value ?? 100 + }, +) function reportSoundError(error: unknown) { if (isDisposed || hasReportedSoundError) return @@ -368,26 +384,38 @@ async function getGame(id: number) { if (canManageGame.value) getGame(Number(gameId)) async function getGameScreens() { - const res = await client.GetGameScreens({ gameID: Number(gameId) }) - screens.value = res.screens || [] - const previewScreen = - screens.value.find((screen) => screen.status === 'connected') || screens.value[0] - if (previewScreen?.mode === 'SCREEN_MODE_RESULTS') { - const result = await client.GetGameResults({ gameID: Number(gameId) }) - previewQuestions.value = result.questions || [] - previewTeams.value = result.teams || [] - } else { - previewQuestions.value = [] - previewTeams.value = [] + try { + const res = await client.GetGameScreens({ gameID: Number(gameId) }) + screens.value = res.screens || [] + screensError.value = '' + if ( + (screenPlanMode.value === 'edit' ? screenPlanDraft.value : screenPlan.value?.steps)?.some( + (step) => step.mode === 'SCREEN_MODE_RESULTS', + ) || + previewScreen.value?.mode === 'SCREEN_MODE_RESULTS' + ) { + const result = await client.GetGameResults({ gameID: Number(gameId) }) + previewQuestions.value = result.questions || [] + previewTeams.value = result.teams || [] + } + } catch (err) { + screensError.value = + err instanceof Error ? err.message : 'Не удалось получить состояние экранов' + } finally { + isScreensLoading.value = false } } if (canManageGame.value) getGameScreens() -async function getGameMusic() { +async function getGameMusic(isAfterSave = false) { + if (isMusicBusy.value && !isAfterSave) return + const requestVersion = ++musicRequestVersion try { const res = await client.GetGameMusic({ gameID: Number(gameId) }) + if (requestVersion !== musicRequestVersion) return playlists.value = res.playlists || [] music.value = res.state + musicSyncedAt = Date.now() } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры') } @@ -399,20 +427,23 @@ function isScreenPlanEditing() { } async function getScreenPlan() { - console.log('getScreenPlan') - if (isScreenPlanEditing() || screenPlanDirty.value) return - console.log('no skip getScreenPlan') + if (isScreenPlanEditing() || screenPlanDirty.value || isScreenBusy.value) return const requestVersion = ++screenPlanRequestVersion - const res = await client.GetScreenPlan({ gameID: Number(gameId) }) - if ( - requestVersion !== screenPlanRequestVersion || - isScreenPlanEditing() || - screenPlanDirty.value - ) { - return + try { + const res = await client.GetScreenPlan({ gameID: Number(gameId) }) + if ( + requestVersion !== screenPlanRequestVersion || + isScreenPlanEditing() || + isScreenBusy.value || + screenPlanDirty.value + ) { + return + } + screenPlan.value = res.plan + screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || [] + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось получить план экранов') } - screenPlan.value = res.plan - screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || [] } if (canManageGame.value) getScreenPlan() @@ -464,6 +495,18 @@ function clearScreenPlan() { async function saveScreenPlan() { if (screenPlanSaving.value) return + const invalidStep = screenPlanDraft.value.findIndex( + (step) => + (step.mode === 'SCREEN_MODE_TEXT' && !step.text?.trim()) || + (step.mode === 'SCREEN_MODE_TIMER' && + (!Number.isInteger(step.timerDurationSeconds) || + (step.timerDurationSeconds ?? 0) < 1 || + (step.timerDurationSeconds ?? 0) > 86400)), + ) + if (invalidStep !== -1) { + message.error(`Проверьте текст или длительность таймера в шаге ${invalidStep + 1}`) + return + } screenPlanRequestVersion += 1 screenPlanSaving.value = true try { @@ -489,18 +532,25 @@ async function saveScreenPlan() { } async function setScreenPlanStep(index: number) { - if (screenPlanDirty.value || !screenPlan.value) return + if (screenPlanDirty.value || !screenPlan.value || isScreenBusy.value || screenPlanSaving.value) + return + isScreenBusy.value = true + screenPlanRequestVersion += 1 try { const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index }) screenPlan.value = res.plan await getGameScreens() } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана') + } finally { + isScreenBusy.value = false } } async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: ScreenCommand) { if ( + isScreenBusy.value || + screenPlanSaving.value || screenPlanDirty.value || (mode !== 'SCREEN_MODE_TIMER' && mode !== 'SCREEN_MODE_INTRO' && @@ -508,19 +558,21 @@ async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: Scre ) { return } + isScreenBusy.value = true try { await client.SendScreenCommand({ gameID: Number(gameId), command, - positionMs: Math.max(0, Math.round(screenPosition.value * 1000)), + positionMs: 0, volume: Math.max(0, Math.min(100, Math.round(screenVolume.value))), }) await getGameScreens() } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось управлять экраном') + } finally { + isScreenBusy.value = false } } - async function bindScannedScreen(screenID: string, accessToken: string) { if (screenBinding.value) return screenBinding.value = true @@ -570,8 +622,16 @@ async function saveMapItemAndReload(item: MapItem, hidden = item.hidden) { } async function setSoundScreen(screen: ScreenState) { - await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true }) - await getGameScreens() + if (isScreenBusy.value) return + isScreenBusy.value = true + try { + await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true }) + await getGameScreens() + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось назначить звук') + } finally { + isScreenBusy.value = false + } } async function setGameMusic(change: { @@ -582,67 +642,122 @@ async function setGameMusic(change: { loop?: boolean volume?: number }) { + if (isMusicBusy.value) return + isMusicBusy.value = true + musicRequestVersion += 1 const current = music.value try { const res = await client.SetGameMusic({ gameID: Number(gameId), playlistID: change.playlistID ?? current?.playlist?.ID ?? 0, trackIndex: change.trackIndex ?? current?.trackIndex ?? 0, - positionMs: change.positionMs ?? current?.positionMs ?? 0, + positionMs: + change.positionMs ?? + (current?.positionMs ?? 0) + + (current?.playback === 'MUSIC_PLAYBACK_PLAYING' + ? Math.max(0, Date.now() - musicSyncedAt) + : 0), volume: change.volume ?? current?.volume ?? 100, loop: change.loop ?? current?.loop ?? false, playback: change.playback ?? current?.playback ?? 'MUSIC_PLAYBACK_STOPPED', }) music.value = res.state + musicSyncedAt = Date.now() } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось изменить музыку') + } finally { + isMusicBusy.value = false } } async function createPlaylist(name: string) { + if (isMusicBusy.value || !name.trim()) return + isMusicBusy.value = true + musicRequestVersion += 1 try { - await client.CreatePlaylist({ name }) - await getGameMusic() + await client.CreatePlaylist({ name: name.trim() }) + await getGameMusic(true) } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист') + } finally { + isMusicBusy.value = false + } +} + +async function renamePlaylist(id: number, name: string) { + if (isMusicBusy.value || !name.trim()) return + isMusicBusy.value = true + musicRequestVersion += 1 + try { + await client.UpdatePlaylist({ ID: id, name: name.trim() }) + await getGameMusic(true) + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось переименовать плейлист') + } finally { + isMusicBusy.value = false + } +} + +async function reorderTracks(playlistID: number, trackIDs: number[]) { + if (isMusicBusy.value) return + isMusicBusy.value = true + musicRequestVersion += 1 + try { + await client.ReorderPlaylistTracks({ playlistID, trackIDs }) + await getGameMusic(true) + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось сохранить порядок треков') + } finally { + isMusicBusy.value = false } } async function deletePlaylist(id: number) { + if (isMusicBusy.value) return + isMusicBusy.value = true + musicRequestVersion += 1 try { await client.DeletePlaylist({ ID: id }) - await getGameMusic() + await getGameMusic(true) } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист') + } finally { + isMusicBusy.value = false } } -async function uploadTrack(file: File) { - const playlistID = music.value?.playlist?.ID - if (!playlistID) { - message.warning('Сначала выберите плейлист') +async function uploadTrack(playlistID: number, file: File) { + if (isMusicBusy.value) return + if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) { + message.error('Выберите непустой MP3 размером до 64 МиБ') return } + isMusicBusy.value = true + musicRequestVersion += 1 try { const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer())) await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data }) - await getGameMusic() + await getGameMusic(true) } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3') + } finally { + isMusicBusy.value = false } } -async function deleteTrack(id: number) { - const playlistID = music.value?.playlist?.ID - if (!playlistID) return +async function deleteTrack(playlistID: number, id: number) { + if (isMusicBusy.value) return + isMusicBusy.value = true + musicRequestVersion += 1 try { await client.DeletePlaylistTrack({ playlistID, trackID: id }) - await getGameMusic() + await getGameMusic(true) } catch (err) { message.error(err instanceof Error ? err.message : 'Не удалось удалить трек') + } finally { + isMusicBusy.value = false } } - function uint8ToBase64(data: Uint8Array): string { let binary = '' for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000)) @@ -650,8 +765,16 @@ function uint8ToBase64(data: Uint8Array): string { } async function unbindScreen(screen: ScreenState) { - await client.UnbindScreen({ screenID: screen.ID }) - await getGameScreens() + if (isScreenBusy.value) return + isScreenBusy.value = true + try { + await client.UnbindScreen({ screenID: screen.ID }) + await getGameScreens() + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось отвязать экран') + } finally { + isScreenBusy.value = false + } } async function toTeamStory(id: string, password: string, newTab: boolean = false) { @@ -1387,43 +1510,64 @@ useSimplePolling(() => {
- - + {{ screensError }} +

Загрузка экранов…

+
+ +

Превью экрана

+ +
Экран не подключён
+
+ +
+
+ + +
@@ -1945,6 +2089,26 @@ useSimplePolling(() => { diff --git a/src/components/GameScreensPanel.vue b/src/components/GameScreensPanel.vue index 6ef8aa9..62ea620 100644 --- a/src/components/GameScreensPanel.vue +++ b/src/components/GameScreensPanel.vue @@ -1,47 +1,33 @@ diff --git a/src/components/ScreenPage.vue b/src/components/ScreenPage.vue index 4e9b287..1c5a22f 100644 --- a/src/components/ScreenPage.vue +++ b/src/components/ScreenPage.vue @@ -82,6 +82,8 @@ const musicSyncState = computed(() => [ state.value?.music?.playlist?.ID, state.value?.music?.trackIndex, state.value?.music?.playback, + state.value?.music?.stateVersion, + state.value?.music?.positionMs, ] as const) const screenBackground = computed(() => { const image = state.value?.backgroundImage || state.value?.coverImage @@ -185,7 +187,12 @@ watch(state, async (value) => { await nextTick() const media = screenMode.value === 'SCREEN_MODE_CONCLUSION' ? conclusionVideoElement.value : introAudioElement.value if (!media) return - media.currentTime = (value.positionMs || 0) / 1000 + if ( + ['SCREEN_COMMAND_PLAY', 'SCREEN_COMMAND_REPLAY', 'SCREEN_COMMAND_STOP', 'SCREEN_COMMAND_SEEK'] + .includes(value.command) + ) { + media.currentTime = (value.positionMs || 0) / 1000 + } media.volume = Math.max(0, Math.min(1, (value.volume ?? 100) / 100)) if (value.command === 'SCREEN_COMMAND_SEEK') { return @@ -226,7 +233,8 @@ watch( if (!media) return const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1] const playbackChanged = !previous || value[2] !== previous[2] - if (!trackChanged && !playbackChanged) return + const isRestart = !!previous && value[3] !== previous[3] && (value[4] ?? 0) < (previous[4] ?? 0) + if (!trackChanged && !playbackChanged && !isRestart) return await waitForMusicMetadata(media) media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000 if (music.playback === 'MUSIC_PLAYBACK_PLAYING') { diff --git a/src/components/ScreenPreview.vue b/src/components/ScreenPreview.vue index ca0489d..7084a7a 100644 --- a/src/components/ScreenPreview.vue +++ b/src/components/ScreenPreview.vue @@ -9,9 +9,10 @@ import type { import { applicationFileURL } from '@/utils/application' const props = defineProps<{ - state: ScreenState + state: Partial questions: ResultsQuestion[] teams: TeamResult[] + isThumbnail?: boolean }>() const timerNow = ref(Date.now()) @@ -42,8 +43,14 @@ watch( () => [props.state.timerRemainingSeconds, props.state.timerState, props.state.stateVersion], () => { const remaining = Math.max(0, Number(props.state.timerRemainingSeconds || 0)) - if (props.state.timerState === 'SCREEN_TIMER_STATE_RUNNING' && timerStateAtSync.value === props.state.timerState) { - const localRemaining = Math.max(0, timerRemainingAtSync.value - (Date.now() - timerSyncedAt.value) / 1000) + if ( + props.state.timerState === 'SCREEN_TIMER_STATE_RUNNING' && + timerStateAtSync.value === props.state.timerState + ) { + const localRemaining = Math.max( + 0, + timerRemainingAtSync.value - (Date.now() - timerSyncedAt.value) / 1000, + ) if (Math.abs(remaining - localRemaining) <= 0.5) return } timerRemainingAtSync.value = remaining @@ -54,6 +61,7 @@ watch( ) onMounted(() => { + if (props.isThumbnail) return timerTick = window.setInterval(() => { timerNow.value = Date.now() }, 250) @@ -65,25 +73,34 @@ onUnmounted(() => { @@ -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', +)