// Local menu regression: run with browser_run_code_unsafe(filename: "tools/check-menu.js"). // Uses the browser tool's Playwright runtime; no package or test code ships to the game. async (hostPage) => { const context = await hostPage.context().browser().newContext({ viewport: { width: 320, height: 568 }, isMobile: true, hasTouch: true, }); const page = await context.newPage(), errors = [], checks = []; page.on('pageerror', e => errors.push(e.message)); const check = (ok, label) => { if (!ok) throw new Error(label); checks.push(label); }; const click = selector => page.locator(selector).click(); const stage = () => page.locator('#intro').getAttribute('data-stage'); const quit = async () => { await click('#qChip'); await click('#qYes'); }; const fits = async selector => { await page.waitForTimeout(650); // let the intentional menu entrance finish return page.locator(selector).evaluate(e => { const r = e.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight; }); }; try { await page.goto('http://localhost:8641'); await page.waitForFunction(() => !document.querySelector('#splash').classList.contains('busy')); for (const selector of ['#mStart', '#mGore', '#mOther', '#mVs', '#mRanks']) check(await fits(selector), '320px splash fits ' + selector); check(await page.evaluate(() => document.querySelector('#mStart').offsetHeight === document.querySelector('#mGore').offsetHeight), 'Cup and Gore share one height'); check(await page.locator('.splashDoors button').evaluateAll(bs => bs.every(b => getComputedStyle(b).textAlign === 'center' && getComputedStyle(b).justifyContent === 'center')), 'secondary door labels are centered'); check(await page.evaluate(() => { const v = document.querySelector('#mVs'), g = document.querySelector('#mGore'); const drop = getComputedStyle(v, '::before'); const animations = document.getAnimations().filter(a => a.animationName?.startsWith('menuBlood')); return Math.abs(v.getBoundingClientRect().top + 2 + parseFloat(drop.top) - g.getBoundingClientRect().bottom) < 1 && drop.pointerEvents === 'none' && getComputedStyle(v, '::after').pointerEvents === 'none' && animations.length === 2 && animations.every(a => a.effect.getTiming().duration === 18000); }), 'blood starts at Gore edge with synchronized decorative animations'); await page.emulateMedia({ reducedMotion: 'reduce' }); check(await page.evaluate(() => !document.getAnimations().some(a => a.animationName?.startsWith('menuBlood'))), 'blood respects reduced motion'); await page.emulateMedia({ reducedMotion: 'no-preference' }); await click('#mStart'); check(await stage() === 'player', 'first cup requests identity'); await page.locator('#pName').fill('MENU CHECK'); await click('#pGo'); check(await page.evaluate(() => document.querySelector('#intro').hidden && !!T.G.match.cup), 'identity resumes the selected cup'); await quit(); await click('#mRanks'); check(await page.locator('#rkModes .on').innerText() === 'THE CUP', 'cup opens its own world board'); await click('#rkBack'); await click('#mOther'); check(await page.evaluate(() => !document.getAnimations().some(a => a.animationName?.startsWith('menuBlood'))), 'blood animation stops away from splash'); check((await page.locator('#modeList button').allTextContents()).join('|') === 'THE CLIMB|TRAINING|CLASSIC TENNIS', 'other games has exactly three additional solo modes'); check(await fits('#modeList button:last-child'), 'all additional modes fit on small portrait'); await page.getByRole('button', { name: 'CLASSIC TENNIS', exact: true }).click(); check(await stage() === 'setup' && await page.locator('#sDifficulty').isVisible(), 'classic opens setup with inline difficulty'); await page.getByRole('button', { name: 'HARD', exact: true }).click(); check(await stage() === 'setup', 'difficulty selects without navigating'); check(await page.locator('#courtList button').count() === 1, 'unlocked court is shown first'); check(await page.locator('#lockedCourts').evaluate(e => e.open), 'locked courts start expanded'); check(await page.locator('#lockedCourtList button:disabled').count() === 4, 'locked courts cannot be selected'); await click('#lockedCourtLabel'); await click('#sEdit'); await page.locator('#pName').fill('MENU EDIT'); await page.locator('#pKitRow button[title="CLARET"]').click(); await click('#pGo'); check(await stage() === 'setup' && await page.locator('#sPlayerName').innerText() === 'MENU EDIT', 'player edit returns to setup with saved identity'); check(await page.locator('#sPlayerKit').innerText() === 'CLARET', 'setup reflects edited kit'); await click('#cStart'); check(await page.evaluate(() => T.G.classic && T.G.diff.id === 'hard'), 'serve starts chosen classic difficulty'); await quit(); await click('#mRanks'); check(await page.locator('#rkModes .on').count() === 1, 'classic fallback selects an existing world board'); await click('#rkBack'); await click('#mOther'); await page.getByRole('button', { name: 'TRAINING', exact: true }).click(); check(await stage() === 'train', 'training chooses opponent before setup'); await page.getByRole('button', { name: 'THE BALL BOY 02', exact: true }).click(); check(await stage() === 'setup' && await page.locator('#sOpponent b').innerText() === 'THE BALL BOY', 'training setup identifies chosen opponent'); await click('#sBack'); check(await stage() === 'train', 'training setup returns to opponents'); await page.getByRole('button', { name: 'THE BALL BOY 02', exact: true }).click(); await click('#cStart'); check(await page.evaluate(() => T.G.match.train && T.G.match.opp === 2), 'serve starts selected training opponent'); await quit(); await click('#mGore'); check(await page.evaluate(() => document.querySelector('#intro').hidden && T.G.mode === 'splatter' && !T.G.match), 'Gore remains a direct start'); await quit(); await click('#mCourt'); check(await stage() === 'setup' && await page.locator('#sTitle').innerText() === 'SPLATTER GORE', 'Gore court link opens its setup'); await click('#sBack'); check(await stage() === 'splash', 'Gore setup returns to splash'); await click('#mVs'); await click('#vsHostC'); await page.waitForFunction(() => /^[A-Z0-9]{4}$/.test(document.querySelector('#vsBig').textContent)); await click('#vsCancel'); await click('#vsBack'); check(await stage() === 'splash' && await page.evaluate(() => !T.G.net), 'friend lobby cancels and returns directly to splash'); await click('#plaque'); check(await page.locator('#cdLeagueDetails').evaluate(e => e.open), 'league details start expanded'); await click('#cdStats'); check(await stage() === 'stats' && await page.locator('#recorder').evaluate(e => e.open), 'stats belongs to profile and recording starts expanded'); await click('#recToggle'); check((await page.locator('#recLabel').innerText()).includes('ON'), 'recording state remains visible when collapsed'); await click('#recToggle'); await click('#recLabel'); await click('#stBack'); check(await stage() === 'card', 'stats returns to profile'); await click('#cdEdit'); check(await page.locator('#pGo').innerText() === 'SAVE PLAYER', 'existing identity uses save wording'); check(await page.locator('#pKitRow button').evaluateAll(bs => bs.every(b => { const r = b.getBoundingClientRect(); return r.width >= 44 && r.height >= 44; })), 'colour swatches meet 44px targets at 320px'); await click('#pBack'); check(await stage() === 'card', 'editing back returns to profile'); await click('#cdBack'); await page.setViewportSize({ width: 390, height: 844 }); check(await fits('#mVs'), 'friend door fits large portrait'); await page.setViewportSize({ width: 768, height: 1024 }); check(await fits('#intro .card') && await page.locator('#intro .card').evaluate(e => e.getBoundingClientRect().width === innerWidth && e.getBoundingClientRect().height >= innerHeight), 'menu card fills wider portrait viewport'); check(errors.length === 0, 'no browser runtime errors: ' + errors.join('; ')); return { passed: checks.length, checks }; } finally { await context.close(); } }