My Sea iter 6c: bud-btn invite stub + #id_my_sea_menu gear (NVM-only, %applet-menu-styled, on both /gameboard/my-sea/ and the gatekeeper) + PAID DRAW now deletes the row and redirects to ?phase=picker so the user drops straight into picking cards instead of looping back to GATE VIEW — Sprint 5 iter 6c of My Sea roadmap — TDD

Bundled fix for the PAID-DRAW-loops-to-GATE-VIEW bug surfaced 2026-05-20 in
live testing: previously the view reset `created_at = now()` + cleared the
hand, but the row's continued existence meant `quota_spent=True` on the
next render → landing rendered GATE VIEW → user clicked it → back to
gatekeeper → loop.

Now PAID DRAW does `active_draw.delete()` after debiting the token + then
redirects to `/gameboard/my-sea/?phase=picker`. The my_sea view honors
`?phase=picker` (only when no active_draw exists — can't bypass
post-DEL GATE VIEW) by forcing `show_picker=True` so the user lands in
the picker ready to draw. First card draw creates a fresh row w. fresh
`created_at`, starting the new 24h quota cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-20 09:47:47 -04:00
parent 1e37fe1475
commit 4417b8c972
9 changed files with 267 additions and 41 deletions

View File

@@ -1563,32 +1563,22 @@ class MySeaPaidDrawViewTest(TestCase):
self.client.post(self.url)
self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())
def test_paid_draw_clears_deposit_fields_and_resets_created_at(self):
old_created = self.draw.created_at
# Push old created_at back so reset is observable.
from datetime import timedelta
def test_paid_draw_deletes_active_draw_row(self):
# User-spec 2026-05-20: PAID DRAW commits the token + drops the row
# entirely so the user returns to a fresh "able-to-draw-now" state
# (instead of the buggy "row preserved → GATE VIEW loop" semantics).
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.filter(pk=self.draw.pk).update(
created_at=old_created - timedelta(hours=12),
)
self.client.post(self.url)
self.draw.refresh_from_db()
self.assertIsNone(self.draw.deposit_token_id)
self.assertIsNone(self.draw.deposit_reserved_at)
self.assertGreater(self.draw.created_at, old_created - timedelta(hours=12))
self.assertFalse(MySeaDraw.objects.filter(pk=self.draw.pk).exists())
def test_paid_draw_resets_hand_to_empty(self):
# Even if hand is non-empty, PAID DRAW wipes it (fresh draw cycle).
from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
self.draw.hand = [
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
]
self.draw.save(update_fields=["hand"])
self.client.post(self.url)
self.draw.refresh_from_db()
self.assertEqual(self.draw.hand, [])
def test_paid_draw_redirects_to_my_sea_with_phase_picker(self):
# User-spec 2026-05-20: drop the user directly into the picker
# after PAID DRAW (no intermediate FREE-DRAW click). Encoded via
# `?phase=picker` query param so the my_sea view can short-
# circuit `show_picker` even when active_draw is now None.
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("phase=picker", response["Location"])
def test_paid_draw_with_coin_sets_24h_cooldown_and_unequips(self):
from datetime import timedelta
@@ -1621,6 +1611,45 @@ class MySeaPaidDrawViewTest(TestCase):
self.assertEqual(response.status_code, 302)
class MySeaPhasePickerQueryParamTest(TestCase):
"""Sprint 6 iter 6c — `?phase=picker` query param forces picker phase
when no active_draw row exists (the just-after-PAID-DRAW state).
Without the param, no-active-draw users default to the FREE DRAW
landing. With it, they drop straight into the picker so they can
start drawing immediately (the token they just spent earns this)."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="phase@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_no_param_lands_on_free_draw(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-phase="landing"')
self.assertContains(response, 'id="id_draw_sea_btn"')
def test_phase_picker_param_forces_picker(self):
response = self.client.get(reverse("my_sea") + "?phase=picker")
self.assertContains(response, 'data-phase="picker"')
# Picker IS rendered (no inline style="display:none" on it).
self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"')
def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self):
# Post-DEL state: active row w. empty hand → quota's spent, the
# query param shouldn't bypass GATE VIEW. Landing branch wins.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
)
response = self.client.get(reverse("my_sea") + "?phase=picker")
self.assertContains(response, 'data-phase="landing"')
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
class SelectMySeaTokenTest(TestCase):
"""Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE
excluded + COIN cooldown-respecting."""