This commit is contained in:
2026-10-18 04:40:55 +07:00
parent c36d3fd314
commit 41d3e8ac5e
12 changed files with 4105 additions and 157 deletions
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiPlan, apiScreenMode, apiScreenCommand } from './api-values.js'
import { parsePlan } from './screen-plan.js'
import { parseRemote } from './remote-state.js'
test('клиент proto задаёт маршруты, методы и JSON enum существующего gateway', async () => {
const calls: { path: string; method: string; body: string | null }[] = []
const client = createEveningDetectiveServerClient(async (request) => {
calls.push(request)
return {}
})
await client.Login({ email: 'dev@example.test', password: 'SECRET' })
await client.Refresh({ refreshToken: 'SECRET' })
await client.GetMe({})
await client.GetGames({})
await client.GetGame({ ID: 7 })
await client.GetScreenPlan({ gameID: 7 })
await client.GetGameMusic({ gameID: 7 })
const plan = parsePlan({
steps: [
{ mode: 8, text: 'Улика' },
{ mode: 7, timerDurationSeconds: 60 },
],
})
await client.SaveScreenPlan({ gameID: 7, ...apiPlan(plan) })
await client.SetScreenMode({ gameID: 7, mode: apiScreenMode(2) })
await client.SendScreenCommand({
gameID: 7,
command: apiScreenCommand(6),
positionMs: 1500,
volume: 50,
})
assert.deepEqual(
calls.map((c) => [c.method, c.path]),
[
['POST', 'api/auth/login'],
['POST', 'api/auth/refresh'],
['GET', 'api/users/me'],
['GET', 'api/games'],
['GET', 'api/games/7'],
['GET', 'api/games/7/screen-plan'],
['GET', 'api/games/7/music'],
['PUT', 'api/games/7/screen-plan'],
['POST', 'api/games/7/screens/mode'],
['POST', 'api/games/7/screens/command'],
],
)
assert.deepEqual(parsePlan(JSON.parse(calls[7]!.body!)), plan)
assert.equal(JSON.parse(calls[7]!.body!).steps[0].mode, 'SCREEN_MODE_TEXT')
assert.equal(JSON.parse(calls[8]!.body!).mode, 'SCREEN_MODE_INTRO')
assert.equal(JSON.parse(calls[9]!.body!).command, 'SCREEN_COMMAND_SEEK')
assert.throws(() => apiScreenMode(99), /режим/)
assert.throws(() => apiScreenCommand(99), /команда/)
})
test('ответ proto сохраняет строковый int64, enum и отсутствующие поля для проверки границы', async () => {
const client = createEveningDetectiveServerClient(async () =>
JSON.parse(
'{"state":{"stateVersion":"9007199254740993","mode":"SCREEN_MODE_TEXT","text":"Текст","positionMs":"1500"}}',
),
)
const next = parseRemote(
(await client.GetScreenState({ screenID: '12', accessToken: 'SECRET' })).state,
)
assert.equal(next.version, 9007199254740993n)
assert.equal(next.mode, 8)
assert.equal(next.position, 1.5)
assert.equal(next.music, null)
const invalid = createEveningDetectiveServerClient(async () => ({
state: { stateVersion: 9007199254740992 },
}))
assert.throws(() => parseRemote({ stateVersion: 9007199254740992 }), /версия/)
await assert.rejects(
async () =>
parseRemote((await invalid.GetScreenState({ screenID: '12', accessToken: 'SECRET' })).state),
/версия/,
)
})
+44
View File
@@ -0,0 +1,44 @@
import type {
ScreenCommand,
ScreenMode,
SaveScreenPlanReq,
} from './api/generated/crabs/evening_detective_server/index.js'
import type { ScreenPlan } from '../src/types/screen.js'
const modes = [
'SCREEN_MODE_UNSPECIFIED',
'SCREEN_MODE_COVER',
'SCREEN_MODE_INTRO',
'SCREEN_MODE_INTRO_DONE',
'SCREEN_MODE_CONCLUSION',
'SCREEN_MODE_CONCLUSION_DONE',
'SCREEN_MODE_RESULTS',
'SCREEN_MODE_TIMER',
'SCREEN_MODE_TEXT',
] as const satisfies readonly ScreenMode[]
const commands = [
'SCREEN_COMMAND_UNSPECIFIED',
'SCREEN_COMMAND_PLAY',
'SCREEN_COMMAND_PAUSE',
'SCREEN_COMMAND_RESUME',
'SCREEN_COMMAND_STOP',
'SCREEN_COMMAND_REPLAY',
'SCREEN_COMMAND_SEEK',
'SCREEN_COMMAND_VOLUME',
'SCREEN_COMMAND_TIMER_START',
'SCREEN_COMMAND_TIMER_PAUSE',
'SCREEN_COMMAND_TIMER_RESUME',
'SCREEN_COMMAND_TIMER_RESET',
] as const satisfies readonly ScreenCommand[]
export function apiScreenMode(mode: number): ScreenMode {
const value = modes[mode]
if (!value) throw new Error('Некорректный режим экрана')
return value
}
export function apiScreenCommand(command: number): ScreenCommand {
const value = commands[command]
if (!value) throw new Error('Некорректная команда экрана')
return value
}
export function apiPlan(plan: ScreenPlan): Pick<SaveScreenPlanReq, 'steps' | 'currentStep'> {
return { ...plan, steps: plan.steps.map((step) => ({ ...step, mode: apiScreenMode(step.mode) })) }
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -188,9 +188,9 @@ async function fixture(t: TestContext, secure = true) {
if (init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as Record<string, unknown>
const state = objectMusic(20, Number(body.trackIndex ?? 0))
state.playback = Number(body.playback ?? 1)
model.remote.music = {
...state,
playback: body.playback,
volume: body.volume,
loop: body.loop,
positionMs: body.positionMs,
@@ -1160,12 +1160,12 @@ test('локальные режим, музыка и таймер публику
f.controller.output(true)
await f.online.poll()
await f.online.planCommand({ action: 'mode', mode: 3 })
assert.equal(f.model.remote.mode, 3)
assert.equal(f.model.remote.mode, 'SCREEN_MODE_INTRO_DONE')
await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 })
assert.equal(f.model.remote.timerDurationSeconds, 60)
await f.online.planCommand({ action: 'play', channel: 'music' })
const music = f.model.remote.music as { playback: number }
assert.equal(music.playback, 2)
const music = f.model.remote.music as { playback: string }
assert.equal(music.playback, 'MUSIC_PLAYBACK_PLAYING')
const operation = f.controller.state.channels.music.operation
await f.online.poll()
assert.equal(f.controller.state.channels.music.operation, operation)
+103 -144
View File
@@ -1,3 +1,5 @@
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@@ -449,12 +451,9 @@ export class Online {
const version = this.planVersion
this.lastPlan = Date.now()
try {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan`,
undefined,
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
gameID: pkg.package.game.id,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} catch (error) {
if (generation === this.generation && version === this.planVersion)
@@ -472,13 +471,10 @@ export class Online {
const version = ++this.planVersion
this.planBusy++
try {
const result = await this.json(
await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plan),
}),
)
const result = await this.client().SaveScreenPlan({
gameID: pkg.package.game.id,
...apiPlan(plan),
})
if (generation !== this.generation) throw new SessionChangedError()
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} finally {
@@ -576,12 +572,10 @@ export class Online {
return
}
if (intent.action === 'step') {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan/step`,
{ currentStep: intent.index },
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: intent.index,
})
const confirmed = parsePlan(result.plan)
const shown = this.controller.state.catalog.steps[intent.index]
await this.acceptPlan(confirmed, pkg.key, generation, version)
@@ -599,29 +593,29 @@ export class Online {
}
} else if ('channel' in intent && intent.channel === 'music') {
const c = localState.channels.music
await this.api(
`/api/games/${pkg.package.game.id}/music`,
{
playlistID: c.playlistId ?? 0,
trackIndex: c.trackIndex,
positionMs: Math.max(0, Math.round(c.position * 1000)),
volume: Math.round(c.volume),
loop: c.loop,
playback: c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1,
},
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
gameID: pkg.package.game.id,
playlistID: c.playlistId ?? 0,
trackIndex: c.trackIndex,
positionMs: Math.max(0, Math.round(c.position * 1000)),
volume: Math.round(c.volume),
loop: c.loop,
playback:
c.desired === 'playing'
? 'MUSIC_PLAYBACK_PLAYING'
: c.desired === 'paused'
? 'MUSIC_PLAYBACK_PAUSED'
: 'MUSIC_PLAYBACK_STOPPED',
})
} else if (intent.action === 'mode' || intent.action === 'select') {
await this.api(
`/api/games/${pkg.package.game.id}/screens/mode`,
{ mode: localState.scene.mode },
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
gameID: pkg.package.game.id,
mode: apiScreenMode(localState.scene.mode),
})
} else if (intent.action === 'text') {
const path = `/api/games/${pkg.package.game.id}/screen-plan`
const current = parsePlan((await this.api(path)).plan)
const current = parsePlan(
(await this.client().GetScreenPlan({ gameID: pkg.package.game.id })).plan,
)
if (
generation !== this.generation ||
session !== this.controller.state.session ||
@@ -637,13 +631,7 @@ export class Online {
timerLabel: '',
timerDurationSeconds: 0,
})
await this.json(
await this.authorized(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
}),
)
await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(current) })
}
if (
generation !== this.generation ||
@@ -651,7 +639,10 @@ export class Online {
version !== this.planVersion
)
return
const result = await this.api(`${path}/step`, { currentStep: index })
const result = await this.client().SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: index,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
@@ -661,21 +652,18 @@ export class Online {
return
this.controller.markStep(index)
} else if (intent.action === 'timer-set') {
await this.api(
`/api/games/${pkg.package.game.id}/screens/timer`,
{ label: intent.label, durationSeconds: intent.durationSeconds },
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SetScreenTimer({
gameID: pkg.package.game.id,
label: intent.label,
durationSeconds: intent.durationSeconds,
})
} else {
const index = this.controller.state.scene.stepIndex
if (index !== null && index !== this.plan.currentStep) {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan/step`,
{ currentStep: index },
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: index,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
@@ -699,16 +687,12 @@ export class Online {
}
if (!(intent.action in commands)) throw new Error('Некорректная команда плана')
expectedCommand = commands[intent.action as keyof typeof commands]
await this.api(
`/api/games/${pkg.package.game.id}/screens/command`,
{
command: expectedCommand,
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
},
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SendScreenCommand({
gameID: pkg.package.game.id,
command: apiScreenCommand(expectedCommand),
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
})
}
if (
generation !== this.generation ||
@@ -719,12 +703,10 @@ export class Online {
this.planNotice = ''
// Ответ следующего опроса становится базой: собственная команда не применяется дважды.
if (this.screen) {
const result = await this.api(
'/api/screens/state',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
screenID: this.screen.id,
accessToken: this.screen.token,
})
if (generation !== this.generation || session !== this.controller.state.session) return
const remote = parseRemote(result.state)
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
@@ -748,12 +730,9 @@ export class Online {
needsMainSync = !isMusicCommand
}
if (needsMainSync && remote.command === 0) {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan`,
undefined,
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
gameID: pkg.package.game.id,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
@@ -1021,19 +1000,18 @@ export class Online {
if (this.refreshPromise) return this.refreshPromise
const c = this.credentials!
this.refreshPromise = (async () => {
const response = await this.raw(c.server, '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: c.refresh }),
})
if (generation !== this.generation) throw new SessionChangedError()
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
throw new ApiError(401)
let result
try {
result = await this.client(false, undefined, c.server).Refresh({ refreshToken: c.refresh })
} catch (error) {
if (generation !== this.generation) throw new SessionChangedError()
if (error instanceof ApiError && error.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
}
throw error
}
const result = await this.json(response)
if (generation !== this.generation) throw new SessionChangedError()
this.credentials = {
...c,
@@ -1070,21 +1048,19 @@ export class Online {
}
return response
}
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) {
const init: RequestInit =
body === undefined
? { signal }
: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
}
return this.json(
authorized
? await this.authorized(path, init)
: await this.raw(this.state.server, path, init),
)
private client(authorized = true, signal?: AbortSignal, server = this.state.server) {
return createEveningDetectiveServerClient(async (request) => {
const init: RequestInit = {
method: request.method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: request.body,
signal,
}
const path = `/${request.path}`
return this.json(
authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
)
})
}
async act(input: unknown): Promise<OnlineState> {
const data = object(input)
@@ -1127,12 +1103,7 @@ export class Online {
this.state.games = []
await this.persistSecrets()
this.state.server = server
const response = await this.raw(server, '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const result = await this.json(response)
const result = await this.client(false, undefined, server).Login({ email, password })
this.credentials = {
server,
access: token(result.accessToken),
@@ -1140,7 +1111,7 @@ export class Online {
screen: null,
}
try {
const me = object((await this.api('/api/users/me')).user)
const me = object((await this.client().GetMe({})).user)
if (
!Array.isArray(me.roles) ||
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
@@ -1261,17 +1232,13 @@ export class Online {
this.state.packagesRevision++
await this.saveSelection()
}
const result = await this.api('/api/screens', {}, false)
const result = await this.client(false).CreateScreenSession({})
const s: ScreenSession = {
id: String(integer(result.screenID, 1)),
token: token(result.accessToken),
gameId,
}
await this.api(`/api/screens/${s.id}/bind`, {
screenID: s.id,
accessToken: s.token,
gameID: s.gameId,
})
await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.gameId })
this.screen = s
this.state.screenId = s.id
this.state.connection = 'waiting'
@@ -1285,10 +1252,7 @@ export class Online {
case 'sound': {
const generation = this.generation
if (!this.screen) throw new Error('Сначала подключите управление')
await this.api(`/api/screens/${this.screen.id}/sound`, {
screenID: this.screen.id,
soundEnabled: true,
})
await this.client().SetScreenSound({ screenID: this.screen.id, soundEnabled: true })
if (generation === this.generation) this.state.soundEnabled = true
return
}
@@ -1299,7 +1263,7 @@ export class Online {
}
}
private async loadGames() {
const result = await this.api('/api/games')
const result = await this.client().GetGames({})
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
this.state.games = ((result.games ?? []) as unknown[]).map((value) => {
const g = object(value)
@@ -1328,11 +1292,10 @@ export class Online {
this.resultsBusy = true
this.lastResults = Date.now()
try {
const result = await this.api(
'/api/screens/results',
{ screenID: s.id, accessToken: s.token },
false,
)
const result = await this.client(false).GetScreenResults({
screenID: s.id,
accessToken: s.token,
})
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? []))
throw new Error('Некорректные результаты')
@@ -1378,12 +1341,10 @@ export class Online {
const version = this.planVersion
this.pollBusy = true
try {
const result = await this.api(
'/api/screens/state',
{ screenID: s.id, accessToken: s.token },
false,
AbortSignal.timeout(5000),
)
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
screenID: s.id,
accessToken: s.token,
})
if (generation !== this.generation || version !== this.planVersion) return
let next = parseRemote(result.state)
if (next.gameId !== s.gameId) {
@@ -1458,12 +1419,10 @@ export class Online {
await this.synchronize()
return
}
const result = await this.api(
'/api/screens/music/advance',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({
screenID: this.screen.id,
accessToken: this.screen.token,
})
if (generation !== this.generation || report.session !== this.controller.state.session) return
const music = parseRemote({ music: result.state }).music
if (this.received) {
+17 -4
View File
@@ -1,3 +1,4 @@
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import {
ApiError,
NetworkError,
@@ -240,10 +241,18 @@ export async function prepareScreenPackage(
AbortSignal.timeout(30_000),
...(request.signal ? [request.signal] : []),
])
async function get(suffix: string): Promise<unknown> {
const client = createEveningDetectiveServerClient(async (requestData, meta) => {
const suffix =
meta.method === 'GetGameMusic'
? '/music'
: meta.method === 'GetScreenPlan'
? '/screen-plan'
: ''
let response: Response
try {
response = await fetchAPI(`${server}/api/games/${request.gameId}${suffix}`, {
response = await fetchAPI(`${server}/${requestData.path}`, {
method: requestData.method,
body: requestData.body,
headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' },
credentials: 'omit',
redirect: 'error',
@@ -292,8 +301,12 @@ export async function prepareScreenPackage(
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
throw new Error('Некорректный JSON в ответе API')
}
}
const [game, plan, music] = await Promise.all([get(''), get('/screen-plan'), get('/music')])
})
const [game, plan, music] = await Promise.all([
client.GetGame({ ID: request.gameId }),
client.GetScreenPlan({ gameID: request.gameId }),
client.GetGameMusic({ gameID: request.gameId }),
])
return buildScreenPackage(server, request.gameId, { game, plan, music })
}