SIG SELECT FYI: mechanisms→energies, articulations→operations; .sig-caution→.sig-info; .btn-caution→.btn-info — TDD

- TarotCard.mechanisms renamed to energies, articulations to operations (migration 0008);
  energies_json + operations_json properties replace old names
- migration 0008 also seeds The Schizo (card 1) w. 4 Energies (LIBIDO/NUMEN/VOLUPTAS×2)
  + 4 Operations (COVER/CROWN/BEHIND/BEFORE)
- FYI info panel renamed throughout: .sig-caution-* → .sig-info-*; data-mechanisms →
  data-energies; data-articulations → data-operations
- _renderCaution() now sets dynamic title (Energies/Operations) + .sig-info-title--energies/
  --operations colour modifier; type element shows entry.type (LIBIDO, COVER etc.)
- .btn-caution → .btn-info across note.js, role-select.js, specs, FT + _button-pad.scss rule
- Major arcana reversed face: card title always shown (reversal concept moves to FYI)
- SigSelectSpec.js rewritten: 242 specs; FYI describe block updated for energies/operations

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-28 20:22:19 -04:00
parent 2757ae855f
commit ed55e4e529
15 changed files with 507 additions and 610 deletions

View File

@@ -21,7 +21,7 @@ const Note = (() => {
'</div>' + '</div>' +
'<div class="note-banner__image"></div>' + '<div class="note-banner__image"></div>' +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' + '<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<a href="/billboard/my-notes/" class="btn btn-caution note-banner__fyi">FYI</a>'; '<a href="/billboard/my-notes/" class="btn btn-info note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () { banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove(); banner.remove();

View File

@@ -0,0 +1,55 @@
"""Rename mechanisms→energies and articulations→operations on TarotCard;
seed The Schizo (Earthman major arcana card 1) with Energy and Operation entries.
"""
from django.db import migrations
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": "When encountering territorial Libido, may convert Emanation into 1. The Priest."},
{"type": "NUMEN", "effect": "When encountering despotic Numen, may convert Emanation into 1. The Powerful."},
{"type": "VOLUPTAS", "effect": "When encountering axiomatic Voluptas, may convert Emanation into 1. The Normal."},
{"type": "VOLUPTAS", "effect": "When encountering annihilating Voluptas, may convert Emanation into 1. The Surrendered."},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": "When covering 2. The Occultist she may choose, by converting her own Reversal into 2. Pestilence, to convert this Reversal into 1. The Pervert."},
{"type": "CROWN", "effect": "When crowning 3. The Despot she may choose, by converting her own Reversal into 3. War, to convert this Reversal into 1. The Paranoiac."},
{"type": "BEHIND", "effect": "When behind 4. The Capitalist he may choose, by converting his own Reversal into 4. Famine, to convert this Reversal into 1. The Neurotic."},
{"type": "BEFORE", "effect": "When before 5. The Fascist he may choose, by converting his own Reversal into 5. Death, to convert this Reversal into 1. The Suicidal."},
]
def seed_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0007_populate_middle_arcana_reversals"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -257,8 +257,8 @@ class TarotCard(models.Model):
gravity_emanation = models.CharField(max_length=200, blank=True, default='') gravity_emanation = models.CharField(max_length=200, blank=True, default='')
levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48) levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48)
gravity_reversal = models.CharField(max_length=200, blank=True, default='') gravity_reversal = models.CharField(max_length=200, blank=True, default='')
mechanisms = models.JSONField(default=list) # list of dicts; in-game effects energies = models.JSONField(default=list) # list of {type, effect} dicts — Energy interactions
articulations = models.JSONField(default=list) # list of dicts; combinatory effects operations = models.JSONField(default=list) # list of {type, effect} dicts — Operation interactions
keywords_upright = models.JSONField(default=list) keywords_upright = models.JSONField(default=list)
keywords_reversed = models.JSONField(default=list) keywords_reversed = models.JSONField(default=list)
cautions = models.JSONField(default=list) cautions = models.JSONField(default=list)
@@ -342,14 +342,14 @@ class TarotCard(models.Model):
return json.dumps(self.cautions) return json.dumps(self.cautions)
@property @property
def mechanisms_json(self): def energies_json(self):
import json import json
return json.dumps(self.mechanisms) return json.dumps(self.energies)
@property @property
def articulations_json(self): def operations_json(self):
import json import json
return json.dumps(self.articulations) return json.dumps(self.operations)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -48,7 +48,7 @@ var RoleSelect = (function () {
el.innerHTML = el.innerHTML =
"<p>Equip card deck before Role select</p>" + "<p>Equip card deck before Role select</p>" +
"<div class=\"guard-actions\">" + "<div class=\"guard-actions\">" +
"<a class=\"btn btn-caution\" href=\"/gameboard/\">FYI</a>" + "<a class=\"btn btn-info\" href=\"/gameboard/\">FYI</a>" +
"<button class=\"btn btn-cancel\">NVM</button>" + "<button class=\"btn btn-cancel\">NVM</button>" +
"</div>"; "</div>";
el.querySelector(".btn-cancel").addEventListener("click", function () { el.querySelector(".btn-cancel").addEventListener("click", function () {

View File

@@ -6,7 +6,7 @@ var SigSelect = (function () {
}; };
var overlay, deckGrid, stage, stageCard, statBlock; var overlay, deckGrid, stage, stageCard, statBlock;
var cautionEl, cautionEffect, cautionTitle, cautionPrev, cautionNext, cautionIndexEl; var cautionEl, cautionEffect, cautionTitle, cautionTypeEl, cautionPrev, cautionNext, cautionIndexEl;
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel; var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
var reserveUrl, readyUrl, userRole, userPolarity; var reserveUrl, readyUrl, userRole, userPolarity;
@@ -47,15 +47,20 @@ var SigSelect = (function () {
function _renderCaution() { function _renderCaution() {
if (_cautionData.length === 0) { if (_cautionData.length === 0) {
if (cautionTitle) cautionTitle.textContent = 'Ally Interaction'; cautionTitle.textContent = 'Energies';
cautionEffect.innerHTML = '<em>No ally interactions defined.</em>'; cautionTitle.className = 'sig-info-title sig-info-title--energies';
if (cautionTypeEl) cautionTypeEl.textContent = '';
cautionEffect.innerHTML = '<em>No interactions defined.</em>';
cautionPrev.disabled = true; cautionPrev.disabled = true;
cautionNext.disabled = true; cautionNext.disabled = true;
cautionIndexEl.textContent = ''; cautionIndexEl.textContent = '';
return; return;
} }
var entry = _cautionData[_cautionIdx]; var entry = _cautionData[_cautionIdx];
if (cautionTitle) cautionTitle.textContent = entry.category || ''; var isEnergies = entry.category === 'energies';
cautionTitle.textContent = isEnergies ? 'Energies' : 'Operations';
cautionTitle.className = 'sig-info-title sig-info-title--' + entry.category;
if (cautionTypeEl) cautionTypeEl.textContent = entry.type || '';
cautionEffect.innerHTML = entry.effect || ''; cautionEffect.innerHTML = entry.effect || '';
cautionPrev.disabled = (_cautionData.length <= 1); cautionPrev.disabled = (_cautionData.length <= 1);
cautionNext.disabled = (_cautionData.length <= 1); cautionNext.disabled = (_cautionData.length <= 1);
@@ -67,9 +72,13 @@ var SigSelect = (function () {
function _openCaution() { function _openCaution() {
if (!_focusedCardEl) return; if (!_focusedCardEl) return;
try { try {
var mechanisms = JSON.parse(_focusedCardEl.dataset.mechanisms || '[]'); var energies = JSON.parse(_focusedCardEl.dataset.energies || '[]');
var articulations = JSON.parse(_focusedCardEl.dataset.articulations || '[]'); var operations = JSON.parse(_focusedCardEl.dataset.operations || '[]');
_cautionData = mechanisms.concat(articulations); _cautionData = energies.map(function (e) {
return { type: e.type, effect: e.effect, category: 'energies' };
}).concat(operations.map(function (o) {
return { type: o.type, effect: o.effect, category: 'operations' };
}));
} catch (e) { } catch (e) {
_cautionData = []; _cautionData = [];
} }
@@ -79,11 +88,11 @@ var SigSelect = (function () {
_cautionBtn.classList.add('btn-disabled'); _cautionBtn.classList.add('btn-disabled');
_flipBtn.textContent = '\u00D7'; _flipBtn.textContent = '\u00D7';
_cautionBtn.textContent = '\u00D7'; _cautionBtn.textContent = '\u00D7';
stage.classList.add('sig-caution-open'); stage.classList.add('sig-info-open');
} }
function _closeCaution() { function _closeCaution() {
stage.classList.remove('sig-caution-open'); stage.classList.remove('sig-info-open');
if (_flipBtn) { if (_flipBtn) {
_flipBtn.classList.remove('btn-disabled'); _flipBtn.classList.remove('btn-disabled');
_cautionBtn.classList.remove('btn-disabled'); _cautionBtn.classList.remove('btn-disabled');
@@ -140,7 +149,7 @@ var SigSelect = (function () {
var reversal = cardEl.dataset.reversal || ''; var reversal = cardEl.dataset.reversal || '';
if (isMajor) { if (isMajor) {
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier;
stageCard.querySelector('.fan-card-reversal-name').textContent = reversal; stageCard.querySelector('.fan-card-reversal-name').textContent = title;
} else if (reversal) { } else if (reversal) {
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal; stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal;
stageCard.querySelector('.fan-card-reversal-name').textContent = title; stageCard.querySelector('.fan-card-reversal-name').textContent = title;
@@ -629,7 +638,7 @@ var SigSelect = (function () {
statBlock = stage.querySelector('.sig-stat-block'); statBlock = stage.querySelector('.sig-stat-block');
_flipBtn = statBlock.querySelector('.sig-flip-btn'); _flipBtn = statBlock.querySelector('.sig-flip-btn');
_cautionBtn = statBlock.querySelector('.sig-caution-btn'); _cautionBtn = statBlock.querySelector('.sig-info-btn');
_flipOrigLabel = _flipBtn.textContent; _flipOrigLabel = _flipBtn.textContent;
_cautionOrigLabel = _cautionBtn.textContent; _cautionOrigLabel = _cautionBtn.textContent;
@@ -639,12 +648,13 @@ var SigSelect = (function () {
stageCard.classList.toggle('stage-card--reversed'); stageCard.classList.toggle('stage-card--reversed');
}); });
cautionEl = stage.querySelector('.sig-caution-tooltip'); cautionEl = stage.querySelector('.sig-info');
cautionEffect = cautionEl.querySelector('.sig-caution-effect'); cautionEffect = cautionEl.querySelector('.sig-info-effect');
cautionTitle = cautionEl.querySelector('.sig-caution-title'); cautionTitle = cautionEl.querySelector('.sig-info-title');
cautionPrev = statBlock.querySelector('.sig-caution-prev'); cautionTypeEl = cautionEl.querySelector('.sig-info-type');
cautionNext = statBlock.querySelector('.sig-caution-next'); cautionPrev = statBlock.querySelector('.sig-info-prev');
cautionIndexEl = cautionEl.querySelector('.sig-caution-index'); cautionNext = statBlock.querySelector('.sig-info-next');
cautionIndexEl = cautionEl.querySelector('.sig-info-index');
// Clicking the tooltip (not nav buttons) dismisses it // Clicking the tooltip (not nav buttons) dismisses it
cautionEl.addEventListener('click', function () { cautionEl.addEventListener('click', function () {
@@ -653,7 +663,7 @@ var SigSelect = (function () {
_cautionBtn.addEventListener('click', function () { _cautionBtn.addEventListener('click', function () {
if (_cautionBtn.classList.contains('btn-disabled')) return; if (_cautionBtn.classList.contains('btn-disabled')) return;
stage.classList.contains('sig-caution-open') ? _closeCaution() : _openCaution(); stage.classList.contains('sig-info-open') ? _closeCaution() : _openCaution();
}); });
cautionPrev.addEventListener('click', function () { cautionPrev.addEventListener('click', function () {
_cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length; _cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length;

View File

@@ -152,7 +152,7 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp") banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
banner.find_element(By.CSS_SELECTOR, ".note-banner__image") banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") # FYI
# FYI navigates to Note page # FYI navigates to Note page
fyi.click() fyi.click()

View File

@@ -95,9 +95,9 @@ describe('Note.showBanner', () => {
// ── T8 ── FYI link ──────────────────────────────────────────────────────── // ── T8 ── FYI link ────────────────────────────────────────────────────────
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/my-notes/', () => { it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => {
Note.showBanner(SAMPLE_NOTE); Note.showBanner(SAMPLE_NOTE);
const fyi = document.querySelector('.note-banner .btn.btn-caution'); const fyi = document.querySelector('.note-banner .btn.btn-info');
expect(fyi).not.toBeNull(); expect(fyi).not.toBeNull();
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/'); expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
}); });

View File

@@ -730,8 +730,8 @@ describe("RoleSelect", () => {
expect(w.textContent).toContain("Equip card deck before Role select"); expect(w.textContent).toContain("Equip card deck before Role select");
}); });
it("warning has a .btn-caution FYI link to gameboard", () => { it("warning has a .btn-info FYI link to gameboard", () => {
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution"); const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-info");
expect(btn).not.toBeNull(); expect(btn).not.toBeNull();
expect(btn.tagName).toBe("A"); expect(btn.tagName).toBe("A");
expect(btn.href).toContain("/gameboard/"); expect(btn.href).toContain("/gameboard/");

View File

@@ -1,7 +1,7 @@
describe("SigSelect", () => { describe("SigSelect", () => {
let testDiv, stageCard, card, statBlock; let testDiv, stageCard, card, statBlock;
function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) { function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) {
testDiv = document.createElement("div"); testDiv = document.createElement("div");
testDiv.innerHTML = ` testDiv.innerHTML = `
<div class="sig-overlay" <div class="sig-overlay"
@@ -16,18 +16,22 @@ describe("SigSelect", () => {
<div class="sig-stage-card" style="display:none"> <div class="sig-stage-card" style="display:none">
<span class="fan-corner-rank"></span> <span class="fan-corner-rank"></span>
<i class="stage-suit-icon"></i> <i class="stage-suit-icon"></i>
<p class="fan-card-name-group"></p> <div class="fan-card-face-upright">
<p class="sig-qualifier-above"></p> <p class="fan-card-name-group"></p>
<h3 class="fan-card-name"></h3> <p class="sig-qualifier-above"></p>
<p class="sig-qualifier-below"></p> <h3 class="fan-card-name"></h3>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p> <p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p> <p class="fan-card-correspondence"></p>
<p class="fan-card-reversal-name"></p> <div class="fan-card-face-reversal">
<p class="fan-card-reversal-qualifier"></p> <p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div> </div>
<div class="sig-stat-block"> <div class="sig-stat-block">
<button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button> <button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button>
<button class="btn btn-caution sig-caution-btn" type="button">!!</button> <button class="btn btn-info sig-info-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright"> <div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p> <p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul> <ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
@@ -36,15 +40,15 @@ describe("SigSelect", () => {
<p class="stat-face-label">Reversal</p> <p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul> <ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div> </div>
<button class="btn btn-nav-left sig-caution-prev" type="button">&#9664;</button> <button class="btn btn-nav-left sig-info-prev" type="button">&#9664;</button>
<button class="btn btn-nav-right sig-caution-next" type="button">&#9654;</button> <button class="btn btn-nav-right sig-info-next" type="button">&#9654;</button>
<div class="sig-caution-tooltip" id="id_sig_caution"> <div class="sig-info" id="id_sig_info">
<div class="sig-caution-header"> <div class="sig-info-header">
<h4 class="sig-caution-title"></h4> <h4 class="sig-info-title"></h4>
<span class="sig-caution-type">Ally Interaction</span> <p class="sig-info-type"></p>
</div> </div>
<p class="sig-caution-effect"></p> <p class="sig-info-effect"></p>
<span class="sig-caution-index"></span> <span class="sig-info-index"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -59,9 +63,8 @@ describe("SigSelect", () => {
data-correspondence="" data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition" data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences" data-keywords-reversed="no direction,disregard for consequences"
data-cautions="${cardCautions.replace(/"/g, '&quot;')}" data-energies="[]"
data-mechanisms="[]" data-operations="[]"
data-articulations="[]"
data-levity-qualifier="Elevated" data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven" data-gravity-qualifier="Graven"
data-reversal=""> data-reversal="">
@@ -150,7 +153,6 @@ describe("SigSelect", () => {
beforeEach(() => makeFixture()); beforeEach(() => makeFixture());
it("does not focus another card while one is reserved", () => { it("does not focus another card while one is reserved", () => {
// Simulate a reservation on some other card (not this one)
SigSelect._setReservedCardId("99"); SigSelect._setReservedCardId("99");
card.dispatchEvent(new MouseEvent("click", { bubbles: true })); card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(card.classList.contains("sig-focused")).toBe(false); expect(card.classList.contains("sig-focused")).toBe(false);
@@ -171,20 +173,15 @@ describe("SigSelect", () => {
}); });
}); });
// ── WS release clears NVM in a second browser ────────────────────── // // ── WS release event (second-browser NVM sync) ────────────────────── //
// 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)", () => { describe("WS release event (second-browser NVM sync)", () => {
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' })); beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
it("removes .sig-reserved and .sig-reserved--own on WS release", () => { 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--own")).toBe(true);
expect(card.classList.contains("sig-reserved")).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", { window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false }, detail: { card_id: 42, role: "PC", reserved: false },
})); }));
@@ -197,194 +194,231 @@ describe("SigSelect", () => {
window.dispatchEvent(new CustomEvent("room:sig_reserved", { window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false }, 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 })); card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(card.classList.contains("sig-focused")).toBe(true); expect(card.classList.contains("sig-focused")).toBe(true);
}); });
}); });
// ── Caution tooltip (!!) ──────────────────────────────────────────── // // ── FYI info panel ────────────────────────────────────────────────── //
describe("caution tooltip", () => { describe("FYI info panel", () => {
var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn; var infoEl, infoEffect, infoTitle, infoType, infoIndex, infoPrev, infoNext, infoBtn;
beforeEach(() => { beforeEach(() => {
makeFixture(); makeFixture();
cautionTooltip = testDiv.querySelector(".sig-caution-tooltip"); infoEl = testDiv.querySelector(".sig-info");
cautionEffect = testDiv.querySelector(".sig-caution-effect"); infoEffect = testDiv.querySelector(".sig-info-effect");
cautionPrev = testDiv.querySelector(".sig-caution-prev"); infoTitle = testDiv.querySelector(".sig-info-title");
cautionNext = testDiv.querySelector(".sig-caution-next"); infoType = testDiv.querySelector(".sig-info-type");
cautionBtn = testDiv.querySelector(".sig-caution-btn"); infoIndex = testDiv.querySelector(".sig-info-index");
infoPrev = testDiv.querySelector(".sig-info-prev");
infoNext = testDiv.querySelector(".sig-info-next");
infoBtn = testDiv.querySelector(".sig-info-btn");
}); });
function hover() { function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); }
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); function openFYI() { hover(); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
}
function openCaution() { it("FYI click adds .sig-info-open to the stage", () => {
hover(); openFYI();
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(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", () => { it("FYI click when btn-disabled does not toggle", () => {
openCaution(); openFYI();
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
}); });
it("shows placeholder when both mechanisms and articulations are empty", () => { it("shows placeholder when both energies and operations are empty", () => {
card.dataset.mechanisms = "[]"; card.dataset.energies = "[]";
card.dataset.articulations = "[]"; card.dataset.operations = "[]";
openCaution(); openFYI();
expect(cautionEffect.innerHTML).toContain("No ally interactions defined"); expect(infoEffect.innerHTML).toContain("No interactions defined");
}); });
it("renders first mechanism effect HTML including .card-ref spans", () => { it("renders first energy effect HTML including .card-ref spans", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: 'First <span class="card-ref">Card</span> effect.' } { type: "LIBIDO", effect: 'First <span class="card-ref">Card</span> effect.' }
]); ]);
openCaution(); openFYI();
expect(cautionEffect.querySelector(".card-ref")).not.toBeNull(); expect(infoEffect.querySelector(".card-ref")).not.toBeNull();
expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card"); expect(infoEffect.querySelector(".card-ref").textContent).toBe("Card");
});
it("energy entry sets title to 'Energies' with --energies modifier class", () => {
card.dataset.energies = JSON.stringify([
{ type: "NUMEN", effect: "An energy entry." }
]);
openFYI();
expect(infoTitle.textContent).toBe("Energies");
expect(infoTitle.classList.contains("sig-info-title--energies")).toBe(true);
});
it("operation entry sets title to 'Operations' with --operations modifier class", () => {
card.dataset.operations = JSON.stringify([
{ type: "COVER", effect: "An operation entry." }
]);
openFYI();
expect(infoTitle.textContent).toBe("Operations");
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("Operations");
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", () => { it("with 1 entry both nav arrows are disabled", () => {
card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "Single." }]); card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Single." }]);
openCaution(); openFYI();
expect(cautionPrev.disabled).toBe(true); expect(infoPrev.disabled).toBe(true);
expect(cautionNext.disabled).toBe(true); expect(infoNext.disabled).toBe(true);
}); });
it("with multiple entries both nav arrows are always enabled", () => { it("with multiple entries both nav arrows are enabled", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "C1" }, { type: "LIBIDO", effect: "C1" },
{ category: "Mechanism", effect: "C2" }, { type: "NUMEN", effect: "C2" },
{ category: "Mechanism", effect: "C3" }, { type: "VOLUPTAS", effect: "C3" },
{ category: "Mechanism", effect: "C4" }, { type: "VOLUPTAS", effect: "C4" },
]); ]);
openCaution(); openFYI();
expect(cautionPrev.disabled).toBe(false); expect(infoPrev.disabled).toBe(false);
expect(cautionNext.disabled).toBe(false); expect(infoNext.disabled).toBe(false);
}); });
it("next click advances to second entry", () => { it("next click advances to second entry", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Second" }, { type: "NUMEN", effect: "Second" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("Second"); expect(infoEffect.innerHTML).toContain("Second");
}); });
it("next wraps from last entry back to first", () => { it("next wraps from last entry back to first", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Last" }, { type: "NUMEN", effect: "Last" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("First"); expect(infoEffect.innerHTML).toContain("First");
}); });
it("prev click goes back to first entry", () => { it("prev click goes back to first entry", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Second" }, { type: "NUMEN", effect: "Second" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("First"); expect(infoEffect.innerHTML).toContain("First");
}); });
it("prev wraps from first entry to last", () => { it("prev wraps from first entry to last", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Middle" }, { type: "NUMEN", effect: "Middle" },
{ category: "Mechanism", effect: "Last" }, { type: "VOLUPTAS", effect: "Last" },
]); ]);
openCaution(); openFYI();
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("Last"); expect(infoEffect.innerHTML).toContain("Last");
}); });
it("index label shows n / total when multiple entries", () => { it("index label shows n / total when multiple entries", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "C1" }, { type: "LIBIDO", effect: "C1" },
{ category: "Mechanism", effect: "C2" }, { type: "NUMEN", effect: "C2" },
{ category: "Mechanism", effect: "C3" }, { type: "VOLUPTAS", effect: "C3" },
]); ]);
openCaution(); openFYI();
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3"); expect(infoIndex.textContent).toBe("1 / 3");
}); });
it("index label is empty when only 1 entry", () => { it("index label is empty when only 1 entry", () => {
card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "Only one." }]); card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Only one." }]);
openCaution(); openFYI();
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe(""); expect(infoIndex.textContent).toBe("");
}); });
it("card mouseleave closes the caution", () => { it("card mouseleave closes the info panel", () => {
openCaution(); openFYI();
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(false);
}); });
it("opening again resets to first entry", () => { it("opening again resets to first entry", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Second" }, { type: "NUMEN", effect: "Second" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Close and reopen
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
openCaution(); openFYI();
expect(cautionEffect.innerHTML).toContain("First"); expect(infoEffect.innerHTML).toContain("First");
}); });
it("opening caution adds .btn-disabled and swaps SPIN/FYI labels to ×", () => { it("opening info panel adds .btn-disabled and swaps SPIN/FYI labels to ×", () => {
openCaution(); openFYI();
var flipBtn = testDiv.querySelector(".sig-flip-btn"); var flipBtn = testDiv.querySelector(".sig-flip-btn");
expect(flipBtn.classList.contains("btn-disabled")).toBe(true); expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
expect(flipBtn.textContent).toBe("\u00D7"); expect(flipBtn.textContent).toBe("×");
expect(cautionBtn.textContent).toBe("\u00D7"); expect(infoBtn.textContent).toBe("×");
}); });
it("closing caution removes .btn-disabled and restores SPIN/FYI labels", () => { it("closing info panel removes .btn-disabled and restores SPIN/FYI labels", () => {
var flipBtn = testDiv.querySelector(".sig-flip-btn"); var flipBtn = testDiv.querySelector(".sig-flip-btn");
var origFlip = flipBtn.textContent; var origFlip = flipBtn.textContent;
var origCaution = cautionBtn.textContent; var origInfo = infoBtn.textContent;
openCaution(); openFYI();
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(flipBtn.classList.contains("btn-disabled")).toBe(false); expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
expect(cautionBtn.classList.contains("btn-disabled")).toBe(false); expect(infoBtn.classList.contains("btn-disabled")).toBe(false);
expect(flipBtn.textContent).toBe(origFlip); expect(flipBtn.textContent).toBe(origFlip);
expect(cautionBtn.textContent).toBe(origCaution); expect(infoBtn.textContent).toBe(origInfo);
}); });
it("clicking the tooltip closes caution", () => { it("clicking the info panel closes it", () => {
openCaution(); openFYI();
cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(false);
}); });
it("SPIN click when caution open (btn-disabled) does nothing", () => { it("SPIN click when info open (btn-disabled) does nothing", () => {
openCaution(); openFYI();
var flipBtn = testDiv.querySelector(".sig-flip-btn"); var flipBtn = testDiv.querySelector(".sig-flip-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
expect(statBlock.classList.contains("is-reversed")).toBe(false); expect(statBlock.classList.contains("is-reversed")).toBe(false);
}); });
}); });
@@ -433,7 +467,6 @@ describe("SigSelect", () => {
); );
expect(statBlock.classList.contains("is-reversed")).toBe(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("mouseleave", { bubbles: true }));
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(statBlock.classList.contains("is-reversed")).toBe(false); expect(statBlock.classList.contains("is-reversed")).toBe(false);
@@ -487,7 +520,6 @@ describe("SigSelect", () => {
makeFixture(); makeFixture();
card.dataset.reversal = "Nervous"; card.dataset.reversal = "Nervous";
hover(); 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-qualifier").textContent).toBe("Nervous");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent) expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
.toBe(card.dataset.nameTitle); .toBe(card.dataset.nameTitle);
@@ -516,34 +548,25 @@ describe("SigSelect", () => {
.toBe(card.dataset.nameTitle); .toBe(card.dataset.nameTitle);
}); });
it("major arcana with data-reversal: polarity qualifier still shown alongside reversal name", () => { it("major arcana reversed face: polarity qualifier + card title (concept name in FYI)", () => {
makeFixture({ polarity: "levity", userRole: "PC" }); makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dataset.reversal = "Territoriality"; card.dataset.nameTitle = "The Schizo";
hover(); hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Territoriality"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("The Schizo");
}); });
it("hovering a card without data-reversal clears the reversal name", () => { it("non-major without data-reversal: reversal-name empty, qualifier mirrors polarity", () => {
makeFixture(); makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.reversal = "Territoriality"; // fixture default: Minor Arcana, no reversal word
hover(); hover();
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
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(""); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("");
}); });
}); });
// ── WS cursor hover (applyHover) ──────────────────────────────────────── // // ── 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", () => { describe("WS cursor hover", () => {
beforeEach(() => makeFixture()); beforeEach(() => makeFixture());
@@ -589,10 +612,6 @@ describe("SigSelect", () => {
}); });
// ── WS reservation — data-reserved-by attribute ───────────────────────── // // ── 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", () => { describe("WS reservation sets data-reserved-by", () => {
beforeEach(() => makeFixture()); beforeEach(() => makeFixture());
@@ -630,19 +649,16 @@ describe("SigSelect", () => {
}); });
it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => { 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", { window.dispatchEvent(new CustomEvent("room:sig_hover", {
detail: { card_id: 42, role: "NC", active: true }, detail: { card_id: 42, role: "NC", active: true },
})); }));
expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull(); expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
expect(document.querySelector('.fa-hand-pointer[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", { window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "NC", reserved: true }, detail: { card_id: 42, role: "NC", reserved: true },
})); }));
// Thumbs-up replaces hand-pointer
const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]'); const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
expect(floatEl).not.toBeNull(); expect(floatEl).not.toBeNull();
expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true); expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
@@ -663,15 +679,10 @@ describe("SigSelect", () => {
}); });
// ── Polarity theming — stage qualifier text ────────────────────────────── // // ── 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", () => { describe("polarity theming — stage qualifier", () => {
it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => { it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
// data-arcana defaults to "Minor Arcana" in fixture → non-major
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated"); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
@@ -695,7 +706,6 @@ describe("SigSelect", () => {
it("non-major arcana title has no trailing comma", () => { it("non-major arcana title has no trailing comma", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
// fixture default: Minor Arcana, "King of Pentacles"
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles"); expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
}); });
@@ -719,7 +729,6 @@ describe("SigSelect", () => {
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
card.dispatchEvent(new MouseEvent("mouseenter", { 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-above").textContent).toBe("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
}); });
@@ -733,17 +742,12 @@ describe("SigSelect", () => {
}); });
// ── WAIT NVM glow pulse ────────────────────────────────────────────────────── // // ── 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", () => { describe("WAIT NVM glow pulse", () => {
let takeSigBtn; let takeSigBtn;
beforeEach(() => { beforeEach(() => {
jasmine.clock().install(); jasmine.clock().install();
// Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init
makeFixture({ reservations: '{"42":"PC"}' }); makeFixture({ reservations: '{"42":"PC"}' });
takeSigBtn = document.getElementById("id_take_sig_btn"); takeSigBtn = document.getElementById("id_take_sig_btn");
}); });
@@ -754,7 +758,6 @@ describe("SigSelect", () => {
async function clickTakeSig() { async function clickTakeSig() {
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Flush the fetch .then() so _startWaitNoGlow() is called
await Promise.resolve(); await Promise.resolve();
} }
@@ -772,8 +775,8 @@ describe("SigSelect", () => {
it("removes .btn-cancel on the second tick (even / trough)", async () => { it("removes .btn-cancel on the second tick (even / trough)", async () => {
await clickTakeSig(); await clickTakeSig();
jasmine.clock().tick(601); // peak jasmine.clock().tick(601);
jasmine.clock().tick(600); // trough jasmine.clock().tick(600);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
}); });
@@ -786,10 +789,9 @@ describe("SigSelect", () => {
it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => { it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => {
await clickTakeSig(); await clickTakeSig();
jasmine.clock().tick(601); // glow is on jasmine.clock().tick(601);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
// Click again → WAIT NVM → fetch unready → _stopWaitNoGlow()
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
@@ -799,94 +801,11 @@ describe("SigSelect", () => {
it("glow does not advance after being stopped", async () => { it("glow does not advance after being stopped", async () => {
await clickTakeSig(); await clickTakeSig();
jasmine.clock().tick(601); // peak jasmine.clock().tick(601);
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve(); // stop await Promise.resolve();
jasmine.clock().tick(600); // would be another tick if running jasmine.clock().tick(600);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); 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 <span class="card-ref">The Occultist</span>.' }
]);
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);
});
});
}); });

View File

@@ -143,7 +143,7 @@
} }
// FYI btn // FYI btn
&.btn-caution { &.btn-info {
color: rgba(var(--priYl), 1); color: rgba(var(--priYl), 1);
border-color: rgba(var(--priYl), 1); border-color: rgba(var(--priYl), 1);
background-color: rgba(var(--terYl), 1); background-color: rgba(var(--terYl), 1);

View File

@@ -277,7 +277,7 @@ html:has(.sig-backdrop) {
z-index: 50; z-index: 50;
} }
.sig-caution-btn { .sig-info-btn {
position: absolute; position: absolute;
top: 1.25rem; top: 1.25rem;
right: -1rem; right: -1rem;
@@ -286,7 +286,7 @@ html:has(.sig-backdrop) {
} }
// Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons. // Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons.
.sig-caution-tooltip { .sig-info-tooltip {
display: none; display: none;
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -301,20 +301,21 @@ html:has(.sig-backdrop) {
overflow-y: auto; overflow-y: auto;
} }
.sig-caution-header { .sig-info-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.1rem; gap: 0.1rem;
} }
.sig-caution-title { .sig-info-title {
font-size: calc(var(--sig-card-w, 120px) * 0.093); font-size: calc(var(--sig-card-w, 120px) * 0.093);
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: rgba(var(--priYl), 1); &--energies { color: rgba(var(--terUser), 1); }
&--operations { color: rgba(var(--quaUser), 1); }
} }
.sig-caution-type { .sig-info-type {
font-size: calc(var(--sig-card-w, 120px) * 0.058); font-size: calc(var(--sig-card-w, 120px) * 0.058);
opacity: 0.7; opacity: 0.7;
text-transform: uppercase; text-transform: uppercase;
@@ -322,14 +323,7 @@ html:has(.sig-backdrop) {
flex-shrink: 0; flex-shrink: 0;
} }
.sig-caution-shoptalk { .sig-info-effect {
font-size: calc(var(--sig-card-w, 120px) * 0.063);
opacity: 0.55;
margin: 0;
font-style: italic;
}
.sig-caution-effect {
flex: 1; flex: 1;
font-size: calc(var(--sig-card-w, 120px) * 0.075); font-size: calc(var(--sig-card-w, 120px) * 0.075);
margin: 0; margin: 0;
@@ -341,22 +335,22 @@ html:has(.sig-backdrop) {
} }
} }
.sig-caution-index { .sig-info-index {
font-size: calc(var(--sig-card-w, 120px) * 0.063); font-size: calc(var(--sig-card-w, 120px) * 0.063);
opacity: 0.55; opacity: 0.55;
} }
// Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70) // Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70)
.sig-caution-prev, .sig-info-prev,
.sig-caution-next { .sig-info-next {
display: none; display: none;
position: absolute; position: absolute;
bottom: -1rem; bottom: -1rem;
margin: 0; margin: 0;
z-index: 70; z-index: 70;
} }
.sig-caution-prev { left: -1rem; } .sig-info-prev { left: -1rem; }
.sig-caution-next { right: -1rem; } .sig-info-next { right: -1rem; }
.stat-face { .stat-face {
display: none; display: none;
@@ -395,9 +389,9 @@ html:has(.sig-backdrop) {
} }
&.sig-stage--frozen .sig-stat-block { display: block; } &.sig-stage--frozen .sig-stat-block { display: block; }
&.sig-caution-open .sig-stat-block { &.sig-info-open .sig-stat-block {
.sig-caution-tooltip { display: flex; } .sig-info-tooltip { display: flex; }
.sig-caution-prev, .sig-caution-next { display: inline-flex; } .sig-info-prev, .sig-info-next { display: inline-flex; }
} }
} }
@@ -613,8 +607,8 @@ html:has(.sig-backdrop) {
.sig-qualifier-above, .sig-qualifier-above,
.sig-qualifier-below { color: rgba(var(--quiUser), 1); } .sig-qualifier-below { color: rgba(var(--quiUser), 1); }
// card-ref spans inside the caution tooltip — must match the base rule's // card-ref spans inside the caution tooltip — must match the base rule's
// .sig-stat-block .sig-caution-effect .card-ref specificity (0,3,0) to win. // .sig-stat-block .sig-info-effect .card-ref specificity (0,3,0) to win.
.sig-caution-effect .card-ref { color: rgba(var(--quiUser), 1); } .sig-info-effect .card-ref { color: rgba(var(--quiUser), 1); }
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements) // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
} }
.sig-overlay[data-polarity="gravity"] { .sig-overlay[data-polarity="gravity"] {
@@ -626,7 +620,7 @@ html:has(.sig-backdrop) {
} }
// Caution tooltip: --tooltip-bg is black so priUser text (dark) would be invisible — // Caution tooltip: --tooltip-bg is black so priUser text (dark) would be invisible —
// override to secUser (light) so body text reads against the dark backdrop. // override to secUser (light) so body text reads against the dark backdrop.
.sig-caution-tooltip { color: rgba(var(--secUser), 1); } .sig-info-tooltip { color: rgba(var(--secUser), 1); }
// Polarity qualifier: terUser for gravity (quiUser is levity's equivalent) // Polarity qualifier: terUser for gravity (quiUser is levity's equivalent)
.sig-qualifier-above, .sig-qualifier-above,
.sig-qualifier-below { color: rgba(var(--terUser), 1); } .sig-qualifier-below { color: rgba(var(--terUser), 1); }

View File

@@ -95,9 +95,9 @@ describe('Note.showBanner', () => {
// ── T8 ── FYI link ──────────────────────────────────────────────────────── // ── T8 ── FYI link ────────────────────────────────────────────────────────
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/my-notes/', () => { it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => {
Note.showBanner(SAMPLE_NOTE); Note.showBanner(SAMPLE_NOTE);
const fyi = document.querySelector('.note-banner .btn.btn-caution'); const fyi = document.querySelector('.note-banner .btn.btn-info');
expect(fyi).not.toBeNull(); expect(fyi).not.toBeNull();
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/'); expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
}); });

