Files
2026-10-23 03:03:06 +07:00

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)