import { expect } from "@esm-bundle/chai"; import sinon from "sinon"; import { MarketManager } from "../../src/managers/MarketManager.js"; import { InventoryManager } from "../../src/managers/InventoryManager.js"; import { InventoryContainer } from "../../src/models/InventoryContainer.js"; import { Item } from "../../src/items/Item.js"; import { ItemRegistry } from "../../src/managers/ItemRegistry.js"; describe("Manager: MarketManager", () => { let marketManager; let mockPersistence; let mockItemRegistry; let mockInventoryManager; let mockMissionManager; let hubStash; beforeEach(() => { // Mock Persistence mockPersistence = { init: sinon.stub().resolves(), saveMarketState: sinon.stub().resolves(), loadMarketState: sinon.stub().resolves(null), }; // Mock Item Registry mockItemRegistry = { loadAll: sinon.stub().resolves(), get: (defId) => { const items = { "ITEM_RUSTY_BLADE": new Item({ id: "ITEM_RUSTY_BLADE", name: "Rusty Infantry Blade", type: "WEAPON", rarity: "COMMON", stats: { attack: 3 }, }), "ITEM_SCRAP_PLATE": new Item({ id: "ITEM_SCRAP_PLATE", name: "Scrap Plate Armor", type: "ARMOR", rarity: "COMMON", stats: { defense: 3 }, }), "ITEM_APPRENTICE_WAND": new Item({ id: "ITEM_APPRENTICE_WAND", name: "Apprentice Spark-Wand", type: "WEAPON", rarity: "COMMON", stats: { magic: 4 }, }), "ITEM_ROBES": new Item({ id: "ITEM_ROBES", name: "Novice Robes", type: "ARMOR", rarity: "COMMON", stats: { willpower: 3 }, }), "ITEM_UNCOMMON_SWORD": new Item({ id: "ITEM_UNCOMMON_SWORD", name: "Steel Blade", type: "WEAPON", rarity: "UNCOMMON", stats: { attack: 5 }, }), }; return items[defId] || null; }, getAll: () => { return [ new Item({ id: "ITEM_RUSTY_BLADE", name: "Rusty Infantry Blade", type: "WEAPON", rarity: "COMMON", stats: { attack: 3 }, }), new Item({ id: "ITEM_SCRAP_PLATE", name: "Scrap Plate Armor", type: "ARMOR", rarity: "COMMON", stats: { defense: 3 }, }), new Item({ id: "ITEM_APPRENTICE_WAND", name: "Apprentice Spark-Wand", type: "WEAPON", rarity: "COMMON", stats: { magic: 4 }, }), new Item({ id: "ITEM_ROBES", name: "Novice Robes", type: "ARMOR", rarity: "COMMON", stats: { willpower: 3 }, }), new Item({ id: "ITEM_UNCOMMON_SWORD", name: "Steel Blade", type: "WEAPON", rarity: "UNCOMMON", stats: { attack: 5 }, }), ]; }, }; // Create real hub stash hubStash = new InventoryContainer("HUB_VAULT"); hubStash.currency.aetherShards = 1000; // Give player some currency // Mock Inventory Manager mockInventoryManager = { hubStash: hubStash, }; // Mock Mission Manager mockMissionManager = { completedMissions: new Set(), }; marketManager = new MarketManager( mockPersistence, mockItemRegistry, mockInventoryManager, mockMissionManager ); }); afterEach(() => { // Clean up event listeners if (marketManager) { marketManager.destroy(); } // Remove any event listeners window.removeEventListener("mission-victory", () => {}); }); describe("Initialization", () => { it("should initialize and load state from persistence", async () => { const savedState = { generationId: "TEST_123", stock: [], buyback: [], }; mockPersistence.loadMarketState.resolves(savedState); await marketManager.init(); expect(mockPersistence.loadMarketState.called).to.be.true; expect(mockItemRegistry.loadAll.called).to.be.true; expect(marketManager.marketState).to.deep.equal(savedState); }); it("should generate initial Tier 1 stock if no saved state", async () => { mockPersistence.loadMarketState.resolves(null); await marketManager.init(); expect(marketManager.marketState).to.exist; expect(marketManager.marketState.stock.length).to.be.greaterThan(0); expect(mockPersistence.saveMarketState.called).to.be.true; }); }); describe("Stock Generation", () => { beforeEach(async () => { await marketManager.init(); }); it("should generate Tier 1 stock with only Common items", async () => { await marketManager.generateStock(1); const stock = marketManager.marketState.stock; expect(stock.length).to.be.greaterThan(0); stock.forEach((item) => { expect(item.rarity).to.equal("COMMON"); }); }); it("should generate Tier 2 stock with proper rarity distribution", async () => { await marketManager.generateStock(2); const stock = marketManager.marketState.stock; expect(stock.length).to.be.greaterThan(0); // Check that we have items of different rarities (at least some) const rarities = stock.map((item) => item.rarity); expect(rarities).to.include.members(["COMMON", "UNCOMMON"]); }); it("should assign unique stock IDs", async () => { await marketManager.generateStock(1); const stock = marketManager.marketState.stock; const ids = stock.map((item) => item.id); const uniqueIds = new Set(ids); expect(uniqueIds.size).to.equal(ids.length); }); it("should calculate prices with variance", async () => { await marketManager.generateStock(1); const stock = marketManager.marketState.stock; stock.forEach((item) => { expect(item.price).to.be.a("number"); expect(item.price).to.be.greaterThan(0); }); }); it("should save state after generation", async () => { await marketManager.generateStock(1); expect(mockPersistence.saveMarketState.called).to.be.true; const savedState = mockPersistence.saveMarketState.firstCall.args[0]; expect(savedState).to.equal(marketManager.marketState); }); }); describe("CoA 1: Persistence Integrity", () => { beforeEach(async () => { await marketManager.init(); await marketManager.generateStock(1); }); it("should maintain stock after reload", async () => { const originalStock = [...marketManager.marketState.stock]; const originalGenerationId = marketManager.marketState.generationId; // Simulate reload mockPersistence.loadMarketState.resolves(marketManager.marketState); await marketManager.init(); expect(marketManager.marketState.generationId).to.equal( originalGenerationId ); expect(marketManager.marketState.stock.length).to.equal( originalStock.length ); }); it("should mark purchased items as sold out after buy", async () => { const stockItem = marketManager.marketState.stock[0]; const originalPrice = stockItem.price; // Set currency hubStash.currency.aetherShards = originalPrice; const success = await marketManager.buyItem(stockItem.id); expect(success).to.be.true; expect(stockItem.purchased).to.be.true; // Reload and verify mockPersistence.loadMarketState.resolves(marketManager.marketState); await marketManager.init(); const reloadedItem = marketManager.marketState.stock.find( (item) => item.id === stockItem.id ); expect(reloadedItem.purchased).to.be.true; }); }); describe("CoA 2: Currency Math", () => { beforeEach(async () => { await marketManager.init(); await marketManager.generateStock(1); }); it("should deduct exact price when buying", async () => { const stockItem = marketManager.marketState.stock[0]; const originalPrice = stockItem.price; const originalCurrency = 1000; hubStash.currency.aetherShards = originalCurrency; await marketManager.buyItem(stockItem.id); expect(hubStash.currency.aetherShards).to.equal( originalCurrency - originalPrice ); }); it("should refund exact sell price (25% of base)", async () => { // Add an item to stash first const itemInstance = { uid: "ITEM_TEST_123", defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1, }; hubStash.addItem(itemInstance); const originalCurrency = 1000; hubStash.currency.aetherShards = originalCurrency; // Get base price calculation const itemDef = mockItemRegistry.get("ITEM_RUSTY_BLADE"); const basePrice = 50 + Object.values(itemDef.stats).reduce((sum, val) => sum + val, 0) * 10; const expectedSellPrice = Math.floor(basePrice * 0.25); const success = await marketManager.sellItem(itemInstance.uid); expect(success).to.be.true; expect(hubStash.currency.aetherShards).to.equal( originalCurrency + expectedSellPrice ); }); it("should allow buyback at exact sell price", async () => { // Add and sell an item const itemInstance = { uid: "ITEM_TEST_456", defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1, }; hubStash.addItem(itemInstance); const originalCurrency = 1000; hubStash.currency.aetherShards = originalCurrency; await marketManager.sellItem(itemInstance.uid); const sellPrice = hubStash.currency.aetherShards - originalCurrency; const buybackItem = marketManager.marketState.buyback[0]; // Try to buy back hubStash.currency.aetherShards = originalCurrency + sellPrice; const buybackSuccess = await marketManager.buyItem(buybackItem.id); expect(buybackSuccess).to.be.true; expect(buybackItem.price).to.equal(sellPrice); }); }); describe("CoA 3: Atomic Transactions", () => { beforeEach(async () => { await marketManager.init(); await marketManager.generateStock(1); }); it("should not add item if currency is insufficient", async () => { const stockItem = marketManager.marketState.stock[0]; hubStash.currency.aetherShards = stockItem.price - 1; // Not enough const originalItemCount = hubStash.items.length; const success = await marketManager.buyItem(stockItem.id); expect(success).to.be.false; expect(hubStash.items.length).to.equal(originalItemCount); expect(hubStash.currency.aetherShards).to.equal(stockItem.price - 1); }); it("should not add currency if item removal fails", async () => { const originalCurrency = hubStash.currency.aetherShards; const nonExistentUid = "ITEM_NONEXISTENT"; const success = await marketManager.sellItem(nonExistentUid); expect(success).to.be.false; expect(hubStash.currency.aetherShards).to.equal(originalCurrency); }); it("should complete transaction atomically on success", async () => { const stockItem = marketManager.marketState.stock[0]; hubStash.currency.aetherShards = stockItem.price; const originalItemCount = hubStash.items.length; const success = await marketManager.buyItem(stockItem.id); expect(success).to.be.true; expect(hubStash.items.length).to.equal(originalItemCount + 1); expect(stockItem.purchased).to.be.true; expect(hubStash.currency.aetherShards).to.equal(0); }); }); describe("CoA 4: Stock Generation", () => { it("should generate Tier 1 stock with only Common items", async () => { await marketManager.init(); await marketManager.generateStock(1); const stock = marketManager.marketState.stock; stock.forEach((item) => { expect(item.rarity).to.equal("COMMON"); }); }); it("should generate stock only when needsRefresh is true", async () => { await marketManager.init(); const originalStock = [...marketManager.marketState.stock]; // Check refresh without flag await marketManager.checkRefresh(); expect(marketManager.marketState.stock).to.deep.equal(originalStock); // Set flag and check again marketManager.needsRefresh = true; await marketManager.checkRefresh(); expect(marketManager.needsRefresh).to.be.false; // Stock should be different (new generation) expect(marketManager.marketState.generationId).to.not.equal( originalStock.length > 0 ? originalStock[0].id : "none" ); }); it("should listen for mission-victory event", async () => { await marketManager.init(); // Reset needsRefresh to false first marketManager.needsRefresh = false; window.dispatchEvent(new CustomEvent("mission-victory")); // Give event listener time to process await new Promise((resolve) => setTimeout(resolve, 10)); expect(marketManager.needsRefresh).to.be.true; }); }); describe("CoA 5: Buyback Limit", () => { beforeEach(async () => { await marketManager.init(); }); it("should limit buyback to 10 items", async () => { // Sell 11 items for (let i = 0; i < 11; i++) { const itemInstance = { uid: `ITEM_TEST_${i}`, defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1, }; hubStash.addItem(itemInstance); await marketManager.sellItem(itemInstance.uid); } expect(marketManager.marketState.buyback.length).to.equal(10); }); it("should remove oldest item when limit exceeded", async () => { // Sell 10 items first const firstItemUid = "ITEM_TEST_FIRST"; hubStash.addItem({ uid: firstItemUid, defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1, }); await marketManager.sellItem(firstItemUid); const firstBuybackId = marketManager.marketState.buyback[0].id; // Sell 10 more items for (let i = 0; i < 10; i++) { const itemInstance = { uid: `ITEM_TEST_${i}`, defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1, }; hubStash.addItem(itemInstance); await marketManager.sellItem(itemInstance.uid); } // First item should be removed const buybackIds = marketManager.marketState.buyback.map( (item) => item.id ); expect(buybackIds).to.not.include(firstBuybackId); expect(marketManager.marketState.buyback.length).to.equal(10); }); }); describe("Merchant Filtering", () => { beforeEach(async () => { await marketManager.init(); await marketManager.generateStock(1); }); it("should filter stock by merchant type", () => { const smithStock = marketManager.getStockForMerchant("SMITH"); smithStock.forEach((item) => { expect(["WEAPON", "ARMOR"]).to.include(item.type); }); const buybackStock = marketManager.getStockForMerchant("BUYBACK"); expect(buybackStock).to.deep.equal(marketManager.marketState.buyback); }); }); describe("State Management", () => { it("should return current state", async () => { await marketManager.init(); const state = marketManager.getState(); expect(state).to.exist; expect(state).to.have.property("generationId"); expect(state).to.have.property("stock"); expect(state).to.have.property("buyback"); }); it("should clean up event listeners on destroy", () => { const removeSpy = sinon.spy(window, "removeEventListener"); marketManager.destroy(); expect(removeSpy.called).to.be.true; removeSpy.restore(); }); }); });