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:
@@ -797,3 +797,357 @@ class MySeaDeckDataViewTest(TestCase):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
deck = response.context["sea_deck_data"]
|
||||
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
|
||||
|
||||
|
||||
# ── Sprint 5 iter 4b — server persistence: MySeaDraw + lock + delete ──────────
|
||||
|
||||
|
||||
class MySeaDrawModelTest(TestCase):
|
||||
"""Sprint 5 iter 4b — `MySeaDraw` model + `active_draw_for` helper."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="model@test.io")
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
|
||||
def _build_hand(self):
|
||||
from apps.epic.models import TarotCard
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||||
return [
|
||||
{"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"},
|
||||
]
|
||||
|
||||
def test_create_round_trips_hand_and_sig_snapshot(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
hand = self._build_hand()
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=hand, significator_id=self.target.id, significator_reversed=False,
|
||||
)
|
||||
draw.refresh_from_db()
|
||||
self.assertEqual(draw.hand, hand)
|
||||
self.assertEqual(draw.significator_id, self.target.id)
|
||||
|
||||
def test_next_free_draw_at_is_created_at_plus_24h(self):
|
||||
from datetime import timedelta
|
||||
from apps.gameboard.models import MySeaDraw, FREE_DRAW_COOLDOWN_HOURS
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=self._build_hand(), significator_id=self.target.id,
|
||||
)
|
||||
delta = draw.next_free_draw_at - draw.created_at
|
||||
self.assertEqual(delta, timedelta(hours=FREE_DRAW_COOLDOWN_HOURS))
|
||||
|
||||
def test_is_within_quota_window_true_when_fresh(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=self._build_hand(), significator_id=self.target.id,
|
||||
)
|
||||
self.assertTrue(draw.is_within_quota_window)
|
||||
|
||||
def test_is_within_quota_window_false_when_older_than_24h(self):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=self._build_hand(), significator_id=self.target.id,
|
||||
created_at=timezone.now() - timedelta(hours=25),
|
||||
)
|
||||
self.assertFalse(draw.is_within_quota_window)
|
||||
|
||||
def test_active_draw_for_returns_recent_draw(self):
|
||||
from apps.gameboard.models import MySeaDraw, active_draw_for
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=self._build_hand(), significator_id=self.target.id,
|
||||
)
|
||||
self.assertEqual(active_draw_for(self.user), draw)
|
||||
|
||||
def test_active_draw_for_returns_none_when_no_draws(self):
|
||||
from apps.gameboard.models import active_draw_for
|
||||
self.assertIsNone(active_draw_for(self.user))
|
||||
|
||||
def test_active_draw_for_returns_none_when_only_stale_draws(self):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.gameboard.models import MySeaDraw, active_draw_for
|
||||
MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=self._build_hand(), significator_id=self.target.id,
|
||||
created_at=timezone.now() - timedelta(hours=25),
|
||||
)
|
||||
self.assertIsNone(active_draw_for(self.user))
|
||||
|
||||
def test_active_draw_for_scopes_to_user(self):
|
||||
from apps.gameboard.models import MySeaDraw, active_draw_for
|
||||
other = User.objects.create(email="other@test.io")
|
||||
MySeaDraw.objects.create(
|
||||
user=other, spread="situation-action-outcome",
|
||||
hand=self._build_hand(), significator_id=self.target.id,
|
||||
)
|
||||
self.assertIsNone(active_draw_for(self.user))
|
||||
|
||||
|
||||
class MySeaLockHandViewTest(TestCase):
|
||||
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/lock` persists a hand."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="lock@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"])
|
||||
self.url = reverse("my_sea_lock")
|
||||
|
||||
def _build_payload(self, spread="situation-action-outcome", hand=None):
|
||||
from apps.epic.models import TarotCard
|
||||
if hand is None:
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.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 {"spread": spread, "hand": hand}
|
||||
|
||||
def test_lock_requires_login(self):
|
||||
import json
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_lock_get_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_lock_post_creates_my_sea_draw_for_user(self):
|
||||
import json
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
draw = MySeaDraw.objects.get(user=self.user)
|
||||
self.assertEqual(draw.spread, "situation-action-outcome")
|
||||
self.assertEqual(len(draw.hand), 3)
|
||||
self.assertEqual(draw.significator_id, self.target.id)
|
||||
|
||||
def test_lock_post_response_includes_next_free_draw_iso_timestamp(self):
|
||||
import json
|
||||
from datetime import datetime
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
body = response.json()
|
||||
self.assertIn("next_free_draw_at", body)
|
||||
# Round-trip parse — the server is expected to send an ISO 8601 string.
|
||||
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.
|
||||
import json
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_lock_post_empty_hand_returns_400(self):
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps({"spread": "situation-action-outcome", "hand": []}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_lock_post_missing_spread_returns_400(self):
|
||||
import json
|
||||
payload = self._build_payload()
|
||||
payload.pop("spread")
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_lock_post_snapshots_user_significator(self):
|
||||
import json
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
draw = MySeaDraw.objects.get(user=self.user)
|
||||
# Sig snapshot persists even after user clears their sig.
|
||||
self.user.significator = None
|
||||
self.user.save(update_fields=["significator"])
|
||||
draw.refresh_from_db()
|
||||
self.assertEqual(draw.significator_id, self.target.id)
|
||||
|
||||
|
||||
class MySeaDeleteDrawViewTest(TestCase):
|
||||
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards, TarotCard
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.user = User.objects.create(email="del@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)[:3])
|
||||
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"},
|
||||
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
||||
],
|
||||
)
|
||||
self.url = reverse("my_sea_delete")
|
||||
|
||||
def test_delete_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(self.url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_delete_get_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_delete_post_clears_active_draw(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
self.assertFalse(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
|
||||
other = User.objects.create(email="other-del@test.io")
|
||||
other_draw = MySeaDraw.objects.create(
|
||||
user=other, spread="situation-action-outcome",
|
||||
hand=self.draw.hand, significator_id=self.target.id,
|
||||
)
|
||||
self.client.post(self.url)
|
||||
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
|
||||
|
||||
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.
|
||||
self.client.post(self.url)
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
|
||||
|
||||
class MySeaViewWithSavedDrawTest(TestCase):
|
||||
"""Sprint 5 iter 4b — `my_sea` view branches when an active draw exists.
|
||||
|
||||
Active draw bypasses the sign-gate (sig snapshot on the draw is used
|
||||
even if `user.significator` is None), bypasses the landing phase (the
|
||||
saved hand IS what the user came to see), and adds a Brief banner +
|
||||
next-free-draw timestamp to the context."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards, TarotCard
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.user = User.objects.create(email="saved@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)[:3])
|
||||
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"},
|
||||
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_context_carries_active_draw(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertEqual(response.context["active_draw"], self.draw)
|
||||
|
||||
def test_context_default_spread_is_saved_spread(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertEqual(response.context["default_spread"], self.draw.spread)
|
||||
|
||||
def test_context_carries_next_free_draw_iso(self):
|
||||
from datetime import datetime
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
ts = response.context["next_free_draw_at"]
|
||||
# Either a datetime instance or an ISO string the template renders.
|
||||
if isinstance(ts, str):
|
||||
self.assertIsNotNone(datetime.fromisoformat(ts))
|
||||
else:
|
||||
self.assertIsNotNone(ts.isoformat())
|
||||
|
||||
def test_saved_draw_bypasses_sign_gate_even_when_user_sig_cleared(self):
|
||||
# User-spec'd: a cleared sig doesn't invalidate the saved draw.
|
||||
# The view must still render the picker phase (NOT the sign-gate)
|
||||
# by falling back to the draw's snapshotted sig.
|
||||
self.user.significator = None
|
||||
self.user.save(update_fields=["significator"])
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertNotIn("my-sea-sign-gate", html)
|
||||
self.assertIn('data-phase="picker"', html)
|
||||
|
||||
def test_view_renders_brief_banner_when_active_draw_exists(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, "my-sea-brief")
|
||||
self.assertContains(response, "my-sea-brief__timestamp")
|
||||
self.assertContains(response, "my-sea-brief__nvm")
|
||||
|
||||
def test_brief_banner_hidden_without_active_draw(self):
|
||||
# Markup is rendered unconditionally so JS can un-hide it on LOCK
|
||||
# HAND POST success without a page reload. When no active_draw,
|
||||
# the wrapping div carries `[hidden]` so the banner is invisible.
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.all().delete()
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, '<div class="my-sea-brief" hidden>')
|
||||
|
||||
def test_view_renders_del_guard_portal_when_active_draw_exists(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'id="id_my_sea_del_portal"')
|
||||
self.assertContains(response, "my-sea-del-portal__confirm")
|
||||
self.assertContains(response, "my-sea-del-portal__nvm")
|
||||
|
||||
def test_saved_hand_renders_as_filled_slots_in_picker(self):
|
||||
# Each saved position's slot is server-rendered as `--filled` w.
|
||||
# the snapshotted card id + polarity. JS-init then layers any
|
||||
# post-load behaviours (label re-rendering, stage-card lookups).
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
for entry in self.draw.hand:
|
||||
self.assertIn(f'data-card-id="{entry["card_id"]}"', html)
|
||||
self.assertIn(f"sea-card-slot--{entry['polarity']}", html)
|
||||
|
||||
def test_landing_phase_suppressed_when_active_draw_exists(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||
self.assertNotContains(response, 'data-phase="landing"')
|
||||
|
||||
Reference in New Issue
Block a user