diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index 1f22eb6..6bcd55d 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -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`. diff --git a/electron/game-duration.test.ts b/electron/game-duration.test.ts new file mode 100644 index 0000000..6cf6c43 --- /dev/null +++ b/electron/game-duration.test.ts @@ -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с') +}) diff --git a/electron/online.test.ts b/electron/online.test.ts index 04eddf9..97d0ce1 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -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) | 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() diff --git a/electron/online.ts b/electron/online.ts index 20cda16..55368db 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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 diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 78b6639..a026474 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -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(`(() => { diff --git a/src/components/GameControlPanel.vue b/src/components/GameControlPanel.vue index 4bd388b..20b798f 100644 --- a/src/components/GameControlPanel.vue +++ b/src/components/GameControlPanel.vue @@ -1,8 +1,9 @@