This commit is contained in:
2026-10-19 00:48:46 +07:00
parent 0f948b316a
commit 5635b8b5b1
13 changed files with 252 additions and 11 deletions
+6 -3
View File
@@ -275,8 +275,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено массив`. `Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено массив`.
Содержимое ответа, значения полей, токены и stack trace не отображаются. Содержимое ответа, значения полей, токены и stack trace не отображаются.
Отсутствие истории сценария в ответе игры не мешает подключению. После подключения При подготовке игры до подключения экрана приложение отдельно запрашивает полный
ссылки на обложку, фон, вступление и видео берутся из состояния экрана и автоматически сценарий, чтобы скачать фон, вступление и остальные материалы. Если API не отдаёт
сохраняются локально. До получения состояния используются доступные ссылки игры. полный сценарий, появляется предупреждение о неполном списке вместо сообщения
о полной готовности. При повторной загрузке уже сохранённый пакет остаётся без
изменений, пока полный список недоступен. После подключения ссылки на обложку,
фон, вступление и видео берутся из состояния экрана и сохраняются локально.
Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает
«Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага. «Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага.
+2 -1
View File
@@ -27,13 +27,14 @@ test('журнал сохраняет только разрешённые пол
}) })
log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' }) 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/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') const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
assert.equal(content.includes('SECRET'), false) assert.equal(content.includes('SECRET'), false)
const lines = content const lines = content
.trim() .trim()
.split('\n') .split('\n')
.map((s) => JSON.parse(s)) .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.equal(lines[0].version, '0.2.0-rc.1')
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time']) assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
}) })
+1 -1
View File
@@ -28,7 +28,7 @@ const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
], ],
}, },
'request-error': { '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'], reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'],
}, },
'media-error': { channel: ['main', 'music', 'scene'] }, 'media-error': { channel: ['main', 'music', 'scene'] },
+37
View File
@@ -55,6 +55,8 @@ async function fixture(t: TestContext, secure = true) {
introURL: 'https://media.test/intro.wav', introURL: 'https://media.test/intro.wav',
omitScenario: false, omitScenario: false,
omitStory: false, omitStory: false,
scenarioId: null as number | null,
scenarioError: 0,
musicOverride: null as Record<string, unknown> | null, musicOverride: null as Record<string, unknown> | null,
mediaHandler: null as ((url: string) => Promise<Response>) | null, mediaHandler: null as ((url: string) => Promise<Response>) | null,
failMedia: false, failMedia: false,
@@ -161,6 +163,7 @@ async function fixture(t: TestContext, secure = true) {
return json({}, 401) return json({}, 401)
if (model.refreshExpired) return json({}, 401) if (model.refreshExpired) return json({}, 401)
if (path === '/api/users/me') return json({ user: { roles: model.roles } }) 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') if (path === '/api/games')
return json({ return json({
games: [ games: [
@@ -187,6 +190,7 @@ async function fixture(t: TestContext, secure = true) {
scenario: model.omitScenario scenario: model.omitScenario
? null ? null
: { : {
...(model.scenarioId ? { ID: model.scenarioId } : {}),
story: model.omitStory story: model.omitStory
? null ? null
: { : {
@@ -389,6 +393,39 @@ test('чистый профиль предлагает адрес сайта п
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art') 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) => { test('список материалов показывает отдельную загрузку, ошибку, повтор и кеш после перезапуска', async (t) => {
const f = await fixture(t) const f = await fixture(t)
await f.login() await f.login()
+17 -3
View File
@@ -23,7 +23,7 @@ import type {
} from '../src/types/screen.js' } from '../src/types/screen.js'
import type { Controller } from './controller.js' import type { Controller } from './controller.js'
import { PackageStore, packageKey } from './package-store.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 { parseResults } from './results.js'
import { parsePlan } from './screen-plan.js' import { parsePlan } from './screen-plan.js'
import { import {
@@ -80,6 +80,7 @@ export class Online {
server: DEFAULT_SERVER, server: DEFAULT_SERVER,
currentGameId: null, currentGameId: null,
materials: [], materials: [],
materialsWarning: '',
authenticated: false, authenticated: false,
remembered: false, remembered: false,
busy: false, busy: false,
@@ -198,7 +199,9 @@ export class Online {
} }
private async description(gameId: number): Promise<ScreenPackage> { private async description(gameId: number): Promise<ScreenPackage> {
if (!this.credentials) throw new ApiError(401) if (!this.credentials) throw new ApiError(401)
return prepareScreenPackage( const generation = this.generation
let incomplete = false
const description = await prepareScreenPackage(
{ {
server: this.credentials.server, server: this.credentials.server,
gameId, gameId,
@@ -206,6 +209,7 @@ export class Online {
token: this.credentials.access, token: this.credentials.access,
signal: this.materialAbort.signal, signal: this.materialAbort.signal,
onRequestFailure: this.onRequestFailure, onRequestFailure: this.onRequestFailure,
onIncomplete: () => { incomplete = true },
}, },
(url, init) => { (url, init) => {
const parsed = new URL(String(url)) const parsed = new URL(String(url))
@@ -213,6 +217,9 @@ export class Online {
return this.authorized(parsed.pathname, init) return this.authorized(parsed.pathname, init)
}, },
) )
if (generation === this.generation)
this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : ''
return description
} }
private async refreshMaterials() { private async refreshMaterials() {
if (this.materialRefresh) return this.materialRefresh if (this.materialRefresh) return this.materialRefresh
@@ -1098,6 +1105,7 @@ export class Online {
} }
} }
private detach() { private detach() {
this.state.materialsWarning = ''
this.materialAbort.abort() this.materialAbort.abort()
this.materialAbort = new AbortController() this.materialAbort = new AbortController()
this.received = null this.received = null
@@ -1405,6 +1413,7 @@ export class Online {
this.downloadKey = action === 'check' ? null : key this.downloadKey = action === 'check' ? null : key
this.cancellation = new AbortController() this.cancellation = new AbortController()
this.state.canCancel = true this.state.canCancel = true
let incomplete = false
try { try {
const description = await prepareScreenPackage( const description = await prepareScreenPackage(
{ {
@@ -1414,6 +1423,7 @@ export class Online {
token: this.credentials.access, token: this.credentials.access,
signal: this.cancellation.signal, signal: this.cancellation.signal,
onRequestFailure: this.onRequestFailure, onRequestFailure: this.onRequestFailure,
onIncomplete: () => { incomplete = true },
}, },
(url, init) => { (url, init) => {
const parsed = new URL(String(url)) const parsed = new URL(String(url))
@@ -1421,6 +1431,9 @@ export class Online {
return this.authorized(parsed.pathname, init) 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') { if (action === 'check') {
const previous = this.packages.list().find((p) => p.key === key) const previous = this.packages.list().find((p) => p.key === key)
this.state.notice = !previous this.state.notice = !previous
@@ -1454,7 +1467,8 @@ export class Online {
await this.persistSecrets() await this.persistSecrets()
} }
} }
this.state.notice = 'Материалы проверены и готовы к показу' this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : ''
this.state.notice = incomplete ? '' : 'Материалы проверены и готовы к показу'
} finally { } finally {
this.cancellation = null this.cancellation = null
this.state.canCancel = false this.state.canCancel = false
+1
View File
@@ -136,6 +136,7 @@ export function requestErrorContext(
if (contextualErrors.has(result)) return result if (contextualErrors.has(result)) return result
const names: Record<string, string> = { const names: Record<string, string> = {
GetGame: 'Описание игры', GetGame: 'Описание игры',
GetScenario: 'Сценарий игры',
GetGameMusic: 'Музыка игры', GetGameMusic: 'Музыка игры',
GetScreenPlan: 'План экранов', GetScreenPlan: 'План экранов',
SaveScreenPlan: 'Сохранение плана', SaveScreenPlan: 'Сохранение плана',
+76
View File
@@ -180,6 +180,82 @@ test('получает данные через три существующих A
assert.ok(!JSON.stringify(result).includes('SECRET')) 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 () => { test('отказы API и некорректный JSON не выдают частичный пакет или ответ с секретом', async () => {
for (const status of [401, 403, 404, 500]) { for (const status of [401, 403, 404, 500]) {
await assert.rejects( await assert.rejects(
+39 -3
View File
@@ -8,6 +8,8 @@ import {
} from './request-error.js' } from './request-error.js'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
export interface ScreenPackage { export interface ScreenPackage {
formatVersion: 1 formatVersion: 1
metadataVersion: string metadataVersion: string
@@ -39,6 +41,7 @@ export interface PackageRequest {
token: string token: string
resources?: { cover: string; background: string; intro: string; conclusion: string } resources?: { cover: string; background: string; intro: string; conclusion: string }
signal?: AbortSignal signal?: AbortSignal
onIncomplete?: () => void
onRequestFailure?: (failure: { onRequestFailure?: (failure: {
path: string path: string
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON' reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
@@ -326,6 +329,7 @@ export async function prepareScreenPackage(
: meta.method === 'GetScreenPlan' : meta.method === 'GetScreenPlan'
? '/screen-plan' ? '/screen-plan'
: '' : ''
const safePath = meta.method === 'GetScenario' ? '/api/scenarios/{id}' : `/api/games/{id}${suffix}`
let response: Response let response: Response
try { try {
response = await fetchAPI(`${server}/${requestData.path}`, { response = await fetchAPI(`${server}/${requestData.path}`, {
@@ -337,7 +341,7 @@ export async function prepareScreenPackage(
signal, signal,
}) })
} catch (error) { } catch (error) {
const path = `/api/games/{id}${suffix}` const path = safePath
if (error instanceof ApiError || error instanceof SessionChangedError) { if (error instanceof ApiError || error instanceof SessionChangedError) {
request.onRequestFailure?.({ request.onRequestFailure?.({
path, path,
@@ -356,7 +360,7 @@ export async function prepareScreenPackage(
} }
if (!response.ok) { if (!response.ok) {
request.onRequestFailure?.({ request.onRequestFailure?.({
path: `/api/games/{id}${suffix}`, path: safePath,
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP', reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
}) })
throw requestErrorContext( throw requestErrorContext(
@@ -369,7 +373,7 @@ export async function prepareScreenPackage(
try { try {
return await response.json() return await response.json()
} catch { } catch {
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' }) request.onRequestFailure?.({ path: safePath, reason: 'INVALID_JSON' })
throw requestErrorContext( throw requestErrorContext(
new Error('Некорректный JSON в ответе API'), new Error('Некорректный JSON в ответе API'),
meta.method, meta.method,
@@ -383,10 +387,42 @@ export async function prepareScreenPackage(
client.GetScreenPlan({ gameID: request.gameId }), client.GetScreenPlan({ gameID: request.gameId }),
client.GetGameMusic({ gameID: request.gameId }), client.GetGameMusic({ gameID: request.gameId }),
]) ])
let fullScenarioId: number | null = null
try { 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) return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources)
} catch (error) { } catch (error) {
const field = error instanceof ResponseFieldError ? error.field : 'game' 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 state = field.startsWith('state.')
const suffix = field.startsWith('plan') const suffix = field.startsWith('plan')
? '/screen-plan' ? '/screen-plan'
+63
View File
@@ -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))
}
}
+5
View File
@@ -219,6 +219,11 @@ app.whenReady().then(async () => {
(await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')), (await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')),
'control UI', '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')) { if (process.argv.includes('--clear-games')) {
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button') await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button')
+3
View File
@@ -331,6 +331,9 @@ const gameOptions = computed(() => {
> >
</div> </div>
</template> </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"> <p v-if="state.preparation.active" role="status" data-testid="materials-progress">
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} · Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ {{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
+1
View File
@@ -12,6 +12,7 @@ export function useOnlineConnection() {
server: DEFAULT_SERVER, server: DEFAULT_SERVER,
currentGameId: null, currentGameId: null,
materials: [], materials: [],
materialsWarning: '',
authenticated: false, authenticated: false,
remembered: false, remembered: false,
busy: false, busy: false,
+1
View File
@@ -41,6 +41,7 @@ export interface OnlineState {
server: string server: string
currentGameId: number | null currentGameId: number | null
materials: GameMaterial[] materials: GameMaterial[]
materialsWarning: string
authenticated: boolean authenticated: boolean
remembered: boolean remembered: boolean
busy: boolean busy: boolean