fix
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user