functional_tests + CI: rename pass + structural consolidations + parallel test-FTs split — every FT file now starts with one of 6 prefixes (test_admin_* / test_bill_* / test_core_* / test_dash_* / test_game_room_* / test_trinket_*) plus the 4 page-roots test_billboard / test_dashboard / test_gameboard / test_jasmine, so the partition is unambiguous and stable for tooling (the previous mix of test_applet_*, test_room_*, test_component_*, ad-hoc names had no consistent grouping); session-side: merged test_gatekeeper_bud_btn.py into test_bud_btn.py (then user renamed to test_core_bud_btn.py) — both files drove the same #id_bud_btn UI in two contexts (post-share + gatekeeper invite) and shared the bud-btn.js skeleton, so consolidation was overdue; split test_component_cards_tarot.py into test_admin_tarot.py (just TarotAdminTest, sitting next to test_admin / test_admin_post_readonly) + 3 classes (TarotDeckTest / GameKitDeckSelectionTest / GameKitPageTest) appended to test_game_room_tray.py; updated stale test_bud_btn.py references in the test_core_bud_btn.py docstring + test_admin_post_readonly.py comment to point at the new filename; user-driven renames (22 files): test_applet_my_notes/posts → test_bill_my_*, test_applet_new_post[_line_validation] → test_bill_new_post[_line_validation], test_applet_my_sky → test_dash_my_sky, test_applet_palette → test_dash_palette, test_wallet → test_dash_wallet, test_login → test_core_login, test_navbar → test_core_navbar, test_sharing → test_core_sharing, test_layout_and_styling → test_core_styling, test_my_buds → test_bill_my_buds, test_bud_btn → test_core_bud_btn, test_deck_contribution → test_game_room_deck_contrib, test_game_invite → test_game_room_invite, test_room_gatekeeper → test_game_room_gatekeeper, test_room_role_select → test_game_room_select_role, test_room_sea_select → test_game_room_select_sea, test_room_sig_select → test_game_room_select_sig, test_room_sky_select → test_game_room_select_sky, test_room_tray → test_game_room_tray, test_component_tray_tooltip → test_game_room_tray_tooltip; the post_page.py / room_page.py helper modules from the May-12 sprint absorbed the cross-file FT imports that would otherwise have cascade-broken on these renames
.woodpecker/main.yaml — CI test-FTs step splits into parallel siblings test-FTs-non-room (22 files via `ls functional_tests/test_*.py | grep -v 'test_game_room_'`) + test-FTs-room (9 files via `ls functional_tests/test_game_room_*.py`); room cluster is the heaviest (~70% of the pre-split ~40-min wall-clock) and now runs concurrently w. the rest instead of in series; DAG explicit via depends_on on every step (Woodpecker mixes default-sequential w. depends_on awkwardly, so each step pins its prerequisite); collectstatic stays in test-two-browser-FTs only — the shared workspace propagates assets to both parallel FT steps, no race + no duplication; screendumps + build-and-push fan back in (depends_on both parallel steps); deploy-staging + deploy-prod depend on build-and-push smoke-import: 31/31 FT modules green after the rename pass 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:
355
src/functional_tests/test_game_room_select_sea.py
Normal file
355
src/functional_tests/test_game_room_select_sea.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Functional tests for the PICK SEA overlay — Celtic Cross draw."""
|
||||
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import Character, GateSlot, Room, TableSeat, TarotCard, DeckVariant
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import ChannelsFunctionalTest
|
||||
|
||||
|
||||
def _make_sky_confirmed_room(live_server_url, user, earthman):
|
||||
"""Create a SKY_SELECT room with one gamer seated and sig assigned.
|
||||
|
||||
Returns (room, seat). The Character is NOT yet confirmed — call
|
||||
_confirm_sky() in the browser to trigger the async transition.
|
||||
"""
|
||||
room = Room.objects.create(
|
||||
name="Sea Test Room", table_status=Room.SKY_SELECT, owner=user
|
||||
)
|
||||
slot = room.gate_slots.get(slot_number=1)
|
||||
slot.gamer = user
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
|
||||
seat = TableSeat.objects.create(
|
||||
room=room, gamer=user, role="PC", slot_number=1,
|
||||
deck_variant=earthman, significator=sig_card,
|
||||
)
|
||||
return room, seat
|
||||
|
||||
|
||||
@tag("channels")
|
||||
class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
|
||||
"""After sky confirm, the sky overlay closes and the room reloads to the
|
||||
table hex w. the PICK SEA btn visible — the gamer must opt into the sea
|
||||
overlay rather than be auto-launched into it."""
|
||||
|
||||
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"}
|
||||
)
|
||||
from apps.lyric.models import User
|
||||
gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
gamer.unlocked_decks.add(earthman)
|
||||
gamer.equipped_deck = earthman
|
||||
gamer.save(update_fields=["equipped_deck"])
|
||||
|
||||
self.gamer = gamer
|
||||
self.room, self.seat = _make_sky_confirmed_room(self.live_server_url, gamer, earthman)
|
||||
self.room_url = self.live_server_url + reverse(
|
||||
"epic:room", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
self.sky_save_url = self.live_server_url + reverse(
|
||||
"epic:sky_save", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
|
||||
def _confirm_sky(self):
|
||||
"""POST to sky_save with action=confirm from browser JS (bypasses chart form)."""
|
||||
# Wait for the room WS connection to be ready before triggering confirm
|
||||
self.wait_for(lambda: self.browser.execute_script(
|
||||
"return !!(window._roomSocket && window._roomSocket.readyState === 1);"
|
||||
))
|
||||
self.browser.execute_script(f"""
|
||||
const csrf = (document.cookie.match(/csrftoken=([^;]+)/) || ['',''])[1];
|
||||
fetch('{self.sky_save_url}', {{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {{'Content-Type': 'application/json', 'X-CSRFToken': csrf}},
|
||||
body: JSON.stringify({{
|
||||
birth_dt: '1990-06-15T09:00:00Z',
|
||||
birth_lat: 51.5, birth_lon: -0.1,
|
||||
birth_place: 'London', house_system: 'O',
|
||||
chart_data: {{}}, action: 'confirm',
|
||||
}}),
|
||||
}});
|
||||
""")
|
||||
|
||||
def test_pick_sea_btn_visible_after_sky_confirm(self):
|
||||
"""Confirming sky reloads the room to the hex w. PICK SEA replacing
|
||||
PICK SKY; the sea overlay is NOT auto-opened."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
|
||||
# Sky not yet confirmed — PICK SKY btn present.
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
# Page reloads → hex shows PICK SEA in place of PICK SKY.
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
|
||||
self.assertEqual(self.browser.find_elements(By.ID, "id_pick_sky_btn"), [])
|
||||
|
||||
# Sea overlay is NOT auto-opened — it only appears once the gamer
|
||||
# clicks PICK SEA.
|
||||
has_sea_open = self.browser.execute_script(
|
||||
"return document.documentElement.classList.contains('sea-open');"
|
||||
)
|
||||
self.assertFalse(has_sea_open)
|
||||
|
||||
def test_sky_overlay_closed_after_sky_confirm(self):
|
||||
"""Sky overlay is gone (page reloaded) after sky confirm."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
|
||||
sky = self.browser.find_elements(By.ID, "id_sky_overlay")
|
||||
self.assertTrue(not sky or not sky[0].is_displayed())
|
||||
|
||||
def test_clicking_pick_sea_btn_opens_sea_overlay(self):
|
||||
"""The gamer's explicit click on PICK SEA is what opens the sea overlay."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
|
||||
|
||||
# On slow CI, the PICK SEA btn parses into the DOM before the inline
|
||||
# `<script>` at the bottom of _sea_overlay.html has bound `openSea` to
|
||||
# it; a one-shot click can land before the handler exists. Retry click
|
||||
# + assert together via wait_for so the race resolves naturally.
|
||||
def _click_and_assert_sea_open():
|
||||
btn = self.browser.find_element(By.ID, "id_pick_sea_btn")
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.assertTrue(self.browser.execute_script(
|
||||
"return document.documentElement.classList.contains('sea-open')"
|
||||
))
|
||||
self.wait_for(_click_and_assert_sea_open)
|
||||
|
||||
sea_overlay = self.browser.find_element(By.ID, "id_sea_overlay")
|
||||
self.assertTrue(sea_overlay.is_displayed())
|
||||
|
||||
|
||||
# ── Helpers for PICK SEA deal tests ──────────────────────────────────────────
|
||||
|
||||
def _seed_earthman_cards(earthman, count=20):
|
||||
"""Seed enough Middle Arcana cards for the deck piles."""
|
||||
suits = ["BRANDS", "GRAILS", "BLADES", "CROWNS"]
|
||||
nums = [11, 12, 13, 14]
|
||||
for suit in suits:
|
||||
for num in nums:
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"m{num}-{suit.lower()}-em",
|
||||
defaults={"arcana": "MIDDLE", "suit": suit, "number": num,
|
||||
"name": f"Card {num} {suit}"},
|
||||
)
|
||||
|
||||
|
||||
def _make_sea_ready_room(earthman):
|
||||
"""Create a SKY_SELECT room with a confirmed Character ready for PICK SEA.
|
||||
|
||||
Returns (room, gamer, seat, char, room_url).
|
||||
"""
|
||||
gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
gamer.unlocked_decks.add(earthman)
|
||||
gamer.equipped_deck = earthman
|
||||
gamer.save(update_fields=["equipped_deck"])
|
||||
|
||||
room = Room.objects.create(
|
||||
name="Sea Deal Room", table_status=Room.SKY_SELECT, owner=gamer
|
||||
)
|
||||
slot = room.gate_slots.get(slot_number=1)
|
||||
slot.gamer = gamer
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
|
||||
seat = TableSeat.objects.create(
|
||||
room=room, gamer=gamer, role="PC", slot_number=1,
|
||||
deck_variant=earthman, significator=sig_card,
|
||||
)
|
||||
char = Character.objects.create(
|
||||
seat=seat, significator=sig_card, confirmed_at=timezone.now()
|
||||
)
|
||||
return room, gamer, seat, char
|
||||
|
||||
|
||||
@tag("channels")
|
||||
class PickSeaDealTest(ChannelsFunctionalTest):
|
||||
"""PICK SEA deck stacks, OK btn interaction, card draw, and LOCK HAND."""
|
||||
|
||||
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"}
|
||||
)
|
||||
# Major Arcana sig card
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman, slug="the-schizo-em",
|
||||
defaults={"arcana": "MAJOR", "number": 1, "name": "The Schizo",
|
||||
"levity_qualifier": "Enlightened", "gravity_qualifier": "Engraven"},
|
||||
)
|
||||
_seed_earthman_cards(earthman)
|
||||
self.room, self.gamer, self.seat, self.char = _make_sea_ready_room(earthman)
|
||||
self.room_url = self.live_server_url + reverse(
|
||||
"epic:room", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
|
||||
def _load_sea_overlay(self):
|
||||
"""Navigate to room page and open the sea overlay."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
|
||||
|
||||
# ── Button presence ───────────────────────────────────────────────── #
|
||||
|
||||
def test_deal_btn_absent(self):
|
||||
"""DEAL btn replaced by deck stacks + LOCK HAND."""
|
||||
self._load_sea_overlay()
|
||||
self.assertEqual(self.browser.find_elements(By.ID, "id_sea_deal"), [])
|
||||
|
||||
def test_lock_hand_btn_present_and_disabled(self):
|
||||
"""LOCK HAND btn is present but disabled before any cards are drawn."""
|
||||
self._load_sea_overlay()
|
||||
lock_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sea_lock_hand")
|
||||
)
|
||||
self.assertFalse(lock_btn.is_enabled())
|
||||
|
||||
def test_del_btn_present(self):
|
||||
"""DEL btn is always present in the sea overlay."""
|
||||
self._load_sea_overlay()
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_del"))
|
||||
|
||||
# ── Deck stacks ───────────────────────────────────────────────────── #
|
||||
|
||||
def test_two_deck_stacks_present(self):
|
||||
"""Both levity and gravity deck stacks are visible."""
|
||||
self._load_sea_overlay()
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
|
||||
))
|
||||
|
||||
def test_clicking_stack_shows_ok_btn(self):
|
||||
"""Clicking a deck stack reveals its OK btn."""
|
||||
self._load_sea_overlay()
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.assertTrue(ok_btn.is_displayed())
|
||||
|
||||
def test_clicking_elsewhere_hides_ok_btn(self):
|
||||
"""Clicking outside a focused stack dismisses the OK btn."""
|
||||
self._load_sea_overlay()
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
).is_displayed())
|
||||
# Click the sea cards column (not a stack)
|
||||
col = self.browser.find_element(By.CSS_SELECTOR, ".sea-cards-col")
|
||||
self.browser.execute_script("arguments[0].click()", col)
|
||||
self.wait_for(lambda: not any(
|
||||
el.is_displayed()
|
||||
for el in self.browser.find_elements(By.CSS_SELECTOR, ".sea-stack-ok")
|
||||
))
|
||||
|
||||
# ── Card draw ─────────────────────────────────────────────────────── #
|
||||
|
||||
def test_ok_click_fills_cover_position(self):
|
||||
"""First OK click places a card in the Cover position."""
|
||||
self._load_sea_overlay()
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", ok_btn)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
|
||||
))
|
||||
|
||||
def test_lock_hand_enables_after_six_draws(self):
|
||||
"""LOCK HAND btn becomes enabled once all 6 positions are filled."""
|
||||
self._load_sea_overlay()
|
||||
|
||||
for _ in range(6):
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", ok_btn)
|
||||
|
||||
lock_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sea_lock_hand")
|
||||
)
|
||||
self.assertTrue(lock_btn.is_enabled())
|
||||
|
||||
def test_del_clears_drawn_cards(self):
|
||||
"""DEL btn clears all drawn cards and resets positions to empty."""
|
||||
self._load_sea_overlay()
|
||||
# Draw one card
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", ok_btn)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
|
||||
))
|
||||
# DEL clears it
|
||||
del_btn = self.browser.find_element(By.ID, "id_sea_del")
|
||||
self.browser.execute_script("arguments[0].click()", del_btn)
|
||||
self.wait_for(lambda: not self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot--filled"
|
||||
))
|
||||
Reference in New Issue
Block a user