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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user