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

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