559 lines
16 KiB
JavaScript
559 lines
16 KiB
JavaScript
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(".bar-fill.hp");
|
|
const apBar = queryShadow(".bar-fill.ap");
|
|
const chargeBar = queryShadow(".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");
|
|
});
|
|
});
|
|
});
|