From b59128707226f204d72946cc8f019738eaa21d2e Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 01:38:02 +0700 Subject: [PATCH] up --- COMPLETED_TASKS.md | 34 +++ README.md | 10 +- docs/USER_GUIDE.md | 23 +- electron/controller.ts | 23 ++ electron/main.ts | 26 ++- electron/online.test.ts | 324 ++++++++++++++++++++++++++- electron/online.ts | 345 ++++++++++++++++++++++++++++- electron/preload.ts | 1 + electron/screen-plan.test.ts | 24 ++ electron/screen-plan.ts | 44 ++++ scripts/online-smoke.cjs | 152 ++++++++++++- src/App.vue | 6 +- src/components/ControlPage.vue | 128 ++++++++--- src/components/ScreenPlanPanel.vue | 206 +++++++++++++++++ src/types/online.ts | 4 + src/types/screen.ts | 10 + 16 files changed, 1308 insertions(+), 52 deletions(-) create mode 100644 electron/screen-plan.test.ts create mode 100644 electron/screen-plan.ts create mode 100644 src/components/ScreenPlanPanel.vue diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index 8bdf775..3eaadcc 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -188,3 +188,37 @@ применимые требования стандарта; нарушений не найдено. GUI: Linux x64, синтетический дисплей, no-sandbox/mute-audio/disable-gpu; физический звук, проектор и остальные ОС не проверялись, релизные архивы не пересобирались. + + +## SCREEN-010 — Редактирование серверного плана экранов в пульте + +- Статус: `DONE`. +- Дата: 2026-10-18. +- Результат: список шагов с «Показать», выделением текущего шага и переходами + назад/вперёд; редактор добавления, удаления, порядка, восьми режимов, текста и + параметров таймера. Сохранение и удаление общего плана через существующий API; + отмена грязного черновика с подтверждением. Ошибка сохранения сохраняет черновик. +- Сервер: права и JWT проверяются прежним Online-транспортом; серверные операции + доступны только пульту. Бэкенд, сайт, protobuf и зависимости не изменены. +- Офлайн: подтверждённый план хранится отдельно от пакета по ключу игры, + восстанавливается после перезапуска. Локальная команда выполняется до сети; + серверные запросы упорядочены, устаревшие ответы и неотправленные промежуточные + переходы пропускаются. После обрыва ожидающие действия не повторяются; + возврат сети сам не меняет показ. Следующее новое действие снова пробует сервер. +- Синхронизация: план обновляется и при закрытом показе; фоновый опрос не меняет + черновик. Последнее сохранение заменяет общий план. Сохранение не меняет сцену; + изменение активного шага снимает выделение. Собственные подтверждения не + сбрасывают повторно таймер/медиа, конкурентный переход с сайта учитывается. +- Проверки: type-check, ESLint, Stylelint, 88 тестов, сборка и полный Electron + GUI smoke — PASS. Smoke покрывает редактор, отказ сохранения, текст/таймер, + перестановку, отмену, удаление и запрет IPC из окна показа. Отдельные тесты + покрывают офлайн/перезапуск, быстрые переходы, запоздалые ответы, смену игры, + конкурентный таймер и назначение звука. Проверки HTTP/GUI выполнялись вне + песочницы; внутри неё HTTP-тест получает EPERM. +- Ревью: ручная проверка diff по применимым правилам и независимое ревью + синхронизации; найденные ошибки исправлены, добавлены регрессионные тесты. + git diff --check — PASS. README и руководство ведущего обновлены. +- Ограничения: GUI использует синтетический внешний дисплей и локальный тестовый + сервер; физический проектор, звук и рабочий сервер отдельно не проверялись. + Сборка предупреждает о JS chunk >500 kB. Установочные архивы не пересобирались; + commit и push не выполнялись. diff --git a/README.md b/README.md index 0724fdc..e498fe1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ офлайн без автоматического открытия показа и запуска звука. Управление с телефона идёт через существующий сервер сайта; прямого LAN-подключения нет. +SCREEN-010 реализована: в пульте доступен редактор общего серверного плана +экранов с локальной копией для офлайн-переходов. Подробности — в руководстве. +Установочные архивы кандидата эти изменения пока не содержат. + Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или проектор показывает материалы игрокам. Все материалы показа заранее находятся на диске; локальная команда не ждёт сеть или ответ сервера. @@ -223,8 +227,10 @@ URL, независимо от исходного имени файла. Лок ## Пульт и режимы SCREEN-005 Пульт включает готовый пакет, план шагов, переключатель восьми режимов, текст, -таймер, предпросмотр и прежние независимые каналы медиа/музыки. План из пакета -доступен только для чтения. Выбор шага применяет его параметры без автоматического +таймер, предпросмотр и прежние независимые каналы медиа/музыки. План экранов редактируется в пульте и сохраняется на сервере выбранной игры; +последняя подтверждённая копия хранится отдельно от пакета для офлайн-показа. +Онлайн-переход меняет общий шаг, офлайн-переход остаётся локальным и не отправляется +позже. Восстановление сети само не переключает показ. Выбор шага применяет его параметры без автоматического запуска звука/таймера; окончание медиа не переключает шаг. Пуск основного ресурса показывает соответствующий режим вступления или видео. Уход в другую сцену останавливает основной канал, сохраняя независимую музыку. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0857617..df3224e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -81,13 +81,34 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова ## Проведение показа -В «План показа» выберите шаг, используйте «Предыдущий шаг» / «Следующий шаг» +В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг» либо переключатель «Режим показа». Доступны восемь режимов: «Обложка», «Введение», «Успехов», «Закрывашка», «Спасибо за игру», «Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает успешного воспроизведения — смотрите статус и фактическое изображение/звук. +### Редактирование плана + +Войдите на сервер выбранной подготовленной игры. Нажмите «Создать план» или +«Редактировать»: добавляйте и удаляйте шаги, меняйте порядок кнопками ↑ / ↓, +режим, текст, надпись и длительность таймера. «Сохранить план» отправляет план +на сервер; «Отменить» возвращает сохранённый вариант. Для удаления всего плана +нажмите «Удалить план», затем «Сохранить план». При ошибке сохранения черновик +остаётся в редакторе. Изменения черновика и сохранение сами не меняют проектор. + +План общий с сайтом и телефоном. При параллельных правках последнее сохранение +заменяет его целиком. Во время редактирования фоновое обновление не меняет +черновик. Сохранение требует сети; локально хранится последняя подтверждённая +копия отдельно от материалов игры. + +Кнопки «Показать», переходы назад/вперёд и управление текущим шагом работают +локально сразу. При наличии связи действие отправляется на сервер для остальных +привязанных экранов. Без сети показ продолжается по последней копии; статус +предупреждает о неподтверждённом обновлении. Офлайн-команды не накапливаются: +восстановление связи само не меняет проектор и не отправляет прошлые переходы. +Следующее действие онлайн снова управляет общим планом. + ### Вступление, видео и музыка В «Вступление и видео» выберите медиа; в «Музыка» — плейлист и трек. diff --git a/electron/controller.ts b/electron/controller.ts index 4c23acb..363d2a7 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -11,6 +11,7 @@ import type { State, ScreenMode, TimerState, + ScreenStep, } from '../src/types/screen.js' import { parseResults } from './results.js' import type { LocalPackage } from './package-store.js' @@ -345,6 +346,28 @@ export class Controller { this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null this.broadcast() } + setPlan(steps: ScreenStep[]) { + if (isDeepStrictEqual(steps, this.state.catalog.steps)) return + const index = this.state.scene.stepIndex + const old = index === null ? null : this.state.catalog.steps[index] + this.state.catalog.steps = structuredClone(steps) + if (index !== null && !isDeepStrictEqual(old, steps[index])) this.state.scene.stepIndex = null + this.broadcast() + } + markStep(index: number) { + const step = this.state.catalog.steps[index] + const scene = this.state.scene + if (!step || step.mode !== scene.mode || (step.mode === 8 && step.text !== scene.text)) return + if ( + step.mode === 7 && + (step.timerLabel !== this.state.timer.label || + step.timerDurationSeconds * 1000 !== this.state.timer.durationMs) + ) + return + if (scene.stepIndex === index) return + scene.stepIndex = index + this.broadcast() + } setResults(value: unknown) { const key = this.state.catalog.key if (value !== null && !key) throw new Error('Сначала выберите пакет игры') diff --git a/electron/main.ts b/electron/main.ts index f24cca5..2f46653 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -419,6 +419,10 @@ app throw new Error('Недоступно') return controller.state }) + ipcMain.handle('plan-status', (event) => { + requireControl(event) + return online!.planStatus() + }) ipcMain.handle('online-state', (event): OnlineState => { requireControl(event) return online!.snapshot() @@ -476,7 +480,7 @@ app }) let localSession = '' let localSequence = 0 - ipcMain.handle('command', (event, command: unknown) => { + ipcMain.handle('command', async (event, command: unknown) => { requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') const parsed = parseCommand(command) @@ -491,8 +495,24 @@ app localSequence = 0 } if (parsed.sequence <= localSequence) return - controller.command({ ...parsed, sequence: controller.state.sequence + 1 }) - localSequence = parsed.sequence + if ( + parsed.action === 'step' || + (controller.state.scene.stepIndex !== null && + ((parsed.action.startsWith('timer-') && parsed.action !== 'timer-set') || + ('channel' in parsed && + parsed.channel === 'main' && + ['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'volume'].includes( + parsed.action, + )))) + ) { + const before = controller.state.sequence + const operation = online!.planCommand(parsed) + if (controller.state.sequence !== before) localSequence = parsed.sequence + await operation + } else { + controller.command({ ...parsed, sequence: controller.state.sequence + 1 }) + localSequence = parsed.sequence + } }) let capturing = false let lastCapture = -Infinity diff --git a/electron/online.test.ts b/electron/online.test.ts index 747850b..fc89a94 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { Controller } from './controller.js' import { Online, type SecretStorage } from './online.js' import { PackageStore } from './package-store.js' +import { parsePlan } from './screen-plan.js' import { applyRemote, parseRemote, send } from './remote-state.js' async function fixture(t: TestContext, secure = true) { @@ -49,6 +50,12 @@ async function fixture(t: TestContext, secure = true) { positionMs: '0', volume: 80, } as Record, + plan: parsePlan({ steps: [{ mode: 2 }] }), + planError: 0, + planSaves: 0, + planTransitions: 0, + planResponse: null as (() => Promise) | null, + stepResponse: null as (() => Promise) | null, stateResponse: null as (() => Promise) | null, } const json = (value: unknown, status = 200) => Response.json(value, { status }) @@ -131,7 +138,43 @@ async function fixture(t: TestContext, secure = true) { }, }, }) - if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } }) + if (path === '/api/games/7/screen-plan') { + if (model.planResponse) return model.planResponse() + if (model.planError) return json({}, model.planError) + if (init?.method === 'PUT') { + model.planSaves++ + model.plan = parsePlan(JSON.parse(String(init.body))) + } + return json({ plan: model.plan.steps.length ? model.plan : null }) + } + if (path === '/api/games/7/screen-plan/step') { + if (model.stepResponse) return model.stepResponse() + model.planTransitions++ + if (model.planError) return json({}, model.planError) + const data = JSON.parse(String(init?.body)) as { currentStep: number } + const step = model.plan.steps[data.currentStep] + if (!step) return json({}, 400) + model.plan.currentStep = data.currentStep + model.remote = { + ...model.remote, + mode: step.mode, + text: step.text, + command: 0, + timerLabel: step.timerLabel, + timerDurationSeconds: step.timerDurationSeconds || 600, + timerState: 1, + timerRemainingSeconds: step.timerDurationSeconds, + stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n), + } + return json({ plan: model.plan }) + } + if (path === '/api/games/7/screens/command') { + if (model.planError) return json({}, model.planError) + const data = JSON.parse(String(init?.body)) as { command: number } + model.remote.command = data.command + model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n) + return json({}) + } if (path === '/api/games/7/music') return json({ gamePlaylistId: 1, @@ -426,7 +469,10 @@ test('перестановка прежних треков останавлив }) assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/) assert.equal(f.controller.state.channels.music.desired, 'stopped') - assert.deepEqual(f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id), [1, 2]) + assert.deepEqual( + f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id), + [1, 2], + ) }) test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => { @@ -625,3 +671,277 @@ test('назначение звука подтверждается сервер false, ) }) + +test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => { + const f = await fixture(t) + await f.prepare() + const key = f.controller.state.catalog.key! + assert.equal(f.online.planStatus().editable, true) + const steps = parsePlan({ + steps: [ + { mode: 'SCREEN_MODE_TEXT', text: ' Новый текст ' }, + { mode: 7, timerDurationSeconds: '60' }, + ], + }).steps + const sceneRevision = f.controller.state.scene.revision + await f.online.act({ action: 'plan-save', key, steps }) + assert.deepEqual(f.model.plan.steps, steps) + assert.deepEqual(f.controller.state.catalog.steps, steps) + assert.equal(f.controller.state.scene.revision, sceneRevision) + assert.equal(f.model.planSaves, 1) + await assert.rejects( + f.online.act({ action: 'plan-save', key, steps: [{ mode: 8, text: ' ' }] }), + /Текст шага/, + ) + await assert.rejects( + f.online.act({ action: 'plan-save', key: 'wrong', steps }), + /игра изменилась/, + ) + assert.equal(f.model.planSaves, 1) + f.model.planError = 403 + await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Нет доступа/) + assert.deepEqual(f.controller.state.catalog.steps, steps) + f.model.planError = 0 + f.model.offline = true + await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Сервер недоступен/) + const restoredController = new Controller(() => {}) + const restored = new Online( + join(f.directory, 'connection'), + f.store, + restoredController, + f.secrets, + f.fetchAPI, + ) + await restored.initialize() + assert.deepEqual(restoredController.state.catalog.steps, steps) + assert.equal(restored.planStatus().key, key) + await restored.select(key) + f.model.offline = false + await f.online.act({ action: 'plan-save', key, steps: [] }) + assert.deepEqual(f.controller.state.catalog.steps, []) + assert.deepEqual( + JSON.parse(await readFile(join(f.directory, 'connection', `plan-${key}.json`), 'utf8')).steps, + [], + ) +}) + +test('план: онлайн-переход общий, без автозапуска и повторного применения; офлайн-переход не отправляется после возврата сети', async (t) => { + const f = await fixture(t) + await f.prepare() + const steps = parsePlan({ + steps: [ + { mode: 7, timerLabel: 'Отдых', timerDurationSeconds: 60 }, + { mode: 8, text: 'Локальный шаг' }, + ], + }).steps + await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps }) + f.controller.output(true) + await f.online.poll() + await f.online.planCommand({ action: 'step', index: 0 }) + assert.equal(f.model.planTransitions, 1) + assert.equal(f.model.plan.currentStep, 0) + assert.equal(f.controller.state.scene.stepIndex, 0) + assert.equal(f.controller.state.timer.status, 'stopped') + assert.equal(f.controller.state.channels.main.desired, 'stopped') + await f.online.planCommand({ action: 'timer-start' }) + const timerRevision = f.controller.state.timer.revision + await f.online.poll() + assert.equal(f.controller.state.timer.revision, timerRevision) + assert.equal(f.controller.state.timer.status, 'running') + f.model.offline = true + await f.online.planCommand({ action: 'step', index: 1 }) + assert.equal(f.controller.state.scene.text, 'Локальный шаг') + assert.match(f.online.planStatus().notice, /локально/) + f.model.offline = false + await f.online.poll() + assert.equal(f.controller.state.scene.text, 'Локальный шаг') + assert.equal(f.controller.state.scene.stepIndex, 1) + assert.equal(f.model.planTransitions, 1) + await f.online.planCommand({ action: 'step', index: 0 }) + assert.equal(f.model.planTransitions, 2) +}) + +test('план: изменение на сайте между опросом и переходом применяет подтверждённое содержимое', async (t) => { + const f = await fixture(t) + await f.prepare() + await f.online.refreshPlan() + f.model.plan = parsePlan({ steps: [{ mode: 8, text: 'Изменено на сайте' }] }) + await f.online.planCommand({ action: 'step', index: 0 }) + assert.equal(f.controller.state.scene.mode, 8) + assert.equal(f.controller.state.scene.text, 'Изменено на сайте') + assert.equal(f.controller.state.scene.stepIndex, 0) +}) + +test('план: поздний ответ старой игры и опрос до сохранения не заменяют новый план', async (t) => { + const f = await fixture(t) + await f.prepare() + const key = f.controller.state.catalog.key! + let resolve!: (response: Response) => void + f.model.planResponse = () => + new Promise((done) => { + resolve = done + }) + const pending = f.online.refreshPlan() + await new Promise((done) => setImmediate(done)) + f.model.planResponse = null + const steps = parsePlan({ steps: [{ mode: 8, text: 'Сохранено позже' }] }).steps + await f.online.act({ action: 'plan-save', key, steps }) + resolve(Response.json({ plan: { steps: [{ mode: 2 }] } })) + await pending + assert.deepEqual(f.controller.state.catalog.steps, steps) + f.model.planResponse = () => + new Promise((done) => { + resolve = done + }) + const oldGame = f.online.refreshPlan() + await new Promise((done) => setImmediate(done)) + await f.online.select(null) + resolve(Response.json({ plan: { steps: [{ mode: 8, text: 'Старая игра' }] } })) + await oldGame + assert.deepEqual(f.controller.state.catalog.steps, []) + assert.equal(f.online.planStatus().editable, false) +}) + +test('план: локальные переходы не ждут незавершённый запрос и поздний ответ не меняет показ', async (t) => { + const f = await fixture(t) + await f.prepare() + const steps = parsePlan({ + steps: [ + { mode: 8, text: 'Первый' }, + { mode: 8, text: 'Второй' }, + ], + }).steps + await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps }) + let finish!: (response: Response) => void + f.model.stepResponse = () => + new Promise((resolve) => { + finish = resolve + }) + const pending = f.online.planCommand({ action: 'step', index: 0 }) + await new Promise((done) => setImmediate(done)) + f.model.stepResponse = null + f.model.offline = true + const second = f.online.planCommand({ action: 'step', index: 1 }) + assert.equal(f.controller.state.scene.text, 'Второй') + finish(Response.json({ plan: { steps: [{ mode: 8, text: 'Устаревший' }], currentStep: 0 } })) + await Promise.all([pending, second]) + assert.equal(f.controller.state.scene.text, 'Второй') + assert.deepEqual(f.controller.state.catalog.steps, steps) +}) + +test('план: изменения с сайта загружаются при закрытом показе; удалённый переход выделяет актуальный шаг', async (t) => { + const f = await fixture(t) + await f.prepare() + f.model.plan = parsePlan({ + steps: [ + { mode: 8, text: 'Один' }, + { mode: 8, text: 'Два' }, + ], + }) + await f.online.poll() + assert.equal(f.controller.state.outputOpen, false) + assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps) + f.controller.output(true) + await f.online.poll() + f.model.plan.currentStep = 1 + f.model.remote = { + ...f.model.remote, + mode: 8, + text: 'Два', + command: 0, + stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n), + } + await f.online.poll() + assert.equal(f.controller.state.scene.text, 'Два') + assert.equal(f.controller.state.scene.stepIndex, 1) +}) + +test('план: быстрые переходы сериализованы, неотправленные промежуточные шаги пропускаются', async (t) => { + const f = await fixture(t) + await f.prepare() + const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps + await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps }) + let finish!: (response: Response) => void + f.model.stepResponse = () => + new Promise((resolve) => { + finish = resolve + }) + const first = f.online.planCommand({ action: 'step', index: 0 }) + await new Promise((done) => setImmediate(done)) + const second = f.online.planCommand({ action: 'step', index: 1 }) + const third = f.online.planCommand({ action: 'step', index: 2 }) + assert.equal(f.controller.state.scene.text, 'Шаг 2') + assert.equal(f.model.planTransitions, 0) + f.model.stepResponse = null + finish(Response.json({ plan: { steps, currentStep: 0 } })) + await Promise.all([first, second, third]) + assert.equal(f.model.planTransitions, 1) + assert.equal(f.model.plan.currentStep, 2) + assert.equal(f.controller.state.scene.stepIndex, 2) +}) + +test('план: конкурентный серверный таймер не теряется при подтверждении собственного перехода', async (t) => { + const f = await fixture(t) + await f.prepare() + const steps = parsePlan({ + steps: [ + { mode: 7, timerLabel: 'Первый', timerDurationSeconds: 60 }, + { mode: 7, timerLabel: 'Второй', timerDurationSeconds: 120 }, + ], + }).steps + await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps }) + f.controller.output(true) + f.model.stateResponse = async () => { + f.model.plan.currentStep = 1 + return Response.json({ + state: { + gameID: 7, + mode: 7, + command: 0, + stateVersion: '99', + timerLabel: 'Второй', + timerDurationSeconds: 120, + timerRemainingSeconds: 120, + }, + }) + } + await f.online.planCommand({ action: 'step', index: 0 }) + assert.equal(f.controller.state.timer.label, 'Второй') + assert.equal(f.controller.state.timer.durationMs, 120000) + assert.equal(f.controller.state.scene.stepIndex, 1) +}) + +test('план: сетевой сбой отбрасывает все ожидающие переходы, включая устаревшие промежуточные', async (t) => { + const f = await fixture(t) + await f.prepare() + const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps + await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps }) + let fail!: (error: Error) => void + f.model.stepResponse = () => + new Promise((_resolve, reject) => { + fail = reject + }) + const first = f.online.planCommand({ action: 'step', index: 0 }) + await new Promise((done) => setImmediate(done)) + const second = f.online.planCommand({ action: 'step', index: 1 }) + const third = f.online.planCommand({ action: 'step', index: 2 }) + f.model.stepResponse = null + fail(new Error('network')) + await Promise.all([first, second, third]) + assert.equal(f.model.planTransitions, 0) + assert.equal(f.controller.state.scene.text, 'Шаг 2') + await f.online.planCommand({ action: 'step', index: 0 }) + assert.equal(f.model.planTransitions, 1) +}) + +test('план: первое подтверждение собственной команды учитывает назначение звука', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + f.model.remote.soundEnabled = false + await f.online.planCommand({ action: 'step', index: 0 }) + assert.equal(f.controller.state.muted, true) + assert.equal(f.online.state.soundEnabled, false) + await f.online.poll() + assert.equal(f.controller.state.muted, true) +}) diff --git a/electron/online.ts b/electron/online.ts index 218c8a9..9656a73 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -1,11 +1,18 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { OnlineIntent, OnlineState } from '../src/types/online.js' -import type { Report, ResultsSnapshot } from '../src/types/screen.js' +import type { + Intent, + PlanStatus, + Report, + ResultsSnapshot, + ScreenPlan, +} from '../src/types/screen.js' import type { Controller } from './controller.js' import { PackageStore, packageKey } from './package-store.js' import { prepareScreenPackage } from './screen-package.js' import { parseResults } from './results.js' +import { parsePlan } from './screen-plan.js' import { applyRemote, integer, @@ -32,6 +39,7 @@ interface Credentials { refresh: string screen: ScreenSession | null } +class NetworkError extends Error {} class ApiError extends Error { constructor(public status: number) { super( @@ -94,6 +102,286 @@ export class Online { private lastResults = -Infinity private resultsBusy = false private advanceBusy = false + private plan: ScreenPlan = { steps: [], currentStep: 0 } + private planBusy = 0 + private planCommands: Promise = Promise.resolve(true) + private planPolling: Promise | null = null + private lastPlan = -Infinity + private planVersion = 0 + private planWrites: Promise = Promise.resolve() + private cachedPlan = '' + private planNotice = '' + planStatus(): PlanStatus { + const pkg = this.selectedGame() + return { + key: this.controller.state.catalog.key, + editable: !!pkg && !!this.credentials && pkg.package.server === this.credentials.server, + notice: this.planNotice, + } + } + private selectedGame() { + return this.packages.list().find((p) => p.key === this.controller.state.catalog.key) + } + private planGame() { + const pkg = this.selectedGame() + if (!pkg || !this.credentials || pkg.package.server !== this.credentials.server) + throw new Error('Войдите на сервер выбранной подготовленной игры') + return pkg + } + private async acceptPlan(plan: ScreenPlan, key: string, generation: number, version: number) { + if ( + generation !== this.generation || + key !== this.controller.state.catalog.key || + version !== this.planVersion + ) + return + const serialized = JSON.stringify(plan) + const hasChanged = this.cachedPlan !== serialized + this.plan = plan + this.controller.setPlan(plan.steps) + this.planNotice = '' + if (!hasChanged) return + try { + const write = this.planWrites.then(async () => { + if (generation === this.generation && version === this.planVersion) + await this.atomic(`plan-${key}.json`, serialized) + if (generation === this.generation && version === this.planVersion) + this.cachedPlan = serialized + }) + this.planWrites = write.catch(() => {}) + await write + } catch { + if (generation === this.generation && version === this.planVersion) + this.planNotice = 'План получен с сервера, но локальную копию сохранить не удалось' + } + } + async refreshPlan() { + if (this.planPolling) return this.planPolling + const task = this.loadPlan() + this.planPolling = task + try { + await task + } finally { + this.planPolling = null + } + } + private async loadPlan() { + if (this.planBusy || !this.planStatus().editable) return + const pkg = this.planGame() + const generation = this.generation + 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), + ) + await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) + } catch (error) { + if (generation === this.generation && version === this.planVersion) + this.planNotice = `${error instanceof Error ? error.message : 'План недоступен'}. Используется локальная копия` + } + } + private async savePlan(data: Record) { + if (this.planBusy) throw new Error('Подождите завершения серверной команды') + if (!Array.isArray(data.steps)) throw new Error('Некорректные шаги плана') + const pkg = this.planGame() + if (data.key !== pkg.key) throw new Error('Выбранная игра изменилась') + const plan = parsePlan({ steps: data.steps, currentStep: 0 }) + plan.currentStep = Math.min(this.plan.currentStep, Math.max(0, plan.steps.length - 1)) + const generation = this.generation + 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), + }), + ) + if (generation !== this.generation) throw new Error('Сессия изменилась') + await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) + } finally { + this.planBusy-- + } + } + async planCommand(intent: Intent) { + if (this.busy) throw new Error('Подождите завершения текущего действия') + const pkg = this.selectedGame() + // Локальная команда не ждёт сеть и никогда не откатывается при обрыве связи. + send(this.controller, intent) + const sequence = this.controller.state.sequence + const version = ++this.planVersion + if (!pkg || !this.planStatus().editable) { + this.planNotice = 'Переход выполнен только локально; войдите на сервер игры' + return + } + const generation = this.generation + const session = this.controller.state.session + const hasPendingCommand = this.planBusy > 0 + const previousCommand = this.planCommands + let complete!: (available: boolean) => void + this.planCommands = new Promise((resolve) => { + complete = resolve + }) + this.planBusy++ + let isAvailable = true + let expectedCommand = 0 + try { + // В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются. + const wasAvailable = await previousCommand + isAvailable = !hasPendingCommand || wasAvailable + if ( + generation !== this.generation || + version !== this.planVersion || + session !== this.controller.state.session + ) + return + if (hasPendingCommand && !wasAvailable) { + isAvailable = false + this.previous = null + this.planNotice = 'Команда выполнена локально; сервер недоступен' + 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 confirmed = parsePlan(result.plan) + const shown = this.controller.state.catalog.steps[intent.index] + await this.acceptPlan(confirmed, pkg.key, generation, version) + if ( + generation !== this.generation || + version !== this.planVersion || + session !== this.controller.state.session + ) + return + if ( + this.controller.state.sequence === sequence && + JSON.stringify(shown) !== JSON.stringify(confirmed.steps[intent.index]) + ) { + if (confirmed.steps[intent.index]) send(this.controller, intent) + } + } 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), + ) + await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) + if ( + generation !== this.generation || + version !== this.planVersion || + session !== this.controller.state.session + ) + return + } + const commands = { + play: 1, + pause: 2, + resume: 3, + stop: 4, + replay: 5, + seek: 6, + volume: 7, + 'timer-start': 8, + 'timer-pause': 9, + 'timer-resume': 10, + 'timer-reset': 11, + } + 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), + ) + } + if ( + generation !== this.generation || + session !== this.controller.state.session || + version !== this.planVersion + ) + return + 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), + ) + 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('Привязка экрана изменилась') + if (version !== this.planVersion) return + const scene = this.controller.state.scene + if ( + this.controller.state.sequence === sequence && + (remote.command !== expectedCommand || + remote.mode !== scene.mode || + (remote.mode === 8 && remote.text !== scene.text) || + (remote.mode === 7 && + (remote.timer.label !== this.controller.state.timer.label || + remote.timer.durationMs !== this.controller.state.timer.durationMs)) || + (intent.action === 'seek' && remote.position !== intent.position) || + (intent.action === 'volume' && remote.volume !== intent.volume)) + ) { + applyRemote(this.controller, { ...remote, version: remote.version - 1n }, remote) + if (remote.command === 0) { + const result = await this.api( + `/api/games/${pkg.package.game.id}/screen-plan`, + undefined, + true, + AbortSignal.timeout(5000), + ) + await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) + if ( + generation !== this.generation || + version !== this.planVersion || + session !== this.controller.state.session + ) + return + this.controller.markStep(this.plan.currentStep) + } + } + if (!this.previous || this.previous.sound !== remote.sound) + send(this.controller, { action: 'mute', enabled: !remote.sound }) + this.lastSound = remote.sound + this.state.soundEnabled = remote.sound + this.previous = { ...remote, music: this.previous?.music ?? null } + } + } catch (error) { + isAvailable = !(error instanceof NetworkError) + if ( + generation !== this.generation || + session !== this.controller.state.session || + version !== this.planVersion + ) + return + this.previous = null + this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление' + if (!(error instanceof NetworkError)) throw error + } finally { + this.planBusy-- + complete(isAvailable) + } + } constructor( private directory: string, private packages: PackageStore, @@ -209,6 +497,22 @@ export class Online { } this.detach() this.controller.selectPackage(pkg) + this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 } + this.planNotice = '' + this.lastPlan = -Infinity + this.cachedPlan = '' + if (key) { + try { + this.plan = parsePlan( + JSON.parse(await readFile(join(this.directory, `plan-${key}.json`), 'utf8')), + ) + this.controller.setPlan(this.plan.steps) + this.cachedPlan = JSON.stringify(this.plan) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + this.planNotice = 'Локальная копия плана повреждена; используется план пакета' + } + } this.lastResults = -Infinity if (key) await this.restoreResults(key) if (persist) { @@ -218,6 +522,7 @@ export class Online { } private detach() { this.generation++ + this.planVersion++ this.screen = null this.previous = null this.lastSound = null @@ -237,7 +542,7 @@ export class Online { ]), }) } catch { - throw new Error('Сервер недоступен или превышено время ожидания') + throw new NetworkError('Сервер недоступен или превышено время ожидания') } } private async json(response: Response): Promise> { @@ -343,6 +648,10 @@ export class Online { private async perform(data: Record) { const action = data.action as OnlineIntent['action'] switch (action) { + case 'plan-save': + return this.savePlan(data) + case 'plan-refresh': + return this.refreshPlan() case 'login': { const server = serverURL(data.server) const email = text(data.email).trim() @@ -442,6 +751,13 @@ export class Online { this.state.packagesRevision++ if (key === this.controller.state.catalog.key) { this.controller.selectPackage(pkg) + this.plan = { + steps: structuredClone(this.controller.state.catalog.steps), + currentStep: 0, + } + this.lastPlan = -Infinity + this.cachedPlan = JSON.stringify(this.plan) + await this.atomic(`plan-${key}.json`, this.cachedPlan) this.previous = null await this.restoreResults(key) } @@ -460,6 +776,7 @@ export class Online { throw new Error('Сначала выберите другой пакет или тестовый режим') await this.packages.remove(key) await rm(join(this.directory, `results-${key}.json`), { force: true }) + await rm(join(this.directory, `plan-${key}.json`), { force: true }) this.state.packagesRevision++ return } @@ -579,6 +896,9 @@ export class Online { } } async poll() { + if (!this.busy && !this.planBusy && Date.now() - this.lastPlan >= 5000) await this.refreshPlan() + if (this.planBusy) return + const planVersion = this.planVersion const s = this.screen const session = this.controller.state.session if (!s || !this.controller.state.outputOpen) { @@ -603,7 +923,8 @@ export class Online { if ( generation !== this.generation || session !== this.controller.state.session || - !this.controller.state.outputOpen + !this.controller.state.outputOpen || + planVersion !== this.planVersion ) return let next = parseRemote(result.state) @@ -615,6 +936,15 @@ export class Online { return } const previous = this.previous + if (previous && next.version > previous.version && next.command === 0) { + await this.refreshPlan() + if ( + generation !== this.generation || + session !== this.controller.state.session || + planVersion !== this.planVersion + ) + return + } if (previous && next.version < previous.version) next = { ...previous, sound: next.sound, music: next.music } if (previous?.music && next.music && next.music.version < previous.music.version) @@ -630,6 +960,8 @@ export class Online { if (previous) { try { applyRemote(this.controller, previous, next) + if (next.version > previous.version && next.command === 0) + this.controller.markStep(this.plan.currentStep) } catch (error) { this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена' } @@ -640,7 +972,12 @@ export class Online { error instanceof Error ? error.message : 'Не удалось обновить результаты' }) } catch (error) { - if (generation !== this.generation || session !== this.controller.state.session) return + if ( + generation !== this.generation || + session !== this.controller.state.session || + planVersion !== this.planVersion + ) + return this.previous = null if (error instanceof ApiError && error.status === 401) { this.detach() diff --git a/electron/preload.ts b/electron/preload.ts index 406e563..58948ff 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron' import type { Command, Report, SceneReport, State } from '../src/types/screen.js' contextBridge.exposeInMainWorld('screenBridge', { + planStatus: () => ipcRenderer.invoke('plan-status'), onlineState: () => ipcRenderer.invoke('online-state'), onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent), closeOutput: () => ipcRenderer.invoke('close-output'), diff --git a/electron/screen-plan.test.ts b/electron/screen-plan.test.ts new file mode 100644 index 0000000..7235c38 --- /dev/null +++ b/electron/screen-plan.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { parsePlan } from './screen-plan.js' + +test('план: режимы protobuf, значения по умолчанию и UTF-8 ограничения сервера', () => { + assert.deepEqual(parsePlan(null), { steps: [], currentStep: 0 }) + assert.equal( + parsePlan({ steps: [{ mode: 'SCREEN_MODE_TIMER', timerDurationSeconds: '60' }] }).steps[0] + ?.timerLabel, + 'Перерыв', + ) + for (const value of [ + { steps: {} }, + { steps: [{ mode: 0 }] }, + { steps: [{ mode: 9 }] }, + { steps: [{ mode: 8, text: ' ' }] }, + { steps: [{ mode: 8, text: 'Я'.repeat(5001) }] }, + { steps: [{ mode: 7, timerDurationSeconds: 0 }] }, + { steps: [{ mode: 7, timerDurationSeconds: 86401 }] }, + { steps: [{ mode: 7, timerDurationSeconds: 1, timerLabel: 'Я'.repeat(101) }] }, + { steps: [{ mode: 1 }], currentStep: 1 }, + ]) + assert.throws(() => parsePlan(value)) +}) diff --git a/electron/screen-plan.ts b/electron/screen-plan.ts new file mode 100644 index 0000000..d29efd4 --- /dev/null +++ b/electron/screen-plan.ts @@ -0,0 +1,44 @@ +import type { ScreenMode, ScreenPlan } from '../src/types/screen.js' +import { integer, object, text } from './remote-state.js' + +const modes = [ + 'UNSPECIFIED', + 'COVER', + 'INTRO', + 'INTRO_DONE', + 'CONCLUSION', + 'CONCLUSION_DONE', + 'RESULTS', + 'TIMER', + 'TEXT', +] + +export function parsePlan(value: unknown): ScreenPlan { + const plan = value == null ? {} : object(value) + const list = plan.steps ?? [] + if (!Array.isArray(list)) throw new Error('Некорректные шаги плана') + const steps = list.map((value) => { + const step = object(value) + const mode = integer( + typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_') + ? modes.indexOf(step.mode.slice(12)) + : step.mode, + 1, + 8, + ) as ScreenMode + const content = text(step.text ?? '').trim() + const label = text(step.timerLabel ?? '').trim() + const seconds = integer(step.timerDurationSeconds ?? 0, 0, 86400) + if (mode === 8 && (!content || Buffer.byteLength(content) > 10000)) + throw new Error('Текст шага должен содержать от 1 до 10000 байт') + if (mode === 7 && (!seconds || Buffer.byteLength(label) > 200)) + throw new Error('Укажите таймер от 1 до 86400 секунд и подпись до 200 байт') + return { + mode, + text: content, + timerLabel: mode === 7 ? label || 'Перерыв' : label, + timerDurationSeconds: seconds, + } + }) + return { steps, currentStep: integer(plan.currentStep ?? 0, 0, Math.max(0, steps.length - 1)) } +} diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 09e128d..17593f9 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -16,6 +16,13 @@ module.exports = async function onlineSmoke({ let offline = false let polls = 0 let currentCommand = 'SCREEN_COMMAND_PLAY' + let plan = { steps: [{ mode: 2 }], currentStep: 0 } + let planSaveFailure = false + let remoteMode = 2 + let remoteText = '' + let timerLabel = 'Перерыв' + let timerDurationSeconds = 600 + let timerState = 1 const requests = [] const server = createServer(async (req, res) => { const chunks = [] @@ -42,7 +49,36 @@ module.exports = async function onlineSmoke({ scenario: { story: { introduction: { audio: 'intro.wav' } } }, }, }) - if (path === '/api/games/6/screen-plan') return send({ plan: { steps: [{ mode: 2 }] } }) + if (path === '/api/games/6/screen-plan') { + if (req.method === 'PUT') { + assert.equal(req.headers.authorization, 'Bearer TEST-ACCESS') + if (planSaveFailure) return send({}, 403) + plan = body + } + return send({ plan: plan.steps.length ? plan : null }) + } + if (path === '/api/games/6/screen-plan/step') { + assert.equal(req.headers.authorization, 'Bearer TEST-ACCESS') + const step = plan.steps[body.currentStep] + if (!step) return send({}, 400) + plan.currentStep = body.currentStep + remoteMode = step.mode + remoteText = step.text || '' + timerLabel = step.timerLabel || 'Перерыв' + timerDurationSeconds = step.timerDurationSeconds || 600 + timerState = 1 + currentCommand = 0 + version++ + return send({ plan }) + } + if (path === '/api/games/6/screens/command') { + currentCommand = body.command + if (body.command === 8 || body.command === 10) timerState = 2 + if (body.command === 9) timerState = 3 + if (body.command === 11) timerState = 1 + version++ + return send({}) + } if (path === '/api/games/6/music') return send({}) if (path === '/api/files/intro.wav') { assert.equal(req.headers.authorization, undefined) @@ -59,7 +95,12 @@ module.exports = async function onlineSmoke({ return send({ state: { gameID: 6, - mode: 'SCREEN_MODE_INTRO', + mode: remoteMode, + text: remoteText, + timerLabel, + timerDurationSeconds, + timerRemainingSeconds: timerDurationSeconds, + timerState, soundEnabled: true, volume: 80, stateVersion: String(version), @@ -209,12 +250,117 @@ module.exports = async function onlineSmoke({ await act({ action: 'results' }) assert.equal((await state()).results.teams[0].name, 'Детективы') assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false) - for (const method of ['onlineState()', "onlineAction({action:'logout'})", 'closeOutput()']) { + for (const method of [ + 'planStatus()', + 'onlineState()', + "onlineAction({action:'logout'})", + 'closeOutput()', + ]) { assert.equal( await inspect(`window.screenBridge.${method}.then(() => false, () => true)`), true, ) } + // Настоящий редактор: черновик, ошибка сохранения, порядок и переходы. + const clickPlan = async (label) => { + await poll( + () => + evaluate(`(() => { + const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button')) + .find(b => b.textContent.trim() === ${JSON.stringify(label)}) + if (!button || button.disabled) return false + button.click(); return true + })()`), + `plan button ${label}`, + ) + } + const setInput = (selector, value) => + evaluate(`(() => { + const input = document.querySelector(${JSON.stringify(selector)}) + input.focus() + input.value = ${JSON.stringify(value)} + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + input.blur() + })()`) + await clickPlan('Редактировать') + await clickPlan('Добавить шаг') + await setInput('[data-testid="screen-plan"] textarea', 'Текст из редактора') + await clickPlan('Добавить шаг') + await evaluate( + `Array.from(document.querySelectorAll('[data-testid="screen-plan"] .n-base-selection')).at(-1).click()`, + ) + await poll( + () => + evaluate( + `Array.from(document.querySelectorAll('.n-base-select-option')).some(e => e.textContent.trim() === 'Таймер')`, + ), + 'timer option', + ) + await evaluate( + `Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.trim() === 'Таймер').click()`, + ) + await setInput('#plan-label-2', 'Отдых') + await setInput('#plan-seconds-2', '60') + assert.equal((await state()).catalog.steps.length, 1, 'draft is not published') + const beforeSaveScene = (await state()).scene.revision + planSaveFailure = true + await clickPlan('Сохранить план') + await poll( + () => evaluate(`document.body.textContent.includes('Нет доступа к действию')`), + 'save failure shown', + ) + assert.equal((await state()).catalog.steps.length, 1) + assert.ok(await evaluate(`!!document.querySelector('[data-testid="screen-plan"] textarea')`)) + planSaveFailure = false + await clickPlan('Сохранить план') + await poll(async () => (await state()).catalog.steps.length === 3, 'plan saved') + assert.equal((await state()).scene.revision, beforeSaveScene) + assert.equal(plan.steps[1].text, 'Текст из редактора') + assert.equal(plan.steps[2].timerDurationSeconds, 60) + await evaluate(`document.querySelector('[data-step-index="1"] button').click()`) + await poll( + async () => + (await state()).scene.text === 'Текст из редактора' && + (await state()).scene.stepIndex === 1, + 'show text step', + ) + await evaluate(`document.querySelector('[data-step-index="2"] button').click()`) + await poll(async () => (await state()).scene.stepIndex === 2, 'show timer step') + assert.equal((await state()).timer.status, 'stopped') + assert.equal((await state()).channels.main.desired, 'stopped') + await clickPlan('Старт') + await poll(async () => (await state()).timer.status === 'running', 'plan timer start') + await clickPlan('Пауза') + await poll(async () => (await state()).timer.status === 'paused', 'plan timer pause') + await clickPlan('Редактировать') + await evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`) + await clickPlan('Сохранить план') + await poll(async () => (await state()).catalog.steps[1].mode === 7, 'reorder plan saved') + assert.equal((await state()).scene.stepIndex, null, 'edited index is not highlighted') + await clickPlan('Редактировать') + await setInput('[data-testid="screen-plan"] textarea', 'Несохранённый черновик') + await clickPlan('Отменить') + await poll( + () => + evaluate( + `Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === 'Отменить изменения')`, + ), + 'discard dialog', + ) + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Отменить изменения').click()`, + ) + await poll( + () => evaluate(`!document.querySelector('[data-testid="screen-plan"] textarea')`), + 'discarded draft', + ) + assert.equal((await state()).catalog.steps[2].text, 'Текст из редактора') + await clickPlan('Редактировать') + await clickPlan('Удалить план') + await clickPlan('Сохранить план') + await poll(async () => (await state()).catalog.steps.length === 0, 'plan deleted') + console.log('SCREEN_PLAN_SMOKE_PASS') await act({ action: 'logout' }) assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false) console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length })) diff --git a/src/App.vue b/src/App.vue index 42b482c..bf38afa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ + + + + diff --git a/src/types/online.ts b/src/types/online.ts index 721040f..befdd79 100644 --- a/src/types/online.ts +++ b/src/types/online.ts @@ -1,4 +1,8 @@ +import type { ScreenStep } from './screen.js' + export type OnlineIntent = + | { action: 'plan-save'; key: string; steps: ScreenStep[] } + | { action: 'plan-refresh' } | { action: 'login'; server: string; email: string; password: string } | { action: 'logout' | 'games' | 'connect' | 'disconnect' | 'sound' | 'results' | 'cancel' } | { action: 'download' | 'check'; gameId: number } diff --git a/src/types/screen.ts b/src/types/screen.ts index f474086..4056b6a 100644 --- a/src/types/screen.ts +++ b/src/types/screen.ts @@ -135,7 +135,17 @@ export interface PackageSummary { version: string name: string } +export interface ScreenPlan { + steps: ScreenStep[] + currentStep: number +} +export interface PlanStatus { + key: string | null + editable: boolean + notice: string +} export interface ScreenBridge { + planStatus: () => Promise onlineState: () => Promise onlineAction: (intent: OnlineIntent) => Promise closeOutput: () => Promise