Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED invitee can watch the owner's my-sea read-only, deposit a token to occupy seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's my_sea.html is left structurally intact — the spectator gets a dedicated, simpler my_sea_visit.html; the read-only draw reuses the existing `latest_draw_slots` payload (no picker surgery). - B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED SeaInvite(owner, request.user); owner bounced to their own my_sea. Context forces owner-only controls off (sea_btn_active=False, read_only=True); renders the table hex (1C owner / 2C visitor) + owner draw read-only. - B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a spectator branch (titles the OWNER's Sea, INSERT posts to the visitor endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step my_sea_visit_insert_token selects+debits the visitor's token (same priority chain) and records token_deposited_at + a 24h voice_until on the SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW. - B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at, clears voice_until (frees 2C, ends voice), redirects /gameboard/. _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages pass no leave_url, so unchanged). - B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a per-occupancy data-seat-token) an occupied seat flares --terUser + --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban already swapped to .fa-circle-check). _room.scss adds .seated / .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's .active→.role-confirmed handoff). Wired on BOTH the spectator page (load) and the owner page (load + on the FREE DRAW seat-1 transition). MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal. - B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit → VIEW DRAW + seat 2C seated. URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice + coturn droplet) next — the 24h voice_until window set here drives it. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2052 lines
97 KiB
Python
2052 lines
97 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 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-<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/ 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 `<a href>` 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 <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()
|
||
_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 `<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()
|
||
_open_spread_modal(self) # action btn lives in the spread modal
|
||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||
# innerText (via JS) works on hidden elements; selenium .text would
|
||
# return "" once we close the modal below.
|
||
self.assertIn("AUTO", self.browser.execute_script(
|
||
"return arguments[0].innerText", action_btn).upper())
|
||
# Close the modal so the deck-stack FLIP clicks below aren't
|
||
# intercepted by the modal backdrop (which covers the whole
|
||
# viewport while open).
|
||
self.browser.execute_script(
|
||
"document.getElementById('id_sea_spread_modal').setAttribute('hidden', '')"
|
||
)
|
||
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.
|
||
# data-state attribute is readable even while the modal is hidden.
|
||
self.wait_for(
|
||
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
|
||
)
|
||
self.assertIn("GATE", self.browser.execute_script(
|
||
"return arguments[0].innerText", action_btn).upper())
|
||
|
||
# ── Test 5b — AUTO DRAW slot click reopens preview modal ────────────────
|
||
|
||
def test_auto_drawn_slots_can_reopen_stage_modal_on_click(self):
|
||
"""Iter-6c bug-fix (user-reported 2026-05-21): cards placed by
|
||
AUTO DRAW were silently un-clickable — the inline `_fillSlot`
|
||
bypassed `SeaDeal.openStage` so SeaDeal's `_seaHand` dict was
|
||
never populated for those slots, and the overlay click handler
|
||
short-circuits on `if (!_seaHand[pos]) return;` → no modal.
|
||
|
||
Reproduce: draw one card manually (sets `_seaHand[lay]`), AUTO
|
||
DRAW the rest (sets `_seaHand[lay]` still, but NOT cover/crown
|
||
if the bug exists), then click an auto-drawn slot — should
|
||
re-open the stage modal w. that card's preview.
|
||
|
||
Fix landed: `SeaDeal.register(card, posSelector, isLevity)`
|
||
public method populates `_seaHand` + delegates to SeaDeal's
|
||
internal `_fillSlot` (so `dataset.posKey` stays consistent w.
|
||
the manual-FLIP path). AUTO DRAW now calls register, not the
|
||
inline shim."""
|
||
picker = self._enter_picker_phase()
|
||
# Manual draw: lay (first position in SAO order).
|
||
self._draw_one(picker, "levity")
|
||
self.assertEqual(_count_filled_slots(picker), 1)
|
||
|
||
# AUTO DRAW the remaining 2 (cover + crown). Reopen modal —
|
||
# the first draw above closed it via AUTO DRAW's modal-close
|
||
# capture handler... actually no, manual FLIP doesn't close the
|
||
# modal. But the modal may have been auto-dismissed by some
|
||
# later interaction; opening defensively keeps the test robust.
|
||
_open_spread_modal(self)
|
||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||
action_btn.click()
|
||
confirm = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
|
||
)
|
||
)
|
||
confirm.click()
|
||
# Wait for hand-complete transition (action btn → GATE VIEW).
|
||
self.wait_for(
|
||
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
|
||
)
|
||
# All 3 slots filled.
|
||
self.wait_for(lambda: self.assertEqual(_count_filled_slots(picker), 3))
|
||
|
||
# Click an AUTO-drawn slot (cover — second in SAO draw order, so
|
||
# placed by AUTO DRAW). Two-tap pattern: first click focuses,
|
||
# second click opens the modal (per SeaDeal's overlay handler).
|
||
cover_slot = picker.find_element(
|
||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
|
||
)
|
||
cover_slot.click()
|
||
cover_slot.click()
|
||
# Stage modal must open w. the card preview.
|
||
stage = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
|
||
)
|
||
self.wait_for(lambda: self.assertTrue(stage.is_displayed()))
|
||
|
||
# ── Test 5c — manual draw survives refresh ──────────────────────────────
|
||
|
||
def test_manual_draw_persists_on_refresh(self):
|
||
"""User-reported 2026-05-21: manually-drawn cards disappeared
|
||
after a refresh. Root cause: SeaDeal's `_fillSlot` stamps
|
||
`slot.dataset.posKey` w. the selector form (".sea-pos-cover"),
|
||
but the inline `_collectHandFromDom` looks up by raw name from
|
||
`_currentOrder()` ("cover"). The key mismatch means manual
|
||
draws are dropped from the POST body — server stores nothing
|
||
→ refresh shows empty state.
|
||
|
||
Fix: standardize on raw position names for `dataset.posKey`."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
picker = self._enter_picker_phase()
|
||
self._draw_one(picker, "levity")
|
||
# POST is async — wait for server commit by polling the DB.
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(MySeaDraw.objects.get(user=self.gamer).hand), 1,
|
||
)
|
||
)
|
||
self.browser.refresh()
|
||
picker = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||
)
|
||
)
|
||
self.assertEqual(_count_filled_slots(picker), 1)
|
||
|
||
# ── Test 5d — reloaded slot re-opens stage modal on click ───────────────
|
||
|
||
def test_reloaded_slot_can_reopen_stage_modal_on_click(self):
|
||
"""After refresh, server-rendered filled slots must remain
|
||
clickable to re-open the stage modal. Requires SeaDeal's init
|
||
to seed `_seaHand` from the embedded deck JSON + the saved
|
||
slots in the DOM. User-reported 2026-05-21."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
picker = self._enter_picker_phase()
|
||
self._draw_one(picker, "levity")
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(MySeaDraw.objects.get(user=self.gamer).hand), 1,
|
||
)
|
||
)
|
||
self.browser.refresh()
|
||
slot = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot--filled"
|
||
)
|
||
)
|
||
slot.click() # first tap — focus
|
||
slot.click() # second tap — open modal
|
||
stage = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
|
||
)
|
||
self.wait_for(lambda: self.assertTrue(stage.is_displayed()))
|
||
|
||
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_del_btn_is_disabled_until_first_draw(self):
|
||
"""User spec 2026-05-26: DEL btn renders `.btn-disabled` only
|
||
UNTIL the first card lands (was: until hand complete). The 24h
|
||
free-draw quota is still committed at first-card-draw + can't be
|
||
refunded by an early DEL, but the user can DEL the in-progress
|
||
hand to start over within the cycle. JS `_setHasDrawn(true)` fires
|
||
on the first deposit + at the AUTO DRAW POST-commit moment."""
|
||
picker = self._enter_picker_phase()
|
||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||
# Pre-draw — disabled.
|
||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||
self._draw_one(picker, "levity")
|
||
# First card landed — DEL un-disables immediately.
|
||
self.wait_for(
|
||
lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
|
||
)
|
||
# Subsequent draws + completion — DEL stays enabled.
|
||
self._draw_one(picker, "levity")
|
||
self._draw_one(picker, "gravity")
|
||
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 (stay on page, OUTSIDE the spread modal).
|
||
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)
|
||
# Action btn + DEL + reversal hint live INSIDE the spread modal —
|
||
# open it before reading their .text (hidden elements return "").
|
||
_open_spread_modal(self)
|
||
# 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 + inner text "×"
|
||
# (case-by-case rendering per game-kit tooltip convention; user-
|
||
# spec 2026-05-20 dropped the universal pseudo-element ×).
|
||
self.assertIn("btn-danger", delbtn.get_attribute("class"))
|
||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||
self.assertEqual(delbtn.text, "×")
|
||
# 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
|
||
spend-moment 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.
|
||
|
||
Sprint 2026-05-26 (@taxman ledger) update — the Brief banner is
|
||
now server-driven via the `free_draw_brief_payload` context var,
|
||
which surfaces ONLY when a TAX_LEDGER Brief exists for the user.
|
||
`my_sea_lock` emits one automatically on the first card of a
|
||
cycle; this FT bypasses `my_sea_lock` by ORM-creating the
|
||
MySeaDraw row, so it must ALSO seed the matching tax-debit
|
||
Brief explicitly (mirrors what `my_sea_lock` would have done)."""
|
||
self._save_draw_for_user()
|
||
# Seed the TAX_LEDGER Brief the server-driven my_sea.html template
|
||
# gates on. Without this, `free_draw_brief_payload` is None in
|
||
# context + no banner renders. (Real `my_sea_lock` calls log_tax_
|
||
# debit on the first-card-of-cycle branch — this FT bypasses that
|
||
# path by ORM-creating MySeaDraw, so the seed has to be manual.)
|
||
from apps.billboard.tax import log_tax_debit
|
||
log_tax_debit(self.gamer, "free_draw_locked")
|
||
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']"
|
||
)
|
||
)
|
||
_open_spread_modal(self) # del btn lives in the spread modal
|
||
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']"
|
||
)
|
||
)
|
||
_open_spread_modal(self) # del btn lives in the spread modal
|
||
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)
|
||
# The User post_save signal auto-creates COIN + FREE tokens
|
||
# (apps.lyric.models). Clear them so the deposit-priority test
|
||
# below picks only the FREE we seed explicitly — otherwise
|
||
# COIN wins (PASS > COIN > FREE > TITHE) and the FREE-count
|
||
# assertion fails (per IT-trap memo, 2026-05-19).
|
||
self.gamer.tokens.all().delete()
|
||
# 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_no_hex_modal_only(self):
|
||
"""Per user spec 2026-05-20, the gatekeeper is a transient in-flight
|
||
UI — modal-over-`--duoUser` bg, NO hex / chair-seats. Seats live on
|
||
the my-sea picker page itself; the gatekeeper just offers the
|
||
coin-slot + (post-deposit) PAID DRAW affordance."""
|
||
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, ".my-sea-gate-modal"
|
||
)
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, ".table-hex")),
|
||
0,
|
||
)
|
||
|
||
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 consumed)
|
||
+ redirects to /gameboard/my-sea/?phase=picker so the user lands
|
||
directly in the picker. Under the 2026-05-23 spec update, the
|
||
row is PRESERVED w. `paid_through_at` set (NOT deleted as
|
||
iter-6c originally did) — preservation keeps the PAID DRAW btn
|
||
on the landing across navigation cycles until the first card is
|
||
drawn. See [[sprint-paid-draw-persistence-may23]] for the
|
||
rationale + the user-reported bug it fixes."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
draw = 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()
|
||
self.wait_for(
|
||
lambda: self.assertIn(
|
||
"/gameboard/my-sea/?phase=picker", self.browser.current_url
|
||
)
|
||
)
|
||
# FREE token consumed.
|
||
self.assertEqual(
|
||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(),
|
||
free_count_before - 1,
|
||
)
|
||
# Row preserved + paid_through_at stamped (sticky PAID DRAW state).
|
||
draw.refresh_from_db()
|
||
self.assertIsNotNone(draw.paid_through_at,
|
||
"PAID DRAW commit must set paid_through_at (preserves PAID "
|
||
"DRAW btn on subsequent landing renders — user-reported "
|
||
"regression 2026-05-23)")
|
||
self.assertIsNone(draw.deposit_token_id)
|
||
self.assertEqual(draw.hand, [])
|
||
|
||
def test_paid_draw_btn_persists_after_navigation_without_card_draw(self):
|
||
"""User-reported bug 2026-05-23: after PAID DRAW commits, if the
|
||
user navigates away without drawing any cards, the landing
|
||
button reverts to FREE DRAW (under the old delete-at-commit
|
||
semantics) even though the free draw is still on cooldown. With
|
||
`paid_through_at` preserved on the row, the landing PAID DRAW
|
||
button stays visible across navigation cycles until the first
|
||
card of the paid session lands."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
from django.utils import timezone as dj_tz
|
||
self._save_empty_hand_draw()
|
||
# Set cooldown anchor explicitly so the user-level state matches
|
||
# the row's "cycle is live" indication.
|
||
self.gamer.last_free_draw_at = dj_tz.now()
|
||
self.gamer.save(update_fields=["last_free_draw_at"])
|
||
self.create_pre_authenticated_session(self.email)
|
||
# Deposit + commit via the gatekeeper.
|
||
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()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||
)
|
||
).click()
|
||
# Land in picker (?phase=picker). Now navigate AWAY without
|
||
# drawing any cards — to /gameboard/ — then back to /gameboard/
|
||
# my-sea/. The landing must still show PAID DRAW (not FREE DRAW
|
||
# nor GATE VIEW).
|
||
self.wait_for(
|
||
lambda: self.assertIn("phase=picker", self.browser.current_url)
|
||
)
|
||
self.browser.get(self.live_server_url + "/gameboard/")
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_applet_new_game")
|
||
)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||
# PAID DRAW button still rendered on the landing.
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||
)
|
||
)
|
||
# FREE DRAW + GATE VIEW NOT shown.
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||
0,
|
||
"FREE DRAW btn must NOT show after PAID DRAW commit (regression)"
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(
|
||
By.CSS_SELECTOR, "#id_my_sea_gate_view_btn"
|
||
)),
|
||
0,
|
||
"GATE VIEW btn must NOT show while paid-through credit is set"
|
||
)
|
||
|
||
|
||
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 MySeaBudBtnInviteTest(FunctionalTest):
|
||
"""bud-btn invite panel on the my-sea gatekeeper. Panel opens +
|
||
autocomplete works (reuses billboard:search_buds); the OK btn now sends a
|
||
REAL invite (Phase A of [[my-sea-invite-voice-blueprint]]) — POSTs create
|
||
a SeaInvite + return an "invite sent" Brief banner. (Was a coming-soon
|
||
stub through iter 6c.)"""
|
||
|
||
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_sends_real_invite(self):
|
||
from apps.lyric.models import User as _U
|
||
from apps.gameboard.models import SeaInvite
|
||
# 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 confirms the invite is on its way.
|
||
brief = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
|
||
)
|
||
self.assertIn("on its way", brief.text.lower())
|
||
# A real SeaInvite row now exists for the invited friend.
|
||
self.assertTrue(
|
||
SeaInvite.objects.filter(invitee_email="friend@test.io").exists()
|
||
)
|
||
|
||
|
||
class MySeaInviteAcceptanceLogTest(FunctionalTest):
|
||
"""Phase A of [[my-sea-invite-voice-blueprint]] — an invited bud opens
|
||
their @mailman "Acceptances & rejections" Post and sees the invite line
|
||
with interactive OK / BYE buttons."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.email = "invited_bud@test.io"
|
||
self.invitee = User.objects.create(email=self.email)
|
||
|
||
def test_invitee_sees_invite_line_with_ok_bye(self):
|
||
from django.urls import reverse
|
||
from apps.billboard.mail import log_sea_invite
|
||
from apps.gameboard.models import SeaInvite
|
||
owner = User.objects.create(email="owner@test.io", username="discoman")
|
||
invite = SeaInvite.objects.create(
|
||
owner=owner, invitee=self.invitee, invitee_email=self.email,
|
||
)
|
||
post, _, _ = log_sea_invite(invite)
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(
|
||
self.live_server_url + reverse("billboard:view_post", args=[post.id])
|
||
)
|
||
body = self.browser.find_element(By.TAG_NAME, "body")
|
||
# @mailman prose + interactive OK/BYE both render on the invite line.
|
||
self.wait_for(lambda: self.assertIn("invites you to", body.text))
|
||
ok = self.browser.find_element(By.CSS_SELECTOR, ".invite-ok-btn")
|
||
bye = self.browser.find_element(By.CSS_SELECTOR, ".invite-bye-btn")
|
||
self.assertEqual(ok.text.upper(), "OK")
|
||
self.assertEqual(bye.text.upper(), "BYE")
|
||
|
||
|
||
class MySeaSpectatorFlowTest(FunctionalTest):
|
||
"""Phase B of [[my-sea-invite-voice-blueprint]] — an ACCEPTED invitee
|
||
visits the owner's my-sea, deposits a token at the visitor gate, and
|
||
thereby occupies seat 2C (the center btn flips GATE VIEW → VIEW DRAW)."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.browser.set_window_size(800, 1200) # portrait — center-hex clicks
|
||
_seed_gameboard_applets()
|
||
from apps.gameboard.models import MySeaDraw, SeaInvite
|
||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||
# Owner has drawn → seat 1C is occupied + there's a draw to view.
|
||
MySeaDraw.objects.create(
|
||
user=self.owner, spread="situation-action-outcome",
|
||
significator_id=1,
|
||
hand=[{"position": "lay", "card_id": 1, "reversed": False,
|
||
"polarity": "gravity"}],
|
||
)
|
||
self.email = "bud@test.io"
|
||
self.bud = User.objects.create(email=self.email, username="budster")
|
||
self.invite = SeaInvite.objects.create(
|
||
owner=self.owner, invitee=self.bud, invitee_email=self.email,
|
||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||
)
|
||
|
||
def test_spectator_deposits_token_to_occupy_seat_2c(self):
|
||
from django.urls import reverse
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(
|
||
self.live_server_url + reverse("my_sea_visit", args=[self.owner.id])
|
||
)
|
||
# Before deposit: GATE VIEW shown, seat 2C not yet seated.
|
||
gate_btn = self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_my_sea_gate_view_btn")
|
||
)
|
||
seat2 = self.browser.find_element(
|
||
By.CSS_SELECTOR, ".table-seat[data-slot='2']"
|
||
)
|
||
self.assertNotIn("seated", seat2.get_attribute("class"))
|
||
# GATE VIEW → visitor gate → INSERT TOKEN (single-step deposit).
|
||
gate_btn.click()
|
||
insert = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-rails")
|
||
)
|
||
insert.click()
|
||
# Back on the visit page: VIEW DRAW now shown, seat 2C seated.
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_my_sea_view_draw_btn")
|
||
)
|
||
seat2 = self.browser.find_element(
|
||
By.CSS_SELECTOR, ".table-seat[data-slot='2']"
|
||
)
|
||
self.assertIn("seated", seat2.get_attribute("class"))
|
||
|
||
|
||
class MySeaGearBtnTest(FunctionalTest):
|
||
"""Sprint 6 iter 6c — `.gear-btn` on every my-sea page state
|
||
(landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE
|
||
deliberately omitted — gear is for "back out without committing");
|
||
NVM nav-backs to /gameboard/ mirroring the room's gear-menu
|
||
convention. Same partial included on both /gameboard/my-sea/ and
|
||
/gameboard/my-sea/gate/ so visual + behavior is identical."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
_seed_earthman_sig_pile()
|
||
_seed_gameboard_applets()
|
||
self.email = "gear@test.io"
|
||
self.gamer = User.objects.create(email=self.email)
|
||
_assign_sig(self.gamer)
|
||
# Seed a quota row so the gatekeeper has an active_draw 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_gear_btn_renders_on_gatekeeper(self):
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
gear = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page .gear-btn"
|
||
)
|
||
)
|
||
# Menu wired via data-menu-target.
|
||
self.assertEqual(
|
||
gear.get_attribute("data-menu-target"),
|
||
"id_my_sea_menu",
|
||
)
|
||
|
||
def test_gear_btn_renders_on_landing_without_active_draw(self):
|
||
"""User direction 2026-05-20 — gear stays on /gameboard/my-sea/
|
||
even when no gatekeeper / no active_draw row exists. Fresh user
|
||
with a sig + no draw lands here + still sees the gear."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.filter(user=self.gamer).delete()
|
||
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 .gear-btn"
|
||
)
|
||
)
|
||
|
||
def test_gear_btn_opens_menu_with_nvm_only(self):
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
gear = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page .gear-btn"
|
||
)
|
||
)
|
||
gear.click()
|
||
menu = self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_my_sea_menu")
|
||
)
|
||
self.assertEqual(menu.value_of_css_property("display"), "block")
|
||
# NVM btn present, DEL + BYE deliberately absent. NVM is a `<button
|
||
# onclick="location.href=...">` (NOT an `<a class="btn">`) per 2026-
|
||
# 05-26 fix — anchors inherit body's serif font, buttons stay sans-
|
||
# serif. Check the onclick attr for the nav target instead of href.
|
||
nvm = menu.find_element(By.CSS_SELECTOR, ".btn-cancel")
|
||
self.assertIn("/gameboard/", nvm.get_attribute("onclick") or "")
|
||
self.assertEqual(
|
||
len(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")), 0,
|
||
)
|
||
self.assertEqual(
|
||
len(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")), 0,
|
||
)
|
||
|
||
def test_nvm_navigates_back_to_my_sea_hex(self):
|
||
# NVM on the my-sea gatekeeper navigates back to the my-sea table hex
|
||
# (/gameboard/my-sea/), NOT out to /gameboard/ — changed 5cade51
|
||
# (gatekeeper + picker NVM → table hex; only the landing + sign-gate
|
||
# phases eject to /gameboard/). Sibling test_gear_btn_opens_menu_with_
|
||
# nvm_only still passes since it only checks the onclick *contains*
|
||
# /gameboard/.
|
||
self.create_pre_authenticated_session(self.email)
|
||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||
gear = self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".my-sea-page .gear-btn"
|
||
)
|
||
)
|
||
gear.click()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_my_sea_menu")
|
||
)
|
||
self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_my_sea_menu .btn-cancel"
|
||
).click()
|
||
self.wait_for(
|
||
lambda: self.assertRegex(
|
||
self.browser.current_url, r"/gameboard/my-sea/$"
|
||
)
|
||
)
|