import { expect } from "@esm-bundle/chai"; import { CombatHUD } from "../../src/ui/combat-hud.js"; describe("UI: CombatHUD", () => { let element; let container; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); element = document.createElement("combat-hud"); container.appendChild(element); }); afterEach(() => { if (container.parentNode) { container.parentNode.removeChild(container); } }); // Helper to create mock combat state function createMockCombatState(overrides = {}) { return { activeUnit: { id: "unit1", name: "Test Unit", portrait: "/test/portrait.png", hp: { current: 80, max: 100 }, ap: { current: 5, max: 10 }, charge: 50, statuses: [ { id: "buff1", icon: "⚡", turnsRemaining: 2, description: "Energized", }, ], skills: [ { id: "skill1", name: "Attack", icon: "⚔", costAP: 3, cooldown: 0, isAvailable: true, }, { id: "skill2", name: "Heal", icon: "💚", costAP: 5, cooldown: 0, isAvailable: true, }, ], }, turnQueue: [ { unitId: "unit1", portrait: "/test/portrait1.png", team: "PLAYER", initiative: 100, }, { unitId: "unit2", portrait: "/test/portrait2.png", team: "ENEMY", initiative: 80, }, { unitId: "unit3", portrait: "/test/portrait3.png", team: "PLAYER", initiative: 60, }, { unitId: "unit4", portrait: "/test/portrait4.png", team: "ENEMY", initiative: 40, }, { unitId: "unit5", portrait: "/test/portrait5.png", team: "PLAYER", initiative: 20, }, ], targetingMode: false, roundNumber: 1, ...overrides, }; } // Helper to wait for Lit updates async function waitForUpdate() { await element.updateComplete; // Give DOM a moment to settle 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: State Hydration", () => { it("should re-render when combatState is updated", async () => { const initialState = createMockCombatState(); element.combatState = initialState; await waitForUpdate(); // Verify initial render expect(queryShadow(".unit-name")?.textContent.trim()).to.equal( "Test Unit" ); // Update combat state const updatedState = createMockCombatState({ activeUnit: { ...initialState.activeUnit, name: "Updated Unit", }, }); element.combatState = updatedState; await waitForUpdate(); // Verify re-render expect(queryShadow(".unit-name")?.textContent.trim()).to.equal( "Updated Unit" ); }); it("should hide Action Bar when activeUnit is null", async () => { const state = createMockCombatState({ activeUnit: null, }); element.combatState = state; await waitForUpdate(); // Action bar should not be visible or should be empty const actionBar = queryShadow(".action-bar"); const skillButtons = queryShadowAll(".skill-button"); // Either action bar doesn't exist or has no skill buttons expect(skillButtons.length).to.equal(0); }); it("should hide Unit Status when activeUnit is null", async () => { const state = createMockCombatState({ activeUnit: null, }); element.combatState = state; await waitForUpdate(); // Unit status should not be visible const unitStatus = queryShadow(".unit-status"); expect(unitStatus).to.be.null; }); }); describe("CoA 2: Turn Queue Visualization", () => { it("should display at least 5 units in the queue", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); const queuePortraits = queryShadowAll(".queue-portrait"); expect(queuePortraits.length).to.be.at.least(5); }); it("should make the first unit visually distinct (larger and gold border)", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); const queuePortraits = queryShadowAll(".queue-portrait"); expect(queuePortraits.length).to.be.greaterThan(0); const firstPortrait = queuePortraits[0]; expect(firstPortrait.classList.contains("active")).to.be.true; // Check computed styles for size difference const firstRect = firstPortrait.getBoundingClientRect(); if (queuePortraits.length > 1) { const secondRect = queuePortraits[1].getBoundingClientRect(); // First should be larger expect(firstRect.width).to.be.greaterThan(secondRect.width); expect(firstRect.height).to.be.greaterThan(secondRect.height); } // Check for gold border class const styles = window.getComputedStyle(firstPortrait); // The active class should be present, which applies gold border via CSS expect(firstPortrait.classList.contains("active")).to.be.true; }); it("should show enemy intent icon when enemy is active", async () => { const state = createMockCombatState({ turnQueue: [ { unitId: "enemy1", portrait: "/test/enemy.png", team: "ENEMY", initiative: 100, }, ], }); element.combatState = state; await waitForUpdate(); const enemyIntent = queryShadow(".enemy-intent"); // Enemy intent should be visible when first unit is enemy const firstPortrait = queryShadow(".queue-portrait.active"); if (firstPortrait) { const intent = firstPortrait.querySelector(".enemy-intent"); // Intent icon should exist for enemy active unit expect(intent).to.exist; } }); }); describe("CoA 3: Action Bar Logic", () => { it("should display AP cost on skill buttons", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); const skillButtons = queryShadowAll(".skill-button"); expect(skillButtons.length).to.be.greaterThan(0); // Check first skill button has AP cost displayed const firstButton = skillButtons[0]; const costElement = firstButton.querySelector(".cost"); expect(costElement).to.exist; expect(costElement.textContent).to.include("AP"); }); it("should disable skill button when unit.ap.current < skill.costAP", async () => { const state = createMockCombatState({ activeUnit: { id: "unit1", name: "Low AP Unit", portrait: "/test/portrait.png", hp: { current: 100, max: 100 }, ap: { current: 2, max: 10 }, // Only 2 AP charge: 0, statuses: [], skills: [ { id: "skill1", name: "Expensive Skill", icon: "⚔", costAP: 5, // Costs 5 AP, but unit only has 2 cooldown: 0, isAvailable: false, // Should be false due to insufficient AP }, { id: "skill2", name: "Cheap Skill", icon: "💚", costAP: 1, // Costs 1 AP, unit has 2 cooldown: 0, isAvailable: true, }, ], }, }); element.combatState = state; await waitForUpdate(); const skillButtons = queryShadowAll(".skill-button"); expect(skillButtons.length).to.equal(2); // First skill (expensive) should be disabled const expensiveButton = Array.from(skillButtons).find((btn) => btn.textContent.includes("Expensive") ); expect(expensiveButton).to.exist; expect(expensiveButton.disabled).to.be.true; expect( expensiveButton.classList.contains("disabled") || expensiveButton.hasAttribute("disabled") ).to.be.true; // Second skill (cheap) should be enabled const cheapButton = Array.from(skillButtons).find((btn) => btn.textContent.includes("Cheap") ); expect(cheapButton).to.exist; expect(cheapButton.disabled).to.be.false; }); it("should dispatch skill-click event with correct ID when skill button is clicked", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); let capturedEvent = null; element.addEventListener("skill-click", (e) => { capturedEvent = e; }); const skillButtons = queryShadowAll(".skill-button"); expect(skillButtons.length).to.be.greaterThan(0); const firstButton = skillButtons[0]; firstButton.click(); await waitForUpdate(); expect(capturedEvent).to.exist; expect(capturedEvent.detail.skillId).to.equal("skill1"); }); it("should disable skill button when isAvailable is false", async () => { const state = createMockCombatState({ activeUnit: { id: "unit1", name: "Test Unit", portrait: "/test/portrait.png", hp: { current: 100, max: 100 }, ap: { current: 10, max: 10 }, charge: 0, statuses: [], skills: [ { id: "skill1", name: "On Cooldown", icon: "⚔", costAP: 3, cooldown: 2, // On cooldown isAvailable: false, }, ], }, }); element.combatState = state; await waitForUpdate(); const skillButtons = queryShadowAll(".skill-button"); expect(skillButtons.length).to.equal(1); const button = skillButtons[0]; expect(button.disabled).to.be.true; }); it("should display cooldown number when skill is on cooldown", async () => { const state = createMockCombatState({ activeUnit: { id: "unit1", name: "Test Unit", portrait: "/test/portrait.png", hp: { current: 100, max: 100 }, ap: { current: 10, max: 10 }, charge: 0, statuses: [], skills: [ { id: "skill1", name: "On Cooldown", icon: "⚔", costAP: 3, cooldown: 3, isAvailable: false, }, ], }, }); element.combatState = state; await waitForUpdate(); const skillButton = queryShadow(".skill-button"); const cooldownElement = skillButton?.querySelector(".cooldown"); expect(cooldownElement).to.exist; expect(cooldownElement.textContent.trim()).to.equal("3"); }); }); describe("CoA 4: Responsive Design", () => { it("should stack Unit Status and Action Bar vertically on mobile (< 768px)", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); // Simulate mobile viewport const originalWidth = window.innerWidth; const originalHeight = window.innerHeight; // Mock window.innerWidth to be mobile size Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 600, }); // Trigger a resize or re-render element.combatState = { ...state }; await waitForUpdate(); const bottomBar = queryShadow(".bottom-bar"); expect(bottomBar).to.exist; // Check if flex-direction is column on mobile // Note: We can't easily test media queries in unit tests without more setup // But we can verify the structure exists and can be styled responsively const unitStatus = queryShadow(".unit-status"); const actionBar = queryShadow(".action-bar"); expect(unitStatus).to.exist; expect(actionBar).to.exist; // Restore original width Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: originalWidth, }); }); }); describe("Additional Functionality", () => { it("should dispatch end-turn event when End Turn button is clicked", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); let eventDispatched = false; element.addEventListener("end-turn", () => { eventDispatched = true; }); const endTurnButton = queryShadow(".end-turn-button"); expect(endTurnButton).to.exist; endTurnButton.click(); await waitForUpdate(); expect(eventDispatched).to.be.true; }); it("should display round number in global info", async () => { const state = createMockCombatState({ roundNumber: 5 }); element.combatState = state; await waitForUpdate(); const roundCounter = queryShadow(".round-counter"); expect(roundCounter).to.exist; expect(roundCounter.textContent).to.include("Round 5"); }); it("should display threat level in global info", async () => { const state = createMockCombatState({ turnQueue: [ { unitId: "e1", portrait: "/test/e1.png", team: "ENEMY", initiative: 100, }, { unitId: "e2", portrait: "/test/e2.png", team: "ENEMY", initiative: 90, }, { unitId: "e3", portrait: "/test/e3.png", team: "ENEMY", initiative: 80, }, ], }); element.combatState = state; await waitForUpdate(); const threatLevel = queryShadow(".threat-level"); expect(threatLevel).to.exist; // With 3+ enemies, should be HIGH expect(threatLevel.classList.contains("high")).to.be.true; }); it("should display unit HP, AP, and Charge bars", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); const hpBar = queryShadow(".progress-bar-fill.hp"); const apBar = queryShadow(".progress-bar-fill.ap"); const chargeBar = queryShadow(".progress-bar-fill.charge"); expect(hpBar).to.exist; expect(apBar).to.exist; expect(chargeBar).to.exist; // Check that bars have correct width percentages expect(hpBar.style.width).to.include("%"); expect(apBar.style.width).to.include("%"); expect(chargeBar.style.width).to.include("%"); }); it("should display status icons with tooltips", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); const statusIcons = queryShadowAll(".status-icon"); expect(statusIcons.length).to.equal(1); const statusIcon = statusIcons[0]; expect(statusIcon.getAttribute("data-description")).to.exist; expect(statusIcon.textContent).to.include("⚡"); }); it("should display hotkeys (1-5) on skill buttons", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); const skillButtons = queryShadowAll(".skill-button"); expect(skillButtons.length).to.be.greaterThan(0); skillButtons.forEach((button, index) => { const hotkey = button.querySelector(".hotkey"); expect(hotkey).to.exist; expect(hotkey.textContent.trim()).to.equal(String(index + 1)); }); }); it("should dispatch hover-skill event when hovering over skill", async () => { const state = createMockCombatState(); element.combatState = state; await waitForUpdate(); let capturedEvent = null; element.addEventListener("hover-skill", (e) => { capturedEvent = e; }); const skillButton = queryShadow(".skill-button"); expect(skillButton).to.exist; // Simulate mouseenter event skillButton.dispatchEvent( new MouseEvent("mouseenter", { bubbles: true }) ); await waitForUpdate(); expect(capturedEvent).to.exist; expect(capturedEvent.detail.skillId).to.equal("skill1"); }); }); });