Files
BT411/scratchpad/test_operator_roster.py
T
arcattackandClaude Opus 5 820caf8765 console review follow-up: two edge cases in my own roster fix, found and fixed
Self-review of 3a69448 (plus an adversarial pass still in flight) turned up two
real defects in the work I just shipped:

1. THE STASH LIFECYCLE LEAKED ACROSS SESSIONS AND EGGS.  _seat_defaults was
   keyed by ROW INDEX and initialised once in __init__ -- never cleared on Start
   Session, on Open/New egg (which rebuilds the table), or on a seat-count
   change.  Concrete harm: stop a session while a seat is OCCUPIED (the stash is
   only popped when a seat empties, so it survives), reconfigure or open a
   different egg, start again -- and when that seat next empties the GUI
   restores LAST SESSION'S snapshot over the new configuration.  Fixed by (a)
   keying the stash by TAG, the seat's actual identity, so a changed roster
   makes stale keys inert instead of landing on whatever row now sits at that
   index, and (b) clearing it at both natural boundaries: session start (both
   local and remote paths -- the clear sits before the remote branch) and the
   table rebuild in _load_egg_into_ui.

2. POSITIONAL hostID->tag MAPPING IS WRONG AFTER A LAUNCH TRIM -- and 3a69448
   widened its blast radius.  The GUI resolves REGISTERED/dropped lines via
   host_tag(), position into the UNTRIMMED egg roster; after a trim remaps seat
   ids (e.g. the lone returning player reclaim-held at seat 3 becomes host 2),
   those lines light the wrong row -- and, with the new seat_info pop on
   dropped, CLEAR the wrong seat's identity, leaving the real one's name up:
   the exact reported bug, resurfacing in the trimmed case.  Fixed on both
   sides: the relay's REGISTERED and dropped prints now carry tag='...' from
   its LIVE (possibly trimmed) roster via a new _tag_of() helper, and the GUI
   regexes prefer the explicit tag with the positional fallback kept for old
   logs.  SEATED/READY/LEFT already carried tags; only these two were
   positional.

VERIFIED
  * test_operator_roster.py grown to 22 checks, all passing: post-trim lines
    light and clear the RIGHT row while the wrong row stays untouched; the
    legacy no-tag format still resolves positionally; a stopped-while-occupied
    session cannot leak GHOST into the next session's empty seat; a stale
    foreign-tag stash never touches a live row.
  * Live 2-pod rig: both new formats confirmed on the wire
    (REGISTERED (1/2) tag='...' / dropped: closed tag='...'), mission launched
    and stopped cleanly, round RESET intact.  rearm 19/19, net 17/17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:54:13 -05:00

156 lines
6.7 KiB
Python

