My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD
Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). 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:
@@ -872,31 +872,26 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
|
||||
# ── 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."""
|
||||
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."""
|
||||
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,
|
||||
)
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
@@ -995,3 +990,189 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
|
||||
)
|
||||
self.assertIsNotNone(stat_block)
|
||||
|
||||
|
||||
class MySeaLockHandTest(FunctionalTest):
|
||||
"""Sprint 5 iter 4b — server persistence + DEL guard.
|
||||
|
||||
Iter 4a left the locked hand purely client-side; this iter persists
|
||||
it via a `MySeaDraw` model so:
|
||||
- reload restores the locked hand (picker renders w. all positions
|
||||
already filled + locked)
|
||||
- a 24-hour free-draw quota applies (user gets 1 draw per 24h
|
||||
irrespective of spread type)
|
||||
- the landing phase is bypassed when a saved draw exists
|
||||
- DEL on a locked hand opens a uniform guard portal (CONFIRM/NVM)
|
||||
- a Brief banner accompanies the picker post-lock w. the next
|
||||
free-draw timestamp + NVM to dismiss
|
||||
|
||||
Per-modal interactivity (NVM dismiss UX, button-enabled state on
|
||||
saved-hand init) defers to Jasmine — this FT pins only the
|
||||
integration paths the server is responsible for.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "lock@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
self.target_card = _assign_sig(self.gamer)
|
||||
|
||||
def _save_draw_for_user(self, hand=None):
|
||||
"""Persist a MySeaDraw row for self.gamer directly, bypassing the
|
||||
LOCK HAND UI. Returns the saved draw. Used by tests that pin the
|
||||
post-lock UX without re-walking the 3-card draw flow each time."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
if hand is None:
|
||||
# Pick three cards from the user's deck (excluding sig)
|
||||
from apps.epic.models import TarotCard
|
||||
cards = list(TarotCard.objects.exclude(
|
||||
id=self.target_card.id
|
||||
)[:3])
|
||||
hand = [
|
||||
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
||||
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
||||
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
||||
]
|
||||
return MySeaDraw.objects.create(
|
||||
user=self.gamer,
|
||||
spread="situation-action-outcome",
|
||||
hand=hand,
|
||||
significator_id=self.target_card.id,
|
||||
significator_reversed=False,
|
||||
)
|
||||
|
||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_saved_draw_bypasses_landing_renders_picker_phase_directly(self):
|
||||
"""User with a MySeaDraw row lands directly on [data-phase='picker']
|
||||
— the landing (FREE DRAW + 6-chair hex) is skipped, since the
|
||||
free quota is already spent and the locked hand is what the user
|
||||
should see."""
|
||||
self._save_draw_for_user()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
page = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(page)
|
||||
# FREE DRAW landing chair-hex should not be visible.
|
||||
landings = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
|
||||
)
|
||||
self.assertEqual(landings, [])
|
||||
|
||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_saved_draw_renders_saved_hand_in_picker_slots(self):
|
||||
"""The picker phase renders each saved position's slot as
|
||||
`--filled` + carries the saved card's id in `data-card-id` +
|
||||
the saved polarity class (`--gravity` / `--levity`)."""
|
||||
draw = self._save_draw_for_user()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
for entry in draw.hand:
|
||||
slot = self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
f".sea-pos-{entry['position']} .sea-card-slot.sea-card-slot--filled",
|
||||
)
|
||||
self.assertEqual(
|
||||
slot.get_attribute("data-card-id"), str(entry["card_id"]),
|
||||
f"slot for position {entry['position']} should carry the saved card id",
|
||||
)
|
||||
self.assertIn(
|
||||
f"sea-card-slot--{entry['polarity']}",
|
||||
slot.get_attribute("class"),
|
||||
)
|
||||
|
||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_saved_draw_renders_brief_banner_with_next_free_draw_timestamp(self):
|
||||
"""Post-lock UX: a Look!-formatted Brief banner above the picker
|
||||
informs the user when the next free draw is available + offers a
|
||||
NVM button to dismiss. Mirrors the Brief banner shape from the
|
||||
Baltimorean Note unlock + the my-sign default-deck warning."""
|
||||
self._save_draw_for_user()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
brief = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-brief"
|
||||
)
|
||||
)
|
||||
text = brief.text
|
||||
self.assertIn("Look!", text)
|
||||
self.assertIn("free draw", text.lower())
|
||||
# The timestamp is rendered inside a dedicated child so the JS
|
||||
# NVM-dismiss handler can find + style it independently of the
|
||||
# surrounding copy.
|
||||
ts = brief.find_element(By.CSS_SELECTOR, ".my-sea-brief__timestamp")
|
||||
self.assertTrue(ts.text.strip(), "brief should render a non-empty next-free-draw timestamp")
|
||||
# NVM button is present (Jasmine pins the dismiss-on-click).
|
||||
brief.find_element(By.CSS_SELECTOR, ".my-sea-brief__nvm")
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_click_opens_guard_portal_with_uniform_confirm_copy(self):
|
||||
"""DEL on a locked hand opens `#id_my_sea_del_portal` — uniform
|
||||
'Are you sure?' copy (no conditional quota wording; the Brief
|
||||
banner carries that info separately) w. CONFIRM + NVM buttons."""
|
||||
self._save_draw_for_user()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
picker = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
portal = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_my_sea_del_portal"
|
||||
)
|
||||
)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
text = portal.text.lower()
|
||||
self.assertIn("sure", text)
|
||||
portal.find_element(By.CSS_SELECTOR, ".my-sea-del-portal__confirm")
|
||||
portal.find_element(By.CSS_SELECTOR, ".my-sea-del-portal__nvm")
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
|
||||
"""Clicking the portal's CONFIRM 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)."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self._save_draw_for_user()
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
picker = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
picker.find_element(By.CSS_SELECTOR, "#id_sea_del").click()
|
||||
confirm = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-del-portal__confirm"
|
||||
)
|
||||
)
|
||||
confirm.click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
|
||||
)
|
||||
)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 0)
|
||||
|
||||
Reference in New Issue
Block a user