From f5fc1e15f843b0417ccad709a8c1846f145bb5a1 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 19 May 2026 16:06:14 -0400 Subject: [PATCH] =?UTF-8?q?My=20Sea=20picker=20phase:=20three-card=20cross?= =?UTF-8?q?=20(sig=20+=20cover/leave/loom)=20=E2=80=94=20Sprint=205=20iter?= =?UTF-8?q?=202=20of=20My=20Sea=20roadmap=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec). DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free. Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/. The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow. View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching. - 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay. - 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence). Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s). Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../gameboard/tests/integrated/test_views.py | 46 ++++++++++ src/apps/gameboard/views.py | 6 ++ src/functional_tests/test_game_my_sea.py | 85 +++++++++++++++++++ src/static_src/scss/_gameboard.scss | 18 +++- src/templates/apps/gameboard/my_sea.html | 32 +++++-- 5 files changed, 180 insertions(+), 7 deletions(-) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index e9905f2..9049699 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -586,3 +586,49 @@ class MySeaDrawSeaLandingViewTest(TestCase): self.user.save(update_fields=["significator"]) response = self.client.get(reverse("my_sea")) self.assertNotContains(response, 'id="id_draw_sea_btn"') + + +class MySeaPickerPhaseTemplateTest(TestCase): + """Sprint 5 iter 2 — picker-phase template render contract: the + three-card cross (sig in core + cover/leave/loom drop zones) is + server-rendered (hidden until JS swaps data-phase after FREE DRAW). + Crown / lay / cross from the gameroom's 6-position Celtic Cross are + deliberately forsaken in the solo flow.""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + self.user = User.objects.create(email="picker@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"]) + + def test_picker_renders_significator_in_core_cell(self): + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + # Sig card carries the user's significator id so iter 3's draw + # flow can target it for SPIN / FLIP / FYI without re-fetching. + self.assertIn('sea-pos-core', html) + self.assertIn('sea-sig-card', html) + self.assertIn(f'data-card-id="{self.target.id}"', html) + + def test_picker_renders_cover_leave_loom_positions(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, "sea-pos-cover") + self.assertContains(response, "sea-pos-leave") + self.assertContains(response, "sea-pos-loom") + + def test_picker_does_not_render_forsaken_positions(self): + # Crown / lay / cross are gameroom-only — user-locked spec drops + # them from the solo three-card spread. + response = self.client.get(reverse("my_sea")) + self.assertNotContains(response, "sea-pos-crown") + self.assertNotContains(response, "sea-pos-lay") + self.assertNotContains(response, "sea-pos-cross") + + def test_picker_not_rendered_when_user_has_no_sig(self): + # 4b gate wins; picker has no business rendering without a sig. + self.user.significator = None + self.user.save(update_fields=["significator"]) + response = self.client.get(reverse("my_sea")) + self.assertNotContains(response, "my-sea-picker") diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 13b6a28..67d8979 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -187,6 +187,12 @@ def my_sea(request): "user_has_sig": user_has_sig, "no_equipped_deck": no_equipped_deck, "show_backup_intro_banner": user_has_sig and no_equipped_deck, + # Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the + # picker phase. Template guards on `user_has_sig` so a None pass- + # through is safe; we pass the FK directly so `.corner_rank` + + # `.suit_icon` resolve at render time. + "significator": request.user.significator, + "significator_reversed": request.user.significator_reversed, "page_class": "page-gameboard page-my-sea", }) diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index adeafce..58d64b3 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -326,3 +326,88 @@ class MySeaDrawSeaLandingTest(FunctionalTest): len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")), 0, ) + + +class MySeaPickerPhaseTest(FunctionalTest): + """Sprint 5 iter 2 — picker phase content on /gameboard/my-sea/ after + FREE DRAW click swaps `data-phase` to `picker`. Three-card spread: + user's saved significator pinned in the center (`.sea-pos-core`) + + three drawn-card positions surrounding it — cover (overlaid on sig), + leave (left of center), loom (right of center). Crown / lay / cross + from the gameroom's 6-position Celtic Cross are deliberately omitted + (user-locked spec). Empty drop-zones are visible — actual card-draw + wiring lands in iter 3 alongside the form col (spread dropdown / + decks / LOCK HAND / DEL).""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "picker@test.io" + self.gamer = User.objects.create(email=self.email) + self.target_card = _assign_sig(self.gamer) + + def _enter_picker_phase(self): + """Common nav: load /gameboard/my-sea/, click FREE DRAW, wait for + the page wrapper's data-phase to swap to `picker` (which happens + ~800ms after click per the seat-1C animation delay in the inline + JS).""" + 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']" + ) + ) + + # ── Test 1 ─────────────────────────────────────────────────────────────── + + def test_picker_renders_significator_card_in_core_cell(self): + """User's saved significator pins the `.sea-pos-core` cell — the + center of the three-card cross. Card data attribute reflects the + actual TarotCard.id so future iters can wire FYI / SPIN onto it.""" + self._enter_picker_phase() + core = self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-picker .sea-pos-core .sea-sig-card" + ) + self.assertEqual( + core.get_attribute("data-card-id"), str(self.target_card.id) + ) + + # ── Test 2 ─────────────────────────────────────────────────────────────── + + def test_picker_renders_cover_leave_loom_positions(self): + """The three drawn-card positions (cover/leave/loom) render as + empty `.sea-card-slot--empty` drop zones. Cover is overlaid on + the sig card via `.sea-pos-core > .sea-pos-cover` nesting; leave + + loom sit in their own grid cells flanking core.""" + picker = self._enter_picker_phase() + # Cover lives nested inside .sea-pos-core (overlaid on sig) + picker.find_element( + By.CSS_SELECTOR, ".sea-pos-core .sea-pos-cover .sea-card-slot--empty" + ) + picker.find_element( + By.CSS_SELECTOR, ".sea-pos-leave .sea-card-slot--empty" + ) + picker.find_element( + By.CSS_SELECTOR, ".sea-pos-loom .sea-card-slot--empty" + ) + + # ── Test 3 ─────────────────────────────────────────────────────────────── + + def test_picker_does_not_render_forsaken_positions(self): + """Crown / lay / cross from the gameroom's 6-position Celtic + Cross are forsaken in the solo three-card spread (user-locked + spec). None of these classes should appear in the picker DOM.""" + picker = self._enter_picker_phase() + for forsaken in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"): + with self.subTest(position=forsaken): + self.assertEqual( + len(picker.find_elements(By.CSS_SELECTOR, forsaken)), + 0, + f"forsaken position {forsaken} should not render in my-sea picker", + ) diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 0dc6b17..0ba362a 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -269,12 +269,26 @@ body.page-gameboard { } } +// Picker phase bg — `--duoUser` matches the table hex's interior so +// the landing→picker swap reads as a continuous surface (parallels +// `.my-sign-page[data-phase="picker"]` in _card-deck.scss line 704). +.my-sea-page[data-phase="picker"] { + background: rgba(var(--duoUser), 1); +} + .my-sea-picker { - // Iter-1 placeholder — iter 2 will populate w. the three-card cross - // (sig in center + cover/leave/loom) on a --duoUser background. flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; } + +// Three-cell horizontal row (leave | core | loom) — strips the crown +// + lay rows from `.sea-cross`'s 3×3 grid template, leaving only the +// middle row. Cover stays nested inside `.sea-pos-core` (absolutely +// positioned overlay handled by _card-deck.scss line 1310-1331). +.my-sea-cross { + grid-template-areas: "leave core loom"; + grid-template-rows: auto; +} diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index c9e62d6..04d268f 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -66,12 +66,34 @@ - {# Picker phase placeholder — iter 2 wires up the three-card #} - {# cross layout (sig in center + cover/leave/loom) w. the #} - {# --duoUser bg + form col (spread dropdown / decks / LOCK #} - {# HAND / DEL). For iter 1 it's just a phase-swap target. #} + {# Picker phase — three-card cross w. sig pinned in core + #} + {# cover (overlaid on sig) + leave (left) + loom (right). #} + {# Crown / lay / cross from the gameroom's 6-position spread #} + {# are forsaken in the solo flow per user-locked spec. Hidden #} + {# until FREE DRAW click swaps data-phase to `picker` (see #} + {# inline JS below); form col (spread dropdown / decks / #} + {# LOCK HAND / DEL) lands in iter 3. #}