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:
@@ -799,100 +799,78 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_lock_hand_enables_when_sao_hand_is_complete(self):
|
||||
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
|
||||
positions are drawn (hand-size = 3 for any three-card spread)."""
|
||||
def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
|
||||
"""Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
|
||||
DRAW (`data-state="auto-draw"`); when the final card lands, JS
|
||||
transitions it to GATE VIEW (`data-state="gate-view"`, label =
|
||||
"GATE VIEW")."""
|
||||
picker = self._enter_picker_phase()
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||||
self.assertIn("AUTO", action_btn.text.upper())
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
# Two draws — still disabled.
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
self._draw_one(picker, "gravity")
|
||||
# Third draw completes the SAO hand — LOCK HAND enables.
|
||||
# Third draw completes the SAO hand — action btn becomes GATE VIEW.
|
||||
self.wait_for(
|
||||
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
||||
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
|
||||
)
|
||||
self.assertIn("GATE", action_btn.text.upper())
|
||||
|
||||
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_click_resets_hand_and_disables_lock_hand(self):
|
||||
"""DEL fully resets — every filled slot returns to `--empty`,
|
||||
labels re-render, _filled counter zeros, LOCK HAND disables."""
|
||||
def test_del_btn_is_disabled_until_hand_complete(self):
|
||||
"""Iter-4c — DEL btn renders `.btn-disabled` server-side until
|
||||
the hand is complete (per spec: the 24h free-draw quota is
|
||||
committed at first-card-draw, can't be refunded by an early
|
||||
DEL). Once the hand fills, JS removes `.btn-disabled` from DEL."""
|
||||
picker = self._enter_picker_phase()
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
self._draw_one(picker, "levity")
|
||||
# Mid-draw — still disabled.
|
||||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
2,
|
||||
)
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
# Hand complete — DEL un-disables (clicking now opens guard portal).
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
)
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
|
||||
# ── Test 7 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_lock_hand_click_disables_further_interaction(self):
|
||||
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
|
||||
itself all carry the `.btn-disabled` class so the hand can't
|
||||
be mutated further. Persistence (POST to a server endpoint)
|
||||
defers to iter 4b — this test pins only the visual lock."""
|
||||
def test_hand_completion_locks_picker_state(self):
|
||||
"""Iter-4c — when the final card lands (manual or AUTO DRAW),
|
||||
the picker gains `.my-sea-picker--locked`; further deck-stack
|
||||
clicks still SHOW the FLIP btn (so the user can see why no
|
||||
further drawing is allowed) but the FLIP carries `.btn-disabled`
|
||||
+ cards no longer fire on its click. No discrete LOCK HAND
|
||||
action; the transition is automatic on hand-completion."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.wait_for(
|
||||
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
||||
)
|
||||
lock.click()
|
||||
# Picker carries a .my-sea-picker--locked class after LOCK HAND.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
|
||||
)
|
||||
)
|
||||
# Swatches no longer respond — clicking them does nothing.
|
||||
gravity_stack = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
|
||||
)
|
||||
self.assertIn("btn-disabled", gravity_stack.get_attribute("class"))
|
||||
|
||||
# ── Test 8 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_first_draw_locks_spread_combobox(self):
|
||||
"""Per the iter-4a follow-up spec lock (2026-05-19): once the
|
||||
first card lands, the SPREAD combobox carries `.sea-select--
|
||||
locked` so mid-draw spread switching is prevented (it would
|
||||
scramble the position→card mapping). DEL releases the lock.
|
||||
|
||||
Was previously `test_switching_spread_resets_in_progress_hand`
|
||||
— that test's premise (mid-draw spread switch resets hand) is
|
||||
obsolete now that switching is blocked outright."""
|
||||
"""Iter-4c — once the first card lands, the SPREAD combobox
|
||||
carries `.sea-select--locked` for the rest of the quota window.
|
||||
The spread is committed at first-card moment (server-side too:
|
||||
any later POST w. a different spread → 409); no client-side
|
||||
unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
|
||||
`.btn-disabled` pre-completion → no reset pathway.)"""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
|
||||
)
|
||||
# DEL unlocks (mirrors `_resetHand` calling `_unlockSpread`).
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotIn("sea-select--locked", combo.get_attribute("class"))
|
||||
)
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -910,14 +888,18 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
)
|
||||
self.assertIn("GRAVITY", names)
|
||||
self.assertIn("LEVITY", names)
|
||||
# LOCK HAND + DEL
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertIn("LOCK", lock.text.upper())
|
||||
self.assertIn("HAND", lock.text.upper())
|
||||
self.assertIn("btn-primary", lock.get_attribute("class"))
|
||||
# Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
self.assertIn("AUTO", action_btn.text.upper())
|
||||
self.assertIn("btn-primary", action_btn.get_attribute("class"))
|
||||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
self.assertIn("DEL", delbtn.text.upper())
|
||||
# DEL renders w. `.btn-disabled` pre-completion (the `×` overlay
|
||||
# is CSS-only; raw text content is still "DEL" in the DOM).
|
||||
# Assert on class state — `.text` returns the visible glyph
|
||||
# rendered by the pseudo-element layer.
|
||||
self.assertIn("btn-danger", delbtn.get_attribute("class"))
|
||||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
# Reversal % caption — default 25
|
||||
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
||||
self.assertIn("25", hint.text)
|
||||
@@ -1157,11 +1139,13 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
|
||||
"""Clicking the portal's OK (`.guard-yes`) POSTs to the delete
|
||||
endpoint → server wipes the MySeaDraw row → reload lands on the
|
||||
FREE DRAW landing again (no saved hand, no Brief banner, FREE
|
||||
DRAW btn present)."""
|
||||
def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
|
||||
"""Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
|
||||
POSTs to the delete endpoint → server CLEARS the hand JSON but
|
||||
preserves the MySeaDraw row (quota tracker stays running for the
|
||||
24h window). Reload lands on the table-hex landing — but the
|
||||
primary nav btn is GATE VIEW (`#id_my_sea_gate_view_btn`), NOT
|
||||
FREE DRAW, since the quota's spent until the row expires."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self._save_draw_for_user()
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
|
||||
@@ -1184,4 +1168,13 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
|
||||
)
|
||||
)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 0)
|
||||
# Row preserved as quota tracker; hand wiped.
|
||||
rows = MySeaDraw.objects.filter(user=self.gamer)
|
||||
self.assertEqual(rows.count(), 1)
|
||||
self.assertEqual(rows.first().hand, [])
|
||||
# Landing renders GATE VIEW (not FREE DRAW) per iter-4c spec.
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user