aether-shards/test/ui/hub-screen.test.js
Matthew Mone 2c86d674f4 Add mission debrief and procedural mission generation features
- Introduce the MissionDebrief component to display after-action reports, including XP, rewards, and squad status.
- Implement the MissionGenerator class to create procedural side missions, enhancing replayability and resource management.
- Update mission schema to include mission objects for INTERACT objectives, improving mission complexity.
- Enhance GameLoop and MissionManager to support new mission features and interactions.
- Add tests for MissionDebrief and MissionGenerator to ensure functionality and integration within the game architecture.
2026-01-01 16:08:54 -08:00

389 lines
13 KiB
JavaScript

import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import { HubScreen } from "../../src/ui/screens/hub-screen.js";
import { gameStateManager } from "../../src/core/GameStateManager.js";
describe("UI: HubScreen", () => {
let element;
let container;
let mockPersistence;
let mockRosterManager;
let mockMissionManager;
let mockHubStash;
beforeEach(() => {
// Set up mocks BEFORE creating element so connectedCallback can use them
mockPersistence = {
loadRun: sinon.stub().resolves(null),
};
mockRosterManager = {
roster: [],
getDeployableUnits: sinon.stub().returns([]),
};
mockMissionManager = {
completedMissions: new Set(),
};
mockHubStash = {
currency: {
aetherShards: 0,
ancientCores: 0,
},
};
// Replace gameStateManager properties with mocks
gameStateManager.persistence = mockPersistence;
gameStateManager.rosterManager = mockRosterManager;
gameStateManager.missionManager = mockMissionManager;
gameStateManager.hubStash = mockHubStash;
// NOW create the element after mocks are set up
container = document.createElement("div");
document.body.appendChild(container);
element = document.createElement("hub-screen");
container.appendChild(element);
});
afterEach(() => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
// Clean up event listeners (element handles its own cleanup in disconnectedCallback)
});
// Helper to wait for LitElement update
async function waitForUpdate() {
await element.updateComplete;
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: Live Data Binding", () => {
it("should fetch wallet and roster data on mount", async () => {
// Set up hub stash (primary source for wallet)
mockHubStash.currency = {
aetherShards: 450,
ancientCores: 12,
};
mockRosterManager.roster = [
{ id: "u1", status: "READY" },
{ id: "u2", status: "READY" },
{ id: "u3", status: "INJURED" },
];
mockRosterManager.getDeployableUnits.returns([
{ id: "u1", status: "READY" },
{ id: "u2", status: "READY" },
]);
// Manually trigger _loadData since element was already created in beforeEach
await element._loadData();
await waitForUpdate();
expect(element.wallet.aetherShards).to.equal(450);
expect(element.wallet.ancientCores).to.equal(12);
expect(element.rosterSummary.total).to.equal(3);
expect(element.rosterSummary.ready).to.equal(2);
expect(element.rosterSummary.injured).to.equal(1);
});
it("should display correct currency values in top bar", async () => {
// Set up hub stash (primary source for wallet)
mockHubStash.currency = {
aetherShards: 450,
ancientCores: 12,
};
// Manually trigger _loadData
await element._loadData();
await waitForUpdate();
const resourceStrip = queryShadow(".resource-strip");
expect(resourceStrip).to.exist;
expect(resourceStrip.textContent).to.include("450");
expect(resourceStrip.textContent).to.include("12");
});
it("should handle missing wallet data gracefully", async () => {
// Clear hub stash to test fallback
mockHubStash.currency = null;
mockPersistence.loadRun.resolves(null);
// Reload data to test fallback path
await element._loadData();
await waitForUpdate();
expect(element.wallet.aetherShards).to.equal(0);
expect(element.wallet.ancientCores).to.equal(0);
});
});
describe("CoA 2: Hotspot & Dock Sync", () => {
it("should open MISSIONS overlay when clicking missions hotspot", async () => {
await waitForUpdate();
const missionsHotspot = queryShadow(".hotspot.missions");
expect(missionsHotspot).to.exist;
missionsHotspot.click();
await waitForUpdate();
expect(element.activeOverlay).to.equal("MISSIONS");
});
it("should open MISSIONS overlay when clicking missions dock button", async () => {
await waitForUpdate();
const missionsButton = queryShadowAll(".dock-button")[1]; // Second button is MISSIONS
expect(missionsButton).to.exist;
missionsButton.click();
await waitForUpdate();
expect(element.activeOverlay).to.equal("MISSIONS");
});
it("should open BARRACKS overlay from both hotspot and button", async () => {
await waitForUpdate();
// Test hotspot
const barracksHotspot = queryShadow(".hotspot.barracks");
barracksHotspot.click();
await waitForUpdate();
expect(element.activeOverlay).to.equal("BARRACKS");
// Close and test button
element.activeOverlay = "NONE";
await waitForUpdate();
const barracksButton = queryShadowAll(".dock-button")[0]; // First button is BARRACKS
barracksButton.click();
await waitForUpdate();
expect(element.activeOverlay).to.equal("BARRACKS");
});
});
describe("CoA 3: Overlay Management", () => {
it("should render mission-board component when activeOverlay is MISSIONS", async () => {
element.activeOverlay = "MISSIONS";
await waitForUpdate();
// Import MissionBoard dynamically (correct filename)
await import("../../src/ui/components/mission-board.js").catch(() => {});
await waitForUpdate();
// Give time for component to render
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForUpdate();
const overlayContainer = queryShadow(".overlay-container.active");
expect(overlayContainer).to.exist;
const missionBoard = queryShadow("mission-board");
expect(missionBoard).to.exist;
});
it("should close overlay when close event is dispatched", async () => {
element.activeOverlay = "MISSIONS";
await waitForUpdate();
// Import MissionBoard to ensure it's loaded
await import("../../src/ui/components/mission-board.js").catch(() => {});
await waitForUpdate();
// Simulate close event from mission-board component
const missionBoard = queryShadow("mission-board");
if (missionBoard) {
const closeEvent = new CustomEvent("close", { bubbles: true, composed: true });
missionBoard.dispatchEvent(closeEvent);
} else {
// If mission-board not rendered, directly call _closeOverlay
element._closeOverlay();
}
await waitForUpdate();
expect(element.activeOverlay).to.equal("NONE");
});
it("should close overlay when backdrop is clicked", async () => {
element.activeOverlay = "BARRACKS";
await waitForUpdate();
const backdrop = queryShadow(".overlay-backdrop");
expect(backdrop).to.exist;
backdrop.click();
await waitForUpdate();
expect(element.activeOverlay).to.equal("NONE");
});
it("should show different overlays for different types", async () => {
const overlayTypes = ["BARRACKS", "MISSIONS", "MARKET", "RESEARCH", "SYSTEM"];
for (const type of overlayTypes) {
element.activeOverlay = type;
await waitForUpdate();
const overlayContainer = queryShadow(".overlay-container.active");
expect(overlayContainer).to.exist;
expect(element.activeOverlay).to.equal(type);
}
});
});
describe("CoA 4: Mission Handoff", () => {
it("should dispatch request-team-builder event when mission is selected", async () => {
let eventDispatched = false;
let eventData = null;
window.addEventListener("request-team-builder", (e) => {
eventDispatched = true;
eventData = e.detail;
});
element.activeOverlay = "MISSIONS";
await waitForUpdate();
// Import MissionBoard (correct filename)
await import("../../src/ui/components/mission-board.js").catch(() => {});
await waitForUpdate();
// Give time for component to render
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForUpdate();
const missionBoard = queryShadow("mission-board");
expect(missionBoard).to.exist;
// Simulate mission selection
const missionEvent = new CustomEvent("mission-selected", {
detail: { missionId: "MISSION_TUTORIAL_01" },
bubbles: true,
composed: true,
});
missionBoard.dispatchEvent(missionEvent);
await waitForUpdate();
expect(eventDispatched).to.be.true;
expect(eventData.missionId).to.equal("MISSION_TUTORIAL_01");
expect(element.activeOverlay).to.equal("NONE");
});
});
describe("Unlock System", () => {
it("should unlock market after first mission", async () => {
mockMissionManager.completedMissions = new Set(["MISSION_TUTORIAL_01"]);
await waitForUpdate();
expect(element.unlocks.market).to.be.true;
expect(element.unlocks.research).to.be.false;
});
it("should unlock research after 3 missions", async () => {
mockMissionManager.completedMissions = new Set([
"MISSION_1",
"MISSION_2",
"MISSION_3",
]);
// Reload data to recalculate unlocks
await element._loadData();
await waitForUpdate();
expect(element.unlocks.research).to.be.true;
});
it("should disable locked facilities in dock", async () => {
mockMissionManager.completedMissions = new Set(); // No missions completed
// Reload data to recalculate unlocks
await element._loadData();
await waitForUpdate();
// Market is always enabled per spec, so it should NOT be disabled
const marketButton = queryShadowAll(".dock-button")[2]; // MARKET is third button
expect(marketButton).to.exist;
// Market should not be disabled (it's always available)
const researchButton = queryShadowAll(".dock-button")[3]; // RESEARCH is fourth button
expect(researchButton).to.exist;
// Research should be disabled when locked (no missions completed)
expect(researchButton.hasAttribute("disabled") || researchButton.classList.contains("disabled")).to.be.true;
});
it("should hide market hotspot when locked", async () => {
mockMissionManager.completedMissions = new Set(); // No missions completed
// Reload data to recalculate unlocks
await element._loadData();
await waitForUpdate();
// Market is always enabled per spec, so market hotspot should NOT be hidden
const marketHotspot = queryShadow(".hotspot.market");
// Market is always available, so hotspot should be visible
expect(marketHotspot).to.exist;
// If there's a hidden attribute, it should be false or not present
if (marketHotspot) {
expect(marketHotspot.hasAttribute("hidden")).to.be.false;
}
});
});
describe("Roster Summary Display", () => {
it("should calculate roster summary correctly", async () => {
mockRosterManager.roster = [
{ id: "u1", status: "READY" },
{ id: "u2", status: "READY" },
{ id: "u3", status: "INJURED" },
{ id: "u4", status: "READY" },
];
mockRosterManager.getDeployableUnits.returns([
{ id: "u1", status: "READY" },
{ id: "u2", status: "READY" },
{ id: "u4", status: "READY" },
]);
// Reload data to recalculate roster summary
await element._loadData();
await waitForUpdate();
expect(element.rosterSummary.total).to.equal(4);
expect(element.rosterSummary.ready).to.equal(3);
expect(element.rosterSummary.injured).to.equal(1);
});
});
describe("State Change Handling", () => {
it("should reload data when gamestate-changed event fires", async () => {
const initialShards = 100;
// Set up initial hub stash
mockHubStash.currency = { aetherShards: initialShards, ancientCores: 0 };
// Load initial data
await element._loadData();
await waitForUpdate();
expect(element.wallet.aetherShards).to.equal(initialShards);
// Change the data in hub stash
const newShards = 200;
mockHubStash.currency = { aetherShards: newShards, ancientCores: 0 };
// Simulate state change
window.dispatchEvent(
new CustomEvent("gamestate-changed", {
detail: { oldState: "STATE_COMBAT", newState: "STATE_MAIN_MENU" },
})
);
// Wait for _handleStateChange to call _loadData
await new Promise((resolve) => setTimeout(resolve, 100));
await waitForUpdate();
expect(element.wallet.aetherShards).to.equal(newShards);
});
});
});