My Sea picker phase: three-card cross (sig + cover/leave/loom) — Sprint 5 iter 2 of My Sea roadmap — TDD

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 <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 16:06:14 -04:00
parent 285597b467
commit f5fc1e15f8
5 changed files with 180 additions and 7 deletions

View File

@@ -586,3 +586,49 @@ class MySeaDrawSeaLandingViewTest(TestCase):
self.user.save(update_fields=["significator"]) self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea")) response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"') 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")

View File

@@ -187,6 +187,12 @@ def my_sea(request):
"user_has_sig": user_has_sig, "user_has_sig": user_has_sig,
"no_equipped_deck": no_equipped_deck, "no_equipped_deck": no_equipped_deck,
"show_backup_intro_banner": user_has_sig and 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", "page_class": "page-gameboard page-my-sea",
}) })

View File

@@ -326,3 +326,88 @@ class MySeaDrawSeaLandingTest(FunctionalTest):
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")), len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")),
0, 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",
)

View File

@@ -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 { .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; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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;
}

View File

@@ -66,12 +66,34 @@
</div> </div>
</div> </div>
{# Picker phase placeholder — iter 2 wires up the three-card #} {# Picker phase — three-card cross w. sig pinned in core + #}
{# cross layout (sig in center + cover/leave/loom) w. the #} {# cover (overlaid on sig) + leave (left) + loom (right). #}
{# --duoUser bg + form col (spread dropdown / decks / LOCK #} {# Crown / lay / cross from the gameroom's 6-position spread #}
{# HAND / DEL). For iter 1 it's just a phase-swap target. #} {# 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. #}
<div class="my-sea-picker" style="display:none"> <div class="my-sea-picker" style="display:none">
<p class="my-sea-picker__placeholder">DRAW SEA picker — wiring lands in Sprint 5 iter 2.</p> <div class="sea-cards-col">
<div class="sea-cross my-sea-cross">
<div class="sea-crucifix-cell sea-pos-leave">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-core">
<div class="sig-stage-card sea-sig-card"
data-card-id="{{ significator.id }}">
<span class="fan-corner-rank">{{ significator.corner_rank }}</span>
{% if significator.suit_icon %}<i class="fa-solid {{ significator.suit_icon }}"></i>{% endif %}
</div>
<div class="sea-pos-cover">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
<div class="sea-crucifix-cell sea-pos-loom">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
</div>
</div> </div>
<script src="{% static 'apps/epic/room.js' %}"></script> <script src="{% static 'apps/epic/room.js' %}"></script>