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