906 lines
28 KiB
JavaScript
906 lines
28 KiB
JavaScript
|
|
import { expect } from "@esm-bundle/chai";
|
||
|
|
import { CharacterSheet } from "../../src/ui/components/CharacterSheet.js";
|
||
|
|
import { Explorer } from "../../src/units/Explorer.js";
|
||
|
|
import { Item } from "../../src/items/Item.js";
|
||
|
|
import vanguardDef from "../../src/assets/data/classes/vanguard.json" with {
|
||
|
|
type: "json",
|
||
|
|
};
|
||
|
|
|
||
|
|
// Import SkillTreeUI to register the custom element
|
||
|
|
import "../../src/ui/components/SkillTreeUI.js";
|
||
|
|
|
||
|
|
describe("UI: CharacterSheet", () => {
|
||
|
|
let element;
|
||
|
|
let container;
|
||
|
|
let testUnit;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
container = document.createElement("div");
|
||
|
|
document.body.appendChild(container);
|
||
|
|
element = document.createElement("character-sheet");
|
||
|
|
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: 2,
|
||
|
|
unlockedNodes: [],
|
||
|
|
};
|
||
|
|
testUnit.recalculateBaseStats(vanguardDef);
|
||
|
|
testUnit.currentHealth = 100;
|
||
|
|
testUnit.maxHealth = 120;
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
if (container.parentNode) {
|
||
|
|
container.parentNode.removeChild(container);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Helper to wait for LitElement update
|
||
|
|
async function waitForUpdate() {
|
||
|
|
await element.updateComplete;
|
||
|
|
// Give a small delay for DOM updates
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper to query shadow DOM
|
||
|
|
function queryShadow(selector) {
|
||
|
|
return element.shadowRoot?.querySelector(selector);
|
||
|
|
}
|
||
|
|
|
||
|
|
function queryShadowAll(selector) {
|
||
|
|
return element.shadowRoot?.querySelectorAll(selector) || [];
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("CoA 1: Stat Rendering", () => {
|
||
|
|
it("should render stats with effective values", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Check that stat values are displayed
|
||
|
|
const statValues = queryShadowAll('.stat-value');
|
||
|
|
expect(statValues.length).to.be.greaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should show stat breakdown in tooltip on hover", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const statItem = queryShadow(".stat-item");
|
||
|
|
expect(statItem).to.exist;
|
||
|
|
|
||
|
|
const tooltip = statItem.querySelector(".tooltip");
|
||
|
|
expect(tooltip).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display debuffed stats in red", async () => {
|
||
|
|
testUnit.statusEffects = [
|
||
|
|
{
|
||
|
|
id: "WEAKNESS",
|
||
|
|
name: "Weakness",
|
||
|
|
statModifiers: { attack: -5 },
|
||
|
|
},
|
||
|
|
];
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const attackStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("Attack")
|
||
|
|
);
|
||
|
|
expect(attackStat).to.exist;
|
||
|
|
expect(attackStat.classList.contains("debuffed")).to.be.true;
|
||
|
|
|
||
|
|
const statValue = attackStat.querySelector(".stat-value");
|
||
|
|
expect(statValue.classList.contains("debuffed")).to.be.true;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display buffed stats in green", async () => {
|
||
|
|
testUnit.statusEffects = [
|
||
|
|
{
|
||
|
|
id: "STRENGTH",
|
||
|
|
name: "Strength",
|
||
|
|
statModifiers: { attack: +5 },
|
||
|
|
},
|
||
|
|
];
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const attackStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("Attack")
|
||
|
|
);
|
||
|
|
expect(attackStat).to.exist;
|
||
|
|
expect(attackStat.classList.contains("buffed")).to.be.true;
|
||
|
|
|
||
|
|
const statValue = attackStat.querySelector(".stat-value");
|
||
|
|
expect(statValue.classList.contains("buffed")).to.be.true;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should calculate effective stats from base + equipment + buffs", async () => {
|
||
|
|
const weapon = new Item({
|
||
|
|
id: "ITEM_TEST_SWORD",
|
||
|
|
name: "Test Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
stats: { attack: 10 },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Reset to level 1 to get base stats
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].level = 1;
|
||
|
|
testUnit.recalculateBaseStats(vanguardDef);
|
||
|
|
|
||
|
|
testUnit.equipment.weapon = weapon;
|
||
|
|
testUnit.statusEffects = [
|
||
|
|
{
|
||
|
|
id: "BUFF",
|
||
|
|
name: "Power Boost",
|
||
|
|
statModifiers: { attack: 3 },
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Base attack from vanguard level 1 is 12, +10 from weapon, +3 from buff = 25
|
||
|
|
const attackStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("Attack")
|
||
|
|
);
|
||
|
|
const statValue = attackStat.querySelector(".stat-value");
|
||
|
|
const totalValue = parseInt(statValue.textContent.trim());
|
||
|
|
expect(totalValue).to.equal(25);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display health bar with current/max values", async () => {
|
||
|
|
testUnit.currentHealth = 80;
|
||
|
|
testUnit.maxHealth = 120;
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const healthBar = queryShadow(".health-bar-container");
|
||
|
|
expect(healthBar).to.exist;
|
||
|
|
|
||
|
|
const healthLabel = queryShadow(".health-label");
|
||
|
|
expect(healthLabel.textContent).to.include("80");
|
||
|
|
expect(healthLabel.textContent).to.include("120");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display AP icons based on speed", async () => {
|
||
|
|
// Speed = 8, so AP = 3 + floor(8/5) = 3 + 1 = 4
|
||
|
|
testUnit.baseStats.speed = 8;
|
||
|
|
testUnit.currentAP = 3;
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Find the AP stat item (which shows AP icons)
|
||
|
|
const apStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("AP")
|
||
|
|
);
|
||
|
|
expect(apStat).to.exist;
|
||
|
|
|
||
|
|
const apIcons = apStat.querySelectorAll(".ap-icon");
|
||
|
|
expect(apIcons.length).to.equal(4); // Max AP
|
||
|
|
|
||
|
|
const emptyIcons = apStat.querySelectorAll(".ap-icon.empty");
|
||
|
|
expect(emptyIcons.length).to.equal(1); // One empty (4 total - 3 used)
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("CoA 2: Equipment Swapping", () => {
|
||
|
|
it("should show equipment slots in paper doll", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Use the new class names (mainHand, offHand, body, accessory)
|
||
|
|
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||
|
|
const armorSlot = queryShadow(".equipment-slot.body");
|
||
|
|
const relicSlot = queryShadow(".equipment-slot.accessory");
|
||
|
|
const utilitySlot = queryShadow(".equipment-slot.offHand");
|
||
|
|
|
||
|
|
expect(weaponSlot).to.exist;
|
||
|
|
expect(armorSlot).to.exist;
|
||
|
|
expect(relicSlot).to.exist;
|
||
|
|
expect(utilitySlot).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should show ghost icon for empty slots", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||
|
|
const slotIcon = weaponSlot.querySelector(".slot-icon");
|
||
|
|
expect(slotIcon).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should show item icon for equipped items", async () => {
|
||
|
|
// Use the new loadout system
|
||
|
|
testUnit.loadout.mainHand = {
|
||
|
|
uid: "ITEM_TEST_SWORD_1",
|
||
|
|
defId: "ITEM_TEST_SWORD",
|
||
|
|
isNew: false,
|
||
|
|
quantity: 1,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Mock inventoryManager with item registry
|
||
|
|
const mockItemRegistry = new Map();
|
||
|
|
mockItemRegistry.set("ITEM_TEST_SWORD", {
|
||
|
|
id: "ITEM_TEST_SWORD",
|
||
|
|
name: "Test Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
icon: "⚔️",
|
||
|
|
});
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventoryManager = {
|
||
|
|
itemRegistry: mockItemRegistry,
|
||
|
|
};
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||
|
|
const itemIcon = weaponSlot.querySelector(".item-icon");
|
||
|
|
expect(itemIcon).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should switch to inventory tab when slot is clicked", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventory = [];
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||
|
|
weaponSlot.click();
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
expect(element.activeTab).to.equal("INVENTORY");
|
||
|
|
expect(element.selectedSlot).to.equal("MAIN_HAND");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should filter inventory by slot type when slot is selected", async () => {
|
||
|
|
const weapon1 = new Item({
|
||
|
|
id: "ITEM_SWORD",
|
||
|
|
name: "Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
});
|
||
|
|
const weapon2 = new Item({
|
||
|
|
id: "ITEM_AXE",
|
||
|
|
name: "Axe",
|
||
|
|
type: "WEAPON",
|
||
|
|
});
|
||
|
|
const armor = new Item({
|
||
|
|
id: "ITEM_PLATE",
|
||
|
|
name: "Plate",
|
||
|
|
type: "ARMOR",
|
||
|
|
});
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventory = [weapon1, weapon2, armor];
|
||
|
|
element.selectedSlot = "WEAPON";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const itemCards = queryShadowAll(".item-card");
|
||
|
|
expect(itemCards.length).to.equal(2); // Only weapons
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should equip item when clicked in inventory", async () => {
|
||
|
|
const weapon = new Item({
|
||
|
|
id: "ITEM_SWORD",
|
||
|
|
name: "Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
stats: { attack: 10 },
|
||
|
|
});
|
||
|
|
const oldWeapon = new Item({
|
||
|
|
id: "ITEM_OLD_SWORD",
|
||
|
|
name: "Old Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
});
|
||
|
|
|
||
|
|
testUnit.equipment.weapon = oldWeapon;
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventory = [weapon];
|
||
|
|
element.selectedSlot = "WEAPON";
|
||
|
|
|
||
|
|
let equipEventFired = false;
|
||
|
|
let equipEventDetail = null;
|
||
|
|
element.addEventListener("equip-item", (e) => {
|
||
|
|
equipEventFired = true;
|
||
|
|
equipEventDetail = e.detail;
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const itemCard = queryShadow(".item-card");
|
||
|
|
itemCard.click();
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
expect(equipEventFired).to.be.true;
|
||
|
|
expect(equipEventDetail.unitId).to.equal(testUnit.id);
|
||
|
|
expect(equipEventDetail.slot).to.equal("WEAPON");
|
||
|
|
expect(equipEventDetail.item.id).to.equal("ITEM_SWORD");
|
||
|
|
expect(equipEventDetail.oldItem.id).to.equal("ITEM_OLD_SWORD");
|
||
|
|
|
||
|
|
// Old item should be in inventory
|
||
|
|
expect(element.inventory.some((item) => item.id === "ITEM_OLD_SWORD")).to.be
|
||
|
|
.true;
|
||
|
|
// New item should be equipped
|
||
|
|
expect(testUnit.equipment.weapon.id).to.equal("ITEM_SWORD");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should update stats immediately after equipping", async () => {
|
||
|
|
const weapon = new Item({
|
||
|
|
id: "ITEM_SWORD",
|
||
|
|
name: "Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
stats: { attack: 15 },
|
||
|
|
});
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventory = [weapon];
|
||
|
|
element.selectedSlot = "WEAPON";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const initialAttack = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("Attack")
|
||
|
|
);
|
||
|
|
const initialValue = parseInt(
|
||
|
|
initialAttack.querySelector(".stat-value").textContent.trim()
|
||
|
|
);
|
||
|
|
|
||
|
|
const itemCard = queryShadow(".item-card");
|
||
|
|
itemCard.click();
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const updatedAttack = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("Attack")
|
||
|
|
);
|
||
|
|
const updatedValue = parseInt(
|
||
|
|
updatedAttack.querySelector(".stat-value").textContent.trim()
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(updatedValue).to.equal(initialValue + 15);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should not allow equipping in read-only mode", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.readOnly = true;
|
||
|
|
element.inventory = [
|
||
|
|
new Item({
|
||
|
|
id: "ITEM_SWORD",
|
||
|
|
name: "Sword",
|
||
|
|
type: "WEAPON",
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
element.selectedSlot = "MAIN_HAND";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||
|
|
expect(weaponSlot.hasAttribute("disabled")).to.be.true;
|
||
|
|
|
||
|
|
const itemCard = queryShadow(".item-card");
|
||
|
|
const initialWeapon = testUnit.equipment.weapon;
|
||
|
|
itemCard.click();
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Equipment should not have changed
|
||
|
|
expect(testUnit.equipment.weapon).to.equal(initialWeapon);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("CoA 3: Skill Interaction", () => {
|
||
|
|
it("should display skill tree tab", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.activeTab = "SKILLS";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const skillsTab = Array.from(queryShadowAll(".tab-button")).find((btn) =>
|
||
|
|
btn.textContent.includes("Skills")
|
||
|
|
);
|
||
|
|
expect(skillsTab).to.exist;
|
||
|
|
|
||
|
|
const skillsContainer = queryShadow(".skills-container");
|
||
|
|
expect(skillsContainer).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should embed skill-tree-ui component", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.activeTab = "SKILLS";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const skillTree = queryShadow("skill-tree-ui");
|
||
|
|
expect(skillTree).to.exist;
|
||
|
|
expect(skillTree.unit).to.equal(testUnit);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display SP badge when skill points are available", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 3;
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const spBadge = queryShadow(".sp-badge");
|
||
|
|
expect(spBadge).to.exist;
|
||
|
|
expect(spBadge.textContent).to.include("SP: 3");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should not display SP badge when no skill points", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 0;
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const spBadge = queryShadow(".sp-badge");
|
||
|
|
expect(spBadge).to.be.null;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should handle unlock-request and update unit stats", async () => {
|
||
|
|
// Set up unit with skill points and mock recalculateStats
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||
|
|
testUnit.maxHealth = 100;
|
||
|
|
testUnit.currentHealth = 100;
|
||
|
|
|
||
|
|
let recalculateStatsCalled = false;
|
||
|
|
let recalculateStatsArgs = null;
|
||
|
|
testUnit.recalculateStats = (itemRegistry, treeDef) => {
|
||
|
|
recalculateStatsCalled = true;
|
||
|
|
recalculateStatsArgs = { itemRegistry, treeDef };
|
||
|
|
// Simulate stat boost application
|
||
|
|
testUnit.maxHealth = 110; // Base 100 + 10 from health boost
|
||
|
|
};
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.activeTab = "SKILLS";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Create mock tree definition
|
||
|
|
const mockTreeDef = {
|
||
|
|
id: "TREE_TEST",
|
||
|
|
nodes: {
|
||
|
|
ROOT: {
|
||
|
|
id: "ROOT",
|
||
|
|
tier: 1,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "health", value: 10 },
|
||
|
|
req: 1,
|
||
|
|
cost: 1,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
element.treeDef = mockTreeDef;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Call the handler directly (since it's a private method, we'll simulate the event)
|
||
|
|
const unlockEvent = new CustomEvent("unlock-request", {
|
||
|
|
detail: { nodeId: "ROOT", cost: 1 },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
});
|
||
|
|
// Simulate the event being handled by calling the method directly
|
||
|
|
element._handleUnlockRequest(unlockEvent);
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Verify node was unlocked
|
||
|
|
expect(testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes).to.include("ROOT");
|
||
|
|
expect(testUnit.classMastery["CLASS_VANGUARD"].skillPoints).to.equal(1);
|
||
|
|
|
||
|
|
// Verify recalculateStats was called with correct args
|
||
|
|
expect(recalculateStatsCalled).to.be.true;
|
||
|
|
expect(recalculateStatsArgs.treeDef).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should dispatch skill-unlocked event after unlocking", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||
|
|
testUnit.recalculateStats = () => {}; // Mock function
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
let skillUnlockedEventFired = false;
|
||
|
|
let skillUnlockedEventDetail = null;
|
||
|
|
element.addEventListener("skill-unlocked", (e) => {
|
||
|
|
skillUnlockedEventFired = true;
|
||
|
|
skillUnlockedEventDetail = e.detail;
|
||
|
|
});
|
||
|
|
|
||
|
|
const unlockEvent = new CustomEvent("unlock-request", {
|
||
|
|
detail: { nodeId: "ROOT", cost: 1 },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
});
|
||
|
|
// Call the handler directly
|
||
|
|
element._handleUnlockRequest(unlockEvent);
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
expect(skillUnlockedEventFired).to.be.true;
|
||
|
|
expect(skillUnlockedEventDetail.unitId).to.equal(testUnit.id);
|
||
|
|
expect(skillUnlockedEventDetail.nodeId).to.equal("ROOT");
|
||
|
|
expect(skillUnlockedEventDetail.cost).to.equal(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should update SkillTreeUI after unlocking", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||
|
|
testUnit.recalculateStats = () => {}; // Mock function
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.activeTab = "SKILLS";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const skillTree = queryShadow("skill-tree-ui");
|
||
|
|
expect(skillTree).to.exist;
|
||
|
|
|
||
|
|
const initialUpdateTrigger = skillTree.updateTrigger || 0;
|
||
|
|
|
||
|
|
const unlockEvent = new CustomEvent("unlock-request", {
|
||
|
|
detail: { nodeId: "ROOT", cost: 1 },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
});
|
||
|
|
// Call the handler directly
|
||
|
|
element._handleUnlockRequest(unlockEvent);
|
||
|
|
|
||
|
|
// Wait for setTimeout to execute
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Verify updateTrigger was incremented
|
||
|
|
expect(skillTree.updateTrigger).to.be.greaterThan(initialUpdateTrigger);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should include skill tree stat boosts in stat breakdown", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||
|
|
testUnit.baseStats.health = 100;
|
||
|
|
testUnit.maxHealth = 100;
|
||
|
|
|
||
|
|
// Create mock tree definition with health boost
|
||
|
|
const mockTreeDef = {
|
||
|
|
id: "TREE_TEST",
|
||
|
|
nodes: {
|
||
|
|
ROOT: {
|
||
|
|
id: "ROOT",
|
||
|
|
tier: 1,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "health", value: 10 },
|
||
|
|
req: 1,
|
||
|
|
cost: 1,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.treeDef = mockTreeDef;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
// Get health stat breakdown - health uses a health bar, not stat-value
|
||
|
|
const healthStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||
|
|
item.textContent.includes("Health")
|
||
|
|
);
|
||
|
|
expect(healthStat).to.exist;
|
||
|
|
|
||
|
|
// Health shows as "current / max" in the health label
|
||
|
|
const healthLabel = healthStat.querySelector(".health-label");
|
||
|
|
if (healthLabel) {
|
||
|
|
// Health bar shows current/max, so we check the max value
|
||
|
|
const healthText = healthLabel.textContent;
|
||
|
|
const match = healthText.match(/\/(\d+)/);
|
||
|
|
if (match) {
|
||
|
|
const maxHealth = parseInt(match[1]);
|
||
|
|
// Should be 100 base + 10 boost = 110
|
||
|
|
expect(maxHealth).to.equal(110);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Fallback: check if the breakdown tooltip would show the boost
|
||
|
|
// This test verifies the calculation happens, even if we can't easily test the UI
|
||
|
|
const { total } = element._getEffectiveStat("health");
|
||
|
|
expect(total).to.equal(110);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("30-Node Skill Tree Integration", () => {
|
||
|
|
it("should receive and pass full 30-node tree to SkillTreeUI", async () => {
|
||
|
|
// Create a mock 30-node tree (simplified version)
|
||
|
|
const mock30NodeTree = {
|
||
|
|
id: "TREE_CLASS_VANGUARD",
|
||
|
|
nodes: {},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Generate 30 node IDs
|
||
|
|
for (let i = 1; i <= 30; i++) {
|
||
|
|
mock30NodeTree.nodes[`NODE_${i}`] = {
|
||
|
|
id: `NODE_${i}`,
|
||
|
|
tier: Math.ceil(i / 6),
|
||
|
|
type: i % 3 === 0 ? "STAT_BOOST" : "ACTIVE_SKILL",
|
||
|
|
data: i % 3 === 0
|
||
|
|
? { stat: "health", value: i }
|
||
|
|
: { id: `SKILL_${i}`, name: `Skill ${i}` },
|
||
|
|
req: Math.ceil(i / 6),
|
||
|
|
cost: Math.ceil(i / 10),
|
||
|
|
children: i < 30 ? [`NODE_${i + 1}`] : [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.treeDef = mock30NodeTree;
|
||
|
|
element.activeTab = "SKILLS";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const skillTree = queryShadow("skill-tree-ui");
|
||
|
|
expect(skillTree).to.exist;
|
||
|
|
expect(skillTree.treeDef).to.exist;
|
||
|
|
expect(skillTree.treeDef.id).to.equal("TREE_CLASS_VANGUARD");
|
||
|
|
expect(Object.keys(skillTree.treeDef.nodes)).to.have.length(30);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should handle treeDef with all node types from template", async () => {
|
||
|
|
const mockFullTree = {
|
||
|
|
id: "TREE_TEST",
|
||
|
|
nodes: {
|
||
|
|
NODE_T1_1: {
|
||
|
|
tier: 1,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "health", value: 2 },
|
||
|
|
req: 1,
|
||
|
|
cost: 1,
|
||
|
|
children: ["NODE_T2_1", "NODE_T2_2"],
|
||
|
|
},
|
||
|
|
NODE_T2_1: {
|
||
|
|
tier: 2,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "defense", value: 2 },
|
||
|
|
req: 2,
|
||
|
|
cost: 1,
|
||
|
|
children: ["NODE_T3_1"],
|
||
|
|
},
|
||
|
|
NODE_T2_2: {
|
||
|
|
tier: 2,
|
||
|
|
type: "ACTIVE_SKILL",
|
||
|
|
data: { id: "SKILL_1", name: "Shield Bash" },
|
||
|
|
req: 2,
|
||
|
|
cost: 1,
|
||
|
|
children: ["NODE_T3_2"],
|
||
|
|
},
|
||
|
|
NODE_T3_1: {
|
||
|
|
tier: 3,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "health", value: 6 },
|
||
|
|
req: 3,
|
||
|
|
cost: 1,
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
NODE_T3_2: {
|
||
|
|
tier: 3,
|
||
|
|
type: "ACTIVE_SKILL",
|
||
|
|
data: { id: "SKILL_2", name: "Taunt" },
|
||
|
|
req: 3,
|
||
|
|
cost: 1,
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
NODE_T4_1: {
|
||
|
|
tier: 4,
|
||
|
|
type: "ACTIVE_SKILL",
|
||
|
|
data: { id: "SKILL_3", name: "Skill 3" },
|
||
|
|
req: 4,
|
||
|
|
cost: 2,
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
NODE_T4_2: {
|
||
|
|
tier: 4,
|
||
|
|
type: "PASSIVE_ABILITY",
|
||
|
|
data: { effect_id: "PASSIVE_1", name: "Passive 1" },
|
||
|
|
req: 4,
|
||
|
|
cost: 2,
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.treeDef = mockFullTree;
|
||
|
|
element.activeTab = "SKILLS";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const skillTree = queryShadow("skill-tree-ui");
|
||
|
|
expect(skillTree).to.exist;
|
||
|
|
expect(skillTree.treeDef).to.equal(mockFullTree);
|
||
|
|
|
||
|
|
// Verify all node types are present
|
||
|
|
const nodes = skillTree.treeDef.nodes;
|
||
|
|
expect(nodes.NODE_T1_1.type).to.equal("STAT_BOOST");
|
||
|
|
expect(nodes.NODE_T2_2.type).to.equal("ACTIVE_SKILL");
|
||
|
|
expect(nodes.NODE_T4_2.type).to.equal("PASSIVE_ABILITY");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should use treeDef in _getTreeDefinition method", async () => {
|
||
|
|
const mockTree = {
|
||
|
|
id: "TREE_TEST",
|
||
|
|
nodes: {
|
||
|
|
NODE_1: {
|
||
|
|
tier: 1,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "health", value: 2 },
|
||
|
|
req: 1,
|
||
|
|
cost: 1,
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.treeDef = mockTree;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const treeDef = element._getTreeDefinition();
|
||
|
|
expect(treeDef).to.equal(mockTree);
|
||
|
|
expect(treeDef.id).to.equal("TREE_TEST");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should pass treeDef to recalculateStats when unlocking nodes", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||
|
|
testUnit.recalculateStats = () => {}; // Mock function
|
||
|
|
|
||
|
|
const mockTree = {
|
||
|
|
id: "TREE_TEST",
|
||
|
|
nodes: {
|
||
|
|
NODE_1: {
|
||
|
|
tier: 1,
|
||
|
|
type: "STAT_BOOST",
|
||
|
|
data: { stat: "health", value: 10 },
|
||
|
|
req: 1,
|
||
|
|
cost: 1,
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
let recalculateStatsCalledWith = null;
|
||
|
|
testUnit.recalculateStats = (itemRegistry, treeDef) => {
|
||
|
|
recalculateStatsCalledWith = { itemRegistry, treeDef };
|
||
|
|
};
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.treeDef = mockTree;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const unlockEvent = new CustomEvent("unlock-request", {
|
||
|
|
detail: { nodeId: "NODE_1", cost: 1 },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
});
|
||
|
|
element._handleUnlockRequest(unlockEvent);
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
expect(recalculateStatsCalledWith).to.exist;
|
||
|
|
expect(recalculateStatsCalledWith.treeDef).to.equal(mockTree);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("CoA 4: Context Awareness", () => {
|
||
|
|
it("should display inventory tab", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventory = [];
|
||
|
|
element.activeTab = "INVENTORY";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const inventoryGrid = queryShadow(".inventory-grid");
|
||
|
|
expect(inventoryGrid).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should show empty message when inventory is empty", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.inventory = [];
|
||
|
|
element.activeTab = "INVENTORY";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const emptyMessage = queryShadow(".inventory-grid p");
|
||
|
|
expect(emptyMessage).to.exist;
|
||
|
|
expect(emptyMessage.textContent).to.include("No items available");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display mastery tab", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.activeTab = "MASTERY";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const masteryContainer = queryShadow(".mastery-container");
|
||
|
|
expect(masteryContainer).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should show mastery progress for all classes", async () => {
|
||
|
|
testUnit.classMastery["CLASS_VANGUARD"] = {
|
||
|
|
level: 5,
|
||
|
|
xp: 250,
|
||
|
|
skillPoints: 2,
|
||
|
|
unlockedNodes: [],
|
||
|
|
};
|
||
|
|
testUnit.classMastery["CLASS_WEAVER"] = {
|
||
|
|
level: 2,
|
||
|
|
xp: 50,
|
||
|
|
skillPoints: 0,
|
||
|
|
unlockedNodes: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
element.unit = testUnit;
|
||
|
|
element.activeTab = "MASTERY";
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const masteryClasses = queryShadowAll(".mastery-class");
|
||
|
|
expect(masteryClasses.length).to.equal(2);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Header Rendering", () => {
|
||
|
|
it("should display unit name, class, and level", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const name = queryShadow(".name");
|
||
|
|
expect(name.textContent).to.include("Test Vanguard");
|
||
|
|
|
||
|
|
const classTitle = queryShadow(".class-title");
|
||
|
|
expect(classTitle.textContent).to.include("Vanguard");
|
||
|
|
|
||
|
|
const level = queryShadow(".level");
|
||
|
|
expect(level.textContent).to.include("Level 5");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display XP bar", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const xpBar = queryShadow(".xp-bar-container");
|
||
|
|
expect(xpBar).to.exist;
|
||
|
|
|
||
|
|
const xpLabel = queryShadow(".xp-label");
|
||
|
|
expect(xpLabel.textContent).to.include("250");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should display close button", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const closeButton = queryShadow(".close-button");
|
||
|
|
expect(closeButton).to.exist;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should dispatch close event when close button is clicked", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
let closeEventFired = false;
|
||
|
|
element.addEventListener("close", () => {
|
||
|
|
closeEventFired = true;
|
||
|
|
});
|
||
|
|
|
||
|
|
const closeButton = queryShadow(".close-button");
|
||
|
|
closeButton.click();
|
||
|
|
|
||
|
|
expect(closeEventFired).to.be.true;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Tab Switching", () => {
|
||
|
|
it("should switch between tabs", async () => {
|
||
|
|
element.unit = testUnit;
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
const inventoryTab = queryShadowAll(".tab-button")[0];
|
||
|
|
const skillsTab = queryShadowAll(".tab-button")[1];
|
||
|
|
const masteryTab = queryShadowAll(".tab-button")[2];
|
||
|
|
|
||
|
|
expect(inventoryTab.classList.contains("active")).to.be.true;
|
||
|
|
|
||
|
|
skillsTab.click();
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
expect(element.activeTab).to.equal("SKILLS");
|
||
|
|
expect(skillsTab.classList.contains("active")).to.be.true;
|
||
|
|
|
||
|
|
masteryTab.click();
|
||
|
|
await waitForUpdate();
|
||
|
|
|
||
|
|
expect(element.activeTab).to.equal("MASTERY");
|
||
|
|
expect(masteryTab.classList.contains("active")).to.be.true;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|