add time
This commit is contained in:
@@ -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
|
||||
|
||||
- Статус: `DONE`.
|
||||
|
||||
@@ -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с')
|
||||
})
|
||||
@@ -72,6 +72,8 @@ async function fixture(t: TestContext, secure = true) {
|
||||
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
|
||||
title: 'Дело',
|
||||
gameStatus: 'finish',
|
||||
startedAt: '',
|
||||
endedAt: '',
|
||||
gameError: 0,
|
||||
gameResponse: null as (() => Promise<Response>) | null,
|
||||
gameCommands: [] as string[],
|
||||
@@ -190,6 +192,8 @@ async function fixture(t: TestContext, secure = true) {
|
||||
ID: Number(path.split('/').at(-1)),
|
||||
name: model.title,
|
||||
status: model.gameStatus,
|
||||
startedAt: model.startedAt,
|
||||
endedAt: model.endedAt,
|
||||
scenario: model.omitScenario
|
||||
? null
|
||||
: {
|
||||
@@ -2101,6 +2105,29 @@ test('управление игрой работает без привязанн
|
||||
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) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
|
||||
+4
-1
@@ -155,7 +155,10 @@ export class Online {
|
||||
const status = text(game.status)
|
||||
if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status))
|
||||
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 = ''
|
||||
const listed = this.state.games.find(g => g.id === id)
|
||||
if (listed) listed.status = status
|
||||
|
||||
@@ -84,6 +84,8 @@ module.exports = async function onlineSmoke({
|
||||
ID: 6,
|
||||
name: 'HTTP smoke',
|
||||
status: 'finish',
|
||||
startedAt: '2026-10-27T08:00:00Z',
|
||||
endedAt: '2026-10-27T09:12:00Z',
|
||||
scenario: { story: invalidStory ? [] : null },
|
||||
},
|
||||
})
|
||||
@@ -426,6 +428,10 @@ module.exports = async function onlineSmoke({
|
||||
},
|
||||
'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(
|
||||
() =>
|
||||
evaluate(`(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<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 type { GameCommand, OnlineIntent, OnlineState } from '../types/online'
|
||||
import { gameCommands, gameStatuses } from '../utils/game-status'
|
||||
import { formatGameDuration } from '../utils/gameDuration'
|
||||
|
||||
const props = defineProps<{
|
||||
state: OnlineState
|
||||
@@ -13,6 +14,12 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
|
||||
const dialog = useDialog()
|
||||
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 disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError)
|
||||
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'">
|
||||
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
|
||||
</n-tag>
|
||||
<n-tag v-if="game" type="info" data-testid="game-duration">{{ duration }}</n-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export type OnlineIntent =
|
||||
| { action: 'select-game' | 'download' | 'check'; gameId: number }
|
||||
| { action: 'remove'; key: string }
|
||||
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
|
||||
server: string
|
||||
currentGameId: number | null
|
||||
|
||||
@@ -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}с`
|
||||
}
|
||||
Reference in New Issue
Block a user