position circles: rich .tt-pos-* hover tooltips on the gate-view + table circles — @handle/title/seat-sig/shoptalk/#tokens/expiry portal + CARTE me-also ?seat switch href — TDD

Workstream A of the position-circle tooltips sprint (green; B/C ride @skip-ped).

The numbered gate-position circles (1-6) gain rich hover tooltips mirroring the My Buds bud tooltip on every surface — and now render on room_gate.html (the GATE VIEW), which showed no circles before (the headline gap).

- _gate_positions(room, user, current_slot): per-circle .tt-pos-* state class (empty / gamer / gamer+bud / me-current / me-also) + data-tt-* payload (@handle via at_handle NOT email, title, seat significator rank/suit, bud shoptalk, deposited #tokens [CARTE slots_claimed else 1], seat-clock cost_current_until expiry). _viewer_current_slot resolves the viewer's acting seat (?seat override or canonical) to split me-current vs me-also.
- room_gate view merges _gate_context so _table_positions renders there; room_view threads ?seat into _role_select_context.
- _table_positions.html: .tt-pos-* appended AFTER role-assigned (keeps the 'gate-slot filled role-assigned' substring + class-before-data-slot regex intact for RoleSelectRenderingTest), data-tt-* attrs, me-also ?seat switch anchor.
- #id_position_tooltip_portal (page-root, position:fixed) + position-tooltip.js (hover/clamp/union-hide modeled on tray-tooltip.js); .tt-sign rank+suit stack; .tt-pos-* circle accents; room-gate pointer-events re-enable.

Tests: 7 PositionTooltipTest + 2 CarteSeatSwitchTest (tokens, me-also href) FTs green; 8 fast render-level ITs (PositionTooltip{,Carte}RenderTest); full suite 1598 green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-01 12:13:09 -04:00
parent 19471662ff
commit 30246cc94a
10 changed files with 509 additions and 17 deletions

View File

@@ -598,6 +598,154 @@ class RoleSelectRenderingTest(TestCase):
self.assertNotIn("fa-circle-check", nc_seat_chunk)
def _circle_start(content, slot_number):
"""Index of the gate-slot circle's opening `<div` for the given slot.
Scopes to `.gate-slot` (room.html also renders `.table-seat` data-slot=N
elements first, so a bare data-slot search would hit the seat, not the
circle)."""
needle = f'data-slot="{slot_number}"'
pos = 0
while True:
idx = content.index('<div class="gate-slot', pos)
end = content.index(">", idx)
if needle in content[idx:end]:
return idx
pos = end
def _circle_tag(content, slot_number):
"""Return the opening `<div ...>` tag of the gate-slot circle for the
given slot — class + every data-tt-* attr live on this one tag."""
idx = _circle_start(content, slot_number)
return content[idx:content.index(">", idx)]
class PositionTooltipRenderTest(TestCase):
"""Render-level coverage for the rich position-circle tooltip payload
(sprint 2026-06-02) — the fast IT counterpart to the Selenium
PositionTooltipTest in functional_tests/test_game_room_position_tooltips.py.
Exercised on the GATE VIEW (room_gate), which rendered no circles before
this sprint."""
def setUp(self):
from apps.epic.models import DeckVariant
self.viewer = User.objects.create(email="disco@test.io", username="disco")
self.deck, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
self.viewer.equipped_deck = self.deck
self.viewer.save(update_fields=["equipped_deck"])
self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
self.gamers = [self.viewer]
for i in range(2, 7):
self.gamers.append(
User.objects.create(email=f"g{i}@test.io", username=f"g{i}")
)
for i, gamer in enumerate(self.gamers, start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.filled_at = timezone.now()
slot.debited_token_type = Token.FREE
slot.save()
self.room.gate_status = Room.OPEN
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
self.client.force_login(self.viewer)
def _gate_content(self):
return self.client.get(self.gate_url).content.decode()
def test_gate_view_renders_six_position_circles(self):
content = self._gate_content()
self.assertContains(self.client.get(self.gate_url), "position-strip")
self.assertEqual(content.count('class="gate-slot'), 6)
def test_own_slot_is_me_current_others_are_gamer(self):
content = self._gate_content()
self.assertIn("tt-pos-me-current", _circle_tag(content, 1))
slot2 = _circle_tag(content, 2)
self.assertIn("tt-pos-gamer", slot2)
self.assertNotIn("tt-pos-me", slot2)
def test_other_gamer_handle_in_title_not_email(self):
slot2 = _circle_tag(self._gate_content(), 2)
self.assertIn('data-tt-title="@g2"', slot2)
# No email field in the tooltip payload (user-spec).
self.assertNotIn("data-tt-email", slot2)
self.assertIn("data-tt-description", slot2)
def test_bud_occupant_carries_bud_class_and_shoptalk(self):
from apps.billboard.models import BudshipNote
amigo = self.gamers[1]
self.viewer.buds.add(amigo)
BudshipNote.objects.create(user=self.viewer, bud=amigo, shoptalk="met at the deli")
slot2 = _circle_tag(self._gate_content(), 2)
self.assertIn("tt-pos-bud", slot2)
self.assertIn('data-tt-shoptalk="met at the deli"', slot2)
def test_deposit_count_and_expiry_present(self):
slot2 = _circle_tag(self._gate_content(), 2)
self.assertIn('data-tt-tokens="1"', slot2)
self.assertIn("data-tt-expiry=", slot2)
def test_seat_significator_rank_rides_the_circle(self):
sig = TarotCard.objects.create(
deck_variant=self.deck, slug="queen-of-brands-em",
arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
)
TableSeat.objects.create(
room=self.room, gamer=self.gamers[1], slot_number=2, significator=sig,
)
slot2 = _circle_tag(self._gate_content(), 2)
self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)
class PositionTooltipCarteRenderTest(TestCase):
"""CARTE-solo render contract: a single gamer owns all six slots — their
non-current circles read tt-pos-me-also + carry a ?seat=N switch href, and
the deposited count reflects the CARTE token's slots_claimed."""
def setUp(self):
from apps.epic.models import DeckVariant
self.viewer = User.objects.create(email="disco@test.io", username="disco")
self.deck, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
self.viewer.equipped_deck = self.deck
self.viewer.save(update_fields=["equipped_deck"])
self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
self.room.gate_slots.update(
gamer=self.viewer, status=GateSlot.FILLED,
filled_at=timezone.now(), debited_token_type=Token.CARTE,
)
Token.objects.create(
user=self.viewer, token_type=Token.CARTE,
current_room=self.room, slots_claimed=6,
)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.client.force_login(self.viewer)
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
def test_tokens_reflects_carte_slots_claimed(self):
content = self.client.get(self.room_url).content.decode()
self.assertIn('data-tt-tokens="6"', _circle_tag(content, 1))
def test_own_other_seat_is_me_also_with_switch_href(self):
content = self.client.get(self.room_url).content.decode()
slot4 = _circle_tag(content, 4)
self.assertIn("tt-pos-me-also", slot4)
# The switch anchor lands just after the opening tag.
idx = _circle_start(content, 4)
chunk = content[idx:idx + 800]
self.assertIn("seat=4", chunk)
class PickRolesViewTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")