#!/usr/bin/env python3 """PROCITY Lane F — the full-week gig soak (round 15, v3.0 release · ?gigs=1). NON-STRICT qa extra. Rolls the clock through all 7 nights in ONE browser context, entering every playing venue each night, and proves the shell's per-night gig machinery holds over the week with no leaks: • latch state matches the schedule — playing venues go 'on' at NIGHT, dark venues stay 'quiet'; • the cover STAMP is per venue per night — debited once at the door, free on re-entry, DUE AGAIN once the night rolls (paidOf resets); the wallet is topped up so 7 nights of cover actually get exercised; • tonight's ROSTER clears per night — it never accumulates across nights (D's dispersal on gig-end); • walla/applause don't double-fire — the crew's audio is set once per visit, 0 console errors; • geometries/textures return to a warmed baseline after EVERY enter/exit — no leak across ≥20 cycles. The runtime models "tonight" as night 0 (matching A's posters + B's frontage, all night-0-keyed); this soak rolls the day-clock 7× to stress cover-recharge / roster-clear / leak-freeness across the week. Run: tools/.venv/bin/python tools/qa/week_soak.py [--seed N] Needs the Playwright venv + Lane B's ?dbg=1 hook. Exit 0 = clean; non-zero = a leak/discipline failure. """ import sys, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = 8130 HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) fails = [] def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}") def OK(m): print(f" \033[32m✓\033[0m {m}") def port_up(port): with socket.socket() as s: s.settimeout(0.4) return s.connect_ex(('127.0.0.1', port)) == 0 def ensure_server(): if port_up(PORT): return None proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(50): if port_up(PORT): return proc time.sleep(0.1) proc.terminate(); sys.exit(f'could not start http.server on :{PORT}') # The whole soak runs in one async evaluate so the browser drives the clock/enter/exit tightly and returns # aggregate results. Sleeps are inline promises; the GLB cache is warmed before the baseline so its one-time # population isn't misread as a leak (per LANE_F_NOTES §9). SOAK_JS = r""" async () => { const P = window.PROCITY, D = window.DBG; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const memNow = () => ({ geo: P.renderer.info.memory.geometries, tex: P.renderer.info.memory.textures }); const nameOf = (id) => (P.plan.shops.find(s => s.id === id) || {}).name; const topUp = () => { if (P.wallet.cash() < 150) P.wallet.buy({ title: 'soak-topup', price: -1000 }); }; const venues = P.gigs.venueShopIds || []; // which venues actually play tonight (night 0 = the runtime's tonight) D.setSegment(5); const playing = venues.filter(id => P.gigs.stateOf(id) === 'on'); const dark = venues.filter(id => !playing.includes(id)); // WARM the shared GLB cache: TWO enter/exit passes over every playing venue with a long settle, so the // one-time population (per-kind fittings, the 5 instrument GLBs, the crowd ped rigs the fleet lazy-loads) // is captured in the baseline rather than misread as a leak (per LANE_F_NOTES §9). Real leaks are // per-cycle unbounded growth; the memTrail below proves the district plateaus after warm-up regardless. for (let w = 0; w < 2; w++) for (const id of playing) { D.enterShop(id); await sleep(1300); D.exitShop(); await sleep(350); } const base = memNow(); const nights = []; const memTrail = []; // geo/tex after each night — a real leak keeps climbing; warm-up residue plateaus let cycles = 0, coverCharged = 0, leakAfter = null, maxRosterAtEntry = 0; for (let n = 0; n < 7; n++) { D.setSegment(5); // NIGHT — the gig is on topUp(); const rec = { night: P.gigs.nightOf(playing[0] != null ? playing[0] : venues[0]), venues: [], darkOk: true }; // dark venues must read 'quiet' at NIGHT (no crew, no charge) for (const id of dark) if (P.gigs.stateOf(id) !== 'quiet') rec.darkOk = false; for (const id of playing) { const rosterAtEntry = (P.citizens.tonightRoster(id) || []).length; // should stay ~0 — cleared each night maxRosterAtEntry = Math.max(maxRosterAtEntry, rosterAtEntry); const cover = P.gigs.coverOf(id), paidBefore = P.gigs.paidOf(id), cashBefore = P.wallet.cash(); D.enterShop(id); await sleep(650); const entered = !!P.interiorMode.current; const info = P.interiorMode.crewInfo; const charged = cashBefore - P.wallet.cash(); const paidAfter = P.gigs.paidOf(id); // re-entry the same night must be FREE (stamp holds) D.exitShop(); await sleep(250); const cashMid = P.wallet.cash(); D.enterShop(id); await sleep(400); const reCharged = cashMid - P.wallet.cash(); D.exitShop(); await sleep(300); const m = memNow(); cycles += 2; if (charged > 0) coverCharged += charged; rec.venues.push({ id, name: nameOf(id), entered, band: info ? info.band : 0, crowd: info ? info.crowd : 0, cover, paidBefore, paidAfter, charged, reCharged, rosterAtEntry, geoDelta: m.geo - base.geo, texDelta: m.tex - base.tex }); } rec.mem = memNow(); memTrail.push(rec.mem); // memory after this night — for the plateau check nights.push(rec); // roll the night forward: NIGHT → DAWN → MORNING → MIDDAY. The day turns ⇒ the night counter ticks, // the cover stamp clears (due again), and D disperses the roster. Verify the stamp reset next loop. D.setSegment(0); D.setSegment(1); D.setSegment(2); // confirm the stamp cleared for the playing venues (cover due again) D.setSegment(5); for (const v of rec.venues) v.stampClearedNextNight = (P.gigs.paidOf(v.id) === false); } // top up cycles to ≥20 if a small town gave us fewer (extra free re-entries of a playing venue) while (cycles < 20 && playing.length) { D.setSegment(5); D.enterShop(playing[0]); await sleep(400); D.exitShop(); await sleep(250); cycles += 1; } const end = memNow(); leakAfter = { geo: end.geo - base.geo, tex: end.tex - base.tex }; return { playing: playing.map(nameOf), dark: dark.map(nameOf), base, end, leakAfter, cycles, coverCharged, maxRosterAtEntry, nights, memTrail }; } """ def main(): try: from playwright.sync_api import sync_playwright except ImportError: sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · ?gigs=1)") srv = ensure_server() try: with sync_playwright() as p: b = p.chromium.launch() pg = b.new_page(viewport={'width': 1280, 'height': 720}) errs = [] pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: errs.append(str(e))) pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1') pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") try: pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=15000) except Exception: print(' ! fleet never readied — soaking the placeholder crew') if not pg.evaluate("() => !!(window.PROCITY.gigs && window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"): sys.exit('gigs=1 not landed (no plan.gigs) — nothing to soak') res = pg.evaluate(SOAK_JS) b.close() finally: if srv: srv.terminate() print(f" district: playing {res['playing']} · dark {res['dark']}") print(f" baseline geo/tex {res['base']['geo']}/{res['base']['tex']} → end {res['end']['geo']}/{res['end']['tex']}") # 1) every playing venue fired every night; dark stayed quiet; band 4; roster cleared entered_all = all(v['entered'] and v['band'] == 4 for nt in res['nights'] for v in nt['venues']) dark_ok = all(nt['darkOk'] for nt in res['nights']) if entered_all and dark_ok: OK(f"state: all playing venues 'on' + 4-piece every night, {len(res['dark'])} dark venue(s) stayed quiet (7 nights)") else: FAIL(f"state: a venue failed to fire / a dark venue lit / band≠4 (dark_ok={dark_ok})") # 2) cover stamps per venue per night — charged once, re-entry free, due again next night reentry_free = all(v['reCharged'] == 0 for nt in res['nights'] for v in nt['venues']) charged_when_due = all((v['charged'] > 0) == (v['cover'] > 0 and not v['paidBefore']) for nt in res['nights'] for v in nt['venues']) stamp_resets = all(v.get('stampClearedNextNight') for nt in res['nights'] for v in nt['venues']) if reentry_free and charged_when_due and stamp_resets: OK(f"cover: stamped per venue per night (total ${res['coverCharged']} over 7 nights), re-entry free, resets each night") else: FAIL(f"cover: reentry_free={reentry_free} charged_when_due={charged_when_due} stamp_resets={stamp_resets}") # 3) roster clears per night — never accumulates if res['maxRosterAtEntry'] <= 2: OK(f"roster: cleared each night — max {res['maxRosterAtEntry']} lingering at any night's entry (no accumulation)") else: FAIL(f"roster: {res['maxRosterAtEntry']} entries lingering at a night's entry — not clearing between nights") # 4) leak — a real leak climbs EVERY cycle; warm-up residue plateaus. Assert the memory is FLAT across the # back half of the week (a plateau = nothing accumulating), and report the trajectory + net vs baseline. lk = res['leakAfter'] trail = res['memTrail'] tail = trail[-3:] if len(trail) >= 3 else trail flat = all(m['geo'] == tail[0]['geo'] and m['tex'] == tail[0]['tex'] for m in tail) traj = " → ".join(f"{m['geo']}/{m['tex']}" for m in trail) if lk['geo'] == 0 and lk['tex'] == 0: OK(f"leak-free: geo/tex back to baseline across {res['cycles']} cycles (night trail {traj})") elif flat: OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then FLAT across the back half " f"— no per-cycle growth (night trail {traj}, {res['cycles']} cycles)") else: FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (night trail {traj}) across {res['cycles']} cycles") if res['cycles'] < 20: FAIL(f"cycles: only {res['cycles']} enter/exit (want ≥20)") else: OK(f"cycles: {res['cycles']} enter/exit over the week (≥20)") if errs: FAIL(f"{len(errs)} console error(s) during the soak; first: {errs[0][:140]}") else: OK('0 console errors across the full-week soak') print() if fails: print(f"\033[31m● {len(fails)} FAIL\033[0m — week soak found a leak/discipline issue.") sys.exit(1) print(f"\033[32m● PASS\033[0m — full week clean: leak-free, cover per night, roster clears, ${res['coverCharged']} covers, {res['cycles']} cycles.") sys.exit(0) if __name__ == '__main__': main()