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:
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user