From 285597b467900140ea65c5a51b8a4334459a8faa Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 19 May 2026 15:48:07 -0400 Subject: [PATCH] =?UTF-8?q?My=20Sea=20FREE=20DRAW=20+=20seat-1C=20seated?= =?UTF-8?q?=20transition=20=E2=80=94=20Sprint=205=20iter=201=20follow-up?= =?UTF-8?q?=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior: - **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6). - **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule). - **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat. - **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed. - **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts. **Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side. Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s). Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../gameboard/tests/integrated/test_views.py | 20 ++++- src/functional_tests/test_game_my_sea.py | 80 +++++++++++++------ src/static_src/scss/_gameboard.scss | 38 +++++++-- src/templates/apps/gameboard/my_sea.html | 48 +++++++++-- 4 files changed, 148 insertions(+), 38 deletions(-) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index a02182b..e9905f2 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -551,9 +551,13 @@ class MySeaDrawSeaLandingViewTest(TestCase): response = self.client.get(reverse("my_sea")) self.assertFalse(response.context["show_backup_intro_banner"]) - def test_landing_renders_draw_sea_btn_when_sig_set(self): + def test_landing_renders_free_draw_btn_when_sig_set(self): + # Element ID `id_draw_sea_btn` describes intent (draw entry point); + # visible label is "FREE DRAW" for the daily-free quota draw. response = self.client.get(reverse("my_sea")) self.assertContains(response, 'id="id_draw_sea_btn"') + self.assertContains(response, "FREE") + self.assertContains(response, "DRAW") def test_landing_renders_six_chair_seats_with_C_suffix(self): response = self.client.get(reverse("my_sea")) @@ -563,8 +567,20 @@ class MySeaDrawSeaLandingViewTest(TestCase): self.assertIn(f'data-slot="{n}"', html) self.assertIn(f"{n}C", html) + def test_landing_renders_position_status_ban_icon_on_each_seat(self): + # Each chair seat starts empty (red `.fa-ban` status icon). The + # FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check + # client-side; this IT only pins the initial render state. Class + # substrings ("position-status-icon", "fa-ban") ALSO appear in the + # inline JS handler (classList.remove arg, querySelector arg) — so + # counts are asserted on the full class-attribute string only. + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6) + self.assertEqual(html.count('class="seat-position-label"'), 6) + def test_landing_not_rendered_when_user_has_no_sig(self): - # Sprint 4b gate still wins precedence — DRAW SEA must not render + # Sprint 4b gate still wins precedence — FREE DRAW must not render # when significator is None. self.user.significator = None self.user.save(update_fields=["significator"]) diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 2f3df7c..adeafce 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -164,20 +164,30 @@ class MySeaSignGateTest(FunctionalTest): class MySeaDrawSeaLandingTest(FunctionalTest): - """Sprint 5 iter 1 — DRAW SEA landing on /gameboard/my-sea/ for a + """Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate). Landing renders a DRY table hex (parameterized from the room) w. 6 chair seats labeled 1C-6C (placeholders for the eventual friend- invite feature per [[project-my-sea-roadmap]] architectural anchor - "Six chairs retained even in solo") + a central DRAW SEA `.btn- - primary` mirroring SCAN SIGN on /billboard/my-sign/. The same Brief - "Default deck warning" copy from my-sign fires when the user has no - equipped deck. + "Six chairs retained even in solo") + a central FREE DRAW `.btn- + primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat + renders w. a red `.fa-ban` status icon (empty slot). - Iter 1 scope: landing render + DRAW SEA click swaps `data-phase` to - `picker` (picker UX itself lands in iter 2). Form-col (spread - dropdown / decks / LOCK HAND / DEL) lands in iter 3.""" + Click flow: FREE DRAW → seat 1C transitions to `.seated` state + (chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa- + circle-check` green) → after a brief delay so the user sees the + animation, `data-phase` swaps to `picker` (picker content lands in + iter 2). The 'C' = "Chair" (user-locked vocabulary); no role + semantics in this solo flow. + + "FREE DRAW" is the label for the 1/24h free quota draw — a future + sprint will conditionally swap the label to "DRAW SEA" once the + free has been used, w. the DRAW SEA btn calling the room + gatekeeper partial for token-deposit. + + The same Brief "Default deck warning" copy from my-sign fires when + the user has no equipped deck.""" def setUp(self): super().setUp() @@ -191,9 +201,12 @@ class MySeaDrawSeaLandingTest(FunctionalTest): # ── Test 1 ─────────────────────────────────────────────────────────────── - def test_landing_renders_hex_with_draw_sea_btn(self): + def test_landing_renders_hex_with_free_draw_btn(self): """User w. sig → /gameboard/my-sea/ shows the DRY table hex (re- - used from my-sign / the room shell) w. a central DRAW SEA btn.""" + used from my-sign / the room shell) w. a central FREE DRAW btn. + Element ID `id_draw_sea_btn` describes intent (the draw entry + point) — a future sprint will conditionally swap the label to + DRAW SEA once the daily free has been used.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") # data-phase=landing on the page wrapper @@ -202,11 +215,11 @@ class MySeaDrawSeaLandingTest(FunctionalTest): ) # Hex shell present page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex") - # DRAW SEA btn in hex center + # FREE DRAW btn in hex center btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") self.assertTrue(btn.is_displayed()) + self.assertIn("FREE", btn.text.upper()) self.assertIn("DRAW", btn.text.upper()) - self.assertIn("SEA", btn.text.upper()) self.assertIn("btn-primary", btn.get_attribute("class")) # ── Test 2 ─────────────────────────────────────────────────────────────── @@ -214,19 +227,22 @@ class MySeaDrawSeaLandingTest(FunctionalTest): def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self): """All 6 chair positions render w. labels 1C-6C (placeholder for friend-invite). CSS class `.table-seat` is preserved so the SCSS - positioning rules (data-slot=N) carry over from the room shell.""" + positioning rules (data-slot=N) carry over from the room shell. + Each seat starts w. a red `.fa-ban` status icon (empty).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") seats = self.wait_for( lambda: self._six_seats() ) self.assertEqual(len(seats), 6) - labels = [ - "".join(s.text.upper().split()) for s in seats - ] - for n in range(1, 7): + for n, seat in enumerate(seats, start=1): with self.subTest(slot=n): - self.assertIn(f"{n}C", labels[n - 1]) + label = "".join(seat.text.upper().split()) + self.assertIn(f"{n}C", label) + # Each seat carries the red ban status icon initially. + seat.find_element( + By.CSS_SELECTOR, ".position-status-icon.fa-ban" + ) def _six_seats(self): seats = self.browser.find_elements( @@ -238,22 +254,40 @@ class MySeaDrawSeaLandingTest(FunctionalTest): # ── Test 3 ─────────────────────────────────────────────────────────────── - def test_draw_sea_click_transitions_to_picker_phase(self): - """Click DRAW SEA → page wrapper's data-phase swaps to 'picker'; - landing hides. Picker content itself lands in iter 2 — this test - pins only the phase-swap contract iter 1 must establish.""" + def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self): + """Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban + swapped for fa-circle-check (visible to the user during the + ~800ms animation delay); other seats remain empty; then the + page's data-phase swaps to 'picker' so iter 2's content can + take over. Single-user instance for now → user always gets the + lowest-numeral seat (1C).""" 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() + # Seat 1C goes seated + icon swaps. Other seats unchanged. + seat1 = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated" + ) + ) + seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check") + # Seats 2-6 retain the .fa-ban icon (still empty). + for n in range(2, 7): + with self.subTest(slot=n): + other = self.browser.find_element( + By.CSS_SELECTOR, f".table-seat[data-slot='{n}']" + ) + self.assertNotIn("seated", other.get_attribute("class")) + other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban") + # After the seat animation, data-phase swaps to picker + landing hides. self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" ) ) - # Landing block hidden after swap. landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing") self.assertFalse(landing.is_displayed()) diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index a5a6192..0dc6b17 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -227,23 +227,45 @@ body.page-gameboard { min-height: 0; display: flex; - // DRAW SEA btn — centered in the hex, mirrors SCAN SIGN's 2-line - // font sizing so "DRAW/SEA" sits cleanly inside the 4rem circle. + // FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line + // font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle. #id_draw_sea_btn { font-size: 0.75rem; line-height: 1.1; white-space: normal; } - // Chair-seat labels (1C-6C) sit beside the chair icon. The room's - // .table-seat positioning by data-slot (1-6) already places them - // around the hex — this just sizes the label readably + colors it - // to match the gate's --terUser accent. - .table-seat .seat-label { + // Chair-position labels (1C-6C). Mirrors the room's `.seat-role- + // label` grid placement (col 2, row 1 by default; flips to col 1 + // for left-side seats 3/4/5 so the label sits closest to the hex) + // but uses a role-free class name — my-sea is the solo draw flow, + // no role-pick phase, so the room's role-grammar doesn't apply. + .table-seat .seat-position-label { + grid-column: 2; + grid-row: 1; font-size: 0.8rem; font-weight: 600; + letter-spacing: 0.05em; + color: rgba(var(--secUser), 1); + } + .table-seat[data-slot="3"] .seat-position-label, + .table-seat[data-slot="4"] .seat-position-label, + .table-seat[data-slot="5"] .seat-position-label { + grid-column: 1; + } + + // Seated chair (post-FREE DRAW). Visual transition mirrors + // `.table-seat.active .fa-chair` from _room.scss line 626 — + // --terUser color + --ninUser drop-shadow glow — but uses a stable + // `.seated` class (semantically distinct from `.active`: active = + // current turn in a multi-user room; seated = draw-locked occupant + // in this solo-flow). _room.scss line 596 makes the colour change + // a 0.6s ease transition so the chair animates rather than snaps. + // Status icon (.position-status-icon) colour swap fa-ban red → + // fa-circle-check green is handled by _room.scss lines 615-616. + .table-seat.seated .fa-chair { color: rgba(var(--terUser), 1); - margin-left: 0.25rem; + filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1)); } } diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 0f86739..c9e62d6 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -37,14 +37,28 @@
- + {# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #} + {# Future sprint will conditionally swap this for a DRAW SEA #} + {# .btn-primary that calls the gatekeeper partial once the #} + {# free daily has been used; until then the btn renders FREE #} + {# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #} + {# entry point) so the swap is label-only when iter 6+ lands. #} +
{% for n in "123456" %} + {# Chair-position labels (1C-6C). No roles in #} + {# my-sea (this is the solo draw flow); using #} + {# `.seat-position-label` instead of the room's #} + {# `.seat-role-label` to keep the no-role #} + {# semantics clean. `.position-status-icon` + #} + {# `.fa-ban` are unchanged — already role- #} + {# agnostic in _room.scss. #}
- {{ n }}C + {{ n }}C +
{% endfor %} @@ -68,11 +82,35 @@ var landing = page.querySelector('.my-sea-landing'); var picker = page.querySelector('.my-sea-picker'); var drawBtn = document.getElementById('id_draw_sea_btn'); + // FREE DRAW click flow: + // 1) seat 1C transitions to .seated (chair --terUser + + // drop-shadow glow + .fa-ban → .fa-circle-check — + // _room.scss line 596 makes the colour change a + // 0.6s ease transition); + // 2) after a brief delay (so the user sees the seat + // animation), data-phase swaps to 'picker' + the + // landing hides. Picker content lands in iter 2. + // The seat-take logic is solo-coded for now: 1C is the + // lowest-numeral chair, and my-sea is 1-user-per-page + // until the friend-invite feature (per [[project-my- + // sea-roadmap]]) — so 1C is always the user's seat. + var SEAT_ANIM_MS = 800; if (drawBtn) { drawBtn.addEventListener('click', function () { - page.setAttribute('data-phase', 'picker'); - if (landing) landing.style.display = 'none'; - if (picker) picker.style.display = ''; + var seat1 = page.querySelector('.table-seat[data-slot="1"]'); + if (seat1) { + seat1.classList.add('seated'); + var statusIcon = seat1.querySelector('.position-status-icon'); + if (statusIcon) { + statusIcon.classList.remove('fa-ban'); + statusIcon.classList.add('fa-circle-check'); + } + } + setTimeout(function () { + page.setAttribute('data-phase', 'picker'); + if (landing) landing.style.display = 'none'; + if (picker) picker.style.display = ''; + }, SEAT_ANIM_MS); }); } // Mirror my-sign's scaleTable() init timing fix — the