My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD

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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-19 15:48:07 -04:00
parent de48ae226d
commit 285597b467
4 changed files with 148 additions and 38 deletions

View File

@@ -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())