My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD

Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.

**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:

- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)

Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).

`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.

**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.

**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).

Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.

**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-19 20:02:20 -04:00
parent f154d660bd
commit ca2a62fd84
5 changed files with 538 additions and 6 deletions

View File

@@ -636,6 +636,232 @@ class MySeaSpreadFormTest(FunctionalTest):
)
return el
class MySeaCardDrawTest(FunctionalTest):
"""Sprint 5 iter 4a — client-side card-draw mechanics on the picker
phase. Server embeds the deck (gravity + levity halves, user's sig
excluded) as JSON; clicking GRAVITY/LEVITY swatch shows FLIP; FLIP
deposits the next card into the next DRAW_ORDER slot for the active
spread. DEL fully resets the in-progress hand. LOCK HAND enables
when the hand is complete + click locks down further interaction.
Switching spreads also resets the hand (the position-subset changes).
Server-side persistence (committing the locked hand to a MySeaDraw
model) defers to iter 4b."""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_seed_gameboard_applets()
self.email = "draw@test.io"
self.gamer = User.objects.create(email=self.email)
self.target_card = _assign_sig(self.gamer)
def _enter_picker_phase(self):
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
)
btn.click()
return self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
def _draw_one(self, picker, polarity):
"""Click a polarity swatch + the FLIP btn that appears →
deposits a card. `polarity` is `'levity'` or `'gravity'`."""
stack = picker.find_element(
By.CSS_SELECTOR, f".sea-deck-stack--{polarity}"
)
stack.click()
flip = self.wait_for(
lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok")
)
# FLIP btn becomes visible after the stack click; wait for it.
self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
flip.click()
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_deck_data_embedded_with_two_polarity_halves(self):
"""Server-side renders the shuffled deck (levity + gravity
halves, sig excluded) inside `<script type="application/json"
id="id_my_sea_deck">`. Client-side JS reads on init."""
import json as _json
picker = self._enter_picker_phase()
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
deck = _json.loads(data_el.get_attribute("textContent"))
self.assertIn("levity", deck)
self.assertIn("gravity", deck)
self.assertIsInstance(deck["levity"], list)
self.assertIsInstance(deck["gravity"], list)
# Both halves should be non-empty (16 court cards in the seed,
# minus 1 sig → 15 cards split ~7/8).
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
# No card appears in both halves.
levity_ids = {c["id"] for c in deck["levity"]}
gravity_ids = {c["id"] for c in deck["gravity"]}
self.assertEqual(levity_ids & gravity_ids, set())
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_user_significator_excluded_from_drawn_deck(self):
"""The user's significator (pinned in `.sea-pos-core`) must NOT
appear in the gravity or levity deck halves — would otherwise
let the same card show up twice in the layout."""
import json as _json
picker = self._enter_picker_phase()
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
deck = _json.loads(data_el.get_attribute("textContent"))
all_ids = {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]}
self.assertNotIn(self.target_card.id, all_ids)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_levity_click_then_flip_deposits_card_into_first_sao_slot(self):
"""Default spread = SAO; first slot = `.sea-pos-lay` per the
DRAW_ORDER spec. Clicking LEVITY → FLIP → the first drawn card
lands in lay's `.sea-card-slot` w. `--filled` + `--levity`
classes + corner_rank text content from the deck card."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
slot = self.wait_for(
lambda: picker.find_element(
By.CSS_SELECTOR,
".sea-pos-lay .sea-card-slot.sea-card-slot--filled",
)
)
self.assertIn("sea-card-slot--levity", slot.get_attribute("class"))
# Card has a corner-rank rendered inside.
slot.find_element(By.CSS_SELECTOR, ".fan-corner-rank")
# Slot has a data-card-id attribute set to the deposited card's id.
self.assertTrue(slot.get_attribute("data-card-id"))
# ── Test 4 ───────────────────────────────────────────────────────────────
def test_two_draws_fill_first_two_slots_in_draw_order(self):
"""SAO draw order = lay → cover → crown. Second draw lands in
`.sea-pos-cover` regardless of polarity. Polarity of each
slot reflects which swatch was clicked."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
self._draw_one(picker, "gravity")
# First slot (lay) — levity
lay = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
)
self.assertIn("sea-card-slot--levity", lay.get_attribute("class"))
# Second slot (cover) — gravity
cover = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot.sea-card-slot--filled"
)
self.assertIn("sea-card-slot--gravity", cover.get_attribute("class"))
# ── 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)."""
picker = self._enter_picker_phase()
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertEqual(lock.get_attribute("disabled"), "true")
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.
self.wait_for(
lambda: self.assertIsNone(lock.get_attribute("disabled"))
)
# ── 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."""
picker = self._enter_picker_phase()
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()
self.wait_for(
lambda: self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
0,
)
)
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."""
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_switching_spread_resets_in_progress_hand(self):
"""Picking a different spread on the combobox mid-draw resets
the hand — different spreads use different position subsets +
different hand-sizes, so an in-progress hand can't carry over."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
1,
)
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
combo.click()
picker.find_element(
By.CSS_SELECTOR,
".sea-select-list [role='option'][data-value='mind-body-spirit']",
).click()
self.wait_for(
lambda: self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
0,
)
)
# ── Test 4 ───────────────────────────────────────────────────────────────
def test_form_col_renders_decks_lock_hand_del_and_reversal_pct(self):