Files
python-tdd/src/functional_tests/test_game_my_sea.py
Disco DeDisco 3fc5491372 My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + _select_my_sea_token / debit_my_sea_token helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD
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>
2026-05-20 02:29:08 -04:00

1558 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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())