From 2b7b95fe2b9d53aaeab0bd64267aa724995988d1 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 14:17:03 +0700 Subject: [PATCH] up --- README.md | 21 +++++++++- docs/agent/TASKS.md | 9 +++-- prod | 2 +- tests/venue/README.md | 18 +++++++++ tests/venue/flow.py | 2 + tests/venue/flow_browser.py | 78 +++++++++++++++++++++++++++++++++++-- 6 files changed, 121 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 83de0f0..f45e47e 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,26 @@ Venue автоматически учитывает браузеры игрок `POST /api/devices/heartbeat` публично регистрирует подключение, а `GET /api/devices?gameID=` возвращает сгруппированные устройства организатору, администратору или разработчику. Фильтр необязателен; при его использовании -список содержит только подключения выбранной игры. Панель состояния — этап 13. +список содержит только подключения выбранной игры, без непривязанных браузеров. + +Вкладка «Состояние» (`/games/:id?tab=state`, этап 13) доступна в venue +организатору, администратору и разработчику. Она показывает число доступных +устройств, устройств без связи и подключений, UUID профиля и последнее +обращение; раскрытие устройства показывает тип подключения, ID команды/экрана, +статус, IP и User-Agent. Устройства без связи идут первыми, далее сортировка +по UUID. Статусы и время обновления берутся из серверного снимка. +Снимок запрашивается сразу и раз в 5 секунд только на активной вкладке, +без параллельных запросов; поздние ответы после ухода игнорируются. +При ошибке предыдущий снимок и время остаются видимыми, доступен повтор. +«Нет связи» означает отсутствие heartbeat сайта, а не диагностику Wi-Fi. + +Разработчик без роли организатора/администратора/автора в venue получает +список игр и только вкладку состояния: сведения игры ограничены ID, названием, +описанием, форматом, местом, плановым началом и статусом. Команды, пароли, +сценарий, материалы и игровой прогресс не выдаются и не загружаются интерфейсом. +Создание игр и результаты скрыты; существующий импорт сохраняется. +Heartbeat разработчика остаётся браузерным без контекста организатора. +В prod этот доступ и вкладка состояния не открываются. UUID браузерного профиля хранится отдельно от авторизации в localStorage; каждая вкладка и перезагрузка страницы создают новый UUID подключения. diff --git a/docs/agent/TASKS.md b/docs/agent/TASKS.md index e836549..1c8e494 100644 --- a/docs/agent/TASKS.md +++ b/docs/agent/TASKS.md @@ -27,13 +27,13 @@ ### ED-029 — Автономная площадка и панель организатора -- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–12 завершены; аппаратная приёмка этапа 8 подтверждена пользователем. +- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–13 завершены; аппаратная приёмка этапа 8 подтверждена пользователем. - Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`. - Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки. - Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия. - Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без несогласованных новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy и автоматического переключения серверов; настройки роутера менять только по явному запросу пользователя; секреты и пакеты вне Git; commit/push выполняет пользователь. - Проверка: Go-стандарт и `go test ./...`; PostgreSQL-интеграционные тесты для атомарности; frontend-стандарт, type-check, lint, lint:css и build; штатная генерация protobuf; ручные контрольные точки после этапов 3, 8, 11, 15. -- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; этап 8 проверен на изолированном стенде и принят пользователем на аппаратной контрольной точке; этапы 9–11 реализованы и проверены; этапы 12–15 ещё не завершены. +- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; этап 8 проверен на изолированном стенде и принят пользователем на аппаратной контрольной точке; этапы 9–13 реализованы и проверены; этапы 14–15 ещё не завершены. - Дополнение по запросу пользователя: структура `venue` приведена к `prod` (`backend/`, `frontend/dist/`, `migrations/`, `Dockerfile.backend`, `docker-compose.yml`, `deploy.sh`); имена основных Compose-сервисов совпадают. Общая генерация `make build` / `make prod-build` готовит артефакты обеих папок; frontend собирается отдельно с настройками своего окружения. Текущие файлы `prod` при этой работе не изменяются; сборка проверяется с production-выводом в `/tmp`. - Упрощение запуска: отдельные `init.sh` и `prepare.sh` удалены, всё собрано в `deploy.sh`. Единственная команда запуска — `make venue`: создаёт `.env` только при его отсутствии, при необходимости готовит зависимости/образы и автоматически создаёт организатора. Повторный готовый запуск не собирает и не скачивает образы. `make venue-update` явно пересобирает окружение с интернетом. Аккаунт организатора синхронизируется со значениями `.env` при каждом запуске. Первый запуск, повторный запуск без интернета и обновление проверяются `python3 tests/venue/deploy.py` с подставными командами; реальный готовый запуск проверен через Docker и API-smoke. - Настройки площадки: по запросу пользователя необязательный `venue/.env.example` удалён; настройки создаются автоматически через `make venue`. Убраны ссылка и проверка примера; `python3 tests/venue/deploy.py` прошёл. Существующая `.env` не изменялась. @@ -81,7 +81,10 @@ - Результат этапа 12 (2026-10-11): добавлены потокобезопасный сервис присутствия в памяти, wiring, protobuf/OpenAPI `POST /api/devices/heartbeat` и `GET /api/devices` с фильтром игры. Проверяются UUID, взаимоисключающие контексты, JWT/существующие права игры, пароль команды и неотменённое участие без загрузки сценария/прогресса, существующая сессия экрана; ID игры команды/экрана определяет сервер. Реестр хранит только проверенные связи, UUID, серверные времена и последние IP/User-Agent; credentials не сохраняются и не выдаются. Полная замена/очистка контекста, граница связи строго менее 60 секунд, удаление через 15 минут при обновлении/чтении, предел 1024 с `ResourceExhausted` без вытеснения существующих записей. В prod heartbeat явно возвращает `enabled=false`, список недоступен. - Браузер этапа 12: общий heartbeat в App сразу/каждые 20 секунд без параллельных запросов, при видимости/восстановлении сети и смене контекста. UUID профиля отдельно от авторизации в localStorage с временным fallback, UUID подключения на экземпляр приложения. Контекст страницы очищается при уходе, отозванный lifecycle не может восстановить старые связи; смена игры/пароля пересоздаёт page-компонент. Generated client и обновление JWT организатора переиспользованы; публичные credentials команды/экрана не запускают обновление JWT. После `enabled=false` polling прекращается. Ошибки не показывают toast и повторяются по интервалу. UI списка устройств не добавлен. - Проверки этапа 12: штатная генерация обоих репозиториев, gofmt, `go test ./...`, race для presence service/app, frontend type-check/lint/lint:css/build, self-check UUID/store/composable и diff-проверки прошли. Unit-тесты с управляемыми часами покрывают повтор/вкладки/смену и очистку связей, границы 60 секунд/15 минут, лимит и конкурентное чтение/обновление; API-тесты — роли, режимы, safe errors и отсутствие секретов. `python3 tests/venue/flow.py --browser` прошёл изолированные реальные API/PostgreSQL/RustFS/Firefox: credentials/отменённое участие, UUID/поля, группировку вкладок игрока/организатора/экрана, фильтр и привязку экрана, SPA-навигацию, прекращение heartbeat и восстановление сети, повторную регистрацию штатным интервалом после перезапуска backend. Независимое ревью выявило два замечания (публичный heartbeat не должен обновлять JWT, обрезка User-Agent должна сохранять UTF-8); оба исправлены и покрыты регрессиями. Итоговое ручное ревью по Go/Frontend стандартам выполнено; замечаний не осталось. -- Следующий шаг: этап 13 — панель состояния площадки. Этап 12 завершён; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись. +- Результат этапа 13 (2026-10-11): venue-вкладка «Состояние» `/games/:id?tab=state` показывает серверные connected/serverTime, количество доступных устройств/устройств без связи/подключений, UUID профиля, последние обращения и раскрываемые подключения с типом, ID команды/экрана, статусом, IP и User-Agent. Нет связи идёт первым, затем UUID; непривязанные браузеры исключает фильтр игры. Generated GetDevices вызывается сразу и каждые 5 секунд через useSimplePolling только на активной вкладке, без параллельных запросов; поздние ответы после смены игры, ухода и уничтожения игнорируются. Первичная загрузка, пустой список, встроенная ошибка, сохранение предыдущего снимка/времени и повтор реализованы без повторных toast. Вкладка доступна с клавиатуры. Игровые показатели, Cudy и история присутствия не добавлялись. +- Доступ этапа 13: GetGames/GetGame и UI permissions расширены для разработчика только в venue. Разработчик без организатора/администратора/автора получает явный whitelist ID/названия/описания/формата/места/планового начала/статуса; GetGame использует краткое чтение без сценария/команд. UI скрывает создание и результаты, показывает название и только состояние, нормализует другие tab, не загружает экраны/музыку/план/карту/ответы; heartbeat остаётся браузерным. Дополнительные роли, импорт и запреты игровых действий сохранены; prod новые права не получает. При смене роли полный поздний GetGame не применяется, приватные баннер/модалки скрываются. +- Проверки этапа 13: gofmt, полный go test ./..., race-тесты device_presence_service/app с управляемыми часами этапа 12; frontend type-check/lint/lint:css/build и self-check gameState прошли. Self-check покрывает сводку/сортировку, активность, отсутствие параллельных запросов, поздние ответы, ошибку/восстановление. Изолированные PostgreSQL/RustFS API-регрессии и Firefox проверили организатора, три подключения одного профиля, клавиатуру, смену контекста, пустой список, потерю/восстановление GetDevices, остановку запросов на другой вкладке, разработчика venue/prod, безопасные GetGame/GetGames и отсутствие закрытых фоновых запросов. API/protobuf/миграции/зависимости не менялись. Ручное ревью diff по Go/frontend-стандартам и независимое read-only ревью выполнены; пользовательские изменения prod/reproxy/backend-бинарника сохранены. Работающие окружения и Cudy не менялись. +- Следующий шаг: этап 14 — сбор данных Cudy. Этап 13 завершён; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись. ### ED-024 — Общая дизайн-система фронтенда diff --git a/prod b/prod index 46a9864..218ed05 160000 --- a/prod +++ b/prod @@ -1 +1 @@ -Subproject commit 46a9864c8c5197583750e26ff7ac3337764ecd0a +Subproject commit 218ed05fd520843f8327caace243d109c18f85dd diff --git a/tests/venue/README.md b/tests/venue/README.md index 4cbc359..2d73f1d 100644 --- a/tests/venue/README.md +++ b/tests/venue/README.md @@ -137,3 +137,21 @@ Firefox проверяет игрока, организатора и экран отозванные lifecycle-контексты, отсутствие параллельных heartbeat, остановка после `enabled=false`, повтор ошибок по интервалу и независимость публичных credentials от JWT. Нагрузочный и аппаратный прогоны не включены. + + +### Панель состояния (этап 13) + +`python3 tests/venue/flow.py --browser` дополнительно проверяет Firefox: +сводку и серверное время, встроенную ошибку с сохранением снимка, повтор, +пустой список, остановку запросов после переключения вкладки; отдельного +разработчика в venue/prod, безопасный состав GetGame/GetGames, скрытые создание +и результаты, нормализацию `tab=answers` в `state`, единственную вкладку и +обычный браузерный heartbeat. Проверки нескольких вкладок и смены контекста +этапа 12 остаются частью прогона. Потеря API и пустой ответ воспроизводятся +перехватом только GetDevices в браузере; остальные API реальные. + +`node src/composables/__tests__/gameState.test.ts` (из frontend) проверяет +счётчики, сортировку, отсутствие параллельных запросов, активность вкладки, +ошибку/восстановление и отбрасывание ответов после смены игры, ухода и unmount. +Временные границы connected остаются в тестах сервиса этапа 12 с управляемыми +часами; клиент пороги не пересчитывает. diff --git a/tests/venue/flow.py b/tests/venue/flow.py index 4bc7165..81ee76d 100644 --- a/tests/venue/flow.py +++ b/tests/venue/flow.py @@ -117,6 +117,7 @@ class Stand: def handle_request(self): if self.path.startswith('/api/'): + stand.request_paths.append((stand.current, urllib.parse.urlparse(self.path).path)) if self.command == 'POST' and self.path.endswith('/results-package/apply'): time.sleep(stand.delay_results) if self.command == 'POST' and self.path.endswith('/actions'): @@ -159,6 +160,7 @@ class Stand: # Navigation may cancel an in-flight browser polling response. pass + self.request_paths = [] self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) self.public = f'http://127.0.0.1:{self.server.server_port}' self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True) diff --git a/tests/venue/flow_browser.py b/tests/venue/flow_browser.py index 0ec8cfb..b55c0f2 100644 --- a/tests/venue/flow_browser.py +++ b/tests/venue/flow_browser.py @@ -63,7 +63,7 @@ def check_browser_flow(stand, fixtures): sock.sendall(str(len(payload)).encode() + b':' + payload) result = read_message() if result[2]: - raise RuntimeError('Firefox command failed: ' + name) + raise RuntimeError('Firefox command failed: ' + name + ': ' + result[2].get('message', 'unknown error')) return result[3] def js(script, *args): @@ -87,13 +87,13 @@ def check_browser_flow(stand, fixtures): def input_value(selector, value): assert js("const input=document.querySelector(arguments[0]);if(!input)return false;input.value=arguments[1];input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));return true;", selector, value), 'Missing input: ' + selector - def login(mode): + def login(mode, email='organizer@test.invalid'): stand.current = mode navigate('/login') js('localStorage.clear();return true;') navigate('/login') wait("document.querySelector('input[type=password]')!==null", 'login form') - input_value('input[placeholder="detective@mail.ru"]', 'organizer@test.invalid') + input_value('input[placeholder="detective@mail.ru"]', email) input_value('input[type=password]', 'test-only-password') click('Вход') wait("location.pathname==='/office'", 'login') @@ -132,6 +132,11 @@ def check_browser_flow(stand, fixtures): js("window.dispatchEvent(new Event('online'));return true;") wait_for(lambda: any(c['ID'] == screen_connection['ID'] and c.get('gameID') == data['game'] for c in connections()), 'bound screen presence') assert {c['kind'] for c in connections()} >= {'organizer', 'team', 'screen'} + command('WebDriver:SwitchToWindow', {'handle': organizer_window}) + assert js("const tab=[...document.querySelectorAll('.n-tabs-tab[data-name]')].find(t=>t.textContent.trim()==='Состояние');tab.focus();tab.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));return true;") + wait("document.body.innerText.includes('Подключения: 3')", 'three connections in one device panel') + assert js("return [...document.querySelectorAll('.device-details strong')].filter(e=>e.textContent===arguments[0]).length===1", device_id) + command('WebDriver:SwitchToWindow', {'handle': screen_window}) command('WebDriver:CloseWindow') command('WebDriver:SwitchToWindow', {'handle': team_window}) command('WebDriver:CloseWindow') @@ -157,13 +162,75 @@ def check_browser_flow(stand, fixtures): assert new_id != old_id and js("return localStorage.getItem('evening-detective-device-ID')") == device_id print('PASS: stage 12 real Firefox organizer/player/screen, grouped tabs, SPA context clearing, reload UUID, stopped heartbeat/network recovery and interval re-registration after restart', flush=True) + def check_state(data): + from flow import API, ROOT, run + game = data['game'] + navigate(f'/games/{game}?tab=state') + wait("document.body.innerText.includes('Последнее обновление:')", 'state initial snapshot') + assert js("return document.body.innerText.includes('Доступны:') && document.body.innerText.includes('Подключения:')") + # Track only list requests; all other requests use the real server. + js("window.stateFetch=window.fetch;window.stateCalls=0;window.stateMode='real';window.fetch=(url,options)=>{if(String(url).includes('/api/devices?')){window.stateCalls++;if(window.stateMode==='error')return Promise.reject(new Error('offline'));if(window.stateMode==='empty')return Promise.resolve(new Response(JSON.stringify({devices:[],serverTime:new Date().toISOString()}),{headers:{'Content-Type':'application/json'}}));}return window.stateFetch(url,options)};window.stateMode='error';return true;") + wait("document.body.innerText.includes('Не удалось обновить состояние')", 'inline API failure', 10) + assert js("return document.body.innerText.includes('Последнее обновление:')") + js("window.stateMode='real';return true;") + click('Повторить') + wait("!document.body.innerText.includes('Не удалось обновить состояние')", 'state recovery') + js("window.stateMode='empty';return true;") + wait("document.body.innerText.includes('Устройства этой игры пока не зарегистрированы')", 'empty devices', 10) + assert js("const tab=[...document.querySelectorAll('.n-tabs-tab[data-name]')].find(t=>t.textContent.trim()==='Игра');tab.click();return true;") + wait("location.search.includes('tab=game')", 'leave state tab') + calls = js('return window.stateCalls') + time.sleep(6) + assert js('return window.stateCalls') == calls, 'Inactive state still polls' + js('window.fetch=window.stateFetch;return true;') + + # Create a separate developer in each disposable database. + bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text() + for mode in ('venue', 'prod'): + run('docker', 'exec', '-i', stand.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1', '-v', 'dev_username=TEST developer', '-v', 'dev_email=developer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap) + stand.sql(mode, "UPDATE users SET roles='[\"developer\"]'::jsonb WHERE email='developer@test.invalid';") + api = API(stand.apis[mode].base) + api.token = api.request('/api/auth/login', {'email':'developer@test.invalid','password':'test-only-password'})['accessToken'] + if mode == 'prod': + api.request('/api/games', expected=403) + api.request(f'/api/games/{game}', expected=403) + else: + summary = api.request(f'/api/games/{game}')['game'] + public_fields = {'ID','name','description','format','location','startAt','status'} + # Gateway may emit unset protobuf fields as null/empty collections. + assert all(not value for key, value in summary.items() if key not in public_fields) + games = api.request('/api/games')['games'] + assert games and all(not value for g in games for key, value in g.items() if key not in public_fields) + old_connections = {c['ID'] for d in stand.apis['venue'].request('/api/devices')['devices'] for c in d['connections']} + login('venue', 'developer@test.invalid') + navigate('/games') + wait("document.body.innerText.includes('Импорт игры')", 'developer games') + assert js("return !document.body.innerText.includes('Создать игру') && !document.body.innerText.includes('Результаты')") + first_request = len(stand.request_paths) + navigate(f'/games/{game}?tab=answers') + wait("location.search.includes('tab=state') && document.body.innerText.includes('Последнее обновление:')", 'developer state default') + assert js("return [...document.querySelectorAll('.n-tabs-tab[data-name]')].map(t=>t.textContent.trim()).join(',')==='Состояние'") + time.sleep(6) + assert not any(mode == 'venue' and path.startswith(f'/api/games/{game}/') for mode, path in stand.request_paths[first_request:]), 'Developer loads private game data' + # Heartbeat is browser-only for this role. + device_id = js("return localStorage.getItem('evening-detective-device-ID')") + devices = stand.apis['venue'].request('/api/devices')['devices'] + current_connections = [c for d in devices if d['ID'] == device_id for c in d['connections'] if c['ID'] not in old_connections] + assert current_connections and all(c['kind'] == 'browser' for c in current_connections) + login('prod', 'developer@test.invalid') + navigate(f'/games/{game}?tab=state') + time.sleep(1) + assert js("return ![...document.querySelectorAll('.n-tabs-tab[data-name]')].some(t=>t.textContent.trim()==='Состояние')") + login('venue') + print('PASS: stage 13 Firefox state counts, inline failure/recovery/empty, inactive polling, developer restricted games/default tab/browser context and prod denial', flush=True) + command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {'acceptInsecureCerts': True}}}) for width, data in zip((1440, 390), fixtures, strict=True): game, captain = data['game'], data['teams'][0] command('WebDriver:SetWindowRect', {'width': width, 'height': 900}) login('prod') navigate(f'/games/{game}?tab=settings') - wait("document.body.innerText.includes('Подготовить пакет')", 'export button') + wait("[...document.querySelectorAll('button')].some(b=>b.textContent.trim()==='Подготовить пакет' && !b.disabled)", 'export button') click('Подготовить пакет') wait("document.body.innerText.includes('Скачать пакет')", 'export ready', 60) previous_downloads = {p.name for p in downloads.glob('*.zip')} @@ -193,6 +260,7 @@ def check_browser_flow(stand, fixtures): wait(f"location.pathname==='/games/{game}'", 'open imported game') if width == 1440: check_presence(data) + check_state(data) navigate(f'/games/{game}?tab=payment') wait("document.body.innerText.includes('Проверить билет')", 'ticket checking') click('Проверить билет') @@ -246,6 +314,7 @@ def check_browser_flow(stand, fixtures): navigate(f'/games/{game}?tab=game') wait("document.querySelector('[aria-label=\"Завершить игру\"]')!==null", 'finish control') click('Завершить игру') + first_request = len(stand.request_paths) navigate(f'/games/{game}?tab=answers') wait("document.querySelector('.answers-score-input input')!==null", 'grading form') input_value('.answers-score-input input', '5') @@ -293,6 +362,7 @@ def check_browser_flow(stand, fixtures): assert stand.apis['prod'].request('/api' + urllib.parse.urlparse(data['tickets'][1]['URL']).path)['ticket']['status'] == 'used' assert stand.apis['prod'].request(f'/api/games/{game}/results')['teams'][0]['totalScore'] == 5 login('venue') + first_request = len(stand.request_paths) navigate(f'/games/{game}?tab=answers') wait("document.querySelector('.answers-score-input input')!==null", 'corrected grading') input_value('.answers-score-input input', '0')