tray sig-card tooltip: portal w. PRV|NXT pager — TDD

Phase 2 of the apps.tooltips integration on the tray. Hovering
.tray-sig-card > .sig-stage-card opens #id_tooltip_portal w. an FYI panel
that mirrors #id_fan_fyi_panel (Energy / Operation entries cycled via
PRV|NXT), but w.o. the stage block, w.o. Reversal entries, & w.o. the fan
stage's click-to-dismiss handler — the panel-body click is reserved for
future drag-and-drop on .tray-sig-card:active.

- _partials/_sig_fyi_panel.html — new partial, the .sig-info + PRV|NXT
  block extracted out of game_kit.html, _sig_select_overlay.html, &
  _sea_overlay.html. {% include %}d back from those 3 callers; pure
  copy-paste extraction (no behavioural change to fan stage, sig select,
  or sea select).
- room.html: .tray-sig-card > .sig-stage-card gains data-energies +
  data-operations (the only attrs StageCard.buildInfoData reads), keyed
  off my_tray_sig.energies_json / .operations_json (existing TarotCard
  properties).
- tray-tooltip.js: new sig branch — _showSig() builds the panel inline,
  paints via StageCard.renderFyi, & wires PRV|NXT cycle handlers; the
  mousemove union now covers the .fyi-prev / .fyi-next btn rects (the
  btns hang past the portal's left & right edges) so mouse-over them
  keeps the panel alive. Click stopPropagation on the btns prevents the
  panel-body click from reaching anything else.
- TrayTooltipSpec: 6 new sig-branch specs (panel structure; first energy
  entry rendered; PRV|NXT cycling; body click no-dismiss; pointer over
  btn rects keeps panel alive; pointer outside full union clears).
- test_component_tray_tooltip.py: 4 sig FTs (hover populates portal w.
  Energy/TESTLIBIDO/effect/1-of-2; PRV|NXT cycle; body click does NOT
  dismiss; mouseleave clears).

FT helper note — the sig FT's _hover dispatches a synthetic mouseenter
via JS rather than ActionChains.move_to_element, because the role-card
& sig-card cells sit side-by-side in the tray grid: the pointer's
animated path crosses the role-card on its way to the sig-card &
opens the role tooltip mid-flight, which then occludes the sig stage
by the time the move lands. Direct dispatch lands the event on the
intended trigger w.o. the cross-cell drag-by.

313 epic ITs + 335 Jasmine specs (incl. 6 new) + 6 tray-tooltip FTs all
green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-03 21:07:33 -04:00
parent 08243d109d
commit b29bcf5c38
9 changed files with 654 additions and 78 deletions

View File

@@ -138,6 +138,139 @@ describe("TrayTooltip", () => {
// Clamping — portal left stays inside the viewport //
// ---------------------------------------------------------------------- //
// ---------------------------------------------------------------------- //
// Phase 2 — sig-card branch //
// ---------------------------------------------------------------------- //
//
// Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with
// a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns).
// No click-to-dismiss; mouseleave union covers the overhanging btn rects.
describe("sig-card branch", () => {
let sigCell, sigStage;
beforeEach((done) => {
sigCell = document.createElement("div");
sigCell.className = "tray-cell tray-sig-card";
sigStage = document.createElement("div");
sigStage.className = "sig-stage-card sea-sig-card";
// Two FYI entries — 1 energy + 1 operation — so PRV/NXT have
// something to cycle through.
sigStage.dataset.energies = JSON.stringify([
{ type: "TESTLIBIDO", effect: "First energy effect." },
]);
sigStage.dataset.operations = JSON.stringify([
{ type: "TESTOP", effect: "First operation effect." },
]);
sigCell.appendChild(sigStage);
grid.appendChild(sigCell);
spyOn(sigStage, "getBoundingClientRect").and.returnValue({
left: 100, right: 160, top: 300, bottom: 396,
width: 60, height: 96,
});
// MutationObserver fires asynchronously — yield once so the new
// sig cell is bound before the spec body runs.
setTimeout(done, 0);
});
it("populates the portal with a .sig-info panel + PRV/NXT btns", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.querySelector(".sig-info-title")).not.toBe(null);
expect(portal.querySelector(".sig-info-type")).not.toBe(null);
expect(portal.querySelector(".sig-info-effect")).not.toBe(null);
expect(portal.querySelector(".sig-info-index")).not.toBe(null);
expect(portal.querySelector(".fyi-prev")).not.toBe(null);
expect(portal.querySelector(".fyi-next")).not.toBe(null);
});
it("renders the first energy entry on hover", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO");
expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect.");
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
});
it("NXT advances to the operation entry; PRV cycles back", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
portal.querySelector(".fyi-next").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation");
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP");
expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2");
portal.querySelector(".fyi-prev").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
});
it("clicking the panel body does NOT dismiss (departure from fan stage)", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
portal.querySelector(".sig-info-effect").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.classList.contains("active")).toBe(true);
expect(portal.style.display).not.toBe("none");
});
it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Stub the portal + btn rects so the union test is deterministic.
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 200, bottom: 320,
width: 200, height: 120,
});
const prv = portal.querySelector(".fyi-prev");
const nxt = portal.querySelector(".fyi-next");
spyOn(prv, "getBoundingClientRect").and.returnValue({
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
});
spyOn(nxt, "getBoundingClientRect").and.returnValue({
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
});
// Pointer over the PRV btn (left:6092) — outside the portal's left
// edge (80) but inside the btn rect — should keep the portal alive.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 70, clientY: 256,
}));
expect(portal.classList.contains("active")).toBe(true);
// Pointer over the NXT btn (right:268300, past the portal right
// edge 280) — likewise stays alive.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 290, clientY: 256,
}));
expect(portal.classList.contains("active")).toBe(true);
});
it("clears portal when pointer leaves trigger + portal + btn rect union", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 200, bottom: 320,
width: 200, height: 120,
});
const prv = portal.querySelector(".fyi-prev");
const nxt = portal.querySelector(".fyi-next");
spyOn(prv, "getBoundingClientRect").and.returnValue({
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
});
spyOn(nxt, "getBoundingClientRect").and.returnValue({
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
});
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 9999, clientY: 9999,
}));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
});
describe("position clamping", () => {
it("clamps to the right when the trigger is near the left edge", () => {
// Trigger near x=0 should push portal centre rightward to halfW + 8.