View File

@@ -730,8 +730,8 @@ describe("RoleSelect", () => {
expect(w.textContent).toContain("Equip card deck before Role select"); expect(w.textContent).toContain("Equip card deck before Role select");
}); });
it("warning has a .btn-caution FYI link to gameboard", () => { it("warning has a .btn-info FYI link to gameboard", () => {
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution"); const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-info");
expect(btn).not.toBeNull(); expect(btn).not.toBeNull();
expect(btn.tagName).toBe("A"); expect(btn.tagName).toBe("A");
expect(btn.href).toContain("/gameboard/"); expect(btn.href).toContain("/gameboard/");

View File

@@ -1,7 +1,7 @@
describe("SigSelect", () => { describe("SigSelect", () => {
let testDiv, stageCard, card, statBlock; let testDiv, stageCard, card, statBlock;
function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) { function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) {
testDiv = document.createElement("div"); testDiv = document.createElement("div");
testDiv.innerHTML = ` testDiv.innerHTML = `
<div class="sig-overlay" <div class="sig-overlay"
@@ -16,18 +16,22 @@ describe("SigSelect", () => {
<div class="sig-stage-card" style="display:none"> <div class="sig-stage-card" style="display:none">
<span class="fan-corner-rank"></span> <span class="fan-corner-rank"></span>
<i class="stage-suit-icon"></i> <i class="stage-suit-icon"></i>
<p class="fan-card-name-group"></p> <div class="fan-card-face-upright">
<p class="sig-qualifier-above"></p> <p class="fan-card-name-group"></p>
<h3 class="fan-card-name"></h3> <p class="sig-qualifier-above"></p>
<p class="sig-qualifier-below"></p> <h3 class="fan-card-name"></h3>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p> <p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p> <p class="fan-card-correspondence"></p>
<p class="fan-card-reversal-name"></p> <div class="fan-card-face-reversal">
<p class="fan-card-reversal-qualifier"></p> <p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div> </div>
<div class="sig-stat-block"> <div class="sig-stat-block">
<button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button> <button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button>
<button class="btn btn-caution sig-caution-btn" type="button">!!</button> <button class="btn btn-info sig-info-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright"> <div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p> <p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul> <ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
@@ -36,15 +40,15 @@ describe("SigSelect", () => {
<p class="stat-face-label">Reversal</p> <p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul> <ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div> </div>
<button class="btn btn-nav-left sig-caution-prev" type="button">&#9664;</button> <button class="btn btn-nav-left sig-info-prev" type="button">&#9664;</button>
<button class="btn btn-nav-right sig-caution-next" type="button">&#9654;</button> <button class="btn btn-nav-right sig-info-next" type="button">&#9654;</button>
<div class="sig-caution-tooltip" id="id_sig_caution"> <div class="sig-info" id="id_sig_info">
<div class="sig-caution-header"> <div class="sig-info-header">
<h4 class="sig-caution-title"></h4> <h4 class="sig-info-title"></h4>
<span class="sig-caution-type">Ally Interaction</span> <p class="sig-info-type"></p>
</div> </div>
<p class="sig-caution-effect"></p> <p class="sig-info-effect"></p>
<span class="sig-caution-index"></span> <span class="sig-info-index"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -59,9 +63,8 @@ describe("SigSelect", () => {
data-correspondence="" data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition" data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences" data-keywords-reversed="no direction,disregard for consequences"
data-cautions="${cardCautions.replace(/"/g, '&quot;')}" data-energies="[]"
data-mechanisms="[]" data-operations="[]"
data-articulations="[]"
data-levity-qualifier="Elevated" data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven" data-gravity-qualifier="Graven"
data-reversal=""> data-reversal="">
@@ -150,7 +153,6 @@ describe("SigSelect", () => {
beforeEach(() => makeFixture()); beforeEach(() => makeFixture());
it("does not focus another card while one is reserved", () => { it("does not focus another card while one is reserved", () => {
// Simulate a reservation on some other card (not this one)
SigSelect._setReservedCardId("99"); SigSelect._setReservedCardId("99");
card.dispatchEvent(new MouseEvent("click", { bubbles: true })); card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(card.classList.contains("sig-focused")).toBe(false); expect(card.classList.contains("sig-focused")).toBe(false);
@@ -171,20 +173,15 @@ describe("SigSelect", () => {
}); });
}); });
// ── WS release clears NVM in a second browser ────────────────────── // // ── WS release event (second-browser NVM sync) ────────────────────── //
// 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)", () => { describe("WS release event (second-browser NVM sync)", () => {
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' })); beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
it("removes .sig-reserved and .sig-reserved--own on WS release", () => { 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--own")).toBe(true);
expect(card.classList.contains("sig-reserved")).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", { window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false }, detail: { card_id: 42, role: "PC", reserved: false },
})); }));
@@ -197,194 +194,231 @@ describe("SigSelect", () => {
window.dispatchEvent(new CustomEvent("room:sig_reserved", { window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false }, 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 })); card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(card.classList.contains("sig-focused")).toBe(true); expect(card.classList.contains("sig-focused")).toBe(true);
}); });
}); });
// ── Caution tooltip (!!) ──────────────────────────────────────────── // // ── FYI info panel ────────────────────────────────────────────────── //
describe("caution tooltip", () => { describe("FYI info panel", () => {
var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn; var infoEl, infoEffect, infoTitle, infoType, infoIndex, infoPrev, infoNext, infoBtn;
beforeEach(() => { beforeEach(() => {
makeFixture(); makeFixture();
cautionTooltip = testDiv.querySelector(".sig-caution-tooltip"); infoEl = testDiv.querySelector(".sig-info");
cautionEffect = testDiv.querySelector(".sig-caution-effect"); infoEffect = testDiv.querySelector(".sig-info-effect");
cautionPrev = testDiv.querySelector(".sig-caution-prev"); infoTitle = testDiv.querySelector(".sig-info-title");
cautionNext = testDiv.querySelector(".sig-caution-next"); infoType = testDiv.querySelector(".sig-info-type");
cautionBtn = testDiv.querySelector(".sig-caution-btn"); infoIndex = testDiv.querySelector(".sig-info-index");
infoPrev = testDiv.querySelector(".sig-info-prev");
infoNext = testDiv.querySelector(".sig-info-next");
infoBtn = testDiv.querySelector(".sig-info-btn");
}); });
function hover() { function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); }
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); function openFYI() { hover(); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
}
function openCaution() { it("FYI click adds .sig-info-open to the stage", () => {
hover(); openFYI();
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(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", () => { it("FYI click when btn-disabled does not toggle", () => {
openCaution(); openFYI();
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
}); });
it("shows placeholder when both mechanisms and articulations are empty", () => { it("shows placeholder when both energies and operations are empty", () => {
card.dataset.mechanisms = "[]"; card.dataset.energies = "[]";
card.dataset.articulations = "[]"; card.dataset.operations = "[]";
openCaution(); openFYI();
expect(cautionEffect.innerHTML).toContain("No ally interactions defined"); expect(infoEffect.innerHTML).toContain("No interactions defined");
}); });
it("renders first mechanism effect HTML including .card-ref spans", () => { it("renders first energy effect HTML including .card-ref spans", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: 'First <span class="card-ref">Card</span> effect.' } { type: "LIBIDO", effect: 'First <span class="card-ref">Card</span> effect.' }
]); ]);
openCaution(); openFYI();
expect(cautionEffect.querySelector(".card-ref")).not.toBeNull(); expect(infoEffect.querySelector(".card-ref")).not.toBeNull();
expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card"); expect(infoEffect.querySelector(".card-ref").textContent).toBe("Card");
});
it("energy entry sets title to 'Energies' with --energies modifier class", () => {
card.dataset.energies = JSON.stringify([
{ type: "NUMEN", effect: "An energy entry." }
]);
openFYI();
expect(infoTitle.textContent).toBe("Energies");
expect(infoTitle.classList.contains("sig-info-title--energies")).toBe(true);
});
it("operation entry sets title to 'Operations' with --operations modifier class", () => {
card.dataset.operations = JSON.stringify([
{ type: "COVER", effect: "An operation entry." }
]);
openFYI();
expect(infoTitle.textContent).toBe("Operations");
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("Operations");
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", () => { it("with 1 entry both nav arrows are disabled", () => {
card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "Single." }]); card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Single." }]);
openCaution(); openFYI();
expect(cautionPrev.disabled).toBe(true); expect(infoPrev.disabled).toBe(true);
expect(cautionNext.disabled).toBe(true); expect(infoNext.disabled).toBe(true);
}); });
it("with multiple entries both nav arrows are always enabled", () => { it("with multiple entries both nav arrows are enabled", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "C1" }, { type: "LIBIDO", effect: "C1" },
{ category: "Mechanism", effect: "C2" }, { type: "NUMEN", effect: "C2" },
{ category: "Mechanism", effect: "C3" }, { type: "VOLUPTAS", effect: "C3" },
{ category: "Mechanism", effect: "C4" }, { type: "VOLUPTAS", effect: "C4" },
]); ]);
openCaution(); openFYI();
expect(cautionPrev.disabled).toBe(false); expect(infoPrev.disabled).toBe(false);
expect(cautionNext.disabled).toBe(false); expect(infoNext.disabled).toBe(false);
}); });
it("next click advances to second entry", () => { it("next click advances to second entry", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Second" }, { type: "NUMEN", effect: "Second" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("Second"); expect(infoEffect.innerHTML).toContain("Second");
}); });
it("next wraps from last entry back to first", () => { it("next wraps from last entry back to first", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Last" }, { type: "NUMEN", effect: "Last" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("First"); expect(infoEffect.innerHTML).toContain("First");
}); });
it("prev click goes back to first entry", () => { it("prev click goes back to first entry", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Second" }, { type: "NUMEN", effect: "Second" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("First"); expect(infoEffect.innerHTML).toContain("First");
}); });
it("prev wraps from first entry to last", () => { it("prev wraps from first entry to last", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Middle" }, { type: "NUMEN", effect: "Middle" },
{ category: "Mechanism", effect: "Last" }, { type: "VOLUPTAS", effect: "Last" },
]); ]);
openCaution(); openFYI();
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(cautionEffect.innerHTML).toContain("Last"); expect(infoEffect.innerHTML).toContain("Last");
}); });
it("index label shows n / total when multiple entries", () => { it("index label shows n / total when multiple entries", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "C1" }, { type: "LIBIDO", effect: "C1" },
{ category: "Mechanism", effect: "C2" }, { type: "NUMEN", effect: "C2" },
{ category: "Mechanism", effect: "C3" }, { type: "VOLUPTAS", effect: "C3" },
]); ]);
openCaution(); openFYI();
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3"); expect(infoIndex.textContent).toBe("1 / 3");
}); });
it("index label is empty when only 1 entry", () => { it("index label is empty when only 1 entry", () => {
card.dataset.mechanisms = JSON.stringify([{ category: "Mechanism", effect: "Only one." }]); card.dataset.energies = JSON.stringify([{ type: "NUMEN", effect: "Only one." }]);
openCaution(); openFYI();
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe(""); expect(infoIndex.textContent).toBe("");
}); });
it("card mouseleave closes the caution", () => { it("card mouseleave closes the info panel", () => {
openCaution(); openFYI();
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(false);
}); });
it("opening again resets to first entry", () => { it("opening again resets to first entry", () => {
card.dataset.mechanisms = JSON.stringify([ card.dataset.energies = JSON.stringify([
{ category: "Mechanism", effect: "First" }, { type: "LIBIDO", effect: "First" },
{ category: "Mechanism", effect: "Second" }, { type: "NUMEN", effect: "Second" },
]); ]);
openCaution(); openFYI();
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Close and reopen
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
openCaution(); openFYI();
expect(cautionEffect.innerHTML).toContain("First"); expect(infoEffect.innerHTML).toContain("First");
}); });
it("opening caution adds .btn-disabled and swaps SPIN/FYI labels to ×", () => { it("opening info panel adds .btn-disabled and swaps SPIN/FYI labels to ×", () => {
openCaution(); openFYI();
var flipBtn = testDiv.querySelector(".sig-flip-btn"); var flipBtn = testDiv.querySelector(".sig-flip-btn");
expect(flipBtn.classList.contains("btn-disabled")).toBe(true); expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
expect(flipBtn.textContent).toBe("\u00D7"); expect(flipBtn.textContent).toBe("×");
expect(cautionBtn.textContent).toBe("\u00D7"); expect(infoBtn.textContent).toBe("×");
}); });
it("closing caution removes .btn-disabled and restores SPIN/FYI labels", () => { it("closing info panel removes .btn-disabled and restores SPIN/FYI labels", () => {
var flipBtn = testDiv.querySelector(".sig-flip-btn"); var flipBtn = testDiv.querySelector(".sig-flip-btn");
var origFlip = flipBtn.textContent; var origFlip = flipBtn.textContent;
var origCaution = cautionBtn.textContent; var origInfo = infoBtn.textContent;
openCaution(); openFYI();
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(flipBtn.classList.contains("btn-disabled")).toBe(false); expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
expect(cautionBtn.classList.contains("btn-disabled")).toBe(false); expect(infoBtn.classList.contains("btn-disabled")).toBe(false);
expect(flipBtn.textContent).toBe(origFlip); expect(flipBtn.textContent).toBe(origFlip);
expect(cautionBtn.textContent).toBe(origCaution); expect(infoBtn.textContent).toBe(origInfo);
}); });
it("clicking the tooltip closes caution", () => { it("clicking the info panel closes it", () => {
openCaution(); openFYI();
cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true })); infoEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(false);
}); });
it("SPIN click when caution open (btn-disabled) does nothing", () => { it("SPIN click when info open (btn-disabled) does nothing", () => {
openCaution(); openFYI();
var flipBtn = testDiv.querySelector(".sig-flip-btn"); var flipBtn = testDiv.querySelector(".sig-flip-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
expect(statBlock.classList.contains("is-reversed")).toBe(false); expect(statBlock.classList.contains("is-reversed")).toBe(false);
}); });
}); });
@@ -433,7 +467,6 @@ describe("SigSelect", () => {
); );
expect(statBlock.classList.contains("is-reversed")).toBe(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("mouseleave", { bubbles: true }));
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(statBlock.classList.contains("is-reversed")).toBe(false); expect(statBlock.classList.contains("is-reversed")).toBe(false);
@@ -487,7 +520,6 @@ describe("SigSelect", () => {
makeFixture(); makeFixture();
card.dataset.reversal = "Nervous"; card.dataset.reversal = "Nervous";
hover(); 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-qualifier").textContent).toBe("Nervous");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent) expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
.toBe(card.dataset.nameTitle); .toBe(card.dataset.nameTitle);
@@ -516,34 +548,25 @@ describe("SigSelect", () => {
.toBe(card.dataset.nameTitle); .toBe(card.dataset.nameTitle);
}); });
it("major arcana with data-reversal: polarity qualifier still shown alongside reversal name", () => { it("major arcana reversed face: polarity qualifier + card title (concept name in FYI)", () => {
makeFixture({ polarity: "levity", userRole: "PC" }); makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dataset.reversal = "Territoriality"; card.dataset.nameTitle = "The Schizo";
hover(); hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Territoriality"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("The Schizo");
}); });
it("hovering a card without data-reversal clears the reversal name", () => { it("non-major without data-reversal: reversal-name empty, qualifier mirrors polarity", () => {
makeFixture(); makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.reversal = "Territoriality"; // fixture default: Minor Arcana, no reversal word
hover(); hover();
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
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(""); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("");
}); });
}); });
// ── WS cursor hover (applyHover) ──────────────────────────────────────── // // ── 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", () => { describe("WS cursor hover", () => {
beforeEach(() => makeFixture()); beforeEach(() => makeFixture());
@@ -589,10 +612,6 @@ describe("SigSelect", () => {
}); });
// ── WS reservation — data-reserved-by attribute ───────────────────────── // // ── 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", () => { describe("WS reservation sets data-reserved-by", () => {
beforeEach(() => makeFixture()); beforeEach(() => makeFixture());
@@ -630,19 +649,16 @@ describe("SigSelect", () => {
}); });
it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => { 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", { window.dispatchEvent(new CustomEvent("room:sig_hover", {
detail: { card_id: 42, role: "NC", active: true }, detail: { card_id: 42, role: "NC", active: true },
})); }));
expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull(); expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
expect(document.querySelector('.fa-hand-pointer[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", { window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "NC", reserved: true }, detail: { card_id: 42, role: "NC", reserved: true },
})); }));
// Thumbs-up replaces hand-pointer
const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]'); const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
expect(floatEl).not.toBeNull(); expect(floatEl).not.toBeNull();
expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true); expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
@@ -663,15 +679,10 @@ describe("SigSelect", () => {
}); });
// ── Polarity theming — stage qualifier text ────────────────────────────── // // ── 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", () => { describe("polarity theming — stage qualifier", () => {
it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => { it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
// data-arcana defaults to "Minor Arcana" in fixture → non-major
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated"); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
@@ -695,7 +706,6 @@ describe("SigSelect", () => {
it("non-major arcana title has no trailing comma", () => { it("non-major arcana title has no trailing comma", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
// fixture default: Minor Arcana, "King of Pentacles"
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles"); expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
}); });
@@ -719,7 +729,6 @@ describe("SigSelect", () => {
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
card.dispatchEvent(new MouseEvent("mouseenter", { 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-above").textContent).toBe("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
}); });
@@ -733,17 +742,12 @@ describe("SigSelect", () => {
}); });
// ── WAIT NVM glow pulse ────────────────────────────────────────────────────── // // ── 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", () => { describe("WAIT NVM glow pulse", () => {
let takeSigBtn; let takeSigBtn;
beforeEach(() => { beforeEach(() => {
jasmine.clock().install(); jasmine.clock().install();
// Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init
makeFixture({ reservations: '{"42":"PC"}' }); makeFixture({ reservations: '{"42":"PC"}' });
takeSigBtn = document.getElementById("id_take_sig_btn"); takeSigBtn = document.getElementById("id_take_sig_btn");
}); });
@@ -754,7 +758,6 @@ describe("SigSelect", () => {
async function clickTakeSig() { async function clickTakeSig() {
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Flush the fetch .then() so _startWaitNoGlow() is called
await Promise.resolve(); await Promise.resolve();
} }
@@ -772,8 +775,8 @@ describe("SigSelect", () => {
it("removes .btn-cancel on the second tick (even / trough)", async () => { it("removes .btn-cancel on the second tick (even / trough)", async () => {
await clickTakeSig(); await clickTakeSig();
jasmine.clock().tick(601); // peak jasmine.clock().tick(601);
jasmine.clock().tick(600); // trough jasmine.clock().tick(600);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
}); });
@@ -786,10 +789,9 @@ describe("SigSelect", () => {
it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => { it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => {
await clickTakeSig(); await clickTakeSig();
jasmine.clock().tick(601); // glow is on jasmine.clock().tick(601);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
// Click again → WAIT NVM → fetch unready → _stopWaitNoGlow()
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
@@ -799,94 +801,11 @@ describe("SigSelect", () => {
it("glow does not advance after being stopped", async () => { it("glow does not advance after being stopped", async () => {
await clickTakeSig(); await clickTakeSig();
jasmine.clock().tick(601); // peak jasmine.clock().tick(601);
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve(); // stop await Promise.resolve();
jasmine.clock().tick(600); // would be another tick if running jasmine.clock().tick(600);
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); 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 <span class="card-ref">The Occultist</span>.' }
]);
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);
});
});
}); });

View File

@@ -43,7 +43,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
</div> </div>
<div class="sig-stat-block"> <div class="sig-stat-block">
<button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button> <button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button>
<button class="btn btn-caution sig-caution-btn" type="button">FYI</button> <button class="btn btn-info sig-info-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright"> <div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p> <p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul> <ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
@@ -52,16 +52,16 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
<p class="stat-face-label">Reversal</p> <p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul> <ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div> </div>
<div class="sig-caution-tooltip" id="id_sig_caution"> <div class="sig-info" id="id_sig_tooltip">
<div class="sig-caution-header"> <div class="sig-info-header">
<h4 class="sig-caution-title"></h4> <h4 class="sig-info-title"></h4>
<p class="sig-caution-type">Ally Interaction</p> <p class="sig-info-type"></p>
</div> </div>
<p class="sig-caution-effect"></p> <p class="sig-info-effect"></p>
<span class="sig-caution-index"></span> <span class="sig-info-index"></span>
</div> </div>
<button class="btn btn-nav-left sig-caution-prev" type="button">PRV</button> <button class="btn btn-nav-left sig-info-prev" type="button">PRV</button>
<button class="btn btn-nav-right sig-caution-next" type="button">NXT</button> <button class="btn btn-nav-right sig-info-next" type="button">NXT</button>
</div> </div>
</div> </div>
@@ -77,8 +77,8 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
data-correspondence="{{ card.correspondence|default:'' }}" data-correspondence="{{ card.correspondence|default:'' }}"
data-keywords-upright="{{ card.keywords_upright|join:',' }}" data-keywords-upright="{{ card.keywords_upright|join:',' }}"
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}" data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
data-mechanisms="{{ card.mechanisms_json }}" data-energies="{{ card.energies_json }}"
data-articulations="{{ card.articulations_json }}" data-operations="{{ card.operations_json }}"
data-levity-qualifier="{{ card.levity_qualifier }}" data-levity-qualifier="{{ card.levity_qualifier }}"
data-gravity-qualifier="{{ card.gravity_qualifier }}" data-gravity-qualifier="{{ card.gravity_qualifier }}"
data-reversal="{{ card.reversal }}"> data-reversal="{{ card.reversal }}">