up
This commit is contained in:
+51
-1
@@ -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 ГиБ также не проверен.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user