"""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()))