tools/qa/week_soak.py: rolls the day-clock through all 7 nights in one context, entering every playing venue each night. Asserts latch state per plan.gigs (dark venues stay quiet), the cover STAMP per venue per night (debited once, re-entry free, due again next night), tonight's roster clears per night (no accumulation), and geo/tex return to a WARMED baseline across >=20 enter/exit cycles — leak proven by a plateau (a real leak climbs every cycle; warm-up cache residue plateaus). Opt-in --soak warn-gate in qa.sh (never blocks the tag; the core 6 stay a fast strict run). Verified seed 20261990: 2 playing (Exchange Hotel + RSL) + 1 dark, 28 cycles, $60 covers, geo/tex flat across the back half (115/86 -> 115/84), 0 console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
223 lines
12 KiB
Python
223 lines
12 KiB
Python
#!/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()
|