// ── NatusWheelSpec.js ───────────────────────────────────────────────────────── // // Unit specs for natus-wheel.js — planet/element click-to-lock tooltips. // // DOM contract assumed: // — target for NatusWheel.draw() //
— 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) ──────── it("T9n: clicking PRV from Sun shows Mars (previous planet counterclockwise = higher degree)", () => { 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("Mars"); const mars = svgEl2.querySelector("[data-planet='Mars']"); expect(mars.classList.contains("nw-planet--active")).toBe(true); }); // ── T9w ── NXT wraps clockwise from the last (lowest-degree) planet ─────── it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to Mars (highest degree)", () => { // Venus is idx 2 (lowest degree = furthest clockwise); NXT wraps to idx 0 = Mars 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("Mars"); const mars = svgEl2.querySelector("[data-planet='Mars']"); expect(mars.classList.contains("nw-planet--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); }); }); 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); }); });