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:
Disco DeDisco
2026-06-01 01:04:25 -04:00
parent 84d328171b
commit 19471662ff

View 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 (16) 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)