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:
@@ -84,6 +84,8 @@ var PositionTooltip = (function () {
|
||||
_set(".tt-title", title);
|
||||
_set(".tt-description", circle.dataset.ttDescription);
|
||||
_set(".tt-shoptalk", circle.dataset.ttShoptalk);
|
||||
var tokens = circle.dataset.ttTokens;
|
||||
_set(".tt-tokens", tokens ? tokens + (tokens === "1" ? " token" : " tokens") : "");
|
||||
_set(".tt-expiry", _fmtExpiry(circle.dataset.ttExpiry));
|
||||
|
||||
var sign = _portal.querySelector(".tt-sign");
|
||||
@@ -112,6 +114,11 @@ var PositionTooltip = (function () {
|
||||
circle._posTooltipBound = true;
|
||||
circle.addEventListener("mouseenter", function () {
|
||||
if (_activeTrig === circle) return;
|
||||
// Clear any prior tooltip BEFORE showing the next — otherwise
|
||||
// an A→B move accumulates B's .tt-pos-* classes onto the portal
|
||||
// (A's never get stripped), and an A→empty move leaves A's
|
||||
// tooltip stranded (since _show early-returns for empty slots).
|
||||
_hide();
|
||||
_show(circle);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -702,6 +702,14 @@ class PositionTooltipRenderTest(TestCase):
|
||||
slot2 = _circle_tag(self._gate_content(), 2)
|
||||
self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)
|
||||
|
||||
def test_no_occupant_email_anywhere_in_page_source(self):
|
||||
# The position circles render @handle (at_handle), never the raw login
|
||||
# email — not in the tooltip payload AND not in the hidden .slot-gamer
|
||||
# span. Assert on the FULL response, not just the circle's opening tag.
|
||||
content = self._gate_content()
|
||||
self.assertNotIn(self.gamers[1].email, content) # g2@test.io
|
||||
self.assertNotIn(self.viewer.email, content) # disco@test.io
|
||||
|
||||
|
||||
class PositionTooltipCarteRenderTest(TestCase):
|
||||
"""CARTE-solo render contract: a single gamer owns all six slots — their
|
||||
@@ -762,6 +770,16 @@ class PositionTooltipCarteRenderTest(TestCase):
|
||||
self.assertIn('data-active-slot="1"', content)
|
||||
self.assertIn('data-state="eligible"', content)
|
||||
|
||||
def test_room_gate_renders_circles_but_no_carte_action_forms(self):
|
||||
# The renewal gate-view shows the circles (for their hover tooltips)
|
||||
# but is NOT a gather surface — no CARTE drop/release forms.
|
||||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||||
content = self.client.get(gate_url).content.decode()
|
||||
self.assertIn("position-strip", content)
|
||||
self.assertEqual(content.count('class="gate-slot'), 6)
|
||||
self.assertNotIn("slot-release-btn", content)
|
||||
self.assertNotIn("drop-token-btn", content)
|
||||
|
||||
|
||||
class PickRolesViewTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -228,8 +228,11 @@ body.page-gameboard {
|
||||
.tt-title,
|
||||
.tt-description,
|
||||
.tt-shoptalk,
|
||||
.tt-tokens,
|
||||
.tt-expiry { display: block; }
|
||||
|
||||
.tt-tokens { font-size: 0.75rem; opacity: 0.65; }
|
||||
|
||||
// Seat significator stack — pinned top-right, modeled on the tray sig
|
||||
// card (.fan-corner-rank + fa suit icon) and the .tt-price corner pin.
|
||||
.tt-sign {
|
||||
|
||||
@@ -361,8 +361,10 @@ html:has(.gate-backdrop) .position-strip .gate-slot form,
|
||||
html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: auto; }
|
||||
// The room-gate renewal modal renders its OWN .gate-backdrop, but its
|
||||
// position circles are hover-only (tooltips) and must stay live — re-enable
|
||||
// them (equal specificity to the suppressor above, wins on source order).
|
||||
html .room-gate-page .position-strip .gate-slot { pointer-events: auto; }
|
||||
// them. The doubled `.room-gate-page` makes this (0,4,1) so it UNAMBIGUOUSLY
|
||||
// out-specifies the (0,3,1) suppressor above — not a fragile source-order tie
|
||||
// that a future SCSS reorder could silently flip. [[feedback-scss-import-order-specificity]]
|
||||
html .room-gate-page.room-gate-page .position-strip .gate-slot { pointer-events: auto; }
|
||||
|
||||
.position-strip {
|
||||
position: absolute;
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<span class="tt-title"></span>
|
||||
<span class="tt-description"></span>
|
||||
<span class="tt-shoptalk"></span>
|
||||
<span class="tt-tokens"></span>
|
||||
<span class="tt-expiry"></span>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}{% if pos.state_class %} {{ pos.state_class }}{% endif %}"
|
||||
data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}" data-tt-title="{{ pos.slot.gamer|at_handle }}" data-tt-description="{{ pos.slot.gamer.active_title_display }}" data-tt-shoptalk="{{ pos.shoptalk }}" data-tt-tokens="{{ pos.tokens }}"{% if pos.expiry %} data-tt-expiry="{{ pos.expiry|date:'c' }}"{% endif %}{% if pos.sign_rank %} data-tt-sign-rank="{{ pos.sign_rank }}" data-tt-sign-suit="{{ pos.sign_suit_icon }}"{% endif %}{% endif %}>
|
||||
<span class="slot-number">{{ pos.slot.slot_number }}</span>
|
||||
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
|
||||
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer|at_handle }}{% else %}empty{% endif %}</span>
|
||||
{% if pos.is_me_also %}
|
||||
{# CARTE: the viewer's own seat they aren't currently acting #}
|
||||
{# as — a switch href loads that seat's view (?seat=N). #}
|
||||
|
||||
Reference in New Issue
Block a user