position-circle tooltips: adversarial-review fixes — drop email leak, hide-on-hover-transition, surface #tokens, room_gate tooltip-only, N+1 hoist + specificity hardening — TDD
Follow-up to the position-circle tooltips sprint, addressing confirmed findings from a multi-agent adversarial review of the diff:
- Email leak (privacy): the hidden .slot-gamer span rendered the raw login email into DOM source on every filled circle — widened to room_gate this sprint. Now renders {{ gamer|at_handle }}; new IT asserts no occupant email anywhere in the page source.
- Stale hover state (position-tooltip.js): moving circle→circle accumulated .tt-pos-* classes on the portal (prior set never stripped), and circle→empty left the prior tooltip stranded. Now _hide() before _show() on every transition.
- Dead #tokens plumbing: data-tt-tokens was computed + rendered but never displayed. Surfaced as a .tt-tokens line in the portal.
- room_gate gather forms: the merged _gate_context let a CARTE owner drop/release gate slots from the renewal gate-view. Zeroed carte_next/nvm/is_last_slot so it's tooltip-only; new IT asserts no drop/release forms.
- N+1: hoisted the per-CARTE-slot token lookup into one carte_claims map; added select_related(significator) on seats + select_related(gamer) on gate_slots.
- SIG_SELECT seat override now gated on an EXPLICIT ?seat (no-param falls back to the canonical PC seat, not the lowest gate slot, so every SIG_SELECT surface agrees).
- Dropped dead is_self/is_bud dict keys (kept the locals + is_me_also).
- room-gate pointer-events override doubled to .room-gate-page.room-gate-page → (0,4,1), no longer a source-order tie with the (0,3,1) suppressor.
Tests: 11 position-tooltip FTs green (no skips); +2 ITs (no-email-in-source, room_gate-tooltip-only); full suite 1604 green. Deferred (noted in memory): in-UI seat switcher during SIG_SELECT, NVM-between-seats 409, gate_status/sea_partial enrichment split.
[[project-position-circle-tooltips]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -215,7 +215,10 @@ def _gate_positions(room, user=None, current_slot=None):
|
||||
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
|
||||
# of which role each gamer chose — so use count, not role matching.
|
||||
assigned_count = room.table_seats.exclude(role__isnull=True).count()
|
||||
seats_by_slot = {s.slot_number: s for s in room.table_seats.all()}
|
||||
seats_by_slot = {
|
||||
s.slot_number: s
|
||||
for s in room.table_seats.select_related("significator").all()
|
||||
}
|
||||
authed = getattr(user, "is_authenticated", False)
|
||||
bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set()
|
||||
shoptalk_map = {}
|
||||
@@ -225,8 +228,15 @@ def _gate_positions(room, user=None, current_slot=None):
|
||||
bn.bud_id: bn.shoptalk
|
||||
for bn in BudshipNote.objects.filter(user=user)
|
||||
}
|
||||
# One CARTE-token-per-gamer map, hoisted out of the loop — a CARTE gamer
|
||||
# owns ONE token (carrying slots_claimed) but claims many slots, so the
|
||||
# per-slot lookup was up to 6 identical queries.
|
||||
carte_claims = {
|
||||
t.user_id: t.slots_claimed
|
||||
for t in Token.objects.filter(token_type=Token.CARTE, current_room=room)
|
||||
}
|
||||
positions = []
|
||||
for slot in room.gate_slots.order_by("slot_number"):
|
||||
for slot in room.gate_slots.select_related("gamer").order_by("slot_number"):
|
||||
gamer = slot.gamer
|
||||
seat = seats_by_slot.get(slot.slot_number)
|
||||
is_self = authed and gamer is not None and gamer.id == user.id
|
||||
@@ -244,20 +254,16 @@ def _gate_positions(room, user=None, current_slot=None):
|
||||
else:
|
||||
state_class = "tt-pos-gamer"
|
||||
# Deposited-token count — CARTE claims many slots on one token.
|
||||
tokens = 1
|
||||
if gamer is not None and slot.debited_token_type == Token.CARTE:
|
||||
carte = gamer.tokens.filter(
|
||||
token_type=Token.CARTE, current_room=room
|
||||
).first()
|
||||
tokens = carte.slots_claimed if carte else 1
|
||||
tokens = carte_claims.get(gamer.id, 1)
|
||||
else:
|
||||
tokens = 1
|
||||
sig = seat.significator if seat else None
|
||||
positions.append({
|
||||
"slot": slot,
|
||||
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
|
||||
"role_assigned": slot.slot_number <= assigned_count,
|
||||
"state_class": state_class,
|
||||
"is_self": is_self,
|
||||
"is_bud": is_bud,
|
||||
"is_me_also": is_me_also,
|
||||
"shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
|
||||
"tokens": tokens,
|
||||
@@ -514,9 +520,11 @@ def _role_select_context(room, user, seat_param=None):
|
||||
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||
# CARTE seat-switch (?seat=N): a multi-seat owner picks a sig per seat,
|
||||
# so the overlay must reflect the SELECTED seat (its role/polarity/deck),
|
||||
# not the canonical PC seat. `current_slot` is the ?seat-resolved owned
|
||||
# slot (falls back to canonical for a one-seat gamer, who never switches).
|
||||
if user.is_authenticated and current_slot is not None:
|
||||
# not the canonical PC seat. Gate on an EXPLICIT ?seat — with no param,
|
||||
# `current_slot` is merely the lowest owned GATE slot, which need not be
|
||||
# the canonical PC seat (roles aren't slot-ordered); keep the canonical
|
||||
# seat in that case so every SIG_SELECT surface (incl. my_tray_sig) agrees.
|
||||
if seat_param and user.is_authenticated and current_slot is not None:
|
||||
_seat_override = room.table_seats.filter(
|
||||
gamer=user, slot_number=current_slot
|
||||
).first()
|
||||
@@ -665,6 +673,13 @@ def room_gate(request, room_id):
|
||||
"cost_current": user_slot.cost_current if user_slot else True,
|
||||
"deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(),
|
||||
"page_class": "page-gameboard page-room page-room-gate",
|
||||
# The renewal gate-view shows the circles for their rich hover tooltips
|
||||
# ONLY — it is not a gather surface. Zero the CARTE drop/release form
|
||||
# triggers so _table_positions renders no OK/NVM/PICK ROLES buttons here
|
||||
# (renewal happens via the modal's own token rails).
|
||||
"carte_next_slot_number": None,
|
||||
"carte_nvm_slot_number": None,
|
||||
"is_last_slot": False,
|
||||
})
|
||||
return render(request, "apps/gameboard/room_gate.html", ctx)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user