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