diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index f90b5e6..da04e6f 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -695,9 +695,27 @@ class MySeaSpreadFormTemplateTest(TestCase): 'data-value="situation-action-outcome" aria-selected="true"', html, ) - def test_cross_carries_initial_three_card_spread_shape(self): + def test_cross_carries_initial_data_spread_sao(self): + # `.my-sea-cross[data-spread]` is the per-spread visibility key; + # default-spread context value renders into the attribute. SCSS + # rules in _gameboard.scss hide the inactive positions per spread. response = self.client.get(reverse("my_sea")) - self.assertContains(response, 'data-spread-shape="three-card"') + self.assertContains(response, 'data-spread="situation-action-outcome"') + + def test_template_renders_sao_position_labels_on_default(self): + # Server-renders the SAO position labels into the empty drop-zone + # `.sea-pos-label` spans so the page is correct before JS boots. + # JS swaps labels on spread change. + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + self.assertIn('data-position="lay">Situation', html) + self.assertIn('data-position="cover">Action', html) + self.assertIn('data-position="crown">Outcome', html) + # Inactive-for-SAO positions render their span but w. empty + # textContent (JS fills them on spread switch). + self.assertIn('data-position="leave">', html) + self.assertIn('data-position="loom">', html) + self.assertIn('data-position="cross">', html) def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self): response = self.client.get(reverse("my_sea")) diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 0a176a1..d569cd3 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -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 `` initial value. The - combobox's `.sea-select-current` `` carries the visible - label (rendered even when the dropdown is closed).""" + the hidden `` 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
  • 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 ─────────────────────────────────────────────────────────────── diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 9e28e47..49c14a0 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -285,25 +285,64 @@ body.page-gameboard { gap: 1rem; } -// .my-sea-cross renders all 6 surrounding positions (crown/lay/cross -// PLUS leave/loom/cover) so the SPREAD dropdown can toggle 3-card vs -// 6-card layout via a single data-attribute swap (no DOM mutation). -// Default `three-card` hides the 3 forsaken positions via `display: -// none`; `six-card` (Celtic Cross variants) renders all 7 cells. -// Cover always shows (it's part of both shapes — overlaid on sig). -.my-sea-cross[data-spread-shape="three-card"] { - grid-template-areas: - "leave core loom"; - grid-template-rows: auto; +// .my-sea-cross renders all 6 surrounding positions (crown/leave/lay/ +// loom + cover/cross overlaid on core) unconditionally. The SPREAD +// dropdown sets `data-spread=""` on this element; per-spread +// rules below hide the positions each spread doesn't use. Inherits +// the 3×3 `grid-template-areas` from _card-deck.scss line 1189-1200 +// so visible cells land in their canonical positions; hidden cells +// just leave their grid slots empty. +// +// Per-spread position subsets — user-locked 2026-05-19: +// PPF: leave (1) cover (2) loom (3) — horizontal middle row +// SAO: lay (1) cover (2) crown (3) — vertical center column +// MBS: crown (1) lay (2) loom (3) — T-shape (crown + lay vertical, loom right) +// DOS: loom (1) cross (2) cover (3) — sig-anchored cluster + loom +// CC variants: all 6 positions (Waite-Smith / Escape Velocity differ in DRAW ORDER only, +// not in position visibility). +.my-sea-cross[data-spread="past-present-future"] { .sea-pos-crown, - .sea-pos-lay, + .sea-pos-cross, + .sea-pos-lay { display: none; } +} + +.my-sea-cross[data-spread="situation-action-outcome"] { + .sea-pos-leave, + .sea-pos-loom, .sea-pos-cross { display: none; } } -.my-sea-cross[data-spread-shape="six-card"] { - // Inherits `.sea-cross`'s 3×3 template from _card-deck.scss line 1189- - // 1200; nothing to override layout-wise. All cells visible. +.my-sea-cross[data-spread="mind-body-spirit"] { + .sea-pos-leave, + .sea-pos-cover, + .sea-pos-cross { display: none; } +} + +.my-sea-cross[data-spread="desire-obstacle-solution"] { + .sea-pos-leave, + .sea-pos-crown, + .sea-pos-lay { display: none; } +} + +// Celtic Cross variants (waite-smith / escape-velocity) — all positions +// visible by default. No `display: none` overrides needed. + +// Position-name caption inside each empty `.sea-card-slot--empty` — +// re-appropriates the GRAVITY/LEVITY `.sea-stack-name` typographic +// look (_card-deck.scss line 1557): small uppercase letter-spaced w. +// a subtle scaleY stretch, --terUser ink at 0.6 opacity. No polarity +// coloring — these are spread-position labels, not deck identifiers. +.sea-pos-label { + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + opacity: 0.6; + transform: scaleY(1.2); + color: rgba(var(--terUser), 1); + text-align: center; + pointer-events: none; } // Section dividers inside the SPREAD combobox — labels "3-card spreads" diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index c99616e..4696b39 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -66,21 +66,27 @@ - {# Picker phase — flexible spread layout. Sig pins .sea-pos- #} - {# core; the 6 surrounding positions (crown/leave/loom/lay/ #} - {# cover/cross) all render unconditionally so the SPREAD #} - {# dropdown can swap `.my-sea-cross[data-spread-shape]` #} - {# between `three-card` (default — hides crown/lay/cross via #} - {# SCSS) + `six-card` (Celtic Cross variants — shows all). #} - {# Hidden until FREE DRAW click swaps data-phase to `picker`. #} + {# Picker phase — per-spread flexible layout. Sig pins .sea- #} + {# pos-core; the 6 surrounding positions all render in DOM #} + {# so the SPREAD dropdown can swap `.my-sea-cross[data- #} + {# spread]` between the 4 three-card variants (each w. its #} + {# own 3-position subset + draw order) + the 2 six-card #} + {# Celtic Cross variants (all 6 surrounding positions). #} + {# Each empty slot carries a `.sea-pos-label` caption (re- #} + {# appropriated from the GRAVITY/LEVITY .sea-stack-name look) #} + {# that JS updates per spread. #}