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>
This commit is contained in:
Disco DeDisco
2026-05-20 02:29:08 -04:00
parent 7b7e80520a
commit 3fc5491372
8 changed files with 986 additions and 14 deletions

View File

@@ -1178,3 +1178,380 @@ class MySeaLockHandTest(FunctionalTest):
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())