describe("SigSelect", () => { let testDiv, stageCard, card, statBlock; function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `

Emanation

    Reversal

      K
      `; 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 Card 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 name slot (visually top after spin); qualifier in qualifier slot (visually bottom)", () => { makeFixture({ polarity: "levity", userRole: "PC" }); card.dataset.arcana = "Major Arcana"; card.dataset.nameTitle = "The Schizo"; hover(); // Class matches semantic content: title → .fan-card-reversal-name, qualifier → .fan-card-reversal-qualifier expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("The Schizo,"); expect(stageCard.querySelector(".fan-card-reversal-qualifier").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 SAVE 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 CAST 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); }); }); // ── CAST SKY click (post pick_sky_available reveal) ──────────────────── // // // After room:pick_sky_available reveals the hidden #id_pick_sky_btn, a // click on CAST SKY must reload the page — the _sky_overlay.html partial // is only rendered server-side once room.table_status == "SKY_SELECT", so // reloading is the only way to bring its modal + openSky handler into the // DOM. The handler must NOT call Tray.open: the tray was already played // during the polarity_room_done sequence (Tray.placeSig) and re-opening it // here would swap Sky Select for the tray. describe("CAST SKY click (post pick_sky_available)", () => { let pickSkyBtn, reloadSpy; beforeEach(() => { pickSkyBtn = document.createElement("button"); pickSkyBtn.id = "id_pick_sky_btn"; pickSkyBtn.style.display = "none"; document.body.appendChild(pickSkyBtn); makeFixture({ polarity: "levity", userRole: "PC" }); reloadSpy = jasmine.createSpy("reload"); SigSelect.setReload(reloadSpy); spyOn(Tray, "open"); }); afterEach(() => { if (pickSkyBtn) pickSkyBtn.remove(); SigSelect.setReload(function () { window.location.reload(); }); }); it("reloads the page so the sky overlay partial renders", () => { pickSkyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(reloadSpy).toHaveBeenCalled(); }); it("does NOT call Tray.open (tray was already played by Tray.placeSig)", () => { pickSkyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(Tray.open).not.toHaveBeenCalled(); }); }); });