ROLE SELECT: block role pick without deck; SigSelect qualifier spec fix — TDD

- role-select.js: _showNoDeckWarning(stack) intercepts at openFan() — fan never
  opens when data-equipped-deck=""; warning positioned fixed over card-stack via
  getBoundingClientRect(); .guard-actions wrapper for FYI/.btn-caution + NVM/.btn-cancel
- room.html: card-stack gains data-equipped-deck="{{ equipped_deck_id|default:'' }}"
- room view context: equipped_deck_id added
- _room.scss: .role-no-deck-warning — glass guard style matching #id_guard_portal
- _base.scss + _room.scss: guard portal + no-deck warning opacity 0.5 → 0.75
  (matches .tt tooltip; light-palette handled via --tooltip-bg CSS var)
- RoleSelectSpec.js: 8 Jasmine specs — no-deck (fan blocked, warning, FYI/NVM,
  no duplicate, no POST) + deck-present pass-through; afterEach cleans up warning
- SigSelectSpec.js: card fixture gains data-levity-qualifier + data-gravity-qualifier;
  all "Leavened" expectations updated to "Elevated"

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-27 23:52:22 -04:00
parent fa68c74b51
commit e512e94056
9 changed files with 252 additions and 16 deletions

View File

@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
afterEach(() => {
RoleSelect.closeFan();
testDiv.remove();
const warning = document.querySelector(".role-no-deck-warning");
if (warning) warning.remove();
delete window.showGuard;
});
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
});
});
});
// ------------------------------------------------------------------ //
// No-deck guard //
// data-equipped-deck="" → openFan() blocked; warning shown over stack //
// ------------------------------------------------------------------ //
describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
let stack;
beforeEach(() => {
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
stack.dataset.equippedDeck = ""; // explicitly empty = no deck
testDiv.appendChild(stack);
RoleSelect.openFan(); // intercepted — fan must NOT open
});
it("does not open the fan backdrop", () => {
expect(document.querySelector(".role-select-backdrop")).toBeNull();
});
it("shows .role-no-deck-warning in the DOM", () => {
expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
});
it("warning contains the deck prompt text", () => {
const w = document.querySelector(".role-no-deck-warning");
expect(w.textContent).toContain("Equip card deck before Role select");
});
it("warning has a .btn-caution FYI link to gameboard", () => {
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
expect(btn).not.toBeNull();
expect(btn.tagName).toBe("A");
expect(btn.href).toContain("/gameboard/");
});
it("warning has a .btn-cancel NVM button", () => {
expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
});
it("NVM click removes the warning", () => {
document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
});
it("second openFan() call does not duplicate the warning", () => {
RoleSelect.openFan();
expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
});
it("does not POST to select_role", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
});
describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
beforeEach(() => {
const stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
stack.dataset.equippedDeck = "42"; // non-empty = deck present
testDiv.appendChild(stack);
RoleSelect.openFan();
});
it("opens the fan backdrop normally", () => {
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
});
it("does not show .role-no-deck-warning", () => {
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
});
});
});

View File

@@ -58,7 +58,9 @@ describe("SigSelect", () => {
data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences"
data-cautions="${cardCautions.replace(/"/g, '&quot;')}">
data-cautions="${cardCautions.replace(/"/g, '&quot;')}"
data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">K</span>
</div>
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
// ── Polarity theming — stage qualifier text ────────────────────────────── //
//
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the
// 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 'Leavened' in qualifier-above, qualifier-below empty", () => {
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("Leavened");
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
});
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
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("Leavened");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
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("Leavened");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
it("correspondence field is never populated", () => {