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:
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user