Files
python-tdd/src/functional_tests/test_game_my_sea.py
Disco DeDisco f154d660bd My Sea per-spread positions + draw-order JS config + position labels — Sprint 5 iter 3 follow-up — TDD
User-locked spec 2026-05-19: each three-card spread uses a DIFFERENT 3-position subset of the 6 surrounding positions, in its own draw order. Replaces the iter-3 binary `data-spread-shape="three-card|six-card"` model w. per-spread `data-spread="<value>"`. Closes iter 3 cleanly + scaffolds the draw-order data iter 4 will consume.

Position subsets (per spread):
  PPF → leave (1) · cover (2) · loom  (3)
  SAO → lay   (1) · cover (2) · crown (3)
  MBS → crown (1) · lay   (2) · loom  (3)
  DOS → loom  (1) · cross (2) · cover (3)
  Waite-Smith     → all 6 surrounding (cover · cross · crown · lay · loom · leave)
  Escape Velocity → all 6 surrounding (cover · cross · lay · leave · crown · loom)

All 6 cells continue to render in DOM unconditionally — `.my-sea-cross[data-spread="<value>"]` SCSS rules hide inactive positions per spread via `display: none`. Cover/cross live nested inside `.sea-pos-core` so their absolute-overlay positioning rules from `_card-deck.scss:1310-1331` carry over for free.

**Position labels** (re-appropriated `.sea-stack-name` typography per user) — `.sea-pos-label` inside each empty `.sea-card-slot--empty` carries the per-spread caption. Server-renders SAO's labels by default (lay=Situation, cover=Action, crown=Outcome); JS swaps labels via `POSITION_LABELS[spread]` lookup on combobox change. Inactive-for-spread positions render their span w. empty `textContent` so JS only has to set text, never toggle visibility. Celtic Cross variants share the gameroom's existing position vocabulary (Crown/Beneath/Cover/Cross/Before/Behind).

**DRAW_ORDER JS const** baked into the inline picker IIFE — array of position names per spread, ready for iter 4's deck-click-deposit logic to consume. Exposed via `window._mySeaDrawOrder` so iter-4 click handlers can `window._mySeaDrawOrder[currentSpread][nextSlotIdx]` to resolve the target position. No click handlers wired yet — iter 4 territory.

