2025-12-28 00:54:03 +00:00
|
|
|
import { expect } from "@esm-bundle/chai";
|
2026-01-02 00:08:54 +00:00
|
|
|
import { SkillTreeUI } from "../../src/ui/components/skill-tree-ui.js";
|
2025-12-28 00:54:03 +00:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|