- Introduce the ResearchManager to manage tech trees, node unlocking, and passive effects, enhancing gameplay depth. - Update GameStateManager to integrate the ResearchManager, ensuring seamless data handling for research states. - Implement lazy loading for mission definitions and class data to improve performance and resource management. - Enhance UI components, including the ResearchScreen and MissionBoard, to support new research features and mission prerequisites. - Add comprehensive tests for the ResearchManager and related UI components to validate functionality and integration within the game architecture.
642 lines
20 KiB
JavaScript
642 lines
20 KiB
JavaScript
import { expect } from "@esm-bundle/chai";
|
|
import { MissionBoard } from "../../src/ui/components/MissionBoard.js";
|
|
import { gameStateManager } from "../../src/core/GameStateManager.js";
|
|
|
|
describe("UI: MissionBoard", () => {
|
|
let element;
|
|
let container;
|
|
let mockMissionManager;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
element = document.createElement("mission-board");
|
|
container.appendChild(element);
|
|
|
|
// Mock MissionManager
|
|
mockMissionManager = {
|
|
missionRegistry: new Map(),
|
|
completedMissions: new Set(),
|
|
};
|
|
|
|
gameStateManager.missionManager = mockMissionManager;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (container && container.parentNode) {
|
|
container.parentNode.removeChild(container);
|
|
}
|
|
});
|
|
|
|
// 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("Mission Display", () => {
|
|
it("should display registered missions", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_TUTORIAL_01",
|
|
type: "TUTORIAL",
|
|
config: {
|
|
title: "Protocol: First Descent",
|
|
description: "Establish a foothold in the Rusting Wastes.",
|
|
difficulty_tier: 1,
|
|
},
|
|
rewards: {
|
|
guaranteed: {
|
|
xp: 100,
|
|
currency: {
|
|
aether_shards: 50,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const mission2 = {
|
|
id: "MISSION_01",
|
|
type: "STORY",
|
|
config: {
|
|
title: "The First Strike",
|
|
description: "Strike back at the enemy.",
|
|
recommended_level: 2,
|
|
},
|
|
rewards: {
|
|
guaranteed: {
|
|
xp: 200,
|
|
currency: {
|
|
aether_shards: 100,
|
|
ancient_cores: 1,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(2);
|
|
|
|
const titles = Array.from(missionCards).map((card) =>
|
|
card.querySelector(".mission-title")?.textContent.trim()
|
|
);
|
|
expect(titles).to.include("Protocol: First Descent");
|
|
expect(titles).to.include("The First Strike");
|
|
});
|
|
|
|
it("should show empty state when no missions available", async () => {
|
|
mockMissionManager.missionRegistry.clear();
|
|
await waitForUpdate();
|
|
|
|
const emptyState = queryShadow(".empty-state");
|
|
expect(emptyState).to.exist;
|
|
expect(emptyState.textContent).to.include("No missions available");
|
|
});
|
|
});
|
|
|
|
describe("Mission Card Details", () => {
|
|
it("should display mission type badge", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "STORY",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
const typeBadge = missionCard.querySelector(".mission-type.STORY");
|
|
expect(typeBadge).to.exist;
|
|
expect(typeBadge.textContent).to.include("STORY");
|
|
});
|
|
|
|
it("should display mission description", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "TUTORIAL",
|
|
config: {
|
|
title: "Test Mission",
|
|
description: "This is a test mission description.",
|
|
},
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
const description = missionCard.querySelector(".mission-description");
|
|
expect(description).to.exist;
|
|
expect(description.textContent).to.include("This is a test mission description.");
|
|
});
|
|
|
|
it("should display difficulty information", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "TUTORIAL",
|
|
config: {
|
|
title: "Test Mission",
|
|
difficulty_tier: 3,
|
|
},
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
const difficulty = missionCard.querySelector(".difficulty");
|
|
expect(difficulty).to.exist;
|
|
expect(difficulty.textContent).to.include("Tier 3");
|
|
});
|
|
});
|
|
|
|
describe("Rewards Display", () => {
|
|
it("should display currency rewards", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {
|
|
guaranteed: {
|
|
currency: {
|
|
aether_shards: 150,
|
|
ancient_cores: 2,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
const rewards = missionCard.querySelector(".mission-rewards");
|
|
expect(rewards).to.exist;
|
|
expect(rewards.textContent).to.include("150");
|
|
expect(rewards.textContent).to.include("2");
|
|
});
|
|
|
|
it("should display XP rewards", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {
|
|
guaranteed: {
|
|
xp: 250,
|
|
},
|
|
},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
const rewards = missionCard.querySelector(".mission-rewards");
|
|
expect(rewards).to.exist;
|
|
expect(rewards.textContent).to.include("250");
|
|
expect(rewards.textContent).to.include("XP");
|
|
});
|
|
|
|
it("should handle both snake_case and camelCase currency", async () => {
|
|
// Test snake_case (from JSON)
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test 1", description: "Test" },
|
|
rewards: {
|
|
guaranteed: {
|
|
currency: {
|
|
aether_shards: 100,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// Test camelCase (from code)
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test 2", description: "Test" },
|
|
rewards: {
|
|
guaranteed: {
|
|
currency: {
|
|
aetherShards: 200,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(2);
|
|
|
|
// Both should display rewards correctly
|
|
const rewards1 = missionCards[0].querySelector(".mission-rewards");
|
|
const rewards2 = missionCards[1].querySelector(".mission-rewards");
|
|
expect(rewards1.textContent).to.include("100");
|
|
expect(rewards2.textContent).to.include("200");
|
|
});
|
|
});
|
|
|
|
describe("Completed Missions", () => {
|
|
it("should mark completed missions", async () => {
|
|
const mission = {
|
|
id: "MISSION_TUTORIAL_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
mockMissionManager.completedMissions.add(mission.id);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
expect(missionCard.classList.contains("completed")).to.be.true;
|
|
expect(missionCard.textContent).to.include("Completed");
|
|
});
|
|
|
|
it("should not show select button for completed missions", async () => {
|
|
const mission = {
|
|
id: "MISSION_TUTORIAL_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
mockMissionManager.completedMissions.add(mission.id);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
const selectButton = missionCard.querySelector(".select-button");
|
|
expect(selectButton).to.be.null;
|
|
});
|
|
});
|
|
|
|
describe("Mission Selection", () => {
|
|
it("should dispatch mission-selected event when mission is clicked", async () => {
|
|
const mission = {
|
|
id: "MISSION_TUTORIAL_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
let eventDispatched = false;
|
|
let eventData = null;
|
|
|
|
element.addEventListener("mission-selected", (e) => {
|
|
eventDispatched = true;
|
|
eventData = e.detail;
|
|
});
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
missionCard.click();
|
|
await waitForUpdate();
|
|
|
|
expect(eventDispatched).to.be.true;
|
|
expect(eventData.missionId).to.equal("MISSION_TUTORIAL_01");
|
|
});
|
|
|
|
it("should dispatch mission-selected event when select button is clicked", async () => {
|
|
const mission = {
|
|
id: "MISSION_TUTORIAL_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
let eventDispatched = false;
|
|
let eventData = null;
|
|
|
|
element.addEventListener("mission-selected", (e) => {
|
|
eventDispatched = true;
|
|
eventData = e.detail;
|
|
});
|
|
|
|
const selectButton = queryShadow(".select-button");
|
|
expect(selectButton).to.exist;
|
|
|
|
selectButton.click();
|
|
await waitForUpdate();
|
|
|
|
expect(eventDispatched).to.be.true;
|
|
expect(eventData.missionId).to.equal("MISSION_TUTORIAL_01");
|
|
});
|
|
});
|
|
|
|
describe("Close Button", () => {
|
|
it("should dispatch close event when close button is clicked", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "TUTORIAL",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
let closeEventDispatched = false;
|
|
|
|
element.addEventListener("close", () => {
|
|
closeEventDispatched = true;
|
|
});
|
|
|
|
const closeButton = queryShadow(".close-button");
|
|
expect(closeButton).to.exist;
|
|
|
|
closeButton.click();
|
|
await waitForUpdate();
|
|
|
|
expect(closeEventDispatched).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe("Mission Type Styling", () => {
|
|
it("should apply correct styling for different mission types", async () => {
|
|
const missions = [
|
|
{ id: "M1", type: "STORY", config: { title: "Story", description: "Test" }, rewards: {} },
|
|
{ id: "M2", type: "SIDE_QUEST", config: { title: "Side", description: "Test" }, rewards: {} },
|
|
{ id: "M3", type: "TUTORIAL", config: { title: "Tutorial", description: "Test" }, rewards: {} },
|
|
{ id: "M4", type: "PROCEDURAL", config: { title: "Proc", description: "Test" }, rewards: {} },
|
|
];
|
|
|
|
missions.forEach((m) => mockMissionManager.missionRegistry.set(m.id, m));
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(4);
|
|
|
|
const typeBadges = Array.from(missionCards).map((card) =>
|
|
card.querySelector(".mission-type")
|
|
);
|
|
|
|
expect(typeBadges[0].classList.contains("STORY")).to.be.true;
|
|
expect(typeBadges[1].classList.contains("SIDE_QUEST")).to.be.true;
|
|
expect(typeBadges[2].classList.contains("TUTORIAL")).to.be.true;
|
|
expect(typeBadges[3].classList.contains("PROCEDURAL")).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe("Mission Prerequisites", () => {
|
|
it("should show mission as available when no prerequisites", async () => {
|
|
const mission = {
|
|
id: "MISSION_01",
|
|
type: "STORY",
|
|
config: { title: "Test Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
mockMissionManager.missionRegistry.set(mission.id, mission);
|
|
await waitForUpdate();
|
|
|
|
const missionCard = queryShadow(".mission-card");
|
|
expect(missionCard).to.exist;
|
|
expect(missionCard.classList.contains("locked")).to.be.false;
|
|
});
|
|
|
|
it("should show mission as locked when prerequisites not met", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "SIDE_QUEST",
|
|
config: { title: "First Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "SIDE_QUEST",
|
|
config: {
|
|
title: "Second Mission",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(2);
|
|
|
|
const mission2Card = Array.from(missionCards).find((card) =>
|
|
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
|
);
|
|
expect(mission2Card).to.exist;
|
|
expect(mission2Card.classList.contains("locked")).to.be.true;
|
|
});
|
|
|
|
it("should show mission as available when prerequisites are met", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "SIDE_QUEST",
|
|
config: { title: "First Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "SIDE_QUEST",
|
|
config: {
|
|
title: "Second Mission",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
mockMissionManager.completedMissions.add("MISSION_01");
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
const mission2Card = Array.from(missionCards).find((card) =>
|
|
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
|
);
|
|
expect(mission2Card).to.exist;
|
|
expect(mission2Card.classList.contains("locked")).to.be.false;
|
|
});
|
|
|
|
it("should display prerequisite requirements for locked missions", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "SIDE_QUEST",
|
|
config: { title: "First Mission", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "SIDE_QUEST",
|
|
config: {
|
|
title: "Second Mission",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
const mission2Card = Array.from(missionCards).find((card) =>
|
|
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
|
);
|
|
expect(mission2Card).to.exist;
|
|
expect(mission2Card.textContent).to.include("Requires");
|
|
expect(mission2Card.textContent).to.include("First Mission");
|
|
});
|
|
});
|
|
|
|
describe("Mission Visibility", () => {
|
|
it("should hide STORY missions when prerequisites not met", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "STORY",
|
|
config: { title: "First Story", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "STORY",
|
|
config: {
|
|
title: "Second Story",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(1);
|
|
const titles = Array.from(missionCards).map((card) =>
|
|
card.querySelector(".mission-title")?.textContent.trim()
|
|
);
|
|
expect(titles).to.include("First Story");
|
|
expect(titles).to.not.include("Second Story");
|
|
});
|
|
|
|
it("should show SIDE_QUEST missions as locked when prerequisites not met", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "SIDE_QUEST",
|
|
config: { title: "First Quest", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "SIDE_QUEST",
|
|
config: {
|
|
title: "Second Quest",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(2);
|
|
const titles = Array.from(missionCards).map((card) =>
|
|
card.querySelector(".mission-title")?.textContent.trim()
|
|
);
|
|
expect(titles).to.include("First Quest");
|
|
expect(titles).to.include("Second Quest");
|
|
|
|
const mission2Card = Array.from(missionCards).find((card) =>
|
|
card.querySelector(".mission-title")?.textContent.includes("Second Quest")
|
|
);
|
|
expect(mission2Card.classList.contains("locked")).to.be.true;
|
|
});
|
|
|
|
it("should show STORY mission when prerequisites are met", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "STORY",
|
|
config: { title: "First Story", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "STORY",
|
|
config: {
|
|
title: "Second Story",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
mockMissionManager.completedMissions.add("MISSION_01");
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(2);
|
|
const titles = Array.from(missionCards).map((card) =>
|
|
card.querySelector(".mission-title")?.textContent.trim()
|
|
);
|
|
expect(titles).to.include("First Story");
|
|
expect(titles).to.include("Second Story");
|
|
});
|
|
|
|
it("should respect explicit visibility_when_locked setting", async () => {
|
|
const mission1 = {
|
|
id: "MISSION_01",
|
|
type: "STORY",
|
|
config: { title: "First Story", description: "Test" },
|
|
rewards: {},
|
|
};
|
|
const mission2 = {
|
|
id: "MISSION_02",
|
|
type: "STORY",
|
|
config: {
|
|
title: "Second Story",
|
|
description: "Test",
|
|
prerequisites: ["MISSION_01"],
|
|
visibility_when_locked: "locked", // Override default hidden behavior
|
|
},
|
|
rewards: {},
|
|
};
|
|
|
|
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
|
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
|
await waitForUpdate();
|
|
|
|
const missionCards = queryShadowAll(".mission-card");
|
|
expect(missionCards.length).to.equal(2);
|
|
const titles = Array.from(missionCards).map((card) =>
|
|
card.querySelector(".mission-title")?.textContent.trim()
|
|
);
|
|
expect(titles).to.include("Second Story");
|
|
});
|
|
});
|
|
});
|
|
|