describe("SigSelect", () => { let testDiv, stageCard, card, statBlock; function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
`; 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", () => { // Simulate a reservation on some other card (not this one) 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 clears NVM in a second browser ─────────────────────── // // Simulates the same gamer having two tabs open: tab B must clear its // .sig-reserved--own when tab A presses NVM (WS release event arrives). // The release payload must carry the card_id so the JS can find the element. describe("WS release event (second-browser NVM sync)", () => { beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' })); it("removes .sig-reserved and .sig-reserved--own on WS release", () => { // Confirm reservation was applied on init expect(card.classList.contains("sig-reserved--own")).toBe(true); expect(card.classList.contains("sig-reserved")).toBe(true); // Tab A presses NVM — tab B receives this WS event with the card_id 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 }, })); // Should now be able to click the card body again card.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(card.classList.contains("sig-focused")).toBe(true); }); }); // ── Caution tooltip (!!) ──────────────────────────────────────────── // describe("caution tooltip", () => { var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn; beforeEach(() => { makeFixture(); cautionTooltip = testDiv.querySelector(".sig-caution-tooltip"); cautionEffect = testDiv.querySelector(".sig-caution-effect"); cautionPrev = testDiv.querySelector(".sig-caution-prev"); cautionNext = testDiv.querySelector(".sig-caution-next"); cautionBtn = testDiv.querySelector(".sig-caution-btn"); }); function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); } function openCaution() { hover(); cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); } it("!! click adds .sig-caution-open to the stage", () => { openCaution(); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); }); it("FYI click when btn-disabled does not close caution", () => { openCaution(); expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); }); it("shows placeholder when both mechanisms and articulations are empty", () => { card.dataset.mechanisms = "[]"; card.dataset.articulations = "[]"; openCaution(); expect(cautionEffect.innerHTML).toContain("No ally interactions defined"); }); it("renders first mechanism effect HTML including .card-ref spans", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: 'First Card effect.' } ]); openCaution(); expect(cautionEffect.querySelector(".card-ref")).not.toBeNull(); expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card"); }); it("with 1 entry both nav arrows are disabled", () => { card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "Single." }]); openCaution(); expect(cautionPrev.disabled).toBe(true); expect(cautionNext.disabled).toBe(true); }); it("with multiple entries both nav arrows are always enabled", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "C1" }, { category: "Mechanism", effect: "C2" }, { category: "Mechanism", effect: "C3" }, { category: "Mechanism", effect: "C4" }, ]); openCaution(); expect(cautionPrev.disabled).toBe(false); expect(cautionNext.disabled).toBe(false); }); it("next click advances to second entry", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "First" }, { category: "Mechanism", effect: "Second" }, ]); openCaution(); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(cautionEffect.innerHTML).toContain("Second"); }); it("next wraps from last entry back to first", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "First" }, { category: "Mechanism", effect: "Last" }, ]); openCaution(); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(cautionEffect.innerHTML).toContain("First"); }); it("prev click goes back to first entry", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "First" }, { category: "Mechanism", effect: "Second" }, ]); openCaution(); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(cautionEffect.innerHTML).toContain("First"); }); it("prev wraps from first entry to last", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "First" }, { category: "Mechanism", effect: "Middle" }, { category: "Mechanism", effect: "Last" }, ]); openCaution(); cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(cautionEffect.innerHTML).toContain("Last"); }); it("index label shows n / total when multiple entries", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "C1" }, { category: "Mechanism", effect: "C2" }, { category: "Mechanism", effect: "C3" }, ]); openCaution(); expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3"); }); it("index label is empty when only 1 entry", () => { card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "Only one." }]); openCaution(); expect(testDiv.querySelector(".sig-caution-index").textContent).toBe(""); }); it("card mouseleave closes the caution", () => { openCaution(); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); }); it("opening again resets to first entry", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "First" }, { category: "Mechanism", effect: "Second" }, ]); openCaution(); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); // Close and reopen card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); openCaution(); expect(cautionEffect.innerHTML).toContain("First"); }); it("opening caution adds .btn-disabled and swaps SPIN/FYI labels to ×", () => { openCaution(); var flipBtn = testDiv.querySelector(".sig-flip-btn"); expect(flipBtn.classList.contains("btn-disabled")).toBe(true); expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); expect(flipBtn.textContent).toBe("\u00D7"); expect(cautionBtn.textContent).toBe("\u00D7"); }); it("closing caution removes .btn-disabled and restores SPIN/FYI labels", () => { var flipBtn = testDiv.querySelector(".sig-flip-btn"); var origFlip = flipBtn.textContent; var origCaution = cautionBtn.textContent; openCaution(); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); expect(flipBtn.classList.contains("btn-disabled")).toBe(false); expect(cautionBtn.classList.contains("btn-disabled")).toBe(false); expect(flipBtn.textContent).toBe(origFlip); expect(cautionBtn.textContent).toBe(origCaution); }); it("clicking the tooltip closes caution", () => { openCaution(); cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); }); it("SPIN click when caution open (btn-disabled) does nothing", () => { openCaution(); var flipBtn = testDiv.querySelector(".sig-flip-btn"); flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-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(".sig-flip-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(".sig-flip-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(".sig-flip-btn").dispatchEvent( new MouseEvent("click", { bubbles: true }) ); expect(statBlock.classList.contains("is-reversed")).toBe(true); // Leave and re-enter (simulates moving to a different card) 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(".sig-flip-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(".sig-flip-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(".sig-flip-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: reversal-qualifier = suit word, reversal-name = card name", () => { makeFixture(); card.dataset.reversal = "Nervous"; hover(); // "Nervous" goes into qualifier slot (own line); upright name reused in name slot expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Nervous"); 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: suit qualifier on own line, upright name repeated below", () => { makeFixture({ polarity: "levity", userRole: "PC" }); card.dataset.reversal = "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 with data-reversal: polarity qualifier still shown alongside reversal name", () => { makeFixture({ polarity: "levity", userRole: "PC" }); card.dataset.arcana = "Major Arcana"; card.dataset.reversal = "Territoriality"; hover(); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Territoriality"); }); it("hovering a card without data-reversal clears the reversal name", () => { makeFixture(); card.dataset.reversal = "Territoriality"; hover(); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); delete card.dataset.reversal; card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); // reversal-name clears because data-reversal is gone; // reversal-qualifier stays (it always mirrors the polarity qualifier) expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(""); }); }); // ── WS cursor hover (applyHover) ──────────────────────────────────────── // // // Fixture polarity = levity, userRole = PC. // POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right] // // Only tests the JS position mapping — colour is CSS-only. 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 ───────────────────────── // // // applyReservation() sets data-reserved-by so the CSS can glow the card in // the reserving gamer's role colour. These tests assert the attribute, not // the colour (CSS variables aren't resolvable in the SpecRunner context). 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", () => { // First, a hover float exists for NC (mid cursor) 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(); // NC then clicks OK — reservation arrives window.dispatchEvent(new CustomEvent("room:sig_reserved", { detail: { card_id: 42, role: "NC", reserved: true }, })); // Thumbs-up replaces hand-pointer 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 ────────────────────────────── // // // On mouseenter, updateStage() injects "Elevated" or "Graven" into the // sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot. // Correspondence field is never populated in sig-select context. describe("polarity theming — stage qualifier", () => { it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => { makeFixture({ polarity: 'levity', userRole: 'PC' }); // data-arcana defaults to "Minor Arcana" in fixture → non-major 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' }); // fixture default: Minor Arcana, "King of Pentacles" 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 })); // Now major — above should be empty, below filled 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 ────────────────────────────────────────────────────── // // // After clicking TAKE SIG (POST ok → isReady=true) a setInterval pulses the // button at 600ms: odd ticks add .btn-cancel + a --terOr outer box-shadow; // even ticks remove both. Uses jasmine.clock() to advance the fake timer. describe("WAIT NVM glow pulse", () => { let takeSigBtn; beforeEach(() => { jasmine.clock().install(); // Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init 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 })); // Flush the fetch .then() so _startWaitNoGlow() is called 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); // peak jasmine.clock().tick(600); // trough 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); // glow is on expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); // Click again → WAIT NVM → fetch unready → _stopWaitNoGlow() 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); // peak takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); // stop jasmine.clock().tick(600); // would be another tick if running expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); }); }); // ── FYI tooltip — mechanisms + articulations data source ──────────────── // // // Sprint 2: the caution tooltip is reworked to draw from data-mechanisms and // data-articulations instead of data-cautions. Entries are {category, effect} // dicts; the category label replaces the old "Caution!" title; the caution-type // reads "Ally Interaction". Shoptalk is absent. describe("FYI from mechanisms + articulations", () => { var cautionEffect, cautionTitle, cautionType, cautionPrev, cautionNext, cautionBtn; beforeEach(() => { makeFixture(); cautionEffect = testDiv.querySelector(".sig-caution-effect"); cautionTitle = testDiv.querySelector(".sig-caution-title"); cautionType = testDiv.querySelector(".sig-caution-type"); cautionPrev = testDiv.querySelector(".sig-caution-prev"); cautionNext = testDiv.querySelector(".sig-caution-next"); cautionBtn = testDiv.querySelector(".sig-caution-btn"); }); function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); } function openFYI() { hover(); cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); } it("caution-type label reads 'Ally Interaction'", () => { openFYI(); expect(cautionType.textContent).toBe("Ally Interaction"); }); it("shows 'No ally interactions defined.' when both lists are empty", () => { card.dataset.mechanisms = "[]"; card.dataset.articulations = "[]"; openFYI(); expect(cautionEffect.textContent).toContain("No ally interactions defined"); }); it("renders first mechanism effect and sets title to its category", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: "The card amplifies adjacent power." } ]); openFYI(); expect(cautionTitle.textContent).toBe("Mechanism"); expect(cautionEffect.textContent).toContain("amplifies adjacent power"); }); it("mechanisms come before articulations in the combined list", () => { card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "First" }]); card.dataset.articulations = JSON.stringify([{ category: "Articulation", effect: "Second" }]); openFYI(); expect(cautionEffect.textContent).toContain("First"); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(cautionEffect.textContent).toContain("Second"); }); it("articulation title is set from its category field", () => { card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "M1" }]); card.dataset.articulations = JSON.stringify([{ category: "Articulation", effect: "A1" }]); openFYI(); cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(cautionTitle.textContent).toBe("Articulation"); }); it("effect HTML is injected (supports .card-ref spans)", () => { card.dataset.mechanisms = JSON.stringify([ { category: "Mechanism", effect: 'Draw The Occultist.' } ]); openFYI(); expect(cautionEffect.querySelector(".card-ref")).not.toBeNull(); expect(cautionEffect.querySelector(".card-ref").textContent).toBe("The Occultist"); }); it("shoptalk element is absent or empty", () => { openFYI(); var shoptalk = testDiv.querySelector(".sig-caution-shoptalk"); // Either removed from DOM or has no visible content expect(!shoptalk || shoptalk.textContent.trim() === "").toBe(true); }); }); });