2026-04-05 22:01:23 -04:00
|
|
|
|
describe("SigSelect", () => {
|
2026-04-07 00:22:04 -04:00
|
|
|
|
let testDiv, stageCard, card, statBlock;
|
2026-04-05 22:01:23 -04:00
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) {
|
2026-04-05 22:01:23 -04:00
|
|
|
|
testDiv = document.createElement("div");
|
|
|
|
|
|
testDiv.innerHTML = `
|
|
|
|
|
|
<div class="sig-overlay"
|
2026-04-08 11:52:49 -04:00
|
|
|
|
data-polarity="${polarity}"
|
|
|
|
|
|
data-user-role="${userRole}"
|
2026-04-05 22:01:23 -04:00
|
|
|
|
data-reserve-url="/epic/room/test/sig-reserve"
|
2026-04-13 00:34:05 -04:00
|
|
|
|
data-ready-url="/epic/room/test/sig-ready"
|
|
|
|
|
|
|
2026-04-05 22:01:23 -04:00
|
|
|
|
data-reservations="${reservations.replace(/"/g, '"')}">
|
|
|
|
|
|
<div class="sig-modal">
|
|
|
|
|
|
<div class="sig-stage">
|
|
|
|
|
|
<div class="sig-stage-card" style="display:none">
|
|
|
|
|
|
<span class="fan-corner-rank"></span>
|
|
|
|
|
|
<i class="stage-suit-icon"></i>
|
2026-04-28 20:22:19 -04:00
|
|
|
|
<div class="fan-card-face-upright">
|
|
|
|
|
|
<p class="fan-card-name-group"></p>
|
|
|
|
|
|
<p class="sig-qualifier-above"></p>
|
|
|
|
|
|
<h3 class="fan-card-name"></h3>
|
|
|
|
|
|
<p class="sig-qualifier-below"></p>
|
|
|
|
|
|
</div>
|
2026-04-05 22:01:23 -04:00
|
|
|
|
<p class="fan-card-arcana"></p>
|
|
|
|
|
|
<p class="fan-card-correspondence"></p>
|
2026-04-28 20:22:19 -04:00
|
|
|
|
<div class="fan-card-face-reversal">
|
|
|
|
|
|
<p class="fan-card-reversal-name"></p>
|
|
|
|
|
|
<p class="fan-card-reversal-qualifier"></p>
|
|
|
|
|
|
</div>
|
2026-04-05 22:01:23 -04:00
|
|
|
|
</div>
|
2026-04-07 00:22:04 -04:00
|
|
|
|
<div class="sig-stat-block">
|
2026-04-30 21:01:52 -04:00
|
|
|
|
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
|
|
|
|
|
|
<button class="btn btn-info fyi-btn" type="button">FYI</button>
|
2026-04-07 00:22:04 -04:00
|
|
|
|
<div class="stat-face stat-face--upright">
|
2026-04-28 17:18:16 -04:00
|
|
|
|
<p class="stat-face-label">Emanation</p>
|
2026-04-07 00:22:04 -04:00
|
|
|
|
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-face stat-face--reversed">
|
2026-04-28 17:18:16 -04:00
|
|
|
|
<p class="stat-face-label">Reversal</p>
|
2026-04-07 00:22:04 -04:00
|
|
|
|
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
|
|
|
|
|
|
</div>
|
2026-04-30 21:01:52 -04:00
|
|
|
|
<button class="btn btn-nav-left fyi-prev" type="button">◀</button>
|
|
|
|
|
|
<button class="btn btn-nav-right fyi-next" type="button">▶</button>
|
2026-04-28 20:22:19 -04:00
|
|
|
|
<div class="sig-info" id="id_sig_info">
|
|
|
|
|
|
<div class="sig-info-header">
|
|
|
|
|
|
<h4 class="sig-info-title"></h4>
|
|
|
|
|
|
<p class="sig-info-type"></p>
|
2026-04-07 00:22:04 -04:00
|
|
|
|
</div>
|
2026-04-28 20:22:19 -04:00
|
|
|
|
<p class="sig-info-effect"></p>
|
|
|
|
|
|
<span class="sig-info-index"></span>
|
2026-04-07 00:22:04 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-05 22:01:23 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sig-deck-grid">
|
|
|
|
|
|
<div class="sig-card"
|
|
|
|
|
|
data-card-id="42"
|
|
|
|
|
|
data-corner-rank="K"
|
|
|
|
|
|
data-suit-icon=""
|
|
|
|
|
|
data-name-group="Pentacles"
|
|
|
|
|
|
data-name-title="King of Pentacles"
|
|
|
|
|
|
data-arcana="Minor Arcana"
|
2026-04-07 00:22:04 -04:00
|
|
|
|
data-correspondence=""
|
|
|
|
|
|
data-keywords-upright="action,impulsiveness,ambition"
|
|
|
|
|
|
data-keywords-reversed="no direction,disregard for consequences"
|
2026-04-28 20:22:19 -04:00
|
|
|
|
data-energies="[]"
|
|
|
|
|
|
data-operations="[]"
|
2026-04-27 23:52:22 -04:00
|
|
|
|
data-levity-qualifier="Elevated"
|
2026-04-28 16:51:53 -04:00
|
|
|
|
data-gravity-qualifier="Graven"
|
2026-04-30 21:01:52 -04:00
|
|
|
|
data-reversal-qualifier="">
|
2026-04-05 22:01:23 -04:00
|
|
|
|
<div class="fan-card-corner fan-card-corner--tl">
|
|
|
|
|
|
<span class="fan-corner-rank">K</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sig-card-actions">
|
|
|
|
|
|
<button class="sig-ok-btn btn btn-confirm">OK</button>
|
|
|
|
|
|
<button class="sig-nvm-btn btn btn-cancel">NVM</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sig-card-cursors">
|
|
|
|
|
|
<span class="sig-cursor sig-cursor--left"></span>
|
|
|
|
|
|
<span class="sig-cursor sig-cursor--mid"></span>
|
|
|
|
|
|
<span class="sig-cursor sig-cursor--right"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(testDiv);
|
|
|
|
|
|
stageCard = testDiv.querySelector(".sig-stage-card");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
statBlock = testDiv.querySelector(".sig-stat-block");
|
2026-04-05 22:01:23 -04:00
|
|
|
|
card = testDiv.querySelector(".sig-card");
|
|
|
|
|
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
|
|
|
|
|
Promise.resolve({ ok: true })
|
|
|
|
|
|
);
|
|
|
|
|
|
window._roomSocket = { readyState: -1, send: jasmine.createSpy("send") };
|
|
|
|
|
|
SigSelect._testInit();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
if (testDiv) testDiv.remove();
|
|
|
|
|
|
delete window._roomSocket;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Stage reveal on mouseenter ─────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("stage preview", () => {
|
|
|
|
|
|
beforeEach(() => makeFixture());
|
|
|
|
|
|
|
|
|
|
|
|
it("shows the stage card on mouseenter", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.style.display).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("hides the stage card on mouseleave when not frozen", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.style.display).toBe("none");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does NOT hide the stage card on mouseleave when frozen (reserved)", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
SigSelect._setFrozen(true);
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.style.display).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Card focus (click → OK overlay) ───────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("card click", () => {
|
|
|
|
|
|
beforeEach(() => makeFixture());
|
|
|
|
|
|
|
|
|
|
|
|
it("adds .sig-focused to the clicked card", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(card.classList.contains("sig-focused")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("shows the stage card after click", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.style.display).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not focus a card reserved by another role", () => {
|
|
|
|
|
|
card.dataset.reservedBy = "NC";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(card.classList.contains("sig-focused")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Lock after reservation ─────────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("lock after reservation", () => {
|
|
|
|
|
|
beforeEach(() => makeFixture());
|
|
|
|
|
|
|
|
|
|
|
|
it("does not focus another card while one is reserved", () => {
|
|
|
|
|
|
SigSelect._setReservedCardId("99");
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(card.classList.contains("sig-focused")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not call fetch when OK is clicked while a different card is reserved", () => {
|
|
|
|
|
|
SigSelect._setReservedCardId("99");
|
|
|
|
|
|
var okBtn = card.querySelector(".sig-ok-btn");
|
|
|
|
|
|
okBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(window.fetch).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("allows focus again after reservation is cleared", () => {
|
|
|
|
|
|
SigSelect._setReservedCardId("99");
|
|
|
|
|
|
SigSelect._setReservedCardId(null);
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(card.classList.contains("sig-focused")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-04-05 22:32:40 -04:00
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
// ── WS release event (second-browser NVM sync) ────────────────────── //
|
2026-04-05 22:32:40 -04:00
|
|
|
|
|
|
|
|
|
|
describe("WS release event (second-browser NVM sync)", () => {
|
|
|
|
|
|
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
|
|
|
|
|
|
|
|
|
|
|
|
it("removes .sig-reserved and .sig-reserved--own on WS release", () => {
|
|
|
|
|
|
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
|
|
|
|
|
expect(card.classList.contains("sig-reserved")).toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "PC", reserved: false },
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
expect(card.classList.contains("sig-reserved--own")).toBe(false);
|
|
|
|
|
|
expect(card.classList.contains("sig-reserved")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("unfreezes the stage so other cards can be focused after WS release", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "PC", reserved: false },
|
|
|
|
|
|
}));
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(card.classList.contains("sig-focused")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
// ── FYI info panel ────────────────────────────────────────────────── //
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
describe("FYI info panel", () => {
|
|
|
|
|
|
var infoEl, infoEffect, infoTitle, infoType, infoIndex, infoPrev, infoNext, infoBtn;
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
makeFixture();
|
2026-04-28 20:22:19 -04:00
|
|
|
|
infoEl = testDiv.querySelector(".sig-info");
|
|
|
|
|
|
infoEffect = testDiv.querySelector(".sig-info-effect");
|
|
|
|
|
|
infoTitle = testDiv.querySelector(".sig-info-title");
|
|
|
|
|
|
infoType = testDiv.querySelector(".sig-info-type");
|
|
|
|
|
|
infoIndex = testDiv.querySelector(".sig-info-index");
|
2026-04-30 21:01:52 -04:00
|
|
|
|
infoPrev = testDiv.querySelector(".fyi-prev");
|
|
|
|
|
|
infoNext = testDiv.querySelector(".fyi-next");
|
|
|
|
|
|
infoBtn = testDiv.querySelector(".fyi-btn");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); }
|
|
|
|
|
|
function openFYI() { hover(); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-04-30 21:01:52 -04:00
|
|
|
|
it("FYI click adds .fyi-open to the stat block", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("FYI click when btn-disabled does not toggle", () => {
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
|
|
|
|
|
|
infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
});
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("shows placeholder when both energies and operations are empty", () => {
|
|
|
|
|
|
card.dataset.energies = "[]";
|
|
|
|
|
|
card.dataset.operations = "[]";
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoEffect.innerHTML).toContain("No interactions defined");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("renders first energy effect HTML including .card-ref spans", () => {
|
|
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: 'First <span class="card-ref">Card</span> effect.' }
|
|
|
|
|
|
]);
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoEffect.querySelector(".card-ref")).not.toBeNull();
|
|
|
|
|
|
expect(infoEffect.querySelector(".card-ref").textContent).toBe("Card");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:44:50 -04:00
|
|
|
|
it("energy entry sets title to 'Energy' with --energies modifier class", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "NUMEN", effect: "An energy entry." }
|
|
|
|
|
|
]);
|
|
|
|
|
|
openFYI();
|
2026-04-28 20:44:50 -04:00
|
|
|
|
expect(infoTitle.textContent).toBe("Energy");
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(infoTitle.classList.contains("sig-info-title--energies")).toBe(true);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:44:50 -04:00
|
|
|
|
it("operation entry sets title to 'Operation' with --operations modifier class", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.operations = JSON.stringify([
|
|
|
|
|
|
{ type: "COVER", effect: "An operation entry." }
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
2026-04-28 20:44:50 -04:00
|
|
|
|
expect(infoTitle.textContent).toBe("Operation");
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(infoTitle.classList.contains("sig-info-title--operations")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("type element shows the entry type in allcaps", () => {
|
|
|
|
|
|
card.dataset.energies = JSON.stringify([{ type: "VOLUPTAS", effect: "..." }]);
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoType.textContent).toBe("VOLUPTAS");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("energies come before operations in the combined list", () => {
|
|
|
|
|
|
card.dataset.energies = JSON.stringify([{ type: "LIBIDO", effect: "Energy first" }]);
|
|
|
|
|
|
card.dataset.operations = JSON.stringify([{ type: "CROWN", effect: "Op second" }]);
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoEffect.textContent).toContain("Energy first");
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(infoEffect.textContent).toContain("Op second");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("advancing to an operation entry switches title and class to --operations", () => {
|
|
|
|
|
|
card.dataset.energies = JSON.stringify([{ type: "LIBIDO", effect: "E1" }]);
|
|
|
|
|
|
card.dataset.operations = JSON.stringify([{ type: "COVER", effect: "O1" }]);
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
2026-04-28 20:44:50 -04:00
|
|
|
|
expect(infoTitle.textContent).toBe("Operation");
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(infoTitle.classList.contains("sig-info-title--operations")).toBe(true);
|
|
|
|
|
|
expect(infoTitle.classList.contains("sig-info-title--energies")).toBe(false);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("with 1 entry both nav arrows are disabled", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Single." }]);
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoPrev.disabled).toBe(true);
|
|
|
|
|
|
expect(infoNext.disabled).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("with multiple entries both nav arrows are enabled", () => {
|
|
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "C1" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "C2" },
|
|
|
|
|
|
{ type: "VOLUPTAS", effect: "C3" },
|
|
|
|
|
|
{ type: "VOLUPTAS", effect: "C4" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoPrev.disabled).toBe(false);
|
|
|
|
|
|
expect(infoNext.disabled).toBe(false);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("next click advances to second entry", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "First" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "Second" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(infoEffect.innerHTML).toContain("Second");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("next wraps from last entry back to first", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "First" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "Last" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(infoEffect.innerHTML).toContain("First");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("prev click goes back to first entry", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "First" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "Second" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(infoEffect.innerHTML).toContain("First");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("prev wraps from first entry to last", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "First" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "Middle" },
|
|
|
|
|
|
{ type: "VOLUPTAS", effect: "Last" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(infoEffect.innerHTML).toContain("Last");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("index label shows n / total when multiple entries", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "C1" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "C2" },
|
|
|
|
|
|
{ type: "VOLUPTAS", effect: "C3" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoIndex.textContent).toBe("1 / 3");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("index label is empty when only 1 entry", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Only one." }]);
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoIndex.textContent).toBe("");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("card mouseleave closes the info panel", () => {
|
|
|
|
|
|
openFYI();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(false);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 17:18:16 -04:00
|
|
|
|
it("opening again resets to first entry", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.energies = JSON.stringify([
|
|
|
|
|
|
{ type: "LIBIDO", effect: "First" },
|
|
|
|
|
|
{ type: "NUMEN", effect: "Second" },
|
2026-04-28 17:18:16 -04:00
|
|
|
|
]);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
2026-04-07 00:22:04 -04:00
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
2026-04-28 20:22:19 -04:00
|
|
|
|
openFYI();
|
|
|
|
|
|
expect(infoEffect.innerHTML).toContain("First");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("opening info panel adds .btn-disabled and swaps SPIN/FYI labels to ×", () => {
|
|
|
|
|
|
openFYI();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
var flipBtn = testDiv.querySelector(".spin-btn");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
|
|
|
|
|
|
expect(flipBtn.textContent).toBe("×");
|
|
|
|
|
|
expect(infoBtn.textContent).toBe("×");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("closing info panel removes .btn-disabled and restores SPIN/FYI labels", () => {
|
2026-04-30 21:01:52 -04:00
|
|
|
|
var flipBtn = testDiv.querySelector(".spin-btn");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
var origFlip = flipBtn.textContent;
|
2026-04-28 20:22:19 -04:00
|
|
|
|
var origInfo = infoBtn.textContent;
|
|
|
|
|
|
openFYI();
|
2026-04-07 00:22:04 -04:00
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
|
|
|
|
|
expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(infoBtn.classList.contains("btn-disabled")).toBe(false);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
expect(flipBtn.textContent).toBe(origFlip);
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(infoBtn.textContent).toBe(origInfo);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("clicking the info panel closes it", () => {
|
|
|
|
|
|
openFYI();
|
|
|
|
|
|
infoEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(false);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:22:19 -04:00
|
|
|
|
it("SPIN click when info open (btn-disabled) does nothing", () => {
|
|
|
|
|
|
openFYI();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
var flipBtn = testDiv.querySelector(".spin-btn");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
|
2026-04-07 00:22:04 -04:00
|
|
|
|
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 16:51:53 -04:00
|
|
|
|
// ── Stat block: keyword population and SPIN toggle ────────────────── //
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-04-28 16:51:53 -04:00
|
|
|
|
describe("stat block and SPIN", () => {
|
2026-04-07 00:22:04 -04:00
|
|
|
|
beforeEach(() => makeFixture());
|
|
|
|
|
|
|
|
|
|
|
|
it("populates upright keywords when a card is hovered", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
var items = statBlock.querySelectorAll("#id_stat_keywords_upright li");
|
|
|
|
|
|
expect(items.length).toBe(3);
|
|
|
|
|
|
expect(items[0].textContent).toBe("action");
|
|
|
|
|
|
expect(items[1].textContent).toBe("impulsiveness");
|
|
|
|
|
|
expect(items[2].textContent).toBe("ambition");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("populates reversed keywords when a card is hovered", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
var items = statBlock.querySelectorAll("#id_stat_keywords_reversed li");
|
|
|
|
|
|
expect(items.length).toBe(2);
|
|
|
|
|
|
expect(items[0].textContent).toBe("no direction");
|
|
|
|
|
|
expect(items[1].textContent).toBe("disregard for consequences");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 16:51:53 -04:00
|
|
|
|
it("SPIN click adds .is-reversed to the stat block", () => {
|
2026-04-07 00:22:04 -04:00
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
var flipBtn = statBlock.querySelector(".spin-btn");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 16:51:53 -04:00
|
|
|
|
it("second SPIN click removes .is-reversed", () => {
|
2026-04-07 00:22:04 -04:00
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
var flipBtn = statBlock.querySelector(".spin-btn");
|
2026-04-07 00:22:04 -04:00
|
|
|
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("hovering a new card resets .is-reversed", () => {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
2026-04-30 21:01:52 -04:00
|
|
|
|
statBlock.querySelector(".spin-btn").dispatchEvent(
|
2026-04-07 00:22:04 -04:00
|
|
|
|
new MouseEvent("click", { bubbles: true })
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
|
|
|
|
|
|
2026-04-07 00:36:28 -04:00
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
2026-04-07 00:22:04 -04:00
|
|
|
|
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("card with no keywords yields empty lists", () => {
|
|
|
|
|
|
card.dataset.keywordsUpright = "";
|
|
|
|
|
|
card.dataset.keywordsReversed = "";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(statBlock.querySelectorAll("#id_stat_keywords_upright li").length).toBe(0);
|
|
|
|
|
|
expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-04-08 11:52:49 -04:00
|
|
|
|
|
2026-04-28 16:51:53 -04:00
|
|
|
|
// ── SPIN card animation — stage-card reversed state ──────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("SPIN card animation", () => {
|
|
|
|
|
|
function hover() {
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it("SPIN click adds .stage-card--reversed to the stage card", () => {
|
|
|
|
|
|
makeFixture();
|
|
|
|
|
|
hover();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
statBlock.querySelector(".spin-btn")
|
2026-04-28 16:51:53 -04:00
|
|
|
|
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.classList.contains("stage-card--reversed")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("second SPIN click removes .stage-card--reversed", () => {
|
|
|
|
|
|
makeFixture();
|
|
|
|
|
|
hover();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
var flipBtn = statBlock.querySelector(".spin-btn");
|
2026-04-28 16:51:53 -04:00
|
|
|
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.classList.contains("stage-card--reversed")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("hovering a new card resets .stage-card--reversed", () => {
|
|
|
|
|
|
makeFixture();
|
|
|
|
|
|
hover();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
statBlock.querySelector(".spin-btn")
|
2026-04-28 16:51:53 -04:00
|
|
|
|
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.classList.contains("stage-card--reversed")).toBe(true);
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(stageCard.classList.contains("stage-card--reversed")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 21:01:52 -04:00
|
|
|
|
it("non-major with data-reversal-qualifier: reversal-qualifier = suit word, reversal-name = card name", () => {
|
2026-04-28 16:51:53 -04:00
|
|
|
|
makeFixture();
|
2026-04-30 21:01:52 -04:00
|
|
|
|
card.dataset.reversalQualifier = "Nervous";
|
2026-04-28 16:51:53 -04:00
|
|
|
|
hover();
|
2026-04-28 20:09:23 -04:00
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Nervous");
|
2026-04-28 16:51:53 -04:00
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
|
2026-04-28 20:09:23 -04:00
|
|
|
|
.toBe(card.dataset.nameTitle);
|
2026-04-28 16:51:53 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("updateStage() populates fan-card-reversal-qualifier with levity qualifier", () => {
|
|
|
|
|
|
makeFixture({ polarity: "levity", userRole: "PC" });
|
|
|
|
|
|
hover();
|
|
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent)
|
|
|
|
|
|
.toBe("Elevated");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("updateStage() populates fan-card-reversal-qualifier with gravity qualifier", () => {
|
|
|
|
|
|
makeFixture({ polarity: "gravity", userRole: "BC" });
|
|
|
|
|
|
hover();
|
|
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent)
|
|
|
|
|
|
.toBe("Graven");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 21:01:52 -04:00
|
|
|
|
it("non-major with data-reversal-qualifier: suit qualifier on own line, upright name repeated below", () => {
|
2026-04-28 20:09:23 -04:00
|
|
|
|
makeFixture({ polarity: "levity", userRole: "PC" });
|
2026-04-30 21:01:52 -04:00
|
|
|
|
card.dataset.reversalQualifier = "Vacant";
|
2026-04-28 20:09:23 -04:00
|
|
|
|
hover();
|
|
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant");
|
|
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
|
|
|
|
|
|
.toBe(card.dataset.nameTitle);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 20:48:47 -04:00
|
|
|
|
it("major arcana reversed face: title, in qualifier slot (first after spin); qualifier in name slot (second)", () => {
|
2026-04-28 20:09:23 -04:00
|
|
|
|
makeFixture({ polarity: "levity", userRole: "PC" });
|
|
|
|
|
|
card.dataset.arcana = "Major Arcana";
|
2026-04-28 20:22:19 -04:00
|
|
|
|
card.dataset.nameTitle = "The Schizo";
|
2026-04-28 20:09:23 -04:00
|
|
|
|
hover();
|
2026-04-28 20:48:47 -04:00
|
|
|
|
// DOM-second element appears first after card spins — so title goes in qualifier slot
|
|
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("The Schizo,");
|
|
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated");
|
2026-04-28 20:09:23 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 21:01:52 -04:00
|
|
|
|
it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => {
|
2026-04-28 20:22:19 -04:00
|
|
|
|
makeFixture({ polarity: "levity", userRole: "PC" });
|
|
|
|
|
|
// fixture default: Minor Arcana, no reversal word
|
2026-04-28 16:51:53 -04:00
|
|
|
|
hover();
|
2026-04-28 20:22:19 -04:00
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
|
2026-04-29 02:30:59 -04:00
|
|
|
|
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(card.dataset.nameTitle);
|
2026-04-28 16:51:53 -04:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-08 11:52:49 -04:00
|
|
|
|
// ── WS cursor hover (applyHover) ──────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("WS cursor hover", () => {
|
|
|
|
|
|
beforeEach(() => makeFixture());
|
|
|
|
|
|
|
|
|
|
|
|
it("NC hover activates the --mid cursor", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", active: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("SC hover activates the --right cursor", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 42, role: "SC", active: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("own role (PC) hover event is ignored — no cursor activates", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 42, role: "PC", active: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("hover-off removes .active from the cursor", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", active: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", active: false },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("hover on unknown card_id is a no-op", () => {
|
|
|
|
|
|
expect(() => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 9999, role: "NC", active: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
}).not.toThrow();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── WS reservation — data-reserved-by attribute ───────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("WS reservation sets data-reserved-by", () => {
|
|
|
|
|
|
beforeEach(() => makeFixture());
|
|
|
|
|
|
|
|
|
|
|
|
it("peer reservation sets data-reserved-by to the reserving role", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.dataset.reservedBy).toBe("NC");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("peer reservation also adds .sig-reserved class", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.classList.contains("sig-reserved")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("release removes data-reserved-by", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: false },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.dataset.reservedBy).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "PC", reserved: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(card.dataset.reservedBy).toBe("PC");
|
|
|
|
|
|
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", active: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
|
|
|
|
|
|
expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
|
|
|
|
|
|
expect(floatEl).not.toBeNull();
|
|
|
|
|
|
expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
|
|
|
|
|
|
expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("peer release removes the thumbs-up float", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: true },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
|
|
|
|
|
detail: { card_id: 42, role: "NC", reserved: false },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("polarity theming — stage qualifier", () => {
|
2026-04-27 23:52:22 -04:00
|
|
|
|
it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
|
2026-04-08 11:52:49 -04:00
|
|
|
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
2026-04-27 23:52:22 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
|
2026-04-08 11:52:49 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-27 23:52:22 -04:00
|
|
|
|
it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
|
2026-04-08 11:52:49 -04:00
|
|
|
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
|
|
|
|
|
card.dataset.arcana = "Major Arcana";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
2026-04-27 23:52:22 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
|
2026-04-08 11:52:49 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
|
|
|
|
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
|
|
|
|
|
card.dataset.arcana = "Major Arcana";
|
|
|
|
|
|
card.dataset.nameTitle = "The Schizo";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("non-major arcana title has no trailing comma", () => {
|
|
|
|
|
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("gravity non-major card puts 'Graven' in qualifier-above", () => {
|
|
|
|
|
|
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("gravity major arcana card puts 'Graven' in qualifier-below", () => {
|
|
|
|
|
|
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
|
|
|
|
|
card.dataset.arcana = "Major Arcana";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("hovering clears qualifier slots from the previous card", () => {
|
|
|
|
|
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
card.dataset.arcana = "Major Arcana";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
2026-04-27 23:52:22 -04:00
|
|
|
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
|
2026-04-08 11:52:49 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("correspondence field is never populated", () => {
|
|
|
|
|
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
|
|
|
|
|
card.dataset.correspondence = "Il Bagatto (Minchiate)";
|
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
|
|
|
|
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-04-13 00:34:05 -04:00
|
|
|
|
|
|
|
|
|
|
// ── WAIT NVM glow pulse ────────────────────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
|
|
describe("WAIT NVM glow pulse", () => {
|
|
|
|
|
|
let takeSigBtn;
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
jasmine.clock().install();
|
|
|
|
|
|
makeFixture({ reservations: '{"42":"PC"}' });
|
|
|
|
|
|
takeSigBtn = document.getElementById("id_take_sig_btn");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
jasmine.clock().uninstall();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function clickTakeSig() {
|
|
|
|
|
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it("adds .btn-cancel after the first pulse tick (600 ms)", async () => {
|
|
|
|
|
|
await clickTakeSig();
|
|
|
|
|
|
jasmine.clock().tick(601);
|
|
|
|
|
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("sets a non-empty box-shadow after the first pulse tick", async () => {
|
|
|
|
|
|
await clickTakeSig();
|
|
|
|
|
|
jasmine.clock().tick(601);
|
|
|
|
|
|
expect(takeSigBtn.style.boxShadow).not.toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("removes .btn-cancel on the second tick (even / trough)", async () => {
|
|
|
|
|
|
await clickTakeSig();
|
2026-04-28 20:22:19 -04:00
|
|
|
|
jasmine.clock().tick(601);
|
|
|
|
|
|
jasmine.clock().tick(600);
|
2026-04-13 00:34:05 -04:00
|
|
|
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("clears box-shadow on the trough tick", async () => {
|
|
|
|
|
|
await clickTakeSig();
|
|
|
|
|
|
jasmine.clock().tick(601);
|
|
|
|
|
|
jasmine.clock().tick(600);
|
|
|
|
|
|
expect(takeSigBtn.style.boxShadow).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => {
|
|
|
|
|
|
await clickTakeSig();
|
2026-04-28 20:22:19 -04:00
|
|
|
|
jasmine.clock().tick(601);
|
2026-04-13 00:34:05 -04:00
|
|
|
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
|
|
|
|
|
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
|
|
|
|
|
expect(takeSigBtn.style.boxShadow).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("glow does not advance after being stopped", async () => {
|
|
|
|
|
|
await clickTakeSig();
|
2026-04-28 20:22:19 -04:00
|
|
|
|
jasmine.clock().tick(601);
|
2026-04-13 00:34:05 -04:00
|
|
|
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
2026-04-28 20:22:19 -04:00
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
jasmine.clock().tick(600);
|
2026-04-13 00:34:05 -04:00
|
|
|
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
|
|
|
|
|
|
// ── polarity_room_done → tray sequence ─────────────────────────────────── //
|
|
|
|
|
|
//
|
|
|
|
|
|
// After all 3 gamers in the user's polarity confirm TAKE SIG and the
|
|
|
|
|
|
// 12s countdown expires, the server fires room:polarity_room_done. The
|
|
|
|
|
|
// sig-select handler should: (1) play the tray sequence — Tray.placeSig
|
|
|
|
|
|
// with the user's selected stage card; (2) on Tray.placeSig's completion
|
|
|
|
|
|
// callback, dismiss the overlay and show the waiting message. Tray runs
|
|
|
|
|
|
// FIRST, while the overlay is still up, so the slide is visually anchored
|
|
|
|
|
|
// to the sig stage's exit.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Cross-polarity events (the OTHER room finishing while we're still
|
|
|
|
|
|
// selecting) must NOT trigger the sequence.
|
|
|
|
|
|
|
|
|
|
|
|
describe("polarity_room_done → tray sequence", () => {
|
|
|
|
|
|
beforeEach(() => {
|
2026-05-03 22:11:07 -04:00
|
|
|
|
jasmine.clock().install();
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
const center = document.createElement("div");
|
|
|
|
|
|
center.className = "table-center";
|
|
|
|
|
|
document.body.appendChild(center);
|
|
|
|
|
|
makeFixture({ polarity: "levity", userRole: "PC" });
|
|
|
|
|
|
spyOn(Tray, "placeSig");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
2026-05-03 22:11:07 -04:00
|
|
|
|
jasmine.clock().uninstall();
|
|
|
|
|
|
document.querySelectorAll(".table-center, #id_hex_waiting_msg, #id_pick_sky_btn")
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
.forEach((el) => el.remove());
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("calls Tray.placeSig with the stage card when own polarity finishes", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
|
|
|
|
|
|
detail: { polarity: "levity" },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(Tray.placeSig).toHaveBeenCalled();
|
|
|
|
|
|
const arg = Tray.placeSig.calls.mostRecent().args[0];
|
|
|
|
|
|
expect(arg).toBe(stageCard);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does NOT call Tray.placeSig when the OTHER polarity finishes", () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
|
|
|
|
|
|
detail: { polarity: "gravity" },
|
|
|
|
|
|
}));
|
|
|
|
|
|
expect(Tray.placeSig).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-03 22:11:07 -04:00
|
|
|
|
it("dismisses the overlay 2s after Tray.placeSig's callback fires", () => {
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
|
|
|
|
|
|
detail: { polarity: "levity" },
|
|
|
|
|
|
}));
|
2026-05-03 22:11:07 -04:00
|
|
|
|
// Overlay still mounted — dismissal deferred to tray callback + hang.
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
expect(document.querySelector(".sig-overlay")).not.toBe(null);
|
|
|
|
|
|
|
|
|
|
|
|
const cb = Tray.placeSig.calls.mostRecent().args[1];
|
|
|
|
|
|
cb();
|
2026-05-03 22:11:07 -04:00
|
|
|
|
// 1.999s after callback — overlay still up.
|
|
|
|
|
|
jasmine.clock().tick(1999);
|
|
|
|
|
|
expect(document.querySelector(".sig-overlay")).not.toBe(null);
|
|
|
|
|
|
// At 2s — overlay dismissed; waiting msg added.
|
|
|
|
|
|
jasmine.clock().tick(1);
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
expect(document.querySelector(".sig-overlay")).toBe(null);
|
|
|
|
|
|
expect(document.getElementById("id_hex_waiting_msg")).not.toBe(null);
|
|
|
|
|
|
});
|
2026-05-03 22:11:07 -04:00
|
|
|
|
|
|
|
|
|
|
it("does NOT add the waiting msg when pick_sky_btn is already revealed", () => {
|
|
|
|
|
|
// pick_sky_available may fire DURING the tray sequence (other
|
|
|
|
|
|
// polarity finishes first). When the tray callback then hangs +
|
|
|
|
|
|
// dismisses, _settle must check whether PICK SKY is up and skip
|
|
|
|
|
|
// the "Levity appraising…" / "Gravity settling…" message so it
|
|
|
|
|
|
// doesn't co-exist w. the btn.
|
|
|
|
|
|
const btn = document.createElement("button");
|
|
|
|
|
|
btn.id = "id_pick_sky_btn";
|
|
|
|
|
|
btn.style.display = ""; // visible — pick_sky_available fired
|
|
|
|
|
|
document.body.appendChild(btn);
|
|
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
|
|
|
|
|
|
detail: { polarity: "levity" },
|
|
|
|
|
|
}));
|
|
|
|
|
|
const cb = Tray.placeSig.calls.mostRecent().args[1];
|
|
|
|
|
|
cb();
|
|
|
|
|
|
jasmine.clock().tick(2001);
|
|
|
|
|
|
expect(document.querySelector(".sig-overlay")).toBe(null);
|
|
|
|
|
|
expect(document.getElementById("id_hex_waiting_msg")).toBe(null);
|
|
|
|
|
|
});
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
|
});
|
2026-04-05 22:01:23 -04:00
|
|
|
|
});
|