- 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.
389 lines
13 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
|