My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD
Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running.
## Server
`MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker.
- `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS).
- `is_hand_complete` / `is_hand_empty` props drive view branching + template button states.
- `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop).
- `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn").
`my_sea_lock` is now a true upsert:
- First POST creates the row (quota commit).
- Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists).
- Spread-mismatch (attempted spread switch within quota window) → 409.
- Empty/malformed hand → 400.
- Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions.
`my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent.
`my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX.
`my_sea` view branches:
1. No sig → sign-gate
2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand
3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn
4. No active draw → landing phase w. FREE DRAW btn
## Template + UX
- Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`:
- `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists).
- `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6).
- JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion).
- DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing.
- Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag.
- Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish.
## Other polish bundled
- **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity.
- **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `×` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression.
- **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg."
- **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw).
## Test coverage
- ITs (1100 IT/UT green in 57s):
- `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`.
- `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response.
- `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test).
- `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state.
- `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW.
- `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled.
- `MySeaGateStubViewTest` (new) — 404 stub + login required.
- FTs (35 my_sea FTs green in 5m):
- Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW).
- Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`.
- Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`.
- Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic).
- Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion).
- Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions.
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:
@@ -717,12 +717,18 @@ class MySeaSpreadFormTemplateTest(TestCase):
|
||||
self.assertIn('data-position="loom"></span>', html)
|
||||
self.assertIn('data-position="cross"></span>', html)
|
||||
|
||||
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
|
||||
def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self):
|
||||
# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS
|
||||
# transitions to GATE VIEW on completion. ID `id_sea_action_btn`
|
||||
# is the single slot housing both states (label + `data-state`
|
||||
# toggled by JS). User w. no active draw → AUTO DRAW label.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertIn("sea-deck-stack--gravity", html)
|
||||
self.assertIn("sea-deck-stack--levity", html)
|
||||
self.assertIn('id="id_sea_lock_hand"', html)
|
||||
self.assertIn('id="id_sea_action_btn"', html)
|
||||
self.assertIn('data-state="auto-draw"', html)
|
||||
self.assertIn("AUTO", html)
|
||||
self.assertIn('id="id_sea_del"', html)
|
||||
self.assertIn("sea-reversal-hint", html)
|
||||
self.assertIn("25% reversals", html)
|
||||
@@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase):
|
||||
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
||||
self.assertIsNotNone(parsed)
|
||||
|
||||
def test_lock_post_within_quota_window_returns_409(self):
|
||||
# Second lock within 24h: the existing draw already occupies the
|
||||
# quota; the server rejects rather than overwriting.
|
||||
def test_lock_post_within_quota_upserts_same_row(self):
|
||||
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
|
||||
# Second POST w. same spread updates the existing row's hand
|
||||
# rather than 409'ing. Only one row exists per user per 24h.
|
||||
import json
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.epic.models import TarotCard
|
||||
# First POST: 1-card partial hand
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||||
partial = {
|
||||
"spread": "situation-action-outcome",
|
||||
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
|
||||
}
|
||||
r1 = self.client.post(
|
||||
self.url, data=json.dumps(partial),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
# Second POST: full 3-card hand (the SAME draw progressing).
|
||||
full = self._build_payload()
|
||||
r2 = self.client.post(
|
||||
self.url, data=json.dumps(full),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
# Exactly one MySeaDraw row exists; hand is the latest full one.
|
||||
rows = MySeaDraw.objects.filter(user=self.user)
|
||||
self.assertEqual(rows.count(), 1)
|
||||
self.assertEqual(len(rows.first().hand), 3)
|
||||
|
||||
def test_lock_post_spread_mismatch_within_quota_returns_409(self):
|
||||
# Spread is committed at first-card moment; switching to a
|
||||
# different spread mid-quota-window is rejected.
|
||||
import json
|
||||
from apps.epic.models import TarotCard
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||||
self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
self.url, data=json.dumps({
|
||||
"spread": "situation-action-outcome",
|
||||
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
self.url, data=json.dumps({
|
||||
"spread": "waite-smith",
|
||||
"hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}],
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_lock_post_returns_hand_complete_flag(self):
|
||||
# Body includes `hand_complete` so the JS can decide whether to
|
||||
# transition the picker into post-completion state (DEL enable,
|
||||
# FLIPs disable, AUTO DRAW → GATE VIEW).
|
||||
import json
|
||||
partial = {
|
||||
"spread": "situation-action-outcome",
|
||||
"hand": self._build_payload()["hand"][:1],
|
||||
}
|
||||
r1 = self.client.post(
|
||||
self.url, data=json.dumps(partial),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertFalse(r1.json()["hand_complete"])
|
||||
r2 = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertTrue(r2.json()["hand_complete"])
|
||||
|
||||
def test_lock_post_empty_hand_returns_400(self):
|
||||
import json
|
||||
@@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(TestCase):
|
||||
|
||||
|
||||
class MySeaDeleteDrawViewTest(TestCase):
|
||||
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
|
||||
"""Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
|
||||
but preserves the row so the 24h quota window keeps running."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards, TarotCard
|
||||
@@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_delete_post_clears_active_draw(self):
|
||||
def test_delete_post_clears_hand_but_preserves_row(self):
|
||||
# Iter 4c — DEL no longer deletes; the row stays as a 24h quota
|
||||
# tracker. Hand JSON gets wiped + `created_at` preserved (so the
|
||||
# landing renders GATE VIEW, not FREE DRAW, until the row expires).
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
original_created_at = self.draw.created_at
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists())
|
||||
self.draw.refresh_from_db()
|
||||
self.assertEqual(self.draw.hand, [])
|
||||
self.assertEqual(self.draw.created_at, original_created_at)
|
||||
self.assertTrue(MySeaDraw.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_delete_post_scoped_to_user_does_not_touch_others(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
@@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase):
|
||||
hand=self.draw.hand, significator_id=self.target.id,
|
||||
)
|
||||
self.client.post(self.url)
|
||||
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
|
||||
other_draw.refresh_from_db()
|
||||
self.assertEqual(len(other_draw.hand), 3) # untouched
|
||||
|
||||
def test_delete_post_idempotent_when_no_active_draw(self):
|
||||
# User deletes twice in a row — second call is a no-op, not a 500.
|
||||
# First DEL clears hand. Second DEL finds the row w. empty hand
|
||||
# already; just no-ops.
|
||||
self.client.post(self.url)
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
@@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||
self.assertNotContains(response, 'data-phase="landing"')
|
||||
|
||||
def test_complete_hand_renders_action_btn_as_gate_view(self):
|
||||
# Iter 4c — server pre-renders the action btn label + data-state
|
||||
# based on `hand_complete`. With the setUp's 3-card SAO hand,
|
||||
# hand is complete → btn label is GATE VIEW.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-state="gate-view"')
|
||||
self.assertContains(response, "GATE")
|
||||
self.assertContains(response, "VIEW")
|
||||
|
||||
def test_complete_hand_picker_carries_locked_class(self):
|
||||
# `.my-sea-picker--locked` is server-rendered for completed hands
|
||||
# so the JS init seeds `_locked=true` w.o. waiting for the post-
|
||||
# placement state transition (matters for hot reloads, bfcache).
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, "my-sea-picker--locked")
|
||||
|
||||
def test_complete_hand_del_btn_is_not_disabled(self):
|
||||
# DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete
|
||||
# hand → DEL renders w.o. the disabled class (clicking opens the
|
||||
# guard portal).
|
||||
import re
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
m = re.search(
|
||||
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
|
||||
)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertNotIn("btn-disabled", m.group(1))
|
||||
|
||||
|
||||
class MySeaViewWithEmptyHandTest(TestCase):
|
||||
"""Sprint 5 iter 4c — view branch for an active draw w. empty hand
|
||||
(the post-DEL state, where the quota row stays as a 24h tracker but
|
||||
the user's hand has been wiped). Landing renders w. GATE VIEW (NOT
|
||||
FREE DRAW) as the primary nav."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.user = User.objects.create(email="empty@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"])
|
||||
# Active draw row but hand is empty — simulates the post-DEL state.
|
||||
self.draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
)
|
||||
|
||||
def test_empty_hand_renders_landing_phase_not_picker(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-phase="landing"')
|
||||
|
||||
def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
|
||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||
|
||||
def test_empty_hand_gate_view_btn_links_to_gate_url(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, reverse("my_sea_gate"))
|
||||
|
||||
def test_empty_hand_brief_banner_still_triggered(self):
|
||||
# Quota's still committed (row exists, 24h clock still running) →
|
||||
# the Brief banner is part of the saved-draw context, regardless
|
||||
# of hand state. Informs the user when the next free draw is.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
|
||||
|
||||
|
||||
class MySeaViewWithPartialHandTest(TestCase):
|
||||
"""Sprint 5 iter 4c — view branch for an active draw w. mid-progress
|
||||
hand (some slots filled, not yet complete). Picker renders w. the
|
||||
partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards, TarotCard
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.user = User.objects.create(email="partial@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"])
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
|
||||
# SAO is a 3-position spread; partial = 2 cards drawn.
|
||||
self.draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id,
|
||||
hand=[
|
||||
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
||||
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_partial_hand_renders_picker_phase(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-phase="picker"')
|
||||
|
||||
def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-state="auto-draw"')
|
||||
self.assertContains(response, "AUTO")
|
||||
|
||||
def test_partial_hand_del_btn_carries_btn_disabled(self):
|
||||
import re
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
m = re.search(
|
||||
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
|
||||
)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertIn("btn-disabled", m.group(1))
|
||||
|
||||
def test_partial_hand_picker_does_NOT_carry_locked_class(self):
|
||||
# Hand is mid-progress; locked class only applies on completion.
|
||||
import re
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
m = re.search(r'<div class="my-sea-picker([^"]*)"', html)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertNotIn("my-sea-picker--locked", m.group(1))
|
||||
|
||||
|
||||
class MySeaGateStubViewTest(TestCase):
|
||||
"""Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns
|
||||
a 404 so the template-side GATE VIEW button URL resolves but the
|
||||
actual gatekeeper UX rides Sprint 6."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gate@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_gate_view_returns_404(self):
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_gate_view_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
# @login_required redirects before the 404 path runs.
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
Reference in New Issue
Block a user