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