2567 lines
107 KiB
TypeScript
2567 lines
107 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { test, type TestContext } from 'node:test'
|
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { Controller } from './controller.js'
|
|
import { Online, type SecretStorage } from './online.js'
|
|
import { PackageStore } from './package-store.js'
|
|
import { parsePlan } from './screen-plan.js'
|
|
import { applyRemote, parseRemote, send } from './remote-state.js'
|
|
|
|
async function fixture(t: TestContext, secure = true) {
|
|
const directory = await mkdtemp(join(tmpdir(), 'screen-online-'))
|
|
let shutdown = async () => {}
|
|
t.after(async () => {
|
|
await shutdown()
|
|
await rm(directory, { recursive: true, force: true })
|
|
})
|
|
const key = randomBytes(32)
|
|
const secrets: SecretStorage = {
|
|
available: () => secure,
|
|
encrypt(value) {
|
|
assert.ok(secure)
|
|
const iv = randomBytes(12)
|
|
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
const bytes = Buffer.concat([cipher.update(value), cipher.final()])
|
|
return Buffer.concat([iv, cipher.getAuthTag(), bytes])
|
|
},
|
|
decrypt(value) {
|
|
const cipher = createDecipheriv('aes-256-gcm', key, value.subarray(0, 12))
|
|
cipher.setAuthTag(value.subarray(12, 28))
|
|
return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]).toString()
|
|
},
|
|
}
|
|
const model = {
|
|
library: [
|
|
{
|
|
ID: 1,
|
|
name: 'Музыка',
|
|
tracks: [
|
|
{ ID: 1, name: 'Один' },
|
|
{ ID: 2, name: 'Два' },
|
|
],
|
|
},
|
|
] as {
|
|
ID: number
|
|
name: string
|
|
groups?: { ID: number; name: string; position: number; endMode?: string }[]
|
|
tracks: { ID: number; name: string; groupId?: number }[]
|
|
}[],
|
|
playlistError: 0,
|
|
upload: null as { filename: string; data: Buffer } | null,
|
|
bindings: [] as Record<string, unknown>[],
|
|
unbindError: 0,
|
|
unbindResponse: null as ((signal?: AbortSignal) => Promise<Response>) | null,
|
|
createScreenError: 0,
|
|
offline: false,
|
|
server: 'https://server.test',
|
|
introURL: 'https://media.test/intro.wav',
|
|
omitScenario: false,
|
|
omitStory: false,
|
|
scenarioId: null as number | null,
|
|
scenarioError: 0,
|
|
musicOverride: null as Record<string, unknown> | null,
|
|
mediaHandler: null as ((url: string) => Promise<Response>) | null,
|
|
failMedia: false,
|
|
expired: false,
|
|
refreshExpired: false,
|
|
accessExpired: false,
|
|
refreshed: false,
|
|
refreshCalls: 0,
|
|
refreshResponse: null as (() => Promise<Response>) | null,
|
|
advanceCalls: 0,
|
|
advanceHandler: null as (() => Promise<Response>) | null,
|
|
advanceRequest: null as Record<string, unknown> | null,
|
|
advanceResponse: null as Record<string, unknown> | null,
|
|
advanceError: 0,
|
|
resultError: false,
|
|
teamsError: false,
|
|
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
|
|
title: 'Дело',
|
|
gameStatus: 'finish',
|
|
startedAt: '',
|
|
endedAt: '',
|
|
gameError: 0,
|
|
gameResponse: null as (() => Promise<Response>) | null,
|
|
gameCommands: [] as string[],
|
|
roles: ['organizer'],
|
|
remote: {
|
|
gameID: 7,
|
|
introAudio: 'https://media.test/intro.wav',
|
|
mode: 2,
|
|
command: 1,
|
|
stateVersion: '9007199254740993',
|
|
soundEnabled: true,
|
|
positionMs: '0',
|
|
volume: 80,
|
|
} as Record<string, unknown>,
|
|
plan: parsePlan({ steps: [{ mode: 2 }] }),
|
|
planError: 0,
|
|
planSaves: 0,
|
|
planTransitions: 0,
|
|
planResponse: null as (() => Promise<Response>) | null,
|
|
stepResponse: null as (() => Promise<Response>) | null,
|
|
stateResponse: null as (() => Promise<Response>) | null,
|
|
}
|
|
const json = (value: unknown, status = 200) => Response.json(value, { status })
|
|
const requests: { path: string; auth: string | null }[] = []
|
|
const fetchAPI: typeof fetch = async (input, init) => {
|
|
if (model.offline) throw new Error('network secret-detail')
|
|
const url = new URL(String(input))
|
|
const auth = new Headers(init?.headers).get('Authorization')
|
|
const path = url.pathname
|
|
requests.push({ path, auth })
|
|
assert.equal(init?.credentials, 'omit')
|
|
if (url.origin === 'https://media.test') {
|
|
assert.equal(auth, null)
|
|
if (model.mediaHandler) return model.mediaHandler(url.href)
|
|
if (model.failMedia) return new Response(null, { status: 404 })
|
|
return new Response(Buffer.from('RIFFtest-WAVE'), {
|
|
headers: { 'Content-Type': 'audio/wav' },
|
|
})
|
|
}
|
|
assert.equal(url.origin, model.server)
|
|
if (path === '/api/auth/login')
|
|
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
|
|
if (path === '/api/auth/refresh') {
|
|
model.refreshCalls++
|
|
if (model.refreshResponse) return model.refreshResponse()
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
if (model.refreshExpired) return json({}, 401)
|
|
model.refreshed = true
|
|
return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
|
|
}
|
|
if (path === '/api/screens')
|
|
return model.createScreenError
|
|
? json({}, model.createScreenError)
|
|
: json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
|
|
if (path.endsWith('/unbind')) {
|
|
assert.equal(auth, 'Bearer ACCESS-SECRET')
|
|
if (model.unbindResponse) return model.unbindResponse(init?.signal ?? undefined)
|
|
return json({}, model.unbindError || 200)
|
|
}
|
|
if (path === '/api/screens/state') {
|
|
assert.equal(auth, null)
|
|
if (model.stateResponse) return model.stateResponse()
|
|
return model.expired ? json({}, 401) : json({ state: model.remote })
|
|
}
|
|
if (path === '/api/screens/music/advance') {
|
|
model.advanceCalls++
|
|
model.advanceRequest = JSON.parse(String(init?.body)) as Record<string, unknown>
|
|
if (model.advanceHandler) return model.advanceHandler()
|
|
if (model.advanceError) return json({}, model.advanceError)
|
|
return json({ state: model.advanceResponse ?? objectMusic(3, 1) })
|
|
}
|
|
if (path === '/api/screens/teams') return model.teamsError ? json({}, 500) : json({ teams: model.screenTeams })
|
|
if (path === '/api/screens/results') {
|
|
if (model.resultError) return json({}, 400)
|
|
return json({
|
|
questions: [{ code: 'A', text: 'Вопрос' }],
|
|
teams: [
|
|
{
|
|
team: {
|
|
ID: 'team',
|
|
name: 'Команда',
|
|
password: 'TEAM-SECRET',
|
|
captainEmail: 'private@example.test',
|
|
applications: [{ name: 'PRIVATE' }],
|
|
},
|
|
place: 1,
|
|
totalScore: 5,
|
|
actionsCount: 2,
|
|
answers: [{ questionCode: 'A', score: 5, answer: 'ANSWER-SECRET' }],
|
|
},
|
|
],
|
|
})
|
|
}
|
|
if (auth === 'Bearer ACCESS-SECRET' && model.accessExpired && !model.refreshed)
|
|
return json({}, 401)
|
|
if (model.refreshExpired) return json({}, 401)
|
|
if (path === '/api/users/me') return json({ user: { roles: model.roles } })
|
|
if (path === '/api/scenarios/23/full') return model.scenarioError ? json({}, model.scenarioError) : json({ scenario: { ID: 23, image: 'https://media.test/cover.png', story: { background: 'https://media.test/background.png', introduction: { audio: model.introURL }, questions: [{ answer: 'PRIVATE-ANSWER' }] } } })
|
|
if (path === '/api/games')
|
|
return json({
|
|
games: [
|
|
{ ID: 7, name: model.title, status: model.gameStatus, teams: [{ password: 'TEAM-SECRET' }] },
|
|
],
|
|
})
|
|
if (/^\/api\/games\/7\/(start|pause|play|finish|reset)$/.test(path)) {
|
|
assert.equal(init?.method, 'PUT')
|
|
assert.equal(JSON.parse(String(init?.body)).ID, 7)
|
|
if (model.gameError) return json({}, model.gameError)
|
|
const action = path.split('/').at(-1)!
|
|
model.gameCommands.push(action)
|
|
model.gameStatus = action === 'reset' ? 'draft' : action === 'finish' ? 'finish' : action === 'pause' ? 'pause' : 'run'
|
|
return json({})
|
|
}
|
|
if (path === '/api/games/7' || path === '/api/games/8') {
|
|
if (model.gameResponse) return model.gameResponse()
|
|
if (model.gameError) return json({}, model.gameError)
|
|
return json({
|
|
game: {
|
|
ID: Number(path.split('/').at(-1)),
|
|
name: model.title,
|
|
status: model.gameStatus,
|
|
startedAt: model.startedAt,
|
|
endedAt: model.endedAt,
|
|
scenario: model.omitScenario
|
|
? null
|
|
: {
|
|
...(model.scenarioId ? { ID: model.scenarioId } : {}),
|
|
story: model.omitStory
|
|
? null
|
|
: {
|
|
introduction: { audio: model.introURL },
|
|
conclusion: { video: '' },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if (path === '/api/games/7/screen-plan') {
|
|
if (model.planResponse) return model.planResponse()
|
|
if (model.planError) return json({}, model.planError)
|
|
if (init?.method === 'PUT') {
|
|
model.planSaves++
|
|
model.plan = parsePlan(JSON.parse(String(init.body)))
|
|
}
|
|
return json({ plan: model.plan.steps.length ? model.plan : null })
|
|
}
|
|
if (path === '/api/games/8/screen-plan') return json({ plan: null })
|
|
if (path === '/api/games/8/music') return json({ playlists: [] })
|
|
if (path === '/api/games/7/screen-plan/step') {
|
|
if (model.stepResponse) return model.stepResponse()
|
|
model.planTransitions++
|
|
if (model.planError) return json({}, model.planError)
|
|
const data = JSON.parse(String(init?.body)) as { currentStep: number }
|
|
const step = model.plan.steps[data.currentStep]
|
|
if (!step) return json({}, 400)
|
|
model.plan.currentStep = data.currentStep
|
|
model.remote = {
|
|
...model.remote,
|
|
mode: step.mode,
|
|
text: step.text,
|
|
qrUrl: step.qrUrl,
|
|
timerType: step.timerType,
|
|
command: 0,
|
|
timerLabel: step.timerLabel,
|
|
timerDurationSeconds: step.timerDurationSeconds || 600,
|
|
timerState: 1,
|
|
timerRemainingSeconds: step.timerDurationSeconds,
|
|
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
|
}
|
|
return json({ plan: model.plan })
|
|
}
|
|
if (path === '/api/games/7/screens/command') {
|
|
if (model.planError) return json({}, model.planError)
|
|
const data = JSON.parse(String(init?.body)) as { command: number }
|
|
model.remote.command = data.command
|
|
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
|
|
return json({})
|
|
}
|
|
if (path === '/api/games/7/music') {
|
|
if (init?.method === 'POST') {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>
|
|
const state = objectMusic(20, Number(body.trackIndex ?? 0))
|
|
model.remote.music = {
|
|
...state,
|
|
playlist:
|
|
(model.remote.music as { playlist?: unknown } | undefined)?.playlist ?? state.playlist,
|
|
playback: body.playback,
|
|
volume: body.volume,
|
|
loop: body.loop,
|
|
groupId: body.groupId,
|
|
positionMs: body.positionMs,
|
|
}
|
|
return json({ state: model.remote.music })
|
|
}
|
|
if (model.musicOverride) return json(model.musicOverride)
|
|
return json({
|
|
gamePlaylistId: 1,
|
|
playlists: [
|
|
{
|
|
ID: 1,
|
|
name: 'Музыка',
|
|
groups: model.library[0]?.groups,
|
|
tracks: [0, 1].map((i) => ({
|
|
ID: i + 1,
|
|
name: `Трек ${i}`,
|
|
filename: `https://media.test/${i}.wav`,
|
|
position: i,
|
|
groupId: model.library[0]?.tracks.find((track) => track.ID === i + 1)?.groupId,
|
|
})),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
if (path === '/api/games/7/screens/mode' || path === '/api/games/7/screens/timer') {
|
|
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
|
model.remote = {
|
|
...model.remote,
|
|
mode: body.mode ?? 7,
|
|
command: 0,
|
|
timerLabel: body.label,
|
|
timerDurationSeconds: body.durationSeconds,
|
|
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
|
}
|
|
return json({})
|
|
}
|
|
if (path.endsWith('/bind')) {
|
|
model.bindings.push(JSON.parse(String(init?.body)))
|
|
return json({})
|
|
}
|
|
if (path.endsWith('/sound')) return json({})
|
|
if (path.startsWith('/api/playlists')) {
|
|
if (model.playlistError) return json({}, model.playlistError)
|
|
const data = init?.body ? JSON.parse(String(init.body)) : {}
|
|
if (path === '/api/playlists') {
|
|
if (init?.method === 'POST') model.library.push({ ID: 2, name: data.name, tracks: [] })
|
|
return json({ playlists: model.library })
|
|
}
|
|
if (path === '/api/playlists/1' && init?.method === 'PUT') model.library[0]!.name = data.name
|
|
if (path === '/api/playlists/2' && init?.method === 'DELETE') model.library.pop()
|
|
const playlist = model.library[0]!
|
|
if (path === '/api/playlists/1/groups' && init?.method === 'POST') {
|
|
playlist.groups ??= []
|
|
const current = playlist.groups.find((group) => group.ID === data.groupID)
|
|
if (current) Object.assign(current, { name: data.name, endMode: data.endMode })
|
|
else
|
|
playlist.groups.push({
|
|
ID: Math.max(0, ...playlist.groups.map((group) => group.ID)) + 1,
|
|
name: data.name,
|
|
position: playlist.groups.length,
|
|
endMode: data.endMode,
|
|
})
|
|
}
|
|
if (path === '/api/playlists/1/groups/order') {
|
|
playlist.groups = data.groupIDs.map((id: number, position: number) => ({
|
|
...playlist.groups!.find((group) => group.ID === id)!,
|
|
position,
|
|
}))
|
|
}
|
|
if (/^\/api\/playlists\/1\/groups\/\d+$/.test(path) && init?.method === 'DELETE') {
|
|
const groupId = Number(path.split('/').at(-1))
|
|
playlist.groups = playlist.groups?.filter((group) => group.ID !== groupId)
|
|
playlist.tracks.forEach((track) => {
|
|
if (track.groupId === groupId) delete track.groupId
|
|
})
|
|
}
|
|
if (/^\/api\/playlists\/1\/tracks\/\d+\/group$/.test(path))
|
|
playlist.tracks.find((track) => track.ID === Number(path.split('/')[5]))!.groupId =
|
|
data.groupID
|
|
if (path === '/api/playlists/1/tracks/order') {
|
|
const tracks = model.library[0]!.tracks
|
|
model.library[0]!.tracks = data.trackIDs.map((id: number) =>
|
|
tracks.find((t) => t.ID === id),
|
|
)
|
|
}
|
|
if (path === '/api/playlists/1/tracks' && init?.method === 'POST') {
|
|
assert.equal(Buffer.from(data.data, 'base64').toString(), 'ID3test')
|
|
model.library[0]!.tracks.push({ ID: 3, name: data.name })
|
|
}
|
|
if (path === '/api/playlists/1/tracks/3' && init?.method === 'DELETE')
|
|
model.library[0]!.tracks.pop()
|
|
return json({})
|
|
}
|
|
throw new Error(`Unexpected request ${path}`)
|
|
}
|
|
const store = new PackageStore(join(directory, 'packages'), {
|
|
fetch: fetchAPI,
|
|
freeBytes: async () => 1n << 40n,
|
|
wait: async () => {},
|
|
})
|
|
await store.initialize()
|
|
const controller = new Controller(() => {})
|
|
const online = new Online(
|
|
join(directory, 'connection'),
|
|
store,
|
|
controller,
|
|
secrets,
|
|
fetchAPI,
|
|
undefined,
|
|
async () => model.upload,
|
|
)
|
|
await online.initialize()
|
|
shutdown = () => online.close()
|
|
const login = () =>
|
|
online.act({
|
|
action: 'login',
|
|
server: 'https://server.test/',
|
|
email: 'test@example.test',
|
|
password: 'PASSWORD-SECRET',
|
|
})
|
|
const prepare = async () => {
|
|
await login()
|
|
await online.act({ action: 'download', gameId: 7 })
|
|
await online.select(store.list()[0]!.key)
|
|
await online.act({ action: 'connect' })
|
|
}
|
|
return {
|
|
directory,
|
|
store,
|
|
controller,
|
|
online,
|
|
secrets,
|
|
fetchAPI,
|
|
model,
|
|
requests,
|
|
login,
|
|
prepare,
|
|
}
|
|
}
|
|
test('подтверждение игры загружает материалы без подключения экрана и сохраняет выбор', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
const selected = f.online.snapshot()
|
|
assert.equal(selected.currentGameId, 7)
|
|
assert.equal(selected.screenId, null)
|
|
assert.equal(selected.connection, 'disabled')
|
|
assert.ok(selected.materials.length > 0)
|
|
assert.ok(selected.materials.every((m) => m.status === 'ready'))
|
|
assert.equal(f.model.bindings.length, 0)
|
|
assert.ok(!f.requests.some((r) => r.path === '/api/screens'))
|
|
const saved = JSON.parse(await readFile(join(f.directory, 'connection', 'selection.json'), 'utf8'))
|
|
assert.equal(saved.key, f.controller.state.catalog.key)
|
|
await f.online.act({ action: 'connect' })
|
|
assert.equal(f.online.snapshot().screenId, '12')
|
|
assert.equal(f.model.bindings.at(-1)!.gameID, 7)
|
|
})
|
|
|
|
test('ошибка подготовки и открытый показ не подтверждают выбор игры', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
f.controller.output(true)
|
|
await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 }), /Закройте показ/)
|
|
f.controller.output(false)
|
|
f.model.failMedia = true
|
|
await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 }))
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.equal(f.online.snapshot().currentGameId, null)
|
|
assert.equal(f.model.bindings.length, 0)
|
|
})
|
|
|
|
test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => {
|
|
const f = await fixture(t)
|
|
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
|
|
})
|
|
|
|
test('полный сценарий скачивает фон и введение до подключения и сохраняет их без сети', async (t) => {
|
|
const f = await fixture(t)
|
|
f.model.scenarioId = 23
|
|
f.model.omitStory = true
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
const state = f.online.snapshot()
|
|
assert.equal(state.screenId, null)
|
|
assert.equal(state.materialsWarning, '')
|
|
assert.ok(state.materials.some((m) => m.usages.some((u) => u.name === 'Фон') && m.status === 'ready'))
|
|
assert.ok(state.materials.some((m) => m.usages.some((u) => u.name === 'Вступление') && m.status === 'ready'))
|
|
assert.ok(state.materials.every((m) => m.status === 'ready'))
|
|
assert.equal(JSON.stringify(f.store.list()).includes('PRIVATE-ANSWER'), false)
|
|
assert.equal(f.model.bindings.length, 0)
|
|
await f.online.close()
|
|
f.model.offline = true
|
|
await f.store.initialize()
|
|
await f.online.initialize()
|
|
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
|
assert.ok(f.online.snapshot().materials.some((m) => m.usages.some((u) => u.name === 'Вступление')))
|
|
})
|
|
|
|
test('недоступный полный сценарий не выдаётся за готовность всех материалов', async (t) => {
|
|
const f = await fixture(t)
|
|
f.model.scenarioId = 23
|
|
f.model.omitStory = true
|
|
f.model.scenarioError = 403
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
assert.match(f.online.snapshot().materialsWarning, /неполный список/)
|
|
assert.equal(f.online.snapshot().notice, '')
|
|
})
|
|
|
|
test('список материалов показывает отдельную загрузку, ошибку, повтор и кеш после перезапуска', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'connect', gameId: 7 })
|
|
const key = f.controller.state.catalog.key!
|
|
const materials = f.online.snapshot().materials
|
|
assert.ok(materials.length >= 3)
|
|
assert.ok(materials.every((m) => m.status === 'missing'))
|
|
const intro = materials.find((m) => m.name === 'Вступление')!
|
|
assert.ok(intro)
|
|
assert.deepEqual(intro.usages, [
|
|
{ groupId: 'game', groupName: 'Игра', name: 'Вступление', order: 2 },
|
|
])
|
|
const music = materials.filter((m) => m.usages.some((u) => u.groupId.startsWith('playlist-')))
|
|
assert.equal(music.length, 2)
|
|
assert.deepEqual(music.flatMap((m) => m.usages.map((u) => u.order)).sort(), [0, 1])
|
|
f.model.failMedia = true
|
|
await assert.rejects(f.online.act({ action: 'download-material', key, assetId: intro.id }))
|
|
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'error')
|
|
f.model.failMedia = false
|
|
let release!: () => void
|
|
let requested!: () => void
|
|
const started = new Promise<void>((resolve) => {
|
|
requested = resolve
|
|
})
|
|
f.model.mediaHandler = async () => {
|
|
requested()
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve
|
|
})
|
|
return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } })
|
|
}
|
|
const download = f.online.act({ action: 'download-material', key, assetId: intro.id })
|
|
await started
|
|
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'loading')
|
|
release()
|
|
await download
|
|
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready')
|
|
assert.ok(f.online.snapshot().materials.some((m) => m.status === 'missing'))
|
|
assert.equal(f.controller.state.catalog.media.find((m) => m.id === 'intro')!.ready, true)
|
|
assert.deepEqual(await f.store.folders(key), [join(f.directory, 'packages', key, 'cache')])
|
|
await assert.rejects(f.store.folders('../'), /Некорректный/)
|
|
await assert.rejects(
|
|
f.online.act({ action: 'download-material', key: 'other', assetId: intro.id }),
|
|
)
|
|
await assert.rejects(f.online.act({ action: 'download-material', key, assetId: 'unknown' }))
|
|
await f.online.close()
|
|
await f.store.initialize()
|
|
await f.online.initialize()
|
|
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready')
|
|
const file = await f.store.localFile(key, intro.id)
|
|
await rm(file.path)
|
|
await f.online.select(key)
|
|
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'missing')
|
|
await f.online.select(null)
|
|
assert.deepEqual(f.online.snapshot().materials, [])
|
|
})
|
|
|
|
test('полная очистка без сети удаляет игры и локальные данные, сохраняя вход после перезапуска', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const directory = join(f.directory, 'connection')
|
|
const key = f.controller.state.catalog.key!
|
|
await writeFile(join(directory, `plan-${key}.json`), '{}')
|
|
await writeFile(join(directory, `results-${'a'.repeat(64)}.json.tmp`), '{}')
|
|
const credentials = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
|
|
f.model.offline = true
|
|
const requests = f.requests.length
|
|
const revision = f.online.snapshot().packagesRevision
|
|
const result = await f.online.act({ action: 'clear-games' })
|
|
assert.equal(result.authenticated, true)
|
|
assert.equal(result.screenId, null)
|
|
assert.equal(result.game, null)
|
|
assert.deepEqual(result.materials, [])
|
|
assert.equal(result.packagesRevision, revision + 1)
|
|
assert.equal(result.notice, 'Все сохранённые игры удалены')
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.deepEqual(f.store.list(), [])
|
|
assert.equal(f.requests.length, requests)
|
|
assert.ok(!(await readdir(directory)).some((name) => /^(plan|results)-/.test(name)))
|
|
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
|
|
assert.equal(saved.access, credentials.access)
|
|
assert.equal(saved.screen, null)
|
|
await f.online.close()
|
|
await f.store.initialize()
|
|
await f.online.initialize()
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.equal(f.online.snapshot().authenticated, true)
|
|
assert.deepEqual(f.store.list(), [])
|
|
await f.online.act({ action: 'logout' })
|
|
await f.online.act({ action: 'clear-games' })
|
|
})
|
|
|
|
test('ошибка очистки обновляет список и позволяет повторить удаление', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const clear = f.store.clear.bind(f.store)
|
|
f.store.clear = async () => {
|
|
assert.equal(f.online.blocksOutput, true)
|
|
throw new Error('disk failure')
|
|
}
|
|
const revision = f.online.snapshot().packagesRevision
|
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Очистка не завершена/)
|
|
assert.equal(f.online.blocksOutput, false)
|
|
assert.equal(f.online.snapshot().packagesRevision, revision + 1)
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.equal(f.store.list().length, 1)
|
|
assert.equal(f.online.snapshot().notice, '')
|
|
f.store.clear = clear
|
|
await f.online.act({ action: 'clear-games' })
|
|
assert.deepEqual(f.store.list(), [])
|
|
})
|
|
|
|
test('очистка отклоняется во время показа и подготовки материалов', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const key = f.controller.state.catalog.key
|
|
f.controller.state.outputOpen = true
|
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Закройте показ/)
|
|
assert.equal(f.controller.state.catalog.key, key)
|
|
assert.equal(f.store.list().length, 1)
|
|
f.controller.state.outputOpen = false
|
|
f.online.state.preparation.active = true
|
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
|
|
assert.equal(f.store.list().length, 1)
|
|
f.online.state.preparation.active = false
|
|
f.online.state.busy = true
|
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
|
|
f.online.state.busy = false
|
|
})
|
|
|
|
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
await f.online.act({ action: 'logout' })
|
|
assert.ok(f.online.snapshot().materials.length > 0)
|
|
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
|
})
|
|
|
|
test('локальную игру можно выбрать без сети и входа, но нельзя сменить во время показа', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const key = f.controller.state.catalog.key!
|
|
await f.online.act({ action: 'logout' })
|
|
await f.online.select(null)
|
|
f.model.offline = true
|
|
const requests = f.requests.length
|
|
await f.online.select(key)
|
|
assert.equal(f.controller.state.catalog.key, key)
|
|
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
|
assert.equal(f.requests.length, requests)
|
|
f.controller.state.outputOpen = true
|
|
await assert.rejects(f.online.select(null), /Закройте показ/)
|
|
assert.equal(f.controller.state.catalog.key, key)
|
|
f.controller.state.outputOpen = false
|
|
})
|
|
|
|
test('частично скачанная игра выбирается без сети после перезапуска', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'connect', gameId: 7 })
|
|
const key = f.controller.state.catalog.key!
|
|
const asset = f.online.snapshot().materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!
|
|
await f.online.act({ action: 'download-material', key, assetId: asset.id })
|
|
await f.online.act({ action: 'logout' })
|
|
await f.online.select(null)
|
|
await f.online.close()
|
|
f.model.offline = true
|
|
await f.store.initialize()
|
|
await f.online.initialize()
|
|
const requests = f.requests.length
|
|
await f.online.select(key)
|
|
const materials = f.online.snapshot().materials
|
|
assert.equal(materials.find((m) => m.id === asset.id)!.status, 'ready')
|
|
assert.ok(materials.some((m) => m.status === 'missing'))
|
|
assert.equal(f.requests.length, requests)
|
|
})
|
|
|
|
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
f.model.musicOverride = {
|
|
gamePlaylistId: 1,
|
|
playlists: [
|
|
{
|
|
ID: 1,
|
|
name: 'Общий плейлист',
|
|
tracks: [{ ID: 1, name: 'Общий трек', filename: f.model.introURL, position: 0 }],
|
|
},
|
|
],
|
|
}
|
|
await f.online.act({ action: 'connect', gameId: 7 })
|
|
const id = f.online
|
|
.snapshot()
|
|
.materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!.id
|
|
const materials = f.online.snapshot().materials
|
|
assert.equal(materials.filter((m) => m.id === id).length, 1)
|
|
assert.deepEqual(
|
|
materials.find((m) => m.id === id)!.usages.map((u) => u.groupId),
|
|
['game', 'playlist-1'],
|
|
)
|
|
})
|
|
|
|
for (const gameId of [7, 8]) {
|
|
test(`переподключение к игре ${gameId} сначала отвязывает прежний экран`, async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
assert.equal(
|
|
f.requests.some((r) => r.path.endsWith('/unbind')),
|
|
false,
|
|
)
|
|
const start = f.requests.length
|
|
await f.online.act({ action: 'connect', gameId })
|
|
const paths = f.requests.slice(start).map((r) => r.path)
|
|
assert.equal(paths[0], '/api/screens/12/unbind')
|
|
assert.ok(paths.indexOf('/api/screens') > 0)
|
|
assert.ok(paths.indexOf('/api/screens/12/bind') > paths.indexOf('/api/screens'))
|
|
assert.equal(f.model.bindings.at(-1)!.gameID, gameId)
|
|
assert.equal(f.online.snapshot().connection, 'waiting')
|
|
})
|
|
}
|
|
|
|
test('отмена подготовки не ждёт общий refresh и не отменяет обновление для другого запроса', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.accessExpired = true
|
|
let started!: () => void
|
|
let release!: () => void
|
|
const refreshStarted = new Promise<void>((resolve) => { started = resolve })
|
|
const refreshReleased = new Promise<void>((resolve) => { release = resolve })
|
|
f.model.refreshResponse = async () => {
|
|
started()
|
|
await refreshReleased
|
|
f.model.refreshed = true
|
|
return Response.json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
|
|
}
|
|
const cancelled = f.online.act({ action: 'check', gameId: 7 }).then(
|
|
() => 'completed',
|
|
(error: Error) => {
|
|
assert.match(error.message, /отмен|ABORTED/)
|
|
return 'cancelled'
|
|
},
|
|
)
|
|
await refreshStarted
|
|
const background = f.online.refreshPlan()
|
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
await f.online.act({ action: 'cancel' })
|
|
try {
|
|
assert.equal(
|
|
await Promise.race([
|
|
cancelled,
|
|
new Promise<string>((resolve) => {
|
|
const timer = setTimeout(() => resolve('still waiting'), 1000)
|
|
timer.unref()
|
|
}),
|
|
]),
|
|
'cancelled',
|
|
)
|
|
assert.equal(f.model.refreshed, false)
|
|
} finally {
|
|
release()
|
|
await background
|
|
await cancelled
|
|
}
|
|
assert.equal(f.model.refreshCalls, 1)
|
|
assert.equal(f.online.snapshot().authenticated, true)
|
|
assert.equal(f.online.planStatus().notice, '')
|
|
})
|
|
|
|
for (const status of [200, 404]) {
|
|
test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const key = f.controller.state.catalog.key
|
|
const materials = f.online.snapshot().materials
|
|
f.model.unbindError = status === 200 ? 0 : status
|
|
await f.online.act({ action: 'disconnect' })
|
|
assert.equal(f.requests.at(-1)!.path, '/api/screens/12/unbind')
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
assert.equal(f.online.snapshot().connection, 'disabled')
|
|
assert.equal(f.controller.state.catalog.key, key)
|
|
assert.deepEqual(f.online.snapshot().materials, materials)
|
|
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
|
|
assert.equal(saved.screen, null)
|
|
const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI)
|
|
t.after(() => restored.close())
|
|
await restored.initialize()
|
|
assert.equal(restored.snapshot().screenId, null)
|
|
assert.equal(restored.snapshot().currentGameId, 7)
|
|
const count = f.requests.length
|
|
await f.online.act({ action: 'disconnect' })
|
|
assert.equal(f.requests.length, count)
|
|
})
|
|
}
|
|
|
|
for (const failure of ['server', 'network'] as const) {
|
|
test(`ошибка отключения прекращает локальную сессию и не восстанавливает её: ${failure}`, async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.unbindError = failure === 'server' ? 500 : 0
|
|
f.model.offline = failure === 'network'
|
|
await f.online.act({ action: 'disconnect' })
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
assert.equal(f.online.snapshot().connection, 'disabled')
|
|
assert.match(f.online.snapshot().notice, /отключён локально/)
|
|
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
|
|
assert.equal(saved.screen, null)
|
|
f.model.unbindError = 0
|
|
f.model.offline = false
|
|
const count = f.requests.length
|
|
await f.online.poll()
|
|
assert.equal(f.requests.length, count)
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI)
|
|
t.after(() => restored.close())
|
|
await restored.initialize()
|
|
assert.equal(restored.snapshot().screenId, null)
|
|
})
|
|
}
|
|
|
|
test('локальное отключение сохраняется до ответа сервера, отвязка ограничена пятью секундами', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const timeout = AbortSignal.timeout
|
|
let bounded = false
|
|
t.mock.method(AbortSignal, 'timeout', (milliseconds: number) => {
|
|
if (milliseconds === 5000) bounded = true
|
|
return timeout(milliseconds === 5000 ? 20 : milliseconds)
|
|
})
|
|
let started!: () => void
|
|
const requested = new Promise<void>((resolve) => { started = resolve })
|
|
f.model.unbindResponse = async (signal) => {
|
|
started()
|
|
return new Promise((_resolve, reject) => {
|
|
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true })
|
|
})
|
|
}
|
|
const disconnect = f.online.act({ action: 'disconnect' })
|
|
await requested
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
|
|
assert.equal(saved.screen, null)
|
|
// AbortSignal timers are unref'ed; keep the test alive until the simulated timeout.
|
|
const keepAlive = setTimeout(() => {}, 1000)
|
|
try {
|
|
await disconnect
|
|
} finally {
|
|
clearTimeout(keepAlive)
|
|
}
|
|
assert.equal(bounded, true)
|
|
assert.match(f.online.snapshot().notice, /не подтвердил отвязку/)
|
|
})
|
|
|
|
test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.unbindError = 500
|
|
const start = f.requests.length
|
|
await assert.rejects(f.online.act({ action: 'connect' }), /Отключение экрана/)
|
|
assert.equal(f.online.snapshot().screenId, '12')
|
|
assert.equal(
|
|
f.requests.slice(start).some((r) => r.path === '/api/screens'),
|
|
false,
|
|
)
|
|
f.model.unbindError = 0
|
|
await f.online.act({ action: 'connect' })
|
|
assert.equal(f.online.snapshot().connection, 'waiting')
|
|
})
|
|
|
|
test('отсутствующий прежний экран не мешает переподключению', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.unbindError = 404
|
|
await f.online.act({ action: 'connect' })
|
|
assert.equal(f.online.snapshot().connection, 'waiting')
|
|
})
|
|
|
|
test('ошибка создания после отвязки оставляет экран отключённым после перезапуска', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.createScreenError = 500
|
|
await assert.rejects(f.online.act({ action: 'connect' }))
|
|
assert.equal(f.online.snapshot().connection, 'disabled')
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
await f.online.close()
|
|
await f.online.initialize()
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
assert.equal(f.online.snapshot().connection, 'disabled')
|
|
})
|
|
|
|
test('сброс текущей игры отключает управление, сохраняет материалы и вход', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const packages = f.store.list().map((pkg) => pkg.key)
|
|
await f.online.select(null)
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.equal(f.online.snapshot().currentGameId, null)
|
|
assert.equal(f.online.snapshot().connection, 'disabled')
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
assert.equal(f.online.snapshot().authenticated, true)
|
|
assert.deepEqual(
|
|
f.store.list().map((pkg) => pkg.key),
|
|
packages,
|
|
)
|
|
await f.online.close()
|
|
await f.online.initialize()
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.equal(f.online.snapshot().currentGameId, null)
|
|
})
|
|
|
|
test('статус запроса виден во время загрузки и очищается после успеха или ошибки', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
const loading = f.online.act({ action: 'playlists' })
|
|
assert.equal(f.online.snapshot().busy, true)
|
|
assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…')
|
|
await loading
|
|
assert.equal(f.online.snapshot().busyMessage, '')
|
|
f.model.playlistError = 500
|
|
const failing = f.online.act({ action: 'playlists' })
|
|
assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…')
|
|
await assert.rejects(failing)
|
|
assert.equal(f.online.snapshot().busy, false)
|
|
assert.equal(f.online.snapshot().busyMessage, '')
|
|
})
|
|
|
|
test('состояние сообщает ID выбранной игры только для текущего сервера', async (t) => {
|
|
const f = await fixture(t)
|
|
assert.equal(f.online.snapshot().currentGameId, null)
|
|
await f.login()
|
|
await f.online.act({ action: 'download', gameId: 7 })
|
|
await f.online.select(f.store.list()[0]!.key)
|
|
assert.equal(f.online.snapshot().currentGameId, 7)
|
|
f.online.state.server = 'https://other.test'
|
|
assert.equal(f.online.snapshot().currentGameId, null)
|
|
})
|
|
|
|
for (const [input, expected] of [
|
|
['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'],
|
|
['http://127.0.0.1:8090', 'http://127.0.0.1:8090'],
|
|
]) {
|
|
test(`адрес входа ${input}: API и сохранённый адрес ${expected}`, async (t) => {
|
|
const f = await fixture(t, false)
|
|
f.model.server = expected!
|
|
await f.online.act({
|
|
action: 'login',
|
|
server: input!,
|
|
email: 'test@example.test',
|
|
password: 'PASSWORD-SECRET',
|
|
})
|
|
assert.equal(f.online.snapshot().authenticated, true)
|
|
assert.equal(f.online.snapshot().server, expected)
|
|
assert.ok(f.requests.some((r) => r.path === '/api/auth/login'))
|
|
await f.online.close()
|
|
const restored = new Online(
|
|
join(f.directory, 'connection'),
|
|
f.store,
|
|
new Controller(() => {}),
|
|
f.secrets,
|
|
f.fetchAPI,
|
|
)
|
|
try {
|
|
await restored.initialize()
|
|
assert.equal(restored.snapshot().server, expected)
|
|
} finally {
|
|
await restored.close()
|
|
}
|
|
})
|
|
}
|
|
|
|
function objectMusic(version: number, index = 0) {
|
|
return {
|
|
stateVersion: String(version),
|
|
playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
|
|
trackIndex: index,
|
|
playback: 2,
|
|
volume: 40,
|
|
loop: true,
|
|
}
|
|
}
|
|
|
|
test('вход, подготовка и зашифрованные секреты; после перезапуска игра и результаты работают офлайн', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
await f.online.act({ action: 'results' })
|
|
const snapshot = f.online.snapshot()
|
|
assert.equal(snapshot.authenticated, true)
|
|
assert.equal(snapshot.remembered, true)
|
|
const files = await readdir(join(f.directory, 'connection'))
|
|
for (const file of files) {
|
|
const bytes = await readFile(join(f.directory, 'connection', file))
|
|
for (const secret of [
|
|
'ACCESS-SECRET',
|
|
'REFRESH-SECRET',
|
|
'SCREEN-SECRET',
|
|
'PASSWORD-SECRET',
|
|
'TEAM-SECRET',
|
|
'ANSWER-SECRET',
|
|
'private@example.test',
|
|
])
|
|
assert.equal(bytes.includes(secret), false)
|
|
}
|
|
assert.equal(JSON.stringify(snapshot).includes('SECRET'), false)
|
|
f.model.offline = true
|
|
const store = new PackageStore(join(f.directory, 'packages'))
|
|
await store.initialize()
|
|
const c = new Controller(() => {})
|
|
const online = new Online(join(f.directory, 'connection'), store, c, f.secrets, f.fetchAPI)
|
|
await online.initialize()
|
|
assert.equal(c.state.catalog.key, f.controller.state.catalog.key)
|
|
assert.equal(c.state.results?.teams[0]?.totalScore, 5)
|
|
assert.ok(c.state.results?.capturedAt)
|
|
assert.equal(c.state.outputOpen, false)
|
|
assert.equal(c.state.channels.main.desired, 'stopped')
|
|
c.output(true)
|
|
send(c, { action: 'play', channel: 'main' })
|
|
await online.poll()
|
|
assert.equal(online.state.connection, 'offline')
|
|
assert.equal(c.state.channels.main.desired, 'playing')
|
|
})
|
|
|
|
test('без сохранённого входа запуск не запрашивает список игр', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.online.restoreGames()
|
|
assert.equal(f.requests.length, 0)
|
|
})
|
|
|
|
for (const expired of [false, true]) {
|
|
test(`после восстановления входа список игр загружается без кнопки; токен просрочен: ${expired}`, async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.close()
|
|
f.requests.length = 0
|
|
f.model.accessExpired = expired
|
|
const restored = new Online(
|
|
join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI,
|
|
)
|
|
t.after(() => restored.close())
|
|
await restored.initialize()
|
|
await restored.restoreGames()
|
|
assert.deepEqual(restored.snapshot().games, [{ id: 7, name: 'Дело', status: 'finish' }])
|
|
assert.equal(restored.snapshot().error, '')
|
|
assert.equal(restored.snapshot().busy, false)
|
|
assert.equal(restored.snapshot().screenId, null)
|
|
assert.equal(f.model.refreshCalls, expired ? 1 : 0)
|
|
assert.ok(f.requests.every(r => ['/api/games', '/api/auth/refresh'].includes(r.path)))
|
|
})
|
|
}
|
|
|
|
test('запуск без сети сохраняет доступ к локальной игре; список можно обновить позже', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
await f.online.close()
|
|
f.model.offline = true
|
|
const controller = new Controller(() => {})
|
|
const restored = new Online(
|
|
join(f.directory, 'connection'), f.store, controller, f.secrets, f.fetchAPI,
|
|
)
|
|
t.after(() => restored.close())
|
|
await restored.initialize()
|
|
const selected = controller.state.catalog.key
|
|
assert.ok(selected)
|
|
await restored.restoreGames()
|
|
assert.match(restored.snapshot().error, /Сервер недоступен/)
|
|
assert.equal(restored.snapshot().busy, false)
|
|
controller.output(true)
|
|
assert.equal(controller.state.outputOpen, true)
|
|
assert.equal(controller.state.catalog.key, selected)
|
|
f.model.offline = false
|
|
await restored.act({ action: 'games' })
|
|
assert.equal(restored.snapshot().games.length, 1)
|
|
assert.equal(restored.snapshot().error, '')
|
|
})
|
|
|
|
for (const fails of [false, true]) {
|
|
test(`ответ фоновой загрузки после выхода игнорируется; ошибка: ${fails}`, async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.close()
|
|
let release!: () => void
|
|
let started!: () => void
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
const requestStarted = new Promise<void>(resolve => { started = resolve })
|
|
const fetchAPI: typeof fetch = async (input, init) => {
|
|
const response = await f.fetchAPI(input, init)
|
|
const read = response.json.bind(response)
|
|
response.json = async () => {
|
|
started()
|
|
await gate
|
|
if (fails) throw new Error('network failure')
|
|
return read()
|
|
}
|
|
return response
|
|
}
|
|
const restored = new Online(
|
|
join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, fetchAPI,
|
|
)
|
|
t.after(() => restored.close())
|
|
await restored.initialize()
|
|
const pending = restored.restoreGames()
|
|
await requestStarted
|
|
assert.equal(restored.snapshot().busy, false)
|
|
await restored.act({ action: 'logout' })
|
|
release()
|
|
await pending
|
|
assert.equal(restored.snapshot().authenticated, false)
|
|
assert.deepEqual(restored.snapshot().games, [])
|
|
assert.equal(restored.snapshot().error, '')
|
|
})
|
|
}
|
|
|
|
test('без хранилища ОС вход только в памяти; выход не останавливает показ', async (t) => {
|
|
const f = await fixture(t, false)
|
|
await f.prepare()
|
|
assert.equal(f.online.state.remembered, false)
|
|
await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), {
|
|
code: 'ENOENT',
|
|
})
|
|
f.controller.output(true)
|
|
send(f.controller, { action: 'play', channel: 'main' })
|
|
await f.online.act({ action: 'logout' })
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
assert.equal(f.online.state.screenId, null)
|
|
})
|
|
|
|
test('три параллельных запроса описания используют одно обновление токена', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
f.model.accessExpired = true
|
|
await f.online.act({ action: 'download', gameId: 7 })
|
|
assert.equal(f.model.refreshCalls, 1)
|
|
assert.equal(f.store.list().length, 1)
|
|
})
|
|
|
|
test('обновление запрещено при показе, ошибка сохраняет старый пакет, новая версия включается остановленной', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const old = f.store.list()[0]!
|
|
f.controller.output(true)
|
|
await assert.rejects(f.online.act({ action: 'download', gameId: 7 }), /Закройте показ/)
|
|
f.controller.output(false)
|
|
f.model.title = 'Новое дело'
|
|
await f.online.act({ action: 'check', gameId: 7 })
|
|
assert.match(f.online.state.notice, /есть изменения/)
|
|
f.model.failMedia = true
|
|
await assert.rejects(f.online.act({ action: 'download', gameId: 7 }))
|
|
assert.equal(f.store.list()[0]?.version, old.version)
|
|
f.model.failMedia = false
|
|
await f.online.act({ action: 'download', gameId: 7 })
|
|
assert.notEqual(f.store.list()[0]?.version, old.version)
|
|
assert.equal(f.controller.state.catalog.name, 'Новое дело')
|
|
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
|
await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/)
|
|
})
|
|
|
|
test('принимает исходную и накопленную версии сервера; одинаковая версия не повторяется', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const c = f.controller
|
|
c.output(true)
|
|
await f.online.poll()
|
|
assert.equal(c.state.channels.main.desired, 'playing')
|
|
f.model.remote.stateVersion = '9007199254740994'
|
|
await f.online.poll()
|
|
assert.equal(c.state.channels.main.desired, 'playing')
|
|
send(c, { action: 'pause', channel: 'main' })
|
|
await f.online.poll()
|
|
assert.equal(c.state.channels.main.desired, 'paused')
|
|
f.model.offline = true
|
|
await f.online.poll()
|
|
f.model.offline = false
|
|
f.model.remote.stateVersion = '9007199254740995'
|
|
await f.online.poll()
|
|
assert.equal(c.state.channels.main.desired, 'playing')
|
|
f.model.remote.stateVersion = '9007199254740996'
|
|
await f.online.poll()
|
|
assert.equal(c.state.channels.main.desired, 'playing')
|
|
c.output(false)
|
|
c.output(true)
|
|
await f.online.poll()
|
|
assert.equal(c.state.channels.main.desired, 'playing')
|
|
})
|
|
|
|
test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
send(f.controller, { action: 'play', channel: 'main' })
|
|
f.model.expired = true
|
|
await f.online.poll()
|
|
assert.equal(f.online.state.connection, 'expired')
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
f.model.expired = false
|
|
await f.online.act({ action: 'connect' })
|
|
f.model.remote.gameID = 8
|
|
await f.online.poll()
|
|
assert.equal(f.online.state.screenId, null)
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
})
|
|
|
|
test('обновляет серверное состояние после открытия окна и не допускает параллельный опрос', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
let release!: (response: Response) => void
|
|
f.model.stateResponse = () =>
|
|
new Promise((resolve) => {
|
|
release = resolve
|
|
})
|
|
const pending = f.online.poll()
|
|
await f.online.poll()
|
|
f.controller.output(false)
|
|
f.controller.output(true)
|
|
release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } }))
|
|
await pending
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
})
|
|
|
|
test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
await f.online.act({ action: 'results' })
|
|
const before = structuredClone(f.controller.state.results)
|
|
f.model.resultError = true
|
|
await assert.rejects(f.online.act({ action: 'results' }), /пока недоступно/)
|
|
assert.deepEqual(f.controller.state.results, before)
|
|
assert.equal(JSON.stringify(before).includes('SECRET'), false)
|
|
})
|
|
|
|
test('таймер синхронизируется один раз на версию; звук меняется без увеличения версии', () => {
|
|
let now = 0
|
|
const c = new Controller(
|
|
() => {},
|
|
() => now,
|
|
)
|
|
const previous = parseRemote({
|
|
gameID: 7,
|
|
mode: 7,
|
|
stateVersion: '1',
|
|
timerDurationSeconds: '60',
|
|
})
|
|
const next = parseRemote({
|
|
gameID: 7,
|
|
mode: 7,
|
|
stateVersion: '2',
|
|
soundEnabled: true,
|
|
timerDurationSeconds: '60',
|
|
timerRemainingSeconds: '40',
|
|
timerState: 2,
|
|
command: 8,
|
|
})
|
|
applyRemote(c, previous, next)
|
|
assert.equal(c.state.timer.remainingMs, 40000)
|
|
now += 3000
|
|
c.tick()
|
|
applyRemote(c, next, next)
|
|
assert.equal(c.state.timer.remainingMs, 37000)
|
|
send(c, { action: 'mute', enabled: true })
|
|
applyRemote(c, { ...next, sound: false }, next)
|
|
assert.equal(c.state.muted, false)
|
|
assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' }))
|
|
for (const mode of [3, 5, 'SCREEN_MODE_INTRO_DONE', 'SCREEN_MODE_CONCLUSION_DONE'])
|
|
assert.throws(() => parseRemote({ mode }))
|
|
assert.throws(() => parseRemote({ mode: 99 }))
|
|
})
|
|
|
|
test('музыкальная громкость не возвращает старый трек; новый серверный трек использует локальные файлы', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
const previous = parseRemote({ music: objectMusic(1) })
|
|
send(f.controller, { action: 'next', channel: 'music' })
|
|
const next = parseRemote({ music: { ...objectMusic(2), volume: 70 } })
|
|
applyRemote(f.controller, previous, next)
|
|
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
|
assert.equal(f.controller.state.channels.music.volume, 70)
|
|
applyRemote(f.controller, next, parseRemote({ music: objectMusic(3, 1) }))
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
|
assert.throws(
|
|
() =>
|
|
applyRemote(
|
|
f.controller,
|
|
next,
|
|
parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }),
|
|
),
|
|
/Музыка ожидает подготовки/,
|
|
)
|
|
})
|
|
|
|
test('перестановка треков сохраняет старую музыку до готовности нового каталога', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
const previous = parseRemote({ music: objectMusic(1) })
|
|
const next = parseRemote({
|
|
music: { ...objectMusic(2), playlist: { ID: 1, tracks: [{ ID: 2 }, { ID: 1 }] } },
|
|
})
|
|
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка ожидает подготовки/)
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
assert.deepEqual(
|
|
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
|
|
[1, 2],
|
|
)
|
|
})
|
|
|
|
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.muted, false)
|
|
send(f.controller, { action: 'mute', enabled: true })
|
|
f.model.offline = true
|
|
await f.online.poll()
|
|
f.model.offline = false
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.muted, true)
|
|
|
|
send(f.controller, { action: 'mute', enabled: false })
|
|
f.model.offline = true
|
|
await f.online.poll()
|
|
f.model.remote.soundEnabled = false
|
|
f.model.offline = false
|
|
await f.online.poll()
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.muted, true)
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
|
|
await f.online.act({ action: 'sound' })
|
|
f.model.remote.soundEnabled = true
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.muted, false)
|
|
f.controller.output(false)
|
|
f.controller.output(true)
|
|
f.model.remote.soundEnabled = false
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.muted, true)
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
})
|
|
|
|
test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
send(f.controller, { action: 'play', channel: 'main' })
|
|
f.model.accessExpired = true
|
|
f.model.refreshExpired = true
|
|
await assert.rejects(f.online.act({ action: 'games' }), /Сессия истекла/)
|
|
assert.equal(f.online.state.authenticated, false)
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
assert.equal(f.controller.state.catalog.key, f.store.list()[0]?.key)
|
|
})
|
|
|
|
test('отмена подготовки не меняет готовую версию и снимает запрет открытия', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const old = f.store.list()[0]!.version
|
|
const original = f.store.download.bind(f.store)
|
|
let started!: () => void
|
|
const ready = new Promise<void>((resolve) => {
|
|
started = resolve
|
|
})
|
|
f.store.download = async (description, options) => {
|
|
started()
|
|
await new Promise<void>((resolve) =>
|
|
options!.signal!.addEventListener('abort', () => resolve(), { once: true }),
|
|
)
|
|
return original(description, options)
|
|
}
|
|
const pending = f.online.act({ action: 'download', gameId: 7 })
|
|
const failure = assert.rejects(pending)
|
|
await ready
|
|
assert.equal(f.online.blocksOutput, true)
|
|
await assert.rejects(f.online.select(null), /Подождите/)
|
|
await f.online.act({ action: 'cancel' })
|
|
await failure
|
|
assert.equal(f.online.blocksOutput, false)
|
|
assert.equal(f.store.list()[0]?.version, old)
|
|
})
|
|
|
|
test('одинаковая серверная сцена с новой версией отменяет локальное переключение', () => {
|
|
const c = new Controller(() => {})
|
|
const previous = parseRemote({ mode: 1, stateVersion: '1' })
|
|
send(c, { action: 'mode', mode: 8 })
|
|
applyRemote(c, previous, parseRemote({ mode: 1, stateVersion: '2' }))
|
|
assert.equal(c.state.scene.mode, 1)
|
|
send(c, { action: 'mode', mode: 8 })
|
|
applyRemote(
|
|
c,
|
|
parseRemote({ mode: 7, stateVersion: '2' }),
|
|
parseRemote({ mode: 7, stateVersion: '3', command: 8, timerState: 2 }),
|
|
)
|
|
assert.equal(c.state.scene.mode, 7)
|
|
assert.equal(c.state.timer.status, 'running')
|
|
})
|
|
|
|
test('откат версии не позволяет повторно выполнить уже полученную команду', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
f.model.remote.stateVersion = '9007199254740995'
|
|
await f.online.poll()
|
|
send(f.controller, { action: 'pause', channel: 'main' })
|
|
f.model.remote.stateVersion = '9007199254740994'
|
|
await f.online.poll()
|
|
f.model.remote.stateVersion = '9007199254740995'
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.main.desired, 'paused')
|
|
})
|
|
|
|
test('окончание серверного трека обновляет сервер один раз и не перематывает локальный следующий', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
f.model.remote.music = objectMusic(1)
|
|
await f.online.poll()
|
|
f.model.remote.music = objectMusic(2)
|
|
f.model.remote.stateVersion = '9007199254740994'
|
|
await f.online.poll()
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
const report = {
|
|
session: f.controller.state.session,
|
|
channel: 'music' as const,
|
|
operation: f.controller.state.channels.music.operation,
|
|
status: 'ended' as const,
|
|
position: 4,
|
|
duration: 4,
|
|
}
|
|
f.controller.report(report)
|
|
f.controller.report(report)
|
|
await eventually(() => f.controller.state.channels.music.trackIndex === 1, 'следующий трек готов')
|
|
assert.equal(f.model.advanceCalls, 1)
|
|
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
|
send(f.controller, { action: 'seek', channel: 'music', position: 2 })
|
|
f.model.remote.music = objectMusic(3, 1)
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.music.position, 2)
|
|
})
|
|
|
|
test('protobuf JSON с опущенными нулями сохраняет тишину и завершённый таймер', () => {
|
|
const state = parseRemote({
|
|
mode: 7,
|
|
timerDurationSeconds: '60',
|
|
timerState: 'SCREEN_TIMER_STATE_FINISHED',
|
|
music: { stateVersion: '1' },
|
|
})
|
|
assert.equal(state.volume, 0)
|
|
assert.equal(state.music?.volume, 0)
|
|
assert.equal(state.timer.remainingMs, 0)
|
|
assert.equal(state.timer.status, 'finished')
|
|
assert.throws(() => parseRemote({ stateVersion: 9007199254740992 }))
|
|
})
|
|
|
|
test('вход без прав управления не сохраняет токены даже после их обновления', async (t) => {
|
|
const f = await fixture(t)
|
|
f.model.accessExpired = true
|
|
f.model.roles = ['player']
|
|
await assert.rejects(f.login(), /нужны права/)
|
|
assert.equal(f.online.state.authenticated, false)
|
|
assert.equal(f.online.state.remembered, false)
|
|
assert.equal(f.model.refreshCalls, 1)
|
|
await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), {
|
|
code: 'ENOENT',
|
|
})
|
|
})
|
|
|
|
test('назначение звука подтверждается сервером отдельно от локального mute и сбрасывается с сессией', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
assert.equal(f.online.state.soundEnabled, true)
|
|
f.controller.output(true)
|
|
f.model.remote.soundEnabled = false
|
|
await f.online.poll()
|
|
assert.equal(f.online.state.soundEnabled, false)
|
|
send(f.controller, { action: 'mute', enabled: false })
|
|
assert.equal(f.online.state.soundEnabled, false)
|
|
await f.online.act({ action: 'sound' })
|
|
assert.equal(f.online.state.soundEnabled, true)
|
|
f.model.remote.soundEnabled = true
|
|
await f.online.poll()
|
|
send(f.controller, { action: 'mute', enabled: true })
|
|
assert.equal(f.online.state.soundEnabled, true)
|
|
f.model.offline = true
|
|
await f.online.poll()
|
|
assert.equal(f.online.state.connection, 'offline')
|
|
assert.equal(f.online.state.soundEnabled, true)
|
|
f.model.offline = false
|
|
f.model.remote.soundEnabled = false
|
|
await f.online.poll()
|
|
assert.equal(f.online.state.soundEnabled, false)
|
|
await f.online.act({ action: 'connect' })
|
|
assert.equal(f.online.state.soundEnabled, true)
|
|
await f.online.poll()
|
|
const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length
|
|
await f.online.act({ action: 'disconnect' })
|
|
assert.equal(f.online.state.soundEnabled, null)
|
|
assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect + 1)
|
|
})
|
|
|
|
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const key = f.controller.state.catalog.key!
|
|
assert.equal(f.online.planStatus().editable, true)
|
|
const steps = parsePlan({
|
|
steps: [
|
|
{ mode: 'SCREEN_MODE_TEXT', text: ' Новый текст ' },
|
|
{ mode: 7, timerDurationSeconds: '60' },
|
|
],
|
|
}).steps
|
|
const sceneRevision = f.controller.state.scene.revision
|
|
await f.online.act({ action: 'plan-save', key, steps })
|
|
assert.deepEqual(f.model.plan.steps, steps)
|
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
|
assert.equal(f.controller.state.scene.revision, sceneRevision)
|
|
assert.equal(f.model.planSaves, 1)
|
|
await assert.rejects(
|
|
f.online.act({ action: 'plan-save', key, steps: [{ mode: 8, text: ' ' }] }),
|
|
/Текст шага/,
|
|
)
|
|
await assert.rejects(
|
|
f.online.act({ action: 'plan-save', key: 'wrong', steps }),
|
|
/игра изменилась/,
|
|
)
|
|
assert.equal(f.model.planSaves, 1)
|
|
f.model.planError = 403
|
|
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Нет доступа/)
|
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
|
f.model.planError = 0
|
|
f.model.offline = true
|
|
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Сервер недоступен/)
|
|
const restoredController = new Controller(() => {})
|
|
const restored = new Online(
|
|
join(f.directory, 'connection'),
|
|
f.store,
|
|
restoredController,
|
|
f.secrets,
|
|
f.fetchAPI,
|
|
)
|
|
await restored.initialize()
|
|
assert.deepEqual(restoredController.state.catalog.steps, steps)
|
|
assert.equal(restored.planStatus().key, key)
|
|
await restored.select(key)
|
|
f.model.offline = false
|
|
await f.online.act({ action: 'plan-save', key, steps: [] })
|
|
assert.deepEqual(f.controller.state.catalog.steps, [])
|
|
assert.deepEqual(
|
|
JSON.parse(await readFile(join(f.directory, 'connection', `plan-${key}.json`), 'utf8')).steps,
|
|
[],
|
|
)
|
|
})
|
|
|
|
test('план: онлайн-переход общий, без автозапуска и повторного применения; офлайн-переход не отправляется после возврата сети', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const steps = parsePlan({
|
|
steps: [
|
|
{ mode: 7, timerLabel: 'Отдых', timerDurationSeconds: 60 },
|
|
{ mode: 8, text: 'Локальный шаг' },
|
|
],
|
|
}).steps
|
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
await f.online.planCommand({ action: 'step', index: 0 })
|
|
assert.equal(f.model.planTransitions, 1)
|
|
assert.equal(f.model.plan.currentStep, 0)
|
|
assert.equal(f.controller.state.scene.stepIndex, 0)
|
|
assert.equal(f.controller.state.timer.status, 'stopped')
|
|
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
|
await f.online.planCommand({ action: 'timer-start' })
|
|
const timerRevision = f.controller.state.timer.revision
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.timer.revision, timerRevision)
|
|
assert.equal(f.controller.state.timer.status, 'running')
|
|
f.model.offline = true
|
|
await f.online.planCommand({ action: 'step', index: 1 })
|
|
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
|
assert.match(f.online.planStatus().notice, /локально/)
|
|
f.model.offline = false
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
|
assert.equal(f.controller.state.scene.stepIndex, 1)
|
|
assert.equal(f.model.planTransitions, 1)
|
|
await f.online.planCommand({ action: 'step', index: 0 })
|
|
assert.equal(f.model.planTransitions, 2)
|
|
})
|
|
|
|
test('план: изменение на сайте между опросом и переходом применяет подтверждённое содержимое', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
await f.online.refreshPlan()
|
|
f.model.plan = parsePlan({ steps: [{ mode: 8, text: 'Изменено на сайте' }] })
|
|
await f.online.planCommand({ action: 'step', index: 0 })
|
|
assert.equal(f.controller.state.scene.mode, 8)
|
|
assert.equal(f.controller.state.scene.text, 'Изменено на сайте')
|
|
assert.equal(f.controller.state.scene.stepIndex, 0)
|
|
})
|
|
|
|
test('план: поздний ответ старой игры и опрос до сохранения не заменяют новый план', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const key = f.controller.state.catalog.key!
|
|
let resolve!: (response: Response) => void
|
|
f.model.planResponse = () =>
|
|
new Promise((done) => {
|
|
resolve = done
|
|
})
|
|
const pending = f.online.refreshPlan()
|
|
await new Promise((done) => setImmediate(done))
|
|
f.model.planResponse = null
|
|
const steps = parsePlan({ steps: [{ mode: 8, text: 'Сохранено позже' }] }).steps
|
|
await f.online.act({ action: 'plan-save', key, steps })
|
|
resolve(Response.json({ plan: { steps: [{ mode: 2 }] } }))
|
|
await pending
|
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
|
f.model.planResponse = () =>
|
|
new Promise((done) => {
|
|
resolve = done
|
|
})
|
|
const oldGame = f.online.refreshPlan()
|
|
await new Promise((done) => setImmediate(done))
|
|
await f.online.select(null)
|
|
resolve(Response.json({ plan: { steps: [{ mode: 8, text: 'Старая игра' }] } }))
|
|
await oldGame
|
|
assert.deepEqual(f.controller.state.catalog.steps, [])
|
|
assert.equal(f.online.planStatus().editable, false)
|
|
})
|
|
|
|
test('план: локальные переходы не ждут незавершённый запрос и поздний ответ не меняет показ', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const steps = parsePlan({
|
|
steps: [
|
|
{ mode: 8, text: 'Первый' },
|
|
{ mode: 8, text: 'Второй' },
|
|
],
|
|
}).steps
|
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
|
let finish!: (response: Response) => void
|
|
f.model.stepResponse = () =>
|
|
new Promise((resolve) => {
|
|
finish = resolve
|
|
})
|
|
const pending = f.online.planCommand({ action: 'step', index: 0 })
|
|
await new Promise((done) => setImmediate(done))
|
|
f.model.stepResponse = null
|
|
f.model.offline = true
|
|
const second = f.online.planCommand({ action: 'step', index: 1 })
|
|
assert.equal(f.controller.state.scene.text, 'Второй')
|
|
finish(Response.json({ plan: { steps: [{ mode: 8, text: 'Устаревший' }], currentStep: 0 } }))
|
|
await Promise.all([pending, second])
|
|
assert.equal(f.controller.state.scene.text, 'Второй')
|
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
|
})
|
|
|
|
test('план: изменения с сайта загружаются при закрытом показе; удалённый переход выделяет актуальный шаг', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.plan = parsePlan({
|
|
steps: [
|
|
{ mode: 8, text: 'Один' },
|
|
{ mode: 8, text: 'Два' },
|
|
],
|
|
})
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.outputOpen, false)
|
|
await f.online.refreshPlan()
|
|
assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps)
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
f.model.plan.currentStep = 1
|
|
f.model.remote = {
|
|
...f.model.remote,
|
|
mode: 8,
|
|
text: 'Два',
|
|
command: 0,
|
|
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
|
}
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.text, 'Два')
|
|
assert.equal(f.controller.state.scene.stepIndex, 1)
|
|
})
|
|
|
|
test('план: быстрые переходы сериализованы, неотправленные промежуточные шаги пропускаются', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
|
let finish!: (response: Response) => void
|
|
f.model.stepResponse = () =>
|
|
new Promise((resolve) => {
|
|
finish = resolve
|
|
})
|
|
const first = f.online.planCommand({ action: 'step', index: 0 })
|
|
await new Promise((done) => setImmediate(done))
|
|
const second = f.online.planCommand({ action: 'step', index: 1 })
|
|
const third = f.online.planCommand({ action: 'step', index: 2 })
|
|
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
|
assert.equal(f.model.planTransitions, 0)
|
|
f.model.stepResponse = null
|
|
finish(Response.json({ plan: { steps, currentStep: 0 } }))
|
|
await Promise.all([first, second, third])
|
|
assert.equal(f.model.planTransitions, 1)
|
|
assert.equal(f.model.plan.currentStep, 2)
|
|
assert.equal(f.controller.state.scene.stepIndex, 2)
|
|
})
|
|
|
|
test('план: конкурентный серверный таймер не теряется при подтверждении собственного перехода', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const steps = parsePlan({
|
|
steps: [
|
|
{ mode: 7, timerLabel: 'Первый', timerDurationSeconds: 60 },
|
|
{ mode: 7, timerLabel: 'Второй', timerDurationSeconds: 120 },
|
|
],
|
|
}).steps
|
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
|
f.controller.output(true)
|
|
f.model.stateResponse = async () => {
|
|
f.model.plan.currentStep = 1
|
|
return Response.json({
|
|
state: {
|
|
gameID: 7,
|
|
mode: 7,
|
|
command: 0,
|
|
stateVersion: '99',
|
|
timerLabel: 'Второй',
|
|
timerDurationSeconds: 120,
|
|
timerRemainingSeconds: 120,
|
|
},
|
|
})
|
|
}
|
|
await f.online.planCommand({ action: 'step', index: 0 })
|
|
assert.equal(f.controller.state.timer.label, 'Второй')
|
|
assert.equal(f.controller.state.timer.durationMs, 120000)
|
|
assert.equal(f.controller.state.scene.stepIndex, 1)
|
|
})
|
|
|
|
test('план: сетевой сбой отбрасывает все ожидающие переходы, включая устаревшие промежуточные', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
|
let fail!: (error: Error) => void
|
|
f.model.stepResponse = () =>
|
|
new Promise((_resolve, reject) => {
|
|
fail = reject
|
|
})
|
|
const first = f.online.planCommand({ action: 'step', index: 0 })
|
|
await new Promise((done) => setImmediate(done))
|
|
const second = f.online.planCommand({ action: 'step', index: 1 })
|
|
const third = f.online.planCommand({ action: 'step', index: 2 })
|
|
f.model.stepResponse = null
|
|
fail(new Error('network'))
|
|
await Promise.all([first, second, third])
|
|
assert.equal(f.model.planTransitions, 0)
|
|
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
|
await f.online.planCommand({ action: 'step', index: 0 })
|
|
assert.equal(f.model.planTransitions, 1)
|
|
})
|
|
|
|
test('план: первое подтверждение собственной команды учитывает назначение звука', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
f.model.remote.soundEnabled = false
|
|
await f.online.planCommand({ action: 'step', index: 0 })
|
|
assert.equal(f.controller.state.muted, true)
|
|
assert.equal(f.online.state.soundEnabled, false)
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.muted, true)
|
|
})
|
|
|
|
async function eventually(check: () => boolean, label: string) {
|
|
for (let i = 0; i < 200; i++) {
|
|
if (check()) return
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
}
|
|
assert.fail(label)
|
|
}
|
|
|
|
test('постоянная связь: подключение без пакета и подготовка при закрытом показе, сервер запускается после открытия', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
assert.equal(f.store.list().length, 0)
|
|
await f.online.act({ action: 'connect', gameId: 7 })
|
|
assert.equal(f.controller.state.catalog.name, 'Дело')
|
|
const session = f.controller.state.session
|
|
await f.online.poll()
|
|
await eventually(
|
|
() => !f.online.state.preparation.active && f.online.state.preparation.total > 0,
|
|
'фоновая подготовка завершена',
|
|
)
|
|
assert.equal(f.online.state.connection, 'connected')
|
|
assert.equal(f.controller.state.session, session)
|
|
assert.equal(f.controller.state.outputOpen, false)
|
|
const downloads = f.requests.filter((q) => q.path.endsWith('.wav')).length
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.mode, 2)
|
|
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
|
assert.match(f.controller.state.channels.main.source!.url, /^screen-app:/)
|
|
const operation = f.controller.state.channels.main.operation
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.main.operation, operation)
|
|
assert.equal(f.requests.filter((q) => q.path.endsWith('.wav')).length, downloads)
|
|
})
|
|
|
|
test('дозагрузка нового плейлиста сохраняет музыку; смена текста не ждёт файл; новый трек запускается только локально', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.remote.mode = 1
|
|
f.model.remote.command = 0
|
|
f.model.remote.music = objectMusic(1)
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
const old = f.controller.state.channels.music.source!.url
|
|
let release!: (response: Response) => void
|
|
let started = false
|
|
f.model.mediaHandler = async (url) => {
|
|
if (url.endsWith('new.wav')) {
|
|
started = true
|
|
return new Promise((resolve) => {
|
|
release = resolve
|
|
})
|
|
}
|
|
return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } })
|
|
}
|
|
const playlist = {
|
|
ID: 99,
|
|
name: 'Новый плейлист',
|
|
tracks: [{ ID: 9, name: 'Новый', filename: 'https://media.test/new.wav' }],
|
|
}
|
|
const music = { ...objectMusic(2), playlist, trackIndex: 0 }
|
|
f.model.remote.music = music
|
|
f.model.musicOverride = { gamePlaylistId: 99, state: music, playlists: [playlist] }
|
|
const pending = f.online.poll()
|
|
await eventually(() => started, 'новый файл запрошен')
|
|
assert.equal(f.controller.state.channels.music.source!.url, old)
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
const channel = f.controller.state.channels.music
|
|
const ended = f.online.musicEnded({
|
|
session: f.controller.state.session,
|
|
channel: 'music',
|
|
operation: channel.operation,
|
|
status: 'ended',
|
|
position: 4,
|
|
duration: 4,
|
|
})
|
|
await new Promise((done) => setImmediate(done))
|
|
assert.equal(f.model.advanceCalls, 0, 'старый трек не переключает новый серверный трек')
|
|
f.model.remote = {
|
|
...f.model.remote,
|
|
mode: 8,
|
|
text: 'Новая сцена',
|
|
command: 0,
|
|
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
|
}
|
|
const newer = f.online.poll()
|
|
await eventually(() => f.controller.state.scene.text === 'Новая сцена', 'текст не ждёт музыку')
|
|
release(new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }))
|
|
await Promise.all([pending, newer, ended])
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.music.playlistId, 99)
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
|
assert.notEqual(f.controller.state.channels.music.source!.url, old)
|
|
})
|
|
|
|
test('устаревшая дозагрузка не применяет прежнюю серверную сцену', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
const original = f.controller.state.channels.main.source!.url
|
|
let release!: (response: Response) => void
|
|
let started = false
|
|
f.model.introURL = 'https://media.test/new-intro.wav'
|
|
f.model.remote = {
|
|
...f.model.remote,
|
|
introAudio: f.model.introURL,
|
|
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
|
}
|
|
f.model.mediaHandler = async () => {
|
|
started = true
|
|
return new Promise((resolve) => {
|
|
release = resolve
|
|
})
|
|
}
|
|
const first = f.online.poll()
|
|
await eventually(() => started, 'загрузка нового вступления')
|
|
assert.equal(f.controller.state.channels.main.source!.url, original)
|
|
f.model.remote = {
|
|
...f.model.remote,
|
|
mode: 8,
|
|
text: 'Актуальный текст',
|
|
command: 0,
|
|
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
|
}
|
|
const second = f.online.poll()
|
|
release(new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }))
|
|
await Promise.all([first, second])
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.mode, 8)
|
|
assert.equal(f.controller.state.scene.text, 'Актуальный текст')
|
|
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
|
})
|
|
|
|
test('локальные режим, музыка и таймер публикуются на сервер без повторного сброса', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
await f.online.planCommand({ action: 'mode', mode: 1 })
|
|
assert.equal(f.model.remote.mode, 'SCREEN_MODE_COVER')
|
|
await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 })
|
|
assert.equal(f.model.remote.timerDurationSeconds, 60)
|
|
await f.online.planCommand({ action: 'play', channel: 'music' })
|
|
const music = f.model.remote.music as { playback: string }
|
|
assert.equal(music.playback, 'MUSIC_PLAYBACK_PLAYING')
|
|
const operation = f.controller.state.channels.music.operation
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.music.operation, operation)
|
|
})
|
|
|
|
test('конкурентная серверная музыка во время подтверждения локальной команды применяется', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
f.model.stateResponse = async () => {
|
|
const music = { ...objectMusic(21, 1), volume: 70 }
|
|
f.model.remote.music = music
|
|
return Response.json({ state: f.model.remote })
|
|
}
|
|
await f.online.planCommand({ action: 'play', channel: 'music' })
|
|
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
|
assert.equal(f.controller.state.channels.music.volume, 70)
|
|
})
|
|
|
|
test('подтверждение музыкальной команды учитывает замену файла у прежнего трека', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
f.model.remote.music = objectMusic(1)
|
|
await f.online.poll()
|
|
const old = f.controller.state.channels.music.source!.url
|
|
f.model.stateResponse = async () => {
|
|
const playlist = {
|
|
ID: 1,
|
|
name: 'Музыка',
|
|
tracks: [{ ID: 1, name: 'Замена', filename: 'https://media.test/replaced.wav' }],
|
|
}
|
|
const music = { ...objectMusic(21), playlist }
|
|
f.model.remote.music = music
|
|
f.model.musicOverride = { gamePlaylistId: 1, state: music, playlists: [playlist] }
|
|
return Response.json({ state: f.model.remote })
|
|
}
|
|
await f.online.planCommand({ action: 'play', channel: 'music' })
|
|
assert.notEqual(f.controller.state.channels.music.source!.url, old)
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
|
})
|
|
|
|
test('публикация текста после смены игры не отправляет устаревшую мутацию', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
let release!: (response: Response) => void
|
|
f.model.planResponse = () =>
|
|
new Promise((resolve) => {
|
|
release = resolve
|
|
})
|
|
const pending = f.online.planCommand({ action: 'text', text: 'Старый текст' })
|
|
await new Promise((done) => setImmediate(done))
|
|
await f.online.select(null)
|
|
release(Response.json({ plan: { steps: [] } }))
|
|
await pending
|
|
assert.equal(f.model.planSaves, 0)
|
|
assert.equal(f.model.planTransitions, 0)
|
|
assert.equal(f.controller.state.catalog.key, null)
|
|
assert.equal(f.controller.state.scene.stepIndex, null)
|
|
})
|
|
|
|
test('истёкшая авторизация при подключении игры не маскируется сетевой ошибкой', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
f.model.accessExpired = true
|
|
f.model.refreshExpired = true
|
|
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
|
|
assert.equal(f.online.snapshot().authenticated, false)
|
|
})
|
|
|
|
test('некорректное серверное состояние отображает запрос и поле без содержимого ответа', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
f.model.stateResponse = async () =>
|
|
Response.json({
|
|
state: {
|
|
...f.model.remote,
|
|
music: { playlist: { tracks: [{ ID: { password: 'SECRET' } }] } },
|
|
},
|
|
})
|
|
await f.online.poll()
|
|
const message = f.online.snapshot().error
|
|
assert.match(message, /Состояние экрана · POST \/api\/screens\/state/)
|
|
assert.ok(message.includes('state.music.playlist.tracks[0].ID'))
|
|
assert.ok(message.includes('получено object'))
|
|
assert.ok(!message.includes('SECRET'))
|
|
})
|
|
|
|
for (const missing of ['scenario', 'story'] as const) {
|
|
test(`подключение без ${missing}: медиа состояния скачиваются и воспроизводятся локально`, async (t) => {
|
|
const f = await fixture(t)
|
|
f.model.omitScenario = missing === 'scenario'
|
|
f.model.omitStory = missing === 'story'
|
|
await f.login()
|
|
await f.online.act({ action: 'connect', gameId: 7 })
|
|
assert.equal(f.online.snapshot().screenId, '12')
|
|
assert.equal(f.controller.state.outputOpen, false)
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
const state = f.controller.state
|
|
const pkg = f.store.description(state.catalog.key!)!
|
|
assert.ok(pkg.package.media.intro)
|
|
assert.equal(state.channels.main.desired, 'playing')
|
|
assert.equal(
|
|
state.channels.main.source?.url,
|
|
`screen-app://app/packages/${pkg.key}/${pkg.package.media.intro}`,
|
|
)
|
|
assert.ok((await f.store.localFile(pkg.key, pkg.package.media.intro!)).path)
|
|
const restored = new PackageStore(join(f.directory, 'packages'))
|
|
await restored.initialize()
|
|
assert.equal(restored.description(pkg.key)?.package.media.intro, pkg.package.media.intro)
|
|
assert.ok((await restored.localFile(pkg.key, pkg.package.media.intro!)).path)
|
|
})
|
|
}
|
|
|
|
test('без истории и медиа текст, таймер и музыка работают; отсутствие вступления не мешает связи', async (t) => {
|
|
const f = await fixture(t)
|
|
f.model.omitScenario = true
|
|
f.model.plan = parsePlan({
|
|
steps: [
|
|
{ mode: 8, text: 'Текст без истории' },
|
|
{ mode: 7, timerDurationSeconds: 30, timerLabel: 'Перерыв' },
|
|
],
|
|
})
|
|
f.model.remote = {
|
|
...f.model.remote,
|
|
mode: 8,
|
|
text: 'Текст без истории',
|
|
command: 0,
|
|
introAudio: '',
|
|
music: objectMusic(1),
|
|
}
|
|
await f.login()
|
|
await f.online.act({ action: 'connect', gameId: 7 })
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.text, 'Текст без истории')
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
f.model.remote = {
|
|
...f.model.remote,
|
|
mode: 7,
|
|
timerDurationSeconds: 30,
|
|
timerRemainingSeconds: 30,
|
|
timerState: 1,
|
|
stateVersion: '9007199254740994',
|
|
}
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.mode, 7)
|
|
assert.equal(f.controller.state.timer.durationMs, 30000)
|
|
f.model.remote = { ...f.model.remote, mode: 2, stateVersion: '9007199254740995' }
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.scene.mode, 2)
|
|
assert.equal(f.controller.state.channels.main.source, null)
|
|
assert.equal(f.controller.state.channels.music.desired, 'paused')
|
|
assert.equal(f.online.snapshot().connection, 'connected')
|
|
assert.equal(f.online.snapshot().preparation.error, '')
|
|
})
|
|
|
|
test('явное подключение включает оба канала на 100%, дальнейшие команды и reconnect сохраняют власть сервера', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
assert.equal(f.model.bindings[0]!.initializeAudio, true)
|
|
assert.equal(f.controller.state.muted, false)
|
|
assert.equal(f.controller.state.channels.main.volume, 100)
|
|
assert.equal(f.controller.state.channels.music.volume, 100)
|
|
f.controller.output(true)
|
|
f.model.remote.volume = 27
|
|
f.model.remote.soundEnabled = false
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.main.volume, 27)
|
|
assert.equal(f.controller.state.muted, true)
|
|
f.model.offline = true
|
|
await f.online.poll()
|
|
f.model.offline = false
|
|
await f.online.poll()
|
|
assert.equal(f.model.bindings.length, 1)
|
|
assert.equal(f.controller.state.channels.main.volume, 27)
|
|
assert.equal(f.controller.state.muted, true)
|
|
})
|
|
|
|
test('редактор музыки: CRUD, native MP3, порядок, валидация и конфликт не меняют текущий канал', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'playlists' })
|
|
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
|
|
const before = structuredClone(f.controller.state.channels.music)
|
|
await f.online.act({ action: 'playlist-create', name: 'Новый' })
|
|
assert.equal(f.online.state.playlists[1]!.name, 'Новый')
|
|
await f.online.act({ action: 'playlist-rename', playlistId: 1, name: 'Новое имя' })
|
|
await f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [2, 1] })
|
|
assert.deepEqual(
|
|
f.online.state.playlists[0]!.tracks.map((t) => t.id),
|
|
[2, 1],
|
|
)
|
|
assert.deepEqual(f.controller.state.channels.music, before)
|
|
await assert.rejects(
|
|
f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 1] }),
|
|
/повторяться/,
|
|
)
|
|
await assert.rejects(
|
|
f.online.act({ action: 'playlist-upload', playlistId: '../etc/passwd' }),
|
|
/число|Некоррект/,
|
|
)
|
|
f.model.upload = { filename: 'wrong.wav', data: Buffer.from('ID3test') }
|
|
await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /MP3/)
|
|
f.model.upload = { filename: 'large.mp3', data: Buffer.alloc(64 * 1048576 + 1) }
|
|
await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /64 МиБ/)
|
|
f.model.upload = { filename: 'test.mp3', data: Buffer.from('ID3test') }
|
|
await f.online.act({ action: 'playlist-upload', playlistId: 1 })
|
|
assert.equal(f.online.state.playlists[0]!.tracks.length, 3)
|
|
await f.online.act({ action: 'playlist-track-delete', playlistId: 1, trackId: 3 })
|
|
await f.online.act({ action: 'playlist-delete', playlistId: 2 })
|
|
assert.equal(f.online.state.playlists.length, 1)
|
|
f.model.playlistError = 409
|
|
await assert.rejects(
|
|
f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 2] }),
|
|
/Состав треков изменился/,
|
|
)
|
|
assert.deepEqual(
|
|
f.online.state.playlists[0]!.tracks.map((t) => t.id),
|
|
[2, 1],
|
|
)
|
|
const conflict = f.online.state.error
|
|
f.model.playlistError = 0
|
|
await f.online.act({ action: 'playlists' })
|
|
assert.equal(f.online.state.error, conflict, 'background refresh preserves the action error')
|
|
f.model.playlistError = 403
|
|
await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/)
|
|
})
|
|
|
|
test('управление игрой работает без привязанного экрана и подтверждается серверным состоянием', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
assert.equal(f.online.snapshot().game?.status, 'finish')
|
|
const key = f.controller.state.catalog.key!
|
|
for (const [command, status] of [
|
|
['reset', 'draft'], ['start', 'run'], ['pause', 'pause'],
|
|
['play', 'run'], ['finish', 'finish'],
|
|
] as const) {
|
|
await f.online.act({ action: 'game-command', command, key })
|
|
assert.equal(f.online.snapshot().game?.status, status)
|
|
assert.equal(f.online.snapshot().games.find(g => g.id === 7)?.status, status)
|
|
assert.equal(f.controller.state.outputOpen, false)
|
|
assert.equal(f.online.snapshot().screenId, null)
|
|
}
|
|
assert.deepEqual(f.model.gameCommands, ['reset', 'start', 'pause', 'play', 'finish'])
|
|
assert.equal(f.requests.some(r => r.path === '/api/screens'), false)
|
|
})
|
|
|
|
test('даты длительности обновляются с сервером и очищаются после сброса', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
assert.equal(f.online.snapshot().game?.startedAt, '')
|
|
f.model.startedAt = '2026-10-27T08:00:00Z'
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.startedAt, f.model.startedAt)
|
|
assert.equal(f.online.snapshot().game?.endedAt, '')
|
|
f.model.endedAt = '2026-10-27T09:12:00Z'
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.endedAt, f.model.endedAt)
|
|
f.model.offline = true
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.endedAt, f.model.endedAt)
|
|
f.model.offline = false
|
|
f.model.startedAt = ''
|
|
f.model.endedAt = ''
|
|
await f.online.act({ action: 'game-command', command: 'reset', key: f.controller.state.catalog.key! })
|
|
assert.equal(f.online.snapshot().game?.startedAt, '')
|
|
assert.equal(f.online.snapshot().game?.endedAt, '')
|
|
})
|
|
|
|
test('статус игры обновляется с сайта; неизвестный статус и ошибки сохраняют последнее состояние', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
f.model.gameStatus = 'pause'
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.status, 'pause')
|
|
const key = f.controller.state.catalog.key!
|
|
f.model.gameStatus = 'unexpected'
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.status, 'pause')
|
|
assert.match(f.online.snapshot().gameError, /неизвестное состояние/)
|
|
for (const status of [403, 404, 500]) {
|
|
f.model.gameError = status
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.status, 'pause')
|
|
assert.ok(f.online.snapshot().gameError)
|
|
await assert.rejects(f.online.act({ action: 'game-command', command: 'play', key }))
|
|
}
|
|
f.model.gameError = 0
|
|
f.model.gameStatus = 'run'
|
|
f.model.offline = true
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.status, 'pause')
|
|
assert.ok(f.online.snapshot().gameError)
|
|
f.model.offline = false
|
|
await f.online.refreshGameState(true)
|
|
assert.equal(f.online.snapshot().game?.status, 'run')
|
|
assert.equal(f.online.snapshot().gameError, '')
|
|
assert.deepEqual(f.model.gameCommands, [])
|
|
})
|
|
|
|
test('команда проверяет актуальный статус и ключ игры; двойные команды не выполняются', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
const key = f.controller.state.catalog.key!
|
|
await assert.rejects(f.online.act({ action: 'game-command', command: 'pause', key }), /недоступна/)
|
|
await assert.rejects(f.online.act({ action: 'game-command', command: 'reset', key: 'other' }), /изменилась/)
|
|
await assert.rejects(f.online.act({ action: 'game-command', command: 'other', key }), /Неизвестная/)
|
|
let release!: (response: Response) => void
|
|
f.model.gameResponse = () => new Promise(resolve => { release = resolve })
|
|
const pending = f.online.act({ action: 'game-command', command: 'reset', key })
|
|
await eventually(() => !!release, 'preflight game state')
|
|
await assert.rejects(f.online.act({ action: 'game-command', command: 'reset', key }), /Подождите/)
|
|
f.model.gameResponse = null
|
|
release(Response.json({ game: { ID: 7, name: 'Дело', status: 'finish' } }))
|
|
await pending
|
|
assert.deepEqual(f.model.gameCommands, ['reset'])
|
|
})
|
|
|
|
test('поздний статус и подтверждение не применяются после смены выбора или выхода', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
const key = f.controller.state.catalog.key!
|
|
for (const change of ['selection', 'logout']) {
|
|
if (change === 'logout') await f.online.act({ action: 'select-game', gameId: 7 })
|
|
let release!: (response: Response) => void
|
|
f.model.gameResponse = () => new Promise(resolve => { release = resolve })
|
|
const pending = f.online.refreshGameState(true)
|
|
await eventually(() => !!release, 'pending game state')
|
|
if (change === 'selection') await f.online.select(null)
|
|
else await f.online.act({ action: 'logout' })
|
|
assert.equal(f.online.snapshot().game, null)
|
|
f.model.gameResponse = null
|
|
release(Response.json({ game: { ID: 7, name: 'Old', status: 'run' } }))
|
|
await pending
|
|
assert.equal(f.online.snapshot().game, null)
|
|
await assert.rejects(f.online.act({ action: 'game-command', command: 'reset', key }))
|
|
}
|
|
assert.deepEqual(f.model.gameCommands, [])
|
|
})
|
|
|
|
test('статус опрашивается раз в две секунды и во время подготовки материалов', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
|
const count = () => f.requests.filter(r => r.path === '/api/games/7').length
|
|
const before = count()
|
|
t.mock.timers.enable({ apis: ['Date'], now: Date.now() })
|
|
await f.online.refreshGameState()
|
|
assert.equal(count(), before)
|
|
t.mock.timers.tick(2001)
|
|
f.online.state.busy = true
|
|
f.model.gameStatus = 'run'
|
|
await f.online.refreshGameState()
|
|
f.online.state.busy = false
|
|
assert.equal(count(), before + 1)
|
|
assert.equal(f.online.snapshot().game?.status, 'run')
|
|
})
|
|
|
|
test('список команд обновляется, сохраняется для офлайна и не переносится в другую игру', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare()
|
|
await f.online.refreshTeams()
|
|
const key = f.controller.state.catalog.key!
|
|
assert.deepEqual(f.controller.state.teams?.teams.map(team => team.name), ['Агенты', 'Ястребы'])
|
|
f.model.teamsError = true
|
|
await assert.rejects(f.online.refreshTeams())
|
|
assert.equal(f.controller.state.teams?.teams.length, 2)
|
|
await f.online.close()
|
|
const c = new Controller(() => {})
|
|
const restored = new Online(join(f.directory, 'connection'), f.store, c, f.secrets, f.fetchAPI)
|
|
t.after(() => restored.close())
|
|
f.model.offline = true
|
|
await restored.initialize()
|
|
assert.equal(c.state.catalog.key, key)
|
|
assert.deepEqual(c.state.teams?.teams.map(team => team.name), ['Агенты', 'Ястребы'])
|
|
await restored.select(null)
|
|
assert.equal(c.state.teams, null)
|
|
})
|
|
|
|
function groupedMusic(version: number, index = 0, endMode = 'PLAYLIST_GROUP_END_MODE_REPEAT') {
|
|
return {
|
|
...objectMusic(version, index),
|
|
groupId: index === 0 ? 42 : 43,
|
|
playlist: {
|
|
ID: 1,
|
|
name: 'Группы',
|
|
groups: [
|
|
{ ID: 42, name: 'Сбор', position: 0, endMode },
|
|
{ ID: 44, name: 'Пустая', position: 1 },
|
|
{ ID: 43, name: 'Финал', position: 2, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' },
|
|
],
|
|
tracks: [0, 1].map((i) => ({
|
|
ID: i + 1,
|
|
name: `Трек ${i}`,
|
|
filename: `https://media.test/${i}.wav`,
|
|
position: i,
|
|
groupId: i === 0 ? 42 : 43,
|
|
})),
|
|
},
|
|
}
|
|
}
|
|
|
|
for (const action of ['volume', 'pause', 'resume', 'replay'] as const) {
|
|
test(`группа сохраняется при команде ${action} нативного плеера`, async (t) => {
|
|
const f = await fixture(t)
|
|
const music = groupedMusic(2)
|
|
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
|
f.model.remote.music = music
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
await f.online.planCommand(action === 'volume' ? { action, channel: 'music', volume: 55 } : { action, channel: 'music' })
|
|
assert.equal((f.model.remote.music as { groupId: number }).groupId, 42)
|
|
await assert.rejects(
|
|
f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }),
|
|
/редакторе/,
|
|
)
|
|
await f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 })
|
|
assert.equal(f.controller.state.channels.music.groupId, 43)
|
|
})
|
|
}
|
|
|
|
test('повтор одного трека группы передаёт точную версию и перезапускает трек', async (t) => {
|
|
const f = await fixture(t)
|
|
const music = { ...groupedMusic(2), stateVersion: '9007199254740995', positionMs: 0 }
|
|
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
|
f.model.remote.music = music
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
const before = f.controller.state.channels.music.operation
|
|
f.model.advanceResponse = { ...groupedMusic(3), stateVersion: '9007199254740996', positionMs: 0 }
|
|
await f.online.musicEnded({
|
|
session: f.controller.state.session,
|
|
channel: 'music',
|
|
operation: before,
|
|
status: 'ended',
|
|
position: 0.1,
|
|
duration: 0.1,
|
|
})
|
|
assert.equal(f.model.advanceRequest?.expectedStateVersion, music.stateVersion)
|
|
assert.ok(f.controller.state.channels.music.operation > before)
|
|
assert.equal(f.controller.state.channels.music.groupId, 42)
|
|
})
|
|
|
|
for (const [mode, index, groupId, desired] of [
|
|
['PLAYLIST_GROUP_END_MODE_REPEAT', 0, 42, 'playing'],
|
|
['PLAYLIST_GROUP_END_MODE_STOP', 0, 42, 'stopped'],
|
|
['PLAYLIST_GROUP_END_MODE_NEXT', 1, 43, 'playing'],
|
|
] as const) {
|
|
test(`офлайн-завершение группы: ${mode}`, async (t) => {
|
|
const f = await fixture(t)
|
|
const music = groupedMusic(2, 0, mode)
|
|
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
|
f.model.remote.music = music
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
f.model.advanceError = 503
|
|
await f.online.musicEnded({
|
|
session: f.controller.state.session,
|
|
channel: 'music',
|
|
operation: f.controller.state.channels.music.operation,
|
|
status: 'ended',
|
|
position: 4,
|
|
duration: 4,
|
|
})
|
|
assert.equal(f.controller.state.channels.music.desired, desired)
|
|
assert.equal(f.controller.state.channels.music.groupId, groupId)
|
|
assert.equal(f.controller.state.channels.music.trackIndex, index)
|
|
await f.online.poll()
|
|
assert.equal(
|
|
f.controller.state.channels.music.groupId,
|
|
42,
|
|
'server wins on reconnect even with unchanged music version',
|
|
)
|
|
assert.equal(f.controller.state.channels.music.trackIndex, 0)
|
|
})
|
|
}
|
|
|
|
test('редактор сохраняет группы и переносит треки через существующие API', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.login()
|
|
await f.online.act({
|
|
action: 'group-save',
|
|
playlistId: 1,
|
|
groupId: 0,
|
|
name: 'Сбор',
|
|
endMode: 'repeat',
|
|
})
|
|
await f.online.act({
|
|
action: 'group-save',
|
|
playlistId: 1,
|
|
groupId: 0,
|
|
name: 'Финал',
|
|
endMode: 'stop',
|
|
})
|
|
const groups = f.online.snapshot().playlists[0]!.groups!
|
|
assert.deepEqual(
|
|
groups.map((g) => g.endMode),
|
|
['repeat', 'stop'],
|
|
)
|
|
await f.online.act({ action: 'track-group', playlistId: 1, trackId: 1, groupId: groups[0]!.id })
|
|
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, groups[0]!.id)
|
|
await f.online.act({
|
|
action: 'group-reorder',
|
|
playlistId: 1,
|
|
groupIds: [groups[1]!.id, groups[0]!.id],
|
|
})
|
|
assert.equal(f.online.snapshot().playlists[0]!.groups![0]!.name, 'Финал')
|
|
await f.online.act({
|
|
action: 'group-save',
|
|
playlistId: 1,
|
|
groupId: groups[0]!.id,
|
|
name: 'Гости',
|
|
endMode: 'next',
|
|
})
|
|
assert.equal(f.online.snapshot().playlists[0]!.groups![1]!.endMode, 'next')
|
|
await f.online.act({ action: 'group-delete', playlistId: 1, groupId: groups[0]!.id })
|
|
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, undefined)
|
|
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
|
|
})
|
|
|
|
test('запоздалая ошибка подготовки группы не останавливает новую группу', async (t) => {
|
|
const f = await fixture(t)
|
|
const music = groupedMusic(2)
|
|
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
|
f.model.remote.music = music
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
const pkg = f.store.description(f.store.list()[0]!.key)!.package
|
|
const target = pkg.playlists[0]!.tracks[0]!.assetId
|
|
const canRead = f.store.canRead.bind(f.store),
|
|
ensure = f.store.ensureAsset.bind(f.store)
|
|
let reject!: (error: Error) => void,
|
|
started = false,
|
|
shouldDefer = true
|
|
const pending = new Promise<void>((_resolve, failure) => {
|
|
reject = failure
|
|
})
|
|
f.store.canRead = (key, id) => (id === target && shouldDefer ? false : canRead(key, id))
|
|
f.store.ensureAsset = (...args) => {
|
|
if (args[1] === target && shouldDefer) {
|
|
shouldDefer = false
|
|
started = true
|
|
return pending
|
|
}
|
|
return ensure(...args)
|
|
}
|
|
f.model.offline = true
|
|
const old = f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
|
|
await eventually(() => started, 'старая загрузка началась')
|
|
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
|
|
const operation = f.controller.state.channels.music.operation
|
|
reject(new Error('old preparation failure'))
|
|
await old
|
|
assert.equal(f.controller.state.channels.music.groupId, 43)
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
assert.equal(f.controller.state.channels.music.operation, operation)
|
|
})
|
|
|
|
test('запоздалая ошибка офлайн-перехода не останавливает новый запуск', async (t) => {
|
|
const f = await fixture(t)
|
|
const music = groupedMusic(2, 0, 'PLAYLIST_GROUP_END_MODE_NEXT')
|
|
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
|
f.model.remote.music = music
|
|
await f.prepare()
|
|
f.controller.output(true)
|
|
await f.online.poll()
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
const pkg = f.store.description(f.store.list()[0]!.key)!.package
|
|
const target = pkg.playlists[0]!.tracks[1]!.assetId
|
|
const ensure = f.store.ensureAsset.bind(f.store)
|
|
let reject!: (error: Error) => void,
|
|
started = false,
|
|
shouldDefer = true
|
|
const pending = new Promise<void>((_resolve, failure) => {
|
|
reject = failure
|
|
})
|
|
f.store.ensureAsset = (...args) => {
|
|
if (args[1] === target && shouldDefer) {
|
|
shouldDefer = false
|
|
started = true
|
|
return pending
|
|
}
|
|
return ensure(...args)
|
|
}
|
|
f.model.advanceError = 503
|
|
const old = f.online.musicEnded({
|
|
session: f.controller.state.session,
|
|
channel: 'music',
|
|
operation: f.controller.state.channels.music.operation,
|
|
status: 'ended',
|
|
position: 4,
|
|
duration: 4,
|
|
})
|
|
await eventually(() => started, 'офлайн-переход готовит файл')
|
|
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
|
|
const operation = f.controller.state.channels.music.operation
|
|
reject(new Error('old next failure'))
|
|
await old
|
|
assert.equal(f.controller.state.channels.music.groupId, 42)
|
|
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
|
assert.equal(f.controller.state.channels.music.operation, operation)
|
|
assert.equal(f.online.snapshot().connection, 'connected')
|
|
})
|
|
|
|
test('ожидающее окончание старой группы не блокирует окончание новой короткой группы', async (t) => {
|
|
const f = await fixture(t); const music = groupedMusic(2)
|
|
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }; f.model.remote.music = music
|
|
await f.prepare(); f.controller.output(true); await f.online.poll()
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
let resolve!: (response: Response) => void
|
|
const pending = new Promise<Response>((done) => { resolve = done })
|
|
let calls = 0
|
|
f.model.advanceHandler = () => ++calls === 1 ? pending : Promise.resolve(Response.json({ state: { ...groupedMusic(21, 1), playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 } }))
|
|
const report = () => ({ session: f.controller.state.session, channel: 'music' as const, operation: f.controller.state.channels.music.operation, status: 'ended' as const, position: .1, duration: .1 })
|
|
const old = f.online.musicEnded(report())
|
|
await eventually(() => calls === 1, 'старый RPC ожидает ответа')
|
|
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
|
|
await f.online.musicEnded(report())
|
|
assert.equal(calls, 2)
|
|
assert.equal(f.controller.state.channels.music.groupId, 43)
|
|
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
|
resolve(Response.json({ state: groupedMusic(3) })); await old
|
|
assert.equal(f.controller.state.channels.music.groupId, 43)
|
|
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
|
})
|
|
|
|
test('группа без подготовленного локального описания не продолжает общий плейлист', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.prepare(); f.controller.output(true); await f.online.poll()
|
|
send(f.controller, { action: 'play', channel: 'music' })
|
|
assert.equal(f.controller.state.channels.music.groupId, 0)
|
|
f.model.remote.music = groupedMusic(2)
|
|
await f.online.poll()
|
|
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
|
assert.match(f.online.snapshot().preparation.error, /Группа/)
|
|
f.model.offline = true
|
|
await f.online.poll()
|
|
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
|
|
assert.equal(f.controller.state.channels.music.trackIndex, 0)
|
|
assert.equal(f.model.advanceCalls, 0)
|
|
})
|