up
This commit is contained in:
@@ -395,3 +395,26 @@
|
|||||||
границы JSON; напрямую трактовать их как number нельзя. Ограничение описано в README.
|
границы JSON; напрямую трактовать их как number нельзя. Ограничение описано в README.
|
||||||
- Границы: изменения только evening_detective_screen. Сервер, frontend и proto
|
- Границы: изменения только evening_detective_screen. Сервер, frontend и proto
|
||||||
не менялись, установочные архивы не пересобирались; commit/push не выполнялись.
|
не менялись, установочные архивы не пересобирались; commit/push не выполнялись.
|
||||||
|
|
||||||
|
## SCREEN-020 — Подробные ошибки API в интерфейсе
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-18.
|
||||||
|
- Результат: существующие сообщения ошибок показывают операцию, метод и путь API,
|
||||||
|
HTTP-статус или разрешённый код сети. Ошибки описания игры, плана, музыки и
|
||||||
|
состояния экрана указывают путь поля с индексами, ожидаемый и фактический тип.
|
||||||
|
Правила обязательности и допустимые значения полей сохранены.
|
||||||
|
- Контекст запроса сохраняет типы ошибок авторизации/сети/смены сессии; повторное
|
||||||
|
добавление префикса предотвращено. Ошибки доступны через существующие поля
|
||||||
|
состояния, в том числе в фоновой подготовке. Длинные строки переносятся в
|
||||||
|
блоке подключения и предупреждении плана. Значения полей, тела ответов,
|
||||||
|
query-параметры, пароли, токены и stack trace в интерфейс не передаются.
|
||||||
|
- Проверки: 113 тестов, type-check, lint, lint:css, сборка, Electron GUI smoke,
|
||||||
|
33 локальные Markdown-ссылки и git diff --check — PASS. Добавлены проверки
|
||||||
|
отсутствующего/null/массивного/строкового поля истории, индексов плана и треков,
|
||||||
|
HTTP-статуса, сетевых кодов, состояния экрана и отсутствия секретов. GUI
|
||||||
|
проверяет подробности ошибки истории, перенос текста и успешное подключение
|
||||||
|
после исправления тестового ответа.
|
||||||
|
- Границы: изменения только evening_detective_screen. Сервер и сгенерированный
|
||||||
|
клиент не менялись. Production-ошибка не воспроизводилась; установочные
|
||||||
|
архивы не пересобирались, commit/push не выполнялись.
|
||||||
|
|||||||
@@ -243,3 +243,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
Ошибка сессии требует повторного входа, а не проверки сети. В `diagnostic.jsonl`
|
Ошибка сессии требует повторного входа, а не проверки сети. В `diagnostic.jsonl`
|
||||||
событие `request-error` содержит только шаблон пути и разрешённый код причины;
|
событие `request-error` содержит только шаблон пути и разрешённый код причины;
|
||||||
пароли, токены, содержимое ответа и полные адреса туда не записываются.
|
пароли, токены, содержимое ответа и полные адреса туда не записываются.
|
||||||
|
|
||||||
|
Подробности ошибки видны прямо в пульте: операция, метод и путь API, статус HTTP
|
||||||
|
или безопасный код сетевого сбоя. Если JSON имеет неверную структуру, сообщение
|
||||||
|
показывает поле (включая индекс элемента), ожидаемый и фактический тип, например:
|
||||||
|
`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено null`.
|
||||||
|
Содержимое ответа, значения полей, токены и stack trace не отображаются.
|
||||||
|
|||||||
@@ -1237,3 +1237,21 @@ test('истёкшая авторизация при подключении иг
|
|||||||
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
|
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
|
||||||
assert.equal(f.online.snapshot().authenticated, false)
|
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'))
|
||||||
|
})
|
||||||
|
|||||||
+18
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestErrorContext } from './request-error.js'
|
||||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||||
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
|
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
|
||||||
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
||||||
@@ -989,11 +990,13 @@ export class Online {
|
|||||||
}
|
}
|
||||||
private async json(response: Response): Promise<Record<string, unknown>> {
|
private async json(response: Response): Promise<Record<string, unknown>> {
|
||||||
if (!response.ok) throw new ApiError(response.status)
|
if (!response.ok) throw new ApiError(response.status)
|
||||||
|
let value: unknown
|
||||||
try {
|
try {
|
||||||
return object(await response.json())
|
value = await response.json()
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Сервер вернул некорректный JSON')
|
throw new Error('Сервер вернул некорректный JSON')
|
||||||
}
|
}
|
||||||
|
return object(value, 'response')
|
||||||
}
|
}
|
||||||
private async refresh(failed: string, generation: number) {
|
private async refresh(failed: string, generation: number) {
|
||||||
if (this.credentials?.access !== failed) return
|
if (this.credentials?.access !== failed) return
|
||||||
@@ -1049,7 +1052,7 @@ export class Online {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
private client(authorized = true, signal?: AbortSignal, server = this.state.server) {
|
private client(authorized = true, signal?: AbortSignal, server = this.state.server) {
|
||||||
return createEveningDetectiveServerClient(async (request) => {
|
return createEveningDetectiveServerClient(async (request, meta) => {
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
@@ -1057,9 +1060,19 @@ export class Online {
|
|||||||
signal,
|
signal,
|
||||||
}
|
}
|
||||||
const path = `/${request.path}`
|
const path = `/${request.path}`
|
||||||
return this.json(
|
try {
|
||||||
authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
|
const result = await this.json(
|
||||||
)
|
authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
|
||||||
|
)
|
||||||
|
// Проверяем JSON до выхода из транспорта, пока доступен контекст запроса.
|
||||||
|
if (meta.method === 'GetScreenState') parseRemote(result.state)
|
||||||
|
if (meta.method === 'AdvanceScreenMusic') parseRemote({ music: result.state })
|
||||||
|
if (['GetScreenPlan', 'SaveScreenPlan', 'SetScreenPlanStep'].includes(meta.method))
|
||||||
|
parsePlan(result.plan)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
throw requestErrorContext(error, meta.method, request.method, path)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async act(input: unknown): Promise<OnlineState> {
|
async act(input: unknown): Promise<OnlineState> {
|
||||||
|
|||||||
+134
-74
@@ -1,34 +1,58 @@
|
|||||||
|
import { fieldError, ResponseFieldError } from './request-error.js'
|
||||||
import type { Controller } from './controller.js'
|
import type { Controller } from './controller.js'
|
||||||
import type { Intent, ScreenMode } from '../src/types/screen.js'
|
import type { Intent, ScreenMode } from '../src/types/screen.js'
|
||||||
|
|
||||||
export function object(value: unknown): Record<string, unknown> {
|
export function object(value: unknown, field?: string): Record<string, unknown> {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
throw new Error('Некорректный ответ сервера')
|
throw fieldError(field, 'объект', value, 'Некорректный ответ сервера')
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
}
|
}
|
||||||
export function integer(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number {
|
export function integer(
|
||||||
|
value: unknown,
|
||||||
|
min = 0,
|
||||||
|
max = Number.MAX_SAFE_INTEGER,
|
||||||
|
field?: string,
|
||||||
|
): number {
|
||||||
const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
|
const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
|
||||||
if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max)
|
if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max)
|
||||||
throw new Error('Некорректное число в ответе сервера')
|
throw fieldError(
|
||||||
|
field,
|
||||||
|
`целое число от ${min} до ${max}`,
|
||||||
|
value,
|
||||||
|
'Некорректное число в ответе сервера',
|
||||||
|
)
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
export function text(value: unknown): string {
|
export function text(value: unknown, field?: string): string {
|
||||||
if (typeof value !== 'string') throw new Error('Некорректный текст в ответе сервера')
|
if (typeof value !== 'string')
|
||||||
|
throw fieldError(field, 'строка', value, 'Некорректный текст в ответе сервера')
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
function enumeration(value: unknown, prefix: string, names: string[]): number {
|
function enumeration(value: unknown, prefix: string, names: string[], field?: string): number {
|
||||||
const n =
|
const n =
|
||||||
typeof value === 'string' && value.startsWith(prefix)
|
typeof value === 'string' && value.startsWith(prefix)
|
||||||
? names.indexOf(value.slice(prefix.length))
|
? names.indexOf(value.slice(prefix.length))
|
||||||
: integer(value ?? 0)
|
: integer(value ?? 0, 0, Number.MAX_SAFE_INTEGER, field)
|
||||||
return integer(n, 0, names.length - 1)
|
if (n < 0 || n >= names.length)
|
||||||
|
throw fieldError(field, `enum ${prefix}`, value, 'Некорректный enum в ответе сервера')
|
||||||
|
return integer(n, 0, names.length - 1, field)
|
||||||
}
|
}
|
||||||
function version(value: unknown): bigint {
|
function version(value: unknown, field: string): bigint {
|
||||||
if (typeof value === 'number' && !Number.isSafeInteger(value))
|
if (typeof value === 'number' && !Number.isSafeInteger(value))
|
||||||
throw new Error('Некорректная версия состояния')
|
throw new ResponseFieldError(
|
||||||
|
field,
|
||||||
|
'неотрицательный int64 без потери точности',
|
||||||
|
value,
|
||||||
|
'Некорректная версия состояния',
|
||||||
|
)
|
||||||
const v = String(value ?? '0')
|
const v = String(value ?? '0')
|
||||||
if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n)
|
if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n)
|
||||||
throw new Error('Некорректная версия состояния')
|
throw new ResponseFieldError(
|
||||||
|
field,
|
||||||
|
'неотрицательный int64 без потери точности',
|
||||||
|
value,
|
||||||
|
'Некорректная версия состояния',
|
||||||
|
)
|
||||||
return BigInt(v)
|
return BigInt(v)
|
||||||
}
|
}
|
||||||
export interface RemoteState {
|
export interface RemoteState {
|
||||||
@@ -60,87 +84,123 @@ export interface RemoteState {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
export function parseRemote(value: unknown): RemoteState {
|
export function parseRemote(value: unknown): RemoteState {
|
||||||
const s = object(value)
|
const s = object(value, 'state')
|
||||||
const mode =
|
const mode =
|
||||||
enumeration(s.mode, 'SCREEN_MODE_', [
|
enumeration(
|
||||||
'UNSPECIFIED',
|
s.mode,
|
||||||
'COVER',
|
'SCREEN_MODE_',
|
||||||
'INTRO',
|
[
|
||||||
'INTRO_DONE',
|
'UNSPECIFIED',
|
||||||
'CONCLUSION',
|
'COVER',
|
||||||
'CONCLUSION_DONE',
|
'INTRO',
|
||||||
'RESULTS',
|
'INTRO_DONE',
|
||||||
'TIMER',
|
'CONCLUSION',
|
||||||
'TEXT',
|
'CONCLUSION_DONE',
|
||||||
]) || 1
|
'RESULTS',
|
||||||
const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400)
|
'TIMER',
|
||||||
const remaining = integer(s.timerRemainingSeconds ?? 0, 0, duration)
|
'TEXT',
|
||||||
const timerState = enumeration(s.timerState, 'SCREEN_TIMER_STATE_', [
|
],
|
||||||
'UNSPECIFIED',
|
'state.mode',
|
||||||
'STOPPED',
|
) || 1
|
||||||
'RUNNING',
|
const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds')
|
||||||
'PAUSED',
|
const remaining = integer(
|
||||||
'FINISHED',
|
s.timerRemainingSeconds ?? 0,
|
||||||
])
|
0,
|
||||||
|
duration,
|
||||||
|
'state.timerRemainingSeconds',
|
||||||
|
)
|
||||||
|
const timerState = enumeration(
|
||||||
|
s.timerState,
|
||||||
|
'SCREEN_TIMER_STATE_',
|
||||||
|
['UNSPECIFIED', 'STOPPED', 'RUNNING', 'PAUSED', 'FINISHED'],
|
||||||
|
'state.timerState',
|
||||||
|
)
|
||||||
let music: RemoteState['music'] = null
|
let music: RemoteState['music'] = null
|
||||||
if (s.music) {
|
if (s.music) {
|
||||||
const m = object(s.music)
|
const m = object(s.music, 'state.music')
|
||||||
const p = m.playlist ? object(m.playlist) : null
|
const p = m.playlist ? object(m.playlist, 'state.music.playlist') : null
|
||||||
const tracks = p?.tracks ?? []
|
const tracks = p?.tracks ?? []
|
||||||
if (!Array.isArray(tracks)) throw new Error('Некорректный плейлист')
|
if (!Array.isArray(tracks))
|
||||||
const trackIds = tracks.map((t) => integer(object(t).ID, 1))
|
throw new ResponseFieldError(
|
||||||
if (new Set(trackIds).size !== trackIds.length) throw new Error('Повтор трека в плейлисте')
|
'state.music.playlist.tracks',
|
||||||
const index = integer(m.trackIndex ?? 0)
|
'массив',
|
||||||
|
tracks,
|
||||||
|
'Некорректный плейлист',
|
||||||
|
)
|
||||||
|
const trackIds = tracks.map((t, i) =>
|
||||||
|
integer(
|
||||||
|
object(t, `state.music.playlist.tracks[${i}]`).ID,
|
||||||
|
1,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
`state.music.playlist.tracks[${i}].ID`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (new Set(trackIds).size !== trackIds.length)
|
||||||
|
throw new ResponseFieldError(
|
||||||
|
'state.music.playlist.tracks',
|
||||||
|
'уникальные ID',
|
||||||
|
tracks,
|
||||||
|
'Повтор трека в плейлисте',
|
||||||
|
)
|
||||||
|
const index = integer(m.trackIndex ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.trackIndex')
|
||||||
music = {
|
music = {
|
||||||
version: version(m.stateVersion),
|
version: version(m.stateVersion, 'state.music.stateVersion'),
|
||||||
playlistId: p ? integer(p.ID, 1) : null,
|
playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null,
|
||||||
trackId: trackIds[index] ?? null,
|
trackId: trackIds[index] ?? null,
|
||||||
trackIds,
|
trackIds,
|
||||||
filenames: tracks.map((t) => text(object(t).filename ?? '')),
|
filenames: tracks.map((t, i) =>
|
||||||
position: integer(m.positionMs ?? 0) / 1000,
|
text(object(t).filename ?? '', `state.music.playlist.tracks[${i}].filename`),
|
||||||
volume: integer(m.volume ?? 0, 0, 100),
|
),
|
||||||
|
position:
|
||||||
|
integer(m.positionMs ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.positionMs') / 1000,
|
||||||
|
volume: integer(m.volume ?? 0, 0, 100, 'state.music.volume'),
|
||||||
loop: m.loop === true,
|
loop: m.loop === true,
|
||||||
playback: enumeration(m.playback, 'MUSIC_PLAYBACK_', [
|
playback: enumeration(
|
||||||
'UNSPECIFIED',
|
m.playback,
|
||||||
'STOPPED',
|
'MUSIC_PLAYBACK_',
|
||||||
'PLAYING',
|
['UNSPECIFIED', 'STOPPED', 'PLAYING', 'PAUSED'],
|
||||||
'PAUSED',
|
'state.music.playback',
|
||||||
]),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
gameId: s.gameID == null ? null : integer(s.gameID, 1),
|
gameId: s.gameID == null ? null : integer(s.gameID, 1, Number.MAX_SAFE_INTEGER, 'state.gameID'),
|
||||||
version: version(s.stateVersion),
|
version: version(s.stateVersion, 'state.stateVersion'),
|
||||||
mode: mode as ScreenMode,
|
mode: mode as ScreenMode,
|
||||||
command: enumeration(s.command, 'SCREEN_COMMAND_', [
|
command: enumeration(
|
||||||
'UNSPECIFIED',
|
s.command,
|
||||||
'PLAY',
|
'SCREEN_COMMAND_',
|
||||||
'PAUSE',
|
[
|
||||||
'RESUME',
|
'UNSPECIFIED',
|
||||||
'STOP',
|
'PLAY',
|
||||||
'REPLAY',
|
'PAUSE',
|
||||||
'SEEK',
|
'RESUME',
|
||||||
'VOLUME',
|
'STOP',
|
||||||
'TIMER_START',
|
'REPLAY',
|
||||||
'TIMER_PAUSE',
|
'SEEK',
|
||||||
'TIMER_RESUME',
|
'VOLUME',
|
||||||
'TIMER_RESET',
|
'TIMER_START',
|
||||||
]),
|
'TIMER_PAUSE',
|
||||||
position: integer(s.positionMs ?? 0) / 1000,
|
'TIMER_RESUME',
|
||||||
volume: integer(s.volume ?? 0, 0, 100),
|
'TIMER_RESET',
|
||||||
|
],
|
||||||
|
'state.command',
|
||||||
|
),
|
||||||
|
position: integer(s.positionMs ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.positionMs') / 1000,
|
||||||
|
volume: integer(s.volume ?? 0, 0, 100, 'state.volume'),
|
||||||
sound: s.soundEnabled === true,
|
sound: s.soundEnabled === true,
|
||||||
text: text(s.text ?? '').slice(0, 10000),
|
text: text(s.text ?? '', 'state.text').slice(0, 10000),
|
||||||
timer: {
|
timer: {
|
||||||
label: text(s.timerLabel ?? 'Перерыв').slice(0, 200),
|
label: text(s.timerLabel ?? 'Перерыв', 'state.timerLabel').slice(0, 200),
|
||||||
durationMs: duration * 1000,
|
durationMs: duration * 1000,
|
||||||
remainingMs: remaining * 1000,
|
remainingMs: remaining * 1000,
|
||||||
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
|
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
cover: text(s.coverImage ?? ''),
|
cover: text(s.coverImage ?? '', 'state.coverImage'),
|
||||||
background: text(s.backgroundImage ?? ''),
|
background: text(s.backgroundImage ?? '', 'state.backgroundImage'),
|
||||||
intro: text(s.introAudio ?? ''),
|
intro: text(s.introAudio ?? '', 'state.introAudio'),
|
||||||
conclusion: text(s.conclusionVideo ?? ''),
|
conclusion: text(s.conclusionVideo ?? '', 'state.conclusionVideo'),
|
||||||
},
|
},
|
||||||
music,
|
music,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import { networkFailure } from './request-error.js'
|
import { networkFailure, requestErrorContext, ApiError } from './request-error.js'
|
||||||
|
|
||||||
test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => {
|
test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => {
|
||||||
for (const [code, message] of [
|
for (const [code, message] of [
|
||||||
@@ -19,3 +19,17 @@ test('безопасная причина соединения извлекае
|
|||||||
assert.ok(!result.message.includes('SECRET'))
|
assert.ok(!result.message.includes('SECRET'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('контекст HTTP сохраняет тип ошибки, статус и исключает query с секретами', () => {
|
||||||
|
const error = new ApiError(403)
|
||||||
|
const result = requestErrorContext(error, 'GetGame', 'GET', '/api/games/7?access_token=SECRET')
|
||||||
|
assert.equal(result, error)
|
||||||
|
assert.equal(error.status, 403)
|
||||||
|
assert.match(result.message, /HTTP 403/)
|
||||||
|
assert.match(result.message, /Описание игры · GET \/api\/games\/7: Нет доступа/)
|
||||||
|
assert.ok(!result.message.includes('SECRET'))
|
||||||
|
assert.equal(
|
||||||
|
requestErrorContext(result, 'GetGame', 'GET', '/api/games/7').message,
|
||||||
|
result.message,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -75,3 +75,72 @@ export function networkFailure(error: unknown, signal: AbortSignal): NetworkErro
|
|||||||
: 'Сервер недоступен'
|
: 'Сервер недоступен'
|
||||||
return new NetworkError(message, code)
|
return new NetworkError(message, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function valueType(value: unknown): string {
|
||||||
|
return value === null
|
||||||
|
? 'null'
|
||||||
|
: value === undefined
|
||||||
|
? 'поле отсутствует'
|
||||||
|
: Array.isArray(value)
|
||||||
|
? 'массив'
|
||||||
|
: typeof value
|
||||||
|
}
|
||||||
|
export class ResponseFieldError extends Error {
|
||||||
|
constructor(
|
||||||
|
public field: string,
|
||||||
|
expected: string,
|
||||||
|
value: unknown,
|
||||||
|
message = 'Некорректный ответ API',
|
||||||
|
) {
|
||||||
|
super(`${message}: поле ${field} — ожидалось ${expected}, получено ${valueType(value)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function fieldError(
|
||||||
|
field: string | undefined,
|
||||||
|
expected: string,
|
||||||
|
value: unknown,
|
||||||
|
message: string,
|
||||||
|
): Error {
|
||||||
|
return field ? new ResponseFieldError(field, expected, value, message) : new Error(message)
|
||||||
|
}
|
||||||
|
const contextualErrors = new WeakSet<Error>()
|
||||||
|
export function requestErrorContext(
|
||||||
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): Error {
|
||||||
|
const result = error instanceof Error ? error : new Error('Не удалось обработать ответ сервера')
|
||||||
|
if (contextualErrors.has(result)) return result
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
GetGame: 'Описание игры',
|
||||||
|
GetGameMusic: 'Музыка игры',
|
||||||
|
GetScreenPlan: 'План экранов',
|
||||||
|
SaveScreenPlan: 'Сохранение плана',
|
||||||
|
SetScreenPlanStep: 'Переход по плану',
|
||||||
|
GetScreenState: 'Состояние экрана',
|
||||||
|
AdvanceScreenMusic: 'Следующий трек',
|
||||||
|
Login: 'Вход',
|
||||||
|
Refresh: 'Обновление сессии',
|
||||||
|
GetMe: 'Данные пользователя',
|
||||||
|
GetGames: 'Список игр',
|
||||||
|
CreateScreenSession: 'Создание экрана',
|
||||||
|
BindScreen: 'Подключение экрана',
|
||||||
|
SetScreenSound: 'Назначение звука',
|
||||||
|
SetGameMusic: 'Управление музыкой',
|
||||||
|
SetScreenMode: 'Режим экрана',
|
||||||
|
SetScreenTimer: 'Таймер',
|
||||||
|
SendScreenCommand: 'Команда экрана',
|
||||||
|
GetScreenResults: 'Результаты',
|
||||||
|
}
|
||||||
|
const safePath = path.split('?')[0]!
|
||||||
|
const code =
|
||||||
|
result instanceof NetworkError
|
||||||
|
? ` [${result.code}]`
|
||||||
|
: result instanceof ApiError && !result.message.includes(`HTTP ${result.status}`)
|
||||||
|
? ` [HTTP ${result.status}]`
|
||||||
|
: ''
|
||||||
|
result.message = `${names[operation] ?? 'Запрос API'} · ${method} ${safePath}: ${result.message}${code}`
|
||||||
|
contextualErrors.add(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ test('отказы API и некорректный JSON не выдают час
|
|||||||
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
||||||
throw new Error('SECRET')
|
throw new Error('SECRET')
|
||||||
}),
|
}),
|
||||||
/^Error: Описание игры: Сервер недоступен$/,
|
/^Error: Описание игры · GET \/api\/games\/7: Сервер недоступен \[NETWORK\]$/,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ test('сетевая ошибка называет запрос и журнал
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'}: Не удалось определить адрес сервера$`,
|
`^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'} · GET /api/games/7${suffix}: Не удалось определить адрес сервера \\[ENOTFOUND\\]$`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }])
|
assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }])
|
||||||
@@ -298,8 +298,71 @@ test('таймаут и отмена подготовки различаются
|
|||||||
return Response.json({})
|
return Response.json({})
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
new RegExp(`^Error: Описание игры: ${message}$`),
|
new RegExp(`^Error: Описание игры · GET /api/games/7: ${message} \\[${reason}\\]$`),
|
||||||
)
|
)
|
||||||
assert.ok(failures.every((f) => f.reason === reason))
|
assert.ok(failures.every((f) => f.reason === reason))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('ошибка структуры описания называет запрос, поле и тип, не раскрывая значения', async () => {
|
||||||
|
for (const [badValue, expectedType] of [
|
||||||
|
[null, 'null'],
|
||||||
|
[undefined, 'поле отсутствует'],
|
||||||
|
[[], 'массив'],
|
||||||
|
['SECRET', 'string'],
|
||||||
|
] as const) {
|
||||||
|
const data = fixture()
|
||||||
|
;(data.game.game.scenario as Record<string, unknown>).story = badValue
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage({ server, gameId: 7, token: 'TOKEN-SECRET' }, async (input) =>
|
||||||
|
Response.json(
|
||||||
|
String(input).endsWith('/music')
|
||||||
|
? data.music
|
||||||
|
: String(input).endsWith('/screen-plan')
|
||||||
|
? data.plan
|
||||||
|
: data.game,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof Error)
|
||||||
|
assert.match(error.message, /Описание игры · GET \/api\/games\/7/)
|
||||||
|
assert.ok(error.message.includes('поле game.scenario.story'))
|
||||||
|
assert.ok(error.message.includes(`получено ${expectedType}`))
|
||||||
|
assert.ok(!error.message.includes('SECRET'))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ошибки элементов плана и треков сохраняют индекс и источник ответа', async () => {
|
||||||
|
const plan = fixture()
|
||||||
|
plan.plan.plan.steps[0]!.mode = 'SECRET'
|
||||||
|
const music = fixture()
|
||||||
|
;(music.music.playlists[1]!.tracks[0] as Record<string, unknown>).filename = {
|
||||||
|
password: 'SECRET',
|
||||||
|
}
|
||||||
|
for (const [data, field, path] of [
|
||||||
|
[plan, 'plan.steps[0].mode', '/screen-plan'],
|
||||||
|
[music, 'music.playlists[1].tracks[0].filename', '/music'],
|
||||||
|
] as const) {
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) =>
|
||||||
|
Response.json(
|
||||||
|
String(input).endsWith('/music')
|
||||||
|
? data.music
|
||||||
|
: String(input).endsWith('/screen-plan')
|
||||||
|
? data.plan
|
||||||
|
: data.game,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof Error)
|
||||||
|
assert.ok(error.message.includes(`/api/games/7${path}:`))
|
||||||
|
assert.ok(error.message.includes(`поле ${field}`))
|
||||||
|
assert.ok(!error.message.includes('SECRET'))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+159
-78
@@ -1,7 +1,7 @@
|
|||||||
|
import { fieldError, ResponseFieldError, requestErrorContext } from './request-error.js'
|
||||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
NetworkError,
|
|
||||||
SessionChangedError,
|
SessionChangedError,
|
||||||
networkFailure,
|
networkFailure,
|
||||||
type NetworkErrorCode,
|
type NetworkErrorCode,
|
||||||
@@ -44,34 +44,42 @@ export interface PackageRequest {
|
|||||||
}) => void
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function object(value: unknown): Record<string, unknown> {
|
function object(value: unknown, field?: string): Record<string, unknown> {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
throw new Error('Некорректный ответ API')
|
throw fieldError(field, 'объект', value, 'Некорректный ответ API')
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
}
|
}
|
||||||
function optionalObject(value: unknown) {
|
function optionalObject(value: unknown, field?: string) {
|
||||||
return value == null ? {} : object(value)
|
return value == null ? {} : object(value, field)
|
||||||
}
|
}
|
||||||
function list(value: unknown): unknown[] {
|
function list(value: unknown, field?: string): unknown[] {
|
||||||
if (value == null) return []
|
if (value == null) return []
|
||||||
if (!Array.isArray(value)) throw new Error('Некорректный список в ответе API')
|
if (!Array.isArray(value))
|
||||||
|
throw fieldError(field, 'массив', value, 'Некорректный список в ответе API')
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
function text(value: unknown): string {
|
function text(value: unknown, field?: string): string {
|
||||||
if (value == null) return ''
|
if (value == null) return ''
|
||||||
if (typeof value !== 'string') throw new Error('Некорректный текст в ответе API')
|
if (typeof value !== 'string')
|
||||||
|
throw fieldError(field, 'строка', value, 'Некорректный текст в ответе API')
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
function integer(value: unknown, fallback = 0): number {
|
function integer(value: unknown, fallback = 0, field?: string): number {
|
||||||
if (value == null) return fallback
|
if (value == null) return fallback
|
||||||
if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value)
|
if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value)
|
||||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
||||||
throw new Error('Некорректное число в ответе API')
|
throw fieldError(field, 'неотрицательное целое число', value, 'Некорректное число в ответе API')
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
function id(value: unknown): number {
|
function id(value: unknown, field?: string): number {
|
||||||
const result = integer(value)
|
const result = integer(value, 0, field)
|
||||||
if (result === 0) throw new Error('Отсутствует идентификатор в ответе API')
|
if (result === 0)
|
||||||
|
throw fieldError(
|
||||||
|
field,
|
||||||
|
'положительный идентификатор',
|
||||||
|
value,
|
||||||
|
'Отсутствует идентификатор в ответе API',
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
function hash(value: string) {
|
function hash(value: string) {
|
||||||
@@ -107,12 +115,18 @@ const modes = [
|
|||||||
'TIMER',
|
'TIMER',
|
||||||
'TEXT',
|
'TEXT',
|
||||||
]
|
]
|
||||||
function mode(value: unknown): number {
|
function mode(value: unknown, field?: string): number {
|
||||||
const result =
|
const result =
|
||||||
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
|
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
|
||||||
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
|
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
|
||||||
: integer(value)
|
: integer(value, 0, field)
|
||||||
if (result < 1 || result > 8) throw new Error('Неизвестный режим показа')
|
if (result < 1 || result > 8)
|
||||||
|
throw fieldError(
|
||||||
|
field,
|
||||||
|
'режим экрана 1–8 или enum ScreenMode',
|
||||||
|
value,
|
||||||
|
'Неизвестный режим показа',
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,86 +152,140 @@ export function buildScreenPackage(
|
|||||||
): ScreenPackage {
|
): ScreenPackage {
|
||||||
server = serverURL(server)
|
server = serverURL(server)
|
||||||
id(gameId)
|
id(gameId)
|
||||||
const game = object(object(responses.game).game)
|
const game = object(object(responses.game, 'gameResponse').game, 'game')
|
||||||
if (id(game.ID) !== gameId) throw new Error('API вернул другую игру')
|
if (id(game.ID, 'game.ID') !== gameId)
|
||||||
const scenario = object(game.scenario)
|
throw new ResponseFieldError(
|
||||||
const story = object(scenario.story)
|
'game.ID',
|
||||||
const intro = optionalObject(story.introduction)
|
'идентификатор выбранной игры',
|
||||||
const conclusion = optionalObject(story.conclusion)
|
game.ID,
|
||||||
|
'API вернул другую игру',
|
||||||
|
)
|
||||||
|
const scenario = object(game.scenario, 'game.scenario')
|
||||||
|
const story = object(scenario.story, 'game.scenario.story')
|
||||||
|
const intro = optionalObject(story.introduction, 'game.scenario.story.introduction')
|
||||||
|
const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion')
|
||||||
const assets = new Map<string, { id: string; url: string }>()
|
const assets = new Map<string, { id: string; url: string }>()
|
||||||
function asset(value: unknown): string | null {
|
function asset(value: unknown, field: string): string | null {
|
||||||
const ref = text(value).trim()
|
const ref = text(value, field).trim()
|
||||||
if (!ref) return null
|
if (!ref) return null
|
||||||
const url = resolveMediaURL(server, ref)
|
let url: string
|
||||||
|
try {
|
||||||
|
url = resolveMediaURL(server, ref)
|
||||||
|
} catch {
|
||||||
|
throw new ResponseFieldError(
|
||||||
|
field,
|
||||||
|
'адрес HTTP(S) без секретов',
|
||||||
|
value,
|
||||||
|
'Недопустимый адрес файла или ссылка содержит секрет',
|
||||||
|
)
|
||||||
|
}
|
||||||
const assetId = hash(url)
|
const assetId = hash(url)
|
||||||
assets.set(assetId, { id: assetId, url })
|
assets.set(assetId, { id: assetId, url })
|
||||||
return assetId
|
return assetId
|
||||||
}
|
}
|
||||||
const cover = asset(scenario.image)
|
const cover = asset(scenario.image, 'game.scenario.image')
|
||||||
const media = {
|
const media = {
|
||||||
cover,
|
cover,
|
||||||
background: asset(story.background) ?? cover,
|
background: asset(story.background, 'game.scenario.story.background') ?? cover,
|
||||||
intro: asset(intro.audio),
|
intro: asset(intro.audio, 'game.scenario.story.introduction.audio'),
|
||||||
conclusion: asset(conclusion.video),
|
conclusion: asset(conclusion.video, 'game.scenario.story.conclusion.video'),
|
||||||
}
|
}
|
||||||
const plan = optionalObject(object(responses.plan).plan)
|
const plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan')
|
||||||
const steps = list(plan.steps).map((value) => {
|
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
|
||||||
const step = object(value)
|
const field = `plan.steps[${index}]`
|
||||||
|
const step = object(value, field)
|
||||||
const result = {
|
const result = {
|
||||||
mode: mode(step.mode),
|
mode: mode(step.mode, `${field}.mode`),
|
||||||
text: text(step.text),
|
text: text(step.text, `${field}.text`),
|
||||||
timerLabel: text(step.timerLabel),
|
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
|
||||||
timerDurationSeconds: integer(step.timerDurationSeconds),
|
timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`),
|
||||||
}
|
}
|
||||||
if (result.mode === 8 && !result.text.trim()) throw new Error('Пустой текст шага показа')
|
if (result.mode === 8 && !result.text.trim())
|
||||||
|
throw new ResponseFieldError(
|
||||||
|
`${field}.text`,
|
||||||
|
'непустая строка',
|
||||||
|
step.text,
|
||||||
|
'Пустой текст шага показа',
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
result.mode === 7 &&
|
result.mode === 7 &&
|
||||||
(result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400)
|
(result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400)
|
||||||
)
|
)
|
||||||
throw new Error('Некорректная длительность таймера')
|
throw new ResponseFieldError(
|
||||||
|
`${field}.timerDurationSeconds`,
|
||||||
|
'целое число от 1 до 86400',
|
||||||
|
step.timerDurationSeconds,
|
||||||
|
'Некорректная длительность таймера',
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
const musicResponse = object(responses.music)
|
const musicResponse = object(responses.music, 'musicResponse')
|
||||||
const state = optionalObject(musicResponse.state)
|
const state = optionalObject(musicResponse.state, 'music.state')
|
||||||
const selected = state.playlist == null ? null : object(state.playlist)
|
const selected = state.playlist == null ? null : object(state.playlist, 'music.state.playlist')
|
||||||
const music = {
|
const music = {
|
||||||
scenarioPlaylistId:
|
scenarioPlaylistId:
|
||||||
integer(musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id) || null,
|
integer(
|
||||||
gamePlaylistId: integer(musicResponse.gamePlaylistId) || null,
|
musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id,
|
||||||
selectedPlaylistId: selected ? id(selected.ID) : null,
|
0,
|
||||||
|
musicResponse.scenarioPlaylistId == null
|
||||||
|
? 'game.scenario.music_playlist_id'
|
||||||
|
: 'music.scenarioPlaylistId',
|
||||||
|
) || null,
|
||||||
|
gamePlaylistId: integer(musicResponse.gamePlaylistId, 0, 'music.gamePlaylistId') || null,
|
||||||
|
selectedPlaylistId: selected ? id(selected.ID, 'music.state.playlist.ID') : null,
|
||||||
}
|
}
|
||||||
const available = new Map<number, Record<string, unknown>>()
|
const available = new Map<number, { value: Record<string, unknown>; field: string }>()
|
||||||
for (const value of list(musicResponse.playlists)) {
|
for (const [index, value] of list(musicResponse.playlists, 'music.playlists').entries()) {
|
||||||
const playlist = object(value)
|
const field = `music.playlists[${index}]`
|
||||||
available.set(id(playlist.ID), playlist)
|
const playlist = object(value, field)
|
||||||
|
available.set(id(playlist.ID, `${field}.ID`), { value: playlist, field })
|
||||||
}
|
}
|
||||||
// Текущий плейлист берём из того же ответа, даже если его нет в библиотеке.
|
// Текущий плейлист берём из того же ответа, даже если его нет в библиотеке.
|
||||||
if (selected) available.set(id(selected.ID), selected)
|
if (selected)
|
||||||
|
available.set(id(selected.ID, 'music.state.playlist.ID'), {
|
||||||
|
value: selected,
|
||||||
|
field: 'music.state.playlist',
|
||||||
|
})
|
||||||
const selectedIds = [
|
const selectedIds = [
|
||||||
...new Set(Object.values(music).filter((value): value is number => value !== null)),
|
...new Set(Object.values(music).filter((value): value is number => value !== null)),
|
||||||
].sort((a, b) => a - b)
|
].sort((a, b) => a - b)
|
||||||
const playlists = selectedIds.map((playlistId) => {
|
const playlists = selectedIds.map((playlistId) => {
|
||||||
const playlist = available.get(playlistId)
|
const entry = available.get(playlistId)
|
||||||
if (!playlist) throw new Error('Не найден связанный плейлист')
|
if (!entry)
|
||||||
const tracks = list(playlist.tracks)
|
throw new ResponseFieldError(
|
||||||
.map((value) => {
|
'music.playlists',
|
||||||
const track = object(value)
|
'связанный плейлист',
|
||||||
const assetId = asset(track.filename)
|
undefined,
|
||||||
if (!assetId) throw new Error('Не указан файл трека')
|
'Не найден связанный плейлист',
|
||||||
|
)
|
||||||
|
const playlist = entry.value
|
||||||
|
const field = entry.field
|
||||||
|
const tracks = list(playlist.tracks, `${field}.tracks`)
|
||||||
|
.map((value, index) => {
|
||||||
|
const trackField = `${field}.tracks[${index}]`
|
||||||
|
const track = object(value, trackField)
|
||||||
|
const assetId = asset(track.filename, `${trackField}.filename`)
|
||||||
|
if (!assetId)
|
||||||
|
throw new ResponseFieldError(
|
||||||
|
`${trackField}.filename`,
|
||||||
|
'непустая строка',
|
||||||
|
track.filename,
|
||||||
|
'Не указан файл трека',
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
id: id(track.ID),
|
id: id(track.ID, `${trackField}.ID`),
|
||||||
name: text(track.name),
|
name: text(track.name, `${trackField}.name`),
|
||||||
position: integer(track.position),
|
position: integer(track.position, 0, `${trackField}.position`),
|
||||||
assetId,
|
assetId,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.position - b.position || a.id - b.id)
|
.sort((a, b) => a.position - b.position || a.id - b.id)
|
||||||
return { id: playlistId, name: text(playlist.name), tracks }
|
return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks }
|
||||||
})
|
})
|
||||||
const data = {
|
const data = {
|
||||||
formatVersion: 1 as const,
|
formatVersion: 1 as const,
|
||||||
server,
|
server,
|
||||||
game: { id: gameId, name: text(game.name) },
|
game: { id: gameId, name: text(game.name, 'game.name') },
|
||||||
media,
|
media,
|
||||||
steps,
|
steps,
|
||||||
music,
|
music,
|
||||||
@@ -270,36 +338,34 @@ export async function prepareScreenPackage(
|
|||||||
: 'HTTP'
|
: 'HTTP'
|
||||||
: 'SESSION_CHANGED',
|
: 'SESSION_CHANGED',
|
||||||
})
|
})
|
||||||
throw error
|
throw requestErrorContext(error, meta.method, requestData.method, `/${requestData.path}`)
|
||||||
}
|
}
|
||||||
const failure = networkFailure(error, signal)
|
const failure = networkFailure(error, signal)
|
||||||
request.onRequestFailure?.({ path, reason: failure.code })
|
request.onRequestFailure?.({ path, reason: failure.code })
|
||||||
const name =
|
throw requestErrorContext(failure, meta.method, requestData.method, `/${requestData.path}`)
|
||||||
suffix === '/music'
|
|
||||||
? 'Музыка игры'
|
|
||||||
: suffix === '/screen-plan'
|
|
||||||
? 'План экранов'
|
|
||||||
: 'Описание игры'
|
|
||||||
throw new NetworkError(`${name}: ${failure.message}`, failure.code)
|
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
request.onRequestFailure?.({
|
request.onRequestFailure?.({
|
||||||
path: `/api/games/{id}${suffix}`,
|
path: `/api/games/{id}${suffix}`,
|
||||||
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
||||||
})
|
})
|
||||||
throw new Error(
|
throw requestErrorContext(
|
||||||
response.status === 401
|
new ApiError(response.status),
|
||||||
? 'Требуется авторизация'
|
meta.method,
|
||||||
: response.status === 403
|
requestData.method,
|
||||||
? 'Нет доступа к игре'
|
`/${requestData.path}`,
|
||||||
: `Ошибка API: HTTP ${response.status}`,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
||||||
throw new Error('Некорректный JSON в ответе API')
|
throw requestErrorContext(
|
||||||
|
new Error('Некорректный JSON в ответе API'),
|
||||||
|
meta.method,
|
||||||
|
requestData.method,
|
||||||
|
`/${requestData.path}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const [game, plan, music] = await Promise.all([
|
const [game, plan, music] = await Promise.all([
|
||||||
@@ -307,7 +373,22 @@ export async function prepareScreenPackage(
|
|||||||
client.GetScreenPlan({ gameID: request.gameId }),
|
client.GetScreenPlan({ gameID: request.gameId }),
|
||||||
client.GetGameMusic({ gameID: request.gameId }),
|
client.GetGameMusic({ gameID: request.gameId }),
|
||||||
])
|
])
|
||||||
return buildScreenPackage(server, request.gameId, { game, plan, music })
|
try {
|
||||||
|
return buildScreenPackage(server, request.gameId, { game, plan, music })
|
||||||
|
} catch (error) {
|
||||||
|
const field = error instanceof ResponseFieldError ? error.field : 'game'
|
||||||
|
const suffix = field.startsWith('plan')
|
||||||
|
? '/screen-plan'
|
||||||
|
: field.startsWith('music')
|
||||||
|
? '/music'
|
||||||
|
: ''
|
||||||
|
throw requestErrorContext(
|
||||||
|
error,
|
||||||
|
suffix === '/music' ? 'GetGameMusic' : suffix ? 'GetScreenPlan' : 'GetGame',
|
||||||
|
'GET',
|
||||||
|
`/api/games/${request.gameId}${suffix}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском.
|
// Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском.
|
||||||
|
|||||||
+54
-17
@@ -1,3 +1,4 @@
|
|||||||
|
import { ResponseFieldError } from './request-error.js'
|
||||||
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
|
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
|
||||||
import { integer, object, text } from './remote-state.js'
|
import { integer, object, text } from './remote-state.js'
|
||||||
|
|
||||||
@@ -14,25 +15,53 @@ const modes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function parsePlan(value: unknown): ScreenPlan {
|
export function parsePlan(value: unknown): ScreenPlan {
|
||||||
const plan = value == null ? {} : object(value)
|
const plan = value == null ? {} : object(value, 'plan')
|
||||||
const list = plan.steps ?? []
|
const list = plan.steps ?? []
|
||||||
if (!Array.isArray(list)) throw new Error('Некорректные шаги плана')
|
if (!Array.isArray(list))
|
||||||
const steps = list.map((value) => {
|
throw new ResponseFieldError('plan.steps', 'массив', list, 'Некорректные шаги плана')
|
||||||
const step = object(value)
|
const steps = list.map((value, index) => {
|
||||||
const mode = integer(
|
const field = `plan.steps[${index}]`
|
||||||
typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_')
|
const step = object(value, field)
|
||||||
? modes.indexOf(step.mode.slice(12))
|
let mode: ScreenMode
|
||||||
: step.mode,
|
try {
|
||||||
1,
|
mode = integer(
|
||||||
8,
|
typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_')
|
||||||
) as ScreenMode
|
? modes.indexOf(step.mode.slice(12))
|
||||||
const content = text(step.text ?? '').trim()
|
: step.mode,
|
||||||
const label = text(step.timerLabel ?? '').trim()
|
1,
|
||||||
const seconds = integer(step.timerDurationSeconds ?? 0, 0, 86400)
|
8,
|
||||||
|
`${field}.mode`,
|
||||||
|
) as ScreenMode
|
||||||
|
} catch {
|
||||||
|
throw new ResponseFieldError(
|
||||||
|
`${field}.mode`,
|
||||||
|
'режим экрана 1–8 или enum ScreenMode',
|
||||||
|
step.mode,
|
||||||
|
'Некорректный режим экрана',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const content = text(step.text ?? '', `${field}.text`).trim()
|
||||||
|
const label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim()
|
||||||
|
const seconds = integer(
|
||||||
|
step.timerDurationSeconds ?? 0,
|
||||||
|
0,
|
||||||
|
86400,
|
||||||
|
`${field}.timerDurationSeconds`,
|
||||||
|
)
|
||||||
if (mode === 8 && (!content || Buffer.byteLength(content) > 10000))
|
if (mode === 8 && (!content || Buffer.byteLength(content) > 10000))
|
||||||
throw new Error('Текст шага должен содержать от 1 до 10000 байт')
|
throw new ResponseFieldError(
|
||||||
|
`${field}.text`,
|
||||||
|
'строка от 1 до 10000 байт',
|
||||||
|
step.text,
|
||||||
|
'Текст шага должен содержать от 1 до 10000 байт',
|
||||||
|
)
|
||||||
if (mode === 7 && (!seconds || Buffer.byteLength(label) > 200))
|
if (mode === 7 && (!seconds || Buffer.byteLength(label) > 200))
|
||||||
throw new Error('Укажите таймер от 1 до 86400 секунд и подпись до 200 байт')
|
throw new ResponseFieldError(
|
||||||
|
!seconds ? `${field}.timerDurationSeconds` : `${field}.timerLabel`,
|
||||||
|
!seconds ? 'число от 1 до 86400' : 'строка до 200 байт',
|
||||||
|
!seconds ? step.timerDurationSeconds : step.timerLabel,
|
||||||
|
'Укажите таймер от 1 до 86400 секунд и подпись до 200 байт',
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
text: content,
|
text: content,
|
||||||
@@ -40,5 +69,13 @@ export function parsePlan(value: unknown): ScreenPlan {
|
|||||||
timerDurationSeconds: seconds,
|
timerDurationSeconds: seconds,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { steps, currentStep: integer(plan.currentStep ?? 0, 0, Math.max(0, steps.length - 1)) }
|
return {
|
||||||
|
steps,
|
||||||
|
currentStep: integer(
|
||||||
|
plan.currentStep ?? 0,
|
||||||
|
0,
|
||||||
|
Math.max(0, steps.length - 1),
|
||||||
|
'plan.currentStep',
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ module.exports = async function onlineSmoke({
|
|||||||
}) {
|
}) {
|
||||||
let gamesCalls = 0
|
let gamesCalls = 0
|
||||||
let varyGames = false
|
let varyGames = false
|
||||||
|
let invalidStory = false
|
||||||
let version = 1
|
let version = 1
|
||||||
let offline = false
|
let offline = false
|
||||||
let polls = 0
|
let polls = 0
|
||||||
@@ -63,7 +64,7 @@ module.exports = async function onlineSmoke({
|
|||||||
game: {
|
game: {
|
||||||
ID: 6,
|
ID: 6,
|
||||||
name: 'HTTP smoke',
|
name: 'HTTP smoke',
|
||||||
scenario: { story: { introduction: { audio: 'intro.wav' } } },
|
scenario: { story: invalidStory ? null : { introduction: { audio: 'intro.wav' } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (path === '/api/games/6/screen-plan') {
|
if (path === '/api/games/6/screen-plan') {
|
||||||
@@ -250,6 +251,28 @@ module.exports = async function onlineSmoke({
|
|||||||
'control target stays on local package despite server game selection',
|
'control target stays on local package despite server game selection',
|
||||||
)
|
)
|
||||||
assert.notEqual(selectedBeforeDownload, 'HTTP smoke')
|
assert.notEqual(selectedBeforeDownload, 'HTTP smoke')
|
||||||
|
invalidStory = true
|
||||||
|
await assert.rejects(act({ action: 'connect', gameId: 6 }), /game.scenario.story/)
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`document.querySelector('[data-testid="connection-error"]')?.textContent.includes('game.scenario.story')`,
|
||||||
|
),
|
||||||
|
'detailed API error shown',
|
||||||
|
)
|
||||||
|
const errorText = await evaluate(
|
||||||
|
`document.querySelector('[data-testid="connection-error"]').textContent`,
|
||||||
|
)
|
||||||
|
assert.ok(errorText.includes('Описание игры · GET /api/games/6'))
|
||||||
|
assert.ok(errorText.includes('получено null'))
|
||||||
|
assert.ok(!errorText.includes('TEST-ACCESS'))
|
||||||
|
assert.equal(
|
||||||
|
await evaluate(
|
||||||
|
`getComputedStyle(document.querySelector('[data-testid="connection-error"]')).overflowWrap`,
|
||||||
|
),
|
||||||
|
'anywhere',
|
||||||
|
)
|
||||||
|
invalidStory = false
|
||||||
await act({ action: 'connect', gameId: 6 })
|
await act({ action: 'connect', gameId: 6 })
|
||||||
const packages = await evaluate('window.screenBridge.packages()')
|
const packages = await evaluate('window.screenBridge.packages()')
|
||||||
const pkg = packages.find((p) => p.name === 'HTTP smoke')
|
const pkg = packages.find((p) => p.name === 'HTTP smoke')
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
{{ state.preparation.total }} файлов; текущая загрузка
|
{{ state.preparation.total }} файлов; текущая загрузка
|
||||||
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
|
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
|
||||||
</p>
|
</p>
|
||||||
<n-alert v-if="state.preparation.error" type="warning">
|
<n-alert v-if="state.preparation.error" type="warning" class="connection-error">
|
||||||
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
|
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
|
||||||
<n-button
|
<n-button
|
||||||
:disabled="isBusy || state.busy || state.preparation.active"
|
:disabled="isBusy || state.busy || state.preparation.active"
|
||||||
@@ -236,7 +236,13 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
>Повторить подготовку</n-button
|
>Повторить подготовку</n-button
|
||||||
>
|
>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-alert v-if="state.error" type="error">{{ state.error }}</n-alert>
|
<n-alert
|
||||||
|
v-if="state.error"
|
||||||
|
type="error"
|
||||||
|
class="connection-error"
|
||||||
|
data-testid="connection-error"
|
||||||
|
>{{ state.error }}</n-alert
|
||||||
|
>
|
||||||
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
||||||
<template v-if="state.busy">
|
<template v-if="state.busy">
|
||||||
<p role="status">
|
<p role="status">
|
||||||
@@ -262,4 +268,9 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
.connection-content > button {
|
.connection-content > button {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-error {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
|||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-space>
|
</n-space>
|
||||||
<n-alert v-if="notice" type="warning" role="status">{{ notice }}</n-alert>
|
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
|
||||||
<p v-if="!canEdit">Для редактирования войдите на сервер выбранной подготовленной игры.</p>
|
<p v-if="!canEdit">Для редактирования войдите на сервер выбранной подготовленной игры.</p>
|
||||||
<template v-if="isEditing">
|
<template v-if="isEditing">
|
||||||
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
|
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
|
||||||
@@ -199,6 +199,10 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.plan-notice {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-step {
|
.plan-step {
|
||||||
border: 1px solid var(--brand-background);
|
border: 1px solid var(--brand-background);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|||||||
Reference in New Issue
Block a user