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>
This commit is contained in:
Disco DeDisco
2026-05-19 17:23:25 -04:00
parent f5fc1e15f8
commit fd5db951a7
5 changed files with 417 additions and 29 deletions

View File

@@ -399,15 +399,191 @@ class MySeaPickerPhaseTest(FunctionalTest):
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_picker_does_not_render_forsaken_positions(self):
"""Crown / lay / cross from the gameroom's 6-position Celtic
Cross are forsaken in the solo three-card spread (user-locked
spec). None of these classes should appear in the picker DOM."""
def test_picker_hides_six_card_only_positions_by_default(self):
"""Crown / lay / cross are 6-card-spread-only positions; they
sit in the picker DOM (so iter 3's spread switch can reveal
them without re-rendering) but stay `display:none` while the
default 3-card spread (`data-spread-shape="three-card"`) is
active. Iter 3's SPREAD dropdown switches `data-spread-shape`
on change to reveal these positions for Celtic Cross variants."""
picker = self._enter_picker_phase()
for forsaken in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"):
with self.subTest(position=forsaken):
for hidden_pos in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"):
with self.subTest(position=hidden_pos):
elements = picker.find_elements(By.CSS_SELECTOR, hidden_pos)
self.assertEqual(
len(picker.find_elements(By.CSS_SELECTOR, forsaken)),
0,
f"forsaken position {forsaken} should not render in my-sea picker",
len(elements), 1,
f"{hidden_pos} should render in DOM (gets shown on 6-card spread switch)",
)
self.assertFalse(
elements[0].is_displayed(),
f"{hidden_pos} should be hidden in default 3-card spread",
)
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. The
combobox's `.sea-select-current` `<span>` carries the visible
label (rendered even when the dropdown is closed)."""
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 <li> is inside the closed dropdown — its `.text` is
# "" per Selenium's hidden-text convention; use textContent.
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",
)
# The visible current-label span is always rendered.
current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current")
self.assertEqual(current.text.strip(), "Situation, Action, Outcome")
# And the cross is in three-card shape mode by default.
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
self.assertEqual(cross.get_attribute("data-spread-shape"), "three-card")
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_picking_celtic_cross_reveals_six_card_positions(self):
"""Clicking a 6-card spread option swaps `data-spread-shape`
to `six-card` on `.my-sea-cross` — crown / lay / cross become
visible. Three-card variants flip back to `three-card`."""
picker = self._enter_picker_phase()
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
# Open the combobox + click Celtic Cross Waite-Smith
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
combo.click()
waite_smith = picker.find_element(
By.CSS_SELECTOR,
".sea-select-list [role='option'][data-value='waite-smith']",
)
waite_smith.click()
self.wait_for(
lambda: self.assertEqual(
cross.get_attribute("data-spread-shape"), "six-card"
)
)
for pos in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"):
with self.subTest(position=pos):
self.assertTrue(
picker.find_element(By.CSS_SELECTOR, pos).is_displayed(),
f"{pos} should be visible when 6-card spread selected",
)
# Switch back to a three-card spread → hidden again.
combo.click()
mbs = picker.find_element(
By.CSS_SELECTOR,
".sea-select-list [role='option'][data-value='mind-body-spirit']",
)
mbs.click()
self.wait_for(
lambda: self.assertEqual(
cross.get_attribute("data-spread-shape"), "three-card"
)
)
# ── 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())