"""FTs for the My Sea standalone page sign-gate. Sprint 4b of [[project-my-sea-roadmap]]. The /gameboard/my-sea/ page is gated behind sig selection — when `user.significator` is None, render a Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/ mirrors the gate hint in its empty-state slot. """ from django.utils import timezone from selenium.webdriver.common.by import By from .base import FunctionalTest from .sig_page import _assign_sig, _seed_earthman_sig_pile from apps.applets.models import Applet from apps.epic.models import personal_sig_cards from apps.lyric.models import User def _count_filled_slots(picker): """Count `.sea-card-slot--filled` elements inside a picker container. Module-level so multiple test classes can use it without inheriting.""" return len( picker.find_elements(By.CSS_SELECTOR, ".sea-card-slot--filled") ) def _open_spread_modal(test): """Open #id_sea_spread_modal so subsequent interactions w. in-modal selectors (`.sea-select*`, `#id_sea_action_btn`, `#id_sea_del`) reach a visible target. Sprint 2026-05-26 wrapped the sea-form-col in a modal triggered by the burger fan's Sea sub-btn; tests that touched those selectors directly now have to open the modal first. Module-level so MySeaSpreadFormTest + MySeaCardDrawTest + MySeaLockHandTest can all call it w/o duplicating the helper. JS .click() is used (not Selenium .click) because the fan sub-btns spend ~0.25s mid-animation stacked at the burger centre — Selenium would hit a click-intercept by whichever sub-btn is z-topmost during the transform. execute_script bypasses the visual hit-test.""" burger_btn = test.browser.find_element(By.ID, "id_burger_btn") test.browser.execute_script("arguments[0].click()", burger_btn) sea_btn = test.wait_for( lambda: test.browser.find_element(By.ID, "id_sea_btn") ) test.browser.execute_script("arguments[0].click()", sea_btn) test.wait_for( lambda: test.assertIsNone( test.browser.find_element(By.ID, "id_sea_spread_modal") .get_attribute("hidden") ) ) def _seed_gameboard_applets(): """My Sea + the rest of the gameboard applets so /gameboard/ renders without missing-applet errors during the applet-side assertions. Mirrors the migration seed (0003 + 0008) — every slug must have a matching _applet-.html partial under apps/gameboard/_partials/.""" for slug, name, cols, rows, ctx in [ ("my-sea", "My Sea", 12, 4, "gameboard"), ("game-kit", "Game Kit", 4, 3, "gameboard"), ("new-game", "New Game", 4, 3, "gameboard"), ("my-games", "My Games", 4, 4, "gameboard"), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "context": ctx, "default_visible": True, "grid_cols": cols, "grid_rows": rows}, ) class MySeaSignGateTest(FunctionalTest): """Sign-gate UX on the standalone /gameboard/my-sea/ page + the /gameboard/ My Sea applet. User without a saved sig sees a Look!- formatted nudge w. FYI to the picker + NVM to the gameboard.""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_gameboard_applets() self.email = "sea@test.io" self.gamer = User.objects.create(email=self.email) sig_pile = personal_sig_cards(self.gamer) self.target_card = sig_pile[0] if sig_pile else None self.assertIsNotNone( self.target_card, "personal_sig_cards(user) returned no cards — check Earthman seed", ) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_no_sig_renders_lookline_gate_on_standalone_page(self): """User without significator → /gameboard/my-sea/ fires the Look!- formatted sign-gate Brief banner w. FYI + NVM controls (refactored 2026-05-22 from inline `.my-sea-sign-gate` div to a `.note-banner` portaled via `Brief.showBanner`).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") banner = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief" ) ) text = banner.text self.assertIn("Look!", text) self.assertIn("pick your sign", text.lower()) self.assertIn("drawing the Sea", text) # FYI + NVM action buttons live inside the Brief shell (built-in). fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi") self.assertTrue(fyi.is_displayed()) nvm = banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm") self.assertTrue(nvm.is_displayed()) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_gate_fyi_links_to_my_sign_picker(self): """FYI button on the Brief is an `` pointing at /billboard/ my-sign/. Carries the standard `.note-banner__fyi` class.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") fyi = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__fyi" ) ) href = fyi.get_attribute("href") or "" self.assertTrue( href.endswith("/billboard/my-sign/"), f"FYI should link to /billboard/my-sign/, got {href!r}", ) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_gate_nvm_dismisses_brief(self): """NVM button on the Brief dismisses the banner (built into `Brief.showBanner`'s nvm handler — `banner.remove()`).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") nvm = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__nvm" ) ) nvm.click() self.wait_for( lambda: self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, ".my-sea-sign-gate-brief" )), 0, "NVM should remove the gate Brief from the DOM", ) ) # ── Test 4 ─────────────────────────────────────────────────────────────── def test_with_sig_skips_gate_and_renders_draw_shell(self): """User w. saved significator → no sign-gate Brief on the page; draw shell renders normally.""" _assign_sig(self.gamer, self.target_card) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page") ) self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, ".my-sea-sign-gate-brief")), 0, "Gate Brief should not render when user has a saved significator", ) # ── Test 5 ─────────────────────────────────────────────────────────────── def test_no_sig_applet_mirrors_gate_with_fyi_link(self): """On /gameboard/, the My Sea applet fires the same sign-gate Brief w. FYI link to /billboard/my-sign/ when the user has no significator. Provides a consistent UX across surfaces.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/") banner = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief" ) ) self.assertIn("Look!", banner.text) fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi") href = fyi.get_attribute("href") or "" self.assertTrue(href.endswith("/billboard/my-sign/")) # ── Test 6 ─────────────────────────────────────────────────────────────── def test_with_sig_applet_renders_default_empty_state(self): """Applet w. saved sig → no gate Brief, empty-state placeholder ("No draws yet.") inside the applet body.""" _assign_sig(self.gamer, self.target_card) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_sea") ) self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, ".my-sea-sign-gate-brief" )), 0, ) class MySeaDrawSeaLandingTest(FunctionalTest): """Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate). Landing renders a DRY table hex (parameterized from the room) w. 6 chair seats labeled 1C-6C (placeholders for the eventual friend- invite feature per [[project-my-sea-roadmap]] architectural anchor "Six chairs retained even in solo") + a central FREE DRAW `.btn- primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat renders w. a red `.fa-ban` status icon (empty slot). Click flow: FREE DRAW → seat 1C transitions to `.seated` state (chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa- circle-check` green) → after a brief delay so the user sees the animation, `data-phase` swaps to `picker` (picker content lands in iter 2). The 'C' = "Chair" (user-locked vocabulary); no role semantics in this solo flow. "FREE DRAW" is the label for the 1/24h free quota draw — a future sprint will conditionally swap the label to "DRAW SEA" once the free has been used, w. the DRAW SEA btn calling the room gatekeeper partial for token-deposit. The same Brief "Default deck warning" copy from my-sign fires when the user has no equipped deck.""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_gameboard_applets() self.email = "draw@test.io" self.gamer = User.objects.create(email=self.email) # Assign a sig so the page passes the Sprint 4b gate + lands on # the new DRAW SEA UX rather than the Look!-line gate. self.target_card = _assign_sig(self.gamer) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_landing_renders_hex_with_free_draw_btn(self): """User w. sig → /gameboard/my-sea/ shows the DRY table hex (re- used from my-sign / the room shell) w. a central FREE DRAW btn. Element ID `id_draw_sea_btn` describes intent (the draw entry point); the hex-center btn is a 3-way state machine: - FREE DRAW (`#id_draw_sea_btn`) — fresh user (no active row, no deposit). Pinned here. - PAID DRAW (`#id_my_sea_paid_draw_btn`) — deposit reserved. Pinned by `MySeaLandingPaidDrawTest`. - GATE VIEW (`#id_my_sea_gate_view_btn`) — quota spent, no deposit (post-DEL). Pinned by `MySeaDeleteDrawAndGuardPortalTest` (the DEL-confirm flow). The three IDs are mutually exclusive — only one renders at a time. This test pins the fresh-user side of that exclusion (FREE DRAW present, the other two absent); the deposit + post- DEL sides have their own dedicated FTs that pin the same invariant from the other directions.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") # data-phase=landing on the page wrapper page = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']") ) # Hex shell present page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex") # FREE DRAW btn in hex center btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") self.assertTrue(btn.is_displayed()) self.assertIn("FREE", btn.text.upper()) self.assertIn("DRAW", btn.text.upper()) self.assertIn("btn-primary", btn.get_attribute("class")) # Mutual exclusion: PAID DRAW + GATE VIEW must NOT also render. self.assertEqual( len(page.find_elements(By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn")), 0, "PAID DRAW btn must not render when no deposit is reserved", ) self.assertEqual( len(page.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")), 0, "GATE VIEW btn must not render when no active draw row exists", ) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self): """All 6 chair positions render w. labels 1C-6C (placeholder for friend-invite). CSS class `.table-seat` is preserved so the SCSS positioning rules (data-slot=N) carry over from the room shell. Each seat starts w. a red `.fa-ban` status icon (empty).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") seats = self.wait_for( lambda: self._six_seats() ) self.assertEqual(len(seats), 6) for n, seat in enumerate(seats, start=1): with self.subTest(slot=n): label = "".join(seat.text.upper().split()) self.assertIn(f"{n}C", label) # Each seat carries the red ban status icon initially. seat.find_element( By.CSS_SELECTOR, ".position-status-icon.fa-ban" ) def _six_seats(self): seats = self.browser.find_elements( By.CSS_SELECTOR, ".my-sea-page[data-phase='landing'] .table-seat" ) if len(seats) != 6: raise AssertionError(f"expected 6 seats, got {len(seats)}") return seats # ── Test 3 ─────────────────────────────────────────────────────────────── def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self): """Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban swapped for fa-circle-check (visible to the user during the ~800ms animation delay); other seats remain empty; then the page's data-phase swaps to 'picker' so iter 2's content can take over. Single-user instance for now → user always gets the lowest-numeral seat (1C).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") ) btn.click() # Seat 1C goes seated + icon swaps. Other seats unchanged. seat1 = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated" ) ) seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check") # Seats 2-6 retain the .fa-ban icon (still empty). for n in range(2, 7): with self.subTest(slot=n): other = self.browser.find_element( By.CSS_SELECTOR, f".table-seat[data-slot='{n}']" ) self.assertNotIn("seated", other.get_attribute("class")) other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban") # After the seat animation, data-phase swaps to picker + landing hides. self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" ) ) landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing") self.assertFalse(landing.is_displayed()) # ── Test 4 ─────────────────────────────────────────────────────────────── def test_brief_banner_renders_when_no_deck_equipped(self): """No equipped deck → the same 'Default deck warning' Brief banner from my-sign fires (lifted verbatim). Tagged w. a my-sea- specific class so FTs can disambiguate from any other Briefs.""" self.gamer.equipped_deck = None self.gamer.save(update_fields=["equipped_deck"]) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") banner = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-intro-banner" ) ) self.assertIn("Default deck warning", banner.text) self.assertIn("no deck is equipped", banner.text) self.assertIn("Shabby Cardstock", banner.text) # ── Test 5 ─────────────────────────────────────────────────────────────── def test_no_brief_banner_when_deck_equipped(self): """User w. an equipped deck → no Default-deck-warning Brief on landing. Auto-equip via the User post_save signal handles this for fresh users; assertion guards against accidental render of the banner when the condition shouldn't fire.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") ) self.assertEqual( len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")), 0, ) class MySeaPickerPhaseTest(FunctionalTest): """Sprint 5 iter 2 — picker phase content on /gameboard/my-sea/ after FREE DRAW click swaps `data-phase` to `picker`. Three-card spread: user's saved significator pinned in the center (`.sea-pos-core`) + three drawn-card positions surrounding it — cover (overlaid on sig), leave (left of center), loom (right of center). Crown / lay / cross from the gameroom's 6-position Celtic Cross are deliberately omitted (user-locked spec). Empty drop-zones are visible — actual card-draw wiring lands in iter 3 alongside the form col (spread dropdown / decks / LOCK HAND / DEL).""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_gameboard_applets() self.email = "picker@test.io" self.gamer = User.objects.create(email=self.email) self.target_card = _assign_sig(self.gamer) def _enter_picker_phase(self): """Common nav: load /gameboard/my-sea/, click FREE DRAW, wait for the page wrapper's data-phase to swap to `picker` (which happens ~800ms after click per the seat-1C animation delay in the inline JS).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") ) btn.click() return self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" ) ) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_picker_renders_significator_card_in_core_cell(self): """User's saved significator pins the `.sea-pos-core` cell — the center of the three-card cross. Card data attribute reflects the actual TarotCard.id so future iters can wire FYI / SPIN onto it.""" self._enter_picker_phase() core = self.browser.find_element( By.CSS_SELECTOR, ".my-sea-picker .sea-pos-core .sea-sig-card" ) self.assertEqual( core.get_attribute("data-card-id"), str(self.target_card.id) ) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_picker_renders_cover_leave_loom_positions(self): """The three drawn-card positions (cover/leave/loom) render as empty `.sea-card-slot--empty` drop zones. Cover is overlaid on the sig card via `.sea-pos-core > .sea-pos-cover` nesting; leave + loom sit in their own grid cells flanking core.""" picker = self._enter_picker_phase() # Cover lives nested inside .sea-pos-core (overlaid on sig) picker.find_element( By.CSS_SELECTOR, ".sea-pos-core .sea-pos-cover .sea-card-slot--empty" ) picker.find_element( By.CSS_SELECTOR, ".sea-pos-leave .sea-card-slot--empty" ) picker.find_element( By.CSS_SELECTOR, ".sea-pos-loom .sea-card-slot--empty" ) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_picker_renders_sao_default_position_subset(self): """Default spread = Situation/Action/Outcome (SAO) → only lay (Situation) + cover (Action) + crown (Outcome) visible from the 6 surrounding positions; leave / loom / cross hidden. All 6 cells render in DOM so spread-switching never re-mutates the cross structure — per-spread visibility lives in SCSS via `.my-sea-cross[data-spread="..."]` rules.""" picker = self._enter_picker_phase() visible = {".sea-pos-lay", ".sea-pos-cover", ".sea-pos-crown"} hidden = {".sea-pos-leave", ".sea-pos-loom", ".sea-pos-cross"} for pos in visible | hidden: with self.subTest(position=pos): elements = picker.find_elements(By.CSS_SELECTOR, pos) self.assertEqual(len(elements), 1, f"{pos} should render in DOM") expected_visible = pos in visible self.assertEqual( elements[0].is_displayed(), expected_visible, f"{pos} visibility wrong for SAO default; expected {expected_visible}", ) class MySeaSpreadFormTest(FunctionalTest): """Sprint 5 iter 3 — form col on the picker phase: SPREAD dropdown (custom combobox w. 6 options + 2 horizontal section dividers for "3-card spreads" / "6-card spreads"), reversal-rate caption, two DECKS swatches (GRAVITY + LEVITY), LOCK HAND + DEL btns. Selecting a 6-card spread (Celtic Cross variants) swaps `.my-sea-cross[data- spread-shape]` from `three-card` to `six-card`, revealing the crown / lay / cross positions hidden by default. Card-draw mechanics — clicking a deck swatch to deposit a card into the next empty slot — defers to iter 4.""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_gameboard_applets() self.email = "spread@test.io" self.gamer = User.objects.create(email=self.email) self.target_card = _assign_sig(self.gamer) def _enter_picker_phase(self): self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") ) btn.click() return self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" ) ) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_spread_dropdown_renders_six_options_and_two_dividers(self): """SPREAD combobox has 4 three-card options + 2 six-card options + 2 horizontal section dividers labelled "3-card spreads" / "6-card spreads". Dividers are `role=presentation` + `.sea-select-divider` so combobox.js skips them. The dropdown is closed (`aria-expanded='false'`) on initial render so the
  • s aren't displayed; use textContent rather than `.text` (which returns "" for hidden elements).""" picker = self._enter_picker_phase() options = picker.find_elements( By.CSS_SELECTOR, ".sea-select-list [role='option']" ) self.assertEqual(len(options), 6) option_labels = [ o.get_attribute("textContent").strip() for o in options ] # Three-card variants — labels per [[project-my-sea-roadmap]] # iter 3 spec lock. self.assertIn("Past, Present, Future", option_labels) self.assertIn("Situation, Action, Outcome", option_labels) self.assertIn("Mind, Body, Spirit", option_labels) self.assertIn("Desire, Obstacle, Solution", option_labels) # Six-card variants self.assertIn("Celtic Cross, Waite-Smith", option_labels) self.assertIn("Celtic Cross, Escape Velocity", option_labels) # Two horizontal dividers dividers = picker.find_elements(By.CSS_SELECTOR, ".sea-select-divider") self.assertEqual(len(dividers), 2) divider_text = "|".join( d.get_attribute("textContent").upper().strip() for d in dividers ) self.assertIn("3-CARD SPREADS", divider_text) self.assertIn("6-CARD SPREADS", divider_text) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_default_spread_is_situation_action_outcome(self): """Per the spec, `Situation, Action, Outcome` is the default spread on landing — selected in the combobox + reflected in the hidden `` initial value + on `.my-sea-cross[data-spread]`.""" picker = self._enter_picker_phase() _open_spread_modal(self) # .sea-select-current lives in the spread modal hidden = picker.find_element(By.CSS_SELECTOR, "#id_sea_spread") self.assertEqual( hidden.get_attribute("value"), "situation-action-outcome", ) selected = picker.find_element( By.CSS_SELECTOR, ".sea-select-list [role='option'][aria-selected='true']" ) self.assertEqual( selected.get_attribute("textContent").strip(), "Situation, Action, Outcome", ) current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current") self.assertEqual(current.text.strip(), "Situation, Action, Outcome") cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross") self.assertEqual( cross.get_attribute("data-spread"), "situation-action-outcome", ) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_picking_spread_swaps_data_spread_and_position_visibility(self): """Each spread reveals its own position subset (user-locked 2026-05-19): PPF → leave + cover + loom visible SAO → lay + cover + crown MBS → crown + lay + loom DOS → loom + cross + cover CC variants → all 6 surrounding positions. `.my-sea-cross[data-spread]` swaps on combobox change; SCSS rules toggle the inactive positions to `display: none`.""" ALL_POSITIONS = {"crown", "leave", "cover", "cross", "loom", "lay"} SPREAD_POSITIONS = { "past-present-future": {"leave", "cover", "loom"}, "situation-action-outcome": {"lay", "cover", "crown"}, "mind-body-spirit": {"crown", "lay", "loom"}, "desire-obstacle-solution": {"loom", "cross", "crown"}, "waite-smith": ALL_POSITIONS, "escape-velocity": ALL_POSITIONS, } picker = self._enter_picker_phase() _open_spread_modal(self) # combobox lives in the spread modal cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross") combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]") def _pick(value): # Combobox click outside an open dropdown opens it; click on # an option inside selects + closes. Re-opening for each pick # keeps the test deterministic. if combo.get_attribute("aria-expanded") != "true": combo.click() opt = picker.find_element( By.CSS_SELECTOR, f".sea-select-list [role='option'][data-value='{value}']", ) opt.click() for value, expected_visible in SPREAD_POSITIONS.items(): with self.subTest(spread=value): _pick(value) self.wait_for( lambda v=value: self.assertEqual( cross.get_attribute("data-spread"), v ) ) for pos in ALL_POSITIONS: element = picker.find_element( By.CSS_SELECTOR, f".sea-pos-{pos}" ) should_show = pos in expected_visible self.assertEqual( element.is_displayed(), should_show, f"spread={value} pos={pos}: expected is_displayed={should_show}", ) # ── Test 4 ─────────────────────────────────────────────────────────────── def test_per_spread_position_labels_render_and_update(self): """Each visible empty slot carries a `.sea-pos-label` caption whose text matches the spread's per-position label map (e.g. SAO default: lay='Situation', cover='Action', crown='Outcome'). JS updates labels on spread change. Reappropriates the GRAVITY/LEVITY (`.sea-stack-name`) caption styling.""" SPREAD_LABELS = { "situation-action-outcome": {"lay": "Situation", "cover": "Action", "crown": "Outcome"}, "past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"}, "mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"}, "desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"}, "waite-smith": {"crown": "Crown", "leave": "Behind", "cover": "Cover", "cross": "Cross", "loom": "Before", "lay": "Beneath"}, } picker = self._enter_picker_phase() _open_spread_modal(self) # combobox lives in the spread modal combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]") def _pick(value): if combo.get_attribute("aria-expanded") != "true": combo.click() picker.find_element( By.CSS_SELECTOR, f".sea-select-list [role='option'][data-value='{value}']", ).click() # SAO default — assert labels via the server-rendered initial state. for pos, expected_label in SPREAD_LABELS["situation-action-outcome"].items(): with self.subTest(spread="situation-action-outcome", position=pos): label_el = picker.find_element( By.CSS_SELECTOR, f".sea-pos-label[data-position='{pos}']", ) self.assertEqual( label_el.get_attribute("textContent").strip(), expected_label, ) # Switch to each other spread + verify the labels update. for spread, position_to_label in SPREAD_LABELS.items(): if spread == "situation-action-outcome": continue _pick(spread) for pos, expected_label in position_to_label.items(): with self.subTest(spread=spread, position=pos): label_el = self.wait_for( lambda p=pos, lbl=expected_label: self._wait_label(p, lbl, picker) ) self.assertEqual( label_el.get_attribute("textContent").strip(), expected_label, ) def _wait_label(self, position, expected_label, picker): el = picker.find_element( By.CSS_SELECTOR, f".sea-pos-label[data-position='{position}']" ) if el.get_attribute("textContent").strip() != expected_label: raise AssertionError( f"label@{position}: got " f"{el.get_attribute('textContent')!r}, want {expected_label!r}" ) return el class MySeaCardDrawTest(FunctionalTest): """Sprint 5 iter 4a — client-side card-draw mechanics on the picker phase. Server embeds the deck (gravity + levity halves, user's sig excluded) as JSON; clicking GRAVITY/LEVITY swatch shows FLIP; FLIP deposits the next card into the next DRAW_ORDER slot for the active spread. DEL fully resets the in-progress hand. LOCK HAND enables when the hand is complete + click locks down further interaction. Switching spreads also resets the hand (the position-subset changes). Server-side persistence (committing the locked hand to a MySeaDraw model) defers to iter 4b.""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_gameboard_applets() self.email = "draw@test.io" self.gamer = User.objects.create(email=self.email) self.target_card = _assign_sig(self.gamer) def _enter_picker_phase(self): self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") ) btn.click() return self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" ) ) def _draw_open_modal(self, picker, polarity): """Click a polarity swatch + the FLIP btn that appears → opens the SeaDeal stage modal. Returns the stage element so callers can assert on it before dismissing.""" stack = picker.find_element( By.CSS_SELECTOR, f".sea-deck-stack--{polarity}" ) stack.click() flip = self.wait_for( lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok") ) self.wait_for(lambda: self.assertTrue(flip.is_displayed())) flip.click() # SeaDeal.openStage shows #id_sea_stage. Wait for the modal. return self.wait_for( lambda: self._stage_visible() ) def _stage_visible(self): stage = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage") if not stage.is_displayed(): raise AssertionError("sea-stage not visible after FLIP click") return stage def _dismiss_modal(self): """Click the stage backdrop → SeaDeal._hideStage → modal hides + slot gains `.--visible` (thumbnail fades in). Uses `execute_script` to dispatch the click rather than a native Selenium `.click()` — `.sea-stage-content` overlays the backdrop visually (centered card + stat block), so Selenium reports ElementClickInterceptedException for a direct click. This is the documented Selenium-limitation exception per the TDD skill; the actual backdrop-click → close behaviour is Jasmine-tested in [[SeaDealSpec.js]] / "Backdrop click closes the stage".""" self.browser.execute_script( "document.querySelector('#id_sea_stage .sea-stage-backdrop').click();" ) self.wait_for( lambda: self.assertFalse( self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage").is_displayed() ) ) def _draw_one(self, picker, polarity): """Full single-draw cycle: open modal + dismiss it. Used by FTs that need to deposit multiple cards in sequence (the stage backdrop blocks subsequent deck-stack clicks).""" self._draw_open_modal(picker, polarity) self._dismiss_modal() # ── Test 1 ─────────────────────────────────────────────────────────────── def test_deck_data_embedded_with_two_polarity_halves(self): """Server-side renders the shuffled deck (levity + gravity halves, sig excluded) inside `