position-circle tooltips: red FT spec (skipped) — gate-position circles get rich .tt-pos-* tooltips + CARTE seat-switch + per-seat SIG — TDD
Outer-loop FTs authored today; implementation lands tomorrow. Both classes are @skip-ped so the red spec rides into the repo without breaking the FT CI stage (we just rescued that pipeline); tomorrow's work removes the skip per-method as each behavior goes green. Spec encoded (user-spec 2026-06-01): - gate-position circles (1–6) gain rich hover tooltips mirroring the My Buds bud tooltip, on EVERY surface — initial gatekeeper, above the hex, AND the new GATE VIEW gate-view (room_gate.html renders no circles today: the headline red) - tooltip: @handle (.tt-title), title (.tt-description), NO email, a top-right .tt-sign stack of the SEAT significator (TableSeat. significator — per-seat, user-decided), bud shoptalk when the occupant is a bud, # tokens deposited (CARTE slots_claimed else 1), .tt-expiry (GateSlot.cost_current_until) - state classes: .tt-pos-empty / .tt-pos-gamer / .tt-pos-gamer.tt-pos-bud / .tt-pos-me-current / .tt-pos-me-also (renamed from -me-other per user). .tt-pos-me-also carries a ?seat=<n> switch href to load that seat's view (preview pos-4 ROLE state w. the .fa-ban atop the deck, or SAVE SIG per seat during Sig Select) - per-seat SIG: today SigReservation is per-(room,gamer) — the FT pins per-SEAT sig so a CARTE gamer picks a different sig per seat (tomorrow's green = SigReservation rework) FTs: PositionTooltipTest (8 — circle render on gate-view, me-current / gamer / bud+shoptalk / no-email / tokens+expiry / seat-sign / hover- portal) + CarteSeatSwitchTest (4 — me-also switch href, carte token count, ?seat= loads seat ROLE view, per-seat sig). game_room bucket. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
270
src/functional_tests/test_game_room_position_tooltips.py
Normal file
270
src/functional_tests/test_game_room_position_tooltips.py
Normal file
@@ -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=<n>`
|
||||
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=<n>` 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)
|
||||
Reference in New Issue
Block a user