diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 9049699..f90b5e6 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -618,13 +618,17 @@ class MySeaPickerPhaseTemplateTest(TestCase): self.assertContains(response, "sea-pos-leave") self.assertContains(response, "sea-pos-loom") - def test_picker_does_not_render_forsaken_positions(self): - # Crown / lay / cross are gameroom-only — user-locked spec drops - # them from the solo three-card spread. + def test_picker_renders_six_card_only_positions_for_spread_switch(self): + # Crown / lay / cross sit in the DOM unconditionally so iter 3's + # SPREAD dropdown can reveal them via CSS attribute swap (data- + # spread-shape="six-card") without re-rendering. Default 3-card + # spread hides them via `.my-sea-cross[data-spread-shape= + # "three-card"]` rules in _gameboard.scss — FT pins the hidden + # state visually. response = self.client.get(reverse("my_sea")) - self.assertNotContains(response, "sea-pos-crown") - self.assertNotContains(response, "sea-pos-lay") - self.assertNotContains(response, "sea-pos-cross") + self.assertContains(response, "sea-pos-crown") + self.assertContains(response, "sea-pos-lay") + self.assertContains(response, "sea-pos-cross") def test_picker_not_rendered_when_user_has_no_sig(self): # 4b gate wins; picker has no business rendering without a sig. @@ -632,3 +636,75 @@ class MySeaPickerPhaseTemplateTest(TestCase): self.user.save(update_fields=["significator"]) response = self.client.get(reverse("my_sea")) self.assertNotContains(response, "my-sea-picker") + + +class MySeaSpreadFormTemplateTest(TestCase): + """Sprint 5 iter 3 — form col + SPREAD dropdown structure + default- + spread context + cross's `data-spread-shape` attribute. Iter 3 spec + locks `Situation, Action, Outcome` as the default spread (a 3-card + variant); the 6 spreads sit under 2 section dividers (3-card / 6- + card).""" + + SPREAD_OPTIONS = [ + ("past-present-future", "Past, Present, Future"), + ("situation-action-outcome", "Situation, Action, Outcome"), + ("mind-body-spirit", "Mind, Body, Spirit"), + ("desire-obstacle-solution", "Desire, Obstacle, Solution"), + ("waite-smith", "Celtic Cross, Waite-Smith"), + ("escape-velocity", "Celtic Cross, Escape Velocity"), + ] + + def setUp(self): + from apps.epic.models import personal_sig_cards + self.user = User.objects.create(email="spread@test.io") + self.client.force_login(self.user) + self.target = personal_sig_cards(self.user)[0] + self.user.significator = self.target + self.user.save(update_fields=["significator"]) + + def test_context_default_spread_is_situation_action_outcome(self): + response = self.client.get(reverse("my_sea")) + self.assertEqual( + response.context["default_spread"], "situation-action-outcome", + ) + + def test_context_reversals_pct_defaults_to_25(self): + response = self.client.get(reverse("my_sea")) + self.assertEqual(response.context["reversals_pct"], 25) + + def test_template_renders_all_six_spread_options(self): + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + for value, label in self.SPREAD_OPTIONS: + with self.subTest(spread=value): + self.assertIn(f'data-value="{value}"', html) + self.assertIn(label, html) + + def test_template_renders_three_card_and_six_card_section_dividers(self): + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + self.assertEqual(html.count("sea-select-divider"), 2) + self.assertIn("3-card spreads", html) + self.assertIn("6-card spreads", html) + + def test_template_marks_situation_action_outcome_aria_selected(self): + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + # The default option carries aria-selected="true"; the others false. + self.assertIn( + 'data-value="situation-action-outcome" aria-selected="true"', html, + ) + + def test_cross_carries_initial_three_card_spread_shape(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-spread-shape="three-card"') + + def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self): + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + self.assertIn("sea-deck-stack--gravity", html) + self.assertIn("sea-deck-stack--levity", html) + self.assertIn('id="id_sea_lock_hand"', html) + self.assertIn('id="id_sea_del"', html) + self.assertIn("sea-reversal-hint", html) + self.assertIn("25% reversals", html) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 67d8979..4d9c8d0 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -193,6 +193,11 @@ def my_sea(request): # `.suit_icon` resolve at render time. "significator": request.user.significator, "significator_reversed": request.user.significator_reversed, + # Sprint 5 iter 3 — SPREAD dropdown defaults to Situation/Action/ + # Outcome (a 3-card spread) per user-locked spec; `reversals_pct` + # is a placeholder UI value pending the per-user setting. + "default_spread": "situation-action-outcome", + "reversals_pct": 25, "page_class": "page-gameboard page-my-sea", }) diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 58d64b3..0a176a1 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -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
  • 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 `` initial value. The + combobox's `.sea-select-current` `` 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
  • 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()) diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 0ba362a..9e28e47 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -282,13 +282,53 @@ body.page-gameboard { display: flex; align-items: center; justify-content: center; + gap: 1rem; } -// Three-cell horizontal row (leave | core | loom) — strips the crown -// + lay rows from `.sea-cross`'s 3×3 grid template, leaving only the -// middle row. Cover stays nested inside `.sea-pos-core` (absolutely -// positioned overlay handled by _card-deck.scss line 1310-1331). -.my-sea-cross { - grid-template-areas: "leave core loom"; +// .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; + + .sea-pos-crown, + .sea-pos-lay, + .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. +} + +// Section dividers inside the SPREAD combobox — labels "3-card spreads" +// / "6-card spreads" separating the option groups. Styled to echo the +// `.kit-bag-label` treatment (small uppercase underlined letter-spaced +// --quaUser) but horizontal rather than vertical (kit-bag uses writing- +// mode: vertical-rl; this is a flat dropdown). +.sea-select-list .sea-select-divider { + font-size: 0.55rem; + text-transform: uppercase; + text-decoration: underline; + letter-spacing: 0.12em; + color: rgba(var(--quaUser), 0.75); + padding: 0.4rem 0.6rem 0.2rem; + pointer-events: none; // not selectable; combobox.js skips it + // (no role=option), but belt-and-braces + // against accidental hover/click styles. + list-style: none; +} + +// Form col on my-sea — same DRY treatment as the gameroom sea-overlay +// `.sea-form-col` (handled in _card-deck.scss) but sits next to the +// picker's cross on a `--duoUser` page. Just constrain the width so it +// doesn't fight the cross for horizontal space. +.my-sea-form-col { + flex: 0 0 16rem; + max-width: 16rem; } diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 04d268f..c99616e 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -66,16 +66,19 @@ - {# Picker phase — three-card cross w. sig pinned in core + #} - {# cover (overlaid on sig) + leave (left) + loom (right). #} - {# Crown / lay / cross from the gameroom's 6-position spread #} - {# are forsaken in the solo flow per user-locked spec. Hidden #} - {# until FREE DRAW click swaps data-phase to `picker` (see #} - {# inline JS below); form col (spread dropdown / decks / #} - {# LOCK HAND / DEL) lands in iter 3. #} + {# 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`. #} + +