This commit is contained in:
2026-10-27 09:38:05 +07:00
parent 1ce8efeef5
commit ccf7240584
8 changed files with 147 additions and 3 deletions
+34
View File
@@ -1,5 +1,39 @@
# Завершённые задачи приложения площадки # Завершённые задачи приложения площадки
## SCREEN-025 — Длительность игры в пульте
- Статус: `DONE`.
- Дата: 2026-10-27.
- Результат: рядом со статусом игры отображается длительность в формате сайта,
обновляемая раз в секунду. Пауза не исключается из времени; завершение фиксирует
длительность, сброс возвращает «Игра ещё не началась». Даты берутся из текущего
GetGame; некорректные даты показывают явное
сообщение. Отсутствующие поля допустимы. При потере связи сохраняются последние
даты и существующее предупреждение, при смене выбора старый счётчик скрывается.
- Проверки: `npm test` — 211/211; `npm run type-check`, `npm run lint`,
`npm run lint:css`, сборка и `git diff --check` — PASS. Ручное ревью diff
по применимым правилам кода выполнено. Новые тесты проверяют границы единиц,
UTC/смещение, некорректные даты, завершение, обновление и сброс серверных дат.
- Electron: отдельный прогон на базе существующего smoke подтвердил отображение,
ход счётчика, продолжение на паузе, завершение, сброс и снятие выбора игры
(`SCREEN025_DURATION_UI_PASS`; сценарии и лог — `/tmp/screen025-*`).
Общий `npm run smoke` остановился до новой проверки на существующем сценарии
громкости (`Timeout: volume slider keyboard update`); полный smoke не подтверждён.
- Ограничения: установочные архивы не пересобирались; живой сервер и физический
проектор не проверялись. Сервер, сайт, protobuf и зависимости не менялись.
Commit/push не выполнялись.
- Исправление по обратной связи (2026-10-27): при статусе «Идёт» отображалось
«Игра ещё не началась». Первоначальный UTC-расчёт не повторял GamePage:
started_at/ended_at хранятся в TIMESTAMP без часового пояса, PostgreSQL настроен
на Asia/Barnaul, а сайт сравнивает их с местными часами текущего компьютера.
Расчёт текущего времени приведён к GamePage без изменения сервера и данных.
Добавлен регрессионный тест для Asia/Barnaul и UTC. 212/212 тестов, типы,
линтеры, сборка, ревью diff и diff --check — PASS. Отдельная проверка Electron
на датах с местными часами подтвердила ход, паузу, завершение, сброс и снятие
выбора (`SCREEN025_DURATION_UI_PASS`, `/tmp/screen025-timezone-ui.log`).
Сохраняется поведение сайта: расчёт идущей игры зависит от часового пояса
компьютера; миграция серверных дат в эту задачу не входит.
## SCREEN-002 — Собрать описание пакета через существующий API ## SCREEN-002 — Собрать описание пакета через существующий API
- Статус: `DONE`. - Статус: `DONE`.
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { formatGameDuration } from '../src/utils/gameDuration.js'
const start = '2026-10-27T08:00:00Z'
const now = Date.parse(start)
const actualNow = now + new Date(now).getTimezoneOffset() * 60000
test('длительность игры форматируется на границах единиц времени', () => {
for (const [seconds, expected] of [
[0, '0с'], [59, '59с'], [60, '1м 0с'], [3599, '59м 59с'],
[3600, '1ч 0м'], [86399, '23ч 59м'], [86400, '1д 0ч'],
[30 * 86400, '1мес 0д'], [365 * 86400, '1г 0д'],
] as const) {
assert.equal(formatGameDuration(start, '', actualNow + seconds * 1000), expected)
}
assert.equal(formatGameDuration('2026-10-27T11:00:00+03:00', '', actualNow + 60000), '1м 0с')
})
test('начало, завершение и некорректные даты обрабатываются явно', () => {
assert.equal(formatGameDuration('', '', now), 'Игра ещё не началась')
assert.equal(formatGameDuration(start, '', actualNow - 1), 'Игра ещё не началась')
assert.equal(formatGameDuration(start, '2026-10-27T09:12:00Z', now + 86400000), '1ч 12м')
assert.equal(formatGameDuration('invalid', '', now), 'Время игры недоступно')
assert.equal(formatGameDuration(start, 'invalid', now), 'Время игры недоступно')
})
test('идущая игра считает местные часы сервера как вкладка «Игра»', (t) => {
const previousTimezone = process.env.TZ
t.after(() => {
if (previousTimezone === undefined) delete process.env.TZ
else process.env.TZ = previousTimezone
})
process.env.TZ = 'Asia/Barnaul'
const now = Date.parse('2026-10-27T08:12:00Z')
// В Барнауле игра началась в 15:00; API возвращает эти часы с суффиксом Z.
assert.equal(formatGameDuration('2026-10-27T15:00:00Z', '', now), '12м 0с')
assert.equal(formatGameDuration('2026-10-27T15:00:00Z', '', now + 1000), '12м 1с')
assert.equal(formatGameDuration('2026-10-27T15:00:00Z', '2026-10-27T15:05:00Z', now), '5м 0с')
process.env.TZ = 'UTC'
assert.equal(formatGameDuration('2026-10-27T08:00:00Z', '', now), '12м 0с')
})
+27
View File
@@ -72,6 +72,8 @@ async function fixture(t: TestContext, secure = true) {
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }], screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
title: 'Дело', title: 'Дело',
gameStatus: 'finish', gameStatus: 'finish',
startedAt: '',
endedAt: '',
gameError: 0, gameError: 0,
gameResponse: null as (() => Promise<Response>) | null, gameResponse: null as (() => Promise<Response>) | null,
gameCommands: [] as string[], gameCommands: [] as string[],
@@ -190,6 +192,8 @@ async function fixture(t: TestContext, secure = true) {
ID: Number(path.split('/').at(-1)), ID: Number(path.split('/').at(-1)),
name: model.title, name: model.title,
status: model.gameStatus, status: model.gameStatus,
startedAt: model.startedAt,
endedAt: model.endedAt,
scenario: model.omitScenario scenario: model.omitScenario
? null ? null
: { : {
@@ -2101,6 +2105,29 @@ test('управление игрой работает без привязанн
assert.equal(f.requests.some(r => r.path === '/api/screens'), false) assert.equal(f.requests.some(r => r.path === '/api/screens'), false)
}) })
test('даты длительности обновляются с сервером и очищаются после сброса', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({ action: 'select-game', gameId: 7 })
assert.equal(f.online.snapshot().game?.startedAt, '')
f.model.startedAt = '2026-10-27T08:00:00Z'
await f.online.refreshGameState(true)
assert.equal(f.online.snapshot().game?.startedAt, f.model.startedAt)
assert.equal(f.online.snapshot().game?.endedAt, '')
f.model.endedAt = '2026-10-27T09:12:00Z'
await f.online.refreshGameState(true)
assert.equal(f.online.snapshot().game?.endedAt, f.model.endedAt)
f.model.offline = true
await f.online.refreshGameState(true)
assert.equal(f.online.snapshot().game?.endedAt, f.model.endedAt)
f.model.offline = false
f.model.startedAt = ''
f.model.endedAt = ''
await f.online.act({ action: 'game-command', command: 'reset', key: f.controller.state.catalog.key! })
assert.equal(f.online.snapshot().game?.startedAt, '')
assert.equal(f.online.snapshot().game?.endedAt, '')
})
test('статус игры обновляется с сайта; неизвестный статус и ошибки сохраняют последнее состояние', async (t) => { test('статус игры обновляется с сайта; неизвестный статус и ошибки сохраняют последнее состояние', async (t) => {
const f = await fixture(t) const f = await fixture(t)
await f.login() await f.login()
+4 -1
View File
@@ -155,7 +155,10 @@ export class Online {
const status = text(game.status) const status = text(game.status)
if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status)) if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status))
throw new Error('Сервер вернул неизвестное состояние игры') throw new Error('Сервер вернул неизвестное состояние игры')
this.state.game = { key: pkg.key, id, name: text(game.name), status: status as GameStatus } this.state.game = {
key: pkg.key, id, name: text(game.name), status: status as GameStatus,
startedAt: text(game.startedAt ?? ''), endedAt: text(game.endedAt ?? ''),
}
this.state.gameError = '' this.state.gameError = ''
const listed = this.state.games.find(g => g.id === id) const listed = this.state.games.find(g => g.id === id)
if (listed) listed.status = status if (listed) listed.status = status
+6
View File
@@ -84,6 +84,8 @@ module.exports = async function onlineSmoke({
ID: 6, ID: 6,
name: 'HTTP smoke', name: 'HTTP smoke',
status: 'finish', status: 'finish',
startedAt: '2026-10-27T08:00:00Z',
endedAt: '2026-10-27T09:12:00Z',
scenario: { story: invalidStory ? [] : null }, scenario: { story: invalidStory ? [] : null },
}, },
}) })
@@ -426,6 +428,10 @@ module.exports = async function onlineSmoke({
}, },
'game prepared without screen connection', 'game prepared without screen connection',
) )
await poll(
() => evaluate(`document.querySelector('[data-testid="game-duration"]')?.textContent.trim() === '1ч 12м'`),
'completed game duration in controller',
)
await poll( await poll(
() => () =>
evaluate(`(() => { evaluate(`(() => {
+9 -1
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { NAlert, NButton, NCard, NSpace, NTag, useDialog } from 'naive-ui' import { NAlert, NButton, NCard, NSpace, NTag, useDialog } from 'naive-ui'
import type { GameCommand, OnlineIntent, OnlineState } from '../types/online' import type { GameCommand, OnlineIntent, OnlineState } from '../types/online'
import { gameCommands, gameStatuses } from '../utils/game-status' import { gameCommands, gameStatuses } from '../utils/game-status'
import { formatGameDuration } from '../utils/gameDuration'
const props = defineProps<{ const props = defineProps<{
state: OnlineState state: OnlineState
@@ -13,6 +14,12 @@ const props = defineProps<{
const emit = defineEmits<{ action: [intent: OnlineIntent] }>() const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
const dialog = useDialog() const dialog = useDialog()
const game = computed(() => props.state.game?.key === props.selectedKey ? props.state.game : null) const game = computed(() => props.state.game?.key === props.selectedKey ? props.state.game : null)
const now = ref(Date.now())
const duration = computed(() => game.value
? formatGameDuration(game.value.startedAt, game.value.endedAt, now.value)
: '')
const tick = setInterval(() => { now.value = Date.now() }, 1000)
onUnmounted(() => clearInterval(tick))
const status = computed(() => game.value ? gameStatuses[game.value.status] : null) const status = computed(() => game.value ? gameStatuses[game.value.status] : null)
const disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError) const disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError)
const availableCommands = computed(() => game.value && props.selectedKey ? gameCommands[game.value.status] : []) const availableCommands = computed(() => game.value && props.selectedKey ? gameCommands[game.value.status] : [])
@@ -52,6 +59,7 @@ function run(command: GameCommand) {
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'info'"> <n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'info'">
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }} {{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
</n-tag> </n-tag>
<n-tag v-if="game" type="info" data-testid="game-duration">{{ duration }}</n-tag>
</div> </div>
</template> </template>
<template #header-extra> <template #header-extra>
+1 -1
View File
@@ -36,7 +36,7 @@ export type OnlineIntent =
| { action: 'select-game' | 'download' | 'check'; gameId: number } | { action: 'select-game' | 'download' | 'check'; gameId: number }
| { action: 'remove'; key: string } | { action: 'remove'; key: string }
export interface OnlineState { export interface OnlineState {
game: { key: string; id: number; name: string; status: GameStatus } | null game: { key: string; id: number; name: string; status: GameStatus; startedAt: string; endedAt: string } | null
gameError: string gameError: string
server: string server: string
currentGameId: number | null currentGameId: number | null
+24
View File
@@ -0,0 +1,24 @@
export function formatGameDuration(startedAt: string, endedAt: string, now: number): string {
if (!startedAt) return 'Игра ещё не началась'
const start = Date.parse(startedAt)
// Совместимость с GamePage: серверные TIMESTAMP содержат местные часы как UTC.
const localNow = now - new Date(now).getTimezoneOffset() * 60000
const end = endedAt ? Date.parse(endedAt) : localNow
const diff = end - start
if (!Number.isFinite(diff)) return 'Время игры недоступно'
if (diff < 0) return 'Игра ещё не началась'
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
if (years > 0) return `${years}г ${days % 365}д`
if (months > 0) return `${months}мес ${days % 30}д`
if (days > 0) return `${days}д ${hours % 24}ч`
if (hours > 0) return `${hours}ч ${minutes % 60}м`
if (minutes > 0) return `${minutes}м ${seconds % 60}с`
return `${seconds}с`
}