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

This commit is contained in:
Disco DeDisco
2026-03-30 16:42:23 -04:00
parent 299a806862
commit 8b006be138
12 changed files with 553 additions and 374 deletions

View File

@@ -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 {

View File

@@ -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); }

View File

@@ -7,7 +7,6 @@ describe("RoleSelect", () => {
<div class="room-page"
data-select-role-url="/epic/room/test-uuid/select-role">
</div>
<div id="id_inv_role_card"></div>
`;
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"));

View File

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