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>
This commit is contained in:
Disco DeDisco
2026-05-19 19:38:53 -04:00
parent fd5db951a7
commit f154d660bd
4 changed files with 270 additions and 93 deletions

View File

@@ -399,24 +399,24 @@ class MySeaPickerPhaseTest(FunctionalTest):
# ── Test 3 ───────────────────────────────────────────────────────────────
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."""
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()
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)
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(
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",
elements[0].is_displayed(), expected_visible,
f"{pos} visibility wrong for SAO default; expected {expected_visible}",
)
@@ -495,16 +495,13 @@ class MySeaSpreadFormTest(FunctionalTest):
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)."""
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 <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']"
)
@@ -512,52 +509,132 @@ class MySeaSpreadFormTest(FunctionalTest):
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")
self.assertEqual(
cross.get_attribute("data-spread"), "situation-action-outcome",
)
# ── 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`."""
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")
# 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"
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}']",
)
)
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",
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
)
)
# 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']",
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}']"
)
mbs.click()
self.wait_for(
lambda: self.assertEqual(
cross.get_attribute("data-spread-shape"), "three-card"
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 ───────────────────────────────────────────────────────────────