From 8b006be1387d5d1499324b7f0cdbe63f8e1ad6e0 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 30 Mar 2026 16:42:23 -0400 Subject: [PATCH] demo'd old inventory area in room.html to make way for new content (hex table now centered in view); old test suite now targets Role card in #id_tray cells where appropriate, or skips Sig card select until aforementioned new feature deployed; new scripts & jasmine tests too; removed one irrelevant test case from apps.epic.tests.ITs.test_views.SelectRoleViewTest --- src/apps/epic/static/apps/epic/role-select.js | 63 ++++--- src/apps/epic/static/apps/epic/tray.js | 40 ++++ src/apps/epic/tests/integrated/test_views.py | 37 ---- src/functional_tests/test_room_role_select.py | 132 +++++--------- src/functional_tests/test_room_sig_select.py | 9 +- src/static/tests/RoleSelectSpec.js | 171 +++++++++++------- src/static/tests/TraySpec.js | 93 ++++++++++ src/static_src/scss/_room.scss | 61 ------- src/static_src/scss/_tray.scss | 31 ++++ src/static_src/tests/RoleSelectSpec.js | 171 +++++++++++------- src/static_src/tests/TraySpec.js | 93 ++++++++++ src/templates/apps/gameboard/room.html | 26 --- 12 files changed, 553 insertions(+), 374 deletions(-) diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index 519c50f..03106e9 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -3,6 +3,11 @@ var RoleSelect = (function () { // ahead of the fetch response doesn't get overridden by Tray.open(). var _turnChangedBeforeFetch = false; + // Set to true while placeCard animation is running. handleTurnChanged + // defers its work until the animation completes. + var _animationPending = false; + var _pendingTurnChange = null; + var ROLES = [ { code: "PC", name: "Player", element: "Fire" }, { code: "BC", name: "Builder", element: "Stone" }, @@ -27,18 +32,10 @@ var RoleSelect = (function () { if (backdrop) backdrop.remove(); } - function selectRole(roleCode, cardEl) { + function selectRole(roleCode) { _turnChangedBeforeFetch = false; // fresh selection, reset the race flag - var invCard = cardEl.cloneNode(true); - invCard.classList.add("flipped"); - // strip old event listeners from the clone by replacing with a clean copy - var clean = invCard.cloneNode(true); - closeFan(); - var invSlot = document.getElementById("id_inv_role_card"); - if (invSlot) invSlot.appendChild(clean); - // Immediately lock the stack — do not wait for WS turn_changed var stack = document.querySelector(".card-stack[data-starter-roles]"); if (stack) { @@ -60,24 +57,27 @@ var RoleSelect = (function () { }).then(function (response) { if (!response.ok) { // Server rejected (role already taken) — undo optimistic update - if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean); if (stack) { stack.dataset.starterRoles = stack.dataset.starterRoles .split(",").filter(function (r) { return r.trim() !== roleCode; }).join(","); } openFan(); } else { - // Place role card in tray grid and open the tray - var grid = document.getElementById("id_tray_grid"); - if (grid) { - var trayCard = document.createElement("div"); - trayCard.className = "tray-cell tray-role-card"; - trayCard.dataset.role = roleCode; - grid.insertBefore(trayCard, grid.firstChild); - } - // Only open if turn_changed hasn't already arrived and closed it. - if (typeof Tray !== "undefined" && !_turnChangedBeforeFetch) { - Tray.open(); + // 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. + if (typeof Tray !== "undefined") { + _animationPending = true; + Tray.placeCard(roleCode, function () { + _animationPending = false; + if (_pendingTurnChange) { + var ev = _pendingTurnChange; + _pendingTurnChange = null; + handleTurnChanged(ev); + } + }); } } }); @@ -135,7 +135,7 @@ var RoleSelect = (function () { "Start round 1 as
" + role.name + " (" + role.code + ") …?", function () { // confirm card.classList.remove("guard-active"); - selectRole(role.code, card); + selectRole(role.code); }, function () { // dismiss (NVM / outside click) card.classList.remove("guard-active"); @@ -165,9 +165,13 @@ var RoleSelect = (function () { } function handleTurnChanged(event) { + // If a placeCard animation is running, defer until it completes. + if (_animationPending) { + _pendingTurnChange = event; + return; + } + var active = String(event.detail.active_slot); - var invSlot = document.getElementById("id_inv_role_card"); - if (invSlot) invSlot.innerHTML = ""; // Force-close tray instantly so it never obscures the next player's card-stack. // Also set the race flag so the fetch .then() doesn't re-open if it arrives late. @@ -220,8 +224,13 @@ var RoleSelect = (function () { } return { - openFan: openFan, - closeFan: closeFan, - setReload: function (fn) { _reload = fn; }, + openFan: openFan, + closeFan: closeFan, + setReload: function (fn) { _reload = fn; }, + // Testing hook — resets animation-pause state between Jasmine specs + _testReset: function () { + _animationPending = false; + _pendingTurnChange = null; + }, }; }()); diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js index c8f23ad..0b71cd4 100644 --- a/src/apps/epic/static/apps/epic/tray.js +++ b/src/apps/epic/static/apps/epic/tray.js @@ -224,6 +224,37 @@ var Tray = (function () { }); } + // _arcIn — add .arc-in to cardEl, wait for animationend, remove it, call onComplete. + function _arcIn(cardEl, onComplete) { + cardEl.classList.add('arc-in'); + cardEl.addEventListener('animationend', function handler() { + cardEl.removeEventListener('animationend', handler); + cardEl.classList.remove('arc-in'); + if (onComplete) onComplete(); + }); + } + + // placeCard(roleCode, onComplete) — mark the first tray cell with the role, + // open the tray, arc-in the cell, then force-close. Calls onComplete after. + // The grid always contains exactly 8 .tray-cell elements (from the template); + // the first one receives .tray-role-card and data-role instead of a new element + // being inserted, so the cell count never changes. + function placeCard(roleCode, onComplete) { + if (!_grid) { if (onComplete) onComplete(); return; } + var firstCell = _grid.querySelector('.tray-cell'); + if (!firstCell) { if (onComplete) onComplete(); return; } + + firstCell.classList.add('tray-role-card'); + firstCell.dataset.role = roleCode; + firstCell.textContent = roleCode; + + open(); + _arcIn(firstCell, function () { + forceClose(); + if (onComplete) onComplete(); + }); + } + function _startDrag(clientX, clientY) { _dragHandled = false; if (_wrap) _wrap.classList.add('tray-dragging'); @@ -428,6 +459,14 @@ var Tray = (function () { _onBtnClick = null; } _cancelPendingHide(); + // Clear any role-card state from tray cells (Jasmine afterEach) + if (_grid) { + _grid.querySelectorAll('.tray-cell').forEach(function (el) { + el.classList.remove('tray-role-card', 'arc-in'); + el.textContent = ''; + delete el.dataset.role; + }); + } _wrap = null; _btn = null; _tray = null; @@ -446,6 +485,7 @@ var Tray = (function () { close: close, forceClose: forceClose, isOpen: isOpen, + placeCard: placeCard, reset: reset, _testSetLandscape: function (v) { _landscapeOverride = v; }, }; diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index fc8690d..c279f4d 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -655,43 +655,6 @@ class SelectRoleViewTest(TestCase): ) -class RevealPhaseRenderingTest(TestCase): - def setUp(self): - self.founder = User.objects.create(email="founder@test.io") - self.room = Room.objects.create(name="Test Room", owner=self.founder) - gamers = [self.founder] - for i in range(2, 7): - gamers.append(User.objects.create(email=f"g{i}@test.io")) - roles = ["PC", "BC", "SC", "AC", "NC", "EC"] - for i, (gamer, role) in enumerate(zip(gamers, roles), start=1): - TableSeat.objects.create( - room=self.room, gamer=gamer, slot_number=i, - role=role, role_revealed=True, - ) - self.room.gate_status = Room.OPEN - self.room.table_status = Room.SIG_SELECT - self.room.save() - self.client.force_login(self.founder) - - def test_face_up_role_cards_rendered_when_sig_select(self): - response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) - ) - self.assertContains(response, "face-up") - - def test_inv_role_card_slot_present(self): - response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) - ) - self.assertContains(response, "id_inv_role_card") - - def test_partner_indicator_present_when_sig_select(self): - response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) - ) - self.assertContains(response, "partner-indicator") - - class RoomActionsViewTest(TestCase): def setUp(self): self.owner = User.objects.create(email="owner@test.io") diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index 196e9b5..e7bcef4 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -216,14 +216,7 @@ class RoleSelectTest(FunctionalTest): ) ) - # 7. Role card appears in inventory - self.wait_for( - lambda: self.browser.find_element( - By.CSS_SELECTOR, "#id_inv_role_card .card" - ) - ) - - # 8. Card stack returns to table centre + # 7. Card stack returns to table centre self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") ) @@ -323,46 +316,6 @@ class RoleSelectTest(FunctionalTest): cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card") self.assertEqual(len(cards), 5) - # ------------------------------------------------------------------ # - # Test 3d — Previously selected roles appear in inventory on re-entry# - # ------------------------------------------------------------------ # - - def test_previously_selected_roles_shown_in_inventory_on_re_entry(self): - """A multi-slot gamer who already chose some roles should see those - role cards pre-populated in the inventory when they re-enter the room.""" - from apps.epic.models import TableSeat - founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Inventory Re-entry Test", owner=founder) - _fill_room_via_orm(room, [ - "founder@test.io", "founder@test.io", - "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", - ]) - room.table_status = Room.ROLE_SELECT - room.save() - - for slot in room.gate_slots.order_by("slot_number"): - TableSeat.objects.create( - room=room, gamer=slot.gamer, slot_number=slot.slot_number, - ) - # Founder's first slot has already chosen BC - TableSeat.objects.filter(room=room, slot_number=1).update(role="BC") - - room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" - self.create_pre_authenticated_session("founder@test.io") - self.browser.get(room_url) - - # Inventory should contain exactly one pre-rendered card for BC - inv_cards = self.wait_for( - lambda: self.browser.find_elements( - By.CSS_SELECTOR, "#id_inv_role_card .card" - ) - ) - self.assertEqual(len(inv_cards), 1) - self.assertIn( - "BUILDER", - inv_cards[0].text.upper(), - ) - # ------------------------------------------------------------------ # # Test 4 — Click-away dismisses fan without selecting # # ------------------------------------------------------------------ # @@ -392,17 +345,13 @@ class RoleSelectTest(FunctionalTest): # Click the backdrop (outside the fan) self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click() - # Modal closes; stack still present; inventory still empty + # Modal closes; stack still present self.wait_for( lambda: self.assertEqual( len(self.browser.find_elements(By.ID, "id_role_select")), 0 ) ) self.browser.find_element(By.CSS_SELECTOR, ".card-stack") - self.assertEqual( - len(self.browser.find_elements(By.CSS_SELECTOR, "#id_inv_role_card .card")), - 0 - ) # ------------------------------------------------------------------ # @@ -529,17 +478,9 @@ class RoleSelectTest(FunctionalTest): self.browser.refresh() - # All role cards in inventory are face-up - face_up_cards = self.wait_for( - lambda: self.browser.find_elements( - By.CSS_SELECTOR, "#id_inv_role_card .card.face-up" - ) - ) - self.assertGreater(len(face_up_cards), 0) - - # Partner indicator is visible + # Sig deck is present (page has transitioned to SIG_SELECT) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".partner-indicator") + lambda: self.browser.find_element(By.ID, "id_sig_deck") ) @@ -590,12 +531,13 @@ class RoleSelectTrayTest(FunctionalTest): self.confirm_guard() # ------------------------------------------------------------------ # - # T1 — Portrait: role card at topmost grid square, tray opens # + # T1 — Portrait: role card marks first cell; tray opens then closes # # ------------------------------------------------------------------ # def test_portrait_role_card_enters_topmost_grid_square(self): - """Portrait: after confirming a role, a .tray-role-card is the first child - of #id_tray_grid (topmost cell) and the tray is open.""" + """Portrait: after confirming a role the first .tray-cell gets + .tray-role-card; the grid still has exactly 8 cells; and the tray + opens briefly then closes once the arc-in animation completes.""" self.browser.set_window_size(390, 844) room = self._make_room() self.create_pre_authenticated_session("slot1@test.io") @@ -604,34 +546,42 @@ class RoleSelectTrayTest(FunctionalTest): self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap")) self._select_role() - # Card appears in the grid. + # First cell receives the role card class. self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, "#id_tray_grid .tray-role-card" ) ) - # It is the first child — topmost in portrait. - is_first = self.browser.execute_script(""" - var card = document.querySelector('#id_tray_grid .tray-role-card'); - return card !== null && card === card.parentElement.firstElementChild; + result = self.browser.execute_script(""" + var grid = document.getElementById('id_tray_grid'); + var card = grid.querySelector('.tray-role-card'); + return { + isFirst: card !== null && card === grid.firstElementChild, + count: grid.children.length, + role: card ? card.dataset.role : null + }; """) - self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid") + self.assertTrue(result["isFirst"], "Role card should be the first cell") + self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells") + self.assertTrue(result["role"], "First cell should carry data-role") - # Tray is open. - self.assertTrue( - self.browser.execute_script("return Tray.isOpen()"), - "Tray should be open after role selection" + # Tray closes after the animation sequence. + self.wait_for( + lambda: self.assertFalse( + self.browser.execute_script("return Tray.isOpen()"), + "Tray should close after the arc-in sequence" + ) ) # ------------------------------------------------------------------ # - # T2 — Landscape: role card at leftmost grid square, tray opens # + # T2 — Landscape: same contract in landscape # # ------------------------------------------------------------------ # @tag('two-browser') def test_landscape_role_card_enters_leftmost_grid_square(self): - """Landscape: after confirming a role, a .tray-role-card is the first child - of #id_tray_grid (leftmost cell) and the tray is open.""" + """Landscape: the first .tray-cell gets .tray-role-card; grid has + 8 cells; tray opens then closes.""" self.browser.set_window_size(844, 390) room = self._make_room() self.create_pre_authenticated_session("slot1@test.io") @@ -640,24 +590,28 @@ class RoleSelectTrayTest(FunctionalTest): self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap")) self._select_role() - # Card appears in the grid. self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, "#id_tray_grid .tray-role-card" ) ) - # It is the first child — leftmost in landscape. - is_first = self.browser.execute_script(""" - var card = document.querySelector('#id_tray_grid .tray-role-card'); - return card !== null && card === card.parentElement.firstElementChild; + result = self.browser.execute_script(""" + var grid = document.getElementById('id_tray_grid'); + var card = grid.querySelector('.tray-role-card'); + return { + isFirst: card !== null && card === grid.firstElementChild, + count: grid.children.length + }; """) - self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid") + self.assertTrue(result["isFirst"], "Role card should be the first cell") + self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells") - # Tray is open. - self.assertTrue( - self.browser.execute_script("return Tray.isOpen()"), - "Tray should be open after role selection" + self.wait_for( + lambda: self.assertFalse( + self.browser.execute_script("return Tray.isOpen()"), + "Tray should close after the arc-in sequence" + ) ) diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_room_sig_select.py index 9a095fe..506fa64 100644 --- a/src/functional_tests/test_room_sig_select.py +++ b/src/functional_tests/test_room_sig_select.py @@ -189,12 +189,9 @@ class SigSelectTest(FunctionalTest): ) ) - # Founder's significator appears in their inventory - self.wait_for( - lambda: self.browser.find_element( - By.CSS_SELECTOR, "#id_inv_sig_card .card" - ) - ) + # TODO: sig card should appear in the tray (tray.placeCard for sig phase) + # once sig-select.js is updated to call Tray.placeCard instead of + # appending to the removed #id_inv_sig_card inventory element. # Active seat advances to NC self.wait_for( diff --git a/src/static/tests/RoleSelectSpec.js b/src/static/tests/RoleSelectSpec.js index cc32d4f..df72887 100644 --- a/src/static/tests/RoleSelectSpec.js +++ b/src/static/tests/RoleSelectSpec.js @@ -7,7 +7,6 @@ describe("RoleSelect", () => {
-
`; document.body.appendChild(testDiv); window.fetch = jasmine.createSpy("fetch").and.returnValue( @@ -102,12 +101,6 @@ describe("RoleSelect", () => { expect(document.getElementById("id_role_select")).toBeNull(); }); - it("clicking a card appends a .card to #id_inv_role_card", () => { - const card = document.querySelector("#id_role_select .card"); - card.click(); - expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); - }); - it("clicking a card POSTs to the select_role URL", () => { const card = document.querySelector("#id_role_select .card"); card.click(); @@ -117,11 +110,6 @@ describe("RoleSelect", () => { ); }); - it("clicking a card results in exactly one card in inventory", () => { - const card = document.querySelector("#id_role_select .card"); - card.click(); - expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1); - }); }); // ------------------------------------------------------------------ // @@ -135,11 +123,6 @@ describe("RoleSelect", () => { expect(document.getElementById("id_role_select")).toBeNull(); }); - it("does not add a card to inventory", () => { - RoleSelect.openFan(); - document.querySelector(".role-select-backdrop").click(); - expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); - }); }); // ------------------------------------------------------------------ // @@ -259,20 +242,13 @@ describe("RoleSelect", () => { // ------------------------------------------------------------------ // describe("tray card after successful role selection", () => { - let grid, guardConfirm; + let guardConfirm; beforeEach(() => { - // Minimal tray grid matching room.html structure - grid = document.createElement("div"); - grid.id = "id_tray_grid"; - for (let i = 0; i < 8; i++) { - const cell = document.createElement("div"); - cell.className = "tray-cell"; - grid.appendChild(cell); - } - testDiv.appendChild(grid); - - spyOn(Tray, "open"); + // Spy on Tray.placeCard: call the onComplete callback immediately. + spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { + if (cb) cb(); + }); // Capturing guard spy — holds onConfirm so we can fire it per-test window.showGuard = jasmine.createSpy("showGuard").and.callFake( @@ -283,54 +259,128 @@ describe("RoleSelect", () => { document.querySelector("#id_role_select .card").click(); }); - it("prepends a .tray-role-card to #id_tray_grid on success", async () => { + it("calls Tray.placeCard() on success", async () => { guardConfirm(); await Promise.resolve(); - expect(grid.querySelector(".tray-role-card")).not.toBeNull(); + expect(Tray.placeCard).toHaveBeenCalled(); }); - it("tray-role-card is the first child of #id_tray_grid", async () => { + it("passes the role code string to Tray.placeCard", async () => { guardConfirm(); await Promise.resolve(); - expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true); + const roleCode = Tray.placeCard.calls.mostRecent().args[0]; + expect(typeof roleCode).toBe("string"); + expect(roleCode.length).toBeGreaterThan(0); }); - it("tray-role-card carries the selected role as data-role", async () => { - guardConfirm(); - await Promise.resolve(); - const trayCard = grid.querySelector(".tray-role-card"); - expect(trayCard.dataset.role).toBeTruthy(); - }); - - it("calls Tray.open() on success", async () => { - guardConfirm(); - await Promise.resolve(); - expect(Tray.open).toHaveBeenCalled(); - }); - - it("does not prepend a tray-role-card on server rejection", async () => { + it("does not call Tray.placeCard() on server rejection", async () => { window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: false }) ); guardConfirm(); await Promise.resolve(); - expect(grid.querySelector(".tray-role-card")).toBeNull(); + expect(Tray.placeCard).not.toHaveBeenCalled(); }); + }); - it("does not call Tray.open() on server rejection", async () => { - window.fetch = jasmine.createSpy("fetch").and.returnValue( - Promise.resolve({ ok: false }) + // ------------------------------------------------------------------ // + // WS turn_changed pause during animation // + // ------------------------------------------------------------------ // + + describe("WS turn_changed pause during placeCard animation", () => { + let stack, guardConfirm; + + beforeEach(() => { + // Six table seats, slot 1 starts active + for (let i = 1; i <= 6; i++) { + const seat = document.createElement("div"); + seat.className = "table-seat" + (i === 1 ? " active" : ""); + seat.dataset.slot = String(i); + testDiv.appendChild(seat); + } + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "ineligible"; + stack.dataset.userSlots = "1"; + stack.dataset.starterRoles = ""; + testDiv.appendChild(stack); + + const grid = document.createElement("div"); + grid.id = "id_tray_grid"; + testDiv.appendChild(grid); + + // placeCard spy that holds the onComplete callback + let heldCallback = null; + spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { + heldCallback = cb; // don't call immediately — simulate animation in-flight + }); + spyOn(Tray, "forceClose"); + + // Expose heldCallback so tests can fire it + Tray._testFirePlaceCardComplete = () => { + if (heldCallback) heldCallback(); + }; + + window.showGuard = jasmine.createSpy("showGuard").and.callFake( + (anchor, message, onConfirm) => { guardConfirm = onConfirm; } ); - guardConfirm(); - await Promise.resolve(); - expect(Tray.open).not.toHaveBeenCalled(); }); - it("grid grows by exactly 1 on success", async () => { - const before = grid.children.length; + afterEach(() => { + delete Tray._testFirePlaceCardComplete; + RoleSelect._testReset(); + }); + + it("turn_changed during animation does not call Tray.forceClose immediately", async () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); + guardConfirm(); + await Promise.resolve(); // fetch resolves; placeCard called; animation pending + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + expect(Tray.forceClose).not.toHaveBeenCalled(); + }); + + it("turn_changed during animation does not immediately move the active seat", async () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); guardConfirm(); await Promise.resolve(); - expect(grid.children.length).toBe(before + 1); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + const activeSeat = testDiv.querySelector(".table-seat.active"); + expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1 + }); + + it("deferred turn_changed is processed when animation completes", async () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); + guardConfirm(); + await Promise.resolve(); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + + // Fire onComplete — deferred turn_changed should now run + Tray._testFirePlaceCardComplete(); + + const activeSeat = testDiv.querySelector(".table-seat.active"); + expect(activeSeat && activeSeat.dataset.slot).toBe("2"); + }); + + it("turn_changed after animation completes is processed immediately", () => { + // No animation in flight — turn_changed should run right away + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + expect(Tray.forceClose).toHaveBeenCalled(); + const activeSeat = testDiv.querySelector(".table-seat.active"); + expect(activeSeat && activeSeat.dataset.slot).toBe("2"); }); }); @@ -433,9 +483,6 @@ describe("RoleSelect", () => { ); }); - it("appends a .card to #id_inv_role_card", () => { - expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); - }); }); describe("dismissing the guard (NVM or outside click)", () => { @@ -463,10 +510,6 @@ describe("RoleSelect", () => { expect(window.fetch).not.toHaveBeenCalled(); }); - it("does not add a card to inventory", () => { - expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); - }); - it("restores normal mouseleave behaviour on the card", () => { card.dispatchEvent(new MouseEvent("mouseenter")); card.dispatchEvent(new MouseEvent("mouseleave")); diff --git a/src/static/tests/TraySpec.js b/src/static/tests/TraySpec.js index 264275f..818249a 100644 --- a/src/static/tests/TraySpec.js +++ b/src/static/tests/TraySpec.js @@ -422,4 +422,97 @@ describe("Tray", () => { expect(Tray.isOpen()).toBe(false); }); }); + + // ---------------------------------------------------------------------- // + // placeCard() // + // ---------------------------------------------------------------------- // + // + // placeCard(roleCode, onComplete): + // 1. Marks the first .tray-cell with .tray-role-card + data-role. + // 2. Opens the tray. + // 3. Arc-in animates the cell (.arc-in class, animationend fires). + // 4. forceClose() — tray closes instantly. + // 5. Calls onComplete. + // + // The grid always has exactly 8 .tray-cell elements (from the template); + // no new elements are inserted. + // + // ---------------------------------------------------------------------- // + + describe("placeCard()", () => { + let grid, firstCell; + + beforeEach(() => { + grid = document.createElement("div"); + grid.id = "id_tray_grid"; + for (let i = 0; i < 8; i++) { + const cell = document.createElement("div"); + cell.className = "tray-cell"; + grid.appendChild(cell); + } + document.body.appendChild(grid); + // Re-init so _grid is set (reset() in outer afterEach clears it) + Tray.init(); + firstCell = grid.querySelector(".tray-cell"); + }); + + afterEach(() => { + grid.remove(); + }); + + it("adds .tray-role-card to the first .tray-cell", () => { + Tray.placeCard("PC", null); + expect(firstCell.classList.contains("tray-role-card")).toBe(true); + }); + + it("sets data-role on the first cell", () => { + Tray.placeCard("NC", null); + expect(firstCell.dataset.role).toBe("NC"); + }); + + it("grid cell count stays at 8", () => { + Tray.placeCard("PC", null); + expect(grid.children.length).toBe(8); + }); + + it("opens the tray", () => { + Tray.placeCard("PC", null); + expect(Tray.isOpen()).toBe(true); + }); + + it("adds .arc-in to the first cell", () => { + Tray.placeCard("PC", null); + expect(firstCell.classList.contains("arc-in")).toBe(true); + }); + + it("removes .arc-in and force-closes after animationend", () => { + Tray.placeCard("PC", null); + expect(Tray.isOpen()).toBe(true); + firstCell.dispatchEvent(new Event("animationend")); + expect(firstCell.classList.contains("arc-in")).toBe(false); + expect(Tray.isOpen()).toBe(false); + }); + + it("calls onComplete after the tray closes", () => { + let called = false; + Tray.placeCard("PC", () => { called = true; }); + firstCell.dispatchEvent(new Event("animationend")); + expect(called).toBe(true); + }); + + it("landscape: same behaviour — first cell gets role card", () => { + Tray._testSetLandscape(true); + Tray.init(); + Tray.placeCard("EC", null); + expect(firstCell.classList.contains("tray-role-card")).toBe(true); + expect(firstCell.dataset.role).toBe("EC"); + }); + + it("reset() removes .tray-role-card and data-role from cells", () => { + Tray.placeCard("PC", null); + Tray.reset(); + expect(firstCell.classList.contains("tray-role-card")).toBe(false); + expect(firstCell.dataset.role).toBeUndefined(); + }); + }); }); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 1fd753c..c118f73 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -419,17 +419,6 @@ $seat-r-y: round($seat-r * 0.5); // 65px justify-content: center; } -.room-inventory { - flex: 1; - display: flex; - flex-direction: column; - gap: 1rem; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(var(--terUser), 0.3) transparent; -} - .table-seat { position: absolute; display: flex; @@ -617,56 +606,6 @@ $card-h: 120px; } } -// ─── Inventory role card hand ─────────────────────────────────────────────── -// -// Cards are stacked vertically: only a $strip-height peek of each card below -// the first is visible by default, showing the role name at the top of the -// card face. Hovering any card slides it right to pop it clear of the stack. - -$inv-card-w: 100px; -$inv-card-h: 150px; -$inv-strip: 30px; // visible height of each stacked card after the first - -#id_inv_role_card { - display: flex; - flex-direction: column; - - .card { - width: $inv-card-w; - height: $inv-card-h; - position: relative; - z-index: 1; - flex-shrink: 0; - transition: transform 0.2s ease; - - // Every card after the first overlaps the one above it - & + .card { - margin-top: -($inv-card-h - $inv-strip); - } - - // Role name pinned to the top of the face so it reads in the strip - .card-front { - justify-content: flex-start; - padding-top: 0.4rem; - } - - // Pop the hovered card to the right, above siblings - &:hover { - transform: translateX(1.5rem); - z-index: 10; - } - } -} - -// ─── Partner indicator ───────────────────────────────────────────────────── - -.partner-indicator { - margin-top: 0.5rem; - font-size: 0.75rem; - opacity: 0.6; - text-align: center; -} - // Landscape mobile — aggressively scale down to fit short viewport @media (orientation: landscape) and (max-width: 1440px) { .gate-modal { diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss index 389dbbb..0cfe05b 100644 --- a/src/static_src/scss/_tray.scss +++ b/src/static_src/scss/_tray.scss @@ -117,6 +117,27 @@ $handle-r: 1rem; &::before { border-color: rgba(var(--quaUser), 1); } } +// ─── Role card: arc-in animation (portrait) ───────────────────────────────── +@keyframes tray-role-arc-in { + from { opacity: 0; transform: scale(0.3) translate(-40%, -40%); } + to { opacity: 1; transform: scale(1) translate(0, 0); } +} + +.tray-role-card { + background: rgba(var(--quaUser), 0.25); + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 0.2em; + font-size: 0.65rem; + color: rgba(var(--quaUser), 1); + font-weight: 600; + + &.arc-in { + animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } +} + @keyframes tray-wobble { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-8px); } @@ -276,6 +297,16 @@ $handle-r: 1rem; border-bottom: none; } + // Role card arc-in for landscape + @keyframes tray-role-arc-in-landscape { + from { opacity: 0; transform: scale(0.3) translate(-40%, 40%); } + to { opacity: 1; transform: scale(1) translate(0, 0); } + } + + .tray-role-card.arc-in { + animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + @keyframes tray-wobble-landscape { 0%, 100% { transform: translateY(0); } 20% { transform: translateY(-8px); } diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js index cc32d4f..df72887 100644 --- a/src/static_src/tests/RoleSelectSpec.js +++ b/src/static_src/tests/RoleSelectSpec.js @@ -7,7 +7,6 @@ describe("RoleSelect", () => {
-
`; document.body.appendChild(testDiv); window.fetch = jasmine.createSpy("fetch").and.returnValue( @@ -102,12 +101,6 @@ describe("RoleSelect", () => { expect(document.getElementById("id_role_select")).toBeNull(); }); - it("clicking a card appends a .card to #id_inv_role_card", () => { - const card = document.querySelector("#id_role_select .card"); - card.click(); - expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); - }); - it("clicking a card POSTs to the select_role URL", () => { const card = document.querySelector("#id_role_select .card"); card.click(); @@ -117,11 +110,6 @@ describe("RoleSelect", () => { ); }); - it("clicking a card results in exactly one card in inventory", () => { - const card = document.querySelector("#id_role_select .card"); - card.click(); - expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1); - }); }); // ------------------------------------------------------------------ // @@ -135,11 +123,6 @@ describe("RoleSelect", () => { expect(document.getElementById("id_role_select")).toBeNull(); }); - it("does not add a card to inventory", () => { - RoleSelect.openFan(); - document.querySelector(".role-select-backdrop").click(); - expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); - }); }); // ------------------------------------------------------------------ // @@ -259,20 +242,13 @@ describe("RoleSelect", () => { // ------------------------------------------------------------------ // describe("tray card after successful role selection", () => { - let grid, guardConfirm; + let guardConfirm; beforeEach(() => { - // Minimal tray grid matching room.html structure - grid = document.createElement("div"); - grid.id = "id_tray_grid"; - for (let i = 0; i < 8; i++) { - const cell = document.createElement("div"); - cell.className = "tray-cell"; - grid.appendChild(cell); - } - testDiv.appendChild(grid); - - spyOn(Tray, "open"); + // Spy on Tray.placeCard: call the onComplete callback immediately. + spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { + if (cb) cb(); + }); // Capturing guard spy — holds onConfirm so we can fire it per-test window.showGuard = jasmine.createSpy("showGuard").and.callFake( @@ -283,54 +259,128 @@ describe("RoleSelect", () => { document.querySelector("#id_role_select .card").click(); }); - it("prepends a .tray-role-card to #id_tray_grid on success", async () => { + it("calls Tray.placeCard() on success", async () => { guardConfirm(); await Promise.resolve(); - expect(grid.querySelector(".tray-role-card")).not.toBeNull(); + expect(Tray.placeCard).toHaveBeenCalled(); }); - it("tray-role-card is the first child of #id_tray_grid", async () => { + it("passes the role code string to Tray.placeCard", async () => { guardConfirm(); await Promise.resolve(); - expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true); + const roleCode = Tray.placeCard.calls.mostRecent().args[0]; + expect(typeof roleCode).toBe("string"); + expect(roleCode.length).toBeGreaterThan(0); }); - it("tray-role-card carries the selected role as data-role", async () => { - guardConfirm(); - await Promise.resolve(); - const trayCard = grid.querySelector(".tray-role-card"); - expect(trayCard.dataset.role).toBeTruthy(); - }); - - it("calls Tray.open() on success", async () => { - guardConfirm(); - await Promise.resolve(); - expect(Tray.open).toHaveBeenCalled(); - }); - - it("does not prepend a tray-role-card on server rejection", async () => { + it("does not call Tray.placeCard() on server rejection", async () => { window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: false }) ); guardConfirm(); await Promise.resolve(); - expect(grid.querySelector(".tray-role-card")).toBeNull(); + expect(Tray.placeCard).not.toHaveBeenCalled(); }); + }); - it("does not call Tray.open() on server rejection", async () => { - window.fetch = jasmine.createSpy("fetch").and.returnValue( - Promise.resolve({ ok: false }) + // ------------------------------------------------------------------ // + // WS turn_changed pause during animation // + // ------------------------------------------------------------------ // + + describe("WS turn_changed pause during placeCard animation", () => { + let stack, guardConfirm; + + beforeEach(() => { + // Six table seats, slot 1 starts active + for (let i = 1; i <= 6; i++) { + const seat = document.createElement("div"); + seat.className = "table-seat" + (i === 1 ? " active" : ""); + seat.dataset.slot = String(i); + testDiv.appendChild(seat); + } + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "ineligible"; + stack.dataset.userSlots = "1"; + stack.dataset.starterRoles = ""; + testDiv.appendChild(stack); + + const grid = document.createElement("div"); + grid.id = "id_tray_grid"; + testDiv.appendChild(grid); + + // placeCard spy that holds the onComplete callback + let heldCallback = null; + spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { + heldCallback = cb; // don't call immediately — simulate animation in-flight + }); + spyOn(Tray, "forceClose"); + + // Expose heldCallback so tests can fire it + Tray._testFirePlaceCardComplete = () => { + if (heldCallback) heldCallback(); + }; + + window.showGuard = jasmine.createSpy("showGuard").and.callFake( + (anchor, message, onConfirm) => { guardConfirm = onConfirm; } ); - guardConfirm(); - await Promise.resolve(); - expect(Tray.open).not.toHaveBeenCalled(); }); - it("grid grows by exactly 1 on success", async () => { - const before = grid.children.length; + afterEach(() => { + delete Tray._testFirePlaceCardComplete; + RoleSelect._testReset(); + }); + + it("turn_changed during animation does not call Tray.forceClose immediately", async () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); + guardConfirm(); + await Promise.resolve(); // fetch resolves; placeCard called; animation pending + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + expect(Tray.forceClose).not.toHaveBeenCalled(); + }); + + it("turn_changed during animation does not immediately move the active seat", async () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); guardConfirm(); await Promise.resolve(); - expect(grid.children.length).toBe(before + 1); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + const activeSeat = testDiv.querySelector(".table-seat.active"); + expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1 + }); + + it("deferred turn_changed is processed when animation completes", async () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); + guardConfirm(); + await Promise.resolve(); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + + // Fire onComplete — deferred turn_changed should now run + Tray._testFirePlaceCardComplete(); + + const activeSeat = testDiv.querySelector(".table-seat.active"); + expect(activeSeat && activeSeat.dataset.slot).toBe("2"); + }); + + it("turn_changed after animation completes is processed immediately", () => { + // No animation in flight — turn_changed should run right away + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + expect(Tray.forceClose).toHaveBeenCalled(); + const activeSeat = testDiv.querySelector(".table-seat.active"); + expect(activeSeat && activeSeat.dataset.slot).toBe("2"); }); }); @@ -433,9 +483,6 @@ describe("RoleSelect", () => { ); }); - it("appends a .card to #id_inv_role_card", () => { - expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); - }); }); describe("dismissing the guard (NVM or outside click)", () => { @@ -463,10 +510,6 @@ describe("RoleSelect", () => { expect(window.fetch).not.toHaveBeenCalled(); }); - it("does not add a card to inventory", () => { - expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); - }); - it("restores normal mouseleave behaviour on the card", () => { card.dispatchEvent(new MouseEvent("mouseenter")); card.dispatchEvent(new MouseEvent("mouseleave")); diff --git a/src/static_src/tests/TraySpec.js b/src/static_src/tests/TraySpec.js index 264275f..818249a 100644 --- a/src/static_src/tests/TraySpec.js +++ b/src/static_src/tests/TraySpec.js @@ -422,4 +422,97 @@ describe("Tray", () => { expect(Tray.isOpen()).toBe(false); }); }); + + // ---------------------------------------------------------------------- // + // placeCard() // + // ---------------------------------------------------------------------- // + // + // placeCard(roleCode, onComplete): + // 1. Marks the first .tray-cell with .tray-role-card + data-role. + // 2. Opens the tray. + // 3. Arc-in animates the cell (.arc-in class, animationend fires). + // 4. forceClose() — tray closes instantly. + // 5. Calls onComplete. + // + // The grid always has exactly 8 .tray-cell elements (from the template); + // no new elements are inserted. + // + // ---------------------------------------------------------------------- // + + describe("placeCard()", () => { + let grid, firstCell; + + beforeEach(() => { + grid = document.createElement("div"); + grid.id = "id_tray_grid"; + for (let i = 0; i < 8; i++) { + const cell = document.createElement("div"); + cell.className = "tray-cell"; + grid.appendChild(cell); + } + document.body.appendChild(grid); + // Re-init so _grid is set (reset() in outer afterEach clears it) + Tray.init(); + firstCell = grid.querySelector(".tray-cell"); + }); + + afterEach(() => { + grid.remove(); + }); + + it("adds .tray-role-card to the first .tray-cell", () => { + Tray.placeCard("PC", null); + expect(firstCell.classList.contains("tray-role-card")).toBe(true); + }); + + it("sets data-role on the first cell", () => { + Tray.placeCard("NC", null); + expect(firstCell.dataset.role).toBe("NC"); + }); + + it("grid cell count stays at 8", () => { + Tray.placeCard("PC", null); + expect(grid.children.length).toBe(8); + }); + + it("opens the tray", () => { + Tray.placeCard("PC", null); + expect(Tray.isOpen()).toBe(true); + }); + + it("adds .arc-in to the first cell", () => { + Tray.placeCard("PC", null); + expect(firstCell.classList.contains("arc-in")).toBe(true); + }); + + it("removes .arc-in and force-closes after animationend", () => { + Tray.placeCard("PC", null); + expect(Tray.isOpen()).toBe(true); + firstCell.dispatchEvent(new Event("animationend")); + expect(firstCell.classList.contains("arc-in")).toBe(false); + expect(Tray.isOpen()).toBe(false); + }); + + it("calls onComplete after the tray closes", () => { + let called = false; + Tray.placeCard("PC", () => { called = true; }); + firstCell.dispatchEvent(new Event("animationend")); + expect(called).toBe(true); + }); + + it("landscape: same behaviour — first cell gets role card", () => { + Tray._testSetLandscape(true); + Tray.init(); + Tray.placeCard("EC", null); + expect(firstCell.classList.contains("tray-role-card")).toBe(true); + expect(firstCell.dataset.role).toBe("EC"); + }); + + it("reset() removes .tray-role-card and data-role from cells", () => { + Tray.placeCard("PC", null); + Tray.reset(); + expect(firstCell.classList.contains("tray-role-card")).toBe(false); + expect(firstCell.dataset.role).toBeUndefined(); + }); + }); }); diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 7592e5c..e3769ee 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -47,32 +47,6 @@ {% endfor %} {% endif %} -
-
-
- {% if room.table_status == "ROLE_SELECT" %} - {% for seat in assigned_seats %} -
-
?
-
-
{{ seat.get_role_display }}
-
-
- {% endfor %} - {% elif room.table_status == "SIG_SELECT" and user_seat %} -
-
-
{{ user_seat.get_role_display }}
-
-
- {% if partner_seat %} -
- Partner: {{ partner_seat.get_role_display }} -
- {% endif %} - {% endif %} -
-
{% if room.table_status == "SIG_SELECT" and sig_cards %}