First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1558 lines
74 KiB
Python
1558 lines
74 KiB
Python
"""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 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 _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-<slug>.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/ shows the Look!-
|
||
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
gate = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-sign-gate"
|
||
)
|
||
)
|
||
text = gate.text
|
||
self.assertIn("Look!", text)
|
||
self.assertIn("pick your sign", text.lower())
|
||
self.assertIn("drawing the Sea", text)
|
||
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
|
||
# post-relabel; the BACK→NVM swap was label-only).
|
||
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
||
self.assertTrue(fyi.is_displayed())
|
||
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
|
||
self.assertTrue(nvm.is_displayed())
|
||
|
||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_gate_fyi_links_to_my_sign_picker(self):
|
||
"""FYI button is an `<a href>` pointing at /billboard/my-sign/."""
|
||
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__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_back_links_to_gameboard(self):
|
||
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
|
||
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
|
||
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__back"
|
||
)
|
||
)
|
||
href = nvm.get_attribute("href") or ""
|
||
self.assertTrue(
|
||
href.endswith("/gameboard/"),
|
||
f"NVM should link to /gameboard/, got {href!r}",
|
||
)
|
||
|
||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_with_sig_skips_gate_and_renders_draw_shell(self):
|
||
"""User w. saved significator → no .my-sea-sign-gate on the page;
|
||
draw shell renders normally (Sprint 3 placeholder)."""
|
||
_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")),
|
||
0,
|
||
"Gate 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's empty state shows the same
|
||
Look!-formatted gate 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/")
|
||
applet_gate = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
||
)
|
||
)
|
||
self.assertIn("Look!", applet_gate.text)
|
||
fyi = applet_gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__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, empty-state placeholder (until
|
||
Sprint 7 wires up the latest-draw rendering)."""
|
||
_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, "#id_applet_my_sea .my-sea-sign-gate"
|
||
)),
|
||
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) — a future sprint will conditionally swap the label to
|
||
DRAW SEA once the daily free has been used."""
|
||
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"))
|
||
|
||
# ── 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 <li>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 `<input id="id_sea_spread">` initial value + on
|
||
`.my-sea-cross[data-spread]`."""
|
||
picker = self._enter_picker_phase()
|
||
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()
|
||
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": "Beneath", "cover": "Cover",
|
||
"cross": "Cross", "loom": "Before", "lay": "Behind"},
|
||
}
|
||
picker = self._enter_picker_phase()
|
||
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 `<script type="application/json"
|
||
id="id_my_sea_deck">`. Client-side JS reads on init."""
|
||
import json as _json
|
||
picker = self._enter_picker_phase()
|
||
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
|
||
deck = _json.loads(data_el.get_attribute("textContent"))
|
||
self.assertIn("levity", deck)
|
||
self.assertIn("gravity", deck)
|
||
self.assertIsInstance(deck["levity"], list)
|
||
self.assertIsInstance(deck["gravity"], list)
|
||
# Both halves should be non-empty (16 court cards in the seed,
|
||
# minus 1 sig → 15 cards split ~7/8).
|
||
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
|
||
# No card appears in both halves.
|
||
levity_ids = {c["id"] for c in deck["levity"]}
|
||
gravity_ids = {c["id"] for c in deck["gravity"]}
|
||
self.assertEqual(levity_ids & gravity_ids, set())
|
||
|
||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_user_significator_excluded_from_drawn_deck(self):
|
||
"""The user's significator (pinned in `.sea-pos-core`) must NOT
|
||
appear in the gravity or levity deck halves — would otherwise
|
||
let the same card show up twice in the layout."""
|
||
import json as _json
|
||
picker = self._enter_picker_phase()
|
||
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
|
||
deck = _json.loads(data_el.get_attribute("textContent"))
|
||
all_ids = {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]}
|
||
self.assertNotIn(self.target_card.id, all_ids)
|
||
|
||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_levity_click_then_flip_deposits_card_into_first_sao_slot(self):
|
||
"""Default spread = SAO; first slot = `.sea-pos-lay` per the
|
||
DRAW_ORDER spec. Clicking LEVITY → FLIP → the first drawn card
|
||
lands in lay's `.sea-card-slot` w. `--filled` + `--levity`
|
||
classes + corner_rank text content from the deck card."""
|
||
picker = self._enter_picker_phase()
|
||
self._draw_one(picker, "levity")
|
||
slot = self.wait_for(
|
||
lambda: picker.find_element(
|
||
By.CSS_SELECTOR,
|
||
".sea-pos-lay .sea-card-slot.sea-card-slot--filled",
|
||
)
|
||
)
|
||
self.assertIn("sea-card-slot--levity", slot.get_attribute("class"))
|
||
# Card has a corner-rank rendered inside.
|
||
slot.find_element(By.CSS_SELECTOR, ".fan-corner-rank")
|
||
# Slot has a data-card-id attribute set to the deposited card's id.
|
||
self.assertTrue(slot.get_attribute("data-card-id"))
|
||
|
||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_two_draws_fill_first_two_slots_in_draw_order(self):
|
||
"""SAO draw order = lay → cover → crown. Second draw lands in
|
||
`.sea-pos-cover` regardless of polarity. Polarity of each
|
||
slot reflects which swatch was clicked."""
|
||
picker = self._enter_picker_phase()
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "gravity")
|
||
# First slot (lay) — levity
|
||
lay = picker.find_element(
|
||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
||
)
|
||
self.assertIn("sea-card-slot--levity", lay.get_attribute("class"))
|
||
# Second slot (cover) — gravity
|
||
cover = picker.find_element(
|
||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot.sea-card-slot--filled"
|
||
)
|
||
self.assertIn("sea-card-slot--gravity", cover.get_attribute("class"))
|
||
|
||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
|
||
"""Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
|
||
DRAW (`data-state="auto-draw"`); when the final card lands, JS
|
||
transitions it to GATE VIEW (`data-state="gate-view"`, label =
|
||
"GATE VIEW")."""
|
||
picker = self._enter_picker_phase()
|
||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||
self.assertIn("AUTO", action_btn.text.upper())
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "gravity")
|
||
# Third draw completes the SAO hand — action btn becomes GATE VIEW.
|
||
self.wait_for(
|
||
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
|
||
)
|
||
self.assertIn("GATE", action_btn.text.upper())
|
||
|
||
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_del_btn_is_disabled_until_hand_complete(self):
|
||
"""Iter-4c — DEL btn renders `.btn-disabled` server-side until
|
||
the hand is complete (per spec: the 24h free-draw quota is
|
||
committed at first-card-draw, can't be refunded by an early
|
||
DEL). Once the hand fills, JS removes `.btn-disabled` from DEL."""
|
||
picker = self._enter_picker_phase()
|
||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||
self._draw_one(picker, "levity")
|
||
# Mid-draw — still disabled.
|
||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "gravity")
|
||
# Hand complete — DEL un-disables (clicking now opens guard portal).
|
||
self.wait_for(
|
||
lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
|
||
)
|
||
|
||
# ── Test 7 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_hand_completion_locks_picker_state(self):
|
||
"""Iter-4c — when the final card lands (manual or AUTO DRAW),
|
||
the picker gains `.my-sea-picker--locked`; further deck-stack
|
||
clicks still SHOW the FLIP btn (so the user can see why no
|
||
further drawing is allowed) but the FLIP carries `.btn-disabled`
|
||
+ cards no longer fire on its click. No discrete LOCK HAND
|
||
action; the transition is automatic on hand-completion."""
|
||
picker = self._enter_picker_phase()
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "gravity")
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
|
||
)
|
||
)
|
||
|
||
# ── Test 8 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_first_draw_locks_spread_combobox(self):
|
||
"""Iter-4c — once the first card lands, the SPREAD combobox
|
||
carries `.sea-select--locked` for the rest of the quota window.
|
||
The spread is committed at first-card moment (server-side too:
|
||
any later POST w. a different spread → 409); no client-side
|
||
unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
|
||
`.btn-disabled` pre-completion → no reset pathway.)"""
|
||
picker = self._enter_picker_phase()
|
||
self._draw_one(picker, "levity")
|
||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||
self.wait_for(
|
||
lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
|
||
)
|
||
|
||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_form_col_renders_decks_lock_hand_del_and_reversal_pct(self):
|
||
"""Form col carries the DECKS swatches (GRAVITY + LEVITY), the
|
||
LOCK HAND `.btn-primary`, the DEL `.btn-danger`, and the
|
||
reversal-percentage caption (default 25%)."""
|
||
picker = self._enter_picker_phase()
|
||
# DECKS — two stacks
|
||
stacks = picker.find_elements(By.CSS_SELECTOR, ".sea-deck-stack")
|
||
self.assertEqual(len(stacks), 2)
|
||
names = "|".join(
|
||
s.find_element(By.CSS_SELECTOR, ".sea-stack-name").text.upper()
|
||
for s in stacks
|
||
)
|
||
self.assertIn("GRAVITY", names)
|
||
self.assertIn("LEVITY", names)
|
||
# Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
|
||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||
self.assertIn("AUTO", action_btn.text.upper())
|
||
self.assertIn("btn-primary", action_btn.get_attribute("class"))
|
||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||
# DEL renders w. `.btn-disabled` pre-completion (the `×` overlay
|
||
# is CSS-only; raw text content is still "DEL" in the DOM).
|
||
# Assert on class state — `.text` returns the visible glyph
|
||
# rendered by the pseudo-element layer.
|
||
self.assertIn("btn-danger", delbtn.get_attribute("class"))
|
||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||
# Reversal % caption — default 25
|
||
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
||
self.assertIn("25", hint.text)
|
||
self.assertIn("reversal", hint.text.lower())
|
||
|
||
# ── Test (modal bug fix) ────────────────────────────────────────────────
|
||
|
||
def test_flip_click_opens_portaled_stage_modal(self):
|
||
"""Bug fix (2026-05-19): the user-reported missing modal. After
|
||
clicking the deck stack + the FLIP btn that appears, SeaDeal.
|
||
openStage should fire — showing `#id_sea_stage` (position-fixed
|
||
full-viewport portal) above everything else. Before the fix the
|
||
slot got filled directly at opacity 0 → 'thumbnail summarily
|
||
disappears'. Now: modal opens; slot stays at `--filled` but
|
||
`--visible` is NOT added yet (waits for backdrop dismiss)."""
|
||
picker = self._enter_picker_phase()
|
||
stage = self._draw_open_modal(picker, "levity")
|
||
# Stage card carries the drawn card's data — non-empty corner rank.
|
||
rank = stage.find_element(
|
||
By.CSS_SELECTOR, ".sea-stage-card .fan-card-corner--tl .fan-corner-rank"
|
||
)
|
||
self.assertTrue(rank.text.strip(), "stage card should display the drawn card's corner rank")
|
||
# Slot in the cross is in `.--filled` state but the thumbnail is
|
||
# invisible until the modal dismisses (the bug we're guarding).
|
||
slot = picker.find_element(
|
||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
||
)
|
||
self.assertNotIn(
|
||
"sea-card-slot--visible", slot.get_attribute("class"),
|
||
"slot should still be in pre-reveal opacity-0 state while modal is open",
|
||
)
|
||
|
||
# ── Test (modal bug fix, dismiss reveal) ───────────────────────────────
|
||
|
||
def test_backdrop_click_dismisses_modal_and_reveals_thumbnail(self):
|
||
"""Bug fix part 2: clicking the `.sea-stage-backdrop` closes the
|
||
modal AND adds `.sea-card-slot--visible` to the deposited slot,
|
||
making the thumbnail fade in. Confirms the user-reported 'card
|
||
appears where the slot was' behavior post-dismiss."""
|
||
picker = self._enter_picker_phase()
|
||
self._draw_open_modal(picker, "levity")
|
||
self._dismiss_modal()
|
||
slot = picker.find_element(
|
||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
||
)
|
||
self.assertIn(
|
||
"sea-card-slot--visible", slot.get_attribute("class"),
|
||
"post-dismiss, the slot should fade in via `.--visible`",
|
||
)
|
||
|
||
# ── Test (modal bug fix, stat block populates) ─────────────────────────
|
||
|
||
def test_modal_stage_renders_stat_block_dom_contract(self):
|
||
"""SeaDeal._populate populates the stat-block keyword `<ul>`s
|
||
via `#id_sea_stat_upright` / `#id_sea_stat_reversed`. The DOM
|
||
contract — these IDs exist inside the stage — is what this FT
|
||
pins; the actual stat content (keyword text, qualifier render)
|
||
is exercised by [[SeaDealSpec.js]]. Earthman seed cards in the
|
||
iter-4a FT pile carry empty keyword arrays so we can't assert
|
||
text content here without enriching the seed."""
|
||
picker = self._enter_picker_phase()
|
||
self._draw_open_modal(picker, "levity")
|
||
# Stat-block UL elements exist inside the visible stage.
|
||
upright = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_upright")
|
||
reversed_ul = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_reversed")
|
||
self.assertIsNotNone(upright)
|
||
self.assertIsNotNone(reversed_ul)
|
||
# The sea stat block is inside the visible stage modal.
|
||
stat_block = self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
|
||
)
|
||
self.assertIsNotNone(stat_block)
|
||
|
||
|
||
class MySeaLockHandTest(FunctionalTest):
|
||
"""Sprint 5 iter 4b — server persistence + DEL guard.
|
||
|
||
Iter 4a left the locked hand purely client-side; this iter persists
|
||
it via a `MySeaDraw` model so:
|
||
- reload restores the locked hand (picker renders w. all positions
|
||
already filled + locked)
|
||
- a 24-hour free-draw quota applies (user gets 1 draw per 24h
|
||
irrespective of spread type)
|
||
- the landing phase is bypassed when a saved draw exists
|
||
- DEL on a locked hand opens a uniform guard portal (CONFIRM/NVM)
|
||
- a Brief banner accompanies the picker post-lock w. the next
|
||
free-draw timestamp + NVM to dismiss
|
||
|
||
Per-modal interactivity (NVM dismiss UX, button-enabled state on
|
||
saved-hand init) defers to Jasmine — this FT pins only the
|
||
integration paths the server is responsible for.
|
||
"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "lock@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
self.target_card = _assign_sig(self.gamer)
|
||
|
||
def _save_draw_for_user(self, hand=None):
|
||
"""Persist a MySeaDraw row for self.gamer directly, bypassing the
|
||
LOCK HAND UI. Returns the saved draw. Used by tests that pin the
|
||
post-lock UX without re-walking the 3-card draw flow each time."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
if hand is None:
|
||
# Pick three cards from the user's deck (excluding sig)
|
||
from apps.epic.models import TarotCard
|
||
cards = list(TarotCard.objects.exclude(
|
||
id=self.target_card.id
|
||
)[:3])
|
||
hand = [
|
||
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
||
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
||
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
||
]
|
||
return MySeaDraw.objects.create(
|
||
user=self.gamer,
|
||
spread="situation-action-outcome",
|
||
hand=hand,
|
||
significator_id=self.target_card.id,
|
||
significator_reversed=False,
|
||
)
|
||
|
||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_saved_draw_bypasses_landing_renders_picker_phase_directly(self):
|
||
"""User with a MySeaDraw row lands directly on [data-phase='picker']
|
||
— the landing (FREE DRAW + 6-chair hex) is skipped, since the
|
||
free quota is already spent and the locked hand is what the user
|
||
should see."""
|
||
self._save_draw_for_user()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
page = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||
)
|
||
)
|
||
self.assertIsNotNone(page)
|
||
# FREE DRAW landing chair-hex should not be visible.
|
||
landings = self.browser.find_elements(
|
||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
|
||
)
|
||
self.assertEqual(landings, [])
|
||
|
||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_saved_draw_renders_saved_hand_in_picker_slots(self):
|
||
"""The picker phase renders each saved position's slot as
|
||
`--filled` + carries the saved card's id in `data-card-id` +
|
||
the saved polarity class (`--gravity` / `--levity`)."""
|
||
draw = self._save_draw_for_user()
|
||
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[data-phase='picker']"
|
||
)
|
||
)
|
||
for entry in draw.hand:
|
||
slot = self.browser.find_element(
|
||
By.CSS_SELECTOR,
|
||
f".sea-pos-{entry['position']} .sea-card-slot.sea-card-slot--filled",
|
||
)
|
||
self.assertEqual(
|
||
slot.get_attribute("data-card-id"), str(entry["card_id"]),
|
||
f"slot for position {entry['position']} should carry the saved card id",
|
||
)
|
||
self.assertIn(
|
||
f"sea-card-slot--{entry['polarity']}",
|
||
slot.get_attribute("class"),
|
||
)
|
||
|
||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_saved_draw_renders_brief_banner_with_next_free_draw_timestamp(self):
|
||
"""Post-lock UX: a Look!-formatted Brief banner appears atop the
|
||
h2 (standard portaled `.note-banner` w. Gaussian-glass bg, same
|
||
styling as my-notes / my-sign default-deck-warning Briefs). The
|
||
next-free-draw timestamp lives in the dedicated `.note-banner__
|
||
timestamp` `<time>` slot (note.js's standard datetime element),
|
||
formatted by JS to `D, M j @ g:i A` shape — e.g. "Wed, May 20 @
|
||
11:57 PM". Tagged `.my-sea-locked-banner` so this FT disambiguates
|
||
from any other Briefs that may stack on the page."""
|
||
self._save_draw_for_user()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
brief = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".note-banner.my-sea-locked-banner"
|
||
)
|
||
)
|
||
text = brief.text
|
||
self.assertIn("Look!", text)
|
||
self.assertIn("free draw", text.lower())
|
||
# Timestamp slot owns the next-free-draw datetime. The "@" token
|
||
# in the `D, M j @ g:i A` format is a stable assertion target;
|
||
# also pin the year to confirm the source ISO parsed correctly
|
||
# (would render "Invalid Date" if note.js got an empty string).
|
||
ts = brief.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
|
||
ts_text = ts.text
|
||
self.assertIn("@", ts_text)
|
||
self.assertNotIn("Invalid", ts_text)
|
||
# NVM dismiss button is wired by note.js itself.
|
||
brief.find_element(By.CSS_SELECTOR, ".note-banner__nvm")
|
||
|
||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_del_click_opens_shared_guard_portal(self):
|
||
"""DEL on a locked hand opens the shared `#id_guard_portal` from
|
||
base.html (same Gaussian-glass tooltip the room gear-menu uses)
|
||
w. uniform 'Are you sure?' copy + the standard `.btn-confirm OK`
|
||
+ `.btn-cancel NVM` button pair. The Brief banner above carries
|
||
the quota-specific info, so the portal stays text-free of
|
||
conditional wording."""
|
||
self._save_draw_for_user()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
picker = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||
)
|
||
)
|
||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||
delbtn.click()
|
||
portal = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_guard_portal.active"
|
||
)
|
||
)
|
||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||
self.assertIn("sure", portal.text.lower())
|
||
portal.find_element(By.CSS_SELECTOR, ".guard-yes")
|
||
portal.find_element(By.CSS_SELECTOR, ".guard-no")
|
||
|
||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
|
||
"""Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
|
||
POSTs to the delete endpoint → server CLEARS the hand JSON but
|
||
preserves the MySeaDraw row (quota tracker stays running for the
|
||
24h window). Reload lands on the table-hex landing — but the
|
||
primary nav btn is GATE VIEW (`#id_my_sea_gate_view_btn`), NOT
|
||
FREE DRAW, since the quota's spent until the row expires."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
self._save_draw_for_user()
|
||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
picker = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||
)
|
||
)
|
||
picker.find_element(By.CSS_SELECTOR, "#id_sea_del").click()
|
||
confirm = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
|
||
)
|
||
)
|
||
confirm.click()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
|
||
)
|
||
)
|
||
# Row preserved as quota tracker; hand wiped.
|
||
rows = MySeaDraw.objects.filter(user=self.gamer)
|
||
self.assertEqual(rows.count(), 1)
|
||
self.assertEqual(rows.first().hand, [])
|
||
# Landing renders GATE VIEW (not FREE DRAW) per iter-4c spec.
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||
0,
|
||
)
|
||
|
||
|
||
# ── Sprint 6 — my-sea gatekeeper (token-deposit-to-redraw within 24h) ─────────
|
||
# FT skeleton written TDD-first per user spec 2026-05-20. See
|
||
# [[sprint-my-sea-iter-6-plan]] for the full spec; iters 6a/6b/6c break
|
||
# the work into three commits but the FTs describe the user-facing
|
||
# behavior end-to-end so the impl can converge against them.
|
||
|
||
|
||
class MySeaGatekeeperPageTest(FunctionalTest):
|
||
"""Sprint 6 iter 6a — `/gameboard/my-sea/gate/` renders the solo
|
||
gatekeeper UI. Coin-slot rails (INSERT TOKEN TO PLAY) + 6-chair hex
|
||
(seat 1 always reserved for owner, others banned). One-token-per-
|
||
draw, refundable until PAID DRAW commits."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "gate@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
_assign_sig(self.gamer)
|
||
# Seed a FREE token so the gatekeeper has something to deposit.
|
||
from datetime import timedelta
|
||
from django.utils import timezone as dj_tz
|
||
from apps.lyric.models import Token
|
||
Token.objects.create(
|
||
user=self.gamer, token_type=Token.FREE,
|
||
expires_at=dj_tz.now() + timedelta(days=30),
|
||
)
|
||
|
||
def _save_empty_hand_draw(self):
|
||
"""Quota-spent state: an active MySeaDraw row w. empty hand
|
||
(post-DEL or post-completion-DEL). This is the canonical state
|
||
where the gatekeeper is meaningful."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
return MySeaDraw.objects.create(
|
||
user=self.gamer, spread="situation-action-outcome",
|
||
significator_id=self.gamer.significator_id, hand=[],
|
||
)
|
||
|
||
def test_gatekeeper_page_renders_token_rails_in_empty_state(self):
|
||
"""No deposit yet → coin-slot shows INSERT TOKEN TO PLAY + the
|
||
rails are active (click-target). No refund btn yet."""
|
||
self._save_empty_hand_draw()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
rails_form = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "form[action$='/my-sea/insert']"
|
||
)
|
||
)
|
||
rails_form.find_element(By.CSS_SELECTOR, "button.token-rails")
|
||
# No refund btn or PAID DRAW yet.
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(
|
||
By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
|
||
)),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(
|
||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||
)),
|
||
0,
|
||
)
|
||
|
||
def test_gatekeeper_renders_six_chair_seats_with_seat1_seated(self):
|
||
"""Hex w. 6 chair seats; seat 1 is the owner's (always `.seated`
|
||
when quota is committed); seats 2-6 carry `.fa-ban` (placeholders
|
||
for the future friend-invite feature)."""
|
||
self._save_empty_hand_draw()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
self.wait_for(
|
||
lambda: self._assert_seats(6)
|
||
)
|
||
seat1 = self.browser.find_element(
|
||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||
)
|
||
self.assertIn("seated", seat1.get_attribute("class"))
|
||
seat1.find_element(By.CSS_SELECTOR, ".fa-circle-check")
|
||
|
||
def _assert_seats(self, count):
|
||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||
if len(seats) != count:
|
||
raise AssertionError(f"expected {count} seats, got {len(seats)}")
|
||
return seats
|
||
|
||
def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self):
|
||
"""Click INSERT TOKEN → server reserves the user's next-priority
|
||
token on the MySeaDraw row; gatekeeper re-renders w. refund btn
|
||
+ `#id_my_sea_paid_draw_btn` visible."""
|
||
self._save_empty_hand_draw()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
rails = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR,
|
||
"form[action$='/my-sea/insert'] button.token-rails",
|
||
)
|
||
)
|
||
rails.click()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||
)
|
||
)
|
||
# Refund affordance is now present.
|
||
self.browser.find_element(
|
||
By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
|
||
)
|
||
# Server-side: MySeaDraw row has deposit_token_id set.
|
||
from apps.gameboard.models import MySeaDraw
|
||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||
self.assertIsNotNone(draw.deposit_token_id)
|
||
|
||
def test_refund_clears_deposit_and_returns_to_empty_state(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
self._save_empty_hand_draw()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR,
|
||
"form[action$='/my-sea/insert'] button.token-rails",
|
||
)
|
||
).click()
|
||
refund = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR,
|
||
"form[action$='/my-sea/refund'] button",
|
||
)
|
||
)
|
||
refund.click()
|
||
# After refund, INSERT TOKEN form is back; PAID DRAW gone.
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR,
|
||
"form[action$='/my-sea/insert']",
|
||
)
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(
|
||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||
)),
|
||
0,
|
||
)
|
||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||
self.assertIsNone(draw.deposit_token_id)
|
||
|
||
def test_paid_draw_commits_token_and_redirects_to_picker(self):
|
||
"""PAID DRAW commits the deposited token (FREE token gets
|
||
consumed → user's token count drops by 1); server resets the
|
||
MySeaDraw row (hand=[], created_at=now, deposit cleared); user
|
||
lands back on /gameboard/my-sea/ ready to draw a fresh hand."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
self._save_empty_hand_draw()
|
||
free_count_before = Token.objects.filter(
|
||
user=self.gamer, token_type=Token.FREE,
|
||
).count()
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR,
|
||
"form[action$='/my-sea/insert'] button.token-rails",
|
||
)
|
||
).click()
|
||
paid_draw = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||
)
|
||
)
|
||
paid_draw.click()
|
||
# Redirect lands on /gameboard/my-sea/ (landing or picker).
|
||
self.wait_for(
|
||
lambda: self.assertIn("/gameboard/my-sea/", self.browser.current_url)
|
||
)
|
||
# FREE token consumed.
|
||
self.assertEqual(
|
||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(),
|
||
free_count_before - 1,
|
||
)
|
||
# MySeaDraw row: hand reset to empty, deposit cleared, fresh quota.
|
||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||
self.assertEqual(draw.hand, [])
|
||
self.assertIsNone(draw.deposit_token_id)
|
||
|
||
|
||
class MySeaLandingPaidDrawTest(FunctionalTest):
|
||
"""Sprint 6 iter 6b — landing center-btn state machine extended w.
|
||
PAID DRAW. After depositing in the gatekeeper, refresh / navigate
|
||
back to /gameboard/my-sea/ → landing renders PAID DRAW (not GATE
|
||
VIEW) at the hex center."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "landpaid@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
_assign_sig(self.gamer)
|
||
|
||
def test_landing_shows_paid_draw_btn_when_deposit_reserved(self):
|
||
from datetime import timedelta
|
||
from django.utils import timezone as dj_tz
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
free_tok = Token.objects.create(
|
||
user=self.gamer, token_type=Token.FREE,
|
||
expires_at=dj_tz.now() + timedelta(days=30),
|
||
)
|
||
MySeaDraw.objects.create(
|
||
user=self.gamer, spread="situation-action-outcome",
|
||
significator_id=self.gamer.significator_id, hand=[],
|
||
deposit_token_id=free_tok.pk,
|
||
deposit_reserved_at=dj_tz.now(),
|
||
)
|
||
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_my_sea_paid_draw_btn"
|
||
)
|
||
)
|
||
# FREE DRAW + GATE VIEW are NOT shown when a deposit is reserved.
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")),
|
||
0,
|
||
)
|
||
|
||
|
||
class MySeaNavbarGateViewTest(FunctionalTest):
|
||
"""Sprint 6 iter 6b — navbar CONT GAME swaps to GATE VIEW whenever
|
||
the user is on `body.page-my-sea`. Always reachable, regardless of
|
||
quota state — clicking takes the user to /gameboard/my-sea/gate/."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "navgate@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
_assign_sig(self.gamer)
|
||
|
||
def test_navbar_renders_gate_view_btn_on_my_sea_page(self):
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
nav = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||
)
|
||
# GATE VIEW btn present, CONT GAME btn not.
|
||
nav.find_element(By.CSS_SELECTOR, "#id_navbar_gate_view_btn")
|
||
self.assertEqual(
|
||
len(nav.find_elements(By.CSS_SELECTOR, "#id_navbar_cont_game_btn")),
|
||
0,
|
||
)
|
||
|
||
|
||
class MySeaSeatOnePersistenceTest(FunctionalTest):
|
||
"""Sprint 6 iter 6b — seat 1 (the owner's reserved chair) renders
|
||
`.seated` whenever the user's hand is non-empty (mid-draw or
|
||
complete). DEL empties the hand → seat 1 reverts to `.fa-ban`."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "seat1@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
_assign_sig(self.gamer)
|
||
|
||
def test_seat_1_banned_for_fresh_user_no_quota(self):
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
seat1 = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||
)
|
||
)
|
||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||
seat1.find_element(By.CSS_SELECTOR, ".fa-ban")
|
||
|
||
def test_seat_1_banned_when_active_draw_has_empty_hand(self):
|
||
"""DEL leaves the row but wipes the hand; seat 1 reverts to
|
||
banned (per user spec 2026-05-20: seat 1 tied to hand non-empty,
|
||
NOT to active_draw existence)."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.create(
|
||
user=self.gamer, spread="situation-action-outcome",
|
||
significator_id=self.gamer.significator_id, hand=[],
|
||
)
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
seat1 = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||
)
|
||
)
|
||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||
|
||
|
||
class MySeaBudBtnStubTest(FunctionalTest):
|
||
"""Sprint 6 iter 6c — bud-btn invite panel rendered on the
|
||
gatekeeper. Panel opens + autocomplete works (reuses billboard:
|
||
search_buds), but the OK btn is a no-op stub — POSTs return a
|
||
'Multiplayer my-sea coming soon' Brief banner. Async invite is
|
||
deferred to a future sprint."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "bud@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
_assign_sig(self.gamer)
|
||
# Seed a quota row so the gatekeeper has context.
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.create(
|
||
user=self.gamer, spread="situation-action-outcome",
|
||
significator_id=self.gamer.significator_id, hand=[],
|
||
)
|
||
|
||
def test_bud_btn_panel_opens_on_gatekeeper(self):
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
bud_btn = self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||
)
|
||
bud_btn.click()
|
||
# Panel opens — html.bud-open class is added.
|
||
self.wait_for(
|
||
lambda: self.assertIn(
|
||
"bud-open",
|
||
self.browser.find_element(By.TAG_NAME, "html").get_attribute("class"),
|
||
)
|
||
)
|
||
self.browser.find_element(By.ID, "id_recipient")
|
||
|
||
def test_bud_btn_ok_renders_coming_soon_brief(self):
|
||
from apps.lyric.models import User as _U
|
||
# Seed a friend so the OK click has a recipient to "invite".
|
||
_U.objects.create(email="friend@test.io")
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
bud_btn = self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||
)
|
||
bud_btn.click()
|
||
recipient = self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_recipient")
|
||
)
|
||
# Wait for slide-out animation to settle (per
|
||
# [[feedback-css-transition-selenium-click-race]]).
|
||
self.wait_for(
|
||
lambda: self.assertGreater(
|
||
self.browser.execute_script(
|
||
"return document.getElementById('id_bud_panel').getBoundingClientRect().width;"
|
||
),
|
||
100,
|
||
)
|
||
)
|
||
recipient.send_keys("friend@test.io")
|
||
self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||
).click()
|
||
# Brief banner appears w. coming-soon copy.
|
||
brief = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
|
||
)
|
||
self.assertIn("coming soon", brief.text.lower())
|