fix
This commit is contained in:
+6
-3
@@ -275,8 +275,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено массив`.
|
||||
Содержимое ответа, значения полей, токены и stack trace не отображаются.
|
||||
|
||||
Отсутствие истории сценария в ответе игры не мешает подключению. После подключения
|
||||
ссылки на обложку, фон, вступление и видео берутся из состояния экрана и автоматически
|
||||
сохраняются локально. До получения состояния используются доступные ссылки игры.
|
||||
При подготовке игры до подключения экрана приложение отдельно запрашивает полный
|
||||
сценарий, чтобы скачать фон, вступление и остальные материалы. Если API не отдаёт
|
||||
полный сценарий, появляется предупреждение о неполном списке вместо сообщения
|
||||
о полной готовности. При повторной загрузке уже сохранённый пакет остаётся без
|
||||
изменений, пока полный список недоступен. После подключения ссылки на обложку,
|
||||
фон, вступление и видео берутся из состояния экрана и сохраняются локально.
|
||||
Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает
|
||||
«Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага.
|
||||
|
||||
@@ -27,13 +27,14 @@ test('журнал сохраняет только разрешённые пол
|
||||
})
|
||||
log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' })
|
||||
log.record('request-error', { path: '/api/games/{id}', reason: 'SECRET' })
|
||||
log.record('request-error', { path: '/api/scenarios/{id}', reason: 'AUTH', token: 'SECRET' })
|
||||
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
|
||||
assert.equal(content.includes('SECRET'), false)
|
||||
const lines = content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) => JSON.parse(s))
|
||||
assert.equal(lines.length, 4)
|
||||
assert.equal(lines.length, 5)
|
||||
assert.equal(lines[0].version, '0.2.0-rc.1')
|
||||
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
||||
],
|
||||
},
|
||||
'request-error': {
|
||||
path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music'],
|
||||
path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music', '/api/scenarios/{id}'],
|
||||
reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'],
|
||||
},
|
||||
'media-error': { channel: ['main', 'music', 'scene'] },
|
||||
|
||||
@@ -55,6 +55,8 @@ async function fixture(t: TestContext, secure = true) {
|
||||
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,
|
||||
@@ -161,6 +163,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
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') 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: [
|
||||
@@ -187,6 +190,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
scenario: model.omitScenario
|
||||
? null
|
||||
: {
|
||||
...(model.scenarioId ? { ID: model.scenarioId } : {}),
|
||||
story: model.omitStory
|
||||
? null
|
||||
: {
|
||||
@@ -389,6 +393,39 @@ test('чистый профиль предлагает адрес сайта п
|
||||
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()
|
||||
|
||||
+17
-3
@@ -23,7 +23,7 @@ import type {
|
||||
} from '../src/types/screen.js'
|
||||
import type { Controller } from './controller.js'
|
||||
import { PackageStore, packageKey } from './package-store.js'
|
||||
import { type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
|
||||
import { INCOMPLETE_MATERIALS_WARNING, type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
|
||||
import { parseResults } from './results.js'
|
||||
import { parsePlan } from './screen-plan.js'
|
||||
import {
|
||||
@@ -80,6 +80,7 @@ export class Online {
|
||||
server: DEFAULT_SERVER,
|
||||
currentGameId: null,
|
||||
materials: [],
|
||||
materialsWarning: '',
|
||||
authenticated: false,
|
||||
remembered: false,
|
||||
busy: false,
|
||||
@@ -198,7 +199,9 @@ export class Online {
|
||||
}
|
||||
private async description(gameId: number): Promise<ScreenPackage> {
|
||||
if (!this.credentials) throw new ApiError(401)
|
||||
return prepareScreenPackage(
|
||||
const generation = this.generation
|
||||
let incomplete = false
|
||||
const description = await prepareScreenPackage(
|
||||
{
|
||||
server: this.credentials.server,
|
||||
gameId,
|
||||
@@ -206,6 +209,7 @@ export class Online {
|
||||
token: this.credentials.access,
|
||||
signal: this.materialAbort.signal,
|
||||
onRequestFailure: this.onRequestFailure,
|
||||
onIncomplete: () => { incomplete = true },
|
||||
},
|
||||
(url, init) => {
|
||||
const parsed = new URL(String(url))
|
||||
@@ -213,6 +217,9 @@ export class Online {
|
||||
return this.authorized(parsed.pathname, init)
|
||||
},
|
||||
)
|
||||
if (generation === this.generation)
|
||||
this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : ''
|
||||
return description
|
||||
}
|
||||
private async refreshMaterials() {
|
||||
if (this.materialRefresh) return this.materialRefresh
|
||||
@@ -1098,6 +1105,7 @@ export class Online {
|
||||
}
|
||||
}
|
||||
private detach() {
|
||||
this.state.materialsWarning = ''
|
||||
this.materialAbort.abort()
|
||||
this.materialAbort = new AbortController()
|
||||
this.received = null
|
||||
@@ -1405,6 +1413,7 @@ export class Online {
|
||||
this.downloadKey = action === 'check' ? null : key
|
||||
this.cancellation = new AbortController()
|
||||
this.state.canCancel = true
|
||||
let incomplete = false
|
||||
try {
|
||||
const description = await prepareScreenPackage(
|
||||
{
|
||||
@@ -1414,6 +1423,7 @@ export class Online {
|
||||
token: this.credentials.access,
|
||||
signal: this.cancellation.signal,
|
||||
onRequestFailure: this.onRequestFailure,
|
||||
onIncomplete: () => { incomplete = true },
|
||||
},
|
||||
(url, init) => {
|
||||
const parsed = new URL(String(url))
|
||||
@@ -1421,6 +1431,9 @@ export class Online {
|
||||
return this.authorized(parsed.pathname, init)
|
||||
},
|
||||
)
|
||||
this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : ''
|
||||
if (incomplete && action !== 'check' && this.packages.list().some((pkg) => pkg.key === key))
|
||||
throw new Error(`${INCOMPLETE_MATERIALS_WARNING}. Сохранённые материалы оставлены без изменений`)
|
||||
if (action === 'check') {
|
||||
const previous = this.packages.list().find((p) => p.key === key)
|
||||
this.state.notice = !previous
|
||||
@@ -1454,7 +1467,8 @@ export class Online {
|
||||
await this.persistSecrets()
|
||||
}
|
||||
}
|
||||
this.state.notice = 'Материалы проверены и готовы к показу'
|
||||
this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : ''
|
||||
this.state.notice = incomplete ? '' : 'Материалы проверены и готовы к показу'
|
||||
} finally {
|
||||
this.cancellation = null
|
||||
this.state.canCancel = false
|
||||
|
||||
@@ -136,6 +136,7 @@ export function requestErrorContext(
|
||||
if (contextualErrors.has(result)) return result
|
||||
const names: Record<string, string> = {
|
||||
GetGame: 'Описание игры',
|
||||
GetScenario: 'Сценарий игры',
|
||||
GetGameMusic: 'Музыка игры',
|
||||
GetScreenPlan: 'План экранов',
|
||||
SaveScreenPlan: 'Сохранение плана',
|
||||
|
||||
@@ -180,6 +180,82 @@ test('получает данные через три существующих A
|
||||
assert.ok(!JSON.stringify(result).includes('SECRET'))
|
||||
})
|
||||
|
||||
test('полный сценарий добавляет фон и введение до подключения: 14 файлов становятся 16', async () => {
|
||||
const game = { game: { ID: 7, name: 'Игра', scenario: { ID: 23, image: 'cover.png' } } }
|
||||
const full = { scenario: { ID: 23, image: 'cover.png', story: {
|
||||
background: 'background.png', introduction: { audio: 'intro.wav' },
|
||||
questions: [{ answer: 'PRIVATE-ANSWER' }],
|
||||
} } }
|
||||
const music = { gamePlaylistId: 1, playlists: [{ ID: 1, name: 'Музыка', tracks:
|
||||
Array.from({ length: 13 }, (_, index) => ({ ID: index + 1, name: `Трек ${index}`, filename: `track-${index}.wav` })),
|
||||
}] }
|
||||
assert.equal(buildScreenPackage(server, 7, { game, plan: {}, music }).assets.length, 14)
|
||||
const calls: string[] = []
|
||||
let incomplete = false
|
||||
const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', onIncomplete: () => { incomplete = true } }, async (input) => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
return Response.json(url.endsWith('/scenarios/23') ? full : url.endsWith('/music') ? music : url.endsWith('/screen-plan') ? {} : game)
|
||||
})
|
||||
assert.equal(pkg.assets.length, 16)
|
||||
assert.ok(pkg.media.background && pkg.media.intro)
|
||||
assert.ok(calls.includes(`${server}/api/scenarios/23`))
|
||||
assert.equal(incomplete, false)
|
||||
assert.equal(JSON.stringify(pkg).includes('PRIVATE-ANSWER'), false)
|
||||
assert.deepEqual(parseScreenPackage(pkg), pkg)
|
||||
})
|
||||
|
||||
test('недоступный или неполный сценарий предупреждает о неполном списке, чужой ID отклоняется', async () => {
|
||||
const game = { game: { ID: 7, scenario: { ID: 23, image: 'cover.png' } } }
|
||||
for (const status of [403, 404, 500, 200]) {
|
||||
let incomplete = false
|
||||
const failures: unknown[] = []
|
||||
const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', onIncomplete: () => { incomplete = true }, onRequestFailure: (failure) => failures.push(failure) }, async (input) => {
|
||||
const url = String(input)
|
||||
return url.endsWith('/scenarios/23') ? Response.json({ scenario: { ID: 23 } }, { status }) : Response.json(url.endsWith('/games/7') ? game : {})
|
||||
})
|
||||
assert.equal(incomplete, true)
|
||||
assert.equal(pkg.assets.length, 1)
|
||||
if (status !== 200) assert.deepEqual(failures, [{ path: '/api/scenarios/{id}', reason: status === 403 ? 'AUTH' : 'HTTP' }])
|
||||
}
|
||||
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) =>
|
||||
Response.json(String(input).endsWith('/scenarios/23') ? { scenario: { ID: 24 } } : String(input).endsWith('/games/7') ? game : {})),
|
||||
/Сценарий игры.*GET \/api\/scenarios\/23.*другой сценарий/)
|
||||
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) =>
|
||||
Response.json(String(input).endsWith('/scenarios/23') ? null : String(input).endsWith('/games/7') ? game : {})),
|
||||
/Сценарий игры.*scenarioResponse/)
|
||||
})
|
||||
|
||||
test('состояние подключённого экрана имеет приоритет и не требует сценария', async () => {
|
||||
const data = fixture()
|
||||
Object.assign(data.game.game.scenario, { ID: 23 })
|
||||
let calls = 0
|
||||
const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', resources: { cover: 'current.png', background: '', intro: '', conclusion: '' } }, async (input) => {
|
||||
const url = String(input)
|
||||
assert.ok(!url.includes('/scenarios/'))
|
||||
calls++
|
||||
return Response.json(url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game)
|
||||
})
|
||||
assert.equal(calls, 3)
|
||||
assert.equal(pkg.media.intro, null)
|
||||
assert.ok(pkg.assets.some((asset) => asset.url.endsWith('/current.png')))
|
||||
})
|
||||
|
||||
test('отмена запроса полного сценария не выдаёт неполный пакет', async () => {
|
||||
const abort = new AbortController()
|
||||
const timer = setTimeout(() => abort.abort(), 20)
|
||||
let incomplete = false
|
||||
try {
|
||||
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET', signal: abort.signal, onIncomplete: () => { incomplete = true } }, async (input, init) => {
|
||||
if (String(input).includes('/scenarios/')) return new Promise((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true })
|
||||
})
|
||||
return Response.json(String(input).endsWith('/games/7') ? { game: { ID: 7, scenario: { ID: 23 } } } : {})
|
||||
}), /Сценарий игры.*отмен/)
|
||||
assert.equal(incomplete, false)
|
||||
} finally { clearTimeout(timer) }
|
||||
})
|
||||
|
||||
test('отказы API и некорректный JSON не выдают частичный пакет или ответ с секретом', async () => {
|
||||
for (const status of [401, 403, 404, 500]) {
|
||||
await assert.rejects(
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from './request-error.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
|
||||
|
||||
export interface ScreenPackage {
|
||||
formatVersion: 1
|
||||
metadataVersion: string
|
||||
@@ -39,6 +41,7 @@ export interface PackageRequest {
|
||||
token: string
|
||||
resources?: { cover: string; background: string; intro: string; conclusion: string }
|
||||
signal?: AbortSignal
|
||||
onIncomplete?: () => void
|
||||
onRequestFailure?: (failure: {
|
||||
path: string
|
||||
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
|
||||
@@ -326,6 +329,7 @@ export async function prepareScreenPackage(
|
||||
: meta.method === 'GetScreenPlan'
|
||||
? '/screen-plan'
|
||||
: ''
|
||||
const safePath = meta.method === 'GetScenario' ? '/api/scenarios/{id}' : `/api/games/{id}${suffix}`
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchAPI(`${server}/${requestData.path}`, {
|
||||
@@ -337,7 +341,7 @@ export async function prepareScreenPackage(
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
const path = `/api/games/{id}${suffix}`
|
||||
const path = safePath
|
||||
if (error instanceof ApiError || error instanceof SessionChangedError) {
|
||||
request.onRequestFailure?.({
|
||||
path,
|
||||
@@ -356,7 +360,7 @@ export async function prepareScreenPackage(
|
||||
}
|
||||
if (!response.ok) {
|
||||
request.onRequestFailure?.({
|
||||
path: `/api/games/{id}${suffix}`,
|
||||
path: safePath,
|
||||
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
||||
})
|
||||
throw requestErrorContext(
|
||||
@@ -369,7 +373,7 @@ export async function prepareScreenPackage(
|
||||
try {
|
||||
return await response.json()
|
||||
} catch {
|
||||
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
||||
request.onRequestFailure?.({ path: safePath, reason: 'INVALID_JSON' })
|
||||
throw requestErrorContext(
|
||||
new Error('Некорректный JSON в ответе API'),
|
||||
meta.method,
|
||||
@@ -383,10 +387,42 @@ export async function prepareScreenPackage(
|
||||
client.GetScreenPlan({ gameID: request.gameId }),
|
||||
client.GetGameMusic({ gameID: request.gameId }),
|
||||
])
|
||||
let fullScenarioId: number | null = null
|
||||
try {
|
||||
const gameData = object(object(game, 'gameResponse').game, 'game')
|
||||
const scenario = optionalObject(gameData.scenario, 'game.scenario')
|
||||
if (!request.resources && gameData.scenario != null) {
|
||||
if (scenario.ID != null) {
|
||||
const scenarioId = id(scenario.ID, 'game.scenario.ID')
|
||||
let response
|
||||
let received = false
|
||||
try {
|
||||
response = await client.GetScenario({ ID: scenarioId })
|
||||
received = true
|
||||
} catch (error) {
|
||||
if (signal.aborted || error instanceof SessionChangedError || (error instanceof ApiError && error.status === 401)) throw error
|
||||
request.onIncomplete?.()
|
||||
}
|
||||
if (received) {
|
||||
try {
|
||||
const full = object(object(response, 'scenarioResponse').scenario, 'scenario')
|
||||
if (id(full.ID, 'scenario.ID') !== scenarioId)
|
||||
throw new ResponseFieldError('scenario.ID', 'идентификатор сценария выбранной игры', full.ID, 'API вернул другой сценарий')
|
||||
optionalObject(full.story, 'scenario.story')
|
||||
fullScenarioId = scenarioId
|
||||
gameData.scenario = { ...scenario, ...full, story: full.story }
|
||||
if (full.story == null) request.onIncomplete?.()
|
||||
} catch (error) {
|
||||
throw requestErrorContext(error, 'GetScenario', 'GET', `/api/scenarios/${scenarioId}`)
|
||||
}
|
||||
}
|
||||
} else if (scenario.story == null) request.onIncomplete?.()
|
||||
}
|
||||
return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources)
|
||||
} catch (error) {
|
||||
const field = error instanceof ResponseFieldError ? error.field : 'game'
|
||||
if (fullScenarioId !== null && field.startsWith('game.scenario.'))
|
||||
throw requestErrorContext(error, 'GetScenario', 'GET', `/api/scenarios/${fullScenarioId}`)
|
||||
const state = field.startsWith('state.')
|
||||
const suffix = field.startsWith('plan')
|
||||
? '/screen-plan'
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createServer } = require('node:http')
|
||||
const { nativeImage } = require('electron')
|
||||
const { soundCheckWav } = require('../dist-electron/electron/sound-check.js')
|
||||
|
||||
module.exports = async ({ evaluate, poll }) => {
|
||||
const requests = []
|
||||
let scenarioError = false
|
||||
const image = nativeImage.createFromBitmap(Buffer.alloc(4), { width: 1, height: 1 }).toPNG()
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url)
|
||||
request.resume()
|
||||
const send = (value, status = 200) => {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
response.end(JSON.stringify(value))
|
||||
}
|
||||
const game = { ID: 7, name: 'Materials smoke', status: 'finish', scenario: { ID: 23, image: 'cover.png' } }
|
||||
if (request.url === '/api/auth/login') return send({ accessToken: 'TEST', refreshToken: 'REFRESH' })
|
||||
if (request.url === '/api/users/me') return send({ user: { roles: ['organizer'] } })
|
||||
if (request.url === '/api/playlists') return send({ playlists: [] })
|
||||
if (request.url === '/api/games') return send({ games: [game] })
|
||||
if (request.url === '/api/games/7') return send({ game })
|
||||
if (request.url === '/api/games/7/screen-plan') return send({ plan: { steps: [{ mode: 2 }] } })
|
||||
if (request.url === '/api/games/7/music') return send({ gamePlaylistId: 1, playlists: [{ ID: 1, name: 'Музыка', tracks:
|
||||
Array.from({ length: 13 }, (_, index) => ({ ID: index + 1, name: `Трек ${index}`, filename: `track-${index}.wav` })),
|
||||
}] })
|
||||
if (request.url === '/api/scenarios/23') return send({ scenario: { ID: 23, image: 'cover.png', story: { background: 'background.png', introduction: { audio: 'intro.wav' } } } }, scenarioError ? 403 : 200)
|
||||
if (request.url.startsWith('/api/files/')) {
|
||||
response.writeHead(200, { 'Content-Type': request.url.endsWith('.png') ? 'image/png' : 'audio/wav' })
|
||||
response.end(request.url.endsWith('.png') ? image : soundCheckWav)
|
||||
return
|
||||
}
|
||||
send({}, 404)
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
try {
|
||||
const url = `http://127.0.0.1:${server.address().port}`
|
||||
await evaluate(`window.screenBridge.onlineAction(${JSON.stringify({ action: 'login', server: url, email: 'test@example.test', password: 'test-password' })})`)
|
||||
await evaluate('window.screenBridge.onlineAction({ action: "select-game", gameId: 7 })')
|
||||
const result = await evaluate('window.screenBridge.onlineState()')
|
||||
assert.equal(result.materials.length, 16)
|
||||
assert.ok(result.materials.every((m) => m.status === 'ready'))
|
||||
assert.ok(result.materials.some((m) => m.usages.some((u) => u.name === 'Фон')))
|
||||
assert.ok(result.materials.some((m) => m.usages.some((u) => u.name === 'Вступление')))
|
||||
assert.equal(result.screenId, null)
|
||||
assert.equal(result.materialsWarning, '')
|
||||
assert.ok(!requests.some((path) => path.startsWith('/api/screens')))
|
||||
await poll(() => evaluate('document.body.textContent.includes("Материалы — загружено 16 из 16")'), 'all materials before screen connection')
|
||||
scenarioError = true
|
||||
await assert.rejects(evaluate('window.screenBridge.onlineAction({ action: "download", gameId: 7 })'), /Сохранённые материалы/)
|
||||
const incomplete = await evaluate('window.screenBridge.onlineState()')
|
||||
assert.equal(incomplete.materials.length, 16)
|
||||
assert.equal(incomplete.notice, '')
|
||||
await poll(() => evaluate('document.querySelector("[data-testid=materials-warning]")?.textContent.includes("неполный список")'), 'incomplete materials warning')
|
||||
console.log('SCREEN_MATERIALS_PREPARE_PASS')
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,11 @@ app.whenReady().then(async () => {
|
||||
(await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')),
|
||||
'control UI',
|
||||
)
|
||||
if (process.argv.includes('--materials-prepare')) {
|
||||
await require('./materials-prepare-smoke.cjs')({ evaluate, poll })
|
||||
app.exit(0)
|
||||
return
|
||||
}
|
||||
if (process.argv.includes('--clear-games')) {
|
||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
||||
await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button')
|
||||
|
||||
@@ -331,6 +331,9 @@ const gameOptions = computed(() => {
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<n-alert v-if="state.materialsWarning" type="warning" role="status" data-testid="materials-warning">
|
||||
{{ state.materialsWarning }}
|
||||
</n-alert>
|
||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
||||
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
||||
|
||||
@@ -12,6 +12,7 @@ export function useOnlineConnection() {
|
||||
server: DEFAULT_SERVER,
|
||||
currentGameId: null,
|
||||
materials: [],
|
||||
materialsWarning: '',
|
||||
authenticated: false,
|
||||
remembered: false,
|
||||
busy: false,
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface OnlineState {
|
||||
server: string
|
||||
currentGameId: number | null
|
||||
materials: GameMaterial[]
|
||||
materialsWarning: string
|
||||
authenticated: boolean
|
||||
remembered: boolean
|
||||
busy: boolean
|
||||
|
||||
Reference in New Issue
Block a user