diff --git a/src/functional_tests/test_game_room_position_tooltips.py b/src/functional_tests/test_game_room_position_tooltips.py new file mode 100644 index 0000000..4e99040 --- /dev/null +++ b/src/functional_tests/test_game_room_position_tooltips.py @@ -0,0 +1,270 @@ +"""Position-circle tooltip FTs — RED spec authored 2026-06-01, implemented +tomorrow ([[sprint-position-circle-tooltips]]). + +The numbered gate-position circles (1–6) gain rich hover tooltips mirroring +the My Buds bud tooltip, on EVERY surface they appear: the initial gatekeeper, +above the table hex during role/sig select, AND the new GATE VIEW gate-view +(`room_gate.html`), which today renders no circles at all. + +Per-circle tooltip content: + • `.tt-title` = the occupant's @handle (NO email field) + • `.tt-description` = the occupant's active title + • top-right `.tt-sign` stack = the occupant's SEAT significator (rank + suit + icon — `TableSeat.significator`, per user-spec 2026-06-01), pinned like + `.tt-price` + • the bud's shoptalk (`BudshipNote.shoptalk`) when the occupant is a bud + • the number of tokens deposited (CARTE `slots_claimed`, else 1) + • `.tt-expiry` of the deposit (`GateSlot.cost_current_until`) + +State class on the circle: + • `.tt-pos-empty` — empty slot + • `.tt-pos-gamer` — another gamer + • `.tt-pos-gamer.tt-pos-bud` — another gamer who is also a bud (+ shoptalk) + • `.tt-pos-me-current` — the viewer's own currently-occupied position + • `.tt-pos-me-also` — the viewer's own position they don't occupy now + (CARTE multi-seat). Also carries a `?seat=` + switch href to load that seat's view, so a + CARTE gamer can preview pos-4's ROLE view + (`.fa-ban` atop the deck) or SAVE SIG per seat + during Sig Select. + +Written RED — the feature lands tomorrow. FT bucket: game_room. + +Both classes are `@skip`-ped so this red spec rides into the repo WITHOUT +breaking CI (the FT stage runs `functional_tests`). Tomorrow's implementation +removes the skip per-method as each behavior goes green. +""" +from unittest import skip + +from django.utils import timezone +from selenium.webdriver.common.by import By + +from .base import FunctionalTest +from .room_page import _assign_all_roles, _equip_earthman_deck, _fill_room_via_orm +from apps.billboard.models import BudshipNote +from apps.epic.models import GateSlot, Room, TableSeat, TarotCard +from apps.lyric.models import Token, User + + +_RED = ("RED spec — position-circle tooltips land 2026-06-02 " + "([[project-position-circle-tooltips]]); remove the skip per-method " + "as each behavior goes green.") + + +def _gate_view_url(self, room): + return self.live_server_url + f"/gameboard/room/{room.id}/gate/view/" + + +@skip(_RED) +class PositionTooltipTest(FunctionalTest): + """Tooltip CONTENT + state classes on the gate-position circles, exercised + on the new GATE VIEW gate-view (the clearest red surface — it renders no + circles today).""" + + def setUp(self): + super().setUp() + # Viewer (slot 1) — give a username so @handle is stable, not email-derived. + self.viewer = User.objects.create(email="disco@test.io", username="disco") + _equip_earthman_deck(self.viewer) + self.room = Room.objects.create(name="Whataburgher", owner=self.viewer) + self.gamers = _fill_room_via_orm( + self.room, + ["disco@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io"], + ) + # Stamp filled_at so cost_current_until (the .tt-expiry) is real. + self.room.gate_slots.filter(status=GateSlot.FILLED).update( + filled_at=timezone.now(), debited_token_type=Token.FREE, + ) + self.room.table_status = Room.ROLE_SELECT # gate-view reachable mid-game + self.room.gate_status = Room.OPEN + self.room.save() + + def _open_gate_view(self): + self.create_pre_authenticated_session("disco@test.io") + self.browser.get(_gate_view_url(self, self.room)) + return self.wait_for( + lambda: self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") + ) + + def test_gate_view_renders_six_position_circles(self): + # room_gate.html renders NO circles today — this is the headline red. + circles = self._open_gate_view() + self.assertEqual(len(circles), 6) + + def test_own_occupied_seat_is_pos_me_current(self): + self._open_gate_view() + me = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='1']") + self.assertIn("tt-pos-me-current", me.get_attribute("class")) + + def test_other_gamer_circle_is_pos_gamer_with_handle_title_no_email(self): + self._open_gate_view() + other = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']") + cls = other.get_attribute("class") + self.assertIn("tt-pos-gamer", cls) + self.assertNotIn("tt-pos-me", cls) + # Tooltip data rides on the circle (the My Buds data-tt-* pattern). + self.assertIn("@", other.get_attribute("data-tt-title")) # @handle + self.assertTrue(other.get_attribute("data-tt-description")) # title + # No email anywhere in the tooltip payload (user-spec). + self.assertNotIn("@test.io", other.get_attribute("data-tt-title")) + self.assertIsNone(other.get_attribute("data-tt-email")) + + def test_bud_occupant_circle_is_pos_bud_and_carries_shoptalk(self): + # Make slot-2's occupant a bud of the viewer, with shoptalk. + amigo = self.gamers[1] + self.viewer.buds.add(amigo) + BudshipNote.objects.create( + user=self.viewer, bud=amigo, shoptalk="met at the deli", + ) + self._open_gate_view() + circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']") + cls = circle.get_attribute("class") + self.assertIn("tt-pos-gamer", cls) + self.assertIn("tt-pos-bud", cls) + self.assertEqual(circle.get_attribute("data-tt-shoptalk"), "met at the deli") + + def test_deposit_count_and_expiry_present(self): + self._open_gate_view() + circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']") + # 1 token deposited (non-CARTE) + a real expiry (cost_current_until). + self.assertEqual(circle.get_attribute("data-tt-tokens"), "1") + self.assertTrue(circle.get_attribute("data-tt-expiry")) + + def test_seat_significator_shows_in_sign_stack(self): + # Assign a significator to seat 2 and assert its rank rides the tooltip. + _assign_all_roles(self.room) # creates TableSeats + advances to SIG_SELECT + seat2 = TableSeat.objects.get(room=self.room, slot_number=2) + sig = TarotCard.objects.filter( + deck_variant=self.viewer.equipped_deck, arcana="MIDDLE", + ).first() + seat2.significator = sig + seat2.save(update_fields=["significator"]) + self.room.table_status = Room.ROLE_SELECT # gate-view circles visible + self.room.save() + self._open_gate_view() + circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']") + self.assertEqual( + circle.get_attribute("data-tt-sign-rank"), str(sig.corner_rank)) + + def test_hover_populates_position_tooltip_portal(self): + # The portal mirrors the My Buds portal: hover a circle → #id_position_ + # tooltip_portal fills + shows the occupant's @handle, NOT their email. + self._open_gate_view() + circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']") + self.browser.execute_script( + "arguments[0].dispatchEvent(new MouseEvent('mouseenter', {bubbles:true}))", + circle, + ) + portal = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_position_tooltip_portal") + ) + self.assertIn("active", portal.get_attribute("class")) + title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text + self.assertTrue(title.startswith("@")) + self.assertNotIn("@test.io", portal.text) + + +@skip(_RED) +class CarteSeatSwitchTest(FunctionalTest): + """A CARTE gamer occupies multiple positions. Their non-current owned + circles read `.tt-pos-me-also` and carry a `?seat=` switch href that + loads that seat's view — to preview pos-N's ROLE-select state (the `.fa-ban` + atop the deck) or to SAVE SIG per seat during Sig Select.""" + + def setUp(self): + super().setUp() + self.viewer = User.objects.create(email="disco@test.io", username="disco") + _equip_earthman_deck(self.viewer) + self.room = Room.objects.create(name="Carte Room", owner=self.viewer) + # CARTE: the viewer owns ALL six slots. + _fill_room_via_orm( + self.room, ["disco@test.io"] * 6, # get_or_create → same viewer in all + ) + self.room.gate_slots.update( + gamer=self.viewer, status=GateSlot.FILLED, + filled_at=timezone.now(), debited_token_type=Token.CARTE, + ) + carte = Token.objects.create( + user=self.viewer, token_type=Token.CARTE, + current_room=self.room, slots_claimed=6, + ) + self.carte = carte + self.room.gate_status = Room.OPEN + self.room.save() + + def test_own_other_seat_is_me_also_with_switch_href(self): + self.create_pre_authenticated_session("disco@test.io") + # During ROLE_SELECT the viewer "acts as" their lowest seat; the others + # are their own seats they don't currently occupy → me-also + switch. + _assign_all_roles(self.room) + self.room.table_status = Room.ROLE_SELECT + # un-assign roles so the card-stack/.fa-ban preview is live + self.room.table_seats.update(role=None, role_revealed=False) + self.room.save() + self.browser.get(self.live_server_url + f"/gameboard/room/{self.room.id}/") + also = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".gate-slot[data-slot='4'].tt-pos-me-also") + ) + href = (also.get_attribute("href") + or also.find_element(By.CSS_SELECTOR, "a").get_attribute("href")) + self.assertIn("seat=4", href) + + def test_tokens_deposited_reflects_carte_slots_claimed(self): + self.create_pre_authenticated_session("disco@test.io") + self.room.table_status = Room.ROLE_SELECT + self.room.save() + self.browser.get(self.live_server_url + f"/gameboard/room/{self.room.id}/gate/view/") + circle = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='1']") + ) + self.assertEqual(circle.get_attribute("data-tt-tokens"), "6") + + def test_switching_seat_loads_that_seats_role_view(self): + # Clicking the me-also seat-4 circle loads ?seat=4 and the card-stack + # reflects seat 4 (a non-active seat → ineligible / .fa-ban atop deck). + self.create_pre_authenticated_session("disco@test.io") + _assign_all_roles(self.room) + self.room.table_status = Room.ROLE_SELECT + self.room.table_seats.update(role=None, role_revealed=False) + self.room.save() + self.browser.get( + self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=4") + self.wait_for( + lambda: self.assertIn( + "seat=4", self.browser.current_url)) + # Seat 4 is not the active turn → card-stack ineligible w/ the fa-ban. + stack = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")) + self.assertEqual(stack.get_attribute("data-active-slot"), "4") + stack.find_element(By.CSS_SELECTOR, ".fa-ban") + + def test_carte_saves_a_significator_per_seat(self): + # Sig Select: the viewer saves a sig on PC (seat 1), switches to seat 2, + # and saves a DIFFERENT sig there — proving per-seat (not per-gamer) sig. + self.create_pre_authenticated_session("disco@test.io") + _assign_all_roles(self.room) # roles + advance to SIG_SELECT + self.room.refresh_from_db() + self.assertEqual(self.room.table_status, Room.SIG_SELECT) + # Seat 1 (PC) — pick its sig + self.browser.get( + self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=1") + card1 = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_sig_deck [data-card-id]")) + card1.click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card.reserved")) + # Switch to seat 2 — its sig pick is independent of seat 1's. + self.browser.get( + self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=2") + deck2 = self.wait_for( + lambda: self.browser.find_elements( + By.CSS_SELECTOR, "#id_sig_deck [data-card-id]")) + self.assertTrue(deck2) + # Each seat ends up with its own significator (per-seat, not shared). + seat1 = TableSeat.objects.get(room=self.room, slot_number=1) + seat2 = TableSeat.objects.get(room=self.room, slot_number=2) + self.assertIsNotNone(seat1.significator_id) + self.assertNotEqual(seat1.significator_id, seat2.significator_id)