2026-05-19 01:38:55 -04:00
|
|
|
"""FTs for the My Sea standalone page sign-gate.
|
|
|
|
|
|
|
|
|
|
Sprint 4b of [[project-my-sea-roadmap]]. The /gameboard/my-sea/ page is
|
|
|
|
|
gated behind sig selection — when `user.significator` is None, render a
|
2026-05-19 15:15:37 -04:00
|
|
|
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
|
2026-05-19 01:38:55 -04:00
|
|
|
(→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
|
|
|
|
|
mirrors the gate hint in its empty-state slot.
|
|
|
|
|
"""
|
|
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
|
|
|
|
|
from .base import FunctionalTest
|
2026-05-19 14:22:49 -04:00
|
|
|
from .sig_page import _assign_sig, _seed_earthman_sig_pile
|
2026-05-19 01:38:55 -04:00
|
|
|
from apps.applets.models import Applet
|
2026-05-19 14:22:49 -04:00
|
|
|
from apps.epic.models import personal_sig_cards
|
2026-05-19 01:38:55 -04:00
|
|
|
from apps.lyric.models import User
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_gameboard_applets():
|
|
|
|
|
"""My Sea + the rest of the gameboard applets so /gameboard/ renders
|
2026-05-19 11:05:39 -04:00
|
|
|
without missing-applet errors during the applet-side assertions.
|
|
|
|
|
Mirrors the migration seed (0003 + 0008) — every slug must have a
|
|
|
|
|
matching _applet-<slug>.html partial under apps/gameboard/_partials/."""
|
2026-05-19 01:38:55 -04:00
|
|
|
for slug, name, cols, rows, ctx in [
|
|
|
|
|
("my-sea", "My Sea", 12, 4, "gameboard"),
|
2026-05-19 11:05:39 -04:00
|
|
|
("game-kit", "Game Kit", 4, 3, "gameboard"),
|
|
|
|
|
("new-game", "New Game", 4, 3, "gameboard"),
|
2026-05-19 01:38:55 -04:00
|
|
|
("my-games", "My Games", 4, 4, "gameboard"),
|
|
|
|
|
]:
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug=slug,
|
|
|
|
|
defaults={"name": name, "context": ctx,
|
|
|
|
|
"default_visible": True, "grid_cols": cols, "grid_rows": rows},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MySeaSignGateTest(FunctionalTest):
|
|
|
|
|
"""Sign-gate UX on the standalone /gameboard/my-sea/ page + the
|
|
|
|
|
/gameboard/ My Sea applet. User without a saved sig sees a Look!-
|
2026-05-19 15:15:37 -04:00
|
|
|
formatted nudge w. FYI to the picker + NVM to the gameboard."""
|
2026-05-19 01:38:55 -04:00
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
2026-05-19 11:05:39 -04:00
|
|
|
_seed_earthman_sig_pile()
|
2026-05-19 01:38:55 -04:00
|
|
|
_seed_gameboard_applets()
|
|
|
|
|
self.email = "sea@test.io"
|
|
|
|
|
self.gamer = User.objects.create(email=self.email)
|
|
|
|
|
sig_pile = personal_sig_cards(self.gamer)
|
|
|
|
|
self.target_card = sig_pile[0] if sig_pile else None
|
|
|
|
|
self.assertIsNotNone(
|
|
|
|
|
self.target_card,
|
|
|
|
|
"personal_sig_cards(user) returned no cards — check Earthman seed",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_no_sig_renders_lookline_gate_on_standalone_page(self):
|
|
|
|
|
"""User without significator → /gameboard/my-sea/ shows the Look!-
|
2026-05-19 15:15:37 -04:00
|
|
|
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
gate = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-sign-gate"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
text = gate.text
|
|
|
|
|
self.assertIn("Look!", text)
|
|
|
|
|
self.assertIn("pick your sign", text.lower())
|
|
|
|
|
self.assertIn("drawing the Sea", text)
|
2026-05-19 15:15:37 -04:00
|
|
|
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
|
|
|
|
|
# post-relabel; the BACK→NVM swap was label-only).
|
2026-05-19 01:38:55 -04:00
|
|
|
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
|
|
|
|
self.assertTrue(fyi.is_displayed())
|
2026-05-19 15:15:37 -04:00
|
|
|
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
|
|
|
|
|
self.assertTrue(nvm.is_displayed())
|
2026-05-19 01:38:55 -04:00
|
|
|
|
|
|
|
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_gate_fyi_links_to_my_sign_picker(self):
|
|
|
|
|
"""FYI button is an `<a href>` pointing at /billboard/my-sign/."""
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
fyi = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-sign-gate__fyi"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
href = fyi.get_attribute("href") or ""
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
href.endswith("/billboard/my-sign/"),
|
|
|
|
|
f"FYI should link to /billboard/my-sign/, got {href!r}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_gate_back_links_to_gameboard(self):
|
2026-05-19 15:15:37 -04:00
|
|
|
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
|
|
|
|
|
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
2026-05-19 15:15:37 -04:00
|
|
|
nvm = self.wait_for(
|
2026-05-19 01:38:55 -04:00
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-sign-gate__back"
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-19 15:15:37 -04:00
|
|
|
href = nvm.get_attribute("href") or ""
|
2026-05-19 01:38:55 -04:00
|
|
|
self.assertTrue(
|
|
|
|
|
href.endswith("/gameboard/"),
|
2026-05-19 15:15:37 -04:00
|
|
|
f"NVM should link to /gameboard/, got {href!r}",
|
2026-05-19 01:38:55 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_with_sig_skips_gate_and_renders_draw_shell(self):
|
|
|
|
|
"""User w. saved significator → no .my-sea-sign-gate on the page;
|
|
|
|
|
draw shell renders normally (Sprint 3 placeholder)."""
|
2026-05-19 14:22:49 -04:00
|
|
|
_assign_sig(self.gamer, self.target_card)
|
2026-05-19 01:38:55 -04:00
|
|
|
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")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-sign-gate")),
|
|
|
|
|
0,
|
|
|
|
|
"Gate should not render when user has a saved significator",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_no_sig_applet_mirrors_gate_with_fyi_link(self):
|
|
|
|
|
"""On /gameboard/, the My Sea applet's empty state shows the same
|
|
|
|
|
Look!-formatted gate w. FYI link to /billboard/my-sign/ when the
|
|
|
|
|
user has no significator. Provides a consistent UX across surfaces."""
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/")
|
|
|
|
|
applet_gate = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("Look!", applet_gate.text)
|
|
|
|
|
fyi = applet_gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
|
|
|
|
href = fyi.get_attribute("href") or ""
|
|
|
|
|
self.assertTrue(href.endswith("/billboard/my-sign/"))
|
|
|
|
|
|
|
|
|
|
# ── Test 6 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_with_sig_applet_renders_default_empty_state(self):
|
|
|
|
|
"""Applet w. saved sig → no gate, empty-state placeholder (until
|
|
|
|
|
Sprint 7 wires up the latest-draw rendering)."""
|
2026-05-19 14:22:49 -04:00
|
|
|
_assign_sig(self.gamer, self.target_card)
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/")
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_sea")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(self.browser.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
|
|
|
|
)),
|
|
|
|
|
0,
|
|
|
|
|
)
|
2026-05-19 15:15:37 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class MySeaDrawSeaLandingTest(FunctionalTest):
|
2026-05-19 15:48:07 -04:00
|
|
|
"""Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a
|
2026-05-19 15:15:37 -04:00
|
|
|
user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate).
|
|
|
|
|
|
|
|
|
|
Landing renders a DRY table hex (parameterized from the room) w. 6
|
|
|
|
|
chair seats labeled 1C-6C (placeholders for the eventual friend-
|
|
|
|
|
invite feature per [[project-my-sea-roadmap]] architectural anchor
|
2026-05-19 15:48:07 -04:00
|
|
|
"Six chairs retained even in solo") + a central FREE DRAW `.btn-
|
|
|
|
|
primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat
|
|
|
|
|
renders w. a red `.fa-ban` status icon (empty slot).
|
2026-05-19 15:15:37 -04:00
|
|
|
|
2026-05-19 15:48:07 -04:00
|
|
|
Click flow: FREE DRAW → seat 1C transitions to `.seated` state
|
|
|
|
|
(chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa-
|
|
|
|
|
circle-check` green) → after a brief delay so the user sees the
|
|
|
|
|
animation, `data-phase` swaps to `picker` (picker content lands in
|
|
|
|
|
iter 2). The 'C' = "Chair" (user-locked vocabulary); no role
|
|
|
|
|
semantics in this solo flow.
|
|
|
|
|
|
|
|
|
|
"FREE DRAW" is the label for the 1/24h free quota draw — a future
|
|
|
|
|
sprint will conditionally swap the label to "DRAW SEA" once the
|
|
|
|
|
free has been used, w. the DRAW SEA btn calling the room
|
|
|
|
|
gatekeeper partial for token-deposit.
|
|
|
|
|
|
|
|
|
|
The same Brief "Default deck warning" copy from my-sign fires when
|
|
|
|
|
the user has no equipped deck."""
|
2026-05-19 15:15:37 -04:00
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
_seed_earthman_sig_pile()
|
|
|
|
|
_seed_gameboard_applets()
|
|
|
|
|
self.email = "draw@test.io"
|
|
|
|
|
self.gamer = User.objects.create(email=self.email)
|
|
|
|
|
# Assign a sig so the page passes the Sprint 4b gate + lands on
|
|
|
|
|
# the new DRAW SEA UX rather than the Look!-line gate.
|
|
|
|
|
self.target_card = _assign_sig(self.gamer)
|
|
|
|
|
|
|
|
|
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-19 15:48:07 -04:00
|
|
|
def test_landing_renders_hex_with_free_draw_btn(self):
|
2026-05-19 15:15:37 -04:00
|
|
|
"""User w. sig → /gameboard/my-sea/ shows the DRY table hex (re-
|
2026-05-19 15:48:07 -04:00
|
|
|
used from my-sign / the room shell) w. a central FREE DRAW btn.
|
|
|
|
|
Element ID `id_draw_sea_btn` describes intent (the draw entry
|
|
|
|
|
point) — a future sprint will conditionally swap the label to
|
|
|
|
|
DRAW SEA once the daily free has been used."""
|
2026-05-19 15:15:37 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
# data-phase=landing on the page wrapper
|
|
|
|
|
page = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']")
|
|
|
|
|
)
|
|
|
|
|
# Hex shell present
|
|
|
|
|
page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex")
|
2026-05-19 15:48:07 -04:00
|
|
|
# FREE DRAW btn in hex center
|
2026-05-19 15:15:37 -04:00
|
|
|
btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
|
|
|
|
self.assertTrue(btn.is_displayed())
|
2026-05-19 15:48:07 -04:00
|
|
|
self.assertIn("FREE", btn.text.upper())
|
2026-05-19 15:15:37 -04:00
|
|
|
self.assertIn("DRAW", btn.text.upper())
|
|
|
|
|
self.assertIn("btn-primary", btn.get_attribute("class"))
|
|
|
|
|
|
|
|
|
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self):
|
|
|
|
|
"""All 6 chair positions render w. labels 1C-6C (placeholder for
|
|
|
|
|
friend-invite). CSS class `.table-seat` is preserved so the SCSS
|
2026-05-19 15:48:07 -04:00
|
|
|
positioning rules (data-slot=N) carry over from the room shell.
|
|
|
|
|
Each seat starts w. a red `.fa-ban` status icon (empty)."""
|
2026-05-19 15:15:37 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
seats = self.wait_for(
|
|
|
|
|
lambda: self._six_seats()
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(seats), 6)
|
2026-05-19 15:48:07 -04:00
|
|
|
for n, seat in enumerate(seats, start=1):
|
2026-05-19 15:15:37 -04:00
|
|
|
with self.subTest(slot=n):
|
2026-05-19 15:48:07 -04:00
|
|
|
label = "".join(seat.text.upper().split())
|
|
|
|
|
self.assertIn(f"{n}C", label)
|
|
|
|
|
# Each seat carries the red ban status icon initially.
|
|
|
|
|
seat.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".position-status-icon.fa-ban"
|
|
|
|
|
)
|
2026-05-19 15:15:37 -04:00
|
|
|
|
|
|
|
|
def _six_seats(self):
|
|
|
|
|
seats = self.browser.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing'] .table-seat"
|
|
|
|
|
)
|
|
|
|
|
if len(seats) != 6:
|
|
|
|
|
raise AssertionError(f"expected 6 seats, got {len(seats)}")
|
|
|
|
|
return seats
|
|
|
|
|
|
|
|
|
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-19 15:48:07 -04:00
|
|
|
def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self):
|
|
|
|
|
"""Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban
|
|
|
|
|
swapped for fa-circle-check (visible to the user during the
|
|
|
|
|
~800ms animation delay); other seats remain empty; then the
|
|
|
|
|
page's data-phase swaps to 'picker' so iter 2's content can
|
|
|
|
|
take over. Single-user instance for now → user always gets the
|
|
|
|
|
lowest-numeral seat (1C)."""
|
2026-05-19 15:15:37 -04:00
|
|
|
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()
|
2026-05-19 15:48:07 -04:00
|
|
|
# Seat 1C goes seated + icon swaps. Other seats unchanged.
|
|
|
|
|
seat1 = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check")
|
|
|
|
|
# Seats 2-6 retain the .fa-ban icon (still empty).
|
|
|
|
|
for n in range(2, 7):
|
|
|
|
|
with self.subTest(slot=n):
|
|
|
|
|
other = self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, f".table-seat[data-slot='{n}']"
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("seated", other.get_attribute("class"))
|
|
|
|
|
other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban")
|
|
|
|
|
# After the seat animation, data-phase swaps to picker + landing hides.
|
2026-05-19 15:15:37 -04:00
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing")
|
|
|
|
|
self.assertFalse(landing.is_displayed())
|
|
|
|
|
|
|
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_brief_banner_renders_when_no_deck_equipped(self):
|
|
|
|
|
"""No equipped deck → the same 'Default deck warning' Brief
|
|
|
|
|
banner from my-sign fires (lifted verbatim). Tagged w. a my-sea-
|
|
|
|
|
specific class so FTs can disambiguate from any other Briefs."""
|
|
|
|
|
self.gamer.equipped_deck = None
|
|
|
|
|
self.gamer.save(update_fields=["equipped_deck"])
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
banner = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-intro-banner"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("Default deck warning", banner.text)
|
|
|
|
|
self.assertIn("no deck is equipped", banner.text)
|
|
|
|
|
self.assertIn("Shabby Cardstock", banner.text)
|
|
|
|
|
|
|
|
|
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_no_brief_banner_when_deck_equipped(self):
|
|
|
|
|
"""User w. an equipped deck → no Default-deck-warning Brief on
|
|
|
|
|
landing. Auto-equip via the User post_save signal handles this
|
|
|
|
|
for fresh users; assertion guards against accidental render of
|
|
|
|
|
the banner when the condition shouldn't fire."""
|
|
|
|
|
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, "#id_draw_sea_btn")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")),
|
|
|
|
|
0,
|
|
|
|
|
)
|
2026-05-19 16:06:14 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-19 19:38:53 -04:00
|
|
|
def test_picker_renders_sao_default_position_subset(self):
|
|
|
|
|
"""Default spread = Situation/Action/Outcome (SAO) → only lay
|
|
|
|
|
(Situation) + cover (Action) + crown (Outcome) visible from the
|
|
|
|
|
6 surrounding positions; leave / loom / cross hidden. All 6
|
|
|
|
|
cells render in DOM so spread-switching never re-mutates the
|
|
|
|
|
cross structure — per-spread visibility lives in SCSS via
|
|
|
|
|
`.my-sea-cross[data-spread="..."]` rules."""
|
2026-05-19 16:06:14 -04:00
|
|
|
picker = self._enter_picker_phase()
|
2026-05-19 19:38:53 -04:00
|
|
|
visible = {".sea-pos-lay", ".sea-pos-cover", ".sea-pos-crown"}
|
|
|
|
|
hidden = {".sea-pos-leave", ".sea-pos-loom", ".sea-pos-cross"}
|
|
|
|
|
for pos in visible | hidden:
|
|
|
|
|
with self.subTest(position=pos):
|
|
|
|
|
elements = picker.find_elements(By.CSS_SELECTOR, pos)
|
|
|
|
|
self.assertEqual(len(elements), 1, f"{pos} should render in DOM")
|
|
|
|
|
expected_visible = pos in visible
|
2026-05-19 16:06:14 -04:00
|
|
|
self.assertEqual(
|
2026-05-19 19:38:53 -04:00
|
|
|
elements[0].is_displayed(), expected_visible,
|
|
|
|
|
f"{pos} visibility wrong for SAO default; expected {expected_visible}",
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MySeaSpreadFormTest(FunctionalTest):
|
|
|
|
|
"""Sprint 5 iter 3 — form col on the picker phase: SPREAD dropdown
|
|
|
|
|
(custom combobox w. 6 options + 2 horizontal section dividers for
|
|
|
|
|
"3-card spreads" / "6-card spreads"), reversal-rate caption, two
|
|
|
|
|
DECKS swatches (GRAVITY + LEVITY), LOCK HAND + DEL btns. Selecting
|
|
|
|
|
a 6-card spread (Celtic Cross variants) swaps `.my-sea-cross[data-
|
|
|
|
|
spread-shape]` from `three-card` to `six-card`, revealing the
|
|
|
|
|
crown / lay / cross positions hidden by default.
|
|
|
|
|
|
|
|
|
|
Card-draw mechanics — clicking a deck swatch to deposit a card into
|
|
|
|
|
the next empty slot — defers to iter 4."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
_seed_earthman_sig_pile()
|
|
|
|
|
_seed_gameboard_applets()
|
|
|
|
|
self.email = "spread@test.io"
|
|
|
|
|
self.gamer = User.objects.create(email=self.email)
|
|
|
|
|
self.target_card = _assign_sig(self.gamer)
|
|
|
|
|
|
|
|
|
|
def _enter_picker_phase(self):
|
|
|
|
|
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_spread_dropdown_renders_six_options_and_two_dividers(self):
|
|
|
|
|
"""SPREAD combobox has 4 three-card options + 2 six-card
|
|
|
|
|
options + 2 horizontal section dividers labelled "3-card
|
|
|
|
|
spreads" / "6-card spreads". Dividers are `role=presentation`
|
|
|
|
|
+ `.sea-select-divider` so combobox.js skips them.
|
|
|
|
|
|
|
|
|
|
The dropdown is closed (`aria-expanded='false'`) on initial
|
|
|
|
|
render so the <li>s aren't displayed; use textContent rather
|
|
|
|
|
than `.text` (which returns "" for hidden elements)."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
options = picker.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-select-list [role='option']"
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(options), 6)
|
|
|
|
|
option_labels = [
|
|
|
|
|
o.get_attribute("textContent").strip() for o in options
|
|
|
|
|
]
|
|
|
|
|
# Three-card variants — labels per [[project-my-sea-roadmap]]
|
|
|
|
|
# iter 3 spec lock.
|
|
|
|
|
self.assertIn("Past, Present, Future", option_labels)
|
|
|
|
|
self.assertIn("Situation, Action, Outcome", option_labels)
|
|
|
|
|
self.assertIn("Mind, Body, Spirit", option_labels)
|
|
|
|
|
self.assertIn("Desire, Obstacle, Solution", option_labels)
|
|
|
|
|
# Six-card variants
|
|
|
|
|
self.assertIn("Celtic Cross, Waite-Smith", option_labels)
|
|
|
|
|
self.assertIn("Celtic Cross, Escape Velocity", option_labels)
|
|
|
|
|
# Two horizontal dividers
|
|
|
|
|
dividers = picker.find_elements(By.CSS_SELECTOR, ".sea-select-divider")
|
|
|
|
|
self.assertEqual(len(dividers), 2)
|
|
|
|
|
divider_text = "|".join(
|
|
|
|
|
d.get_attribute("textContent").upper().strip() for d in dividers
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("3-CARD SPREADS", divider_text)
|
|
|
|
|
self.assertIn("6-CARD SPREADS", divider_text)
|
|
|
|
|
|
|
|
|
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_default_spread_is_situation_action_outcome(self):
|
|
|
|
|
"""Per the spec, `Situation, Action, Outcome` is the default
|
|
|
|
|
spread on landing — selected in the combobox + reflected in
|
2026-05-19 19:38:53 -04:00
|
|
|
the hidden `<input id="id_sea_spread">` initial value + on
|
|
|
|
|
`.my-sea-cross[data-spread]`."""
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
hidden = picker.find_element(By.CSS_SELECTOR, "#id_sea_spread")
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
hidden.get_attribute("value"), "situation-action-outcome",
|
|
|
|
|
)
|
|
|
|
|
selected = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-select-list [role='option'][aria-selected='true']"
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
selected.get_attribute("textContent").strip(),
|
|
|
|
|
"Situation, Action, Outcome",
|
|
|
|
|
)
|
|
|
|
|
current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current")
|
|
|
|
|
self.assertEqual(current.text.strip(), "Situation, Action, Outcome")
|
|
|
|
|
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
|
2026-05-19 19:38:53 -04:00
|
|
|
self.assertEqual(
|
|
|
|
|
cross.get_attribute("data-spread"), "situation-action-outcome",
|
|
|
|
|
)
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
|
|
|
|
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-19 19:38:53 -04:00
|
|
|
def test_picking_spread_swaps_data_spread_and_position_visibility(self):
|
|
|
|
|
"""Each spread reveals its own position subset (user-locked
|
|
|
|
|
2026-05-19):
|
|
|
|
|
PPF → leave + cover + loom visible
|
|
|
|
|
SAO → lay + cover + crown
|
|
|
|
|
MBS → crown + lay + loom
|
|
|
|
|
DOS → loom + cross + cover
|
|
|
|
|
CC variants → all 6 surrounding positions.
|
|
|
|
|
`.my-sea-cross[data-spread]` swaps on combobox change; SCSS
|
|
|
|
|
rules toggle the inactive positions to `display: none`."""
|
|
|
|
|
ALL_POSITIONS = {"crown", "leave", "cover", "cross", "loom", "lay"}
|
|
|
|
|
SPREAD_POSITIONS = {
|
|
|
|
|
"past-present-future": {"leave", "cover", "loom"},
|
|
|
|
|
"situation-action-outcome": {"lay", "cover", "crown"},
|
|
|
|
|
"mind-body-spirit": {"crown", "lay", "loom"},
|
My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.
SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.
Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).
Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.
DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.
SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).
.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.
Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.
SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).
Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.
Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
|
|
|
"desire-obstacle-solution": {"loom", "cross", "crown"},
|
2026-05-19 19:38:53 -04:00
|
|
|
"waite-smith": ALL_POSITIONS,
|
|
|
|
|
"escape-velocity": ALL_POSITIONS,
|
|
|
|
|
}
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
picker = self._enter_picker_phase()
|
2026-05-19 19:38:53 -04:00
|
|
|
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
|
|
|
|
|
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
|
|
|
|
|
|
|
|
|
def _pick(value):
|
|
|
|
|
# Combobox click outside an open dropdown opens it; click on
|
|
|
|
|
# an option inside selects + closes. Re-opening for each pick
|
|
|
|
|
# keeps the test deterministic.
|
|
|
|
|
if combo.get_attribute("aria-expanded") != "true":
|
|
|
|
|
combo.click()
|
|
|
|
|
opt = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
f".sea-select-list [role='option'][data-value='{value}']",
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
)
|
2026-05-19 19:38:53 -04:00
|
|
|
opt.click()
|
|
|
|
|
|
|
|
|
|
for value, expected_visible in SPREAD_POSITIONS.items():
|
|
|
|
|
with self.subTest(spread=value):
|
|
|
|
|
_pick(value)
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda v=value: self.assertEqual(
|
|
|
|
|
cross.get_attribute("data-spread"), v
|
|
|
|
|
)
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
)
|
2026-05-19 19:38:53 -04:00
|
|
|
for pos in ALL_POSITIONS:
|
|
|
|
|
element = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, f".sea-pos-{pos}"
|
|
|
|
|
)
|
|
|
|
|
should_show = pos in expected_visible
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
element.is_displayed(), should_show,
|
|
|
|
|
f"spread={value} pos={pos}: expected is_displayed={should_show}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_per_spread_position_labels_render_and_update(self):
|
|
|
|
|
"""Each visible empty slot carries a `.sea-pos-label` caption
|
|
|
|
|
whose text matches the spread's per-position label map (e.g.
|
|
|
|
|
SAO default: lay='Situation', cover='Action', crown='Outcome').
|
|
|
|
|
JS updates labels on spread change. Reappropriates the
|
|
|
|
|
GRAVITY/LEVITY (`.sea-stack-name`) caption styling."""
|
|
|
|
|
SPREAD_LABELS = {
|
|
|
|
|
"situation-action-outcome": {"lay": "Situation", "cover": "Action", "crown": "Outcome"},
|
|
|
|
|
"past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"},
|
|
|
|
|
"mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"},
|
My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.
SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.
Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).
Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.
DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.
SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).
.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.
Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.
SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).
Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.
Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
|
|
|
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"},
|
2026-05-19 19:38:53 -04:00
|
|
|
"waite-smith": {"crown": "Crown", "leave": "Beneath", "cover": "Cover",
|
|
|
|
|
"cross": "Cross", "loom": "Before", "lay": "Behind"},
|
|
|
|
|
}
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
|
|
|
|
|
|
|
|
|
def _pick(value):
|
|
|
|
|
if combo.get_attribute("aria-expanded") != "true":
|
|
|
|
|
combo.click()
|
|
|
|
|
picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
f".sea-select-list [role='option'][data-value='{value}']",
|
|
|
|
|
).click()
|
|
|
|
|
|
|
|
|
|
# SAO default — assert labels via the server-rendered initial state.
|
|
|
|
|
for pos, expected_label in SPREAD_LABELS["situation-action-outcome"].items():
|
|
|
|
|
with self.subTest(spread="situation-action-outcome", position=pos):
|
|
|
|
|
label_el = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
f".sea-pos-label[data-position='{pos}']",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
label_el.get_attribute("textContent").strip(),
|
|
|
|
|
expected_label,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Switch to each other spread + verify the labels update.
|
|
|
|
|
for spread, position_to_label in SPREAD_LABELS.items():
|
|
|
|
|
if spread == "situation-action-outcome":
|
|
|
|
|
continue
|
|
|
|
|
_pick(spread)
|
|
|
|
|
for pos, expected_label in position_to_label.items():
|
|
|
|
|
with self.subTest(spread=spread, position=pos):
|
|
|
|
|
label_el = self.wait_for(
|
|
|
|
|
lambda p=pos, lbl=expected_label: self._wait_label(p, lbl, picker)
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
label_el.get_attribute("textContent").strip(),
|
|
|
|
|
expected_label,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _wait_label(self, position, expected_label, picker):
|
|
|
|
|
el = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, f".sea-pos-label[data-position='{position}']"
|
|
|
|
|
)
|
|
|
|
|
if el.get_attribute("textContent").strip() != expected_label:
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
f"label@{position}: got "
|
|
|
|
|
f"{el.get_attribute('textContent')!r}, want {expected_label!r}"
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
)
|
2026-05-19 19:38:53 -04:00
|
|
|
return el
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
|
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.
**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:
- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)
Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).
`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.
**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.
**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).
Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.
**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
|
|
|
|
|
|
|
|
class MySeaCardDrawTest(FunctionalTest):
|
|
|
|
|
"""Sprint 5 iter 4a — client-side card-draw mechanics on the picker
|
|
|
|
|
phase. Server embeds the deck (gravity + levity halves, user's sig
|
|
|
|
|
excluded) as JSON; clicking GRAVITY/LEVITY swatch shows FLIP; FLIP
|
|
|
|
|
deposits the next card into the next DRAW_ORDER slot for the active
|
|
|
|
|
spread. DEL fully resets the in-progress hand. LOCK HAND enables
|
|
|
|
|
when the hand is complete + click locks down further interaction.
|
|
|
|
|
Switching spreads also resets the hand (the position-subset changes).
|
|
|
|
|
|
|
|
|
|
Server-side persistence (committing the locked hand to a MySeaDraw
|
|
|
|
|
model) defers to iter 4b."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
_seed_earthman_sig_pile()
|
|
|
|
|
_seed_gameboard_applets()
|
|
|
|
|
self.email = "draw@test.io"
|
|
|
|
|
self.gamer = User.objects.create(email=self.email)
|
|
|
|
|
self.target_card = _assign_sig(self.gamer)
|
|
|
|
|
|
|
|
|
|
def _enter_picker_phase(self):
|
|
|
|
|
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']"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.
SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.
Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).
Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.
DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.
SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).
.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.
Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.
SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).
Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.
Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
|
|
|
def _draw_open_modal(self, picker, polarity):
|
|
|
|
|
"""Click a polarity swatch + the FLIP btn that appears → opens
|
|
|
|
|
the SeaDeal stage modal. Returns the stage element so callers
|
|
|
|
|
can assert on it before dismissing."""
|
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.
**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:
- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)
Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).
`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.
**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.
**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).
Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.
**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
|
|
|
stack = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, f".sea-deck-stack--{polarity}"
|
|
|
|
|
)
|
|
|
|
|
stack.click()
|
|
|
|
|
flip = self.wait_for(
|
|
|
|
|
lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok")
|
|
|
|
|
)
|
|
|
|
|
self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
|
|
|
|
|
flip.click()
|
My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.
SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.
Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).
Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.
DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.
SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).
.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.
Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.
SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).
Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.
Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
|
|
|
# SeaDeal.openStage shows #id_sea_stage. Wait for the modal.
|
|
|
|
|
return self.wait_for(
|
|
|
|
|
lambda: self._stage_visible()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _stage_visible(self):
|
|
|
|
|
stage = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
|
|
|
|
|
if not stage.is_displayed():
|
|
|
|
|
raise AssertionError("sea-stage not visible after FLIP click")
|
|
|
|
|
return stage
|
|
|
|
|
|
|
|
|
|
def _dismiss_modal(self):
|
|
|
|
|
"""Click the stage backdrop → SeaDeal._hideStage → modal hides +
|
|
|
|
|
slot gains `.--visible` (thumbnail fades in).
|
|
|
|
|
|
|
|
|
|
Uses `execute_script` to dispatch the click rather than a native
|
|
|
|
|
Selenium `.click()` — `.sea-stage-content` overlays the backdrop
|
|
|
|
|
visually (centered card + stat block), so Selenium reports
|
|
|
|
|
ElementClickInterceptedException for a direct click. This is
|
|
|
|
|
the documented Selenium-limitation exception per the TDD skill;
|
|
|
|
|
the actual backdrop-click → close behaviour is Jasmine-tested
|
|
|
|
|
in [[SeaDealSpec.js]] / "Backdrop click closes the stage"."""
|
|
|
|
|
self.browser.execute_script(
|
|
|
|
|
"document.querySelector('#id_sea_stage .sea-stage-backdrop').click();"
|
|
|
|
|
)
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertFalse(
|
|
|
|
|
self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage").is_displayed()
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _draw_one(self, picker, polarity):
|
|
|
|
|
"""Full single-draw cycle: open modal + dismiss it. Used by FTs
|
|
|
|
|
that need to deposit multiple cards in sequence (the stage
|
|
|
|
|
backdrop blocks subsequent deck-stack clicks)."""
|
|
|
|
|
self._draw_open_modal(picker, polarity)
|
|
|
|
|
self._dismiss_modal()
|
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.
**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:
- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)
Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).
`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.
**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.
**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).
Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.
**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
|
|
|
|
|
|
|
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_deck_data_embedded_with_two_polarity_halves(self):
|
|
|
|
|
"""Server-side renders the shuffled deck (levity + gravity
|
|
|
|
|
halves, sig excluded) inside `<script type="application/json"
|
|
|
|
|
id="id_my_sea_deck">`. Client-side JS reads on init."""
|
|
|
|
|
import json as _json
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
|
|
|
|
|
deck = _json.loads(data_el.get_attribute("textContent"))
|
|
|
|
|
self.assertIn("levity", deck)
|
|
|
|
|
self.assertIn("gravity", deck)
|
|
|
|
|
self.assertIsInstance(deck["levity"], list)
|
|
|
|
|
self.assertIsInstance(deck["gravity"], list)
|
|
|
|
|
# Both halves should be non-empty (16 court cards in the seed,
|
|
|
|
|
# minus 1 sig → 15 cards split ~7/8).
|
|
|
|
|
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
|
|
|
|
|
# No card appears in both halves.
|
|
|
|
|
levity_ids = {c["id"] for c in deck["levity"]}
|
|
|
|
|
gravity_ids = {c["id"] for c in deck["gravity"]}
|
|
|
|
|
self.assertEqual(levity_ids & gravity_ids, set())
|
|
|
|
|
|
|
|
|
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_user_significator_excluded_from_drawn_deck(self):
|
|
|
|
|
"""The user's significator (pinned in `.sea-pos-core`) must NOT
|
|
|
|
|
appear in the gravity or levity deck halves — would otherwise
|
|
|
|
|
let the same card show up twice in the layout."""
|
|
|
|
|
import json as _json
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
|
|
|
|
|
deck = _json.loads(data_el.get_attribute("textContent"))
|
|
|
|
|
all_ids = {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]}
|
|
|
|
|
self.assertNotIn(self.target_card.id, all_ids)
|
|
|
|
|
|
|
|
|
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_levity_click_then_flip_deposits_card_into_first_sao_slot(self):
|
|
|
|
|
"""Default spread = SAO; first slot = `.sea-pos-lay` per the
|
|
|
|
|
DRAW_ORDER spec. Clicking LEVITY → FLIP → the first drawn card
|
|
|
|
|
lands in lay's `.sea-card-slot` w. `--filled` + `--levity`
|
|
|
|
|
classes + corner_rank text content from the deck card."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
slot = self.wait_for(
|
|
|
|
|
lambda: picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
".sea-pos-lay .sea-card-slot.sea-card-slot--filled",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("sea-card-slot--levity", slot.get_attribute("class"))
|
|
|
|
|
# Card has a corner-rank rendered inside.
|
|
|
|
|
slot.find_element(By.CSS_SELECTOR, ".fan-corner-rank")
|
|
|
|
|
# Slot has a data-card-id attribute set to the deposited card's id.
|
|
|
|
|
self.assertTrue(slot.get_attribute("data-card-id"))
|
|
|
|
|
|
|
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_two_draws_fill_first_two_slots_in_draw_order(self):
|
|
|
|
|
"""SAO draw order = lay → cover → crown. Second draw lands in
|
|
|
|
|
`.sea-pos-cover` regardless of polarity. Polarity of each
|
|
|
|
|
slot reflects which swatch was clicked."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
self._draw_one(picker, "gravity")
|
|
|
|
|
# First slot (lay) — levity
|
|
|
|
|
lay = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("sea-card-slot--levity", lay.get_attribute("class"))
|
|
|
|
|
# Second slot (cover) — gravity
|
|
|
|
|
cover = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot.sea-card-slot--filled"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("sea-card-slot--gravity", cover.get_attribute("class"))
|
|
|
|
|
|
|
|
|
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_lock_hand_enables_when_sao_hand_is_complete(self):
|
|
|
|
|
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
|
|
|
|
|
positions are drawn (hand-size = 3 for any three-card spread)."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
|
|
|
|
self.assertEqual(lock.get_attribute("disabled"), "true")
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
# Two draws — still disabled.
|
|
|
|
|
self.assertEqual(lock.get_attribute("disabled"), "true")
|
|
|
|
|
self._draw_one(picker, "gravity")
|
|
|
|
|
# Third draw completes the SAO hand — LOCK HAND enables.
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 6 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_del_click_resets_hand_and_disables_lock_hand(self):
|
|
|
|
|
"""DEL fully resets — every filled slot returns to `--empty`,
|
|
|
|
|
labels re-render, _filled counter zeros, LOCK HAND disables."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
self._draw_one(picker, "gravity")
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(picker.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
|
|
|
|
)),
|
|
|
|
|
2,
|
|
|
|
|
)
|
|
|
|
|
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
|
|
|
|
delbtn.click()
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertEqual(
|
|
|
|
|
len(picker.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
|
|
|
|
)),
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
|
|
|
|
self.assertEqual(lock.get_attribute("disabled"), "true")
|
|
|
|
|
|
|
|
|
|
# ── Test 7 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_lock_hand_click_disables_further_interaction(self):
|
|
|
|
|
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
|
|
|
|
|
itself all carry the `.btn-disabled` class so the hand can't
|
|
|
|
|
be mutated further. Persistence (POST to a server endpoint)
|
|
|
|
|
defers to iter 4b — this test pins only the visual lock."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
self._draw_one(picker, "levity")
|
|
|
|
|
self._draw_one(picker, "gravity")
|
|
|
|
|
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
|
|
|
|
)
|
|
|
|
|
lock.click()
|
|
|
|
|
# Picker carries a .my-sea-picker--locked class after LOCK HAND.
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
# Swatches no longer respond — clicking them does nothing.
|
|
|
|
|
gravity_stack = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("btn-disabled", gravity_stack.get_attribute("class"))
|
|
|
|
|
|
|
|
|
|
# ── 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."""
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_form_col_renders_decks_lock_hand_del_and_reversal_pct(self):
|
|
|
|
|
"""Form col carries the DECKS swatches (GRAVITY + LEVITY), the
|
|
|
|
|
LOCK HAND `.btn-primary`, the DEL `.btn-danger`, and the
|
|
|
|
|
reversal-percentage caption (default 25%)."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
# DECKS — two stacks
|
|
|
|
|
stacks = picker.find_elements(By.CSS_SELECTOR, ".sea-deck-stack")
|
|
|
|
|
self.assertEqual(len(stacks), 2)
|
|
|
|
|
names = "|".join(
|
|
|
|
|
s.find_element(By.CSS_SELECTOR, ".sea-stack-name").text.upper()
|
|
|
|
|
for s in stacks
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("GRAVITY", names)
|
|
|
|
|
self.assertIn("LEVITY", names)
|
|
|
|
|
# LOCK HAND + DEL
|
|
|
|
|
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
|
|
|
|
self.assertIn("LOCK", lock.text.upper())
|
|
|
|
|
self.assertIn("HAND", lock.text.upper())
|
|
|
|
|
self.assertIn("btn-primary", lock.get_attribute("class"))
|
|
|
|
|
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
|
|
|
|
self.assertIn("DEL", delbtn.text.upper())
|
|
|
|
|
self.assertIn("btn-danger", delbtn.get_attribute("class"))
|
|
|
|
|
# Reversal % caption — default 25
|
|
|
|
|
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
|
|
|
|
self.assertIn("25", hint.text)
|
|
|
|
|
self.assertIn("reversal", hint.text.lower())
|
My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.
SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.
Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).
Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.
DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.
SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).
.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.
Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.
SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).
Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.
Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
|
|
|
|
|
|
|
|
# ── Test (modal bug fix) ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_flip_click_opens_portaled_stage_modal(self):
|
|
|
|
|
"""Bug fix (2026-05-19): the user-reported missing modal. After
|
|
|
|
|
clicking the deck stack + the FLIP btn that appears, SeaDeal.
|
|
|
|
|
openStage should fire — showing `#id_sea_stage` (position-fixed
|
|
|
|
|
full-viewport portal) above everything else. Before the fix the
|
|
|
|
|
slot got filled directly at opacity 0 → 'thumbnail summarily
|
|
|
|
|
disappears'. Now: modal opens; slot stays at `--filled` but
|
|
|
|
|
`--visible` is NOT added yet (waits for backdrop dismiss)."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
stage = self._draw_open_modal(picker, "levity")
|
|
|
|
|
# Stage card carries the drawn card's data — non-empty corner rank.
|
|
|
|
|
rank = stage.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-stage-card .fan-card-corner--tl .fan-corner-rank"
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(rank.text.strip(), "stage card should display the drawn card's corner rank")
|
|
|
|
|
# Slot in the cross is in `.--filled` state but the thumbnail is
|
|
|
|
|
# invisible until the modal dismisses (the bug we're guarding).
|
|
|
|
|
slot = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
"sea-card-slot--visible", slot.get_attribute("class"),
|
|
|
|
|
"slot should still be in pre-reveal opacity-0 state while modal is open",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test (modal bug fix, dismiss reveal) ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_backdrop_click_dismisses_modal_and_reveals_thumbnail(self):
|
|
|
|
|
"""Bug fix part 2: clicking the `.sea-stage-backdrop` closes the
|
|
|
|
|
modal AND adds `.sea-card-slot--visible` to the deposited slot,
|
|
|
|
|
making the thumbnail fade in. Confirms the user-reported 'card
|
|
|
|
|
appears where the slot was' behavior post-dismiss."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
self._draw_open_modal(picker, "levity")
|
|
|
|
|
self._dismiss_modal()
|
|
|
|
|
slot = picker.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"sea-card-slot--visible", slot.get_attribute("class"),
|
|
|
|
|
"post-dismiss, the slot should fade in via `.--visible`",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test (modal bug fix, stat block populates) ─────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_modal_stage_renders_stat_block_dom_contract(self):
|
|
|
|
|
"""SeaDeal._populate populates the stat-block keyword `<ul>`s
|
|
|
|
|
via `#id_sea_stat_upright` / `#id_sea_stat_reversed`. The DOM
|
|
|
|
|
contract — these IDs exist inside the stage — is what this FT
|
|
|
|
|
pins; the actual stat content (keyword text, qualifier render)
|
|
|
|
|
is exercised by [[SeaDealSpec.js]]. Earthman seed cards in the
|
|
|
|
|
iter-4a FT pile carry empty keyword arrays so we can't assert
|
|
|
|
|
text content here without enriching the seed."""
|
|
|
|
|
picker = self._enter_picker_phase()
|
|
|
|
|
self._draw_open_modal(picker, "levity")
|
|
|
|
|
# Stat-block UL elements exist inside the visible stage.
|
|
|
|
|
upright = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_upright")
|
|
|
|
|
reversed_ul = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_reversed")
|
|
|
|
|
self.assertIsNotNone(upright)
|
|
|
|
|
self.assertIsNotNone(reversed_ul)
|
|
|
|
|
# The sea stat block is inside the visible stage modal.
|
|
|
|
|
stat_block = self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNotNone(stat_block)
|