generated from VLADIMIR/template_frontend
356 lines
13 KiB
JavaScript
356 lines
13 KiB
JavaScript
// 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'
|
|
|
|
const presentationModule = { exports: {} }
|
|
runInNewContext(ts.transpile(readFileSync(new URL('../src/utils/screenPresentation.ts', import.meta.url), 'utf8'), { module: ts.ModuleKind.CommonJS }), { exports: presentationModule.exports, URL, TextEncoder })
|
|
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 : name === '@/utils/screenPresentation' ? presentationModule.exports : {}) }
|
|
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',
|
|
)
|
|
const timerStep = { timerLabel: 'Перерыв', timerType: 'SCREEN_TIMER_TYPE_BREAK' }
|
|
screens.state.setTimerType(timerStep, 'SCREEN_TIMER_TYPE_BEFORE_GAME')
|
|
assert.equal(timerStep.timerLabel, 'До начала игры')
|
|
timerStep.timerLabel = 'Обсуждение'
|
|
screens.state.setTimerType(timerStep, 'SCREEN_TIMER_TYPE_UNTIL_END')
|
|
assert.equal(timerStep.timerLabel, 'Обсуждение')
|
|
assert.equal(presentationModule.exports.validScreenQR('https://example.org'), true)
|
|
assert.equal(presentationModule.exports.validScreenQR('javascript:alert(1)'), false)
|
|
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',
|
|
)
|
|
|
|
const browserTeams = {
|
|
state: vue.ref({ gameID: 7 }),
|
|
screenId: vue.ref('12'),
|
|
accessToken: vue.ref('test-token'),
|
|
screenTeamNames: vue.ref(),
|
|
lastTeamsLoaded: -Infinity,
|
|
screenClient: { GetScreenTeams: async () => ({ teams: [{ name: 'Ястребы' }, { name: 'Агенты' }] }) },
|
|
}
|
|
runInNewContext(ts.transpile(functions('ScreenPage', ['loadScreenTeams']), { target: ts.ScriptTarget.ES2022 }), browserTeams)
|
|
await browserTeams.loadScreenTeams()
|
|
assert.deepEqual(Array.from(browserTeams.screenTeamNames.value), ['Агенты', 'Ястребы'])
|
|
browserTeams.screenClient.GetScreenTeams = async () => { throw new Error('offline') }
|
|
await assert.rejects(browserTeams.loadScreenTeams(), /offline/)
|
|
assert.equal(browserTeams.screenTeamNames.value.length, 2)
|
|
let resolveTeams
|
|
browserTeams.screenClient.GetScreenTeams = () => new Promise(resolve => { resolveTeams = resolve })
|
|
const pendingTeams = browserTeams.loadScreenTeams()
|
|
browserTeams.state.value.gameID = 8
|
|
browserTeams.screenTeamNames.value = undefined
|
|
resolveTeams({ teams: [{ name: 'Из прошлой игры' }] })
|
|
await pendingTeams
|
|
assert.equal(browserTeams.screenTeamNames.value, undefined)
|