#!/usr/bin/env python3 """PROCITY Lane F — R40 §40.6 gate: THE PLAYTEST HARNESS (F8 notes + ?bugtour=1). Three arms, each with its non-vacuous control, per the round brief: OFF = BYTE-IDENTICAL ?classic=1 and ?playtest=0 must never fetch playtest.js, never grow the DOM (#pc-note / #pc-bugtour absent), never touch storage. CONTROL: the default boot DOES fetch it and publishes PROCITY.playtest — otherwise the absence arms prove nothing. F8 ROUND-TRIPS The REAL key path (keyboard F8, not an API call): screenshot download fires, the overlay opens, a typed line + Enter saves. Export JSON schema-validates field-for-field (the ticket contract, LANE_F_NOTES §40), a reload persists the note, Esc cancels without saving, and a corrupt / wrong-schema blob is REJECTED WHOLE on load and on import() (delta law). ?bugtour=1 ADVANCES N steps through EVERY stop (spawn → street → shop/buy → dig → op-shop bin (unarmed) → gig night → tram → rain → night → fog map → second town) with ZERO console errors, the caption tracking each stop — and the shell loads DBG under ?bugtour=1 alone (no ?dbg=1), because the tour drives it. Run: tools/.venv/bin/python tools/qa/r40_playtest.py (own no-store server, fresh contexts) Exit: 0 green, 1 red. """ import sys, os, json, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = int(os.environ.get('PROCITY_R40_PT_PORT', '8974')) HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 SCHEMA = 'procity-playtest/1' NOTE_FIELDS = {'id': str, 't': (int, float), 'text': str, 'shot': (str, type(None)), 'pos': dict, 'mode': str, 'shopId': (int, str, type(None)), 'shopName': (str, type(None)), 'street': (str, type(None)), 'locality': (str, type(None)), 'citySeed': int, 'town': (str, type(None)), 'seg': int, 'clock': str, 'flags': dict, 'fps': (int, float, type(None)), 'draws': (int, float), 'tris': (int, float)} 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 head(m): print(f"\n\033[1m{m}\033[0m") def note(m): print(f" \033[33m·\033[0m {m}") def check(cond, m): (OK if cond else FAIL)(m) return cond NOSTORE = r''' import sys, http.server, functools class H(http.server.SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') super().end_headers() def log_message(self, *a): pass http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever() ''' def port_up(port): with socket.socket() as s: s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0 def serve(root, port): proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(80): if port_up(port): return proc time.sleep(0.1) proc.terminate(); raise SystemExit(f'could not serve on :{port}') def new_page(p): b = p.chromium.launch() pg = b.new_page(viewport={'width': 1280, 'height': 720}, accept_downloads=True) errs, reqs, cons = [], [], [] pg.on('console', lambda m: (cons.append(m.text), errs.append(m.text) if m.type == 'error' else None)) pg.on('pageerror', lambda e: errs.append(str(e))) pg.on('request', lambda r: reqs.append(r.url)) return b, pg, errs, reqs, cons def boot(pg, query, wait_dbg=True): pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + query) if query else '')) if wait_dbg: pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000) else: pg.wait_for_function('window.PROCITY && window.PROCITY.chunks && window.PROCITY.chunks.count > 0', timeout=30000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") def gate_off(p): head('OFF = BYTE-IDENTICAL — ?classic=1 / ?playtest=0 never fetch, never grow, never store') for q, label in (('classic=1&dbg=1', '?classic=1'), ('playtest=0&dbg=1', '?playtest=0')): b, pg, errs, reqs, _ = new_page(p) boot(pg, q) pg.wait_for_timeout(1200) # give a wrongly-wired dynamic import time to land fetched = [u for u in reqs if 'playtest.js' in u] dom = pg.evaluate("() => !!(document.getElementById('pc-note') || document.getElementById('pc-bugtour'))") pub = pg.evaluate("() => window.PROCITY.playtest !== undefined") stored = pg.evaluate("() => localStorage.getItem('procity-playtest')") nstore = pg.evaluate("() => localStorage.length") check(not fetched, f'{label}: playtest.js never fetched ({len(reqs)} requests swept)') check(not dom and not pub, f'{label}: no harness DOM, PROCITY.playtest undefined') check(stored is None, f'{label}: storage untouched (procity-playtest absent' + (f', localStorage empty)' if q.startswith('classic') and nstore == 0 else ')')) if q.startswith('classic'): check(nstore == 0, f'{label}: localStorage has 0 keys (the classic covenant)') check(not errs, f'{label}: 0 console errors') b.close() # ?game=0 — the harness stays LIVE but its store is LAZY: the R30c purity law holds ?game=0 at # ZERO Storage-prototype calls (instrumented before any page script), and an eager getItem in # the constructor turned that gate red once (this round). Boot must make no calls; the first # deliberate use pays exactly one read (the control that proves the instrument sees anything). b, pg, errs, reqs, _ = new_page(p) pg.add_init_script("""(() => { window.__pcStorageCalls = []; const P = Storage.prototype; for (const m of ['getItem','setItem','removeItem','clear','key']) { const o = P[m]; P[m] = function(...a) { window.__pcStorageCalls.push(m + ':' + (a[0] ?? '')); return o.apply(this, a); }; } })()""") boot(pg, 'game=0&dbg=1') pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000) pg.wait_for_timeout(500) ncalls = pg.evaluate("() => window.__pcStorageCalls.length") check(ncalls == 0, f'?game=0: playtest LIVE with ZERO Storage calls at boot (saw {ncalls}) — R30c purity holds') cnt = pg.evaluate("() => window.PROCITY.playtest.count") lazy = pg.evaluate("() => window.__pcStorageCalls") check(cnt == 0 and any(c == 'getItem:procity-playtest' for c in lazy), 'CONTROL: first use pays exactly the one lazy read (the instrument sees it)') check(not errs, '?game=0: 0 console errors') b.close() # CONTROL — the absence arms are only meaningful if the default boot has the harness b, pg, errs, reqs, _ = new_page(p) boot(pg, 'dbg=1') pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000) fetched = [u for u in reqs if 'playtest.js' in u] check(bool(fetched), 'CONTROL: default boot fetches playtest.js') check(pg.evaluate("() => window.PROCITY.flags.playtest === true"), 'CONTROL: flags.playtest true on default') stored = pg.evaluate("() => localStorage.getItem('procity-playtest')") check(stored is None, 'default boot with 0 notes still writes nothing (persist only on save)') check(not errs, 'default boot: 0 console errors') b.close() def gate_f8(p): head('F8 ROUND-TRIP — the real key path: shot download · overlay · Enter saves · export validates') b, pg, errs, _, cons = new_page(p) boot(pg, 'dbg=1') pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000) pg.wait_for_function('() => window.DBG.exportNotes !== undefined', timeout=15000) OK('DBG.exportNotes bound (the §40.6 DBG method)') with pg.expect_download() as dl: pg.keyboard.press('F8') name = dl.value.suggested_filename check(name.startswith('procity-note-') and name.endswith('.png'), f'F8 fired the screenshot download ({name})') pg.wait_for_selector('#pc-note', state='visible', timeout=5000) OK('the note overlay opened') pg.wait_for_function("() => document.activeElement && document.activeElement.id === 'pc-note-text'", timeout=5000) pg.type('#pc-note-text', 'the awning clips the verandah post here') pg.press('#pc-note-text', 'Enter') check(pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'Enter saved — 1 note stored') check(pg.evaluate("() => document.getElementById('pc-note').style.display") == 'none', 'overlay closed on save') # Esc cancels — and cancels means NOT saved with pg.expect_download(): pg.keyboard.press('F8') pg.wait_for_selector('#pc-note', state='visible', timeout=5000) pg.press('#pc-note-text', 'Escape') check(pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'Esc cancelled — still 1 note') # export → schema-validate field-for-field (the ticket contract) exported = pg.evaluate("() => window.DBG.exportNotes()") try: obj = json.loads(exported) except Exception as e: FAIL(f'export is not JSON: {e}'); b.close(); return check(obj.get('schema') == SCHEMA and isinstance(obj.get('notes'), list) and len(obj['notes']) == 1, f'export envelope: schema {SCHEMA}, 1 note') n = obj['notes'][0] bad = [k for k, t in NOTE_FIELDS.items() if k not in n or not isinstance(n[k], t)] extra = [k for k in n if k not in NOTE_FIELDS] check(not bad and not extra, f'note schema exact: {len(NOTE_FIELDS)} fields typed' + (f' — BAD {bad} EXTRA {extra}' if bad or extra else '')) check(n['text'] == 'the awning clips the verandah post here', 'the typed one-liner round-tripped') check(n['mode'] == 'street' and n['citySeed'] == SEED and n['draws'] > 0 and all(isinstance(n['pos'][k], (int, float)) for k in ('x', 'y', 'z')), f"context captured: mode {n['mode']} · seed {n['citySeed']} · {n['draws']} draws · pos ✓ · seg {n['seg']} {n['clock']}") check(n['shot'] == name, 'note links its screenshot filename') note(f"street {n['street']!r} · locality {n['locality']!r} · fps {n['fps']} · flags {sum(n['flags'].values())} on") # reload → persisted (delta law: survives, bounded, same note) pg.reload() boot(pg, 'dbg=1') pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000) check(pg.evaluate("() => window.PROCITY.playtest.count") == 1 and pg.evaluate("() => window.PROCITY.playtest.list()[0].id") == n['id'], 'reload: the note survived, same id') # import-reject leaves current notes untouched; corrupt store rejected WHOLE on next boot ok_rej = pg.evaluate("() => window.PROCITY.playtest.import('{\"schema\":\"procity-playtest/1\",\"notes\":[{\"bad\":1}]}')") check(ok_rej is False and pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'import(): malformed note REJECTED, current notes untouched') pg.evaluate("() => localStorage.setItem('procity-playtest', '{\"schema\":\"nope\",\"notes\":[]}')") pg.reload() boot(pg, 'dbg=1') pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000) check(pg.evaluate("() => window.PROCITY.playtest.count") == 0, 'corrupt store on boot: REJECTED WHOLE, empty start') check(any('REJECTED' in c for c in cons), 'the reject is LOUD (console names it)') # F8 INSIDE A SHOP — the interiorMode.renderFrame arm, and the no-eject guard (noteActive joins # the digActive/sellActive unlock contract: taking a note must not walk you out the door). pg.evaluate("() => window.DBG.setSegment(2)") pg.evaluate("() => window.DBG.enterShop('record')") pg.wait_for_function("() => window.PROCITY.mode === 'interior'", timeout=10000) pg.wait_for_timeout(400) with pg.expect_download(): pg.keyboard.press('F8') pg.wait_for_selector('#pc-note', state='visible', timeout=5000) pg.press('#pc-note-text', 'Enter') ni = pg.evaluate("() => window.PROCITY.playtest.list()[0]") check(ni['mode'] == 'interior' and bool(ni['shopName']) and ni['draws'] > 0, f"interior note: mode {ni['mode']} · shop {ni['shopName']!r} (id {ni['shopId']}) · {ni['draws']} draws") check(pg.evaluate("() => window.PROCITY.mode") == 'interior', 'F8 did NOT eject the player from the shop') check(not errs or all('REJECTED' in e for e in errs), '0 unexplained console errors on the F8 arm') b.close() def gate_tour(p): head('?bugtour=1 — N advances through every stop, zero console errors, DBG loads without ?dbg=1') b, pg, errs, _, _ = new_page(p) boot(pg, 'bugtour=1', wait_dbg=True) # no ?dbg=1 — the shell must load DBG for the tour itself OK('DBG present under ?bugtour=1 alone') pg.wait_for_function('() => window.PROCITY.playtest && window.PROCITY.playtest.tour', timeout=15000) t0 = pg.evaluate("() => window.PROCITY.playtest.tour") total = t0['total'] check(t0['active'] and t0['index'] == -1, f'tour armed at the intro caption ({total} stops)') seen = [] for i in range(total): pg.keyboard.press('n') pg.wait_for_function(f"() => window.PROCITY.playtest.tour.index === {i}", timeout=20000) st = pg.evaluate("() => window.PROCITY.playtest.tour") cap = pg.evaluate("() => (document.getElementById('pc-bugtour')||{}).innerText || ''") okc = st['stopId'] is not None and f'{i + 1}/{total}' in cap seen.append(st['stopId']) if not okc: FAIL(f'stop {i}: id {st["stopId"]} caption {cap[:60]!r}') pg.wait_for_timeout(150) check(len(seen) == total and all(seen), f'advanced through all {total} stops: {" → ".join(seen)}') for req in ('spawn', 'street_day', 'shop_buy', 'dig', 'opshop_bin', 'gig_night', 'tram', 'rain', 'night', 'fog_map', 'second_town'): if req not in seen: FAIL(f'required stop missing from the tour: {req}') pg.keyboard.press('n') pg.wait_for_function("() => window.PROCITY.playtest.tour.done === true", timeout=10000) OK('one more N — tour completes') # F8 works mid-tour (the point of the whole harness) with pg.expect_download(): pg.keyboard.press('F8') pg.wait_for_selector('#pc-note', state='visible', timeout=5000) pg.press('#pc-note-text', 'Enter') check(pg.evaluate("() => window.PROCITY.playtest.count") >= 1, 'F8 drops a note mid-tour') check(not errs, f'0 console errors across the whole tour ({len(errs)} seen)' + (f' — {errs[:3]}' if errs else '')) b.close() def main(): try: from playwright.sync_api import sync_playwright except ImportError: sys.exit('playwright not installed — tools/.venv/bin/pip install playwright') srv = serve(ROOT / 'web', PORT) try: with sync_playwright() as p: gate_off(p) gate_f8(p) gate_tour(p) finally: srv.terminate() print() if fails: print(f'\033[31mRED — {len(fails)} arm(s) failed\033[0m') for f in fails: print(f' · {f}') sys.exit(1) print('\033[32mGREEN — the playtest harness holds (off byte-identical · F8 round-trips · the tour walks)\033[0m') if __name__ == '__main__': main()