Files
python-tdd/src/static/tests/SigSelectSpec.js
Disco DeDisco 5413e63585 billboard Most Recent Scroll: fix SQLite NULL drop on SIG_READY exclude; pronouns flow FT; Blades middle reversal Nervous → Fickle — TDD
- billboard/views.py _billboard_context: `.exclude(verb=SIG_READY, data__retracted=True)` was silently dropping every SIG_READY event whose data had no `retracted` key — `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL via JSON_EXTRACT, which the SQL engine treats as "row not satisfying WHERE", so the row was excluded. Fix: pull a 100-row buffer w. only the SIG_UNREADY exclude at the SQL level, then post-filter retracted SIG_READY in Python before slicing to 36; PostgreSQL handles the lookup correctly so this is a SQLite-only manifestation that explained intermittent "No events yet" in Most Recent Scroll
- CLAUDE.md gotchas: new entry warning that `.exclude(data__key=value)` / `.filter(data__key=value)` on SQLite JSONField bites on missing keys; if the predicate must require key existence, post-filter in Python
- functional_tests/test_game_kit.py PronounsAppletFlowTest: end-to-end profile-wide pronoun flip — start on per-room billscroll seeing "their" cognates, navigate to Game Kit, click bawlmorese card, assert guard portal active w. "yo/yo/yos" preview, click OK, navigate to billboard + see Most Recent Scroll re-rendered w. "yos", navigate back to billscroll + see same flip; covers the whole render-time-pronoun-resolution path on real DOM
- epic/0008_blades_reversal_fickle.py: rename Middle Arcana Blades reversal_qualifier "Nervous" → "Fickle" (RunPython forward+reverse on arcana=MIDDLE, suit=BLADES, number ∈ {11,12,13,14}); SigSelectSpec.js hardcoded "Nervous" updated to "Fickle" + collected static

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:27:17 -04:00

898 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

describe("SigSelect", () => {
let testDiv, stageCard, card, statBlock;
function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) {
testDiv = document.createElement("div");
testDiv.innerHTML = `
<div class="sig-overlay"
data-polarity="${polarity}"
data-user-role="${userRole}"
data-reserve-url="/epic/room/test/sig-reserve"
data-ready-url="/epic/room/test/sig-ready"
data-reservations="${reservations.replace(/"/g, '&quot;')}">
<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>
<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>
<p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p>
<div class="fan-card-face-reversal">
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="sig-stat-block">
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">&#9664;</button>
<button class="btn btn-nav-right fyi-next" type="button">&#9654;</button>
<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>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
</div>
</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"
data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences"
data-energies="[]"
data-operations="[]"
data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven"
data-reversal-qualifier="">
<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");
statBlock = testDiv.querySelector(".sig-stat-block");
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);
});
});
// ── WS release event (second-browser NVM sync) ────────────────────── //
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);
});
});
// ── FYI info panel ────────────────────────────────────────────────── //
describe("FYI info panel", () => {
var infoEl, infoEffect, infoTitle, infoType, infoIndex, infoPrev, infoNext, infoBtn;
beforeEach(() => {
makeFixture();
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");
infoPrev = testDiv.querySelector(".fyi-prev");
infoNext = testDiv.querySelector(".fyi-next");
infoBtn = testDiv.querySelector(".fyi-btn");
});
function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); }
function openFYI() { hover(); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
it("FYI click adds .fyi-open to the stat block", () => {
openFYI();
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
});
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 }));
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
});
it("shows placeholder when both energies and operations are empty", () => {
card.dataset.energies = "[]";
card.dataset.operations = "[]";
openFYI();
expect(infoEffect.innerHTML).toContain("No interactions defined");
});
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");
});
it("energy entry sets title to 'Energy' with --energies modifier class", () => {
card.dataset.energies = JSON.stringify([
{ type: "NUMEN", effect: "An energy entry." }
]);
openFYI();
expect(infoTitle.textContent).toBe("Energy");
expect(infoTitle.classList.contains("sig-info-title--energies")).toBe(true);
});
it("operation entry sets title to 'Operation' with --operations modifier class", () => {
card.dataset.operations = JSON.stringify([
{ type: "COVER", effect: "An operation entry." }
]);
openFYI();
expect(infoTitle.textContent).toBe("Operation");
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 }));
expect(infoTitle.textContent).toBe("Operation");
expect(infoTitle.classList.contains("sig-info-title--operations")).toBe(true);
expect(infoTitle.classList.contains("sig-info-title--energies")).toBe(false);
});
it("with 1 entry both nav arrows are disabled", () => {
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" },
]);
openFYI();
expect(infoPrev.disabled).toBe(false);
expect(infoNext.disabled).toBe(false);
});
it("next click advances to second entry", () => {
card.dataset.energies = JSON.stringify([
{ type: "LIBIDO", effect: "First" },
{ type: "NUMEN", effect: "Second" },
]);
openFYI();
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(infoEffect.innerHTML).toContain("Second");
});
it("next wraps from last entry back to first", () => {
card.dataset.energies = JSON.stringify([
{ type: "LIBIDO", effect: "First" },
{ type: "NUMEN", effect: "Last" },
]);
openFYI();
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(infoEffect.innerHTML).toContain("First");
});
it("prev click goes back to first entry", () => {
card.dataset.energies = JSON.stringify([
{ type: "LIBIDO", effect: "First" },
{ type: "NUMEN", effect: "Second" },
]);
openFYI();
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(infoEffect.innerHTML).toContain("First");
});
it("prev wraps from first entry to last", () => {
card.dataset.energies = JSON.stringify([
{ type: "LIBIDO", effect: "First" },
{ type: "NUMEN", effect: "Middle" },
{ type: "VOLUPTAS", effect: "Last" },
]);
openFYI();
infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(infoEffect.innerHTML).toContain("Last");
});
it("index label shows n / total when multiple entries", () => {
card.dataset.energies = JSON.stringify([
{ type: "LIBIDO", effect: "C1" },
{ type: "NUMEN", effect: "C2" },
{ type: "VOLUPTAS", effect: "C3" },
]);
openFYI();
expect(infoIndex.textContent).toBe("1 / 3");
});
it("index label is empty when only 1 entry", () => {
card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Only one." }]);
openFYI();
expect(infoIndex.textContent).toBe("");
});
it("card mouseleave closes the info panel", () => {
openFYI();
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(false);
});
it("opening again resets to first entry", () => {
card.dataset.energies = JSON.stringify([
{ type: "LIBIDO", effect: "First" },
{ type: "NUMEN", effect: "Second" },
]);
openFYI();
infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
openFYI();
expect(infoEffect.innerHTML).toContain("First");
});
it("opening info panel adds .btn-disabled and swaps SPIN/FYI labels to ×", () => {
openFYI();
var flipBtn = testDiv.querySelector(".spin-btn");
expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
expect(flipBtn.textContent).toBe("×");
expect(infoBtn.textContent).toBe("×");
});
it("closing info panel removes .btn-disabled and restores SPIN/FYI labels", () => {
var flipBtn = testDiv.querySelector(".spin-btn");
var origFlip = flipBtn.textContent;
var origInfo = infoBtn.textContent;
openFYI();
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
expect(infoBtn.classList.contains("btn-disabled")).toBe(false);
expect(flipBtn.textContent).toBe(origFlip);
expect(infoBtn.textContent).toBe(origInfo);
});
it("clicking the info panel closes it", () => {
openFYI();
infoEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(false);
});
it("SPIN click when info open (btn-disabled) does nothing", () => {
openFYI();
var flipBtn = testDiv.querySelector(".spin-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
expect(statBlock.classList.contains("is-reversed")).toBe(false);
});
});
// ── Stat block: keyword population and SPIN toggle ────────────────── //
describe("stat block and SPIN", () => {
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");
});
it("SPIN click adds .is-reversed to the stat block", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
var flipBtn = statBlock.querySelector(".spin-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(statBlock.classList.contains("is-reversed")).toBe(true);
});
it("second SPIN click removes .is-reversed", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
var flipBtn = statBlock.querySelector(".spin-btn");
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 }));
statBlock.querySelector(".spin-btn").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(statBlock.classList.contains("is-reversed")).toBe(true);
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
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);
});
});
// ── 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();
statBlock.querySelector(".spin-btn")
.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();
var flipBtn = statBlock.querySelector(".spin-btn");
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();
statBlock.querySelector(".spin-btn")
.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);
});
it("non-major with data-reversal-qualifier: reversal-qualifier = suit word, reversal-name = card name", () => {
makeFixture();
card.dataset.reversalQualifier = "Fickle";
hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Fickle");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
.toBe(card.dataset.nameTitle);
});
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");
});
it("non-major with data-reversal-qualifier: suit qualifier on own line, upright name repeated below", () => {
makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.reversalQualifier = "Vacant";
hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
.toBe(card.dataset.nameTitle);
});
it("major arcana reversed face: title, in qualifier slot (first after spin); qualifier in name slot (second)", () => {
makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.arcana = "Major Arcana";
card.dataset.nameTitle = "The Schizo";
hover();
// 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");
});
it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => {
makeFixture({ polarity: "levity", userRole: "PC" });
// fixture default: Minor Arcana, no reversal word
hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(card.dataset.nameTitle);
});
});
// ── 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", () => {
it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' });
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
});
it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
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("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
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("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
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("");
});
});
// ── 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();
jasmine.clock().tick(601);
jasmine.clock().tick(600);
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();
jasmine.clock().tick(601);
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();
jasmine.clock().tick(601);
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
jasmine.clock().tick(600);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
});
});
// ── 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(() => {
jasmine.clock().install();
const center = document.createElement("div");
center.className = "table-center";
document.body.appendChild(center);
makeFixture({ polarity: "levity", userRole: "PC" });
spyOn(Tray, "placeSig");
});
afterEach(() => {
jasmine.clock().uninstall();
document.querySelectorAll(".table-center, #id_hex_waiting_msg, #id_pick_sky_btn")
.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();
});
it("dismisses the overlay 2s after Tray.placeSig's callback fires", () => {
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
detail: { polarity: "levity" },
}));
// Overlay still mounted — dismissal deferred to tray callback + hang.
expect(document.querySelector(".sig-overlay")).not.toBe(null);
const cb = Tray.placeSig.calls.mostRecent().args[1];
cb();
// 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);
expect(document.querySelector(".sig-overlay")).toBe(null);
expect(document.getElementById("id_hex_waiting_msg")).not.toBe(null);
});
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);
});
});
});