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:
@@ -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"])
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,28 @@
|
||||
<div class="table-hex-border">
|
||||
<div class="table-hex">
|
||||
<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>
|
||||
{% 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 }}">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user