Files
lolero/tests/e2e_smoke.py
T

224 lines
8.9 KiB
Python

"""E2E smoke test for 1v9 — Convertite en Leyenda.
Juega carreras completas con un bot y verifica:
- 0 errores de consola / 0 requests fallidos
- resumen alcanzado en 100% de las carreras
- sin overflow horizontal (desktop + mobile)
- timeline colapsada en mobile
- design tokens correctos
- gating: 2ª división no ve competencias internacionales
- fichajes solo desde split >= 4
- stats sin NaN ni claves fantasma
- diversidad: las 5 competencias aparecen en N carreras
- export de imagen 1080x1920 válido (PIL)
"""
import asyncio
import json
import sys
import random
from playwright.async_api import async_playwright
BASE = "http://127.0.0.1:8899/"
async def pick_region_role(page):
cards = page.locator(".region-card")
n = await cards.count()
idx = random.randrange(n)
await cards.nth(idx).click()
cards = page.locator(".role-card")
n = await cards.count()
await cards.nth(random.randrange(n)).click()
async def play_career(page, want_export=False, max_rounds=60):
"""Juega una carrera completa. Devuelve dict con datos de la partida."""
data = {"decisions": [], "intl_seen": [], "categories": [], "transfer_splits": [], "errors": [], "intl_divs": []}
page.on("pageerror", lambda e: data["errors"].append(f"pageerror: {e}"))
page.on("console", lambda m: data["errors"].append(f"console: {m.text}") if m.type == "error" else None)
page.on("requestfailed", lambda r: data["errors"].append(f"requestfailed: {r.url}"))
await page.goto(BASE, wait_until="networkidle")
await page.click("#btnStart")
await pick_region_role(page)
await page.fill("#inpNick", "BotPro")
await page.click("#btnBegin")
await page.click(".opt-card:visible >> nth=0") # oferta de academia
rounds = 0
while rounds < max_rounds:
rounds += 1
summary_active = await page.evaluate("document.getElementById('summary').classList.contains('active')")
if summary_active:
break
overlay = await page.evaluate("document.getElementById('resultOverlay').classList.contains('show')")
if overlay:
cont = page.locator("#btnContinue")
if await cont.count():
await cont.click()
continue
# nueva decisión renderizada
cat = await page.locator(".dc-cat").first.text_content()
if cat:
data["categories"].append(cat.strip())
if "Fichaje" in cat:
split = await page.evaluate("G.split")
data["transfer_splits"].append(split)
for comp in ["First Stand", "MSI", "EWC", "Asian Games", "Worlds"]:
if comp in cat and comp not in data["intl_seen"]:
data["intl_seen"].append(comp)
if any(c in cat for c in ["First Stand", "MSI", "EWC", "Asian Games", "Worlds"]):
div = await page.evaluate("G.div")
data["intl_divs"].append(div)
opts = page.locator(".opt-card:visible")
n = await opts.count()
if not n:
await page.wait_for_timeout(300)
continue
# 40% de retirarse cuando esté disponible (split >= 6) para acelerar
retire = page.locator('.opt-card[data-i="retire"]:visible')
if await retire.count() and random.random() < 0.4:
await retire.click()
else:
await opts.nth(random.randrange(n)).click()
await page.wait_for_timeout(120)
state = await page.evaluate("""() => ({
split: G.split, totalSplits: G.totalSplits, retired: G.retired, div: G.div,
stats: G.stats, teamId: G.teamId, intl: G.intl, teamTitles: G.teamTitles,
indTitles: G.indTitles, history: G.history, games: G.games,
kills: G.kills, assists: G.assists, deaths: G.deaths, penta: G.penta,
maxValue: G.maxValue, ratingMax: G.ratingMax, regSelect: G.regSelect
})""")
data["state"] = state
if want_export:
async with page.expect_download(timeout=10000) as dl_info:
await page.click("#btnExport")
dl = await dl_info.value
path = await dl.path()
data["download"] = path
return data
async def check_layout(page, is_mobile):
"""Chequeos de layout programáticos."""
res = {}
res["overflow"] = await page.evaluate(
"document.documentElement.scrollWidth > window.innerWidth + 1"
)
res["tl_closed_mobile"] = await page.evaluate(
"""() => {
const b = document.getElementById('tlBody');
return !b || b.classList.contains('closed');
}"""
) if is_mobile else None
res["tokens"] = await page.evaluate(
"""() => {
const s = getComputedStyle(document.documentElement);
return {
accent: s.getPropertyValue('--accent-ovr').trim(),
success: s.getPropertyValue('--success').trim(),
bg: s.getPropertyValue('--bg-base').trim()
};
}"""
)
return res
def check_state(data):
"""Verifica mecánicas sobre el estado final de una carrera."""
issues = []
st = data["state"]
for k, v in st["stats"].items():
if v != v or v is None: # NaN check
issues.append(f"stat NaN: {k}")
ghost = [k for k in st["stats"] if k in ("valor",)]
if ghost:
issues.append(f"ghost stats: {ghost}")
# gating internacionales: evento intl solo con div 1
for cat in data["categories"]:
if any(c in cat for c in ["First Stand", "MSI", "EWC", "Asian Games", "Worlds"]):
pass # no podemos saber el div en el momento; se chequea en vivo abajo
# fichajes >= split 4
for s in data["transfer_splits"]:
if s < 4:
issues.append(f"transfer en split {s} (< 4)")
# gating: toda competencia vista con div 1
for d in data["intl_divs"]:
if d != 1:
issues.append(f"competencia internacional vista en div {d}")
return issues
def validate_export(path):
"""Valida el PNG exportado con PIL."""
try:
from PIL import Image
except ImportError:
return "PIL no instalado (skip)"
img = Image.open(path)
size = img.size
mode = img.mode
px = img.convert("RGB").getpixel((100, 100))
luma = img.convert("L").getextrema()
ok = size == (1080, 1920) and luma[1] > 60
return f"OK size={size} mode={mode} bg={px} luma={luma}" if ok else f"FAIL size={size} mode={mode} bg={px} luma={luma}"
async def main():
results = {"errors_total": 0, "careers": [], "intl_all": set(), "export_ok": None}
n_careers = int(sys.argv[1]) if len(sys.argv) > 1 else 6
async with async_playwright() as p:
browser = await p.chromium.launch()
# --- desktop ---
page = await browser.new_page(viewport={"width": 1280, "height": 800})
data = await play_career(page, want_export=True)
layout = await check_layout(page, is_mobile=False)
print("DESKTOP layout:", json.dumps(layout, ensure_ascii=False))
print("DESKTOP errors:", data["errors"])
print("DESKTOP finished:", data["state"]["split"] >= data["state"]["totalSplits"] or data["state"]["retired"])
if data.get("download"):
results["export_ok"] = validate_export(data["download"])
print("DESKTOP export:", results["export_ok"])
await page.close()
# --- mobile (390x844) + N carreras ---
for i in range(n_careers):
page = await browser.new_page(viewport={"width": 390, "height": 844})
data = await play_career(page, want_export=False)
issues = check_state(data)
finished = data["state"]["split"] >= data["state"]["totalSplits"] or data["state"]["retired"]
print(f"--- carrera {i+1}: errors={len(data['errors'])} finished={finished} div={data['state']['div']} intl={data['intl_seen']} transfers={data['transfer_splits']} issues={issues}")
results["errors_total"] += len(data["errors"])
results["careers"].append({"finished": finished, "issues": issues})
results["intl_all"] |= set(data["intl_seen"])
await page.close()
# layout mobile una vez
page = await browser.new_page(viewport={"width": 390, "height": 844})
data = await play_career(page)
layout = await check_layout(page, is_mobile=True)
print("MOBILE layout:", json.dumps(layout, ensure_ascii=False))
await page.close()
await browser.close()
ok = results["errors_total"] == 0 and all(c["finished"] for c in results["careers"])
print("\n=== RESUMEN ===")
print("errores totales:", results["errors_total"])
print("carreras completadas:", sum(1 for c in results["careers"] if c["finished"]), "/", len(results["careers"]))
print("issue por carrera:", [c["issues"] for c in results["careers"]])
print("competencias vistas:", sorted(results["intl_all"]))
print("export:", results["export_ok"])
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))