aether-shards/test/ui/combat-hud.test.js

560 lines
16 KiB
JavaScript
Raw Normal View History

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