Files
python-tdd/src/static/tests/NatusWheelSpec.js
Disco DeDisco c78ecb61bf natus wheel: unified PRV/NXT cycle merging planets & angles; drop degree from classic element contribs; tt-planet-sym larger; tt-sign-type italic — TDD
- _chartItems merges _planetItems + _angleItems sorted by degree desc;
  _stepCycle dispatches to _activatePlanet or _activateAngle via unified list
- T15g/h/i: angle↔planet boundary navigation & wrap; T9n/T9w updated for merged cycle
- classic element contrib rows: removed @ deg° (pdata/inDeg lookup dropped)
- .tt-planet-sym 1.2→1.8rem; .tt-house-of/.tt-house-type 0.6em→0.7rem;
  .tt-sign-type added alongside .tt-house-type selector, font-style: italic

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:42:47 -04:00

1002 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── NatusWheelSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for natus-wheel.js — planet/element click-to-lock tooltips.
//
// DOM contract assumed:
// <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page)
//
// Click-lock contract:
// click on [data-planet] group → adds .nw-planet--active class
// raises group to DOM front
// shows #id_natus_tooltip with
// planet name, in-sign degree, sign name,
// ℞ if retrograde, and "n / total" index
// click same planet again → removes .nw-planet--active; hides tooltip
// PRV / NXT buttons in tooltip → cycle to adjacent planet by ecliptic degree
//
// In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces)
//
// ─────────────────────────────────────────────────────────────────────────────
// Shared chart — Sun (66.7°), Venus (63.3°), Mars (132.0°)
// Descending-degree (clockwise) order: Mars (132.0) → Sun (66.7) → Venus (63.3)
const CONJUNCTION_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
Venus: { sign: "Gemini", degree: 63.3, retrograde: false },
Mars: { sign: "Leo", degree: 132.0, retrograde: false },
},
houses: {
cusps: [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
asc: 180.0, mc: 90.0,
},
elements: { Fire: 1, Stone: 0, Air: 2, Water: 0, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1": 0, "2": 0, "3": 2, "4": 0, "5": 0, "6": 0,
"7": 0, "8": 0, "9": 1, "10": 0, "11": 0, "12": 0,
},
house_system: "O",
};
describe("NatusWheel — planet click tooltips", () => {
const SYNTHETIC_CHART = {
planets: {
Sun: { sign: "Pisces", degree: 338.4, retrograde: false },
Moon: { sign: "Capricorn", degree: 295.1, retrograde: false },
Mercury: { sign: "Aquarius", degree: 312.8, retrograde: true },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 1, Stone: 2, Air: 1, Water: 3, Time: 1, Space: 2 },
aspects: [],
distinctions: {
"1": 0, "2": 0, "3": 0, "4": 0,
"5": 0, "6": 0, "7": 0, "8": 0,
"9": 0, "10": 0, "11": 0, "12": 0,
},
house_system: "P",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, SYNTHETIC_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// ── T3 ── click planet shows name / sign / in-sign degree + glow ──────────
it("T3: clicking a planet group adds the active class and shows the tooltip with name, sign, and in-sign degree", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(sun.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Sun");
expect(text).toContain("Pisces");
// in-sign degree: 338.4° ecliptic 330° (Pisces start) = 8.4°
expect(text).toContain("8.4");
});
// ── T4 ── retrograde planet shows ℞ ──────────────────────────────────────
it("T4: clicking a retrograde planet shows ℞ in the tooltip", () => {
const mercury = svgEl.querySelector("[data-planet='Mercury']");
expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG");
mercury.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("℞");
});
// ── T5 ── clicking same planet again hides tooltip and removes active ──────
it("T5: clicking the same planet again hides the tooltip and removes the active class", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T6 ── tooltip shows PRV / NXT buttons ─────────────────────────────────
it("T6: tooltip contains PRV and NXT buttons after a planet click", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull("expected .nw-tt-prv button");
expect(tooltipEl.querySelector(".nw-tt-nxt")).not.toBeNull("expected .nw-tt-nxt button");
});
});
describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
let svgEl2, tooltipEl;
beforeEach(() => {
svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl2.setAttribute("id", "id_natus_svg_conj");
svgEl2.setAttribute("width", "400");
svgEl2.setAttribute("height", "400");
svgEl2.style.width = "400px";
svgEl2.style.height = "400px";
document.body.appendChild(svgEl2);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
tooltipEl.style.position = "fixed";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl2, CONJUNCTION_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl2.remove();
tooltipEl.remove();
});
// ── T7 ── tick present in DOM and extends past the zodiac ring ───────────
// Visibility is CSS-controlled (opacity-0 by default, revealed on --active).
it("T7: each planet has a tick line whose outer endpoint extends past the sign ring", () => {
const tick = svgEl2.querySelector(".nw-planet-tick");
expect(tick).not.toBeNull("expected at least one .nw-planet-tick element");
const cx = 200, cy = 200;
const x2 = parseFloat(tick.getAttribute("x2"));
const y2 = parseFloat(tick.getAttribute("y2"));
const rOuter = Math.sqrt((x2 - cx) ** 2 + (y2 - cy) ** 2);
// _r = Math.min(400,400) * 0.46 = 184; signOuter = _r * 0.90 = 165.6
const signOuter = 400 * 0.46 * 0.90;
expect(rOuter).toBeGreaterThan(signOuter);
});
// ── T8 ── click raises planet to front ────────────────────────────────────
it("T8: clicking a planet raises it to the last DOM position (visually on top)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
const venus = svgEl2.querySelector("[data-planet='Venus']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
expect(venus).not.toBeNull("expected [data-planet='Venus']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group"));
expect(groups[groups.length - 1].getAttribute("data-planet")).toBe("Venus");
});
// ── T9c ── NXT cycles clockwise (to lower ecliptic degree) ──────────────
// Descending order: Mars [idx 0] → Sun [idx 1] → Venus [idx 2]
// Clicking Sun (idx 1) then NXT should activate Venus (idx 2, lower degree = clockwise).
it("T9c: clicking NXT from Sun shows Venus (next planet clockwise = lower degree)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("Sun");
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
expect(nxtBtn).not.toBeNull("expected .nw-tt-nxt button in tooltip");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("Venus");
const venus = svgEl2.querySelector("[data-planet='Venus']");
expect(venus.classList.contains("nw-planet--active")).toBe(true);
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T9n ── PRV cycles counterclockwise (to higher ecliptic degree) ────────
// CONJUNCTION_CHART merged sorted desc: ASC(180)→Mars(132)→MC(90)→Sun(66.7)→Venus(63.3)
// PRV from Sun (pos 3) → MC (pos 2, 90°) — angles and planets share the cycle.
it("T9n: clicking PRV from Sun shows MC (next higher ecliptic degree in merged cycle)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Midheaven");
const mc = svgEl2.querySelector("[data-angle='MC']");
expect(mc.classList.contains("nw-angle--active")).toBe(true);
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T9w ── NXT wraps clockwise from the lowest-degree item ───────────────
// Venus(63.3°) is lowest; NXT wraps to ASC(180°) — the highest-degree item.
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to ASC (highest degree)", () => {
const venus = svgEl2.querySelector("[data-planet='Venus']");
venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Ascendant");
const asc = svgEl2.querySelector("[data-angle='ASC']");
expect(asc.classList.contains("nw-angle--active")).toBe(true);
});
});
// ── Half-wheel tooltip positioning ───────────────────────────────────────────
//
// Tooltip lands in the opposite vertical half, with horizontal edge anchored
// to the item's screen edge on the same L/R side.
//
// SVG: 400×400 at viewport origin → centre = (200, 200).
// Tooltip offsetWidth/Height: 0 in JSDOM (no layout engine) → ttW=ttH=0.
// Clamping uses svgRect bounds (not window.inner*), so no viewport mock needed.
// REM = 16 px. Item circle: 20×20 px around mock centre.
//
// Vertical results (item circle centre at y):
// y ≥ 200 (lower half): top = svgCY - REM - ttH = 200 - 16 - 0 = 184
// y < 200 (upper half): top = svgCY + REM = 200 + 16 = 216
//
// Horizontal results (item circle centre at x, radius=10):
// x < 200 (left side): left = iRect.left = x - 10
// x ≥ 200 (right side): left = iRect.right - ttW = x + 10 - 0 = x + 10
// ─────────────────────────────────────────────────────────────────────────────
// ── DON / DOFF aspect line persistence ───────────────────────────────────────
//
// Aspect lines belong to the page session, not the tooltip:
// - DON draws lines into .nw-aspects and disables DON btn (shows ×)
// - closing the tooltip does NOT clear lines
// - re-opening the SAME planet preserves _aspectsVisible → DON still disabled
// - opening a DIFFERENT planet resets state: lines cleared, DON active
// - DOFF clears lines; re-opening same planet finds DON active
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — DON/DOFF aspect line persistence", () => {
const ASPECT_CHART = {
planets: {
Sun: { sign: "Capricorn", degree: 280.4, retrograde: false },
Moon: { sign: "Scorpio", degree: 220.1, retrograde: false },
Mars: { sign: "Taurus", degree: 40.7, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0, mc: 270,
},
elements: { Fire: 0, Stone: 0, Air: 0, Water: 1, Time: 0, Space: 0 },
aspects: [
{ planet1: "Sun", planet2: "Mars", type: "Trine",
orb: 0.3, angle: 120, applying_planet: "Sun" },
{ planet1: "Sun", planet2: "Moon", type: "Sextile",
orb: 2.9, angle: 60, applying_planet: "Moon" },
],
distinctions: {
"1": 0, "2": 0, "3": 0, "4": 0,
"5": 0, "6": 0, "7": 0, "8": 0,
"9": 0, "10": 0, "11": 0, "12": 0,
},
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, ASPECT_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
function clickPlanet(name) {
svgEl.querySelector(`[data-planet="${name}"]`)
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
function clickDon() { tooltipEl.querySelector(".nw-asp-don") .dispatchEvent(new MouseEvent("click", { bubbles: true })); }
function clickDoff() { tooltipEl.querySelector(".nw-asp-doff").dispatchEvent(new MouseEvent("click", { bubbles: true })); }
function aspectLines() { return svgEl.querySelectorAll(".nw-aspects line").length; }
function donDisabled() { return tooltipEl.querySelector(".nw-asp-don").classList.contains("btn-disabled"); }
// T11a — DON draws lines
it("T11a: clicking DON draws aspect lines into .nw-aspects", () => {
clickPlanet("Sun");
expect(aspectLines()).toBe(0);
clickDon();
expect(aspectLines()).toBeGreaterThan(0);
});
// T11b — closing tooltip must not clear aspect lines
it("T11b: closing the tooltip (outside click) does not clear aspect lines", () => {
clickPlanet("Sun");
clickDon();
const lineCount = aspectLines();
expect(lineCount).toBeGreaterThan(0);
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
expect(aspectLines()).toBe(lineCount);
});
// T11c — re-opening same planet preserves DON-disabled state
it("T11c: re-opening the same planet after DON keeps DON disabled (lines still active)", () => {
clickPlanet("Sun");
clickDon();
expect(donDisabled()).toBe(true);
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
clickPlanet("Sun");
expect(donDisabled()).toBe(true);
expect(aspectLines()).toBeGreaterThan(0);
});
// T11d — switching planet leaves previous DONned lines intact; DON active for new planet
it("T11d: opening a different planet leaves DONned lines intact — DON active for new planet", () => {
clickPlanet("Sun");
clickDon();
const lineCount = aspectLines();
expect(lineCount).toBeGreaterThan(0);
clickPlanet("Moon");
expect(donDisabled()).toBe(false); // Moon's DON is fresh/active
expect(aspectLines()).toBe(lineCount); // Sun's lines still there
});
// T11f — DONning a second planet replaces the first planet's lines + tick
it("T11f: clicking DON on a second planet clears the first planet's lines", () => {
clickPlanet("Sun");
clickDon();
expect(aspectLines()).toBeGreaterThan(0);
clickPlanet("Moon");
clickDon();
expect(donDisabled()).toBe(true); // Moon's DON now disabled
// Moon aspects — Sun's lines replaced (lines may be 0 if Moon has no aspects)
const sunGrp = svgEl.querySelector('[data-planet="Sun"]');
expect(sunGrp.classList.contains('nw-planet--asp-active')).toBe(false);
});
// T11e — DOFF clears lines; re-opening same planet starts fresh
it("T11e: DOFF clears lines; re-opening same planet finds DON active again", () => {
clickPlanet("Sun");
clickDon();
clickDoff();
expect(aspectLines()).toBe(0);
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
clickPlanet("Sun");
expect(donDisabled()).toBe(false);
expect(aspectLines()).toBe(0);
});
// T11g — PRV/NXT navigation back to a DONned planet must restore DOFF state
it("T11g: navigating away via NXT then back via PRV restores DOFF-active state", () => {
clickPlanet("Sun");
clickDon();
expect(donDisabled()).toBe(true); // DON disabled, aspects active
// Navigate away to next planet
tooltipEl.querySelector(".nw-tt-nxt")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(donDisabled()).toBe(false); // new planet — DON fresh/active
// Navigate back to Sun
tooltipEl.querySelector(".nw-tt-prv")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Sun's aspects are still drawn — DOFF must be active, DON must be disabled
expect(donDisabled()).toBe(true);
expect(aspectLines()).toBeGreaterThan(0);
});
});
xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
const HALF_CHART = {
planets: {
// Vesta 90° → SVG (200, 274) — BELOW centre
// Ceres 270° → SVG (200, 126) — ABOVE centre
Vesta: { sign: "Cancer", degree: 90, retrograde: false },
Ceres: { sign: "Capricorn", degree: 270, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0, mc: 270,
},
elements: { Fire: 0, Stone: 1, Air: 0, Water: 1, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1": 0, "2": 0, "3": 0, "4": 1,
"5": 0, "6": 0, "7": 0, "8": 0,
"9": 0, "10": 1, "11": 0, "12": 0,
},
house_system: "P",
};
let svgEl3, tooltipEl;
beforeEach(() => {
svgEl3 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl3.setAttribute("id", "id_natus_svg_half");
svgEl3.setAttribute("width", "400");
svgEl3.setAttribute("height", "400");
svgEl3.style.width = "400px";
svgEl3.style.height = "400px";
document.body.appendChild(svgEl3);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
tooltipEl.style.position = "fixed";
document.body.appendChild(tooltipEl);
// Simulate SVG occupying [0,400]×[0,400] in the viewport.
// Clamping uses svgRect bounds, so no need to mock window.inner*.
spyOn(svgEl3, "getBoundingClientRect").and.returnValue(
{ left: 0, top: 0, width: 400, height: 400, right: 400, bottom: 400 }
);
NatusWheel.draw(svgEl3, HALF_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl3.remove();
tooltipEl.remove();
});
function mockPlanetAt(name, screenX, screenY) {
const grp = svgEl3.querySelector(`[data-planet="${name}"]`);
const circle = grp && (grp.querySelector("circle") || grp);
if (circle) {
spyOn(circle, "getBoundingClientRect").and.returnValue({
left: screenX - 10, top: screenY - 10,
width: 20, height: 20,
right: screenX + 10, bottom: screenY + 10,
});
}
}
// T10a — lower half: lower edge of tooltip sits 1rem above centreline
it("T10a: planet in lower half places tooltip lower-edge 1rem above the centreline (top=184)", () => {
mockPlanetAt("Vesta", 200, 274);
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.top)).toBe(184);
});
// T10b — upper half: upper edge of tooltip sits 1rem below centreline
it("T10b: planet in upper half places tooltip upper-edge 1rem below the centreline (top=216)", () => {
mockPlanetAt("Ceres", 200, 126);
svgEl3.querySelector("[data-planet='Ceres']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.top)).toBe(216);
});
// T10c — left side: tooltip left edge aligns with item left edge (x=140 → left=130)
it("T10c: planet on left side of wheel aligns tooltip left edge with item left edge", () => {
mockPlanetAt("Vesta", 140, 274); // itemRect.left = 130
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.left)).toBe(130);
});
// T10d — right side: tooltip right edge aligns with item right edge (x=260 → left=270-ttW=270)
it("T10d: planet on right side of wheel aligns tooltip right edge with item right edge", () => {
mockPlanetAt("Vesta", 260, 274); // iRect.right=270, ttW=0 → left=270
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.left)).toBe(270);
});
});
// ── T14 — element tooltip shows contributor planets (enriched data) ───────────
//
// When element data arrives in enriched format {count, contributors/stellia/parades},
// clicking a classic-element slice lists contributor planet names in the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — element tooltip contributor display", () => {
const ENRICHED_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
Venus: { sign: "Gemini", degree: 63.3, retrograde: false },
Mars: { sign: "Leo", degree: 132.0, retrograde: false },
Saturn: { sign: "Virgo", degree: 153.0, retrograde: false },
},
houses: {
cusps: [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
asc: 180.0, mc: 90.0,
},
elements: {
Fire: { count: 2, contributors: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Mars", sign: "Leo" },
]},
Stone: { count: 1, contributors: [
{ planet: "Saturn", sign: "Virgo" },
]},
Air: { count: 0, contributors: [] },
Water: { count: 0, contributors: [] },
Time: { count: 1, stellia: [
{ sign: "Gemini", planets: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Venus", sign: "Gemini" },
]},
]},
Space: { count: 1, parades: [
{ signs: ["Gemini", "Leo", "Virgo"],
planets: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Mars", sign: "Leo" },
{ planet: "Saturn", sign: "Virgo" },
]},
]},
},
aspects: [],
distinctions: { "1":0,"2":0,"3":2,"4":0,"5":0,"6":0,"7":0,"8":0,"9":1,"10":0,"11":0,"12":0 },
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, ENRICHED_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T14a — Fire slice lists contributor planet symbols ☉ (Sun) and ♂ (Mars)
it("T14a: clicking Fire element slice shows contributor planet symbols", () => {
const fireSlice = svgEl.querySelector("[data-element='Fire']");
expect(fireSlice).not.toBeNull();
fireSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("☉"); // Sun
expect(body).toContain("♂"); // Mars
});
// T14b — Space slice shows parade formation with planet symbols
it("T14b: clicking Space element slice shows parade formation block", () => {
const spaceSlice = svgEl.querySelector("[data-element='Space']");
spaceSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("Parade");
// Planet symbols for Sun ☉, Mars ♂, Saturn ♄ appear in the parade
expect(body).toContain("☉");
});
// T14c — Time slice shows stellium formation with planet symbols
it("T14c: clicking Time element slice shows stellium formation block", () => {
const timeSlice = svgEl.querySelector("[data-element='Time']");
timeSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("Stellium");
// Sun ☉ and Venus ♀ are in the Gemini stellium
expect(body).toContain("☉");
expect(body).toContain("♀");
});
// T14d — Air slice (count 0) shows em dash fallback, not an empty list
it("T14d: empty element slice shows em dash fallback", () => {
const airSlice = svgEl.querySelector("[data-element='Air']");
airSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("—");
});
});
// ── T12 — sign ring click tooltips ────────────────────────────────────────────
//
// Clicking a sign slice shows:
// ♉ Taurus · Stone
// Clicking the same sign again closes the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — sign ring click tooltips", () => {
const SIGN_CHART = {
planets: {
Sun: { sign: "Taurus", degree: 40.0, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 0, Stone: 1, Air: 0, Water: 0, Time: 0, Space: 0 },
aspects: [],
distinctions: { "1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0 },
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, SIGN_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T12a — clicking a sign shows symbol + name + element
it("T12a: clicking a sign slice shows sign symbol, name, and element", () => {
const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']");
expect(taurusSlice).not.toBeNull();
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyText).toContain("Taurus");
expect(bodyText).toContain("Stone");
expect(bodyText).toContain("♉");
});
// T12b — clicking same sign again closes the tooltip
it("T12b: clicking the same sign again hides the tooltip", () => {
const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']");
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
});
// ── T13 — house ring click tooltips ───────────────────────────────────────────
//
// Clicking a house slice shows:
// 3 · Education
// Clicking the same house again closes the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — house ring click tooltips", () => {
const HOUSE_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 1, Stone: 0, Air: 0, Water: 0, Time: 0, Space: 0 },
aspects: [],
distinctions: { "1":0,"2":0,"3":1,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0 },
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, HOUSE_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T13a — clicking a house slice shows house number + label
it("T13a: clicking a house slice shows house number and house label", () => {
// House 3 is at index 2 (zero-based), cusps[2]=60° span
const house3 = svgEl.querySelector("[data-house='3']");
expect(house3).not.toBeNull();
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyText).toContain("3");
expect(bodyText).toContain("Education");
});
// T13b — clicking same house again closes the tooltip
it("T13b: clicking the same house again hides the tooltip", () => {
const house3 = svgEl.querySelector("[data-house='3']");
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
});
// ── T15 — ASC / MC angle click tooltips + aspect lines ────────────────────────
//
// ASC and MC labels are clickable groups ([data-angle='ASC'] / [data-angle='MC']).
// Clicking shows a tooltip similar to a planet tooltip:
// Title: "Ascendant" (ASC) or "Midheaven" (MC)
// Degree in sign + sign name, plus the house number the angle defines.
// Aspect list for planets within 10° orb of the angle (client-side computed).
// Aspect lines drawn to those planets.
// Clicking same angle again closes the tooltip.
// Planet tooltips include angle aspects in their own aspect lists.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — angle (ASC/MC) click tooltips", () => {
// ASC=0°(Aries): Sun@8° → Conjunction orb 8° ✓; Mars@188° → Opposition orb 8° ✓
// MC=90°(Cancer): Moon@97° → Conjunction orb 7° ✓
const ANGLE_CHART = {
planets: {
Sun: { sign: "Aries", degree: 8.0, retrograde: false },
Moon: { sign: "Cancer", degree: 97.0, retrograde: false },
Mars: { sign: "Libra", degree: 188.0, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0.0,
mc: 90.0,
},
elements: { Fire: 2, Stone: 0, Air: 1, Water: 1, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1":1,"2":0,"3":0,"4":1,"5":0,"6":0,
"7":1,"8":0,"9":0,"10":1,"11":0,"12":0,
},
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, ANGLE_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T15a — ASC label is a clickable group with data-angle='ASC'
it("T15a: clicking [data-angle='ASC'] shows tooltip with 'Ascendant' title and house 1", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
expect(ascGroup).not.toBeNull("expected [data-angle='ASC'] to exist in the SVG");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Ascendant");
// ASC=0° is the cusp of House 1
expect(text).toContain("1");
});
// T15b — MC label is a clickable group with data-angle='MC'
it("T15b: clicking [data-angle='MC'] shows tooltip with 'Midheaven' title and house 10", () => {
const mcGroup = svgEl.querySelector("[data-angle='MC']");
expect(mcGroup).not.toBeNull("expected [data-angle='MC'] to exist in the SVG");
mcGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Midheaven");
expect(text).toContain("10");
});
// T15c — ASC tooltip shows degree-in-sign and sign name
it("T15c: ASC tooltip shows the in-sign degree and sign of the Ascendant", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const text = tooltipEl.textContent;
// ASC=0° → 0° Aries
expect(text).toContain("Aries");
expect(text).toContain("0.0");
});
// T15d — clicking same angle a second time hides the tooltip
it("T15d: clicking the same angle again hides the tooltip", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
// T15e — ASC tooltip lists planets within 10° orb (client-side computed)
it("T15e: ASC tooltip includes aspect rows for planets within 10° orb", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
// Sun at 8° → Conjunction (orb 8°) ✓ — _pSym emits data-planet attr
expect(bodyHtml).toContain('data-planet="Sun"');
// Mars at 188° → Opposition to ASC (0°), angular distance 172° → orb 8° ✓
expect(bodyHtml).toContain('data-planet="Mars"');
});
// T15f — planet tooltip includes ASC in its aspect list when within orb
it("T15f: planet tooltip for Sun lists ASC as an aspect partner", () => {
const sunGroup = svgEl.querySelector("[data-planet='Sun']");
expect(sunGroup).not.toBeNull();
sunGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyHtml).toContain("ASC");
});
// T15g — NXT from a planet steps into an angle when angle is next by degree
// ANGLE_CHART sorted descending: Mars(188)→Moon(97)→MC(90)→Sun(8)→ASC(0)
// Moon is idx 1; NXT steps to MC (idx 2).
it("T15g: clicking NXT from Moon (97°) activates MC (90°, next clockwise)", () => {
const moonGroup = svgEl.querySelector("[data-planet='Moon']");
expect(moonGroup).not.toBeNull("expected [data-planet='Moon']");
moonGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Midheaven");
const mcGroup = svgEl.querySelector("[data-angle='MC']");
expect(mcGroup.classList.contains("nw-angle--active")).toBe(true);
expect(moonGroup.classList.contains("nw-planet--active")).toBe(false);
});
// T15h — PRV from an angle steps back into a planet
it("T15h: clicking PRV from MC (90°) activates Moon (97°, previous counterclockwise)", () => {
const mcGroup = svgEl.querySelector("[data-angle='MC']");
mcGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const moonGroup = svgEl.querySelector("[data-planet='Moon']");
expect(moonGroup.classList.contains("nw-planet--active")).toBe(true);
expect(mcGroup.classList.contains("nw-angle--active")).toBe(false);
});
// T15i — NXT from ASC (lowest degree, 0°) wraps to Mars (highest degree, 188°)
it("T15i: NXT from ASC (0°, lowest) wraps clockwise to Mars (188°, highest)", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const marsGroup = svgEl.querySelector("[data-planet='Mars']");
expect(marsGroup.classList.contains("nw-planet--active")).toBe(true);
});
});