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

@@ -551,9 +551,13 @@ class MySeaDrawSeaLandingViewTest(TestCase):
response = self.client.get(reverse("my_sea")) response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["show_backup_intro_banner"]) 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")) response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_draw_sea_btn"') 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): def test_landing_renders_six_chair_seats_with_C_suffix(self):
response = self.client.get(reverse("my_sea")) response = self.client.get(reverse("my_sea"))
@@ -563,8 +567,20 @@ class MySeaDrawSeaLandingViewTest(TestCase):
self.assertIn(f'data-slot="{n}"', html) self.assertIn(f'data-slot="{n}"', html)
self.assertIn(f"{n}C", 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): 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. # when significator is None.
self.user.significator = None self.user.significator = None
self.user.save(update_fields=["significator"]) self.user.save(update_fields=["significator"])

View File

@@ -164,20 +164,30 @@ class MySeaSignGateTest(FunctionalTest):
class MySeaDrawSeaLandingTest(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). 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 Landing renders a DRY table hex (parameterized from the room) w. 6
chair seats labeled 1C-6C (placeholders for the eventual friend- chair seats labeled 1C-6C (placeholders for the eventual friend-
invite feature per [[project-my-sea-roadmap]] architectural anchor invite feature per [[project-my-sea-roadmap]] architectural anchor
"Six chairs retained even in solo") + a central DRAW SEA `.btn- "Six chairs retained even in solo") + a central FREE DRAW `.btn-
primary` mirroring SCAN SIGN on /billboard/my-sign/. The same Brief primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat
"Default deck warning" copy from my-sign fires when the user has no renders w. a red `.fa-ban` status icon (empty slot).
equipped deck.
Iter 1 scope: landing render + DRAW SEA click swaps `data-phase` to Click flow: FREE DRAW → seat 1C transitions to `.seated` state
`picker` (picker UX itself lands in iter 2). Form-col (spread (chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa-
dropdown / decks / LOCK HAND / DEL) lands in iter 3.""" 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): def setUp(self):
super().setUp() super().setUp()
@@ -191,9 +201,12 @@ class MySeaDrawSeaLandingTest(FunctionalTest):
# ── Test 1 ─────────────────────────────────────────────────────────────── # ── 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- """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.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/") self.browser.get(self.live_server_url + "/gameboard/my-sea/")
# data-phase=landing on the page wrapper # data-phase=landing on the page wrapper
@@ -202,11 +215,11 @@ class MySeaDrawSeaLandingTest(FunctionalTest):
) )
# Hex shell present # Hex shell present
page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex") 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") btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
self.assertTrue(btn.is_displayed()) self.assertTrue(btn.is_displayed())
self.assertIn("FREE", btn.text.upper())
self.assertIn("DRAW", btn.text.upper()) self.assertIn("DRAW", btn.text.upper())
self.assertIn("SEA", btn.text.upper())
self.assertIn("btn-primary", btn.get_attribute("class")) self.assertIn("btn-primary", btn.get_attribute("class"))
# ── Test 2 ─────────────────────────────────────────────────────────────── # ── Test 2 ───────────────────────────────────────────────────────────────
@@ -214,19 +227,22 @@ class MySeaDrawSeaLandingTest(FunctionalTest):
def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self): def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self):
"""All 6 chair positions render w. labels 1C-6C (placeholder for """All 6 chair positions render w. labels 1C-6C (placeholder for
friend-invite). CSS class `.table-seat` is preserved so the SCSS 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.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/") self.browser.get(self.live_server_url + "/gameboard/my-sea/")
seats = self.wait_for( seats = self.wait_for(
lambda: self._six_seats() lambda: self._six_seats()
) )
self.assertEqual(len(seats), 6) self.assertEqual(len(seats), 6)
labels = [ for n, seat in enumerate(seats, start=1):
"".join(s.text.upper().split()) for s in seats
]
for n in range(1, 7):
with self.subTest(slot=n): 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): def _six_seats(self):
seats = self.browser.find_elements( seats = self.browser.find_elements(
@@ -238,22 +254,40 @@ class MySeaDrawSeaLandingTest(FunctionalTest):
# ── Test 3 ─────────────────────────────────────────────────────────────── # ── Test 3 ───────────────────────────────────────────────────────────────
def test_draw_sea_click_transitions_to_picker_phase(self): def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self):
"""Click DRAW SEA → page wrapper's data-phase swaps to 'picker'; """Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban
landing hides. Picker content itself lands in iter 2 — this test swapped for fa-circle-check (visible to the user during the
pins only the phase-swap contract iter 1 must establish.""" ~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.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/") self.browser.get(self.live_server_url + "/gameboard/my-sea/")
btn = self.wait_for( btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn") lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
) )
btn.click() 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( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" 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") landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing")
self.assertFalse(landing.is_displayed()) self.assertFalse(landing.is_displayed())

View File

@@ -227,23 +227,45 @@ body.page-gameboard {
min-height: 0; min-height: 0;
display: flex; display: flex;
// DRAW SEA btn — centered in the hex, mirrors SCAN SIGN's 2-line // FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line
// font sizing so "DRAW/SEA" sits cleanly inside the 4rem circle. // font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle.
#id_draw_sea_btn { #id_draw_sea_btn {
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.1; line-height: 1.1;
white-space: normal; white-space: normal;
} }
// Chair-seat labels (1C-6C) sit beside the chair icon. The room's // Chair-position labels (1C-6C). Mirrors the room's `.seat-role-
// .table-seat positioning by data-slot (1-6) already places them // label` grid placement (col 2, row 1 by default; flips to col 1
// around the hex — this just sizes the label readably + colors it // for left-side seats 3/4/5 so the label sits closest to the hex)
// to match the gate's --terUser accent. // but uses a role-free class name — my-sea is the solo draw flow,
.table-seat .seat-label { // 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-size: 0.8rem;
font-weight: 600; 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); color: rgba(var(--terUser), 1);
margin-left: 0.25rem; filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
} }
} }

View File

@@ -37,14 +37,28 @@
<div class="table-hex-border"> <div class="table-hex-border">
<div class="table-hex"> <div class="table-hex">
<div class="table-center"> <div class="table-center">
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">DRAW<br>SEA</button> {# 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. #}
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
</div> </div>
</div> </div>
</div> </div>
{% for n in "123456" %} {% 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. #}
<div class="table-seat" data-slot="{{ n }}"> <div class="table-seat" data-slot="{{ n }}">
<i class="fa-solid fa-chair"></i> <i class="fa-solid fa-chair"></i>
<span class="seat-label">{{ n }}C</span> <span class="seat-position-label">{{ n }}C</span>
<i class="position-status-icon fa-solid fa-ban"></i>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -68,11 +82,35 @@
var landing = page.querySelector('.my-sea-landing'); var landing = page.querySelector('.my-sea-landing');
var picker = page.querySelector('.my-sea-picker'); var picker = page.querySelector('.my-sea-picker');
var drawBtn = document.getElementById('id_draw_sea_btn'); 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) { if (drawBtn) {
drawBtn.addEventListener('click', function () { drawBtn.addEventListener('click', function () {
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'); page.setAttribute('data-phase', 'picker');
if (landing) landing.style.display = 'none'; if (landing) landing.style.display = 'none';
if (picker) picker.style.display = ''; if (picker) picker.style.display = '';
}, SEAT_ANIM_MS);
}); });
} }
// Mirror my-sign's scaleTable() init timing fix — the // Mirror my-sign's scaleTable() init timing fix — the