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:
0
src/apps/gameboard/management/__init__.py
Normal file
0
src/apps/gameboard/management/__init__.py
Normal file
0
src/apps/gameboard/management/commands/__init__.py
Normal file
0
src/apps/gameboard/management/commands/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Hard-delete MySeaDraw rows older than FREE_DRAW_COOLDOWN_HOURS.
|
||||
|
||||
The lazy cleanup inside `apps.gameboard.models.active_draw_for` already
|
||||
prunes a user's stale rows on every view hit; this command is the cron
|
||||
backstop for inactive accounts whose rows would otherwise sit forever.
|
||||
|
||||
Run from cron (or invoke manually). No flags; idempotent.
|
||||
|
||||
Usage:
|
||||
python manage.py delete_stale_my_sea_draws
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Delete MySeaDraw rows older than the free-draw cooldown window (24h)."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
n = MySeaDraw.delete_stale()
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Deleted {n} stale MySeaDraw row{'s' if n != 1 else ''}."
|
||||
))
|
||||
@@ -5,28 +5,45 @@ from django.utils import timezone
|
||||
|
||||
FREE_DRAW_COOLDOWN_HOURS = 24
|
||||
|
||||
# Total hand size per spread — matches DRAW_ORDER in my_sea.html JS.
|
||||
# Used by `is_hand_complete` to decide whether the picker UX is in
|
||||
# "still drawing" (AUTO DRAW visible, DEL disabled, FLIPs enabled)
|
||||
# vs "completed" (GATE VIEW visible, DEL enabled, FLIPs disabled) state.
|
||||
HAND_SIZE_BY_SPREAD = {
|
||||
"past-present-future": 3,
|
||||
"situation-action-outcome": 3,
|
||||
"mind-body-spirit": 3,
|
||||
"desire-obstacle-solution": 3,
|
||||
"waite-smith": 6,
|
||||
"escape-velocity": 6,
|
||||
}
|
||||
|
||||
|
||||
class MySeaDraw(models.Model):
|
||||
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
|
||||
feature. Each row is one locked hand by one user.
|
||||
feature. Each row is the user's active draw — both the hand state
|
||||
AND the 24h quota tracker.
|
||||
|
||||
Sprint 5 iter 4b of [[project-my-sea-roadmap]] — server-side
|
||||
persistence of the iter-4a client-side draw mechanic.
|
||||
|
||||
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window
|
||||
(24h, irrespective of spread type). Subsequent draws within the
|
||||
window are intended to be gated behind a token deposit at the My
|
||||
Sea gatekeeper, which Sprint 6 will build.
|
||||
Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
|
||||
iter-4b LOCK-HAND model. The row is created on the FIRST card draw
|
||||
(manual or AUTO DRAW), not on a separate LOCK action. Quota is
|
||||
committed at first-card moment + survives a DEL (DEL clears the
|
||||
`hand` JSON but preserves the row — so `created_at` keeps running
|
||||
the 24h clock + landing renders GATE VIEW instead of FREE DRAW
|
||||
until the row expires).
|
||||
|
||||
`hand` is an ordered list of position-dicts in draw order — Sprint 7's
|
||||
applet renders them left-to-right in that order. Each entry shape:
|
||||
|
||||
{"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}
|
||||
|
||||
For mid-draw rows, `hand` is partial (1+ entries < HAND_SIZE_BY_SPREAD).
|
||||
For DEL'd rows, `hand` is `[]` but `created_at` still anchors the
|
||||
quota window.
|
||||
|
||||
`significator_id` + `significator_reversed` snapshot the user's sig
|
||||
at lock time so a subsequent `User.significator = None` (via my-sign
|
||||
DEL) doesn't invalidate the saved draw — per user spec, preserve the
|
||||
old sig; any future draw uses whatever sig is current at that time.
|
||||
at first-card-draw time so a subsequent `User.significator = None`
|
||||
(via my-sign DEL) doesn't invalidate the saved draw.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -60,16 +77,45 @@ class MySeaDraw(models.Model):
|
||||
timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_hand_complete(self):
|
||||
"""True iff the hand has filled every position for its spread.
|
||||
Drives the picker-form-col button state (AUTO DRAW → GATE VIEW
|
||||
+ DEL enable + FLIPs disable on the deck stacks)."""
|
||||
return len(self.hand or []) >= HAND_SIZE_BY_SPREAD.get(self.spread, 0)
|
||||
|
||||
@property
|
||||
def is_hand_empty(self):
|
||||
"""True iff the hand carries no entries — the post-DEL state,
|
||||
where the row stays as a quota tracker but the cards are gone."""
|
||||
return not self.hand
|
||||
|
||||
@classmethod
|
||||
def delete_stale(cls):
|
||||
"""Hard-delete every draw older than FREE_DRAW_COOLDOWN_HOURS.
|
||||
Returns the deletion count.
|
||||
|
||||
Called lazily from `active_draw_for` on every view access (so
|
||||
the cleanup naturally rides user traffic — no scheduler needed
|
||||
for the common case) AND from the `delete_stale_my_sea_draws`
|
||||
management command (cron backstop for inactive periods)."""
|
||||
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||
deleted, _ = cls.objects.filter(created_at__lt=cutoff).delete()
|
||||
return deleted
|
||||
|
||||
|
||||
def active_draw_for(user):
|
||||
"""Return the user's most-recent draw within the quota window, or
|
||||
None. Used both for rendering the picker w. saved hand on page load
|
||||
and for gating LOCK HAND POSTs.
|
||||
None. Single source of truth for "does the user have an active draw"
|
||||
(drives view branching: no-active → landing w. FREE DRAW; active w.
|
||||
empty hand → landing w. GATE VIEW; active w. non-empty hand → picker).
|
||||
|
||||
Importing this helper rather than re-deriving the cutoff in every
|
||||
caller keeps the 24h window a single-source-of-truth tied to
|
||||
FREE_DRAW_COOLDOWN_HOURS."""
|
||||
Lazy stale-row cleanup: every call prunes the user's >24h rows, so
|
||||
the DB doesn't accumulate one row per user per day. The user-spec
|
||||
'auto-delete all draws after 24hrs, whether or not the user has
|
||||
deleted them' (2026-05-20) lands here w. no scheduler required."""
|
||||
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
|
||||
return MySeaDraw.objects.filter(
|
||||
user=user, created_at__gte=cutoff,
|
||||
).order_by("-created_at").first()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,5 +16,6 @@ urlpatterns = [
|
||||
path('my-sea/', views.my_sea, name='my_sea'),
|
||||
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
|
||||
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
|
||||
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from .models import MySeaDraw, active_draw_for
|
||||
from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for
|
||||
|
||||
|
||||
def _annotate_deck_in_use(decks, user):
|
||||
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
|
||||
def my_sea(request):
|
||||
"""Shell view for the My Sea standalone page.
|
||||
|
||||
Branches:
|
||||
Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as
|
||||
hand storage AND 24h quota tracker. The row is created on first
|
||||
card draw + survives DEL (which only wipes the hand). View states:
|
||||
|
||||
1. Active saved draw (within FREE_DRAW_COOLDOWN_HOURS) → picker phase
|
||||
w. saved hand + Brief banner + DEL guard portal. The draw's sig
|
||||
snapshot is rendered (NOT user.significator) so a cleared sig
|
||||
elsewhere doesn't invalidate the saved draw.
|
||||
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
|
||||
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
|
||||
click swaps data-phase to picker for a fresh draw.
|
||||
3a. + no equipped deck → also show backup-deck Brief banner.
|
||||
1. No sig → Look!-formatted sign-gate (Sprint 4b).
|
||||
2. Active draw, hand non-empty (mid-draw or complete) → picker phase
|
||||
w. saved hand state. The DEL btn is server-rendered `.btn-
|
||||
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
|
||||
completion. The draw's sig snapshot is rendered (NOT user.
|
||||
significator) so a cleared sig elsewhere doesn't invalidate the
|
||||
saved draw.
|
||||
3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the
|
||||
free-draw quota is spent; user must use tokens via the upcoming
|
||||
Sprint 6 gatekeeper). Brief banner still surfaces the next-
|
||||
free-draw timestamp.
|
||||
4. No active draw + sig set → landing w. FREE DRAW (the daily quota
|
||||
is available).
|
||||
4a. + no equipped deck → also show backup-deck Brief banner.
|
||||
"""
|
||||
active_draw = active_draw_for(request.user)
|
||||
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
||||
@@ -194,10 +202,22 @@ def my_sea(request):
|
||||
default_spread = active_draw.spread
|
||||
saved_hand = active_draw.hand
|
||||
next_free_draw_at = active_draw.next_free_draw_at
|
||||
hand_complete = active_draw.is_hand_complete
|
||||
hand_empty = active_draw.is_hand_empty
|
||||
else:
|
||||
default_spread = "situation-action-outcome"
|
||||
saved_hand = []
|
||||
next_free_draw_at = None
|
||||
hand_complete = False
|
||||
hand_empty = True
|
||||
# Picker is the active phase iff the user has a non-empty hand in
|
||||
# progress (or completed). Empty-hand active draws (post-DEL) fall
|
||||
# back to the landing — but render GATE VIEW instead of FREE DRAW
|
||||
# (the daily quota's spent already; landing's primary nav routes to
|
||||
# the upcoming gatekeeper). New users + post-24h users land on the
|
||||
# standard FREE DRAW landing.
|
||||
show_picker = active_draw is not None and not hand_empty
|
||||
quota_spent = active_draw is not None # any active row = quota committed
|
||||
|
||||
# Per-position lookup for the template — keyed by the position slug
|
||||
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
|
||||
@@ -234,11 +254,14 @@ def my_sea(request):
|
||||
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
|
||||
if user_has_sig else {"levity": [], "gravity": []}
|
||||
),
|
||||
# Iter 4b
|
||||
# Iter 4b / 4c
|
||||
"active_draw": active_draw,
|
||||
"saved_hand": saved_hand,
|
||||
"saved_by_position": saved_by_position,
|
||||
"next_free_draw_at": next_free_draw_at,
|
||||
"hand_complete": hand_complete,
|
||||
"show_picker": show_picker,
|
||||
"quota_spent": quota_spent,
|
||||
"page_class": "page-gameboard page-my-sea",
|
||||
})
|
||||
|
||||
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_lock(request):
|
||||
"""Persist the user's just-drawn hand as a `MySeaDraw` row.
|
||||
"""Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
|
||||
fires on every card placement (manual FLIP or AUTO DRAW completion)
|
||||
rather than only on a discrete LOCK HAND action.
|
||||
|
||||
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
|
||||
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success,
|
||||
400 for malformed payload, 409 if the user is still within the free-
|
||||
draw cooldown window (existing active draw)."""
|
||||
polarity}, ...]}` — `hand` is the current FULL state (partial OK
|
||||
for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
|
||||
|
||||
Returns:
|
||||
200 `{ok, next_free_draw_at, hand_complete}` on success
|
||||
400 malformed payload or no sig
|
||||
409 spread differs from the user's already-active draw's spread
|
||||
(the spread is locked at first-card moment; can't switch mid-
|
||||
draw via a sneaky POST)
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
@@ -272,10 +304,26 @@ def my_sea_lock(request):
|
||||
hand = payload.get("hand")
|
||||
if not spread or not isinstance(hand, list) or not hand:
|
||||
return JsonResponse({"error": "spread_and_hand_required"}, status=400)
|
||||
if spread not in HAND_SIZE_BY_SPREAD:
|
||||
return JsonResponse({"error": "unknown_spread"}, status=400)
|
||||
|
||||
if active_draw_for(request.user) is not None:
|
||||
return JsonResponse({"error": "quota_active"}, status=409)
|
||||
existing = active_draw_for(request.user)
|
||||
if existing is not None:
|
||||
# Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
|
||||
# through the gatekeeper but the endpoint stays permissive here).
|
||||
# Spread-switch attempts get 409 — the spread is committed at
|
||||
# first-card moment.
|
||||
if existing.spread != spread:
|
||||
return JsonResponse({"error": "spread_mismatch"}, status=409)
|
||||
existing.hand = hand
|
||||
existing.save(update_fields=["hand"])
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
|
||||
"hand_complete": existing.is_hand_complete,
|
||||
})
|
||||
|
||||
# First card draw → quota commit. Create the row.
|
||||
sig_id = request.user.significator_id
|
||||
if sig_id is None:
|
||||
return JsonResponse({"error": "no_significator"}, status=400)
|
||||
@@ -290,18 +338,35 @@ def my_sea_lock(request):
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
|
||||
"hand_complete": draw.is_hand_complete,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_delete(request):
|
||||
"""Delete the user's active draw — invoked by the DEL guard portal's
|
||||
CONFIRM. Idempotent: a second call w. no active draw is a 204."""
|
||||
MySeaDraw.objects.filter(user=request.user).delete()
|
||||
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
|
||||
so the 24h quota window keeps running. Per user spec (2026-05-20):
|
||||
DEL doesn't refund the daily free-draw; the row stays as a quota
|
||||
tracker until 24h elapse, after which `active_draw_for`'s lazy
|
||||
cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).
|
||||
|
||||
Idempotent: re-firing on a row w. already-empty hand is a no-op."""
|
||||
draw = active_draw_for(request.user)
|
||||
if draw is not None:
|
||||
draw.hand = []
|
||||
draw.save(update_fields=["hand"])
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea_gate(request):
|
||||
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the
|
||||
button-target placeholder lets the template's GATE VIEW UX wire up
|
||||
in advance; Sprint 6 will replace this w. the token-deposit flow."""
|
||||
return HttpResponse(status=404)
|
||||
|
||||
|
||||
def _my_sea_deck_data(user, exclude_id=None):
|
||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||
picker's card-draw mechanic. Card payload shape is whatever
|
||||
|
||||
Reference in New Issue
Block a user