diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..48d130d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,3 +74,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index 431adcb..16840ff 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,11 @@ mvn clean verify Point the installer at your TLibs checkout. -Tests use JUnit and run without a live Minecraft server. +Tests use JUnit, MockBukkit, and Mockito without a live Minecraft server. They +cover trade creation, purchases, menus, persistence, demand recovery, and the +plugin lifecycle, including regressions for invalid quantities and stale state. +`mvn clean verify` enforces 100% runtime line coverage with no production-code +exclusions. JaCoCo's HTML and XML reports are written to `target/site/jacoco/`. ## License diff --git a/pom.xml b/pom.xml index 74e0166..5cf6a4c 100644 --- a/pom.xml +++ b/pom.xml @@ -12,12 +12,37 @@ 21 UTF-8 full + 0.2.1 0.2.0 2.0.0 + + + + org.junit + junit-bom + 5.14.0 + pom + import + + + + + + org.mockito + mockito-core + 5.23.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + me.plugins tlibs @@ -94,6 +119,46 @@ + + org.apache.maven.plugins + maven-dependency-plugin + 3.7.0 + + properties + + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + prepare-agent + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin @@ -103,6 +168,10 @@ org.apache.maven.plugins maven-surefire-plugin 3.5.4 + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + true + diff --git a/src/main/java/net/tfminecraft/marketblock/Cache.java b/src/main/java/net/tfminecraft/marketblock/Cache.java index 8b93dbf..f449186 100644 --- a/src/main/java/net/tfminecraft/marketblock/Cache.java +++ b/src/main/java/net/tfminecraft/marketblock/Cache.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.bukkit.block.Block; @@ -24,7 +25,7 @@ public static double freshnessMultiplier(String stepId) { if (stepId == null || stepId.isBlank()) { return 1.0; } - Double value = freshnessPrice.get(stepId.toLowerCase()); + Double value = freshnessPrice.get(stepId.toLowerCase(Locale.ROOT)); return value == null ? 1.0 : value; } diff --git a/src/main/java/net/tfminecraft/marketblock/inventory/CategoryView.java b/src/main/java/net/tfminecraft/marketblock/inventory/CategoryView.java index c8431e6..ec81907 100644 --- a/src/main/java/net/tfminecraft/marketblock/inventory/CategoryView.java +++ b/src/main/java/net/tfminecraft/marketblock/inventory/CategoryView.java @@ -29,6 +29,7 @@ public void categoryView(Inventory inv, Player p) { inv = MarketBlock.plugin.getServer().createInventory(new MBHolder(MBGUI.CATEGORY, "none"), 54, "§7Market Categories"); open = true; } + inv.clear(); List categories = CategoryLoader.getAsList(); for(int i = 0; i < Cache.slots.size() && i < categories.size(); i++) { int slot = Cache.slots.get(i); diff --git a/src/main/java/net/tfminecraft/marketblock/inventory/TradeView.java b/src/main/java/net/tfminecraft/marketblock/inventory/TradeView.java index 8a99a7a..a912709 100644 --- a/src/main/java/net/tfminecraft/marketblock/inventory/TradeView.java +++ b/src/main/java/net/tfminecraft/marketblock/inventory/TradeView.java @@ -32,6 +32,7 @@ public void tradeView(Inventory inv, Player p, Category cat) { inv = MarketBlock.plugin.getServer().createInventory(new MBHolder(MBGUI.TRADE, cat.getId()), 54, cat.getTradesTitle()); open = true; } + inv.clear(); List trades = new ArrayList<>(cat.getTrades()); trades.sort(Comparator.comparingInt(Trade::getGroup)); for(int i = 0; i < Cache.slots.size() && i < trades.size(); i++) { @@ -58,12 +59,10 @@ public void tradeView(Inventory inv, Player p, Category cat) { @SuppressWarnings("deprecation") private ItemStack createTradeItem(Trade trade, Category cat) { ItemStack i = trade.getIconItem(); - if (i == null || i.getItemMeta() == null) { - i = new ItemStack(Material.GRAY_DYE, 1); - } - ItemMeta m = i.getItemMeta(); + ItemMeta m = i == null ? null : i.getItemMeta(); if (m == null) { - return i; + i = new ItemStack(Material.GRAY_DYE, 1); + m = i.getItemMeta(); } m.setDisplayName(StringFormatter.formatHex(cat.getColorHex() + getPlainName(i) + "#d6d3a7x#b6e66e" + trade.getAmount())); List lore = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java b/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java index 1853feb..1ec13a7 100644 --- a/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java +++ b/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java @@ -12,6 +12,7 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.persistence.PersistentDataType; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; @@ -48,19 +49,13 @@ public void update() { } private String getItemId(MBGUI type, ItemStack i) { - NamespacedKey key; - switch (type) { - case CATEGORY: - key = new NamespacedKey(MarketBlock.plugin, "category_id"); - break; - case TRADE: - key = new NamespacedKey(MarketBlock.plugin, "trade_id"); - break; - default: - key = new NamespacedKey(MarketBlock.plugin, "none"); - break; - } - return i.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + ItemMeta meta = i.getItemMeta(); + if (meta == null) return null; + NamespacedKey key = new NamespacedKey(MarketBlock.plugin, switch (type) { + case CATEGORY -> "category_id"; + case TRADE -> "trade_id"; + }); + return meta.getPersistentDataContainer().get(key, PersistentDataType.STRING); } public void start() { @@ -104,6 +99,7 @@ public void openMarket(PlayerInteractEvent e) { public void click(InventoryClickEvent e) { if(!(e.getView().getTopInventory().getHolder() instanceof MBHolder)) return; e.setCancelled(true); + if(e.getClickedInventory() != e.getView().getTopInventory()) return; Player p = (Player) e.getWhoClicked(); MBHolder h = (MBHolder) e.getView().getTopInventory().getHolder(); ItemStack i = e.getCurrentItem(); @@ -152,11 +148,15 @@ public void tradeClick(Player p, MBHolder h, ItemStack i, InventoryClickEvent e) return; } SaleTake take = InventoryUtils.removeItems(p, tradePath, requiredAmount); + if (take.total() == 0) { + p.sendMessage("§cThe market could not take the items for this trade."); + return; + } p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); double base = PriceCalculator.calculatePrice(trade); double price = applyFreshness(base, take); DenarEconomy.getMoneyManager().addMoney(p, price, false, true); - sendSaleBreakdown(p, take, requiredAmount, base, price); + sendSaleBreakdown(p, take, (int) requiredAmount, base, price); trade.sell(); Bukkit.getPluginManager().callEvent(new MarketSaleEvent(p, trade, price, requiredAmount)); update(); @@ -164,9 +164,6 @@ public void tradeClick(Player p, MBHolder h, ItemStack i, InventoryClickEvent e) private static double applyFreshness(double base, SaleTake take) { int taken = take.total(); - if (taken <= 0) { - return Math.max(0.01, base); - } double weight = 0; for (var entry : take.getCounts().entrySet()) { weight += entry.getValue() * Cache.freshnessMultiplier(entry.getKey()); @@ -176,8 +173,8 @@ private static double applyFreshness(double base, SaleTake take) { return Math.max(0.01, price); } - private static void sendSaleBreakdown(Player p, SaleTake take, double amount, double base, double price) { - p.sendMessage("§aSold " + formatAmount(amount) + " for " + formatMoney(price) + "d"); + private static void sendSaleBreakdown(Player p, SaleTake take, int amount, double base, double price) { + p.sendMessage("§aSold " + amount + " for " + formatMoney(price) + "d"); if (take.allFresh()) { return; } @@ -198,13 +195,6 @@ private static void sendSaleBreakdown(Player p, SaleTake take, double amount, do } } - private static String formatAmount(double amount) { - if (amount == Math.rint(amount)) { - return String.valueOf((long) amount); - } - return String.valueOf(amount); - } - private static String formatMoney(double value) { if (value == Math.rint(value)) { return String.valueOf((long) value); diff --git a/src/main/java/net/tfminecraft/marketblock/manager/commands/ChatListener.java b/src/main/java/net/tfminecraft/marketblock/manager/commands/ChatListener.java index 9577627..a9edadb 100644 --- a/src/main/java/net/tfminecraft/marketblock/manager/commands/ChatListener.java +++ b/src/main/java/net/tfminecraft/marketblock/manager/commands/ChatListener.java @@ -57,12 +57,18 @@ void answerIfCurrent(AsyncPlayerChatEvent event, Player player, MarketblockConve private void answer(Player player, MarketblockConversation convo, String message) { switch (convo.getStep()) { case 0 -> { - if (TradeLoader.getTradeById(message) != null) { + // IDs are one YAML path segment and one /marketblock command argument. + String id = message.strip(); + if (id.isEmpty() || id.contains(".") || id.chars().anyMatch(Character::isWhitespace)) { + player.sendMessage("§cInvalid ID. Use a nonempty ID without dots or spaces."); + return; + } + if (TradeLoader.getTradeById(id) != null) { player.sendMessage("§cThat id is already taken"); return; } - convo.setId(message); - player.sendMessage("§aID set to: " + message); + convo.setId(id); + player.sendMessage("§aID set to: " + id); convo.nextStep(); player.sendMessage("§aEnter the demand limit:"); } @@ -113,15 +119,9 @@ private void answer(Player player, MarketblockConversation convo, String message case 5 -> { // Done! Claim the conversation first, so a quit that got there first stops the save. if (!ConversationManager.finishConversation(player, convo)) return; - Category cat = CategoryLoader.getByString(message); - if (cat == null || cat.getId().equalsIgnoreCase("unknown")) { - player.sendMessage("§cWarning, no category found, default selected"); - } - convo.setCategory(cat); - player.sendMessage("§aCategory set to: " + cat.getId()); // Chat arrives on an async thread; trades and trades.yml belong to the main thread. Bukkit.getScheduler().runTask(MarketBlock.plugin, () -> { - if (!saveTrade(player, convo)) return; + if (!saveTrade(player, convo, message)) return; player.sendMessage("§aTrade successfully created!"); }); } @@ -133,7 +133,12 @@ public void onQuit(PlayerQuitEvent event) { ConversationManager.endConversation(event.getPlayer()); } - private boolean saveTrade(Player p, MarketblockConversation convo) { + private boolean saveTrade(Player p, MarketblockConversation convo, String categoryId) { + // The final answer and this queued task may straddle a permission change. + if (!p.hasPermission(CommandManager.ADMIN_PERMISSION)) { + p.sendMessage("§cYou do not have permission to create a trade."); + return false; + } String path = TLibs.getItemAPI().getChecker().getAsStringPath(convo.getItem()); if(path == null) { p.sendMessage("§cCould not parse the item you are holding"); @@ -143,6 +148,17 @@ private boolean saveTrade(Player p, MarketblockConversation convo) { p.sendMessage("§cA trade already exists with that id"); return false; } + // Resolve after scheduling so a category reload cannot leave a trade on an obsolete object. + Category cat = CategoryLoader.getByString(categoryId); + if (cat == null) { + cat = new Category(); + CategoryLoader.categories.put(cat.getId(), cat); + } + if (cat.getId().equalsIgnoreCase("unknown")) { + p.sendMessage("§cWarning, no category found, default selected"); + } + convo.setCategory(cat); + p.sendMessage("§aCategory set to: " + cat.getId()); TradeLoader.add(new Trade(convo)); return true; } diff --git a/src/main/java/net/tfminecraft/marketblock/manager/commands/MarketblockConversation.java b/src/main/java/net/tfminecraft/marketblock/manager/commands/MarketblockConversation.java index 9d6ed6c..9647257 100644 --- a/src/main/java/net/tfminecraft/marketblock/manager/commands/MarketblockConversation.java +++ b/src/main/java/net/tfminecraft/marketblock/manager/commands/MarketblockConversation.java @@ -22,7 +22,7 @@ public class MarketblockConversation { public MarketblockConversation(Player player, ItemStack item) { this.player = player; - this.item = item; + this.item = item == null ? null : item.clone(); } public Category getCategory() { return category; } @@ -58,4 +58,3 @@ public double getRestingPrice() { return restingPrice; } } - diff --git a/src/main/java/net/tfminecraft/marketblock/trade/Trade.java b/src/main/java/net/tfminecraft/marketblock/trade/Trade.java index 9488a71..54f5da1 100644 --- a/src/main/java/net/tfminecraft/marketblock/trade/Trade.java +++ b/src/main/java/net/tfminecraft/marketblock/trade/Trade.java @@ -37,7 +37,7 @@ public Trade(MarketblockConversation convo) { this.category = convo.getCategory(); category.addTrade(this); this.demandLimit = Math.max(1, convo.getDemandLimit()); - this.demand = this.demandLimit / 2; + this.demand = clampDemand(this.demandLimit / 2); this.item = TLibs.getItemAPI().getChecker().getAsStringPath(convo.getItem()); this.itemRestingPrice = convo.getRestingPrice(); this.priceChange = convo.getPriceChange(); @@ -46,7 +46,7 @@ public Trade(MarketblockConversation convo) { } public void resetDemand() { - demand = demandLimit / 2; + demand = clampDemand(demandLimit / 2); } public void setDemand(double demand) { diff --git a/src/main/java/net/tfminecraft/marketblock/util/FreshnessLookup.java b/src/main/java/net/tfminecraft/marketblock/util/FreshnessLookup.java index 91f81fe..0833b98 100644 --- a/src/main/java/net/tfminecraft/marketblock/util/FreshnessLookup.java +++ b/src/main/java/net/tfminecraft/marketblock/util/FreshnessLookup.java @@ -1,5 +1,7 @@ package net.tfminecraft.marketblock.util; +import java.util.Locale; + import org.bukkit.Bukkit; import org.bukkit.inventory.ItemStack; @@ -25,6 +27,6 @@ public static String stepId(ItemStack item) { return "fresh"; } String id = track.getCurrentStep().getId(); - return id == null || id.isBlank() ? "fresh" : id.toLowerCase(); + return id == null || id.isBlank() ? "fresh" : id.toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java b/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java index 27d7b76..0955c83 100644 --- a/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java +++ b/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java @@ -20,11 +20,19 @@ public static double getTotalAmount(Player p, String path) { } public static boolean hasEnough(Player p, String path, double requiredAmount) { - return getTotalAmount(p, path) >= requiredAmount; + return Double.isFinite(requiredAmount) && requiredAmount > 0 + && requiredAmount <= Integer.MAX_VALUE + && requiredAmount == Math.rint(requiredAmount) + && getTotalAmount(p, path) >= requiredAmount; } public static SaleTake removeItems(Player p, String path, double amountToRemove) { SaleTake take = new SaleTake(); + if (!Double.isFinite(amountToRemove) || amountToRemove <= 0 + || amountToRemove > Integer.MAX_VALUE + || amountToRemove != Math.rint(amountToRemove)) { + return take; + } double remaining = amountToRemove; for (ItemStack item : p.getInventory().getContents()) { diff --git a/src/test/java/net/tfminecraft/marketblock/CacheTest.java b/src/test/java/net/tfminecraft/marketblock/CacheTest.java new file mode 100644 index 0000000..1aa713a --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/CacheTest.java @@ -0,0 +1,66 @@ +package net.tfminecraft.marketblock; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Locale; +import net.tfminecraft.marketblock.loader.ConfigLoader; +import net.tfminecraft.marketblock.database.TradeDatabase; +import net.tfminecraft.marketblock.manager.commands.ConversationManager; +import net.tfminecraft.marketblock.manager.commands.MarketblockConversation; +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.util.DemandFormatter; +import net.tfminecraft.marketblock.util.PriceCalculator; +import org.bukkit.configuration.ConfigurationSection; +import static org.mockito.Mockito.*; +import org.junit.jupiter.api.Test; + +class CacheTest extends MarketTestSupport { + @Test + void publicUtilityConstructionDoesNotDiscardLiveStateAndConversationRetainsItsPlayer() { + Cache.freshnessPrice.put("ripe", 0.75); + assertNotNull(new Cache()); + assertNotNull(new TradeDatabase()); + assertNotNull(new DemandFormatter()); + assertNotNull(new PriceCalculator()); + assertNotNull(new ConversationManager()); + assertEquals(0.75, Cache.freshnessMultiplier("ripe")); + var player = server.addPlayer(); + assertSame(player, new MarketblockConversation(player, null).getPlayer()); + } + + @Test + void aProgrammaticCategoryWithANullNameKeepsTheDefaultColour() { + var config = mock(ConfigurationSection.class); + var category = new Category("custom", config); + assertEquals("#ffffff", category.getColorHex()); + } + + @Test + void freshnessIsCaseInsensitiveRegardlessOfServerLocaleAndUnknownStagesKeepFullValue() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + Cache.freshnessPrice.put("ripe", 0.755); + assertEquals(0.755, Cache.freshnessMultiplier("RIPE")); + assertEquals(76, Cache.freshnessPercent("RIPE")); + assertEquals(1, Cache.freshnessMultiplier(null)); + assertEquals(1, Cache.freshnessMultiplier(" ")); + assertEquals(1, Cache.freshnessMultiplier("other")); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void missingConfigurationUsesSafeDefaultsAndClearsPreviousSettings() { + Cache.freshnessPrice.put("obsolete", 10.0); + new ConfigLoader().load(directory.resolve("missing.yml").toFile()); + assertNull(Cache.marketBlock); + assertTrue(Cache.slots.isEmpty()); + assertEquals(4, Cache.demandRecoveryHours); + assertEquals(1, Cache.freshnessMultiplier("fresh")); + assertEquals(0.5, Cache.freshnessMultiplier("stale")); + assertEquals(0.1, Cache.freshnessMultiplier("rotten")); + assertFalse(Cache.freshnessPrice.containsKey("obsolete")); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/MarketBlockLifecycleTest.java b/src/test/java/net/tfminecraft/marketblock/MarketBlockLifecycleTest.java new file mode 100644 index 0000000..a329955 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/MarketBlockLifecycleTest.java @@ -0,0 +1,63 @@ +package net.tfminecraft.marketblock; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.Comparator; +import java.util.List; +import net.tfminecraft.marketblock.database.TradeDatabase; +import net.tfminecraft.marketblock.loader.CategoryLoader; +import net.tfminecraft.marketblock.loader.ConfigLoader; +import net.tfminecraft.marketblock.loader.TradeLoader; +import net.tfminecraft.marketblock.manager.TradeManager; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class MarketBlockLifecycleTest { + @Test + void enableCreatesDefaultsRegistersListenersAndStartsDemandReloadPreservesFiles() throws Exception { + var previous = MarketBlock.plugin; + MockBukkit.mock(); + try (var config = mockConstruction(ConfigLoader.class); + var categories = mockConstruction(CategoryLoader.class); + var trades = mockConstruction(TradeLoader.class); + var managers = mockConstruction(TradeManager.class); + var database = mockStatic(TradeDatabase.class)) { + var plugin = MockBukkit.load(MarketBlock.class); + assertSame(plugin, MarketBlock.plugin); + assertNotNull(plugin.getCommand("marketblock").getExecutor()); + assertNotNull(plugin.getCommand("marketblock").getTabCompleter()); + assertTrue(Files.isDirectory(plugin.getDataFolder().toPath().resolve("data"))); + for (String name : List.of("config.yml", "categories.yml", "trades.yml")) { + assertTrue(Files.isRegularFile(plugin.getDataFolder().toPath().resolve(name))); + } + verify(managers.constructed().getFirst()).start(); + var marker = plugin.getDataFolder().toPath().resolve("config.yml"); + Files.writeString(marker, "market-block: preserved\n"); + plugin.createFolders(); + plugin.createConfigs(); + assertEquals("market-block: preserved\n", Files.readString(marker)); + Player player = mock(Player.class); + plugin.reload(player); + verify(player).sendMessage("§a[MarketBlock] §eReloading..."); + verify(player).sendMessage("§a[MarketBlock] §eReloaded!"); + verify(config.constructed().getFirst(), times(2)).load(marker.toFile()); + verify(categories.constructed().getFirst(), times(2)).load(plugin.getDataFolder().toPath().resolve("categories.yml").toFile()); + verify(trades.constructed().getFirst(), times(2)).loadTrades(); + verify(managers.constructed().getFirst()).rescheduleDemandCycle(); + plugin.onDisable(); + database.verify(TradeDatabase::saveDemand); + try (var paths = Files.walk(plugin.getDataFolder().toPath())) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } + plugin.createFolders(); + assertTrue(Files.isDirectory(plugin.getDataFolder().toPath().resolve("data"))); + MockBukkit.unmock(); + } finally { + if (MockBukkit.isMocked()) MockBukkit.unmock(); + MarketBlock.plugin = previous; + } + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/MarketTestSupport.java b/src/test/java/net/tfminecraft/marketblock/MarketTestSupport.java new file mode 100644 index 0000000..ff1c0f7 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/MarketTestSupport.java @@ -0,0 +1,69 @@ +package net.tfminecraft.marketblock; + +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.logging.Logger; +import net.tfminecraft.marketblock.loader.CategoryLoader; +import net.tfminecraft.marketblock.loader.TradeLoader; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemChecker; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.MockedStatic; + +/** Real Bukkit inventory/YAML behavior, with only the external TLibs boundary mocked. */ +public abstract class MarketTestSupport { + @TempDir protected Path directory; + protected ServerMock server; + protected MarketBlock plugin; + protected Logger logger; + protected ItemCreator creator; + protected ItemChecker checker; + private MarketBlock previousPlugin; + protected MockedStatic tlibs; + + @BeforeEach + void createServer() { + server = MockBukkit.mock(); + previousPlugin = MarketBlock.plugin; + plugin = mock(MarketBlock.class); + when(plugin.getName()).thenReturn("MarketBlock"); + when(plugin.namespace()).thenReturn("marketblock"); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getServer()).thenReturn(server); + when(plugin.isEnabled()).thenReturn(true); + logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + MarketBlock.plugin = plugin; + var api = mock(ItemAPI.class); + creator = mock(ItemCreator.class); + checker = mock(ItemChecker.class); + when(api.getCreator()).thenReturn(creator); + when(api.getChecker()).thenReturn(checker); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + CategoryLoader.categories.clear(); + TradeLoader.getTrades().clear(); + Cache.slots.clear(); + Cache.freshnessPrice.clear(); + Cache.demandRecoveryHours = 4.0; + } + + @AfterEach + void closeServer() { + tlibs.close(); + MockBukkit.unmock(); + MarketBlock.plugin = previousPlugin; + CategoryLoader.categories.clear(); + TradeLoader.getTrades().clear(); + Cache.slots.clear(); + Cache.freshnessPrice.clear(); + Cache.demandRecoveryHours = 4.0; + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/inventory/MarketViewsTest.java b/src/test/java/net/tfminecraft/marketblock/inventory/MarketViewsTest.java new file mode 100644 index 0000000..4a2a447 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/inventory/MarketViewsTest.java @@ -0,0 +1,95 @@ +package net.tfminecraft.marketblock.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import net.tfminecraft.marketblock.Cache; +import net.tfminecraft.marketblock.MarketTestSupport; +import net.tfminecraft.marketblock.inventory.holder.MBGUI; +import net.tfminecraft.marketblock.inventory.holder.MBHolder; +import net.tfminecraft.marketblock.loader.CategoryLoader; +import net.tfminecraft.marketblock.manager.InventoryManager; +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.Trade; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("deprecation") +class MarketViewsTest extends MarketTestSupport { + @BeforeEach + void itemProvider() { + Cache.slots = new ArrayList<>(List.of(0, 1)); + when(creator.getItemFromPath(anyString())).thenAnswer(i -> new ItemStack(Material.STONE)); + } + + @Test + void categoriesHaveIdsNamesCountsAndFillEmptySlots() { + var player = server.addPlayer(); + var category = new Category(); + CategoryLoader.categories.put(category.getId(), category); + new InventoryManager().categoryView(null, player); + var inventory = player.getOpenInventory().getTopInventory(); + assertEquals(54, inventory.getSize()); + assertEquals("§7Market Categories", player.getOpenInventory().getTitle()); + var holder = (MBHolder) inventory.getHolder(); + assertEquals(MBGUI.CATEGORY, holder.getType()); + assertEquals("none", holder.getId()); + assertNull(holder.getInventory()); + assertEquals("unknown", inventory.getItem(0).getItemMeta().getPersistentDataContainer() + .get(new NamespacedKey(plugin, "category_id"), PersistentDataType.STRING)); + assertTrue(inventory.getItem(0).getItemMeta().getLore().getFirst().contains("0")); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(53).getType()); + CategoryLoader.categories.clear(); + new InventoryManager().categoryView(inventory, player); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(0).getType(), "Removed categories must disappear from a reused menu"); + } + + @Test + void tradesSortByGroupShowFreshnessAndRemoveStaleOffersOnRefresh() { + var player = server.addPlayer(); + var category = new Category(); + var late = new Trade("late", category, 10, 20, 1, "v.stone", 2, 16, 5); + var early = new Trade("early", category, 10, 20, 1, "c.bread", 3, 8, 1); + var named = new ItemStack(Material.BREAD); + var meta = named.getItemMeta(); + meta.setDisplayName("§aBread"); + named.setItemMeta(meta); + when(creator.getItemFromPath("c.bread")).thenReturn(named); + new InventoryManager().tradeView(null, player, category); + var inventory = player.getOpenInventory().getTopInventory(); + var key = new NamespacedKey(plugin, "trade_id"); + assertEquals("early", inventory.getItem(0).getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING)); + assertEquals("late", inventory.getItem(1).getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING)); + assertTrue(inventory.getItem(0).getItemMeta().getDisplayName().contains("Bread")); + assertTrue(inventory.getItem(0).getItemMeta().getLore().stream().anyMatch(line -> line.contains("Stale"))); + assertEquals(Material.BARRIER, inventory.getItem(53).getType()); + assertEquals("§cBack", inventory.getItem(53).getItemMeta().getDisplayName()); + category.removeTrade(late); + category.removeTrade(early); + new InventoryManager().tradeView(inventory, player, category); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(0).getType(), "Removed trades must disappear from open menus"); + assertEquals(Material.BARRIER, inventory.getItem(53).getType()); + } + + @Test + void missingAndAirIconsUseVisibleFallbackAndConfiguredSlotCapacityIsRespected() { + var player = server.addPlayer(); + var category = new Category(); + new Trade("missing", category, 1, 2, 0, "missing", 1, 1, 0); + new Trade("air", category, 1, 2, 0, "air", 1, 1, 1); + new Trade("overflow", category, 1, 2, 0, "overflow", 1, 1, 2); + when(creator.getItemFromPath("missing")).thenReturn(null); + when(creator.getItemFromPath("air")).thenReturn(new ItemStack(Material.AIR)); + new TradeView().tradeView(null, player, category); + var inventory = player.getOpenInventory().getTopInventory(); + assertEquals(Material.GRAY_DYE, inventory.getItem(0).getType()); + assertEquals(Material.GRAY_DYE, inventory.getItem(1).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(2).getType()); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/loader/MarketPersistenceTest.java b/src/test/java/net/tfminecraft/marketblock/loader/MarketPersistenceTest.java new file mode 100644 index 0000000..790a139 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/loader/MarketPersistenceTest.java @@ -0,0 +1,112 @@ +package net.tfminecraft.marketblock.loader; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.Map; +import net.tfminecraft.marketblock.MarketTestSupport; +import net.tfminecraft.marketblock.database.TradeDatabase; +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.Trade; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class MarketPersistenceTest extends MarketTestSupport { + @Test + void categoriesLoadInOrderAndUnknownIdsUseTheFallback() throws Exception { + var file = directory.resolve("categories.yml"); + Files.writeString(file, "wood:\n name: '#aabbccWood'\n item: v.oak_log\n"); + var loader = new CategoryLoader(); + loader.load(file.toFile()); + assertEquals(2, CategoryLoader.get().size()); + assertEquals("wood", CategoryLoader.getAsList().getFirst().getId()); + assertSame(CategoryLoader.getDefaultCategory(), CategoryLoader.getByString("missing")); + assertEquals("#aabbcc", CategoryLoader.getByString("wood").getColorHex()); + Files.writeString(file, "unknown:\n name: Custom fallback\n"); + loader.load(file.toFile()); + assertEquals(1, CategoryLoader.get().size()); + assertEquals("Custom fallback", CategoryLoader.getDefaultCategory().getName()); + loader.load(directory.resolve("missing.yml").toFile()); + assertEquals(1, CategoryLoader.get().size()); + assertEquals("unknown", CategoryLoader.getDefaultCategory().getId()); + } + + @Test + void demandSurvivesDiskRoundTripAndLiveValuesTakePrecedenceOnReload() throws Exception { + Category category = new Category(); + CategoryLoader.categories.put("unknown", category); + assertTrue(TradeDatabase.loadDemand().isEmpty()); + Trade live = trade("live", category, 4); + TradeLoader.add(live); + Trade saved = trade("saved", category, 7); + TradeLoader.add(saved); + TradeDatabase.saveDemand(); + assertEquals(Map.of("live", 4.0, "saved", 7.0), TradeDatabase.loadDemand()); + live.setDemand(9); + TradeLoader.getTrades().remove("saved"); + var definitions = directory.resolve("trades.yml"); + Files.writeString(definitions, Files.readString(definitions) + "new:\n item: v.stone\n demand-limit: 12\nscalar: ignored\n"); + new TradeLoader().loadTrades(); + assertEquals(9, TradeLoader.getTradeById("live").getDemand()); + assertEquals(7, TradeLoader.getTradeById("saved").getDemand()); + assertEquals(6, TradeLoader.getTradeById("new").getDemand()); + assertNull(TradeLoader.getTradeById("scalar")); + TradeDatabase.deleteTrade(TradeLoader.getTradeById("saved")); + assertFalse(TradeDatabase.loadDemand().containsKey("saved")); + assertFalse(YamlConfiguration.loadConfiguration(definitions.toFile()).contains("saved")); + TradeDatabase.deleteTrade(TradeLoader.getTradeById("new")); + Files.delete(directory.resolve("data/demand.yml")); + TradeDatabase.deleteTrade(live); + Files.delete(definitions); + new TradeLoader().loadTrades(); + assertTrue(TradeLoader.getTrades().isEmpty()); + verify(logger).warning(startsWith("trades.yml does not exist:")); + } + + @Test + void definitionPreservesOptionalIconAndAllTradeTerms() throws Exception { + Category category = new Category(); + CategoryLoader.categories.put("unknown", category); + var config = new YamlConfiguration(); + var section = config.createSection("sale"); + section.set("icon", "v.apple"); + section.set("item", "v.stone"); + section.set("amount", 16); + section.set("demand-limit", 40); + section.set("resting-price", 2.5); + section.set("price-change", 0.5); + section.set("group", 3); + Trade trade = Trade.fromYaml("sale", section, 8); + TradeDatabase.addTradeDefinition(trade); + var saved = YamlConfiguration.loadConfiguration(directory.resolve("trades.yml").toFile()); + assertEquals("v.apple", saved.getString("sale.icon")); + assertEquals("v.stone", saved.getString("sale.item")); + assertEquals(16, saved.getDouble("sale.amount")); + assertEquals(40, saved.getDouble("sale.demand-limit")); + assertEquals(2.5, saved.getDouble("sale.resting-price")); + assertEquals(0.5, saved.getDouble("sale.price-change")); + assertEquals(3, saved.getInt("sale.group")); + assertEquals("unknown", saved.getString("sale.category")); + } + + @Test + void failedWritesAreReportedWithoutDestroyingOtherFiles() throws Exception { + Category category = new Category(); + Trade trade = trade("oak", category, 3); + Files.createDirectory(directory.resolve("trades.yml")); + Files.createDirectories(directory.resolve("data/demand.yml")); + TradeDatabase.addTradeDefinition(trade); + TradeDatabase.saveDemand(); + TradeDatabase.deleteTrade(trade); + verify(logger, times(2)).warning(startsWith("Could not update trades.yml:")); + verify(logger).warning(startsWith("Could not save demand.yml:")); + verify(logger).warning(startsWith("Could not update demand.yml:")); + assertTrue(Files.isDirectory(directory.resolve("trades.yml"))); + assertTrue(Files.isDirectory(directory.resolve("data/demand.yml"))); + } + + private Trade trade(String id, Category category, double demand) { + return new Trade(id, category, demand, 20, 1, "v.oak_log", 2, 64, 0); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/manager/MarketCommandsTest.java b/src/test/java/net/tfminecraft/marketblock/manager/MarketCommandsTest.java new file mode 100644 index 0000000..52e931a --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/manager/MarketCommandsTest.java @@ -0,0 +1,108 @@ +package net.tfminecraft.marketblock.manager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import net.tfminecraft.marketblock.MarketBlock; +import net.tfminecraft.marketblock.MarketTestSupport; +import net.tfminecraft.marketblock.database.TradeDatabase; +import net.tfminecraft.marketblock.loader.TradeLoader; +import net.tfminecraft.marketblock.manager.commands.ConversationManager; +import net.tfminecraft.marketblock.manager.commands.TabCompletion; +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.Trade; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; + +class MarketCommandsTest extends MarketTestSupport { + @Test + void permissionsUsageReloadAndPlayerOnlyBranchesKeepTheirContracts() { + var manager = new CommandManager(); + var command = mock(Command.class); + var console = mock(CommandSender.class); + when(command.getName()).thenReturn("other"); + assertTrue(manager.onCommand(console, command, "", new String[0])); + verifyNoInteractions(console); + when(command.getName()).thenReturn("marketblock"); + when(console.hasPermission(CommandManager.ADMIN_PERMISSION)).thenReturn(true); + assertTrue(manager.onCommand(console, command, "", new String[0])); + verify(console).sendMessage(startsWith("§cUsage:")); + assertTrue(manager.onCommand(console, command, "", new String[] {"add"})); + verify(console).sendMessage("Only players can use this command."); + var player = server.addPlayer(); + player.setOp(true); + try (var plugins = mockStatic(JavaPlugin.class)) { + plugins.when(() -> JavaPlugin.getPlugin(MarketBlock.class)).thenReturn(plugin); + assertTrue(manager.onCommand(console, command, "", new String[] {"reload"})); + assertTrue(manager.onCommand(player, command, "", new String[] {"reload"})); + verify(plugin).reload(); + verify(plugin).reload(player); + } + assertFalse(manager.onCommand(player, command, "", new String[] {"unknown"})); + assertTrue(manager.onCommand(player, command, "", new String[] {"add"})); + assertTrue(player.nextMessage().contains("hold an item")); + player.getInventory().setItemInMainHand(new ItemStack(Material.STONE, 4)); + assertTrue(manager.onCommand(player, command, "", new String[] {"add"})); + var conversation = ConversationManager.getConversation(player); + assertNotNull(conversation); + assertEquals(4, conversation.getItem().getAmount()); + assertTrue(player.nextMessage().contains("trade ID")); + ConversationManager.endConversation(player); + } + + @Test + void resetsAndDeletionKeepTheTradeIndexCategoryAndDiskInSync() { + var manager = new CommandManager(); + var command = mock(Command.class); + when(command.getName()).thenReturn("marketblock"); + var player = server.addPlayer(); + player.setOp(true); + var category = new Category(); + var trade = new Trade("oak", category, 2, 20, 1, "v.oak_log", 2, 64, 0); + TradeLoader.getTrades().put("oak", trade); + for (String sub : List.of("reset", "delete")) { + assertTrue(manager.onCommand(player, command, "", new String[] {sub})); + assertTrue(player.nextMessage().contains("specify the trade ID")); + assertTrue(manager.onCommand(player, command, "", new String[] {sub, "missing"})); + assertTrue(player.nextMessage().contains("No trade found")); + } + assertTrue(manager.onCommand(player, command, "", new String[] {"reset", "oak"})); + assertEquals(10, trade.getDemand()); + assertTrue(player.nextMessage().contains("has been reset")); + trade.setDemand(1); + assertTrue(manager.onCommand(player, command, "", new String[] {"resetall"})); + assertEquals(10, trade.getDemand()); + assertEquals("§aReset 1 trades.", player.nextMessage()); + try (var database = mockStatic(TradeDatabase.class)) { + assertTrue(manager.onCommand(player, command, "", new String[] {"delete", "oak"})); + database.verify(() -> TradeDatabase.deleteTrade(trade)); + assertNull(TradeLoader.getTradeById("oak")); + assertTrue(category.getTrades().isEmpty()); + assertTrue(player.nextMessage().contains("has been deleted")); + } + } + + @Test + void completionRespectsCommandPermissionPrefixAndTradeArguments() { + var completion = new TabCompletion(); + var command = mock(Command.class); + var sender = mock(CommandSender.class); + when(command.getName()).thenReturn("other"); + assertTrue(completion.onTabComplete(sender, command, "", new String[] {""}).isEmpty()); + when(command.getName()).thenReturn("marketblock"); + assertTrue(completion.onTabComplete(sender, command, "", new String[] {""}).isEmpty()); + when(sender.hasPermission(CommandManager.ADMIN_PERMISSION)).thenReturn(true); + assertEquals(List.of("reload", "reset", "resetall"), completion.onTabComplete(sender, command, "", new String[] {"RE"})); + TradeLoader.getTrades().put("oak", new Trade("oak", new Category(), 2, 20, 1, "v.oak_log", 2, 64, 0)); + assertEquals(List.of("oak"), completion.onTabComplete(sender, command, "", new String[] {"delete", ""})); + assertEquals(List.of("oak"), completion.onTabComplete(sender, command, "", new String[] {"reset", ""})); + assertTrue(completion.onTabComplete(sender, command, "", new String[] {"add", ""}).isEmpty()); + assertTrue(completion.onTabComplete(sender, command, "", new String[0]).isEmpty()); + assertTrue(completion.onTabComplete(sender, command, "", new String[] {"reset", "oak", "extra"}).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java b/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java new file mode 100644 index 0000000..32620b3 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java @@ -0,0 +1,283 @@ +package net.tfminecraft.marketblock.manager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.marketblock.Cache; +import net.tfminecraft.marketblock.MarketTestSupport; +import net.tfminecraft.marketblock.events.MarketSaleEvent; +import net.tfminecraft.marketblock.inventory.holder.MBGUI; +import net.tfminecraft.marketblock.inventory.holder.MBHolder; +import net.tfminecraft.marketblock.loader.CategoryLoader; +import net.tfminecraft.marketblock.loader.TradeLoader; +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.Trade; +import net.tfminecraft.marketblock.util.FreshnessLookup; +import net.tfminecraft.marketblock.util.InventoryUtils; +import net.tfminecraft.marketblock.util.SaleTake; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import net.tfminecraft.tlibs.objects.api.subapi.BlockChecker; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class TradeManagerTest extends MarketTestSupport { + private TradeManager manager; + private PlayerMock player; + private Category category; + + @BeforeEach + void prepareMarket() { + player = server.addPlayer(); + manager = new TradeManager(); + category = new Category(); + CategoryLoader.categories.put(category.getId(), category); + Cache.slots = new ArrayList<>(List.of(0, 1)); + Cache.freshnessPrice.put("fresh", 1.0); + Cache.freshnessPrice.put("stale", 0.5); + Cache.freshnessPrice.put("rotten", 0.1); + when(creator.getItemFromPath(anyString())).thenReturn(new ItemStack(Material.CARROT)); + when(checker.checkItemWithPath(any(), eq("v.carrot"))).thenAnswer(i -> i.getArgument(0).getType() == Material.CARROT); + } + + @Test + void successfulSaleConsumesGoodsPaysOnceAndPublishesMatchingEvent() { + var trade = trade("sale", 4); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 2)); + player.getInventory().setItem(1, new ItemStack(Material.CARROT, 4)); + var holder = openTrade(); + var money = mock(MoneyManager.class); + try (var economy = mockStatic(DenarEconomy.class); var freshness = mockStatic(FreshnessLookup.class)) { + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + freshness.when(() -> FreshnessLookup.stepId(any())).thenReturn("stale", "fresh"); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + verify(money).addMoney(player, 6.0, false, true); + assertEquals(0, player.getInventory().getItem(0).getAmount()); + assertEquals(2, player.getInventory().getItem(1).getAmount()); + assertEquals("§aSold 4 for 6d", player.nextMessage()); + assertEquals("§7 Base: 8d", player.nextMessage()); + assertEquals("§7 Fresh x2: 100%", player.nextMessage()); + assertEquals("§7 Stale x2: 50%", player.nextMessage()); + var events = server.getPluginManager().getFiredEvents().filter(MarketSaleEvent.class::isInstance).map(MarketSaleEvent.class::cast).toList(); + assertEquals(1, events.size()); + var event = events.getFirst(); + assertSame(player, event.getPlayer()); + assertSame(trade, event.getTrade()); + assertEquals(6, event.getPrice()); + assertEquals(4, event.getAmount()); + assertSame(MarketSaleEvent.getHandlerList(), event.getHandlers()); + } + } + + @Test + void freshSalesAndUnknownFreshnessHaveAccurateMessagesAndRounding() { + var trade = trade("sale", 2); + var holder = openTrade(); + var money = mock(MoneyManager.class); + try (var economy = mockStatic(DenarEconomy.class); var freshness = mockStatic(FreshnessLookup.class)) { + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + freshness.when(() -> FreshnessLookup.stepId(any())).thenReturn("fresh", "custom"); + for (String step : List.of("fresh", "custom")) { + trade.setDemand(1.234); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 2)); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§aSold 2 for 0.99d", player.nextMessage()); + if (step.equals("custom")) { + assertEquals("§7 Base: 0.99d", player.nextMessage()); + assertEquals("§7 Custom x2: 100%", player.nextMessage()); + } + assertNull(player.nextMessage()); + } + verify(money, times(2)).addMoney(player, 0.99, false, true); + } + } + + @Test + void anEmptyRemovalCannotPayEvenIfTheInventoryCheckPreviouslySucceeded() { + trade("sale", 2); + var holder = openTrade(); + try (var inventory = mockStatic(InventoryUtils.class); var economy = mockStatic(DenarEconomy.class)) { + inventory.when(() -> InventoryUtils.hasEnough(player, "v.carrot", 2)).thenReturn(true); + inventory.when(() -> InventoryUtils.removeItems(player, "v.carrot", 2)).thenReturn(new SaleTake()); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§cThe market could not take the items for this trade.", player.nextMessage()); + economy.verifyNoInteractions(); + server.getPluginManager().assertEventNotFired(MarketSaleEvent.class); + } + } + + @Test + void externallySuppliedFreshnessCountsIgnoreZeroEntriesAndDisplayUnnamedStagesSafely() { + trade("sale", 2); + var holder = openTrade(); + var take = new SaleTake(); + // SaleTake exposes a mutable map; tolerate a caller that supplied legacy unnamed stages. + take.getCounts().put("fresh", 0); + take.getCounts().put("unknown", 0); + take.getCounts().put(null, 2); + var money = mock(MoneyManager.class); + try (var inventory = mockStatic(InventoryUtils.class); var economy = mockStatic(DenarEconomy.class)) { + inventory.when(() -> InventoryUtils.hasEnough(player, "v.carrot", 2)).thenReturn(true); + inventory.when(() -> InventoryUtils.removeItems(player, "v.carrot", 2)).thenReturn(take); + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + verify(money).addMoney(player, 8.0, false, true); + assertEquals("§aSold 2 for 8d", player.nextMessage()); + assertEquals("§7 Base: 8d", player.nextMessage()); + assertEquals("§7 Fresh x2: 100%", player.nextMessage()); + assertNull(player.nextMessage()); + } + } + + @Test + void missingGoodsInvalidAmountsDeletedOffersAndDecorationsDoNotPay() { + trade("sale", 2); + var holder = openTrade(); + try (var economy = mockStatic(DenarEconomy.class)) { + manager.tradeClick(player, holder, new ItemStack(Material.STONE), null); + manager.tradeClick(player, holder, tagged("trade_id", "missing"), null); + assertEquals("§cThe market no longer buys this.", player.nextMessage()); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§cYou don't have enough items for this trade.", player.nextMessage()); + for (double amount : new double[] {0, -1, 0.5, Double.NaN, Double.POSITIVE_INFINITY}) { + trade("sale", amount); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 3)); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + assertEquals("§cYou don't have enough items for this trade.", player.nextMessage()); + } + economy.verifyNoInteractions(); + } + } + + @Test + void menuClicksAreCancelledAndNavigateOnlyWithinTheTopInventory() { + manager.inv = mock(InventoryManager.class); + var holder = openTrade(); + var event = clickEvent(new ItemStack(Material.BARRIER), player.getOpenInventory().getTopInventory()); + manager.click(event); + verify(event).setCancelled(true); + verify(manager.inv).categoryView(null, player); + reset(manager.inv); + event = clickEvent(new ItemStack(Material.BARRIER), player.getInventory()); + manager.click(event); + verify(event).setCancelled(true); + verifyNoInteractions(manager.inv); + manager.click(clickEvent(null, player.getOpenInventory().getTopInventory())); + manager.click(clickEvent(new ItemStack(Material.AIR), player.getOpenInventory().getTopInventory())); + verifyNoInteractions(manager.inv); + player.openInventory(server.createInventory(new MBHolder(MBGUI.CATEGORY, "none"), 54)); + manager.click(clickEvent(tagged("category_id", "unknown"), player.getOpenInventory().getTopInventory())); + verify(manager.inv).tradeView(null, player, category); + reset(manager.inv); + manager.categoryClick(player, new MBHolder(MBGUI.CATEGORY, "none"), new ItemStack(Material.STONE), null); + verifyNoInteractions(manager.inv); + player.openInventory(server.createInventory(null, 9)); + event = clickEvent(new ItemStack(Material.STONE), player.getInventory()); + manager.click(event); + verify(event, never()).setCancelled(true); + } + + @Test + void opensOnlyMatchingRightClickedMarketBlocks() { + manager.inv = mock(InventoryManager.class); + var blocks = mock(BlockAPI.class); + var blockChecker = mock(BlockChecker.class); + when(blocks.getChecker()).thenReturn(blockChecker); + tlibs.when(TLibs::getBlockAPI).thenReturn(blocks); + Cache.marketBlock = "v.stone"; + var block = mock(Block.class); + var event = mock(PlayerInteractEvent.class); + when(event.getPlayer()).thenReturn(player); + when(event.getClickedBlock()).thenReturn(block); + when(event.getAction()).thenReturn(Action.LEFT_CLICK_BLOCK); + manager.openMarket(event); + verifyNoInteractions(blockChecker, manager.inv); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + manager.openMarket(event); + verifyNoInteractions(manager.inv); + when(blockChecker.checkBlock(block, "v.stone")).thenReturn(true); + manager.openMarket(event); + verify(event).setCancelled(true); + verify(manager.inv).categoryView(null, player); + } + + @Test + void recoveryRunsOnScheduleAndReschedulingCancelsThePreviousTimer() { + var trade = trade("sale", 2); + trade.setDemand(1); + openTrade(); + var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + var first = mock(org.bukkit.scheduler.BukkitTask.class); + var replacement = mock(org.bukkit.scheduler.BukkitTask.class); + var callbacks = new ArrayList(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> { + callbacks.add(call.getArgument(1)); + return callbacks.size() == 1 ? first : replacement; + }); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + manager.start(); + verify(scheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(288000L)); + callbacks.getFirst().run(); + assertTrue(trade.getDemand() >= 2); + Cache.demandRecoveryHours = 2.0 / 72000; + manager.rescheduleDemandCycle(); + verify(first).cancel(); + verify(scheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(2L), eq(2L)); + double before = trade.getDemand(); + callbacks.getLast().run(); + assertTrue(trade.getDemand() > before); + } + player.closeInventory(); + manager.update(); + player.openInventory(server.createInventory(new MBHolder(MBGUI.CATEGORY, "none"), 54)); + manager.update(); + } + + private Trade trade(String id, double amount) { + var trade = new Trade(id, category, 10, 20, 0, "v.carrot", 8, amount, 0); + TradeLoader.getTrades().put(id, trade); + return trade; + } + + private MBHolder openTrade() { + var holder = new MBHolder(MBGUI.TRADE, "unknown"); + player.openInventory(server.createInventory(holder, 54)); + return holder; + } + + private ItemStack tagged(String key, String value) { + var item = new ItemStack(Material.CARROT); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(new NamespacedKey(plugin, key), PersistentDataType.STRING, value); + item.setItemMeta(meta); + return item; + } + + private InventoryClickEvent clickEvent(ItemStack item, Inventory clicked) { + var event = mock(InventoryClickEvent.class); + when(event.getView()).thenReturn(player.getOpenInventory()); + when(event.getWhoClicked()).thenReturn(player); + when(event.getCurrentItem()).thenReturn(item); + when(event.getClickedInventory()).thenReturn(clicked); + return event; + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/manager/commands/ChatCreationTest.java b/src/test/java/net/tfminecraft/marketblock/manager/commands/ChatCreationTest.java new file mode 100644 index 0000000..73ef670 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/manager/commands/ChatCreationTest.java @@ -0,0 +1,311 @@ +package net.tfminecraft.marketblock.manager.commands; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import net.tfminecraft.marketblock.MarketTestSupport; +import net.tfminecraft.marketblock.loader.CategoryLoader; +import net.tfminecraft.marketblock.loader.TradeLoader; +import net.tfminecraft.marketblock.manager.CommandManager; +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.Trade; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +@SuppressWarnings("deprecation") +class ChatCreationTest extends MarketTestSupport { + private final ChatListener listener = new ChatListener(); + private PlayerMock player; + private Category wood; + + @BeforeEach + void setUpConversation() { + player = server.addPlayer(); + player.setOp(true); + YamlConfiguration config = new YamlConfiguration(); + config.set("name", "Wood"); + wood = new Category("wood", config); + CategoryLoader.categories.put("wood", wood); + CategoryLoader.categories.put("unknown", new Category()); + when(checker.getAsStringPath(any(ItemStack.class))).thenReturn("v.oak_log"); + } + + @AfterEach + void endConversation() { + if (player != null) ConversationManager.endConversation(player); + } + + @Test + void chatWithoutAConversationIsNotConsumed() { + assertFalse(chat("hello everyone").isCancelled()); + assertTrue(messages().isEmpty()); + verifyNoInteractions(checker); + } + + @Test + void cancellationConsumesTheAnswerAndDoesNotCreateATrade() { + start(new ItemStack(Material.OAK_LOG, 12)); + assertTrue(chat(" CaNcEl ").isCancelled()); + assertNull(ConversationManager.getConversation(player)); + assertEquals(List.of("§eTrade creation cancelled."), messages()); + server.getScheduler().performOneTick(); + assertTrue(TradeLoader.getTrades().isEmpty()); + assertFalse(Files.exists(directory.resolve("trades.yml"))); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", ".", "oak.logs", "two words", "oak\tlogs"}) + void idsThatCannotBeAddressedAsOneYamlKeyAreRejected(String id) { + MarketblockConversation conversation = start(new ItemStack(Material.OAK_LOG, 12)); + + assertTrue(chat(id).isCancelled()); + + assertEquals(0, conversation.getStep()); + assertNull(conversation.getId()); + assertSame(conversation, ConversationManager.getConversation(player)); + assertTrue(messages().getLast().contains("ID")); + assertFalse(Files.exists(directory.resolve("trades.yml"))); + } + + @Test + void surroundingWhitespaceIsRemovedBeforeDuplicateIdChecks() { + Trade existing = existing("oak", 3); + TradeLoader.getTrades().put("oak", existing); + MarketblockConversation conversation = start(new ItemStack(Material.OAK_LOG, 12)); + + chat(" oak "); + + assertEquals(0, conversation.getStep()); + assertNull(conversation.getId()); + assertSame(existing, TradeLoader.getTradeById("oak")); + assertEquals(List.of("§cThat id is already taken"), messages()); + } + + @Test + void invalidNumbersKeepTheCurrentQuestionUntilAUsableAnswerArrives() { + MarketblockConversation conversation = start(new ItemStack(Material.OAK_LOG, 12)); + chat("oak"); + for (String invalid : List.of("NaN", "Infinity", "0", "-1", "not a number")) { + chat(invalid); + assertEquals(1, conversation.getStep()); + } + chat("40"); + assertEquals(2, conversation.getStep()); + assertEquals(40, conversation.getDemandLimit()); + for (String invalid : List.of("1.5", "2147483648", "group")) { + chat(invalid); + assertEquals(2, conversation.getStep()); + } + chat("4"); + assertEquals(3, conversation.getStep()); + assertEquals(4, conversation.getGroup()); + for (String invalid : List.of("-1", "NaN", "Infinity", "price")) { + chat(invalid); + assertEquals(3, conversation.getStep()); + } + chat("0"); + assertEquals(4, conversation.getStep()); + assertEquals(0, conversation.getPriceChange()); + for (String invalid : List.of("0", "-1", "NaN", "Infinity", "price")) { + chat(invalid); + assertEquals(4, conversation.getStep()); + } + chat("2.75"); + assertEquals(5, conversation.getStep()); + assertEquals(2.75, conversation.getRestingPrice()); + assertTrue(messages().contains("§cInvalid number. Please enter a valid group.")); + assertFalse(Files.exists(directory.resolve("trades.yml"))); + } + + @Test + void completeConversationSavesOnTheMainThreadAndSurvivesYamlReload() { + MarketblockConversation conversation = answerThroughPrice(" oak-log:bundle ", 12); + + assertTrue(chat("wood").isCancelled()); + assertNull(ConversationManager.getConversation(player)); + assertNull(TradeLoader.getTradeById("oak-log:bundle")); + assertFalse(Files.exists(directory.resolve("trades.yml"))); + server.getScheduler().performOneTick(); + + Trade trade = TradeLoader.getTradeById("oak-log:bundle"); + assertNotNull(trade); + assertSame(wood, trade.getCategory()); + assertEquals(Set.of(trade), wood.getTrades()); + assertEquals(20, trade.getDemand()); + assertEquals(12, trade.getAmount()); + assertEquals(4, trade.getGroup()); + assertEquals(1.5, trade.getPriceChange()); + assertEquals(2.75, trade.getItemRestingPrice()); + assertEquals("oak-log:bundle", conversation.getId()); + YamlConfiguration saved = saved(); + assertEquals(Set.of("oak-log:bundle"), saved.getKeys(false)); + assertEquals("v.oak_log", saved.getString("oak-log:bundle.item")); + assertEquals("wood", saved.getString("oak-log:bundle.category")); + assertEquals(12, saved.getDouble("oak-log:bundle.amount")); + assertEquals(40, saved.getDouble("oak-log:bundle.demand-limit")); + assertEquals(4, saved.getInt("oak-log:bundle.group")); + assertEquals(1.5, saved.getDouble("oak-log:bundle.price-change")); + assertEquals(2.75, saved.getDouble("oak-log:bundle.resting-price")); + assertTrue(messages().contains("§aTrade successfully created!")); + + TradeLoader.getTrades().clear(); + new TradeLoader().loadTrades(); + assertEquals("v.oak_log", TradeLoader.getTradeById("oak-log:bundle").getItemString()); + assertEquals(12, TradeLoader.getTradeById("oak-log:bundle").getAmount()); + assertNull(TradeLoader.getTradeById(" oak-log:bundle ")); + } + + @Test + void unknownCategoryUsesTheConfiguredFallbackAndWarnsThePlayer() { + answerThroughPrice("oak", 12); + chat("missing"); + server.getScheduler().performOneTick(); + + assertSame(CategoryLoader.getDefaultCategory(), TradeLoader.getTradeById("oak").getCategory()); + assertEquals("unknown", saved().getString("oak.category")); + assertTrue(messages().contains("§cWarning, no category found, default selected")); + } + + @Test + void missingFallbackCategoryIsRecreatedInsteadOfCrashingTheFinalAnswer() { + answerThroughPrice("oak", 12); + CategoryLoader.categories.clear(); + + assertDoesNotThrow(() -> chat("missing")); + assertDoesNotThrow(() -> server.getScheduler().performOneTick()); + + Trade trade = TradeLoader.getTradeById("oak"); + assertNotNull(trade); + assertEquals("unknown", trade.getCategory().getId()); + assertSame(trade.getCategory(), CategoryLoader.getDefaultCategory()); + assertEquals(Set.of(trade), trade.getCategory().getTrades()); + } + + @Test + void categoryReloadBeforeTheQueuedSaveUsesTheCurrentCategoryObject() { + answerThroughPrice("oak", 12); + chat("wood"); + Category replacement = new Category("wood", new YamlConfiguration()); + CategoryLoader.categories.put("wood", replacement); + + server.getScheduler().performOneTick(); + + Trade trade = TradeLoader.getTradeById("oak"); + assertSame(replacement, trade.getCategory()); + assertEquals(Set.of(trade), replacement.getTrades()); + assertTrue(wood.getTrades().isEmpty()); + } + + @Test + void competingCreationBeforeTheQueuedSaveCannotOverwriteAnExistingTrade() throws Exception { + answerThroughPrice("oak", 12); + chat("wood"); + Trade winner = existing("oak", 3); + TradeLoader.add(winner); + String before = Files.readString(directory.resolve("trades.yml")); + + server.getScheduler().performOneTick(); + + assertSame(winner, TradeLoader.getTradeById("oak")); + assertEquals(before, Files.readString(directory.resolve("trades.yml"))); + assertTrue(messages().contains("§cA trade already exists with that id")); + } + + @Test + void itemThatStopsParsingBeforeTheQueuedSaveDoesNotCreateATrade() { + answerThroughPrice("oak", 12); + chat("wood"); + when(checker.getAsStringPath(any(ItemStack.class))).thenReturn(null); + + server.getScheduler().performOneTick(); + + assertTrue(TradeLoader.getTrades().isEmpty()); + assertTrue(wood.getTrades().isEmpty()); + assertFalse(Files.exists(directory.resolve("trades.yml"))); + assertTrue(messages().contains("§cCould not parse the item you are holding")); + } + + @Test + void permissionRevokedAfterTheFinalAnswerPreventsTheQueuedSave() { + answerThroughPrice("oak", 12); + chat("wood"); + player.setOp(false); + assertFalse(player.hasPermission(CommandManager.ADMIN_PERMISSION)); + + server.getScheduler().performOneTick(); + + assertTrue(TradeLoader.getTrades().isEmpty()); + assertTrue(wood.getTrades().isEmpty()); + assertFalse(Files.exists(directory.resolve("trades.yml"))); + } + + @Test + void mutatingTheOriginalHeldStackDoesNotChangeTheSavedTradeQuantity() { + ItemStack held = new ItemStack(Material.OAK_LOG, 12); + start(held); + held.setAmount(1); + chat("oak"); + chat("40"); + chat("4"); + chat("1.5"); + chat("2.75"); + chat("wood"); + + server.getScheduler().performOneTick(); + + assertEquals(12, TradeLoader.getTradeById("oak").getAmount()); + assertEquals(12, saved().getDouble("oak.amount")); + assertEquals(1, held.getAmount()); + } + + private MarketblockConversation answerThroughPrice(String id, int amount) { + MarketblockConversation conversation = start(new ItemStack(Material.OAK_LOG, amount)); + for (String answer : List.of(id, "40", "4", "1.5", "2.75")) { + assertTrue(chat(answer).isCancelled()); + } + assertEquals(5, conversation.getStep()); + return conversation; + } + + private MarketblockConversation start(ItemStack item) { + MarketblockConversation conversation = new MarketblockConversation(player, item); + ConversationManager.startConversation(player, conversation); + return conversation; + } + + private Trade existing(String id, int amount) { + return new Trade(id, wood, 10, 20, 1, "v.oak_log", 1, amount, 0); + } + + private AsyncPlayerChatEvent chat(String message) { + AsyncPlayerChatEvent event = new AsyncPlayerChatEvent(true, player, message, new HashSet<>()); + listener.onPlayerChat(event); + return event; + } + + private List messages() { + List messages = new ArrayList<>(); + String message; + while ((message = player.nextMessage()) != null) messages.add(message); + return messages; + } + + private YamlConfiguration saved() { + return YamlConfiguration.loadConfiguration(directory.resolve("trades.yml").toFile()); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/trade/TradeModelTest.java b/src/test/java/net/tfminecraft/marketblock/trade/TradeModelTest.java new file mode 100644 index 0000000..01a87fc --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/trade/TradeModelTest.java @@ -0,0 +1,249 @@ +package net.tfminecraft.marketblock.trade; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashMap; +import java.util.Set; + +import net.tfminecraft.marketblock.loader.CategoryLoader; +import net.tfminecraft.marketblock.manager.commands.MarketblockConversation; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemChecker; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; + +class TradeModelTest { + private LinkedHashMap previousCategories; + private MockedStatic tlibs; + private ItemCreator creator; + private ItemChecker checker; + private Category unknown; + + @BeforeEach + void setUp() { + MockBukkit.mock(); + previousCategories = CategoryLoader.categories; + CategoryLoader.categories = new LinkedHashMap<>(); + unknown = new Category(); + CategoryLoader.categories.put("unknown", unknown); + + ItemAPI api = mock(ItemAPI.class); + creator = mock(ItemCreator.class); + checker = mock(ItemChecker.class); + when(api.getCreator()).thenReturn(creator); + when(api.getChecker()).thenReturn(checker); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + } + + @AfterEach + void tearDown() { + if (tlibs != null) tlibs.close(); + CategoryLoader.categories = previousCategories; + MockBukkit.unmock(); + } + + @Test + void categoryReadsItsNameColorAndIconAndFormatsTheTradeMenuTitle() throws Exception { + Category category = new Category("ores", yaml(""" + name: '#123456Ores' + item: v.iron_ingot + """)); + ItemStack icon = new ItemStack(Material.IRON_INGOT); + when(creator.getItemFromPath("v.iron_ingot")).thenReturn(icon); + + assertEquals("ores", category.getId()); + assertEquals("#123456", category.getColorHex()); + assertEquals("§x§1§2§3§4§5§6Ores", category.getName()); + assertEquals("§x§1§2§3§4§5§6Ores Trades", category.getTradesTitle()); + assertEquals("v.iron_ingot", category.getItemString()); + assertSame(icon, category.getItem()); + assertTrue(category.getTrades().isEmpty()); + } + + @Test + void categoryDefaultsAndPlainNamesRemainUsable() throws Exception { + Category configuredDefault = new Category("empty", yaml("{}")); + assertEquals("Unknown Category", ChatColor.stripColor(configuredDefault.getName())); + assertEquals("#ffffff", configuredDefault.getColorHex()); + assertEquals("v.gray_dye", configuredDefault.getItemString()); + assertEquals("unknown", unknown.getId()); + assertEquals("§fUnknown Category", unknown.getName()); + assertEquals("Unknown Category Trades", ChatColor.stripColor(unknown.getTradesTitle())); + assertEquals("#ffffff", unknown.getColorHex()); + assertEquals("v.gray_dye", unknown.getItemString()); + + assertEquals("#ffffff", new Category("short", yaml("name: Ore")).getColorHex()); + assertEquals("#ffffff", new Category("plain", yaml("name: Plain category")).getColorHex()); + assertEquals("#abcdef", new Category("spaced", yaml("name: ' #abcdefOres'")).getColorHex()); + } + + @Test + void tradeAutomaticallyJoinsItsCategoryWithoutDuplicateMembership() { + Trade trade = trade(unknown, 8, 20, 1); + assertEquals(Set.of(trade), unknown.getTrades()); + unknown.addTrade(trade); + assertEquals(Set.of(trade), unknown.getTrades()); + unknown.removeTrade(trade); + assertTrue(unknown.getTrades().isEmpty()); + unknown.removeTrade(trade); + assertTrue(unknown.getTrades().isEmpty()); + } + + @Test + void yamlRoundTripPreservesEveryConfiguredTradeFieldAndResolvesItsCategory() throws Exception { + Category category = new Category("ores", yaml("name: Ores")); + CategoryLoader.categories.put("ores", category); + YamlConfiguration original = yaml(""" + category: ores + demand-limit: 30 + price-change: 2.5 + item: v.iron_ingot + icon: v.iron_block + resting-price: 1.25 + amount: 16 + group: 4 + """); + Trade trade = Trade.fromYaml("iron", yaml(original.saveToString()), 7.5); + ItemStack item = new ItemStack(Material.IRON_INGOT); + ItemStack icon = new ItemStack(Material.IRON_BLOCK); + when(creator.getItemFromPath("v.iron_ingot")).thenReturn(item); + when(creator.getItemFromPath("v.iron_block")).thenReturn(icon); + + assertEquals("iron", trade.getId()); + assertSame(category, trade.getCategory()); + assertEquals(Set.of(trade), category.getTrades()); + assertEquals(7.5, trade.getDemand()); + assertEquals(30, trade.getDemandLimit()); + assertEquals(2.5, trade.getPriceChange()); + assertEquals("v.iron_ingot", trade.getItemString()); + assertEquals("v.iron_block", trade.getIconString()); + assertEquals(1.25, trade.getItemRestingPrice()); + assertEquals(16, trade.getAmount()); + assertEquals(4, trade.getGroup()); + assertSame(item, trade.getItem()); + assertSame(icon, trade.getIconItem()); + } + + @Test + void omittedTradeSettingsUseDefaultsAndTheTradeItemAsItsIcon() throws Exception { + Trade trade = Trade.fromYaml("fallback", yaml("{}"), 100); + ItemStack item = new ItemStack(Material.GRAY_DYE); + when(creator.getItemFromPath("v.gray_dye")).thenReturn(item); + + assertSame(unknown, trade.getCategory()); + assertEquals(20, trade.getDemandLimit()); + assertEquals(20, trade.getDemand()); + assertEquals(1, trade.getPriceChange()); + assertEquals("v.gray_dye", trade.getItemString()); + assertNull(trade.getIconString()); + assertSame(item, trade.getIconItem()); + assertEquals(1, trade.getItemRestingPrice()); + assertEquals(64, trade.getAmount()); + assertEquals(0, trade.getGroup()); + } + + @Test + void blankIconsAndUnknownCategoriesFallBackWithoutLosingTheTradeItem() throws Exception { + ItemStack item = new ItemStack(Material.OAK_LOG); + when(creator.getItemFromPath("v.oak_log")).thenReturn(item); + for (String icon : new String[] {"", " "}) { + YamlConfiguration config = yaml("category: nonexistent\nitem: v.oak_log\n"); + config.set("icon", icon); + Trade trade = Trade.fromYaml("oak", config, 10); + assertSame(unknown, trade.getCategory()); + assertNull(trade.getIconString()); + assertSame(item, trade.getIconItem()); + } + } + + @Test + void demandIsClampedAtConstructionAndWhenUpdatedAndResetsToItsMidpoint() { + Trade trade = trade(unknown, -1, 20, 0); + assertEquals(1, trade.getDemand()); + trade.setDemand(30); + assertEquals(20, trade.getDemand()); + trade.setDemand(7.5); + assertEquals(7.5, trade.getDemand()); + trade.sell(); + assertEquals(7.5, trade.getDemand(), "zero price change must not reduce demand"); + trade.setDemand(-10); + assertEquals(1, trade.getDemand()); + trade.resetDemand(); + assertEquals(10, trade.getDemand()); + trade.setDemand(20); + trade.demand(); + assertEquals(20, trade.getDemand()); + + Trade minimum = trade(unknown, 10, -5, 0); + assertEquals(1, minimum.getDemandLimit()); + assertEquals(1, minimum.getDemand()); + } + + @Test + void resettingAOneUnitDemandLimitDoesNotDropBelowTheMinimum() { + Trade trade = trade(unknown, 1, 1, 0); + trade.resetDemand(); + assertEquals(1, trade.getDemand()); + } + + @Test + void conversationBuildsATradeFromTheHeldStackAndSelectedSettings() { + ItemStack item = new ItemStack(Material.OAK_LOG, 16); + when(checker.getAsStringPath(item)).thenReturn("v.oak_log"); + MarketblockConversation conversation = conversation(item, 40); + + Trade trade = new Trade(conversation); + + assertEquals("oak", trade.getId()); + assertSame(unknown, trade.getCategory()); + assertTrue(unknown.getTrades().contains(trade)); + assertEquals(40, trade.getDemandLimit()); + assertEquals(20, trade.getDemand()); + assertEquals("v.oak_log", trade.getItemString()); + assertEquals(16, trade.getAmount()); + assertEquals(1.5, trade.getPriceChange()); + assertEquals(2.25, trade.getItemRestingPrice()); + assertEquals(3, trade.getGroup()); + } + + @Test + void conversationWithTheSmallestAllowedLimitStartsAtTheDemandMinimum() { + ItemStack item = new ItemStack(Material.OAK_LOG); + when(checker.getAsStringPath(item)).thenReturn("v.oak_log"); + Trade trade = new Trade(conversation(item, 1)); + assertEquals(1, trade.getDemand()); + assertEquals(1, trade.getDemandLimit()); + } + + private MarketblockConversation conversation(ItemStack item, double limit) { + MarketblockConversation conversation = new MarketblockConversation(null, item); + conversation.setId("oak"); + conversation.setCategory(unknown); + conversation.setDemandLimit(limit); + conversation.setPriceChange(1.5); + conversation.setRestingPrice(2.25); + conversation.setGroup(3); + return conversation; + } + + private static Trade trade(Category category, double demand, double limit, double priceChange) { + return new Trade("oak", category, demand, limit, priceChange, "v.oak_log", 2, 64, 0); + } + + private static YamlConfiguration yaml(String source) throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.loadFromString(source); + return config; + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/util/FreshnessLookupTest.java b/src/test/java/net/tfminecraft/marketblock/util/FreshnessLookupTest.java new file mode 100644 index 0000000..058ee45 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/util/FreshnessLookupTest.java @@ -0,0 +1,126 @@ +package net.tfminecraft.marketblock.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Locale; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; + +class FreshnessLookupTest { + private ItemStack item; + private MockedStatic foods; + + @BeforeEach + void setUp() { + MockBukkit.mock(); + item = new ItemStack(Material.CARROT); + foods = mockStatic(FoodItem.class); + } + + @AfterEach + void tearDown() { + if (foods != null) foods.close(); + MockBukkit.unmock(); + } + + @Test + void missingCookingPluginUsesFreshWithoutLookingUpFoodMetadata() { + assertNotNull(new FreshnessLookup()); + assertFalse(FreshnessLookup.cookingLoaded()); + assertEquals("fresh", FreshnessLookup.stepId(item)); + foods.verifyNoInteractions(); + } + + @Test + void nullItemsNeverReachCookingEvenWhenThePluginIsLoaded() { + MockBukkit.createMockPlugin("Cooking"); + assertTrue(FreshnessLookup.cookingLoaded()); + assertEquals("fresh", FreshnessLookup.stepId(null)); + foods.verifyNoInteractions(); + } + + @Test + void ordinaryItemsWithoutFoodDataUseFresh() { + MockBukkit.createMockPlugin("Cooking"); + foods.when(() -> FoodItem.fromItem(item)).thenReturn(null); + + assertEquals("fresh", FreshnessLookup.stepId(item)); + + foods.verify(() -> FoodItem.fromItem(item)); + } + + @Test + void foodWithoutAFreshnessTrackUsesFresh() { + MockBukkit.createMockPlugin("Cooking"); + FoodItem food = mock(FoodItem.class); + foods.when(() -> FoodItem.fromItem(item)).thenReturn(food); + + assertEquals("fresh", FreshnessLookup.stepId(item)); + + verify(food).getTagTrack("freshness"); + } + + @Test + void foodWithoutACurrentFreshnessStepUsesFresh() { + MockBukkit.createMockPlugin("Cooking"); + FoodItem food = mock(FoodItem.class); + TagTrack track = mock(TagTrack.class); + foods.when(() -> FoodItem.fromItem(item)).thenReturn(food); + when(food.getTagTrack("freshness")).thenReturn(track); + + assertEquals("fresh", FreshnessLookup.stepId(item)); + + verify(track).getCurrentStep(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t\n"}) + void absentOrBlankStepIdsUseFresh(String id) { + installFoodStep(id); + assertEquals("fresh", FreshnessLookup.stepId(item)); + } + + @ParameterizedTest + @ValueSource(strings = {"fresh", "STALE", "Rotten", "custom_step"}) + void recognizedAndCustomFreshnessIdsUseNormalizedKeys(String id) { + installFoodStep(id); + assertEquals(id.toLowerCase(Locale.ROOT), FreshnessLookup.stepId(item)); + } + + @Test + void freshnessIdsMatchConfiguredKeysUnderATurkishServerLocale() { + installFoodStep("RIPE"); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals("ripe", FreshnessLookup.stepId(item)); + } finally { + Locale.setDefault(previous); + } + } + + private void installFoodStep(String id) { + MockBukkit.createMockPlugin("Cooking"); + FoodItem food = mock(FoodItem.class); + TagTrack track = mock(TagTrack.class); + TagStep step = mock(TagStep.class); + foods.when(() -> FoodItem.fromItem(item)).thenReturn(food); + when(food.getTagTrack("freshness")).thenReturn(track); + when(track.getCurrentStep()).thenReturn(step); + when(step.getId()).thenReturn(id); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java b/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java new file mode 100644 index 0000000..1d3b5e8 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java @@ -0,0 +1,225 @@ +package net.tfminecraft.marketblock.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemChecker; + +class InventoryUtilsTest { + private static final String PATH = "food.vegetables"; + + private PlayerMock player; + private ItemChecker checker; + private MockedStatic tlibs; + private MockedStatic freshness; + + @BeforeEach + void setUp() { + player = MockBukkit.mock().addPlayer(); + checker = mock(ItemChecker.class); + ItemAPI api = mock(ItemAPI.class); + when(api.getChecker()).thenReturn(checker); + when(checker.checkItemWithPath(any(ItemStack.class), eq(PATH))).thenAnswer(invocation -> { + Material type = invocation.getArgument(0).getType(); + return type == Material.CARROT || type == Material.POTATO; + }); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + freshness = mockStatic(FreshnessLookup.class); + freshness.when(() -> FreshnessLookup.stepId(any(ItemStack.class))).thenAnswer(invocation -> + invocation.getArgument(0).getType() == Material.CARROT ? "fresh" : "stale"); + } + + @AfterEach + void tearDown() { + if (freshness != null) freshness.close(); + if (tlibs != null) tlibs.close(); + MockBukkit.unmock(); + } + + @Test + void emptyInventoryHasNoMatchingItems() { + assertNotNull(new InventoryUtils()); + assertEquals(0, InventoryUtils.getTotalAmount(player, PATH)); + assertFalse(InventoryUtils.hasEnough(player, PATH, 1)); + assertEquals(0, InventoryUtils.removeItems(player, PATH, 1).total()); + verifyNoInteractions(checker); + freshness.verifyNoInteractions(); + } + + @Test + void explicitAirStacksDoNotReachItemOrFreshnessLookups() { + Player owner = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(owner.getInventory()).thenReturn(inventory); + when(inventory.getContents()).thenReturn(new ItemStack[] {null, new ItemStack(Material.AIR)}); + + assertEquals(0, InventoryUtils.getTotalAmount(owner, PATH)); + assertEquals(0, InventoryUtils.removeItems(owner, PATH, 1).total()); + verifyNoInteractions(checker); + freshness.verifyNoInteractions(); + } + + @Test + void totalsOnlyMatchingStacksAndChecksTheExactRequiredAmount() { + put(1, Material.AIR, 1); + put(2, Material.CARROT, 3); + put(3, Material.STONE, 64); + put(4, Material.POTATO, 5); + + assertEquals(8, InventoryUtils.getTotalAmount(player, PATH)); + assertEquals(0, InventoryUtils.getTotalAmount(player, "food.other")); + assertTrue(InventoryUtils.hasEnough(player, PATH, 1)); + assertTrue(InventoryUtils.hasEnough(player, PATH, 8)); + assertFalse(InventoryUtils.hasEnough(player, PATH, 9)); + assertEquals(64, player.getInventory().getItem(3).getAmount()); + } + + @ParameterizedTest + @ValueSource(doubles = {0, -0.0, -1, 0.5, 1.5, Double.NaN, + Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, 2147483648d, Double.MAX_VALUE}) + void invalidAmountsCannotAuthorizeATrade(double amount) { + put(0, Material.CARROT, 10); + + assertFalse(InventoryUtils.hasEnough(player, PATH, amount)); + assertEquals(10, player.getInventory().getItem(0).getAmount()); + verifyNoInteractions(checker); + } + + @ParameterizedTest + @ValueSource(doubles = {0, -0.0, -1, 0.5, 1.5, Double.NaN, + Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, 2147483648d, Double.MAX_VALUE}) + void invalidRemovalAmountsLeaveEveryInventoryStackUntouched(double amount) { + put(0, Material.CARROT, 3); + put(1, Material.STONE, 7); + put(2, Material.POTATO, 5); + ItemStack[] before = snapshot(); + + SaleTake taken = InventoryUtils.removeItems(player, PATH, amount); + + assertEquals(Map.of(), taken.getCounts()); + assertArrayEquals(before, player.getInventory().getContents()); + verifyNoInteractions(checker); + freshness.verifyNoInteractions(); + } + + @Test + void largestSupportedWholeAmountKeepsAnExactRemovalTotal() { + Player owner = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(owner.getInventory()).thenReturn(inventory); + ItemStack stack = new ItemStack(Material.CARROT, Integer.MAX_VALUE); + when(inventory.getContents()).thenReturn(new ItemStack[] {stack}); + assertTrue(InventoryUtils.hasEnough(owner, PATH, Integer.MAX_VALUE)); + SaleTake taken = InventoryUtils.removeItems(owner, PATH, Integer.MAX_VALUE); + assertEquals(Integer.MAX_VALUE, taken.total()); + assertEquals(0, stack.getAmount()); + } + + @Test + void removesPartOfAStackAndStopsBeforeLaterMatches() { + put(0, Material.CARROT, 10); + put(1, Material.POTATO, 4); + + SaleTake taken = InventoryUtils.removeItems(player, PATH, 3); + + assertEquals(Map.of("fresh", 3), taken.getCounts()); + assertEquals(7, player.getInventory().getItem(0).getAmount()); + assertEquals(4, player.getInventory().getItem(1).getAmount()); + assertEquals(11, InventoryUtils.getTotalAmount(player, PATH)); + } + + @Test + void exactStackRemovalDoesNotTouchTheNextMatchingStack() { + put(0, Material.CARROT, 3); + put(1, Material.POTATO, 4); + + SaleTake taken = InventoryUtils.removeItems(player, PATH, 3); + + assertEquals(Map.of("fresh", 3), taken.getCounts()); + assertEquals(0, player.getInventory().getItem(0).getAmount()); + assertEquals(4, player.getInventory().getItem(1).getAmount()); + } + + @Test + void mixedStackRemovalSkipsEmptyAndUnrelatedItemsAndTracksFreshness() { + put(1, Material.AIR, 1); + put(2, Material.STONE, 9); + put(3, Material.CARROT, 2); + put(4, Material.POTATO, 5); + put(5, Material.CARROT, 7); + + SaleTake taken = InventoryUtils.removeItems(player, PATH, 4); + + assertEquals(Map.of("fresh", 2, "stale", 2), taken.getCounts()); + assertEquals(4, taken.total()); + assertEquals(9, player.getInventory().getItem(2).getAmount()); + assertEquals(0, player.getInventory().getItem(3).getAmount()); + assertEquals(3, player.getInventory().getItem(4).getAmount()); + assertEquals(7, player.getInventory().getItem(5).getAmount()); + assertEquals(10, InventoryUtils.getTotalAmount(player, PATH)); + } + + @Test + void requestingMoreThanAvailableReturnsOnlyTheItemsActuallyRemoved() { + put(0, Material.CARROT, 2); + put(2, Material.STONE, 9); + put(3, Material.POTATO, 3); + + assertFalse(InventoryUtils.hasEnough(player, PATH, 8)); + SaleTake taken = InventoryUtils.removeItems(player, PATH, 8); + + assertEquals(Map.of("fresh", 2, "stale", 3), taken.getCounts()); + assertEquals(5, taken.total()); + assertEquals(0, InventoryUtils.getTotalAmount(player, PATH)); + assertEquals(9, player.getInventory().getItem(2).getAmount()); + } + + @Test + void removalWithNoMatchingPathLeavesInventoryIntact() { + put(0, Material.CARROT, 2); + put(1, Material.POTATO, 3); + ItemStack[] before = snapshot(); + + assertEquals(0, InventoryUtils.removeItems(player, "food.other", 1).total()); + assertArrayEquals(before, player.getInventory().getContents()); + freshness.verifyNoInteractions(); + } + + private void put(int slot, Material material, int amount) { + player.getInventory().setItem(slot, new ItemStack(material, amount)); + } + + private ItemStack[] snapshot() { + return Arrays.stream(player.getInventory().getContents()) + .map(item -> item == null ? null : item.clone()) + .toArray(ItemStack[]::new); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/util/PriceAndDemandTest.java b/src/test/java/net/tfminecraft/marketblock/util/PriceAndDemandTest.java new file mode 100644 index 0000000..dbeaad6 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/util/PriceAndDemandTest.java @@ -0,0 +1,145 @@ +package net.tfminecraft.marketblock.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.Trade; +import org.bukkit.ChatColor; +import org.junit.jupiter.api.Test; + +class PriceAndDemandTest { + @Test + void pricesScaleWithDemandAndRoundToTheNearestCent() { + Trade trade = trade(10, 2.345); + assertEquals(2.35, PriceCalculator.calculatePrice(trade)); + trade.setDemand(5); + assertEquals(1.17, PriceCalculator.calculatePrice(trade)); + trade.setDemand(20); + assertEquals(4.69, PriceCalculator.calculatePrice(trade)); + } + + @Test + void pricesNeverFallBelowOneCentEvenForZeroOrNegativeConfiguredPrices() { + assertEquals(0.01, PriceCalculator.calculatePrice(trade(1, 0.001))); + assertEquals(0.01, PriceCalculator.calculatePrice(trade(1, 0))); + assertEquals(0.01, PriceCalculator.calculatePrice(trade(1, -10))); + assertEquals(0.02, PriceCalculator.calculatePrice(trade(10, 0.015))); + } + + @Test + void emptyDemandUsesTwentyDarkRedBarsInsideGrayBrackets() { + String bar = DemandFormatter.getDemandBar(0, 20); + assertEquals("§x§a§a§a§a§a§a[§x§3§8§0§0§0§0" + "|".repeat(20) + + "§x§a§a§a§a§a§a]", bar); + assertEquals("[" + "|".repeat(20) + "]", ChatColor.stripColor(bar)); + } + + @Test + void halfDemandSeparatesTenFilledAndTenDimmedBars() { + assertEquals("§x§a§a§a§a§a§a[§x§5§5§8§0§0§0" + "|".repeat(10) + + "§x§1§c§2§a§0§0" + "|".repeat(10) + "§x§a§a§a§a§a§a]", + DemandFormatter.getDemandBar(10, 20)); + } + + @Test + void fullDemandUsesTwentyGreenBarsAndNoEmptySegment() { + assertEquals("§x§a§a§a§a§a§a[§x§0§0§f§f§0§0" + "|".repeat(20) + + "§x§a§a§a§a§a§a]", DemandFormatter.getDemandBar(20, 20)); + } + + @Test + void demandBarsClampOutOfRangeValuesAndTreatNonPositiveLimitsAsEmpty() { + String empty = DemandFormatter.getDemandBar(0, 20); + String full = DemandFormatter.getDemandBar(20, 20); + assertEquals(empty, DemandFormatter.getDemandBar(-5, 20)); + assertEquals(full, DemandFormatter.getDemandBar(30, 20)); + assertEquals(empty, DemandFormatter.getDemandBar(10, 0)); + assertEquals(empty, DemandFormatter.getDemandBar(10, -2)); + } + + @Test + void fractionalDemandRoundsTheFilledBarCountInsteadOfTruncating() { + String bar = DemandFormatter.getDemandBar(1, 8); + String[] visibleSegments = bar.split("§x(?:§[0-9a-f]){6}"); + assertArrayEquals(new String[] {"", "[", "|||", "|".repeat(17), "]"}, visibleSegments); + } + + @Test + void emptySaleHasNoItemsAndIsFresh() { + SaleTake sale = new SaleTake(); + assertTrue(sale.getCounts().isEmpty()); + assertEquals(0, sale.total()); + assertTrue(sale.allFresh()); + sale.add("rotten", 0); + sale.add("rotten", -2); + assertTrue(sale.getCounts().isEmpty()); + assertTrue(sale.allFresh()); + } + + @Test + void missingAndBlankFreshnessLabelsMergeWithFreshQuantities() { + SaleTake sale = new SaleTake(); + sale.add(null, 2); + sale.add("", 3); + sale.add(" \t ", 4); + sale.add("FRESH", 5); + assertEquals(Map.of("fresh", 14), sale.getCounts()); + assertEquals(14, sale.total()); + assertTrue(sale.allFresh()); + } + + @Test + void mixedFreshnessKeepsFirstSeenOrderAndAggregatesRepeatedSteps() { + SaleTake sale = new SaleTake(); + sale.add("fresh", 8); + sale.add("Stale", 3); + sale.add("rotten", 2); + sale.add("STALE", 4); + assertEquals(List.of("fresh", "stale", "rotten"), new ArrayList<>(sale.getCounts().keySet())); + assertEquals(Map.of("fresh", 8, "stale", 7, "rotten", 2), sale.getCounts()); + assertEquals(17, sale.total()); + assertFalse(sale.allFresh()); + } + + @Test + void freshnessKeysDoNotDependOnTheServerLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + SaleTake sale = new SaleTake(); + sale.add("RIPE", 2); + sale.add("ripe", 3); + assertEquals(Map.of("ripe", 5), sale.getCounts()); + assertEquals(5, sale.total()); + assertFalse(sale.allFresh()); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void zeroQuantityStepsDoNotMakeASaleNonFresh() { + SaleTake sale = new SaleTake(); + sale.add("fresh", 4); + sale.getCounts().put("stale", 0); + assertTrue(sale.allFresh()); + assertEquals(4, sale.total()); + } + + @Test + void recoveryPeriodsSaturateAtLongMaxForFiniteOrOverflowingProducts() { + assertEquals(Long.MAX_VALUE, DemandSchedule.periodTicks(Double.MAX_VALUE)); + assertEquals(Long.MAX_VALUE, DemandSchedule.periodTicks(Long.MAX_VALUE / 36_000.0)); + assertEquals(72_000_000_000L, DemandSchedule.periodTicks(1_000_000)); + assertEquals(DemandSchedule.DEFAULT_HOURS, DemandSchedule.normalizeHours(Double.NEGATIVE_INFINITY)); + } + + private static Trade trade(double demand, double restingPrice) { + return new Trade("oak", new Category(), demand, 20, 1, "v.oak_log", restingPrice, 64, 0); + } +}