**Selenium trap caught**: the combobox click-twice-on-the-toggle bug — re-clicking the combobox while `aria-expanded='true'` closes the dropdown (combobox.js's toggle behavior). Test 3's spread-cycling iterates through 6 spreads, each needs the dropdown OPEN before clicking a new option; added a `_pick(value)` helper that checks `aria-expanded` first.

Files:
- `templates/apps/gameboard/my_sea.html` — `.my-sea-cross[data-spread]` w. server-rendered default; each empty slot wraps a `<span class="sea-pos-label" data-position="<name>">` (SAO labels seeded inline, others empty initially); inline IIFE adds `DRAW_ORDER` + `POSITION_LABELS` consts + `syncLabels()` that swaps captions on `change`.
- `static_src/scss/_gameboard.scss` — drops the `data-spread-shape="three-card"|"six-card"` rules; adds 4 per-spread visibility rules (PPF/SAO/MBS/DOS). Celtic Cross variants inherit the gameroom's full 3×3 grid w. no overrides. `.sea-pos-label` style mirrors `.sea-stack-name` from _card-deck.scss line 1557 (small-uppercase-letter-spaced-scaleY) sans the polarity color — these aren't deck identifiers, just spread-position captions.
- `apps/gameboard/tests/integrated/test_views.py` — IT `test_cross_carries_initial_three_card_spread_shape` renamed + retargeted to `data-spread="situation-action-outcome"`; new IT `test_template_renders_sao_position_labels_on_default` pins the seeded SAO labels + empty spans for inactive positions.
- `functional_tests/test_game_my_sea.py` — iter-2's `test_picker_hides_six_card_only_positions_by_default` renamed to `test_picker_renders_sao_default_position_subset` w. SAO-specific visibility expectations (lay/cover/crown visible; leave/loom/cross hidden). iter-3's `test_picking_celtic_cross_reveals_six_card_positions` rewritten + expanded to `test_picking_spread_swaps_data_spread_and_position_visibility` — cycles through all 6 spreads, asserts `data-spread` attribute + per-position `is_displayed()` for each. New `test_per_spread_position_labels_render_and_update` cycles through 5 spreads (SAO default + 4 switches) asserting captions match the spec.

Tests: 33/33 FT green across test_bill_my_sign + test_game_my_sea; 1049/1049 IT/UT green in 52s.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:38:53 -04:00

667 lines
33 KiB
Python

"""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
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
(→ /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
from .sig_page import _assign_sig, _seed_earthman_sig_pile
from apps.applets.models import Applet
from apps.epic.models import personal_sig_cards
from apps.lyric.models import User
def _seed_gameboard_applets():
"""My Sea + the rest of the gameboard applets so /gameboard/ renders
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/."""
for slug, name, cols, rows, ctx in [
("my-sea", "My Sea", 12, 4, "gameboard"),
("game-kit", "Game Kit", 4, 3, "gameboard"),
("new-game", "New Game", 4, 3, "gameboard"),
("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!-
formatted nudge w. FYI to the picker + NVM to the gameboard."""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_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!-
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
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)
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
# post-relabel; the BACK→NVM swap was label-only).
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
self.assertTrue(fyi.is_displayed())
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
self.assertTrue(nvm.is_displayed())
# ── 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):
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
nvm = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-sign-gate__back"
)
)
href = nvm.get_attribute("href") or ""
self.assertTrue(
href.endswith("/gameboard/"),
f"NVM should link to /gameboard/, got {href!r}",
)
# ── 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)."""
_assign_sig(self.gamer, self.target_card)
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)."""
_assign_sig(self.gamer, self.target_card)
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,
)
class MySeaDrawSeaLandingTest(FunctionalTest):
"""Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a
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
"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).
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."""
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 ───────────────────────────────────────────────────────────────
def test_landing_renders_hex_with_free_draw_btn(self):
"""User w. sig → /gameboard/my-sea/ shows the DRY table hex (re-
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."""
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")
# FREE DRAW btn in hex center
btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
self.assertTrue(btn.is_displayed())
self.assertIn("FREE", btn.text.upper())
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
positioning rules (data-slot=N) carry over from the room shell.
Each seat starts w. a red `.fa-ban` status icon (empty)."""
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)
for n, seat in enumerate(seats, start=1):
with self.subTest(slot=n):
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"
)
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 ───────────────────────────────────────────────────────────────
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)."""
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()
# 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.
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,
)
class MySeaPickerPhaseTest(FunctionalTest):
"""Sprint 5 iter 2 — picker phase content on /gameboard/my-sea/ after
FREE DRAW click swaps `data-phase` to `picker`. Three-card spread:
user's saved significator pinned in the center (`.sea-pos-core`) +
three drawn-card positions surrounding it — cover (overlaid on sig),
leave (left of center), loom (right of center). Crown / lay / cross
from the gameroom's 6-position Celtic Cross are deliberately omitted
(user-locked spec). Empty drop-zones are visible — actual card-draw
wiring lands in iter 3 alongside the form col (spread dropdown /
decks / LOCK HAND / DEL)."""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_seed_gameboard_applets()
self.email = "picker@test.io"
self.gamer = User.objects.create(email=self.email)
self.target_card = _assign_sig(self.gamer)
def _enter_picker_phase(self):
"""Common nav: load /gameboard/my-sea/, click FREE DRAW, wait for
the page wrapper's data-phase to swap to `picker` (which happens
~800ms after click per the seat-1C animation delay in the inline
JS)."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
)
btn.click()
return self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_picker_renders_significator_card_in_core_cell(self):
"""User's saved significator pins the `.sea-pos-core` cell — the
center of the three-card cross. Card data attribute reflects the
actual TarotCard.id so future iters can wire FYI / SPIN onto it."""
self._enter_picker_phase()
core = self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-picker .sea-pos-core .sea-sig-card"
)
self.assertEqual(
core.get_attribute("data-card-id"), str(self.target_card.id)
)
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_picker_renders_cover_leave_loom_positions(self):
"""The three drawn-card positions (cover/leave/loom) render as
empty `.sea-card-slot--empty` drop zones. Cover is overlaid on
the sig card via `.sea-pos-core > .sea-pos-cover` nesting; leave
+ loom sit in their own grid cells flanking core."""
picker = self._enter_picker_phase()
# Cover lives nested inside .sea-pos-core (overlaid on sig)
picker.find_element(
By.CSS_SELECTOR, ".sea-pos-core .sea-pos-cover .sea-card-slot--empty"
)
picker.find_element(
By.CSS_SELECTOR, ".sea-pos-leave .sea-card-slot--empty"
)
picker.find_element(
By.CSS_SELECTOR, ".sea-pos-loom .sea-card-slot--empty"
)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_picker_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."""
picker = self._enter_picker_phase()
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
self.assertEqual(
elements[0].is_displayed(), expected_visible,
f"{pos} visibility wrong for SAO default; expected {expected_visible}",
)
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
the hidden `<input id="id_sea_spread">` initial value + on
`.my-sea-cross[data-spread]`."""
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")
self.assertEqual(
cross.get_attribute("data-spread"), "situation-action-outcome",
)
# ── Test 3 ───────────────────────────────────────────────────────────────
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"},
"desire-obstacle-solution": {"loom", "cross", "cover"},
"waite-smith": ALL_POSITIONS,
"escape-velocity": ALL_POSITIONS,
}
picker = self._enter_picker_phase()
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}']",
)
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
)
)
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"},
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","cover":"Solution"},
"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}"
)
return el
# ── 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())