"""Roster-display test: a departed player must not keep their seat (2026-07-26).
Operator report: "if a player disconnects from the console, it leaves their name
up in the roster ... it should turn their seat back to empty."
Drives the REAL Qt widget offscreen and feeds it the REAL relay log lines, then
asserts what the roster table shows. No relay, no game, no visible window.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
REPO = r"C:\git\bt411"
sys.path.insert(0, os.path.join(REPO, "tools"))
fails = []
def check(label, ok, extra=""):
print(" %-58s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra))
if not ok:
fails.append(label)
from PySide6.QtWidgets import QApplication # noqa: E402
import btoperator # noqa: E402
app = QApplication.instance() or QApplication([])
win = btoperator.Operator()
# Relay mode with a fresh monitor over the roster the UI actually built.
tags = []
for r in range(win.table.rowCount()):
tags.append(win.table.item(r, 0).text().strip())
win.monitor = btoperator.SessionMonitor(True, tags)
print(" roster rows: %d first tag: %s" % (len(tags), tags[0] if tags else "-"))
def callsign(row):
it = win.table.item(row, 1)
return it.text() if it is not None else None
def light(row):
return win.monitor.state.get(tags[row])
def feed(line):
win.monitor.feed(line)
win._refresh_pod_status()
configured = callsign(0)
print(" row 0 configured callsign: %r" % configured)
print("\n=== a walk-up player takes seat 1 ===")
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='SAURON' mech='lok2'"
% tags[0])
check("roster shows the player's callsign", callsign(0) == "SAURON", callsign(0))
check("pilot light is 'seated'", light(0) == btoperator.ST_SEATED, str(light(0)))
print("\n=== they register, then DISCONNECT (the reported case) ===")
feed("[relay] game[1.2.3.4:5555 host=2] REGISTERED (1/2)")
check("light is 'registered'", light(0) == btoperator.ST_REGISTERED, str(light(0)))
feed("[relay] game[1.2.3.4:5555 host=2] dropped: closed")
check("pilot light returns to 'waiting'", light(0) == btoperator.ST_IDLE,
str(light(0)))
check("SEAT NO LONGER SHOWS THE DEPARTED NAME", callsign(0) != "SAURON",
callsign(0))
check("seat restored to its configured pilot", callsign(0) == configured,
callsign(0))
print("\n=== the seat is reusable: a DIFFERENT player takes it ===")
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='NEWGUY' mech='thor'"
% tags[0])
check("the new player's callsign shows", callsign(0) == "NEWGUY", callsign(0))
feed("[relay] game[9.9.9.9:1 host=2] dropped: closed")
check("and it clears again for them too", callsign(0) == configured, callsign(0))
print("\n=== the other clearing path: 'PLAYER n LEFT' (never claimed) ===")
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='WALKUP' mech='owens'"
% tags[0])
check("occupied again", callsign(0) == "WALKUP", callsign(0))
feed("[relay] PLAYER 1 LEFT (host 2) tag='%s' (0 seated)" % tags[0])
check("LEFT also clears the seat", callsign(0) == configured, callsign(0))
check("light is 'waiting'", light(0) == btoperator.ST_IDLE, str(light(0)))
print("\n=== an operator edit made while the seat is EMPTY must survive ===")
win.table.item(0, 1).setText("MY_DEFAULT")
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='VISITOR' mech='lok2'"
% tags[0])
check("visitor's name shows while seated", callsign(0) == "VISITOR", callsign(0))
feed("[relay] game[7.7.7.7:7 host=2] dropped: closed")
check("reverts to the operator's NEW edit, not the old one",
callsign(0) == "MY_DEFAULT", callsign(0))
print("\n=== an untouched seat is never rewritten ===")
if len(tags) > 1:
before = callsign(1)
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='X' mech='lok2'"
% tags[0])
check("row 1 untouched by row 0 traffic", callsign(1) == before,
"%r vs %r" % (callsign(1), before))
print("\n=== POST-TRIM: explicit tag beats the positional fallback ===")
# After a LAUNCH trim the relay remaps seat ids: host 2 can be roster position 3.
# The relay now prints tag='...' on REGISTERED/dropped; the monitor must use it.
if len(tags) > 1:
# relay says host 2, but the tag names row 1 -- the trimmed truth
feed("[relay] PLAYER 2 SEATED (host 2) tag='%s' callsign='TRIMMED' mech='thor'"
% tags[1])
feed("[relay] game[5.5.5.5:5 host=2] REGISTERED (1/1) tag='%s'" % tags[1])
check("explicit tag lights the RIGHT row", light(1) == btoperator.ST_REGISTERED,
str(light(1)))
check("row 0 not falsely lit", light(0) != btoperator.ST_REGISTERED,
str(light(0)))
feed("[relay] game[5.5.5.5:5 host=2] dropped: closed tag='%s'" % tags[1])
check("explicit tag clears the RIGHT row", light(1) == btoperator.ST_IDLE,
str(light(1)))
check("the departed name cleared on the right row too",
callsign(1) != "TRIMMED", callsign(1))
# old-format line (no tag=): the positional fallback must still work
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='OLDFMT' mech='lok2'"
% tags[0])
feed("[relay] game[5.5.5.5:5 host=2] REGISTERED (1/1)")
check("legacy line (no tag) still resolves positionally",
light(0) == btoperator.ST_REGISTERED, str(light(0)))
feed("[relay] game[5.5.5.5:5 host=2] dropped: closed")
print("\n=== stash survives NOTHING it should not: stopped-while-occupied ===")
# A session stopped while a seat is occupied never pops the stash. The next
# session must not restore last session's snapshot -- _start_session clears it.
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='GHOST' mech='lok2'"
% tags[0])
check("occupied at 'session stop'", callsign(0) == "GHOST", callsign(0))
# simulate the parts of _start_session that matter (no relay spawn)
win._seat_defaults.clear()
win.monitor = btoperator.SessionMonitor(True, tags)
win.table.item(0, 1).setText("FRESH_CFG") # operator reconfigures
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='NEWSESSION' mech='thor'"
% tags[0])
feed("[relay] game[8.8.8.8:8 host=2] dropped: closed")
check("post-restart empty seat shows the NEW config, not GHOST",
callsign(0) == "FRESH_CFG", callsign(0))
print("\n=== stash is TAG-keyed: an egg reload makes old keys inert ===")
win._seat_defaults["10.99.9.9:9999"] = ("STALE", "lok2") # a tag not in this roster
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='ABC' mech='lok2'" % tags[0])
feed("[relay] game[8.8.8.8:8 host=2] dropped: closed")
check("a stale foreign-tag stash never touches a live row",
callsign(0) == "FRESH_CFG", callsign(0))
print("\n%s" % ("ALL CHECKS PASSED" if not fails else "FAILURES: %s" % fails))
sys.exit(1 if fails else 0)