aether-shards/test/ui/skill-tree-ui.test.js

613 lines
21 KiB
JavaScript
Raw Normal View History

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
});
});
});