import { expect } from "@esm-bundle/chai"; import { SkillTreeUI } from "../../src/ui/components/SkillTreeUI.js"; import { Explorer } from "../../src/units/Explorer.js"; import vanguardDef from "../../src/assets/data/classes/vanguard.json" with { type: "json", }; describe("UI: SkillTreeUI", () => { let element; let container; let testUnit; let mockTreeDef; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); element = document.createElement("skill-tree-ui"); container.appendChild(element); // Create a test Explorer unit testUnit = new Explorer("test-unit-1", "Test Vanguard", "CLASS_VANGUARD", vanguardDef); testUnit.classMastery["CLASS_VANGUARD"] = { level: 5, xp: 250, skillPoints: 3, unlockedNodes: [], }; testUnit.recalculateBaseStats(vanguardDef); // Create mock tree definition mockTreeDef = { id: "TREE_TEST", nodes: { ROOT: { id: "ROOT", tier: 1, type: "STAT_BOOST", children: ["NODE_1", "NODE_2"], data: { stat: "health", value: 10 }, req: 1, cost: 1, }, NODE_1: { id: "NODE_1", tier: 2, type: "ACTIVE_SKILL", children: ["NODE_3"], data: { name: "Shield Bash", id: "SKILL_SHIELD_BASH" }, req: 2, cost: 1, }, NODE_2: { id: "NODE_2", tier: 2, type: "STAT_BOOST", children: [], data: { stat: "defense", value: 5 }, req: 2, cost: 1, }, NODE_3: { id: "NODE_3", tier: 3, type: "PASSIVE_ABILITY", children: [], data: { name: "Iron Skin", id: "PASSIVE_IRON_SKIN" }, req: 3, cost: 2, }, }, }; }); afterEach(() => { if (container.parentNode) { container.parentNode.removeChild(container); } }); // Helper to wait for LitElement update async function waitForUpdate() { await element.updateComplete; await new Promise((resolve) => setTimeout(resolve, 50)); } // Helper to query shadow DOM function queryShadow(selector) { return element.shadowRoot?.querySelector(selector); } function queryShadowAll(selector) { return element.shadowRoot?.querySelectorAll(selector) || []; } describe("CoA 1: Dynamic Rendering", () => { it("should render tree with variable tier depths", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const tierRows = queryShadowAll(".tier-row"); expect(tierRows.length).to.be.greaterThan(0); // Should have nodes from different tiers const nodes = queryShadowAll(".voxel-node"); expect(nodes.length).to.equal(4); // ROOT, NODE_1, NODE_2, NODE_3 }); it("should update node states immediately when unit changes", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Initially, ROOT should be available (level 5 >= req 1) const rootNode = queryShadow('[data-node-id="ROOT"]'); expect(rootNode).to.exist; expect(rootNode.classList.contains("available")).to.be.true; // Unlock ROOT testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; element.unit = { ...testUnit }; // Trigger update await waitForUpdate(); const updatedRootNode = queryShadow('[data-node-id="ROOT"]'); expect(updatedRootNode.classList.contains("unlocked")).to.be.true; }); it("should handle tier 1 to tier 5 nodes", async () => { const multiTierTree = { id: "TREE_MULTI", nodes: { T1: { id: "T1", tier: 1, type: "STAT_BOOST", children: ["T2"], req: 1, cost: 1 }, T2: { id: "T2", tier: 2, type: "STAT_BOOST", children: ["T3"], req: 2, cost: 1 }, T3: { id: "T3", tier: 3, type: "STAT_BOOST", children: ["T4"], req: 3, cost: 1 }, T4: { id: "T4", tier: 4, type: "STAT_BOOST", children: ["T5"], req: 4, cost: 1 }, T5: { id: "T5", tier: 5, type: "STAT_BOOST", children: [], req: 5, cost: 1 }, }, }; element.unit = testUnit; element.treeDef = multiTierTree; await waitForUpdate(); const tierRows = queryShadowAll(".tier-row"); expect(tierRows.length).to.equal(5); }); }); describe("CoA 2: Validation Feedback", () => { it("should show inspector with disabled button for locked node", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Click on NODE_3 which requires NODE_1 to be unlocked first const node3 = queryShadow('[data-node-id="NODE_3"]'); node3.click(); await waitForUpdate(); const inspector = queryShadow(".inspector"); expect(inspector.classList.contains("visible")).to.be.true; const unlockButton = queryShadow(".unlock-button"); expect(unlockButton.hasAttribute("disabled")).to.be.true; const errorMessage = queryShadow(".error-message"); expect(errorMessage).to.exist; expect(errorMessage.textContent).to.include("Requires"); }); it("should show 'Insufficient Points' for available node with 0 SP", async () => { testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 0; testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Click on NODE_1 which is available but costs 1 SP const node1 = queryShadow('[data-node-id="NODE_1"]'); node1.click(); await waitForUpdate(); const errorMessage = queryShadow(".error-message"); expect(errorMessage).to.exist; expect(errorMessage.textContent).to.include("Insufficient Points"); const unlockButton = queryShadow(".unlock-button"); expect(unlockButton.hasAttribute("disabled")).to.be.true; }); it("should enable unlock button for available node with sufficient SP", async () => { testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 3; testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Click on NODE_1 which is available const node1 = queryShadow('[data-node-id="NODE_1"]'); node1.click(); await waitForUpdate(); const unlockButton = queryShadow(".unlock-button"); expect(unlockButton.hasAttribute("disabled")).to.be.false; expect(unlockButton.textContent).to.include("Unlock"); }); it("should show 'Unlocked' state for already unlocked nodes", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const rootNode = queryShadow('[data-node-id="ROOT"]'); rootNode.click(); await waitForUpdate(); const unlockButton = queryShadow(".unlock-button"); expect(unlockButton.textContent).to.include("Unlocked"); expect(unlockButton.hasAttribute("disabled")).to.be.true; }); }); describe("CoA 3: Responsive Lines", () => { it("should draw connection lines between nodes", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Trigger connection update element._updateConnections(); await waitForUpdate(); const svg = queryShadow(".connections-overlay svg"); expect(svg).to.exist; const paths = queryShadowAll(".connections-overlay svg path"); expect(paths.length).to.be.greaterThan(0); }); it("should update connection lines on resize", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Trigger initial connection update element._updateConnections(); await waitForUpdate(); const initialPaths = queryShadowAll(".connections-overlay svg path"); const initialCount = initialPaths.length; expect(initialCount).to.be.greaterThan(0); // Simulate resize by changing container size const container = queryShadow(".tree-container"); container.style.width = "200px"; container.style.height = "200px"; // Wait for ResizeObserver to trigger await new Promise((resolve) => setTimeout(resolve, 200)); await waitForUpdate(); // Paths should still exist (may have been redrawn) const pathsAfterResize = queryShadowAll(".connections-overlay svg path"); expect(pathsAfterResize.length).to.equal(initialCount); }); it("should style connection lines based on child node status", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Trigger connection update element._updateConnections(); await waitForUpdate(); // ROOT -> NODE_1 connection const paths = queryShadowAll(".connections-overlay svg path"); expect(paths.length).to.be.greaterThan(0); // Verify paths exist (they should have status classes applied by _updateConnections) // Note: Paths may not have classes if nodes aren't rendered yet, which is acceptable expect(paths.length).to.be.greaterThan(0); // Unlock ROOT and NODE_1 by directly modifying the unit's classMastery testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT", "NODE_1"]; // Trigger update by setting the unit property again (Lit will detect the change) element.unit = { ...testUnit }; // Also increment updateTrigger to force re-render element.updateTrigger = (element.updateTrigger || 0) + 1; await waitForUpdate(); // Trigger connection update after state change element._updateConnections(); await waitForUpdate(); await new Promise((resolve) => setTimeout(resolve, 100)); // Connection to NODE_1 should now be unlocked style // (Connection from ROOT to NODE_1, where NODE_1 is now unlocked) const updatedPaths = queryShadowAll(".connections-overlay svg path"); expect(updatedPaths.length).to.be.greaterThan(0); // Verify NODE_1 exists and has been updated const node1 = queryShadow('[data-node-id="NODE_1"]'); expect(node1).to.exist; // Node should have a status class (unlocked, available, or locked) const node1HasStatusClass = node1.classList.contains("unlocked") || node1.classList.contains("available") || node1.classList.contains("locked"); expect(node1HasStatusClass).to.be.true; // Connection styling is based on child status // Verify that paths have status classes and were updated // Paths have class "connection-line" plus status class const allPathClasses = Array.from(updatedPaths).map((p) => Array.from(p.classList)); const pathHasStatusClass = allPathClasses.some((classes) => classes.includes("connection-line") && (classes.includes("locked") || classes.includes("available") || classes.includes("unlocked")) ); expect(pathHasStatusClass).to.be.true; }); }); describe("CoA 4: Scroll Position", () => { it("should scroll to highest tier with available node on open", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT", "NODE_1"]; testUnit.classMastery["CLASS_VANGUARD"].level = 5; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // NODE_3 should be available (tier 3, parent NODE_1 is unlocked, level 5 >= req 3) // The scroll should center on NODE_3 await new Promise((resolve) => setTimeout(resolve, 200)); const node3 = queryShadow('[data-node-id="NODE_3"]'); expect(node3).to.exist; // Note: scrollIntoView behavior is hard to test in headless, but we verify the node exists }); it("should handle case where no nodes are available", async () => { testUnit.classMastery["CLASS_VANGUARD"].level = 1; testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = []; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Should not crash, tree should still render const treeContainer = queryShadow(".tree-container"); expect(treeContainer).to.exist; }); }); describe("Node Status Calculation", () => { it("should mark node as UNLOCKED if in unlockedNodes", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const rootNode = queryShadow('[data-node-id="ROOT"]'); expect(rootNode.classList.contains("unlocked")).to.be.true; }); it("should mark node as AVAILABLE if parent unlocked and level requirement met", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; testUnit.classMastery["CLASS_VANGUARD"].level = 2; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const node1 = queryShadow('[data-node-id="NODE_1"]'); expect(node1.classList.contains("available")).to.be.true; }); it("should mark node as LOCKED if parent not unlocked", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = []; testUnit.classMastery["CLASS_VANGUARD"].level = 5; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const node1 = queryShadow('[data-node-id="NODE_1"]'); expect(node1.classList.contains("locked")).to.be.true; }); it("should mark node as LOCKED if level requirement not met", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; testUnit.classMastery["CLASS_VANGUARD"].level = 1; // Below NODE_1 req of 2 element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const node1 = queryShadow('[data-node-id="NODE_1"]'); expect(node1.classList.contains("locked")).to.be.true; }); }); describe("Inspector Footer", () => { it("should show inspector when node is clicked", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const rootNode = queryShadow('[data-node-id="ROOT"]'); rootNode.click(); await waitForUpdate(); const inspector = queryShadow(".inspector"); expect(inspector.classList.contains("visible")).to.be.true; }); it("should hide inspector when close button is clicked", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const rootNode = queryShadow('[data-node-id="ROOT"]'); rootNode.click(); await waitForUpdate(); const closeButton = queryShadow(".inspector-close"); closeButton.click(); await waitForUpdate(); const inspector = queryShadow(".inspector"); expect(inspector.classList.contains("visible")).to.be.false; }); it("should display node information in inspector", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const node1 = queryShadow('[data-node-id="NODE_1"]'); node1.click(); await waitForUpdate(); const title = queryShadow(".inspector-title"); expect(title.textContent).to.include("Shield Bash"); }); it("should dispatch unlock-request event when unlock button is clicked", async () => { testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 3; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); let unlockEventFired = false; let unlockEventDetail = null; element.addEventListener("unlock-request", (e) => { unlockEventFired = true; unlockEventDetail = e.detail; }); const node1 = queryShadow('[data-node-id="NODE_1"]'); node1.click(); await waitForUpdate(); const unlockButton = queryShadow(".unlock-button"); unlockButton.click(); await waitForUpdate(); expect(unlockEventFired).to.be.true; expect(unlockEventDetail.nodeId).to.equal("NODE_1"); expect(unlockEventDetail.cost).to.equal(1); }); it("should update node display when updateTrigger changes", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Initially ROOT should be available const rootNode = queryShadow('[data-node-id="ROOT"]'); expect(rootNode.classList.contains("available")).to.be.true; // Unlock the node in the unit testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; // Increment updateTrigger to force re-render element.updateTrigger = (element.updateTrigger || 0) + 1; await waitForUpdate(); // Now ROOT should show as unlocked const updatedRootNode = queryShadow('[data-node-id="ROOT"]'); expect(updatedRootNode.classList.contains("unlocked")).to.be.true; }); it("should update inspector footer when updateTrigger changes after unlock", async () => { testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2; element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); // Click on ROOT node const rootNode = queryShadow('[data-node-id="ROOT"]'); rootNode.click(); await waitForUpdate(); // Initially should show "Unlock" button const unlockButton = queryShadow(".unlock-button"); expect(unlockButton.textContent).to.include("Unlock"); // Simulate unlock by updating unit and incrementing updateTrigger testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 1; element.updateTrigger = (element.updateTrigger || 0) + 1; await waitForUpdate(); // Now should show "Unlocked" button const updatedUnlockButton = queryShadow(".unlock-button"); expect(updatedUnlockButton.textContent).to.include("Unlocked"); expect(updatedUnlockButton.hasAttribute("disabled")).to.be.true; }); }); describe("Voxel Node Rendering", () => { it("should render voxel cubes with 6 faces", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const rootNode = queryShadow('[data-node-id="ROOT"]'); const faces = rootNode.querySelectorAll(".cube-face"); expect(faces.length).to.equal(6); }); it("should apply correct CSS classes based on node status", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const rootNode = queryShadow('[data-node-id="ROOT"]'); expect(rootNode.classList.contains("available")).to.be.true; testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"]; element.unit = { ...testUnit }; await waitForUpdate(); const updatedRootNode = queryShadow('[data-node-id="ROOT"]'); expect(updatedRootNode.classList.contains("unlocked")).to.be.true; }); it("should display appropriate icons for different node types", async () => { element.unit = testUnit; element.treeDef = mockTreeDef; await waitForUpdate(); const statNode = queryShadow('[data-node-id="ROOT"] .node-icon'); expect(statNode.textContent).to.include("📈"); const skillNode = queryShadow('[data-node-id="NODE_1"] .node-icon'); expect(skillNode.textContent).to.include("⚔️"); }); }); describe("Edge Cases", () => { it("should handle missing unit gracefully", async () => { element.unit = null; element.treeDef = mockTreeDef; await waitForUpdate(); const placeholder = queryShadow(".placeholder"); expect(placeholder).to.exist; expect(placeholder.textContent).to.include("No unit selected"); }); it("should handle missing tree definition gracefully", async () => { element.unit = testUnit; element.treeDef = null; await waitForUpdate(); // Should fall back to mock tree or show placeholder const treeContainer = queryShadow(".tree-container"); expect(treeContainer).to.exist; }); it("should handle nodes without children", async () => { const treeWithLeafNodes = { id: "TREE_LEAF", nodes: { LEAF: { id: "LEAF", tier: 1, type: "STAT_BOOST", children: [], req: 1, cost: 1 }, }, }; element.unit = testUnit; element.treeDef = treeWithLeafNodes; await waitForUpdate(); // Trigger connection update (should not crash with no children) element._updateConnections(); await waitForUpdate(); const svg = queryShadow(".connections-overlay svg"); expect(svg).to.exist; // Should not crash when drawing connections }); }); });