This commit is contained in:
2026-10-11 15:00:22 +07:00
parent 2b7b95fe2b
commit f25c48b57b
8 changed files with 222 additions and 6 deletions
+51 -1
View File
@@ -62,7 +62,8 @@ python3 tests/venue/flow.py --apply --browser
(`offline`). Проверяются клавиатурный focus, отказ на ход с сохранением ввода,
блокировка повторной отправки и кнопки на паузе при уже введённом коде.
Локальный HTTP-прокси переключает один публичный адрес между двумя реальными
backend, имитируя смену DNS; API-ответы не подменяются. Тестовые ссылки, ZIP,
backend, имитируя смену DNS; Игровые API-ответы не подменяются; ошибки, пустой список и состояния Wi-Fi
панели мониторинга дополнительно проверяются управляемыми ответами GetDevices. Тестовые ссылки, ZIP,
профиль Firefox и сборки остаются во временном каталоге до конца прогона.
`--results` дополнительно проверяет этап 9: серверный сервис собирает ZIP
@@ -155,3 +156,52 @@ Firefox проверяет игрока, организатора и экран
ошибку/восстановление и отбрасывание ответов после смены игры, ухода и unmount.
Временные границы connected остаются в тестах сервиса этапа 12 с управляемыми
часами; клиент пороги не пересчитывает.
## Wi-Fi и нагрузка (этапы 14–15)
```sh
python3 tests/venue/load.py
python3 tests/venue/flow.py --apply --browser
```
`load.py` использует тот же одноразовый стенд без WAN: 100 виртуальных UUID,
25 новых команд по четыре браузера, 60 секунд. Каждый профиль отправляет
heartbeat раз в 20 секунд и читает историю раз в 5 секунд; по одному профилю
команды выполняют два хода и сохраняют ответ. Организатор опрашивает состояние
раз в 5 секунд. Проверяются ровно 100 доступных устройств, 50 сохранённых ходов,
25 ответивших команд и отсутствие HTTP-ошибок. Отчёт содержит число запросов,
p50/p95/max; p95 ≤ 2 секунды — бюджет этого теста, не обещание для любого железа.
Запросы идут прямо к backend по HTTP: DNS, HTTPS и прокси проверяются отдельно
`dns.py --docker` и `https.py --docker`. Это нагрузка backend, не проверка радио.
Go-тесты адаптера Cudy проверяют вход/продление сессии, ограничение URL/ответа,
разбор обеих полос, отсутствующий сигнал, ошибку при неизвестной разметке,
атомарный снимок, свежесть с управляемыми часами и venue/prod/роли. Firefox
проверяет MAC/IP/диапазон/dBm, устаревание, ошибку и отсутствие сигнала; эти
состояния Wi-Fi задаются fixture. Отдельно адаптер проверен чтением настоящего
TR3000 V1 2.5.27-20260804-183625 без изменения настроек роутера.
### Аппаратная контрольная точка этапа 15
После согласованного обновления реальной площадки, только на тестовой игре:
1. Включить сбор Cudy по корневому README; отключить WAN и мобильные данные.
На Android/iPhone открыть прежние домены, проверить DNS и доверенный HTTPS.
2. Подключить 100 настоящих Wi-Fi-устройств (несколько вкладок одного профиля
не заменяют устройства); открыть игру 25 командами, держать нагрузку
минимум 60 секунд, выполнять ходы и сохранять ответы.
3. Сверить клиентов Cudy с панелью, диапазоны и доступный сигнал; пройти края
и проблемные зоны площадки, проверить материалы и действия в каждой зоне.
MAC/UUID не сопоставлять предположениями; «Нет связи» относится к heartbeat.
4. Проверить паузу/продолжение, перезапуск venue и повторную регистрацию
браузеров, смену контекста/несколько вкладок; убедиться, что панели в prod нет.
5. Временно остановить сборщик: через 30 секунд снимок должен устареть,
прежние данные и время остаться видимыми. Запустить снова и проверить
восстановление. Потерю API и восстановление проверить без потери снимка.
6. Завершить игру, вернуть ZIP на источник, проверить результаты и прежние
ссылки. Зафиксировать оборудование, число клиентов, длительность,
задержки/ошибки и покрытие; сообщить результат для приёмки ED-029.
Автоматические проверки не заменяют эту приёмку. Пока она не подтверждена,
ED-029 остаётся `IN_PROGRESS`. Фактический ZIP 1 ГиБ также не проверен.
+5 -1
View File
@@ -76,7 +76,7 @@ def check_browser_flow(stand, fixtures):
if js('return ' + script):
return
time.sleep(.1)
raise AssertionError('Browser timed out: ' + description)
raise AssertionError('Browser timed out: ' + description + ': ' + str(js('return document.body.innerText')).replace('\n', ' ')[:1500])
def navigate(path):
command('WebDriver:Navigate', {'url': stand.public + path})
@@ -177,6 +177,10 @@ def check_browser_flow(stand, fixtures):
wait("!document.body.innerText.includes('Не удалось обновить состояние')", 'state recovery')
js("window.stateMode='empty';return true;")
wait("document.body.innerText.includes('Устройства этой игры пока не зарегистрированы')", 'empty devices', 10)
js("window.fetch=(url,options)=>{if(String(url).includes('/api/devices?')){window.stateCalls++;return Promise.resolve(new Response(JSON.stringify({devices:[],serverTime:new Date().toISOString(),wifi:{enabled:true,hasError:window.wifiFailed,stale:window.wifiFailed,collectedAt:new Date().toISOString(),clients:[{MAC:'AA:BB:CC:DD:EE:01',IP:'192.168.10.9',band:'5 GHz',signal:window.wifiFailed?undefined:-65}]}}),{headers:{'Content-Type':'application/json'}}));}return window.stateFetch(url,options)};return true;")
wait("document.body.innerText.includes('-65 dBm')", 'Wi-Fi client and dBm', 10)
js("window.wifiFailed=true;return true;")
wait("document.body.innerText.includes('Данные устарели') && document.body.innerText.includes('Не удалось обновить данные Cudy') && document.body.innerText.includes('Нет данных')", 'stale Wi-Fi and missing signal', 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')
+9
View File
@@ -181,6 +181,15 @@ with tempfile.TemporaryDirectory(prefix="venue-https-") as directory:
assert {(p["protocol"], str(p["published"])) for p in dns["ports"]} == {("udp", "53"), ("tcp", "53")}
assert all(p["host_ip"] == "127.0.0.1" for p in dns["ports"])
assert any(v["target"] == "/etc/coredns/Corefile" and v["read_only"] for v in dns["volumes"])
collector = https["services"]["cudy_collector"]
backend = https["services"]["evening_detective_server"]
assert set(collector["networks"]) == {"entrance"} and not collector.get("ports")
assert set(backend["networks"]) == {"default"}
assert "DATABASE_URL" not in collector["environment"] and "S3_PASSWORD" not in collector["environment"]
assert any(v["target"] == "/run/secrets/cudy" and v["read_only"] for v in collector["volumes"])
assert any(v["target"] == "/run/cudy" and v["read_only"] for v in backend["volumes"])
assert not any(v["target"] == "/run/secrets/cudy" for v in backend["volumes"])
assert collector["environment"]["CUDY_SNAPSHOT_FILE"] == server["CUDY_SNAPSHOT_FILE"]
print("PASS: Compose merge, HTTPS addresses, ports, read-only mounts and preserved data volumes")
suffix = uuid.uuid4().hex[:10]
network = "venue-https-test-" + suffix
+98
View File
@@ -0,0 +1,98 @@
"""100 virtual clients in disposable prod/venue; does not test Wi-Fi radios."""
import json
import math
from concurrent.futures import ThreadPoolExecutor
import statistics
import sys
import tempfile
import threading
import time
import uuid
sys.dont_write_bytecode = True
from flow import API, Stand, export_package, fixture
def check_load(stand):
data = fixture(stand, 'online')
venue = stand.apis['venue']
venue.request('/api/games/import', export_package(stand.apis['prod'], data['game']))
game = data['game']
teams = [venue.request('/api/teams', {'gameID': game, 'name': f'TEST load {i}', 'participantsCount': 4}) for i in range(25)]
venue.request(f'/api/games/{game}/start', {}, 'PUT')
samples = {}
lock = threading.Lock()
barrier = threading.Barrier(101)
ids = [str(uuid.uuid4()) for _ in range(100)]
connections = [str(uuid.uuid4()) for _ in range(100)]
def measured(name, action):
before = time.perf_counter()
result = action()
with lock:
samples.setdefault(name, []).append(time.perf_counter() - before)
return result
def client(index):
public = API(venue.base)
team = teams[index // 4]
heartbeat = {'deviceID': ids[index], 'connectionID': connections[index], 'teamID': team['ID'], 'password': team['password']}
barrier.wait(timeout=30)
started = time.monotonic()
next_heartbeat = next_story = started
moved = False
while time.monotonic() - started < 60:
now = time.monotonic()
if now >= next_heartbeat:
result = measured('heartbeat', lambda: public.request('/api/devices/heartbeat', heartbeat))
assert result['enabled'] is True
next_heartbeat += 20
if not moved and index % 4 == 0:
for code in ('p1', 'p2'):
measured('move', lambda code=code: public.move(team, code))
measured('answers', lambda: public.request(f'/api/teams/{team["ID"]}/answers', {'password': team['password'], 'answers': [{'questionCode': 'q1', 'answer': 'Butler'}]}))
moved = True
if now >= next_story:
measured('story', lambda: public.story(team))
next_story += 5
time.sleep(.05)
with ThreadPoolExecutor(max_workers=100) as pool:
futures = [pool.submit(client, index) for index in range(100)]
barrier.wait(timeout=30)
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
snapshot = measured('devices', lambda: venue.request(f'/api/devices?gameID={game}'))
assert len(snapshot['devices']) <= 100
time.sleep(5)
for future in futures:
future.result()
devices = venue.request(f'/api/devices?gameID={game}')['devices']
assert {device['ID'] for device in devices} == set(ids)
assert all(device['connected'] and len(device['connections']) == 1 for device in devices)
for team in teams:
assert len(API(venue.base).story(team)['actions']) == 2
answers = venue.request(f'/api/games/{game}/answers')['teams']
assert {row['team']['ID'] for row in answers if any(answer.get('answer') == 'Butler' for answer in row.get('answers', []))} == {team['ID'] for team in teams}
report = {}
for name, values in sorted(samples.items()):
values.sort()
p95 = values[math.ceil(.95 * len(values)) - 1]
report[name] = {'requests': len(values), 'p50_ms': round(statistics.median(values) * 1000), 'p95_ms': round(p95 * 1000), 'max_ms': round(values[-1] * 1000)}
assert p95 <= 2, f'{name} exceeds the test budget p95 <= 2s'
print('PASS: 100 profiles, 25 teams, 60s, 100 connected devices, 50 moves, 25 answers, zero HTTP errors', flush=True)
print(json.dumps(report, sort_keys=True), flush=True)
def main():
with tempfile.TemporaryDirectory(prefix='ed029-load-') as directory:
stand = Stand(directory)
try:
stand.start()
check_load(stand)
finally:
stand.close()
if __name__ == '__main__':
main()