// 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', )