diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index b87384f..3033955 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -8,6 +8,15 @@ var RoleSelect = (function () { var _animationPending = false; var _pendingTurnChange = null; + // Delay before the tray animation begins (ms). Gives the gamer a moment + // to see their pick confirmed before the tray slides in. Set to 0 by + // _testReset() so Jasmine tests don't need jasmine.clock(). + var _placeCardDelay = 3000; + + // Delay after the tray closes before advancing to the next turn (ms). + // Gives the gamer a moment to see their confirmed seat before the turn moves. + var _postTrayDelay = 3000; + var ROLES = [ { code: "PC", name: "Player", element: "Fire" }, { code: "BC", name: "Builder", element: "Stone" }, @@ -36,6 +45,10 @@ var RoleSelect = (function () { _turnChangedBeforeFetch = false; // fresh selection, reset the race flag closeFan(); + // Show the tray handle — gamer confirmed a pick, tray animation about to run + var trayWrap = document.getElementById("id_tray_wrap"); + if (trayWrap) trayWrap.classList.remove("role-select-phase"); + // Immediately lock the stack — do not wait for WS turn_changed var stack = document.querySelector(".card-stack[data-starter-roles]"); if (stack) { @@ -45,12 +58,24 @@ var RoleSelect = (function () { stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode; } - // Mark position as actively being seated (glow state) - var activePos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]'); + // Mark seat as actively being claimed (glow state) + var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]'); if (activePos) activePos.classList.add('active'); + // Immediately fade out the gate-slot circle for the current turn's slot + var activeSlot = stack ? stack.dataset.activeSlot : null; + if (activeSlot) { + var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]'); + if (slotCircle) slotCircle.classList.add('role-assigned'); + } + var url = getSelectRoleUrl(); if (!url) return; + + // Block handleTurnChanged immediately — WS turn_changed can arrive while + // the fetch is in-flight and must be deferred until our animation completes. + _animationPending = true; + fetch(url, { method: "POST", headers: { @@ -61,34 +86,41 @@ var RoleSelect = (function () { }).then(function (response) { if (!response.ok) { // Server rejected (role already taken) — undo optimistic update + _animationPending = false; if (stack) { stack.dataset.starterRoles = stack.dataset.starterRoles .split(",").filter(function (r) { return r.trim() !== roleCode; }).join(","); } openFan(); } else { - // Always animate the role card into the tray, even if turn_changed - // already arrived. placeCard opens the tray, arcs the card in, - // then force-closes — so the user always sees their role card land. - // If turn_changed arrived before the fetch, handleTurnChanged already - // ran; _pendingTurnChange will be null and onComplete is a no-op. + // Animate the role card into the tray: open, arc-in, force-close. + // Any turn_changed that arrived while the fetch was in-flight is + // queued in _pendingTurnChange and will run after onComplete. if (typeof Tray !== "undefined") { - _animationPending = true; - Tray.placeCard(roleCode, function () { - _animationPending = false; - // Swap ban → check and clear glow on the seated position - var seatedPos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]'); - if (seatedPos) { - seatedPos.classList.remove('active'); - var ban = seatedPos.querySelector('.fa-ban'); - if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); } - } - if (_pendingTurnChange) { - var ev = _pendingTurnChange; - _pendingTurnChange = null; - handleTurnChanged(ev); - } - }); + setTimeout(function () { + Tray.placeCard(roleCode, function () { + // Swap ban → check, clear glow, mark seat as confirmed + var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]'); + if (seatedPos) { + seatedPos.classList.remove('active'); + seatedPos.classList.add('role-confirmed'); + var ban = seatedPos.querySelector('.fa-ban'); + if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); } + } + // Hold _animationPending through the post-tray pause so any + // turn_changed WS event that arrives now is still deferred. + setTimeout(function () { + _animationPending = false; + if (_pendingTurnChange) { + var ev = _pendingTurnChange; + _pendingTurnChange = null; + handleTurnChanged(ev); + } + }, _postTrayDelay); + }); + }, _placeCardDelay); + } else { + _animationPending = false; } } }); @@ -189,11 +221,39 @@ var RoleSelect = (function () { _turnChangedBeforeFetch = true; if (typeof Tray !== "undefined") Tray.forceClose(); - // Clear any stale .active glow from position indicators - document.querySelectorAll('.table-position.active').forEach(function (p) { + // Hide tray handle until the next player confirms their pick + var trayWrap = document.getElementById("id_tray_wrap"); + if (trayWrap) trayWrap.classList.add("role-select-phase"); + + // Clear any stale .active glow from hex seats + document.querySelectorAll('.table-seat.active').forEach(function (p) { p.classList.remove('active'); }); + // Sync seat icons from starter_roles so state persists without a reload + if (event.detail.starter_roles) { + var assignedRoles = event.detail.starter_roles; + document.querySelectorAll(".table-seat").forEach(function (seat) { + var role = seat.dataset.role; + if (assignedRoles.indexOf(role) !== -1) { + seat.classList.add("role-confirmed"); + var ban = seat.querySelector(".fa-ban"); + if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); } + } + }); + // Hide slot circles in turn order: slots 1..N done when N roles assigned + var assignedCount = assignedRoles.length; + document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) { + if (parseInt(circle.dataset.slot, 10) <= assignedCount) { + circle.classList.add("role-assigned"); + } + }); + } + + // Update active slot on the card stack so selectRole() can read it + var stack = document.querySelector(".card-stack[data-user-slots]"); + if (stack) stack.dataset.activeSlot = active; + var stack = document.querySelector(".card-stack[data-user-slots]"); if (stack) { // Sync starter-roles from server so the fan reflects actual DB state @@ -221,12 +281,10 @@ var RoleSelect = (function () { } } - // Move .active to the newly active seat + // Clear any stale seat glow (JS-only; glow is only during tray animation) document.querySelectorAll(".table-seat.active").forEach(function (s) { s.classList.remove("active"); }); - var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']"); - if (activeSeat) activeSeat.classList.add("active"); } window.addEventListener("room:role_select_start", init); @@ -247,6 +305,8 @@ var RoleSelect = (function () { _testReset: function () { _animationPending = false; _pendingTurnChange = null; + _placeCardDelay = 0; + _postTrayDelay = 0; }, }; }()); diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index a3039ab..7d6dc65 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -402,7 +402,9 @@ class RoleSelectRenderingTest(TestCase): response = self.client.get( self.url ) - self.assertNotContains(response, "fa-ban") + # Seat ban icons carry "position-status-icon"; card-stack ban does not. + # Assert the bare "fa-solid fa-ban" (card-stack form) is absent. + self.assertNotContains(response, 'class="fa-solid fa-ban"') def test_gatekeeper_overlay_absent_when_role_select(self): self.client.force_login(self.founder) @@ -411,6 +413,21 @@ class RoleSelectRenderingTest(TestCase): ) self.assertNotContains(response, "gate-overlay") + def test_tray_wrap_has_role_select_phase_class(self): + # Tray handle hidden until gamer confirms a role pick + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"') + + def test_tray_absent_during_gatekeeper_phase(self): + # Tray must not render before the gamer occupies a seat + room = Room.objects.create(name="Gate Room", owner=self.founder) + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": room.id}) + ) + self.assertNotContains(response, 'id="id_tray_wrap"') + def test_six_table_seats_rendered(self): self.client.force_login(self.founder) response = self.client.get( @@ -418,20 +435,66 @@ class RoleSelectRenderingTest(TestCase): ) self.assertContains(response, "table-seat", count=6) - def test_active_table_seat_has_active_class(self): - self.client.force_login(self.founder) # slot 1 is active - response = self.client.get( - self.url - ) - self.assertContains(response, 'class="table-seat active"') - - def test_inactive_table_seat_lacks_active_class(self): + def test_table_seats_never_active_on_load(self): + # Seat glow is JS-only (during tray animation); never server-rendered self.client.force_login(self.founder) - response = self.client.get( - self.url - ) - # Slots 2–6 are not active, so at least one plain table-seat exists - self.assertContains(response, 'class="table-seat"') + response = self.client.get(self.url) + self.assertNotContains(response, 'class="table-seat active"') + + def test_assigned_seat_renders_role_confirmed_class(self): + # A seat with a role already picked must load as role-confirmed (opaque chair) + self.gamers[0].refresh_from_db() + seat = self.room.table_seats.get(slot_number=1) + seat.role = "PC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, 'table-seat role-confirmed') + + def test_unassigned_seat_lacks_role_confirmed_class(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertNotContains(response, 'table-seat role-confirmed') + + def test_assigned_slot_circle_renders_role_assigned_class(self): + # Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based) + seat = self.room.table_seats.get(slot_number=1) + seat.role = "PC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, 'gate-slot filled role-assigned') + + def test_slot_circle_hides_by_count_not_role_label(self): + # Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's + seat = self.room.table_seats.get(slot_number=1) + seat.role = "NC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + content = response.content.decode() + import re + # Template renders class before data-slot; capture both orderings + circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content) + slot1_classes = next((cls for cls, slot in circles if slot == "1"), "") + slot2_classes = next((cls for cls, slot in circles if slot == "2"), "") + self.assertIn("role-assigned", slot1_classes) + self.assertNotIn("role-assigned", slot2_classes) + + def test_unassigned_slot_circle_lacks_role_assigned_class(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertNotContains(response, 'role-assigned') + + def test_position_strip_rendered_during_role_select(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, "position-strip") + + def test_position_strip_has_six_gate_slots(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, "gate-slot", count=6) def test_card_stack_has_data_user_slots_for_eligible_gamer(self): self.client.force_login(self.founder) # founder is slot 1 only @@ -447,6 +510,29 @@ class RoleSelectRenderingTest(TestCase): ) self.assertContains(response, 'data-user-slots="2"') + def test_assigned_seat_renders_check_icon(self): + seat = self.room.table_seats.get(slot_number=1) + seat.role = "PC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + content = response.content.decode() + # The PC seat should have fa-circle-check, not fa-ban + pc_seat_start = content.index('data-role="PC"') + pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300] + self.assertIn("fa-circle-check", pc_seat_chunk) + self.assertNotIn("fa-ban", pc_seat_chunk) + + def test_unassigned_seat_renders_ban_icon(self): + # slot 2's role is still null + self.client.force_login(self.founder) + response = self.client.get(self.url) + content = response.content.decode() + nc_seat_start = content.index('data-role="NC"') + nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300] + self.assertIn("fa-ban", nc_seat_chunk) + self.assertNotIn("fa-circle-check", nc_seat_chunk) + class PickRolesViewTest(TestCase): def setUp(self): diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 99e142d..d18e8ca 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -75,9 +75,16 @@ SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} def _gate_positions(room): - """Return list of dicts [{slot, role_label}] for _table_positions.html.""" + """Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html.""" + # Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless + # of which role each gamer chose — so use count, not role matching. + assigned_count = room.table_seats.exclude(role__isnull=True).count() return [ - {"slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, "")} + { + "slot": slot, + "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""), + "role_assigned": slot.slot_number <= assigned_count, + } for slot in room.gate_slots.order_by("slot_number") ] @@ -147,6 +154,7 @@ def _gate_context(room, user): "carte_nvm_slot_number": carte_nvm_slot_number, "carte_next_slot_number": carte_next_slot_number, "gate_positions": _gate_positions(room), + "starter_roles": [], } @@ -199,6 +207,7 @@ def _role_select_context(room, user): ) if user.is_authenticated else [], "active_slot": active_slot, "gate_positions": _gate_positions(room), + "slots": room.gate_slots.order_by("slot_number"), } if room.table_status == Room.SIG_SELECT: user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 338c54c..66f9e21 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -607,20 +607,20 @@ class PositionIndicatorsTest(FunctionalTest): ) # ------------------------------------------------------------------ # - # Test P1 — 6 position indicators present while gatekeeper is open # + # Test P1 — 6 position circles present in strip alongside gatekeeper # # ------------------------------------------------------------------ # def test_position_indicators_visible_alongside_gatekeeper(self): self.browser.get(self.gate_url) - # Gatekeeper modal is open self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") ) - # Six .table-position elements are rendered outside the modal - positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position") - self.assertEqual(len(positions), 6) - for pos in positions: - self.assertTrue(pos.is_displayed()) + # Six .gate-slot elements are rendered in .position-strip, outside modal + strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip") + slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot") + self.assertEqual(len(slots), 6) + for slot in slots: + self.assertTrue(slot.is_displayed()) # ------------------------------------------------------------------ # # Test P2 — URL drops /gate/ after pick_roles # @@ -631,63 +631,57 @@ class PositionIndicatorsTest(FunctionalTest): "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", ]) - # Simulate pick_roles having fired: room advances to ROLE_SELECT self.room.table_status = Room.ROLE_SELECT self.room.save() - # Navigating to the /gate/ URL should redirect to the plain room URL self.browser.get(self.gate_url) - expected_url = ( - f"{self.live_server_url}/gameboard/room/{self.room.id}/" - ) + expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/" self.wait_for( lambda: self.assertEqual(self.browser.current_url, expected_url) ) # ------------------------------------------------------------------ # - # Test P3 — Each position has a chair icon and correct role label # + # Test P3 — Gate-slot circles live outside the modal # # ------------------------------------------------------------------ # - def test_position_shows_chair_icon_and_role_label(self): - SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} + def test_position_circles_outside_gatekeeper_modal(self): + """The numbered position circles must NOT be descendants of .gate-modal — + they live in .position-strip which sits above the backdrop.""" self.browser.get(self.gate_url) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal") ) - for slot_number, role_label in SLOT_ROLE_LABELS.items(): - pos = self.browser.find_element( - By.CSS_SELECTOR, f".table-position[data-slot='{slot_number}']" - ) - # Chair icon present - self.assertTrue(pos.find_elements(By.CSS_SELECTOR, ".fa-chair")) - # Role label attribute and visible text - self.assertEqual(pos.get_attribute("data-role-label"), role_label) - label_el = pos.find_element(By.CSS_SELECTOR, ".position-role-label") - self.assertEqual(label_el.text.strip(), role_label) + # No .gate-slot inside the modal + modal_slots = self.browser.find_elements( + By.CSS_SELECTOR, ".gate-modal .gate-slot" + ) + self.assertEqual(len(modal_slots), 0) + # All 6 live in .position-strip + strip_slots = self.browser.find_elements( + By.CSS_SELECTOR, ".position-strip .gate-slot" + ) + self.assertEqual(len(strip_slots), 6) # ------------------------------------------------------------------ # - # Test P4 — Unoccupied position shows ban icon # + # Test P4 — Each circle displays its slot number # # ------------------------------------------------------------------ # - def test_unoccupied_position_shows_ban_icon(self): + def test_position_circle_shows_slot_number(self): self.browser.get(self.gate_url) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip") ) - # All slots are empty — every position should have a ban icon - positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position") - for pos in positions: - self.assertTrue( - pos.find_elements(By.CSS_SELECTOR, ".fa-ban"), - f"Expected .fa-ban on slot {pos.get_attribute('data-slot')}", + for n in range(1, 7): + slot_el = self.browser.find_element( + By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']" ) + self.assertIn(str(n), slot_el.text) # ------------------------------------------------------------------ # - # Test P5 — Occupied position shows check icon after token confirmed # + # Test P5 — Filled slot carries .filled class in strip # # ------------------------------------------------------------------ # - def test_occupied_position_shows_check_icon_after_token_confirmed(self): - # Slot 1 is filled via ORM + def test_filled_slot_shown_in_strip(self): from apps.epic.models import GateSlot slot = self.room.gate_slots.get(slot_number=1) slot.gamer = self.founder @@ -696,10 +690,13 @@ class PositionIndicatorsTest(FunctionalTest): self.browser.get(self.gate_url) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip") ) - pos1 = self.browser.find_element( - By.CSS_SELECTOR, ".table-position[data-slot='1']" + slot1 = self.browser.find_element( + By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']" ) - self.assertTrue(pos1.find_elements(By.CSS_SELECTOR, ".fa-circle-check")) - self.assertFalse(pos1.find_elements(By.CSS_SELECTOR, ".fa-ban")) + self.assertIn("filled", slot1.get_attribute("class")) + slot2 = self.browser.find_element( + By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']" + ) + self.assertIn("empty", slot2.get_attribute("class")) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index 0141253..e5d0b5d 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -445,12 +445,14 @@ class RoleSelectTest(FunctionalTest): ) # ------------------------------------------------------------------ # - # Test 7 — All roles revealed simultaneously after all gamers select # + # Test 8a — Hex seats carry role labels during role select # # ------------------------------------------------------------------ # - def test_roles_revealed_simultaneously_after_all_select(self): + def test_seats_around_hex_have_role_labels(self): + """During role select the 6 .table-seat elements carry data-role + attributes matching the fixed slot→role mapping (PC at slot 1, etc.).""" founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Reveal Test", owner=founder) + room = Room.objects.create(name="Seat Label Test", owner=founder) _fill_room_via_orm(room, [ "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", @@ -462,38 +464,24 @@ class RoleSelectTest(FunctionalTest): self.create_pre_authenticated_session("founder@test.io") self.browser.get(room_url) - # Assign all roles via ORM (simulating all gamers having chosen) - from apps.epic.models import TableSeat - roles = ["PC", "BC", "SC", "AC", "NC", "EC"] - for i, slot in enumerate(room.gate_slots.order_by("slot_number")): - TableSeat.objects.create( - room=room, - gamer=slot.gamer, - slot_number=slot.slot_number, - role=roles[i], - role_revealed=True, - ) - room.table_status = Room.SIG_SELECT - room.save() - - self.browser.refresh() - - # Sig deck is present (page has transitioned to SIG_SELECT) + expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} self.wait_for( - lambda: self.browser.find_element(By.ID, "id_sig_deck") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat") ) - + for slot_number, role_label in expected.items(): + seat = self.browser.find_element( + By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']" + ) + self.assertEqual(seat.get_attribute("data-role"), role_label) # ------------------------------------------------------------------ # - # Test 8a — Position glows while role card is being placed # + # Test 8b — Hex seats show .fa-ban when empty # # ------------------------------------------------------------------ # - def test_position_glows_when_role_card_confirmed(self): - """Immediately after confirming a role pick, the matching - .table-position should receive .active (the glow state) before - the tray animation completes.""" + def test_seats_show_ban_icon_when_empty(self): + """All 6 seats carry .fa-ban before any role has been chosen.""" founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Position Glow Test", owner=founder) + room = Room.objects.create(name="Seat Ban Test", owner=founder) _fill_room_via_orm(room, [ "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", @@ -509,32 +497,26 @@ class RoleSelectTest(FunctionalTest): self.create_pre_authenticated_session("founder@test.io") self.browser.get(room_url) - # Open fan, click first card (PC), confirm guard self.wait_for( - lambda: self.browser.find_element( - By.CSS_SELECTOR, ".card-stack[data-state='eligible']" - ) - ).click() - self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) - self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() - self.confirm_guard() - - # PC position gains .active immediately after confirmation - self.wait_for( - lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-position[data-role-label='PC'].active" - ) + lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat") ) + seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat") + self.assertEqual(len(seats), 6) + for seat in seats: + self.assertTrue( + seat.find_elements(By.CSS_SELECTOR, ".fa-ban"), + f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}", + ) # ------------------------------------------------------------------ # - # Test 8b — Position shows check icon after tray sequence ends # + # Test 8c — Hex seat gets .fa-circle-check after role selected # # ------------------------------------------------------------------ # - def test_position_gets_check_when_tray_sequence_ends(self): - """After the tray arc-in animation completes and the tray closes, - the PC .table-position should show .fa-circle-check and no .fa-ban.""" + def test_seat_gets_check_after_role_selected(self): + """After confirming a role pick the corresponding hex seat should + show .fa-circle-check and lose .fa-ban.""" founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Position Check Test", owner=founder) + room = Room.objects.create(name="Seat Check Test", owner=founder) _fill_room_via_orm(room, [ "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", @@ -550,7 +532,7 @@ class RoleSelectTest(FunctionalTest): self.create_pre_authenticated_session("founder@test.io") self.browser.get(room_url) - # Open fan, pick PC card, confirm guard + # Open fan, pick first card (PC), confirm guard self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".card-stack[data-state='eligible']" @@ -560,23 +542,23 @@ class RoleSelectTest(FunctionalTest): self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.confirm_guard() - # Wait for tray animation to complete (tray closes) + # Wait for tray animation to complete self.wait_for( lambda: self.assertFalse( self.browser.execute_script("return Tray.isOpen()"), - "Tray should close after arc-in sequence" + "Tray should close after arc-in sequence", ) ) - # PC position now shows check icon, ban icon gone + # The PC seat (slot 1) now shows check, no ban self.wait_for( lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-circle-check" + By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-circle-check" ) ) self.assertEqual( len(self.browser.find_elements( - By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-ban" + By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-ban" )), 0, ) @@ -745,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): ) room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" - # 1. Watcher loads the room — slot 1 is active on initial render + # 1. Watcher (slot 2) loads the room self.create_pre_authenticated_session("watcher@test.io") self.browser.get(room_url) self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" + By.CSS_SELECTOR, ".card-stack[data-state='ineligible']" )) # 2. Founder picks a role in second browser @@ -764,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.confirm_guard(browser=self.browser2) - # 3. Watcher's seat arc moves to slot 2 — no page refresh + # 3. Watcher's turn arrives via WS — card-stack becomes eligible self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" )) - self.assertEqual( - len(self.browser.find_elements( - By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" - )), - 0, - ) finally: self.browser2.quit() @@ -836,16 +812,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): return card !== null && card === card.parentElement.firstElementChild; """))) - # Turn advances via WS — seat 2 becomes active. - self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" - )) - - # Tray must be closed: forceClose() fires in handleTurnChanged. - self.assertFalse( + # Turn advances via WS — tray must close (forceClose in handleTurnChanged). + self.wait_for(lambda: self.assertFalse( self.browser.execute_script("return Tray.isOpen()"), "Tray should be closed after turn advances" - ) + )) def test_landscape_tray_closes_on_turn_advance(self): """Landscape: role card at leftmost grid square; tray closes when @@ -869,15 +840,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): return card !== null && card === card.parentElement.firstElementChild; """))) - # Turn advances via WS — seat 2 becomes active. - self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" - )) - - # Tray must be closed. - self.assertFalse( + # Turn advances via WS — tray must close (forceClose in handleTurnChanged). + self.wait_for(lambda: self.assertFalse( self.browser.execute_script("return Tray.isOpen()"), "Tray should be closed after turn advances" - ) + )) diff --git a/src/functional_tests/test_room_tray.py b/src/functional_tests/test_room_tray.py index 20e22bd..3dacaaf 100644 --- a/src/functional_tests/test_room_tray.py +++ b/src/functional_tests/test_room_tray.py @@ -80,6 +80,20 @@ class TrayTest(FunctionalTest): document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true})); """, btn, start_y, end_y) + def _make_role_select_room(self, founder_email="founder@test.io"): + from apps.epic.models import TableSeat + founder, _ = User.objects.get_or_create(email=founder_email) + room = Room.objects.create(name="Tray Test Room", owner=founder) + emails = [founder_email, "nc@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io"] + _fill_room_via_orm(room, emails) + room.table_status = Room.ROLE_SELECT + room.save() + for i, email in enumerate(emails, start=1): + gamer, _ = User.objects.get_or_create(email=email) + TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i) + return room + def _make_sig_select_room(self, founder_email="founder@test.io"): founder, _ = User.objects.get_or_create(email=founder_email) room = Room.objects.create(name="Tray Test Room", owner=founder) @@ -262,7 +276,7 @@ class TrayTest(FunctionalTest): @tag('two-browser') def test_tray_grid_is_1_column_by_8_rows_in_portrait(self): - room = self._make_sig_select_room() + room = self._make_role_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) diff --git a/src/static/tests/RoleSelectSpec.js b/src/static/tests/RoleSelectSpec.js index df72887..1aaf626 100644 --- a/src/static/tests/RoleSelectSpec.js +++ b/src/static/tests/RoleSelectSpec.js @@ -2,6 +2,7 @@ describe("RoleSelect", () => { let testDiv; beforeEach(() => { + RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange testDiv = document.createElement("div"); testDiv.innerHTML = `