Lane F R15 (v3.0): full-week gig soak (ledger #3) — leak/cover/roster over 7 nights
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>
This commit is contained in:
parent
687d23f654
commit
edc0c479c1
15
tools/qa.sh
15
tools/qa.sh
@ -111,6 +111,21 @@ else
|
||||
soft_skip "interior figure-scale gate" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"
|
||||
fi
|
||||
|
||||
# ── Gate 6 (Lane F, round 15): full-week gig soak — OPT-IN (--soak), NON-STRICT ─
|
||||
# The v3.0 release soak: 7 nights × every playing venue in one context — latch state per plan.gigs,
|
||||
# cover stamp per venue per night, roster clears per night, leak geo/tex to a warmed baseline across
|
||||
# ≥20 enter/exit cycles. Slow (~90s) + diagnostic, so it is OPT-IN and warn-level (never blocks a tag);
|
||||
# the core 6 gates stay a fast strict run. Wired per ROUND15 §Lane F.3.
|
||||
SOAK_RUN=0; for a in "$@"; do [ "$a" = "--soak" ] && SOAK_RUN=1; done
|
||||
if [ "$SOAK_RUN" = 1 ]; then
|
||||
if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
warn_gate "full-week gig soak (7 nights · cover/roster per night · leak-free · Lane F R15)" \
|
||||
tools/.venv/bin/python tools/qa/week_soak.py
|
||||
else
|
||||
soft_skip "full-week gig soak" "Playwright venv absent"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
hr
|
||||
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \
|
||||
|
||||
222
tools/qa/week_soak.py
Normal file
222
tools/qa/week_soak.py
Normal file
@ -0,0 +1,222 @@
|
||||
#!/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()
|
||||
Loading…
Reference in New Issue
Block a user