tray sig-card tooltip: portal w. PRV|NXT pager — TDD

Phase 2 of the apps.tooltips integration on the tray. Hovering
.tray-sig-card > .sig-stage-card opens #id_tooltip_portal w. an FYI panel
that mirrors #id_fan_fyi_panel (Energy / Operation entries cycled via
PRV|NXT), but w.o. the stage block, w.o. Reversal entries, & w.o. the fan
stage's click-to-dismiss handler — the panel-body click is reserved for
future drag-and-drop on .tray-sig-card:active.

- _partials/_sig_fyi_panel.html — new partial, the .sig-info + PRV|NXT
  block extracted out of game_kit.html, _sig_select_overlay.html, &
  _sea_overlay.html. {% include %}d back from those 3 callers; pure
  copy-paste extraction (no behavioural change to fan stage, sig select,
  or sea select).
- room.html: .tray-sig-card > .sig-stage-card gains data-energies +
  data-operations (the only attrs StageCard.buildInfoData reads), keyed
  off my_tray_sig.energies_json / .operations_json (existing TarotCard
  properties).
- tray-tooltip.js: new sig branch — _showSig() builds the panel inline,
  paints via StageCard.renderFyi, & wires PRV|NXT cycle handlers; the
  mousemove union now covers the .fyi-prev / .fyi-next btn rects (the
  btns hang past the portal's left & right edges) so mouse-over them
  keeps the panel alive. Click stopPropagation on the btns prevents the
  panel-body click from reaching anything else.
- TrayTooltipSpec: 6 new sig-branch specs (panel structure; first energy
  entry rendered; PRV|NXT cycling; body click no-dismiss; pointer over
  btn rects keeps panel alive; pointer outside full union clears).
- test_component_tray_tooltip.py: 4 sig FTs (hover populates portal w.
  Energy/TESTLIBIDO/effect/1-of-2; PRV|NXT cycle; body click does NOT
  dismiss; mouseleave clears).

FT helper note — the sig FT's _hover dispatches a synthetic mouseenter
via JS rather than ActionChains.move_to_element, because the role-card
& sig-card cells sit side-by-side in the tray grid: the pointer's
animated path crosses the role-card on its way to the sig-card &
opens the role tooltip mid-flight, which then occludes the sig stage
by the time the move lands. Direct dispatch lands the event on the
intended trigger w.o. the cross-cell drag-by.

313 epic ITs + 335 Jasmine specs (incl. 6 new) + 6 tray-tooltip FTs all
green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-03 21:07:33 -04:00
parent 08243d109d
commit b29bcf5c38
9 changed files with 654 additions and 78 deletions

View File

@@ -14,7 +14,7 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard
from apps.lyric.models import User
@@ -161,3 +161,229 @@ class TrayRoleCardTooltipTest(FunctionalTest):
self._move_far_away()
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))
class TraySigCardTooltipTest(FunctionalTest):
"""Phase 2 — sig-card tooltip mirrors #id_fan_fyi_panel functionally:
Energy/Operation entries cycled via PRV/NXT btns, "1/N" pager, no stage
block, no Reversal entries. Extended-mouseleave (union incl. PRV/NXT btn
rects) replaces the fan-stage version's click-to-dismiss handler.
"""
EMAILS = [
"ts1@test.io", "ts2@test.io", "ts3@test.io",
"ts4@test.io", "ts5@test.io", "ts6@test.io",
]
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
def _make_room_with_sig(self):
"""Room w. founder seated, role assigned, & a TarotCard set as sig.
The seeded card carries 1 energy + 1 operation = 2 FYI entries so the
PRV/NXT pager has something to cycle through.
"""
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
deck, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
founder.equipped_deck = deck
founder.save(update_fields=["equipped_deck"])
# Stable test fixture — slug + arcana + suit unique per (deck_variant, slug).
# corner_rank / suit_icon are computed properties on TarotCard;
# only the underlying fields go in create().
sig_card = TarotCard.objects.create(
deck_variant=deck,
name="The Tester",
arcana="MAJOR",
number=98,
slug="the-tester-sig-tt",
icon="fa-flask",
energies=[
{"type": "TESTLIBIDO", "effect": "First energy effect."},
],
operations=[
{"type": "TESTOP", "effect": "First operation effect."},
],
)
room = Room.objects.create(name="Sig Tooltip Room", owner=founder)
_fill_room_via_orm(room, self.EMAILS)
room.table_status = Room.ROLE_SELECT
room.save()
for slot in room.gate_slots.order_by("slot_number"):
seat = TableSeat.objects.create(
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
role=("PC" if slot.slot_number == 1 else None),
)
if slot.slot_number == 1:
seat.significator = sig_card
seat.save(update_fields=["significator"])
return room, sig_card
def _open_tray(self):
self.browser.execute_script("Tray.open();")
self.wait_for(
lambda: self.assertLess(
self.browser.execute_script(
"return document.getElementById('id_tray_wrap').getBoundingClientRect().left"
),
40,
)
)
def _hover(self, element):
# Synthetic mouseenter — Selenium's ActionChains animates the pointer
# from the previous position to the destination. With role-card and
# sig-card cells side-by-side in the tray grid, an ActionChains hover
# on the sig stage crosses the role-card's hit area on the way and
# opens the role tooltip mid-flight, which then occludes the sig stage
# by the time the pointer lands. Direct dispatch avoids this and still
# exercises the same listener path (mouseenter → _showSig).
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('mouseenter',"
"{bubbles:false, cancelable:false}));",
element,
)
def _move_far_away(self):
self.browser.execute_script(
"document.dispatchEvent(new MouseEvent('mousemove',"
"{bubbles:true, clientX:9999, clientY:9999}));"
)
# ------------------------------------------------------------------ #
# T1 — Hover sig card → portal shows first energy entry #
# ------------------------------------------------------------------ #
def test_hover_sig_card_populates_fyi_portal(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self.assertFalse(portal.is_displayed())
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
# Portal renders the same .sig-info structure as the fan FYI panel.
title = portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip()
ttype = portal.find_element(By.CSS_SELECTOR, ".sig-info-type").text.strip()
effect = portal.find_element(By.CSS_SELECTOR, ".sig-info-effect").text.strip()
index = portal.find_element(By.CSS_SELECTOR, ".sig-info-index").text.strip()
self.assertEqual(title, "Energy")
self.assertEqual(ttype, "TESTLIBIDO")
self.assertEqual(effect, "First energy effect.")
self.assertEqual(index, "1 / 2") # 1 energy + 1 operation = 2 entries
# PRV + NXT buttons are present.
portal.find_element(By.CSS_SELECTOR, ".fyi-prev")
portal.find_element(By.CSS_SELECTOR, ".fyi-next")
# ------------------------------------------------------------------ #
# T2 — NXT cycles to operation entry; PRV cycles back #
# ------------------------------------------------------------------ #
def test_prv_nxt_cycle_through_entries(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
nxt = portal.find_element(By.CSS_SELECTOR, ".fyi-next")
nxt.click()
self.wait_for(
lambda: self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip(),
"Operation",
)
)
self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-type").text.strip(),
"TESTOP",
)
self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-index").text.strip(),
"2 / 2",
)
prv = portal.find_element(By.CSS_SELECTOR, ".fyi-prev")
prv.click()
self.wait_for(
lambda: self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip(),
"Energy",
)
)
# ------------------------------------------------------------------ #
# T3 — Clicking the panel body does NOT dismiss (departure from fan). #
# ------------------------------------------------------------------ #
def test_panel_body_click_does_not_dismiss(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
# Click on the panel body (not on PRV / NXT) → portal stays active.
portal.find_element(By.CSS_SELECTOR, ".sig-info-effect").click()
self.assertTrue(portal.is_displayed())
# ------------------------------------------------------------------ #
# T4 — Mouseleave (outside trigger + portal + btn rects) clears. #
# ------------------------------------------------------------------ #
def test_mouseleave_outside_union_clears_portal(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
self._move_far_away()
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))