burger Sea sub-btn: spread-form modal + relocated deck stacks + mid-draw CONT DRAW escape — TDD (phase 2/3)
Second slice of the Sea sub-btn rollout (phase 1 = .active wiring in 3ae85b9; phase 3 = --priYl glow handoff to come).
## Spread modal (#id_sea_spread_modal)
`templates/apps/gameboard/my_sea.html` — `.sea-form-col` (spread combobox + AUTO DRAW + DEL btns) now lives inside `<div id="id_sea_spread_modal" class="my-sea-spread-modal" hidden>`. Hidden by default; opens on #id_sea_btn click (per the inline `<script>` block). The deck stacks (`.sea-stacks` + `.sea-stacks-label`) are extracted to a new `.my-sea-stacks-wrap` sibling that stays visible on the page so the FLIP affordance remains usable while the modal is closed.
## Modal SCSS
`static_src/scss/_gameboard.scss`:
- `.my-sea-spread-modal` — position:fixed inset:0; z-index:320 (above all corner btns); pointer-events:none w. children opting back in. `&[hidden]` makes it explicit that the closed state stays display:none.
- `.my-sea-spread-modal__backdrop` — full-viewport semi-transparent w. backdrop-filter blur; pointer-events:auto so click-outside closes.
- `.my-sea-spread-modal__panel` — opaque card, border + shadow, max-width:90vw. `.sea-form-col` inside drops its fixed 16rem width + uses min-width.
- `.my-sea-stacks-wrap` — position:absolute bottom:4.5rem right:1rem (clear of bud/burger); z-index:5 (above .my-sea-cross, below modal).
## Modal JS
`templates/apps/gameboard/my_sea.html` — new inline `<script>` at the bottom owns:
- `#id_sea_btn` click → opens modal (guarded on `.active` so the burger-btn.js inactive-flash handler still runs for inactive sub-btns).
- Escape key → close.
- Backdrop click → close.
- `#id_guard_portal .guard-yes` click (delegated) → close. This is the key UX detail user-spec'd: AUTO DRAW + DEL both open a guard portal that positions itself against the visible action/del btn — closing the modal on the btn click itself would dump the portal at (0,0). Closing on guard OK instead means the portal positions correctly + the modal closes only when the action is confirmed. NVM (`.guard-no`) leaves the modal up so the user can retry.
Also `apps/epic/static/apps/epic/burger-btn.js` — delegated fan click now closes the burger fan when an ACTIVE sub-btn is clicked (was: no-op for active). The per-page sub-btn handler runs in target phase BEFORE the fan-bubble handler, so the action kicks off w. a clean visual.
Client-side `.active` sync: server renders sea_btn inactive on the landing page (show_picker=False there). The DRAW SEA → picker transition is client-side (no re-render), so the IIFE in my_sea.html now flips `seaBtn.classList.add('active')` at the same SEAT_ANIM_MS moment data-phase swaps to 'picker'. Sea_btn stays `.active` for the rest of the picker phase, INCLUDING after hand_complete — DEL + GATE VIEW both live inside the modal + the user needs them accessible (earlier iteration deactivated on hand_complete; reverted on user feedback).
## Mid-draw NVM → CONT DRAW (apps/gameboard/views.py + my_sea.html)
Earlier iteration's gear-menu NVM nav-backed to `/gameboard/my-sea/` mid-draw, but the server's `hand_non_empty` branch re-rendered the picker on the next GET — looping the user right back into the spread. New `?phase=landing` escape hatch:
- `views.py`: `force_landing = request.GET.get('phase') == 'landing'`; `show_picker = (hand_non_empty or (phase_param and show_paid_draw)) and not force_landing`. New context var `show_cont_draw = force_landing and active_draw and not active_draw.is_hand_complete`.
- `my_sea.html` landing: new `{% if show_cont_draw %} CONT DRAW {% elif show_paid_draw %} ... ` branch on the table-center action-btn. CONT DRAW is an `<a href="{% url 'my_sea' %}">` (plain navigation, no `?phase=landing` so the next GET falls through to the hand_non_empty picker branch).
- `my_sea.html` gear include: picker phase passes `nvm_url={% url 'my_sea' %}?phase=landing` so the gear NVM exits to the landing-with-CONT-DRAW state instead of looping back into the picker.
## Tests
`apps/gameboard/tests/integrated/test_views.py` — 4 ITs added/updated:
- `test_sea_btn_stays_active_when_hand_complete` (replaced the prior "returns to inactive" assertion — sea_btn now stays active per user spec).
- `test_gear_nvm_navs_to_my_sea_landing_on_picker_phase` (updated URL to include `?phase=landing`).
- `test_force_landing_renders_cont_draw_btn_mid_draw` (NEW) — verifies CONT DRAW renders + FREE DRAW absent.
- `test_force_landing_hides_cont_draw_when_no_active_draw` (NEW) — regression guard: sig'd user w. no draw still sees FREE DRAW.
- `test_force_landing_hides_cont_draw_when_hand_complete` (NEW) — CONT DRAW absent for completed hands.
`functional_tests/test_game_my_sea.py` — new module-level `_open_spread_modal(test)` helper + applied to 7 tests that touch in-modal selectors (combobox / action_btn / del):
- `MySeaSpreadFormTest.test_picking_spread_swaps_data_spread_and_position_visibility`
- `MySeaSpreadFormTest.test_per_spread_position_labels_render_and_update`
- `MySeaCardDrawTest.test_action_btn_transitions_to_gate_view_on_hand_complete` (also: close modal before manual FLIP draws, use innerText for hidden-element text checks)
- `MySeaCardDrawTest.test_auto_drawn_slots_can_reopen_stage_modal_on_click`
- `MySeaCardDrawTest.test_form_col_renders_decks_lock_hand_del_and_reversal_pct`
- `MySeaLockHandTest.test_del_click_opens_shared_guard_portal`
- `MySeaLockHandTest.test_del_confirm_clears_hand_and_returns_to_gate_view_landing`
JS .click() is used inside `_open_spread_modal` (not Selenium .click) because the fan sub-btns spend ~0.25s mid-animation stacked at the burger centre — Selenium would hit a click-intercept by whichever sub-btn is z-topmost during the transform.
## Verification
All 1370 IT+UT green (+5 new MySeaViewTest, +1 from earlier phase 1 carry-over). 7 updated FTs green when re-run together.
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:
@@ -23,6 +23,34 @@ def _count_filled_slots(picker):
|
||||
)
|
||||
|
||||
|
||||
def _open_spread_modal(test):
|
||||
"""Open #id_sea_spread_modal so subsequent interactions w. in-modal
|
||||
selectors (`.sea-select*`, `#id_sea_action_btn`, `#id_sea_del`) reach
|
||||
a visible target. Sprint 2026-05-26 wrapped the sea-form-col in a
|
||||
modal triggered by the burger fan's Sea sub-btn; tests that touched
|
||||
those selectors directly now have to open the modal first.
|
||||
|
||||
Module-level so MySeaSpreadFormTest + MySeaCardDrawTest +
|
||||
MySeaLockHandTest can all call it w/o duplicating the helper.
|
||||
|
||||
JS .click() is used (not Selenium .click) because the fan sub-btns
|
||||
spend ~0.25s mid-animation stacked at the burger centre — Selenium
|
||||
would hit a click-intercept by whichever sub-btn is z-topmost during
|
||||
the transform. execute_script bypasses the visual hit-test."""
|
||||
burger_btn = test.browser.find_element(By.ID, "id_burger_btn")
|
||||
test.browser.execute_script("arguments[0].click()", burger_btn)
|
||||
sea_btn = test.wait_for(
|
||||
lambda: test.browser.find_element(By.ID, "id_sea_btn")
|
||||
)
|
||||
test.browser.execute_script("arguments[0].click()", sea_btn)
|
||||
test.wait_for(
|
||||
lambda: test.assertIsNone(
|
||||
test.browser.find_element(By.ID, "id_sea_spread_modal")
|
||||
.get_attribute("hidden")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _seed_gameboard_applets():
|
||||
"""My Sea + the rest of the gameboard applets so /gameboard/ renders
|
||||
without missing-applet errors during the applet-side assertions.
|
||||
@@ -577,6 +605,7 @@ class MySeaSpreadFormTest(FunctionalTest):
|
||||
"escape-velocity": ALL_POSITIONS,
|
||||
}
|
||||
picker = self._enter_picker_phase()
|
||||
_open_spread_modal(self) # combobox lives in the spread modal
|
||||
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
|
||||
@@ -627,6 +656,7 @@ class MySeaSpreadFormTest(FunctionalTest):
|
||||
"cross": "Cross", "loom": "Before", "lay": "Beneath"},
|
||||
}
|
||||
picker = self._enter_picker_phase()
|
||||
_open_spread_modal(self) # combobox lives in the spread modal
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
|
||||
def _pick(value):
|
||||
@@ -844,17 +874,29 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
transitions it to GATE VIEW (`data-state="gate-view"`, label =
|
||||
"GATE VIEW")."""
|
||||
picker = self._enter_picker_phase()
|
||||
_open_spread_modal(self) # action btn lives in the spread modal
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||||
self.assertIn("AUTO", action_btn.text.upper())
|
||||
# innerText (via JS) works on hidden elements; selenium .text would
|
||||
# return "" once we close the modal below.
|
||||
self.assertIn("AUTO", self.browser.execute_script(
|
||||
"return arguments[0].innerText", action_btn).upper())
|
||||
# Close the modal so the deck-stack FLIP clicks below aren't
|
||||
# intercepted by the modal backdrop (which covers the whole
|
||||
# viewport while open).
|
||||
self.browser.execute_script(
|
||||
"document.getElementById('id_sea_spread_modal').setAttribute('hidden', '')"
|
||||
)
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
# Third draw completes the SAO hand — action btn becomes GATE VIEW.
|
||||
# data-state attribute is readable even while the modal is hidden.
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
|
||||
)
|
||||
self.assertIn("GATE", action_btn.text.upper())
|
||||
self.assertIn("GATE", self.browser.execute_script(
|
||||
"return arguments[0].innerText", action_btn).upper())
|
||||
|
||||
# ── Test 5b — AUTO DRAW slot click reopens preview modal ────────────────
|
||||
|
||||
@@ -880,7 +922,12 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
self._draw_one(picker, "levity")
|
||||
self.assertEqual(_count_filled_slots(picker), 1)
|
||||
|
||||
# AUTO DRAW the remaining 2 (cover + crown).
|
||||
# AUTO DRAW the remaining 2 (cover + crown). Reopen modal —
|
||||
# the first draw above closed it via AUTO DRAW's modal-close
|
||||
# capture handler... actually no, manual FLIP doesn't close the
|
||||
# modal. But the modal may have been auto-dismissed by some
|
||||
# later interaction; opening defensively keeps the test robust.
|
||||
_open_spread_modal(self)
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
action_btn.click()
|
||||
confirm = self.wait_for(
|
||||
@@ -1032,7 +1079,7 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
LOCK HAND `.btn-primary`, the DEL `.btn-danger`, and the
|
||||
reversal-percentage caption (default 25%)."""
|
||||
picker = self._enter_picker_phase()
|
||||
# DECKS — two stacks
|
||||
# DECKS — two stacks (stay on page, OUTSIDE the spread modal).
|
||||
stacks = picker.find_elements(By.CSS_SELECTOR, ".sea-deck-stack")
|
||||
self.assertEqual(len(stacks), 2)
|
||||
names = "|".join(
|
||||
@@ -1041,6 +1088,9 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
)
|
||||
self.assertIn("GRAVITY", names)
|
||||
self.assertIn("LEVITY", names)
|
||||
# Action btn + DEL + reversal hint live INSIDE the spread modal —
|
||||
# open it before reading their .text (hidden elements return "").
|
||||
_open_spread_modal(self)
|
||||
# Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
self.assertIn("AUTO", action_btn.text.upper())
|
||||
@@ -1293,6 +1343,7 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
_open_spread_modal(self) # del btn lives in the spread modal
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
portal = self.wait_for(
|
||||
@@ -1324,6 +1375,7 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
_open_spread_modal(self) # del btn lives in the spread modal
|
||||
picker.find_element(By.CSS_SELECTOR, "#id_sea_del").click()
|
||||
confirm = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
|
||||
Reference in New Issue
Block a user