import { expect } from "@esm-bundle/chai"; import { CharacterSheet } from "../../src/ui/components/character-sheet.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/skill-tree-ui.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; }); }); });