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 `&times;` 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:
Disco DeDisco
2026-05-20 01:34:03 -04:00
parent 6f901fd9ce
commit 7b7e80520a
12 changed files with 735 additions and 230 deletions

View File

@@ -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)