diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..3d42f99 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 reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index dff277e..23469c2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,14 @@ The same gemstone links discovery and craft: its infusion determines the startin Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Tests + +With Java 21 and the pinned plugin dependencies installed, run `mvn clean verify`. +Tests use JUnit, Mockito, and MockBukkit; JaCoCo reports are written to +`target/site/jacoco/index.html` and uploaded by CI. Tests run locally without a live +Minecraft server. Verification requires 100% line, branch, and instruction +coverage of production code, with no coverage exclusions. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/pom.xml b/pom.xml index a21e532..48f0643 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,24 @@ + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + local gson @@ -86,6 +104,55 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + prepare-agent + + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 1.0 + + + LINE + COVEREDRATIO + 1.0 + + + BRANCH + COVEREDRATIO + 1.0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/net/tfminecraft/geminfusion/AttributeInfluence.java b/src/main/java/net/tfminecraft/geminfusion/AttributeInfluence.java index d2928b5..db2814d 100644 --- a/src/main/java/net/tfminecraft/geminfusion/AttributeInfluence.java +++ b/src/main/java/net/tfminecraft/geminfusion/AttributeInfluence.java @@ -88,12 +88,6 @@ public double readAttribute(Player player) { } private static double lerp(double a, double b, double t) { - if (t < 0) { - t = 0; - } - if (t > 1) { - t = 1; - } return a + (b - a) * t; } } diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java index 2d932ac..4e64dec 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java @@ -18,6 +18,8 @@ import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.EquipmentSlot; +import net.tfminecraft.geminfusion.goldsmith.GoldsmithLog; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; @@ -33,6 +35,7 @@ public class InfusionEvents implements Listener{ @SuppressWarnings("deprecation") @EventHandler public void addGemEvent(PlayerInteractEvent e) { + if (e.getHand() == EquipmentSlot.OFF_HAND) return; if(!e.getAction().equals(Action.RIGHT_CLICK_BLOCK)) return; Material block = e.getClickedBlock().getType(); if(!ConfigLoader.stations.contains(block)) return; @@ -76,7 +79,7 @@ public void addGemEvent(PlayerInteractEvent e) { p.sendMessage(ChatColor.RED + "Infusion has already started, you cannot add more gems at this point!"); return; } - if(b.getCurrentItems().size() >= 10) { + if(loc.equals(b.getLocation()) && b.getCurrentItems().size() >= 10) { p.sendMessage(ChatColor.RED + "You can only infuse 10 gems at a time!"); return; } @@ -108,6 +111,7 @@ public void addGemEvent(PlayerInteractEvent e) { @SuppressWarnings("deprecation") @EventHandler public void infuseHitEvent(PlayerInteractEvent e) { + if (e.getHand() == EquipmentSlot.OFF_HAND) return; if(!e.getAction().equals(Action.LEFT_CLICK_BLOCK)) return; Material block = e.getClickedBlock().getType(); if(!ConfigLoader.stations.contains(block)) return; @@ -124,6 +128,18 @@ public void infuseHitEvent(PlayerInteractEvent e) { String itemType = ConfigLoader.infusionStaff.split("\\.")[0]; String itemID = ConfigLoader.infusionStaff.split("\\.")[1]; if(nbt.getType().equalsIgnoreCase(itemType) && nbt.getString("MMOITEMS_ITEM_ID").equalsIgnoreCase(itemID)) { + // Resolve the whole batch before consuming the staff or clearing the bench. + // A later config reload must not strand an already consumed batch. + List batchRarities = new ArrayList<>(); + if (b.getInfusionHits() >= 4) { + try { + for (Gemstone ignored : b.getCurrentItems()) batchRarities.add(getRarity()); + } catch (IllegalStateException invalidRarities) { + GoldsmithLog.warn(invalidRarities.getMessage()); + p.sendMessage(ChatColor.RED + "Infusion rarities are not configured correctly. Contact staff; your gems remain on the bench."); + return; + } + } b.setInfustionHits(b.getInfusionHits()+1); p.sendTitle(ChatColor.LIGHT_PURPLE + "Infusing...", ChatColor.GREEN + "Progress: " + ChatColor.YELLOW + b.getInfusionHits() + "/5", 1, 40, 20); b.getLocation().getWorld().spawnParticle(Particle.FLAME, b.getParticleLocation(), 60); @@ -143,7 +159,7 @@ public void run() { if(i < b.getCurrentItems().size()) { Gemstone gem = b.getCurrentItems().get(i); - GemRarity r = getRarity(); + GemRarity r = batchRarities.get(i); if(r.shouldAnnounce()) { for(Player player : Bukkit.getOnlinePlayers()) { player.sendMessage("§e"+p.getName()+" just infused a "+r.getName()+" "+gem.getColour()+gem.getName()+"§e Gemstone"); @@ -175,31 +191,34 @@ public void run() } } public GemRarity getRarity() { - Double maxWeight = 0.0; - for(GemRarity r : ConfigLoader.loadedRarities) { - maxWeight = maxWeight+r.getChance(); - } - Integer dropped = 0; - GemRarity rarity = null; - while(dropped < 1) { - Double random = Math.random(); - Double previous = 0.0; - for(GemRarity r : ConfigLoader.loadedRarities) { - if(maxWeight <= 0) { - maxWeight = 1.0; - } - Double chance = r.getChance() / maxWeight; - Double max = previous+chance; - if(random <= max && random > previous) { - dropped++; - rarity = r; - } - previous = max; + return getRarity(Math.random()); + } + + GemRarity getRarity(double draw) { + List valid = new ArrayList<>(); + double largest = 0; + for (GemRarity rarity : ConfigLoader.loadedRarities) { + double weight = rarity.getChance(); + if (Double.isFinite(weight) && weight > 0) { + valid.add(rarity); + largest = Math.max(largest, weight); } } - return rarity; + if (valid.isEmpty()) { + throw new IllegalStateException("Gem infusion requires at least one finite positive rarity weight."); + } + // Scale before summing so individually valid large weights cannot overflow. + double total = 0; + for (GemRarity rarity : valid) total += rarity.getChance() / largest; + double roll = draw * total; + for (GemRarity rarity : valid) { + roll -= rarity.getChance() / largest; + if (roll < 0) return rarity; + } + return valid.getLast(); // Floating-point rounding at the upper boundary. } + public ItemStack getInfusedGem(Player player, Gemstone gem, Integer amount, GemRarity r) { return InfusedGemBuilder.buildInfusedGem(gem, r, amount, player); } diff --git a/src/main/java/net/tfminecraft/geminfusion/UnsocketedGemRestorer.java b/src/main/java/net/tfminecraft/geminfusion/UnsocketedGemRestorer.java index 0dfe2f4..41cf594 100644 --- a/src/main/java/net/tfminecraft/geminfusion/UnsocketedGemRestorer.java +++ b/src/main/java/net/tfminecraft/geminfusion/UnsocketedGemRestorer.java @@ -31,9 +31,6 @@ public static void restore(Player player, String rarityId, Gemstone gem, Map before.getAmount()) { + if (item.getAmount() > before.getAmount()) { return slot; } } diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java index a128175..684a9e4 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java @@ -47,7 +47,6 @@ public int openMenu(Player player) { @SuppressWarnings("deprecation") private ItemStack decorate(ItemStack icon, JewelryProject project) { ItemMeta meta = icon.getItemMeta(); - if (meta == null) return icon; meta.setDisplayName(project.getName()); List lore = new ArrayList<>(); lore.add("§7Tier: §e" + formatTierName(project.getTierId())); @@ -74,7 +73,7 @@ private ItemStack iconFromPath(String path, String label) { return null; } ItemStack stack = TLibs.getItemAPI().getCreator().getItemFromPath(path); - if (stack == null) { + if (stack == null || stack.getItemMeta() == null) { GoldsmithLog.warn("Could not build item for " + label + " (" + path + "), skipping menu slot."); return null; } diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMath.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMath.java index 368375c..9f6488e 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMath.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMath.java @@ -114,8 +114,7 @@ public static double hitPercentPenalized(Map required, Ma int lineCurrent = counter == null ? 0 : Math.max(0, counter.getCurrent()); double d = lineHitPercentage(lineCurrent, lineNeeded); if (d >= 200.0) continue; - if (d <= 100.0) amount += d; - if (d > 100.0 && d <= 200.0) amount += (200.0 - d); + amount += d <= 100.0 ? d : 200.0 - d; } if (lineCount <= 0) return 0; return Math.round(amount / lineCount); @@ -132,8 +131,7 @@ public static double hitPercentFromCounts(Map required, M int lineCurrent = currentCounts == null ? 0 : Math.max(0, currentCounts.getOrDefault(entry.getKey(), 0)); double d = lineHitPercentage(lineCurrent, lineNeeded); if (d >= 200.0) continue; - if (d <= 100.0) amount += d; - if (d > 100.0 && d <= 200.0) amount += (200.0 - d); + amount += d <= 100.0 ? d : 200.0 - d; } if (lineCount <= 0) return 0; return Math.round(amount / lineCount); @@ -148,7 +146,6 @@ public static boolean meetsMinHitPercent(double hitPercent) { } private static double lineHitPercentage(int current, int needed) { - if (needed <= 0) return 0; return Math.round((double) current / needed * 100.0); } diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStation.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStation.java index b745eb3..bcacdd8 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStation.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStation.java @@ -238,16 +238,16 @@ public boolean checkItems() { if (!c.isEqual()) return false; } if (project.requiresGem() && gem == null) return false; - if (project.requiresGem() && gem != null && !InfusedGemValidator.isInfused(gem)) return false; + if (project.requiresGem() && !InfusedGemValidator.isInfused(gem)) return false; return true; } public GoldsmithFeedback canFinish() { if (project == null) return GoldsmithFeedback.NO_PROJECT; - if (!checkItems()) return GoldsmithFeedback.LACKING_ITEMS; if (project.requiresGem() && gem != null && !InfusedGemValidator.isInfused(gem)) { return GoldsmithFeedback.NOT_INFUSED; } + if (!checkItems()) return GoldsmithFeedback.LACKING_ITEMS; if (!GoldsmithMath.meetsMinHitPercent(getHitPercent())) return GoldsmithFeedback.LACKING_HITS; return GoldsmithFeedback.SUCCESS; } diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java index 9766e32..7e5409a 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java @@ -194,7 +194,7 @@ public void onMenuClick(InventoryClickEvent e) { p.playSound(p.getLocation(), Sound.BLOCK_ANVIL_PLACE, 0.8f, 2f); } - @EventHandler + @EventHandler(ignoreCancelled = true) public void onBreak(BlockBreakEvent e) { if (!isGoldsmithStation(e.getBlock())) return; Location loc = e.getBlock().getLocation(); @@ -303,18 +303,12 @@ private void handleLeftClick(PlayerInteractEvent e) { Player p = e.getPlayer(); GoldsmithStation station = get(e.getClickedBlock().getLocation()); if (station == null || !station.hasProject()) { - if (!onCooldown(p)) { - p.sendMessage("§7Right-click the bench to choose a project."); - markCooldown(p); - } + p.sendMessage("§7Right-click the bench to choose a project."); + markCooldown(p); return; } ItemStack hand = p.getInventory().getItemInMainHand(); - if (hand == null || hand.getType().isAir()) { - return; - } - if (isBranding(hand)) { markCooldown(p); if (p.isSneaking()) { @@ -346,7 +340,6 @@ private void handleLeftClick(PlayerInteractEvent e) { } GoldsmithHit hit = GoldsmithHitLoader.getByItem(hand); - if (hit == null) return; markCooldown(p); GoldsmithFeedback feedback = station.hit(hit); @@ -398,11 +391,9 @@ private void completeCraft(Player p, GoldsmithStation station) { return; } Location drop = station.getLoc().clone().add(0.5, 1, 0.5); - if (drop.getWorld() != null) { - drop.getWorld().dropItemNaturally(drop, result.getItem()); - drop.getWorld().playSound(drop, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); - drop.getWorld().playSound(drop, Sound.BLOCK_ANVIL_PLACE, 1f, 1f); - } + drop.getWorld().dropItemNaturally(drop, result.getItem()); + drop.getWorld().playSound(drop, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + drop.getWorld().playSound(drop, Sound.BLOCK_ANVIL_PLACE, 1f, 1f); p.sendTitle("§aYou made a " + station.getProject().getName(), "", 5, 40, 10); p.sendMessage("§7Recipe: §e" + Math.round(result.getRecipePercent()) + "%"); p.sendMessage("§7Hits: §e" + Math.round(result.getHitPercent()) + "%"); @@ -436,7 +427,6 @@ private GoldsmithMaterial matchMaterial(ItemStack item) { private void consumeOne(Player p) { ItemStack hand = p.getInventory().getItemInMainHand(); - if (hand == null || hand.getType().isAir()) return; hand.setAmount(hand.getAmount() - 1); } @@ -450,7 +440,6 @@ private void giveOrDrop(Player p, List items) { } private void dropAt(Location loc, List items) { - if (loc.getWorld() == null) return; Location drop = loc.clone().add(0.5, 1, 0.5); for (ItemStack item : items) { loc.getWorld().dropItemNaturally(drop, item); @@ -458,7 +447,6 @@ private void dropAt(Location loc, List items) { } private void playWorkFx(Location loc, Material dust) { - if (loc.getWorld() == null) return; loc.getWorld().spawnParticle( Particle.BLOCK, loc.clone().add(0.5, 1, 0.5), diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStore.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStore.java index 3fa9846..fc312f4 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStore.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStore.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -27,6 +28,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; import net.tfminecraft.geminfusion.InfusionMain; import net.tfminecraft.tlibs.objects.utils.IntCounter; @@ -38,6 +40,8 @@ public final class GoldsmithStationStore { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + /** Files rejected on load remain available for repair, including during autosave. */ + private static final Set retainedFiles = new HashSet<>(); private GoldsmithStationStore() { } @@ -59,6 +63,7 @@ public static File fileFor(Location loc) { public static void delete(Location loc) { if (loc == null) return; File file = fileFor(loc); + if (retainedFiles.contains(file)) return; if (file.exists() && !file.delete()) { GoldsmithLog.warn("Failed to delete goldsmith station file " + file.getName()); } @@ -75,7 +80,7 @@ public static void saveAll(Collection stations) { if (station == null || !station.hasProject() || station.getLoc() == null) continue; File file = fileFor(station.getLoc()); keep.add(file.getName()); - write(station, file); + if (!retainedFiles.contains(file) || quarantineRetained(file)) write(station, file); } } @@ -83,7 +88,7 @@ public static void saveAll(Collection stations) { if (files == null) return; for (File file : files) { if (!file.isFile() || !file.getName().endsWith(".json")) continue; - if (!keep.contains(file.getName()) && !file.delete()) { + if (!keep.contains(file.getName()) && !retainedFiles.contains(file) && !file.delete()) { GoldsmithLog.warn("Failed to delete leftover goldsmith station file " + file.getName()); } } @@ -91,6 +96,7 @@ public static void saveAll(Collection stations) { public static List loadAll() { List out = new ArrayList<>(); + retainedFiles.clear(); File dir = folder(); if (!dir.exists()) { dir.mkdirs(); @@ -102,10 +108,27 @@ public static List loadAll() { if (!file.isFile() || !file.getName().endsWith(".json")) continue; GoldsmithStation station = loadFile(file); if (station != null) out.add(station); + else retainedFiles.add(file); } return out; } + /** Keep rejected bytes recoverable without blocking a new station at the same coordinates. */ + private static boolean quarantineRetained(File file) { + if (file.exists()) { + File backup = new File(file.getParentFile(), file.getName() + ".rejected-" + UUID.randomUUID()); + try { + Files.move(file.toPath(), backup.toPath()); + GoldsmithLog.info("Retained rejected goldsmith station file as " + backup.getName()); + } catch (IOException ex) { + GoldsmithLog.warn("Failed to quarantine goldsmith station file " + file.getName() + ": " + ex.getMessage()); + return false; + } + } + retainedFiles.remove(file); + return true; + } + private static void write(GoldsmithStation station, File file) { StationData data = toData(station); if (data == null) return; @@ -118,39 +141,40 @@ private static void write(GoldsmithStation station, File file) { } private static GoldsmithStation loadFile(File file) { + StationData data; try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { - StationData data = GSON.fromJson(reader, StationData.class); - if (data == null || data.world == null || data.project == null) { - GoldsmithLog.warn("Invalid goldsmith station file " + file.getName() + ", leaving it on disk."); - return null; - } - World world = Bukkit.getWorld(data.world); - if (world == null) { - GoldsmithLog.warn("Goldsmith station file " + file.getName() + " world '" + data.world - + "' is missing, leaving it on disk."); - return null; - } - JewelryProject project = JewelryProjectLoader.getByString(data.project); - if (project == null) { - GoldsmithLog.warn("Goldsmith station file " + file.getName() + " unknown project '" + data.project - + "', leaving it on disk."); - return null; - } - Location loc = GoldsmithStationManager.key(new Location(world, data.x, data.y, data.z)); - GoldsmithStation station = new GoldsmithStation(loc); - station.setProject(project); - station.applySavedProgress(data.materials, data.hits, decodeItems(data.deposited), decodeItem(data.gem), - data.overworkWarned); - return station; - } catch (IOException ex) { + data = GSON.fromJson(reader, StationData.class); + } catch (IOException | JsonParseException ex) { GoldsmithLog.warn("Failed to read goldsmith station file " + file.getName() + ": " + ex.getMessage()); return null; } + if (data == null || data.world == null || data.project == null) { + GoldsmithLog.warn("Invalid goldsmith station file " + file.getName() + ", leaving it on disk."); + return null; + } + World world = Bukkit.getWorld(data.world); + if (world == null) { + GoldsmithLog.warn("Goldsmith station file " + file.getName() + " world '" + data.world + + "' is missing, leaving it on disk."); + return null; + } + JewelryProject project = JewelryProjectLoader.getByString(data.project); + if (project == null) { + GoldsmithLog.warn("Goldsmith station file " + file.getName() + " unknown project '" + data.project + + "', leaving it on disk."); + return null; + } + Location loc = GoldsmithStationManager.key(new Location(world, data.x, data.y, data.z)); + GoldsmithStation station = new GoldsmithStation(loc); + station.setProject(project); + station.applySavedProgress(data.materials, data.hits, decodeItems(data.deposited), decodeItem(data.gem), + data.overworkWarned); + return station; } private static StationData toData(GoldsmithStation station) { Location loc = station.getLoc(); - if (loc.getWorld() == null || station.getProject() == null) return null; + if (loc.getWorld() == null) return null; StationData data = new StationData(); data.world = loc.getWorld().getName(); data.x = loc.getBlockX(); @@ -173,7 +197,6 @@ private static StationData toData(GoldsmithStation station) { private static List encodeItems(List items) { List out = new ArrayList<>(); - if (items == null) return out; for (ItemStack item : items) { String encoded = encodeItem(item); if (encoded != null) out.add(encoded); diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java index 305d90b..2ed149e 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java @@ -126,7 +126,6 @@ private static StatRoll firstNumeric(MMOItem mmo) { } private static StatRoll readDouble(MMOItem mmo, String statId) { - if (statId == null || statId.isBlank()) return null; if (statId.equalsIgnoreCase("SUCCESS_RATE") || statId.equalsIgnoreCase("SUCCESS-RATE")) return null; ItemStat stat = MMOItems.plugin.getStats().get(statId.toUpperCase()); if (stat == null) return null; diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/QualityLoader.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/QualityLoader.java index ffc82a1..57f8096 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/QualityLoader.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/QualityLoader.java @@ -42,7 +42,7 @@ public static double resolveStatFactor(double finishedTotal) { if (tier == null) { return 1; } - if (tier.getAmount() >= 100 || tier.getStatMin() >= 100) { + if (tier.getStatMin() >= 100) { return 100; } Quality next = getByValue(tier.getValue() + 1); diff --git a/src/test/java/net/tfminecraft/geminfusion/DomainHelpersTest.java b/src/test/java/net/tfminecraft/geminfusion/DomainHelpersTest.java new file mode 100644 index 0000000..3a5f998 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/DomainHelpersTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmoitems.MMOItems; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.tfminecraft.geminfusion.goldsmith.GoldsmithLog; +import org.bukkit.*; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class DomainHelpersTest { + @AfterEach + void cleanup() { + MockBukkit.unmock(); + } + + YamlConfiguration config(String text) throws Exception { + YamlConfiguration c = new YamlConfiguration(); + c.loadFromString(text); + return c; + } + + @Test + void statParserAcceptsNewAndLegacyFormatsAndRejectsMalformedEntries() throws Exception { + InfusionMain previous = InfusionMain.plugin; + try { + InfusionMain.plugin = null; + assertNull(GemStat.parse("ruby", "rare", null)); + assertNull(GemStat.parse("ruby", "rare", config("{}"))); + GemStat stat = GemStat.parse("ruby", "rare", config("stat: ATTACK_DAMAGE\nmin: 2\nmax: 4")); + assertEquals("rare", stat.getId()); + assertEquals("ATTACK_DAMAGE", stat.getStatId()); + assertEquals(2, stat.getMin()); + assertEquals(4, stat.getMax()); + assertEquals( + 2, GemStat.parse("ruby", "rare", config("stat: ATTACK_DAMAGE\nmin: 2")).getMax()); + assertNull(GemStat.parse("ruby", "rare", config("stat: ' '"))); + ConfigurationSection broken = mock(ConfigurationSection.class); + when(broken.contains("stat")).thenReturn(true); + assertNull(GemStat.parse("ruby", "rare", broken)); + when(broken.contains("stat")).thenReturn(false); + when(broken.getStringList("stats")).thenReturn(null); + assertNull(GemStat.parse("ruby", "rare", broken)); + for (String entry : + Arrays.asList( + null, + "invalid", + "(1-2)", + "damage(1-2", + "damage(2)", + "damage(x-2)", + "damage(1-x)", + "damage(2)")) { + when(broken.getStringList("stats")).thenReturn(Arrays.asList(entry)); + assertNull(GemStat.parse("ruby", "rare", broken)); + } + stat = GemStat.parse("ruby", "rare", config("stats: ['damage(1-2)', 'ignored(2-3)']")); + assertEquals("damage", stat.getStatId()); + assertEquals(1, stat.getMin()); + assertEquals(2, stat.getMax()); + InfusionMain.plugin = mock(InfusionMain.class); + var logger = mock(java.util.logging.Logger.class); + when(InfusionMain.plugin.getLogger()).thenReturn(logger); + GemStat.parse("ruby", "rare", null); + verify(logger).warning(anyString()); + } finally { + InfusionMain.plugin = previous; + } + } + + @Test + void attributeCurveInterpolatesAndRejectsInvalidThresholdOrdering() throws Exception { + assertEquals(0, AttributeInfluence.disabled().delta(9)); + assertEquals(0, AttributeInfluence.from(null, null).delta(9)); + assertEquals(0, AttributeInfluence.from(null, "dexterity").delta(9)); + AttributeInfluence curve = + AttributeInfluence.from( + config("floor: 0\nneutral: 10\nfull: 20\nmax-bonus: 0.4\nmax-penalty: 0.2"), + "intelligence"); + assertEquals(-.2, curve.delta(-1)); + assertEquals(-.2, curve.delta(0)); + assertEquals(-.1, curve.delta(5), .00001); + assertEquals(0, curve.delta(10)); + assertEquals(.2, curve.delta(15), .00001); + assertEquals(.4, curve.delta(20)); + assertEquals(.4, curve.delta(100)); + assertEquals( + 0, AttributeInfluence.from(config("floor: 0\nneutral: 10\nfull: 5"), null).delta(4)); + assertEquals( + 0, AttributeInfluence.from(config("floor: 10\nneutral: 0\nfull: 20"), null).delta(4)); + } + + @Test + void attributeLookupHandlesAbsentPluginPlayerInvalidIdAndIntegrationFailures() throws Exception { + var server = MockBukkit.mock(); + Player player = server.addPlayer(); + AttributeInfluence influence = + AttributeInfluence.from(config("mmocore-id: dexterity"), "intelligence"); + assertEquals(0, influence.readAttribute(null)); + assertEquals(0, influence.readAttribute(player)); + assertEquals(0, AttributeInfluence.from(config("{}"), null).readAttribute(player)); + assertEquals(0, AttributeInfluence.from(config("mmocore-id: ' '"), " ").readAttribute(player)); + MockBukkit.createMockPlugin("MMOCore"); + try (var data = mockStatic(PlayerData.class)) { + data.when(() -> PlayerData.get(player)).thenThrow(new IllegalStateException("unavailable")); + assertEquals(0, influence.readAttribute(player)); + PlayerData record = mock(PlayerData.class, RETURNS_DEEP_STUBS); + when(record.getAttributes().getInstance("dexterity").getTotal()).thenReturn(15); + data.when(() -> PlayerData.get(player)).thenReturn(record); + assertEquals(15, influence.readAttribute(player)); + assertEquals(.1, influence.forPlayer(player), .00001); + } + } + + @Test + void gemAndStationAccessorsPreserveAssignedValuesAndLookupMmoTemplate() { + MMOItems previous = MMOItems.plugin; + try { + MMOItems.plugin = mock(MMOItems.class, RETURNS_DEEP_STUBS); + Gemstone gem = new Gemstone(); + gem.setMMOItem("gem_stone.ruby"); + MMOItem template = mock(MMOItem.class); + when(MMOItems.plugin + .getItems() + .getMMOItem(MMOItems.plugin.getTypes().get("GEM_STONE"), "RUBY")) + .thenReturn(template); + assertSame(template, gem.getMMOItem()); + gem.addStat(null); + assertTrue(gem.getStats().isEmpty()); + InfusionBlock block = new InfusionBlock(); + block.setToken(true); + assertTrue(block.hasToken()); + block.setCurrentGems(new ArrayList<>(List.of(gem))); + assertEquals(List.of(gem), block.getCurrentItems()); + GemRarity rarity = mock(GemRarity.class, CALLS_REAL_METHODS); + rarity.setId("rare"); + rarity.setName("Rare"); + rarity.setChance(2); + rarity.setAnnounce(true); + assertEquals("rare", rarity.getId()); + assertEquals("Rare", rarity.getName()); + assertEquals(2, rarity.getChance()); + assertTrue(rarity.shouldAnnounce()); + } finally { + MMOItems.plugin = previous; + } + } + + @Test + void loggingUsesPluginLoggerOrFallback() { + InfusionMain previous = InfusionMain.plugin; + try { + InfusionMain.plugin = null; + assertNotNull(GoldsmithLog.get()); + GoldsmithLog.info("Coverage fallback logger"); + GoldsmithLog.warn("Coverage fallback warning"); + InfusionMain.plugin = mock(InfusionMain.class); + var logger = mock(java.util.logging.Logger.class); + when(InfusionMain.plugin.getLogger()).thenReturn(logger); + GoldsmithLog.info("info"); + GoldsmithLog.warn("warning"); + verify(logger).info("info"); + verify(logger).warning("warning"); + } finally { + InfusionMain.plugin = previous; + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java new file mode 100644 index 0000000..44c3760 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java @@ -0,0 +1,376 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import java.util.function.Consumer; +import net.Indyuce.mmoitems.*; +import net.Indyuce.mmoitems.api.item.mmoitem.*; +import net.Indyuce.mmoitems.stat.data.*; +import net.Indyuce.mmoitems.stat.type.*; +import net.tfminecraft.geminfusion.goldsmith.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.*; + +class GemOutputTest { + InfusionMain previous; + MMOItems previousMmo; + io.lumine.mythic.lib.MythicLib previousLib; + AttributeInfluence previousInfusion, previousJewelry; + MockedStatic nbtApi; + NBTItem nbt; + Gemstone gem; + GemRarity rarity; + Player player; + + @BeforeEach + void setup() throws Exception { + MockBukkit.mock(); + previousLib = io.lumine.mythic.lib.MythicLib.plugin; + io.lumine.mythic.lib.MythicLib.plugin = + mock(io.lumine.mythic.lib.MythicLib.class, RETURNS_DEEP_STUBS); + previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.namespace()).thenReturn("geminfusion"); + when(InfusionMain.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + previousMmo = MMOItems.plugin; + MMOItems.plugin = mock(MMOItems.class, RETURNS_DEEP_STUBS); + when(MMOItems.plugin.namespace()).thenReturn("mmoitems"); + when(io.lumine.mythic.lib.MythicLib.plugin.namespace()).thenReturn("mythiclib"); + previousInfusion = AttributeInfluence.infusion; + previousJewelry = AttributeInfluence.jewelry; + AttributeInfluence.infusion = mock(AttributeInfluence.class); + AttributeInfluence.jewelry = mock(AttributeInfluence.class); + nbt = mock(NBTItem.class); + nbtApi = mockStatic(NBTItem.class); + nbtApi.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + gem = new Gemstone(); + gem.setId("ruby"); + gem.setName("Ruby"); + gem.setMMOItem("GEM_STONE.RUBY"); + gem.setSocketColour("Red"); + gem.setSocketNameColour(ChatColor.RED); + rarity = new GemRarity("rare", config("name: Rare\nchance: 1")); + player = mock(Player.class); + ConfigLoader.loadedGems.clear(); + ConfigLoader.loadedGems.add(gem); + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("GEM_STONE"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("RUBY"); + var attack = ItemStats.ATTACK_DAMAGE; + when(MMOItems.plugin.getStats().get("ATTACK_DAMAGE")).thenReturn(attack); + } + + @AfterEach + void cleanup() { + if (nbtApi != null) nbtApi.close(); + io.lumine.mythic.lib.MythicLib.plugin = previousLib; + InfusionMain.plugin = previous; + MMOItems.plugin = previousMmo; + AttributeInfluence.infusion = previousInfusion; + AttributeInfluence.jewelry = previousJewelry; + ConfigLoader.loadedGems.clear(); + MockBukkit.unmock(); + } + + static YamlConfiguration config(String text) throws Exception { + YamlConfiguration c = new YamlConfiguration(); + c.loadFromString(text); + return c; + } + + GemStat stat(String rarityId, String id, double value) throws Exception { + return GemStat.parse("ruby", rarityId, config("stat: " + id + "\nmin: " + value)); + } + + ItemStack tagged(Material material) { + ItemStack item = new ItemStack(material); + var meta = item.getItemMeta(); + meta.setDisplayName("Original"); + item.setItemMeta(meta); + return item; + } + + MockedConstruction live(Consumer configure) { + return mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (m, ctx) -> { + when(m.computeStatHistory(any())).thenReturn(null); + when(m.getData(any())).thenReturn(null); + configure.accept(m); + }); + } + + @Test + void statRollUsesMatchingRarityAndAttributeThenClampsSuccessRange() throws Exception { + gem.addStat(stat("other", "ATTACK_DAMAGE", 9)); + gem.addStat(stat("rare", "ATTACK_DAMAGE", 2)); + MMOItem output = mock(MMOItem.class); + when(AttributeInfluence.infusion.forPlayer(player)).thenReturn(.25); + InfusedGemBuilder.rollStats(output, gem, rarity, 100, player); + var value = ArgumentCaptor.forClass(DoubleData.class); + verify(output).setData(eq(ItemStats.ATTACK_DAMAGE), value.capture()); + assertEquals(2.5, value.getValue().getValue()); + verify(output) + .setData(eq(ItemStats.SUCCESS_RATE), argThat(v -> ((DoubleData) v).getValue() == 40)); + gem.setStats(new ArrayList<>()); + InfusedGemBuilder.rollStats(output, gem, rarity, 1, player); + verify(output, times(2)).setData(eq(ItemStats.SUCCESS_RATE), any()); + gem.addStat(stat("rare", "MISSING", 1)); + when(MMOItems.plugin.getStats().get("MISSING")).thenReturn(null); + InfusedGemBuilder.rollStats(output, gem, rarity, 1, player); + InfusionMain.plugin = null; + InfusedGemBuilder.rollStats(output, gem, rarity, 1, player); + } + + @Test + void cosmeticsUpdateNameHistoryLoreAndRarityWithoutChangingStats() { + MMOItem output = mock(MMOItem.class); + InfusedGemBuilder.applyCosmetics(output, gem, rarity); + var name = ArgumentCaptor.forClass(StringData.class); + verify(output).replaceData(eq(ItemStats.NAME), name.capture()); + assertEquals("Rare Infused Ruby", name.getValue().getString()); + StringData existing = new StringData("Old"); + when(output.getData(ItemStats.NAME)).thenReturn(existing); + StatHistory history = mock(StatHistory.class); + NameData original = new NameData("Old"); + when(history.getOriginalData()).thenReturn(original); + when(output.computeStatHistory(ItemStats.NAME)).thenReturn(history); + InfusedGemBuilder.applyCosmetics(output, gem, rarity); + assertEquals("Rare Infused Ruby", existing.getString()); + assertEquals("Rare Infused Ruby", original.getString()); + verify(output).setStatHistory(ItemStats.NAME, history); + verify(output, times(2)).setData(eq(ItemStats.LORE), any(StringListData.class)); + } + + @Test + void buildsAndRebuildsGemWithCosmeticsGlintAndPdc() { + ItemStack blank = tagged(Material.DIAMOND), result = tagged(Material.DIAMOND); + Gemstone template = mock(Gemstone.class); + when(template.getMMOItem()).thenReturn(mock(MMOItem.class, RETURNS_DEEP_STUBS)); + when(template.getMMOItem().newBuilder().build()).thenReturn(blank); + when(template.getName()).thenReturn("Ruby"); + when(template.getSocketColour()).thenReturn("Red"); + when(template.getSocketNameColour()).thenReturn(ChatColor.RED); + try (var constructed = live(m -> when(m.newBuilder().build()).thenReturn(result))) { + assertSame(result, InfusedGemBuilder.buildInfusedGem(template, rarity, 3, player)); + assertEquals("rare", GemRarityPdc.read(result)); + assertTrue(result.getItemMeta().hasItemFlag(ItemFlag.HIDE_ENCHANTS)); + assertSame(result, InfusedGemBuilder.applyCosmeticsToItem(blank, gem, rarity)); + } + assertNull(InfusedGemBuilder.applyCosmeticsToItem(null, gem, rarity)); + ItemStack air = new ItemStack(Material.AIR); + assertSame(air, InfusedGemBuilder.applyCosmeticsToItem(air, gem, rarity)); + assertSame(blank, InfusedGemBuilder.applyCosmeticsToItem(blank, null, rarity)); + assertSame(blank, InfusedGemBuilder.applyCosmeticsToItem(blank, gem, null)); + assertNull(InfusedGemBuilder.finalizeItem(null, "rare")); + assertSame(air, InfusedGemBuilder.finalizeItem(air, "rare")); + } + + @Test + void infusedValidatorRecognizesOnlyConfiguredInfusedGemsAndCandidates() { + ItemStack item = tagged(Material.DIAMOND); + assertFalse(InfusedGemValidator.isInfused(null)); + assertFalse(InfusedGemValidator.isInfused(new ItemStack(Material.AIR))); + assertFalse(InfusedGemValidator.isGemstoneCandidate(null)); + assertFalse(InfusedGemValidator.isGemstoneCandidate(new ItemStack(Material.AIR))); + when(nbt.hasType()).thenReturn(false); + assertFalse(InfusedGemValidator.isInfused(item)); + assertFalse(InfusedGemValidator.isGemstoneCandidate(item)); + when(nbt.hasType()).thenReturn(true); + when(nbt.getString("MMOITEMS_DISPLAYED_TYPE")).thenReturn("Blank Gemstone"); + assertFalse(InfusedGemValidator.isInfused(item)); + assertTrue(InfusedGemValidator.isGemstoneCandidate(item)); + when(nbt.getString("MMOITEMS_DISPLAYED_TYPE")).thenReturn("Infused Gemstone"); + assertTrue(InfusedGemValidator.isInfused(item)); + assertTrue(InfusedGemValidator.isGemstoneCandidate(item)); + ConfigLoader.loadedGems.clear(); + assertFalse(InfusedGemValidator.isInfused(item)); + when(nbt.getString("MMOITEMS_DISPLAYED_TYPE")).thenReturn("other"); + assertFalse(InfusedGemValidator.isGemstoneCandidate(item)); + ConfigLoader.loadedGems.add(gem); + assertTrue(InfusedGemValidator.isGemstoneCandidate(item)); + } + + @Test + void jewelryRejectsMissingInputsInvalidGemsAndUnreadableStats() { + GoldsmithStation station = mock(GoldsmithStation.class); + JewelryProject project = mock(JewelryProject.class); + ItemStack gemItem = tagged(Material.DIAMOND); + try (var log = mockStatic(GoldsmithLog.class); + var valid = mockStatic(InfusedGemValidator.class); + var constructed = live(m -> when(m.getStats()).thenReturn(Set.of()))) { + assertNull(JewelryOutput.build(station, player)); + when(station.getProject()).thenReturn(project); + assertNull(JewelryOutput.build(station, player)); + when(station.getGem()).thenReturn(gemItem); + assertNull(JewelryOutput.build(station, player)); + valid.when(() -> InfusedGemValidator.isInfused(gemItem)).thenReturn(true); + when(nbt.hasType()).thenReturn(false); + assertNull(JewelryOutput.build(station, player)); + when(nbt.hasType()).thenReturn(true); + assertNull(JewelryOutput.build(station, player)); + ConfigLoader.loadedGems.clear(); + assertNull(JewelryOutput.build(station, player)); + } + } + + @Test + void jewelryCarriesScaledStatIntoFreshHistoryAndQualityLore() throws Exception { + GoldsmithStation station = mock(GoldsmithStation.class); + JewelryProject project = mock(JewelryProject.class); + ItemStack gemItem = tagged(Material.DIAMOND), + base = tagged(Material.GOLD_INGOT), + out = tagged(Material.GOLD_INGOT); + when(station.getProject()).thenReturn(project); + when(station.getGem()).thenReturn(gemItem); + when(station.getRecipePercent()).thenReturn(90.0); + when(station.getHitPercent()).thenReturn(80.0); + when(project.getItem()).thenReturn("m.ring"); + when(project.getTierMultiplier()).thenReturn(.5); + when(AttributeInfluence.jewelry.forPlayer(player)).thenReturn(.2); + gem.addStat(stat("other", "MISSING", 3)); + gem.addStat(stat("rare", "ATTACK_DAMAGE", 10)); + GemRarityPdc.write(gemItem, "rare"); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath("m.ring")).thenReturn(base); + Quality quality = mock(Quality.class); + when(quality.getName()).thenReturn("Fine"); + StatHistory history = mock(StatHistory.class); + DoubleData original = new DoubleData(7); + when(history.getOriginalData()).thenReturn(original); + try (var libs = mockStatic(TLibs.class); + var valid = mockStatic(InfusedGemValidator.class); + var qualityApi = mockStatic(QualityLoader.class); + var constructed = + live( + m -> { + when(m.getData(ItemStats.ATTACK_DAMAGE)).thenReturn(new DoubleData(10)); + when(m.newBuilder().build()).thenReturn(out); + when(m.computeStatHistory(ItemStats.ATTACK_DAMAGE)).thenReturn(history); + })) { + libs.when(TLibs::getItemAPI).thenReturn(api); + valid.when(() -> InfusedGemValidator.isInfused(gemItem)).thenReturn(true); + qualityApi.when(() -> QualityLoader.getByAmount(80)).thenReturn(quality); + qualityApi.when(() -> QualityLoader.resolveStatFactor(80)).thenReturn(50.0); + JewelryCraftResult result = JewelryOutput.build(station, player); + assertNotNull(result); + assertSame(out, result.getItem()); + assertEquals(80, result.getFinishedTotal()); + assertEquals(90, result.getRecipePercent()); + assertEquals(80, result.getHitPercent()); + assertEquals(50, result.getStatCarryPercent()); + assertSame(quality, result.getQuality()); + assertEquals(0, original.getValue()); + verify(constructed.constructed().getLast()) + .setData( + eq(ItemStats.ATTACK_DAMAGE), + argThat(v -> v instanceof DoubleData d && d.getValue() == 3.0)); + verify(history) + .registerExternalData(argThat(v -> v instanceof DoubleData d && d.getValue() == 3.0)); + } + } + + @Test + void jewelryFallsBackAfterMatchingNonnumericStatAndHandlesFailedBuild() throws Exception { + GoldsmithStation station = mock(GoldsmithStation.class); + JewelryProject project = mock(JewelryProject.class); + ItemStack gemItem = tagged(Material.DIAMOND), base = tagged(Material.DIAMOND); + when(station.getProject()).thenReturn(project); + when(station.getGem()).thenReturn(gemItem); + when(project.getItem()).thenReturn("m.ring"); + gem.addStat(stat("rare", "NAME", 1)); + gem.addStat(stat("other", "ATTACK_DAMAGE", 2)); + GemRarityPdc.write(gemItem, "rare"); + when(MMOItems.plugin.getStats().get("NAME")).thenReturn(ItemStats.NAME); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath("m.ring")).thenReturn(base); + StatHistory history = mock(StatHistory.class); + when(history.getOriginalData()).thenReturn(new StringData("unexpected legacy value")); + try (var libs = mockStatic(TLibs.class); + var valid = mockStatic(InfusedGemValidator.class); + var quality = mockStatic(QualityLoader.class); + var log = mockStatic(GoldsmithLog.class); + var constructed = + live( + m -> { + when(m.getData(ItemStats.NAME)).thenReturn(new StringData("Name")); + when(m.getData(ItemStats.ATTACK_DAMAGE)).thenReturn(new DoubleData(2)); + when(m.newBuilder().build()).thenReturn(null); + when(m.computeStatHistory(ItemStats.ATTACK_DAMAGE)).thenReturn(history); + })) { + libs.when(TLibs::getItemAPI).thenReturn(api); + valid.when(() -> InfusedGemValidator.isInfused(gemItem)).thenReturn(true); + assertNull(JewelryOutput.build(station, player)); + log.verify(() -> GoldsmithLog.warn(contains("Could not build jewelry item"))); + GemRarityPdc.write(gemItem, "absent"); + assertNull(JewelryOutput.build(station, player)); + } + } + + @Test + void jewelryHandlesFallbackStatsInvalidOutputPathsAndAbsentHistory() throws Exception { + GoldsmithStation station = mock(GoldsmithStation.class); + JewelryProject project = mock(JewelryProject.class); + ItemStack gemItem = tagged(Material.DIAMOND), + base = tagged(Material.DIAMOND), + out = tagged(Material.DIAMOND); + when(station.getProject()).thenReturn(project); + when(station.getGem()).thenReturn(gemItem); + when(project.getTierMultiplier()).thenReturn(1.0); + gem.addStat(stat("other", "SUCCESS_RATE", 1)); + gem.addStat(stat("rare", "ATTACK_DAMAGE", 1)); + gem.addStat(stat("x", "MISSING", 1)); + when(MMOItems.plugin.getStats().get("MISSING")).thenReturn(null); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath(any())).thenReturn(base); + ItemStat nullId = mock(ItemStat.class); + ItemStat dash = mock(ItemStat.class); + when(dash.getId()).thenReturn("SUCCESS-RATE"); + LinkedHashSet all = + new LinkedHashSet<>( + Arrays.asList( + null, + nullId, + ItemStats.SUCCESS_RATE, + dash, + ItemStats.NAME, + ItemStats.ATTACK_DAMAGE)); + try (var libs = mockStatic(TLibs.class); + var valid = mockStatic(InfusedGemValidator.class); + var quality = mockStatic(QualityLoader.class); + var log = mockStatic(GoldsmithLog.class); + var constructed = + live( + m -> { + when(m.getStats()).thenReturn(all); + when(m.getData(ItemStats.NAME)).thenReturn(new StringData("Name")); + when(m.getData(ItemStats.ATTACK_DAMAGE)).thenReturn(new DoubleData(2)); + when(m.newBuilder().build()).thenReturn(out); + })) { + libs.when(TLibs::getItemAPI).thenReturn(api); + valid.when(() -> InfusedGemValidator.isInfused(gemItem)).thenReturn(true); + assertNotNull(JewelryOutput.build(station, player)); + gem.setStats(List.of(stat("x", "SUCCESS-RATE", 1), stat("x", "MISSING", 1))); + assertNotNull(JewelryOutput.build(station, player)); + when(project.getItem()).thenReturn("ia.bad"); + when(api.getCreator().getItemFromPath("ia.bad")).thenReturn(new ItemStack(Material.DIRT)); + assertNull(JewelryOutput.build(station, player)); + when(api.getCreator().getItemFromPath("ia.bad")).thenReturn(null); + assertNull(JewelryOutput.build(station, player)); + when(api.getCreator().getItemFromPath("ia.bad")).thenReturn(base); + when(MMOItems.plugin.getStats().get("ATTACK_DAMAGE")).thenReturn(null); + assertNull(JewelryOutput.build(station, player)); + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java new file mode 100644 index 0000000..563fe06 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java @@ -0,0 +1,366 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.*; +import org.bukkit.scheduler.*; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +class InfusionEventsTest { + InfusionEvents events; + World world; + Player player; + PlayerInventory inventory; + Block block; + ItemStack hand; + NBTItem nbt; + Gemstone gem; + MockedStatic nbtApi; + Location location; + + @BeforeEach + void setup() { + events = new InfusionEvents(); + world = mock(World.class); + player = mock(Player.class); + inventory = mock(PlayerInventory.class); + block = mock(Block.class); + location = new Location(world, 1, 2, 3); + hand = mock(ItemStack.class); + nbt = mock(NBTItem.class); + gem = mock(Gemstone.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItemInMainHand()).thenReturn(hand); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + when(block.getLocation()).thenReturn(location); + when(block.getType()).thenReturn(Material.ENCHANTING_TABLE); + when(hand.getAmount()).thenReturn(5); + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("GEM_STONE"); + when(nbt.getString("MMOITEMS_DISPLAYED_TYPE")).thenReturn("Blank Gemstone"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("RUBY"); + when(gem.getMMOItemString()).thenReturn("GEM_STONE.RUBY"); + MMOItem mmo = mock(MMOItem.class); + when(mmo.getId()).thenReturn("RUBY"); + when(gem.getMMOItem()).thenReturn(mmo); + when(gem.getColour()).thenReturn(ChatColor.RED); + when(gem.getName()).thenReturn("Ruby"); + ConfigLoader.stations.clear(); + ConfigLoader.stations.add(Material.ENCHANTING_TABLE); + ConfigLoader.locations.clear(); + ConfigLoader.useLocations = false; + ConfigLoader.loadedGems.clear(); + ConfigLoader.loadedGems.add(gem); + ConfigLoader.loadedRarities.clear(); + ConfigLoader.infusionStaff = "STAFF.INFUSER"; + nbtApi = mockStatic(NBTItem.class); + nbtApi.when(() -> NBTItem.get(hand)).thenReturn(nbt); + } + + @AfterEach + void cleanup() { + nbtApi.close(); + ConfigLoader.stations.clear(); + ConfigLoader.locations.clear(); + ConfigLoader.loadedGems.clear(); + ConfigLoader.loadedRarities.clear(); + } + + PlayerInteractEvent event(Action action, EquipmentSlot slot) { + return new PlayerInteractEvent( + player, action, hand, block, org.bukkit.block.BlockFace.UP, slot); + } + + InfusionBlock station(Location loc, int size, int hits) { + InfusionBlock s = new InfusionBlock(); + s.setLocation(loc); + s.setParticleLocation(loc.clone().add(.5, 1, .5)); + s.setInfustionHits(hits); + for (int i = 0; i < size; i++) s.addGem(gem); + events.currentStations.add(s); + return s; + } + + @Test + void addsOneGemToNewAndExistingStationAndCancelsVanillaInteraction() { + PlayerInteractEvent click = event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + events.addGemEvent(click); + assertTrue(click.isCancelled()); + assertEquals(1, events.currentStations.size()); + InfusionBlock s = events.currentStations.getFirst(); + assertEquals(location, s.getLocation()); + assertEquals(1, s.getCurrentItems().size()); + events.addGemEvent(click); + assertEquals(2, s.getCurrentItems().size()); + verify(hand, times(2)).setAmount(4); + } + + @Test + void nonStationWrongActionAndNonGemItemsDoNotConsumeAnything() { + events.addGemEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(block.getType()).thenReturn(Material.DIRT); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(block.getType()).thenReturn(Material.ENCHANTING_TABLE); + when(nbt.hasType()).thenReturn(false); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(nbt.hasType()).thenReturn(true); + when(nbt.getString("MMOITEMS_DISPLAYED_TYPE")).thenReturn("Sword"); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(nbt.getString("MMOITEMS_DISPLAYED_TYPE")).thenReturn("Blank Gemstone"); + when(nbt.getType()).thenReturn("SWORD"); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(nbt.getType()).thenReturn("GEM_STONE"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("OTHER"); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertTrue(events.currentStations.isEmpty()); + verify(hand, never()).setAmount(anyInt()); + } + + @Test + void configuredStationAndGemLocationsMustMatch() { + ConfigLoader.useLocations = true; + ConfigLoader.locations.add("9,2,3"); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertTrue(events.currentStations.isEmpty()); + ConfigLoader.locations.add("1,2,3"); + when(gem.isLocationSpecific()).thenReturn(true); + when(gem.getLocation()).thenReturn("9,2,3"); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertTrue(events.currentStations.isEmpty()); + when(gem.getLocation()).thenReturn("1,2,3"); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals( + location.clone().add(.5, 1, .5), events.currentStations.getFirst().getParticleLocation()); + } + + @Test + void startedAndFullStationsRejectFurtherGems() { + InfusionBlock s = station(location, 1, 1); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(1, s.getCurrentItems().size()); + events.currentStations.clear(); + s = station(location, 10, 0); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(10, s.getCurrentItems().size()); + verify(hand, never()).setAmount(anyInt()); + } + + @Test + void unrelatedFullStationMustNotBlockDifferentBench() { + station(location.clone().add(10, 0, 0), 10, 0); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals( + 2, events.currentStations.size(), "A full distant bench must not block this bench"); + verify(hand).setAmount(4); + } + + @Test + void offhandDuplicateMustNotConsumeMainHandGem() { + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND)); + assertTrue(events.currentStations.isEmpty(), "Offhand event must not consume main-hand items"); + verify(hand, never()).setAmount(anyInt()); + } + + @Test + void infusionHitsRequireCorrectStaffAndOnlyAffectMatchingNonemptyStation() { + InfusionBlock s = station(location, 1, 0); + station(location.clone().add(10, 0, 0), 1, 0); + events.infuseHitEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(block.getType()).thenReturn(Material.DIRT); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(block.getType()).thenReturn(Material.ENCHANTING_TABLE); + when(nbt.hasType()).thenReturn(false); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(nbt.hasType()).thenReturn(true); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(nbt.getType()).thenReturn("STAFF"); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + PlayerInteractEvent click = event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND); + events.infuseHitEvent(click); + assertTrue(click.isCancelled()); + assertEquals(1, s.getInfusionHits()); + assertEquals(0, events.currentStations.get(1).getInfusionHits()); + s.getCurrentItems().clear(); + events.infuseHitEvent(click); + assertEquals(1, s.getInfusionHits()); + } + + @Test + void fifthHitConsumesStaffAndScheduledTaskDropsBuiltGemThenStops() { + InfusionBlock s = station(location, 2, 4); + when(nbt.getType()).thenReturn("STAFF"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + GemRarity rarity = mock(GemRarity.class); + when(rarity.getChance()).thenReturn(1.0); + when(rarity.shouldAnnounce()).thenReturn(true, false); + ConfigLoader.loadedRarities.add(rarity); + InfusionMain previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(7); + Item dropped = mock(Item.class); + ItemStack built = mock(ItemStack.class); + when(world.dropItem(any(Location.class), same(built))).thenReturn(dropped); + try (var bukkit = mockStatic(Bukkit.class); + var builder = mockStatic(InfusedGemBuilder.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + builder + .when(() -> InfusedGemBuilder.buildInfusedGem(gem, rarity, 2, player)) + .thenReturn(built); + org.mockito.ArgumentCaptor capture = + org.mockito.ArgumentCaptor.forClass(Runnable.class); + when(scheduler.runTaskTimer(same(InfusionMain.plugin), capture.capture(), eq(30L), eq(2L))) + .thenReturn(task); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertTrue(events.currentStations.isEmpty()); + assertEquals(5, s.getInfusionHits()); + verify(hand).setAmount(4); + ConfigLoader.loadedRarities + .clear(); // Reload after consumption cannot invalidate the resolved batch. + capture.getValue().run(); + verify(world).dropItem(location.clone().add(.5, 1.1, .5), built); + verify(dropped).setVelocity(any()); + capture.getValue().run(); + verify(world, times(2)).dropItem(location.clone().add(.5, 1.1, .5), built); + capture.getValue().run(); + verify(scheduler).cancelTask(7); + verify(player).sendMessage(contains("just infused")); + } finally { + InfusionMain.plugin = previous; + } + } + + @Test + void unrelatedNonfullStationIsNotChangedByAnotherBench() { + InfusionBlock unrelated = station(location.clone().add(10, 0, 0), 1, 0); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(2, events.currentStations.size()); + assertEquals(1, unrelated.getCurrentItems().size()); + } + + @Test + void emptyRarityConfigurationFailsPromptlyInIsolatedJvm() throws Exception { + Process process = + new ProcessBuilder( + System.getProperty("java.home") + "/bin/java", + "-cp", + System.getProperty("java.class.path"), + EmptyRarityProbe.class.getName()) + .redirectErrorStream(true) + .start(); + try { + assertTrue( + process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS), + "Empty rarity configuration must not hang the server thread"); + assertEquals( + 0, + process.exitValue(), + new String( + process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + } finally { + process.destroyForcibly(); + process.waitFor(); + } + } + + public static class EmptyRarityProbe { + public static void main(String[] args) { + ConfigLoader.loadedRarities.clear(); + try { + new InfusionEvents().getRarity(); + throw new AssertionError("Expected invalid configuration rejection"); + } catch (IllegalStateException expected) { + } + } + } + + @Test + void invalidRaritiesRejectPromptlyAndMixedWeightsKeepPositiveRatios() { + assertThrows(IllegalStateException.class, events::getRarity); + for (double weight : + new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + GemRarity rarity = mock(GemRarity.class); + when(rarity.getChance()).thenReturn(weight); + ConfigLoader.loadedRarities.add(rarity); + } + assertThrows(IllegalStateException.class, events::getRarity); + GemRarity small = mock(GemRarity.class), large = mock(GemRarity.class); + when(small.getChance()).thenReturn(1.0); + when(large.getChance()).thenReturn(3.0); + ConfigLoader.loadedRarities.add(small); + ConfigLoader.loadedRarities.add(large); + assertSame(small, events.getRarity(0)); + assertSame(small, events.getRarity(Math.nextDown(.25))); + assertSame(large, events.getRarity(.25)); + assertSame(large, events.getRarity(Math.nextDown(1.0))); + when(small.getChance()).thenReturn(Double.MAX_VALUE); + when(large.getChance()).thenReturn(Double.MAX_VALUE); + assertSame(small, events.getRarity(0)); + assertSame(large, events.getRarity(.5)); + } + + @Test + void invalidRaritiesLeaveCompletedBenchAndStaffAvailableForRetry() { + InfusionBlock station = station(location, 2, 4); + when(nbt.getType()).thenReturn("STAFF"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + try (var log = mockStatic(net.tfminecraft.geminfusion.goldsmith.GoldsmithLog.class)) { + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(List.of(station), events.currentStations); + assertEquals(2, station.getCurrentItems().size()); + assertEquals(4, station.getInfusionHits()); + verify(hand, never()).setAmount(anyInt()); + verify(player).sendMessage(contains("your gems remain")); + log.verify( + () -> + net.tfminecraft.geminfusion.goldsmith.GoldsmithLog.warn( + contains("positive rarity weight"))); + } + } + + @Test + void offhandHitDoesNotAdvanceMainHandStaffProgress() { + InfusionBlock station = station(location, 1, 0); + when(nbt.getType()).thenReturn("STAFF"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.OFF_HAND)); + assertEquals(0, station.getInfusionHits()); + } + + @Test + void upperBoundaryRoundingStillSelectsLastValidRarity() { + GemRarity last = null; + for (double weight : + new double[] { + 0.00015979636207798768, 2.1443702942086142, 3259.817615131147, 0.11210730216548585 + }) { + last = mock(GemRarity.class); + when(last.getChance()).thenReturn(weight); + ConfigLoader.loadedRarities.add(last); + } + assertSame(last, events.getRarity(Math.nextDown(1.0))); + } + + @Test + void positiveRarityWeightsAlwaysSelectConfiguredRarity() { + GemRarity zero = mock(GemRarity.class), positive = mock(GemRarity.class); + when(positive.getChance()).thenReturn(5.0); + ConfigLoader.loadedRarities.add(zero); + ConfigLoader.loadedRarities.add(positive); + assertSame(positive, events.getRarity()); + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java b/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java new file mode 100644 index 0000000..10b44c8 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java @@ -0,0 +1,164 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.geminfusion.goldsmith.*; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class LifecycleCommandsTest { + ServerMock server; + InfusionMain previous; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + previous = InfusionMain.plugin; + } + + @AfterEach + void cleanup() { + MockBukkit.unmock(); + InfusionMain.plugin = previous; + JewelryProjectLoader.get().clear(); + } + + @Test + void pluginLoadsResourcesRegistersCommandAutosavesReloadsAndFlushesOnDisable() { + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + try (var stations = mockConstruction(GoldsmithStationManager.class); + var log = mockStatic(GoldsmithLog.class)) { + InfusionMain plugin = MockBukkit.load(InfusionMain.class); + assertSame(plugin, InfusionMain.plugin); + assertSame(stations.constructed().getFirst(), plugin.getGoldsmithStations()); + assertNotNull(plugin.getCommand("geminfusion").getExecutor()); + assertNotNull(plugin.getCommand("geminfusion").getTabCompleter()); + assertTrue(new java.io.File(plugin.getDataFolder(), "goldsmithing/projects.yml").exists()); + verify(plugin.getGoldsmithStations()).loadPersisted(); + server.getScheduler().performTicks(1200); + verify(plugin.getGoldsmithStations()).flush(false); + plugin.reloadConfigPCommand(server.addPlayer()); + verify(plugin.getGoldsmithStations()).clear(); + verify(plugin.getGoldsmithStations(), times(2)).loadPersisted(); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + var plugins = mock(org.bukkit.plugin.PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + plugin.onEnable(); + } + plugin.onDisable(); + verify(plugin.getGoldsmithStations(), atLeast(2)).flush(true); + } + } + + @Test + void commandsCoverUsageReloadPermissionsAndProjectSelection() { + CommandManager commands = new CommandManager(); + Command command = mock(Command.class); + when(command.getName()).thenReturn("other"); + Player player = mock(Player.class); + CommandSender console = mock(CommandSender.class); + InfusionMain.plugin = mock(InfusionMain.class); + assertFalse(commands.onCommand(player, command, "x", new String[0])); + when(command.getName()).thenReturn("geminfusion"); + assertTrue(commands.onCommand(console, command, "x", new String[0])); + commands.onCommand(console, command, "x", new String[] {"unknown"}); + commands.onCommand(console, command, "x", new String[] {"reload"}); + verify(InfusionMain.plugin, never()).reloadConfigCommand(); + when(console.hasPermission(Permissions.ADMIN)).thenReturn(true); + commands.onCommand(console, command, "x", new String[] {"reload"}); + verify(InfusionMain.plugin).reloadConfigCommand(); + when(player.hasPermission(Permissions.ADMIN)).thenReturn(true); + commands.onCommand(player, command, "x", new String[] {"reload"}); + verify(InfusionMain.plugin).reloadConfigPCommand(player); + when(player.hasPermission(Permissions.ADMIN)).thenReturn(false); + commands.onCommand(player, command, "x", new String[] {"select"}); + when(player.hasPermission(Permissions.ADMIN)).thenReturn(true); + commands.onCommand(console, command, "x", new String[] {"select"}); + verify(console).sendMessage(contains("Only players")); + try (var perms = mockStatic(Permissions.class, CALLS_REAL_METHODS)) { + perms.when(() -> Permissions.requireUseGoldsmith(player)).thenReturn(false); + commands.onCommand(player, command, "x", new String[] {"select"}); + } + commands.onCommand(player, command, "x", new String[] {"select"}); + verify(player).sendMessage(contains("Usage")); + commands.onCommand(player, command, "x", new String[] {"select", "missing"}); + verify(player).sendMessage(contains("Unknown project")); + JewelryProject project = mock(JewelryProject.class); + when(project.getName()).thenReturn("Ring"); + JewelryProjectLoader.get().put("ring", project); + GoldsmithStationManager stations = mock(GoldsmithStationManager.class); + when(InfusionMain.plugin.getGoldsmithStations()).thenReturn(stations); + commands.onCommand(player, command, "x", new String[] {"select", "ring"}); + Block target = server.addSimpleWorld("world").getBlockAt(1, 2, 3); + when(player.getTargetBlockExact(6)).thenReturn(target); + commands.onCommand(player, command, "x", new String[] {"select", "ring"}); + when(stations.isGoldsmithStation(target)).thenReturn(true); + GoldsmithStation station = new GoldsmithStation(target.getLocation()); + when(stations.getOrCreate(target.getLocation())).thenReturn(station); + commands.onCommand(player, command, "x", new String[] {"select", "ring"}); + assertSame(project, station.getProject()); + verify(stations).markDirty(); + commands.onCommand(player, command, "x", new String[] {"select", "ring"}); + verify(player).sendMessage(contains("already has a project")); + } + + @Test + void tabCompletionFiltersPrefixesPermissionsAndLimitsResults() { + CommandManager commands = new CommandManager(); + Command command = mock(Command.class); + CommandSender sender = mock(CommandSender.class); + when(command.getName()).thenReturn("other"); + assertNull(commands.onTabComplete(sender, command, "x", new String[0])); + when(command.getName()).thenReturn("geminfusion"); + assertEquals( + List.of("reload", "select"), + commands.onTabComplete(sender, command, "x", new String[] {""})); + assertEquals( + List.of("reload"), commands.onTabComplete(sender, command, "x", new String[] {"RE"})); + assertTrue( + commands.onTabComplete(sender, command, "x", new String[] {"unknown", ""}).isEmpty()); + assertTrue(commands.onTabComplete(sender, command, "x", new String[0]).isEmpty()); + assertTrue(commands.onTabComplete(sender, command, "x", new String[] {"select", ""}).isEmpty()); + when(sender.hasPermission(Permissions.ADMIN)).thenReturn(true); + JewelryProjectLoader.get().put("other", mock(JewelryProject.class)); + for (int i = 0; i < 45; i++) + JewelryProjectLoader.get().put("ring" + i, mock(JewelryProject.class)); + assertEquals( + 40, commands.onTabComplete(sender, command, "x", new String[] {"select", "RING"}).size()); + assertEquals( + List.of("other"), + commands.onTabComplete(sender, command, "x", new String[] {"select", "o"})); + } + + @Test + void goldsmithPermissionsRespectAdminOptionalPermissionAndDenialMessages() { + String previousPermission = GoldsmithCache.permission; + Player player = mock(Player.class); + try { + assertFalse(Permissions.requireAdmin(player)); + when(player.hasPermission(Permissions.ADMIN)).thenReturn(true); + assertTrue(Permissions.requireAdmin(player)); + assertTrue(Permissions.canUseGoldsmith(player)); + when(player.hasPermission(Permissions.ADMIN)).thenReturn(false); + GoldsmithCache.permission = null; + assertTrue(Permissions.canUseGoldsmith(player)); + GoldsmithCache.permission = " "; + assertTrue(Permissions.canUseGoldsmith(player)); + GoldsmithCache.permission = "goldsmith"; + assertFalse(Permissions.requireUseGoldsmith(player)); + when(player.hasPermission("goldsmith")).thenReturn(true); + assertTrue(Permissions.requireUseGoldsmith(player)); + } finally { + GoldsmithCache.permission = previousPermission; + } + new Permissions(); + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/SocketIntegrationTest.java b/src/test/java/net/tfminecraft/geminfusion/SocketIntegrationTest.java new file mode 100644 index 0000000..7e8b62c --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/SocketIntegrationTest.java @@ -0,0 +1,295 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.event.item.UnsocketGemStoneEvent; +import net.Indyuce.mmoitems.stat.data.*; +import net.tfminecraft.tlibs.event.MMOItemRebuildEvent; +import net.tfminecraft.tlibs.event.MMOItemRebuildEvent.RebuildReason; +import net.tfminecraft.tlibs.socket.GemSocketsNbtEditor; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.scheduler.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class SocketIntegrationTest { + ServerMock server; + InfusionMain previous; + Player player; + io.lumine.mythic.lib.MythicLib previousLib; + net.Indyuce.mmoitems.MMOItems previousMmo; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + previousLib = io.lumine.mythic.lib.MythicLib.plugin; + io.lumine.mythic.lib.MythicLib.plugin = + mock(io.lumine.mythic.lib.MythicLib.class, RETURNS_DEEP_STUBS); + previousMmo = net.Indyuce.mmoitems.MMOItems.plugin; + net.Indyuce.mmoitems.MMOItems.plugin = + mock(net.Indyuce.mmoitems.MMOItems.class, RETURNS_DEEP_STUBS); + previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.namespace()).thenReturn("geminfusion"); + player = server.addPlayer(); + } + + @AfterEach + void cleanup() { + UnsocketInventorySnapshot.clear(player); + ConfigLoader.loadedGems.clear(); + ConfigLoader.loadedRarities.clear(); + InfusionMain.plugin = previous; + io.lumine.mythic.lib.MythicLib.plugin = previousLib; + net.Indyuce.mmoitems.MMOItems.plugin = previousMmo; + MockBukkit.unmock(); + } + + ItemStack tagged(Material type) { + ItemStack s = new ItemStack(type); + var m = s.getItemMeta(); + m.setDisplayName("Original"); + s.setItemMeta(m); + return s; + } + + @Test + void pdcRarityAndSocketMapsRoundtripMergeRemoveAndProtectViews() { + ItemStack host = tagged(Material.DIAMOND_SWORD), + other = tagged(Material.DIAMOND_SWORD), + plain = new ItemStack(Material.DIAMOND); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + assertNull(GemRarityPdc.read(null)); + assertNull(GemRarityPdc.read(plain)); + ItemStack withoutMeta = mock(ItemStack.class); + when(withoutMeta.hasItemMeta()).thenReturn(false); + assertNull(GemRarityPdc.read(withoutMeta)); + assertTrue(SocketRarityStore.read(withoutMeta).isEmpty()); + GemRarityPdc.write(null, "rare"); + GemRarityPdc.write(host, null); + GemRarityPdc.write(plain, "rare"); + GemRarityPdc.write(host, "rare"); + assertEquals("rare", GemRarityPdc.read(host)); + assertTrue(SocketRarityStore.read(null).isEmpty()); + assertTrue(SocketRarityStore.read(plain).isEmpty()); + assertTrue(SocketRarityStore.read(host).isEmpty()); + var meta = host.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.socketRarities(), PersistentDataType.STRING, " "); + host.setItemMeta(meta); + assertTrue(SocketRarityStore.read(host).isEmpty()); + SocketRarityStore.put(host, first, "rare"); + SocketRarityStore.put(other, first, "common"); + SocketRarityStore.put(other, second, "epic"); + SocketRarityStore.mergeOnto(host, other); + assertEquals( + Map.of(first.toString(), "common", second.toString(), "epic"), + SocketRarityStore.read(host)); + assertEquals("common", SocketRarityStore.get(host, first)); + assertThrows( + UnsupportedOperationException.class, + () -> SocketRarityStore.unmodifiableView(host).clear()); + SocketRarityStore.remove(host, first); + assertNull(SocketRarityStore.get(host, first)); + SocketRarityStore.remove(host, first); + SocketRarityStore.remove(host, second); + assertTrue(SocketRarityStore.read(host).isEmpty()); + SocketRarityStore.write(host, null); + SocketRarityStore.write(null, Map.of()); + SocketRarityStore.write(new ItemStack(Material.AIR), Map.of()); + SocketRarityStore.mergeOnto(null, host); + SocketRarityStore.mergeOnto(host, null); + SocketRarityStore.put(null, first, "rare"); + SocketRarityStore.put(host, null, "rare"); + SocketRarityStore.put(host, first, null); + SocketRarityStore.remove(null, first); + SocketRarityStore.remove(host, null); + assertNull(SocketRarityStore.get(null, first)); + assertNull(SocketRarityStore.get(host, null)); + } + + @Test + void snapshotsCloneOccupiedSlotsPollOnceAndClear() { + player.getInventory().setItem(0, new ItemStack(Material.DIAMOND, 2)); + UnsocketGemStoneEvent event = mock(UnsocketGemStoneEvent.class); + when(event.getPlayer()).thenReturn(player); + new GemUnsocketSnapshotListener().onUnsocketInventorySnapshot(event); + player.getInventory().getItem(0).setAmount(3); + Map snapshot = UnsocketInventorySnapshot.poll(player); + assertEquals(2, snapshot.get(0).getAmount()); + assertTrue(UnsocketInventorySnapshot.poll(player).isEmpty()); + UnsocketInventorySnapshot.capture(player); + UnsocketInventorySnapshot.clear(player); + assertTrue(UnsocketInventorySnapshot.poll(player).isEmpty()); + UnsocketInventorySnapshot.capture(null); + UnsocketInventorySnapshot.clear(null); + assertTrue(UnsocketInventorySnapshot.poll(null).isEmpty()); + } + + @Test + void snapshotsIgnoreExplicitAirSlots() { + Player actor = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(actor.getInventory()).thenReturn(inventory); + when(actor.getUniqueId()).thenReturn(UUID.randomUUID()); + when(inventory.getSize()).thenReturn(1); + when(inventory.getItem(0)).thenReturn(new ItemStack(Material.AIR)); + UnsocketInventorySnapshot.capture(actor); + assertTrue(UnsocketInventorySnapshot.poll(actor).isEmpty()); + } + + @Test + void socketApplyCopiesOldMapAndRecordsOnlyNewSocketUuidWithCursorRarity() { + ItemStack old = tagged(Material.DIAMOND_SWORD), + rebuilt = tagged(Material.DIAMOND_SWORD), + cursor = tagged(Material.DIAMOND); + UUID existing = UUID.randomUUID(), added = UUID.randomUUID(); + SocketRarityStore.put(old, existing, "common"); + GemRarityPdc.write(cursor, "rare"); + GemSocketRebuildListener listener = new GemSocketRebuildListener(); + listener.onRebuild(new MMOItemRebuildEvent(player, null, rebuilt, RebuildReason.GEM_APPLY)); + listener.onRebuild(new MMOItemRebuildEvent(player, old, null, RebuildReason.GEM_APPLY)); + try (var sockets = mockStatic(GemSocketsNbtEditor.class)) { + sockets + .when(() -> GemSocketsNbtEditor.getGemstoneUuids(same(old))) + .thenReturn(Set.of(existing)); + sockets + .when(() -> GemSocketsNbtEditor.getGemstoneUuids(same(rebuilt))) + .thenReturn(new LinkedHashSet<>(List.of(existing, added))); + MMOItemRebuildEvent event = + new MMOItemRebuildEvent(player, old, rebuilt, RebuildReason.GEM_APPLY); + event.setAppliedCursorSnapshot(cursor); + listener.onRebuild(event); + assertEquals("rare", SocketRarityStore.get(rebuilt, added)); + assertEquals("common", SocketRarityStore.get(rebuilt, existing)); + event.setAppliedCursorSnapshot(null); + listener.onRebuild(event); + event.setAppliedCursorSnapshot(new ItemStack(Material.DIAMOND)); + listener.onRebuild(event); + sockets + .when(() -> GemSocketsNbtEditor.getGemstoneUuids(same(rebuilt))) + .thenReturn(Set.of(existing)); + event.setAppliedCursorSnapshot(cursor); + listener.onRebuild(event); + listener.onRebuild(new MMOItemRebuildEvent(player, old, rebuilt, null)); + } + } + + @Test + void unsocketRemovesRarityAndSchedulesRestorationFromSnapshot() { + ItemStack old = tagged(Material.DIAMOND_SWORD), rebuilt = tagged(Material.DIAMOND_SWORD); + UUID removed = UUID.randomUUID(), other = UUID.randomUUID(); + SocketRarityStore.put(old, removed, "rare"); + Gemstone gem = new Gemstone(); + gem.setMMOItem("GEM_STONE.RUBY"); + ConfigLoader.loadedGems.add(gem); + GemSocketsData data = mock(GemSocketsData.class); + GemstoneData unrelated = mock(GemstoneData.class), entry = mock(GemstoneData.class); + when(unrelated.getHistoricUUID()).thenReturn(other); + when(entry.getHistoricUUID()).thenReturn(removed); + when(entry.getMMOItemType()).thenReturn("GEM_STONE"); + when(entry.getMMOItemID()).thenReturn("RUBY"); + when(data.getGems()).thenReturn(List.of(unrelated, entry)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + try (var sockets = mockStatic(GemSocketsNbtEditor.class); + var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var restorer = mockStatic(UnsocketedGemRestorer.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + sockets + .when(() -> GemSocketsNbtEditor.getGemstoneUuids(same(old))) + .thenReturn(Set.of(removed)); + sockets.when(() -> GemSocketsNbtEditor.getGemstoneUuids(same(rebuilt))).thenReturn(Set.of()); + sockets.when(() -> GemSocketsNbtEditor.getSockets(same(old))).thenReturn(data); + UnsocketInventorySnapshot.capture(player); + MMOItemRebuildEvent event = + new MMOItemRebuildEvent(player, old, rebuilt, RebuildReason.GEM_UNSOCKET); + new GemSocketRebuildListener().onRebuild(event); + assertNull(SocketRarityStore.get(event.getNewItem(), removed)); + var task = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(same(InfusionMain.plugin), task.capture()); + task.getValue().run(); + restorer.verify( + () -> UnsocketedGemRestorer.restore(eq(player), eq("rare"), same(gem), anyMap())); + sockets.when(() -> GemSocketsNbtEditor.getSockets(same(old))).thenReturn(null); + new GemSocketRebuildListener().onRebuild(event); + sockets.when(() -> GemSocketsNbtEditor.getSockets(same(old))).thenReturn(data); + when(data.getGems()).thenReturn(List.of(unrelated)); + new GemSocketRebuildListener().onRebuild(event); + SocketRarityStore.write(old, Map.of()); + new GemSocketRebuildListener().onRebuild(event); + sockets.when(() -> GemSocketsNbtEditor.getGemstoneUuids(same(old))).thenReturn(Set.of()); + new GemSocketRebuildListener().onRebuild(event); + } + } + + @Test + void restorerTargetsNewOrIncreasedStackWithoutTouchingPreexistingCandidates() { + Player actor = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(actor.getInventory()).thenReturn(inventory); + when(inventory.getSize()).thenReturn(3); + ItemStack first = tagged(Material.DIAMOND), + second = tagged(Material.DIAMOND), + fixed = tagged(Material.EMERALD); + second.setAmount(2); + Gemstone gem = new Gemstone(); + gem.setMMOItem("GEM_STONE.RUBY"); + GemRarity rarity = mock(GemRarity.class); + when(rarity.getId()).thenReturn("rare"); + ConfigLoader.loadedRarities.add(rarity); + NBTItem nbt = mock(NBTItem.class); + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("GEM_STONE"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("RUBY"); + try (var api = mockStatic(NBTItem.class); + var builder = mockStatic(InfusedGemBuilder.class)) { + api.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + builder + .when(() -> InfusedGemBuilder.applyCosmeticsToItem(any(), same(gem), same(rarity))) + .thenReturn(fixed); + UnsocketedGemRestorer.restore(null, "rare", gem, Map.of()); + UnsocketedGemRestorer.restore(actor, null, gem, Map.of()); + UnsocketedGemRestorer.restore(actor, "rare", null, Map.of()); + UnsocketedGemRestorer.restore(actor, "missing", gem, Map.of()); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + verify(inventory, never()).setItem(anyInt(), any()); + when(inventory.getItem(0)).thenReturn(first); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + verify(inventory).setItem(0, fixed); + when(inventory.getItem(1)).thenReturn(second); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of(0, first)); + verify(inventory).setItem(1, fixed); + UnsocketedGemRestorer.restore( + actor, "rare", gem, Map.of(0, first, 1, new ItemStack(Material.AIR))); + verify(inventory, times(2)).setItem(1, fixed); + UnsocketedGemRestorer.restore( + actor, "rare", gem, Map.of(0, first, 1, new ItemStack(Material.DIAMOND))); + verify(inventory, times(3)).setItem(1, fixed); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of(0, first, 1, second)); + verify(inventory, times(3)).setItem(1, fixed); + for (String display : Arrays.asList(null, " ", "Blank Gemstone", "Infused Gemstone")) { + when(nbt.hasTag(ItemStats.DISPLAYED_TYPE.getNBTPath())).thenReturn(true); + when(nbt.getString(ItemStats.DISPLAYED_TYPE.getNBTPath())).thenReturn(display); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + } + when(inventory.getItem(0)).thenReturn(new ItemStack(Material.AIR)); + when(inventory.getItem(1)).thenReturn(null); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + when(inventory.getItem(0)).thenReturn(first); + when(nbt.hasType()).thenReturn(false); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("SWORD"); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + when(nbt.getType()).thenReturn("GEM_STONE"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("OTHER"); + UnsocketedGemRestorer.restore(actor, "rare", gem, Map.of()); + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java new file mode 100644 index 0000000..ab27903 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java @@ -0,0 +1,426 @@ +package net.tfminecraft.geminfusion.goldsmith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.geminfusion.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.*; +import org.bukkit.*; +import org.bukkit.configuration.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ConfigLoadersTest { + @TempDir Path temp; + Map cacheBefore = new HashMap<>(); + + @BeforeEach + void captureCache() throws Exception { + for (var field : GoldsmithCache.class.getFields()) cacheBefore.put(field, field.get(null)); + } + + @AfterEach + void clearRegistries() throws Exception { + for (var entry : cacheBefore.entrySet()) entry.getKey().set(null, entry.getValue()); + GoldsmithHitTypeLoader.get().clear(); + GoldsmithHitLoader.get().clear(); + GoldsmithMaterialLoader.get().clear(); + GoldsmithMaterialTypeLoader.get().clear(); + JewelryProjectLoader.get().clear(); + ProjectTierLoader.get().clear(); + QualityLoader.get().clear(); + ConfigLoader.loadedGems.clear(); + ConfigLoader.loadedRarities.clear(); + ConfigLoader.stations.clear(); + ConfigLoader.locations.clear(); + } + + File yaml(String contents) throws Exception { + return Files.writeString(temp.resolve(UUID.randomUUID() + ".yml"), contents).toFile(); + } + + YamlConfiguration config(String text) throws Exception { + YamlConfiguration c = new YamlConfiguration(); + c.loadFromString(text); + return c; + } + + void basicDefinitions() throws Exception { + new GoldsmithHitTypeLoader().load(yaml("gold:\n name: Gold work\n")); + new GoldsmithHitLoader().load(yaml("hit:\n tool: m.hammer\n type: gold\n name: Hammer\n")); + new GoldsmithMaterialLoader() + .load(yaml("gold:\n path: m.gold\n name: Gold\n hits: [hit.2, hit.3]\n")); + } + + @Test + void shippedConfigurationDefinitionsLoadAndReferenceEachOther() throws Exception { + Path root = Path.of("src/main/resources/goldsmithing"); + new GoldsmithHitTypeLoader().load(root.resolve("hit-types.yml").toFile()); + new GoldsmithHitLoader().load(root.resolve("hits.yml").toFile()); + new GoldsmithMaterialTypeLoader().load(root.resolve("material-types.yml").toFile()); + new GoldsmithMaterialLoader().load(root.resolve("materials.yml").toFile()); + new ProjectTierLoader().load(root.resolve("tiers.yml").toFile()); + new QualityLoader().load(root.resolve("qualities.yml").toFile()); + new JewelryProjectLoader().load(root.resolve("projects.yml").toFile()); + assertFalse(JewelryProjectLoader.get().isEmpty()); + for (JewelryProject p : JewelryProjectLoader.get().values()) { + assertNotNull(p.getId()); + assertNotNull(p.getName()); + assertNotNull(p.getItem()); + assertNotNull(p.getTierId()); + assertTrue(p.getTierMultiplier() > 0); + assertFalse(p.getRecipe().isEmpty()); + assertFalse(p.getMaterialsByType().isEmpty()); + assertTrue(p.requiresGem()); + assertThrows(UnsupportedOperationException.class, () -> p.getRecipe().clear()); + } + for (GoldsmithMaterial m : GoldsmithMaterialLoader.get().values()) { + assertNotNull(m.getId()); + assertNotNull(m.getName()); + assertNotNull(m.getPath()); + assertNotNull(m.getType()); + assertFalse(m.getHits().isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> m.getHits().clear()); + } + GoldsmithHit hit = GoldsmithHitLoader.getByString("hit"); + assertEquals("hit", hit.getId()); + assertNotNull(hit.getName()); + assertNotNull(hit.getType().getId()); + assertNotNull(hit.getType().getName()); + assertSame(hit, GoldsmithHitLoader.getByTool(hit.getTool().toUpperCase())); + GoldsmithMaterialType type = GoldsmithMaterialTypeLoader.get().values().iterator().next(); + assertNotNull(type.getId()); + assertEquals(type.getName(), GoldsmithMaterialTypeLoader.display(type.getId())); + assertEquals(100, QualityLoader.resolveStatFactor(100)); + assertEquals(100, QualityLoader.resolveStatFactor(150)); + for (Quality q : QualityLoader.get().values()) { + assertEquals(q, QualityLoader.getByString(q.getId())); + assertNotNull(q.getName()); + assertTrue(q.isValid(q.getAmount())); + assertFalse(q.isValid(q.getAmount() - .1)); + } + assertNull(GoldsmithHitLoader.getByString(null)); + assertNull(GoldsmithHitTypeLoader.getByString(null)); + assertNull(GoldsmithMaterialLoader.getByString(null)); + assertNull(GoldsmithMaterialTypeLoader.getByString(null)); + assertNull(JewelryProjectLoader.getByString(null)); + assertNull(QualityLoader.getByString(null)); + assertNull(GoldsmithHitLoader.getByTool(null)); + assertNull(GoldsmithHitLoader.getByTool("absent")); + assertEquals("Materials", GoldsmithMaterialTypeLoader.display(null)); + assertEquals("Materials", GoldsmithMaterialTypeLoader.display(" ")); + assertEquals("Silver Materials", GoldsmithMaterialTypeLoader.display("silver")); + } + + @Test + void missingUnreadableAndMalformedYamlAreLoggedWithoutCrashing() throws Exception { + try (var log = mockStatic(GoldsmithLog.class)) { + assertNull(GoldsmithYaml.read(null)); + assertNull(GoldsmithYaml.read(temp.resolve("missing").toFile())); + assertNull(GoldsmithYaml.read(yaml("x: ["))); + assertNull(GoldsmithYaml.read(temp.toFile())); + assertNotNull(GoldsmithYaml.read(yaml("x: 1"))); + new GoldsmithHitTypeLoader().load(null); + new GoldsmithHitLoader().load(null); + new GoldsmithMaterialLoader().load(null); + new GoldsmithMaterialTypeLoader().load(null); + new JewelryProjectLoader().load(null); + new QualityLoader().load(null); + new ProjectTierLoader().load(null); + new GoldsmithConfigLoader().load(null); + assertTrue(GoldsmithHitLoader.get().isEmpty()); + assertTrue(QualityLoader.get().isEmpty()); + assertEquals(1, QualityLoader.resolveStatFactor(20)); + } + } + + @Test + void invalidDefinitionsAreSkippedAndValidHitCountsAreMerged() throws Exception { + basicDefinitions(); + try (var log = mockStatic(GoldsmithLog.class)) { + new GoldsmithHitLoader() + .load( + yaml( + "badType:\n" + + " type: missing\n" + + " tool: x\n" + + "noTool:\n" + + " type: gold\n" + + "hit:\n" + + " type: gold\n" + + " tool: m.hammer\n")); + assertEquals(Set.of("hit"), GoldsmithHitLoader.get().keySet()); + new GoldsmithMaterialLoader() + .load( + yaml( + "noPath:\n" + + " name: Bad\n" + + "gold:\n" + + " path: m.gold\n" + + " hits: [hit.2, hit.3, invalid, .2, 'hit.', hit.zero, hit.0, hit.-1," + + " unknown.3]\n")); + assertEquals(Set.of("gold"), GoldsmithMaterialLoader.get().keySet()); + assertEquals( + 5, + GoldsmithMaterialLoader.getByString("gold") + .getHits() + .get(GoldsmithHitLoader.getByString("hit"))); + new JewelryProjectLoader() + .load( + yaml( + "scalar: 1\n" + + "noItem:\n" + + " name: Bad\n" + + "ring:\n" + + " item: m.ring\n" + + " recipe: [gold.1, gold.2, invalid, .2, 'gold.', gold.zero, gold.0," + + " gold.-1, unknown.3]\n" + + "blankTier:\n" + + " item: m.ring\n" + + " tier: ' '\n")); + assertEquals(Set.of("ring", "blankTier"), JewelryProjectLoader.get().keySet()); + JewelryProject ring = JewelryProjectLoader.getByString("ring"); + assertEquals("greater", ring.getTierId()); + assertEquals("greater", JewelryProjectLoader.getByString("blankTier").getTierId()); + assertFalse(ring.requiresGem()); + assertEquals(Map.of("gold", 3), ring.getMaterialsByType()); + } + } + + @Test + void nullListEntriesAreRejectedByModelParsers() { + ConfigurationSection c = mock(ConfigurationSection.class); + when(c.getString("name", "gold")).thenReturn("Gold"); + when(c.getString("type", "gold")).thenReturn("gold"); + when(c.getStringList("hits")).thenReturn(Arrays.asList((String) null)); + try (var log = mockStatic(GoldsmithLog.class)) { + assertTrue(new GoldsmithMaterial("gold", c).getHits().isEmpty()); + when(c.getStringList("recipe")).thenReturn(Arrays.asList((String) null)); + assertTrue(new JewelryProject("gold", c).getRecipe().isEmpty()); + } + } + + @Test + void itemLookupSkipsNonmatchingToolsAndMissingItems() throws Exception { + basicDefinitions(); + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.STICK); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + try (var libs = mockStatic(TLibs.class)) { + libs.when(TLibs::getItemAPI).thenReturn(api); + ItemStack air = mock(ItemStack.class); + when(air.getType()).thenReturn(Material.AIR); + assertNull(GoldsmithHitLoader.getByItem(null)); + assertNull(GoldsmithHitLoader.getByItem(air)); + assertNull(GoldsmithHitLoader.getByItem(item)); + when(api.getChecker().checkItemWithPath(item, "m.hammer")).thenReturn(true); + assertSame(GoldsmithHitLoader.getByString("hit"), GoldsmithHitLoader.getByItem(item)); + } + } + + @Test + void tierDefaultsIgnoreSectionsAndResolveCaseInsensitively() throws Exception { + try (var log = mockStatic(GoldsmithLog.class)) { + new ProjectTierLoader().load(yaml("Major: 0.8\nsection:\n nested: 2\n")); + assertEquals(Map.of("major", .8), ProjectTierLoader.get()); + assertEquals(.8, ProjectTierLoader.getMultiplier("MAJOR")); + assertEquals(1, ProjectTierLoader.getMultiplier(null)); + assertEquals(1, ProjectTierLoader.getMultiplier(" ")); + assertEquals(1, ProjectTierLoader.getMultiplier("missing")); + } + } + + @Test + void qualityInterpolationHonorsThresholdsAndFinalTier() throws Exception { + new QualityLoader() + .load( + yaml( + "low:\n" + + " amount: 0\n" + + " value: 1\n" + + " stat-min: 10\n" + + " stat-max: 30\n" + + "high:\n" + + " amount: 50\n" + + " value: 2\n" + + " stat-min: 40\n" + + " stat-max: 80\n")); + assertNull(QualityLoader.getByAmount(-1)); + assertEquals("low", QualityLoader.getByAmount(0).getId()); + assertEquals("high", QualityLoader.getByAmount(50).getId()); + assertEquals(20, QualityLoader.resolveStatFactor(25)); + assertEquals(60, QualityLoader.resolveStatFactor(75)); + assertEquals(1, QualityLoader.resolveStatFactor(-1)); + new QualityLoader() + .load(yaml("perfect:\n amount: 10\n value: 1\n stat-min: 100\n stat-max: 100\n")); + assertEquals(100, QualityLoader.resolveStatFactor(50)); + } + + @Test + void removedQualityTierLeavesEqualOrInvertedCachedThresholdSafe() throws Exception { + for (int nextAmount : new int[] {40, 50}) { + new QualityLoader() + .load( + yaml( + "low:\n amount: 50\n value: 1\n stat-min: 10\n stat-max: 30\n" + + "high:\n amount: " + + nextAmount + + "\n value: 2\n stat-min: 40\n stat-max: 80\n")); + // The public mutable registry can change independently of the cached sorted tiers. + QualityLoader.get().remove("high"); + assertEquals("low", QualityLoader.getByAmount(50).getId()); + assertEquals(10, QualityLoader.resolveStatFactor(50)); + assertEquals(10, QualityLoader.resolveStatFactor(75)); + } + } + + @Test + void extremeConfiguredQualityValuesKeepDegenerateInterpolationFinite() throws Exception { + for (int nextAmount : new int[] {40, 50}) { + new QualityLoader() + .load( + yaml( + "top:\n amount: 50\n value: 2147483647\n stat-min: 10\n stat-max: 30\n" + + "wrapped:\n amount: " + + nextAmount + + "\n value: -2147483648\n stat-min: 40\n stat-max: 80\n")); + assertEquals("top", QualityLoader.getByAmount(50).getId()); + // The next-value lookup wraps at MAX_VALUE even without registry mutations. + assertEquals(10, QualityLoader.resolveStatFactor(50)); + assertEquals(10, QualityLoader.resolveStatFactor(75)); + } + } + + @Test + void mainGoldsmithConfigLoadsDefaultsOverridesAndBlankPermission() throws Exception { + GoldsmithConfigLoader loader = new GoldsmithConfigLoader(); + loader.load(yaml("{}")); + assertNull(GoldsmithCache.permission); + assertEquals(.4, GoldsmithCache.minHitPercent); + assertEquals(30, GoldsmithCache.hitOvershootWarnPercent); + assertEquals("§cYou have worked this piece too much", GoldsmithCache.hitOvershootWarnMessage); + loader.load(yaml("permission: ' '\nhit-overshoot-warn-message: ' '\n")); + assertNull(GoldsmithCache.permission); + loader.load( + yaml( + "station: table\n" + + "branding-tool: branding\n" + + "permission: smith\n" + + "min-hit-percent: 0.6\n" + + "hit-overshoot-warn-percent: 50\n" + + "hit-overshoot-warn-message: Overworked\n")); + assertEquals("table", GoldsmithCache.station); + assertEquals("branding", GoldsmithCache.brandingTool); + assertEquals("smith", GoldsmithCache.permission); + assertEquals(.6, GoldsmithCache.minHitPercent); + assertEquals(50, GoldsmithCache.hitOvershootWarnPercent); + assertEquals("Overworked", GoldsmithCache.hitOvershootWarnMessage); + } + + @Test + void infusionConfigReloadClearsOldStateAndResolvesCaseInsensitiveIds() throws Exception { + YamlConfiguration c = + config( + "location_specific: true\n" + + "infusion_item: STAFF.INFUSER\n" + + "locations: ['1,2,3']\n" + + "infusion_blocks: [enchanting_table]\n" + + "gems:\n" + + " ruby:\n" + + " name: Ruby\n" + + " colour: RED\n" + + " socket_colour: Red\n" + + " socket_name_colour: GOLD\n" + + " gem: GEM_STONE.RUBY\n" + + " rarities:\n" + + " common:\n" + + " stat: ATTACK_DAMAGE\n" + + " min: 2\n" + + " max: 4\n" + + " bad: {}\n" + + "rarities:\n" + + " common:\n" + + " name: Common\n" + + " chance: 1\n" + + " announce: true\n"); + new ConfigLoader().loadConfig(c); + assertEquals(1, ConfigLoader.loadedGems.size()); + assertTrue(ConfigLoader.useLocations); + assertEquals("STAFF.INFUSER", ConfigLoader.infusionStaff); + assertEquals(List.of(Material.ENCHANTING_TABLE), ConfigLoader.stations); + assertEquals(List.of("1,2,3"), ConfigLoader.locations); + Gemstone gem = ConfigLoader.loadedGems.getFirst(); + assertEquals("ruby", gem.getId()); + assertEquals("Ruby", gem.getName()); + assertEquals("Red", gem.getSocketColour()); + assertEquals(ChatColor.RED, gem.getColour()); + assertEquals(ChatColor.GOLD, gem.getSocketNameColour()); + assertFalse(gem.isLocationSpecific()); + assertEquals("none", gem.getLocation()); + assertEquals(1, gem.getStats().size()); + assertEquals("ATTACK_DAMAGE", gem.getStats().getFirst().getStatId()); + assertSame(gem, ConfigLoader.findGemByMmoItem("gem_stone", "ruby")); + assertNull(ConfigLoader.findGemByMmoItem(null, "ruby")); + assertNull(ConfigLoader.findGemByMmoItem("GEM_STONE", null)); + Gemstone absent = new Gemstone(); + Gemstone malformed = new Gemstone(); + malformed.setMMOItem("missing"); + ConfigLoader.loadedGems.addFirst(absent); + ConfigLoader.loadedGems.addFirst(malformed); + assertNull(ConfigLoader.findGemByMmoItem("other", "ruby")); + assertNull(ConfigLoader.findGemByMmoItem("GEM_STONE", "other")); + assertSame(gem, ConfigLoader.findGemByMmoItem("GEM_STONE", "ruby")); + GemRarity rarity = ConfigLoader.loadedRarities.getFirst(); + assertSame(rarity, ConfigLoader.findRarityById("COMMON")); + assertNull(ConfigLoader.findRarityById(null)); + assertNull(ConfigLoader.findRarityById("other")); + assertTrue(rarity.shouldAnnounce()); + assertEquals("Common", rarity.getName()); + assertEquals(1, rarity.getChance()); + c.set("gems.ruby.location_specific", true); + c.set("gems.ruby.location", "1,2,3"); + c.set("rarities.common.announce", null); + new ConfigLoader().loadConfig(c); + assertEquals(1, ConfigLoader.loadedGems.size()); + assertTrue(ConfigLoader.loadedGems.getFirst().isLocationSpecific()); + assertEquals("1,2,3", ConfigLoader.loadedGems.getFirst().getLocation()); + assertFalse(ConfigLoader.loadedRarities.getFirst().shouldAnnounce()); + } + + @Test + void cancelledBreakDispatcherPreservesStation() { + var server = MockBukkit.mock(); + String previous = GoldsmithCache.station; + try { + var plugin = MockBukkit.createMockPlugin(); + var player = server.addPlayer(); + var block = server.addSimpleWorld("protected").getBlockAt(1, 2, 3); + GoldsmithCache.station = "table"; + GoldsmithStationManager manager = new GoldsmithStationManager(); + GoldsmithStation station = manager.getOrCreate(block.getLocation()); + BlockAPI api = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + try (var libs = mockStatic(TLibs.class); + var store = mockStatic(GoldsmithStationStore.class)) { + libs.when(TLibs::getBlockAPI).thenReturn(api); + when(api.getChecker().checkBlock(block, "table")).thenReturn(true); + server.getPluginManager().registerEvents(manager, plugin); + BlockBreakEvent event = new BlockBreakEvent(block, player); + event.setCancelled(true); + server.getPluginManager().callEvent(event); + assertSame(station, manager.get(block.getLocation())); + store.verifyNoInteractions(); + } + } finally { + GoldsmithCache.station = previous; + MockBukkit.unmock(); + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java new file mode 100644 index 0000000..4344830 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java @@ -0,0 +1,125 @@ +package net.tfminecraft.geminfusion.goldsmith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.geminfusion.InfusionMain; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +class GoldsmithInventoryManagerTest { + ServerMock server; + InfusionMain previous; + Player player; + ItemAPI items; + MockedStatic libs; + MockedStatic projects; + LinkedHashMap registry = new LinkedHashMap<>(); + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.getName()).thenReturn("GemInfusion"); + when(InfusionMain.plugin.namespace()).thenReturn("geminfusion"); + when(InfusionMain.plugin.getServer()).thenReturn(server); + player = mock(Player.class); + items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs = mockStatic(TLibs.class); + libs.when(TLibs::getItemAPI).thenReturn(items); + projects = mockStatic(JewelryProjectLoader.class); + projects.when(JewelryProjectLoader::get).thenReturn(registry); + } + + @AfterEach + void cleanup() { + projects.close(); + libs.close(); + InfusionMain.plugin = previous; + MockBukkit.unmock(); + } + + JewelryProject project(String id, String path, String tier, boolean gem) { + JewelryProject p = mock(JewelryProject.class); + when(p.getId()).thenReturn(id); + when(p.getItem()).thenReturn(path); + when(p.getName()).thenReturn("Fancy " + id); + when(p.getTierId()).thenReturn(tier); + when(p.requiresGem()).thenReturn(gem); + when(p.getMaterialsByType()).thenReturn(Map.of("gold", 2)); + registry.put(id, p); + return p; + } + + Inventory opened() { + var capture = org.mockito.ArgumentCaptor.forClass(Inventory.class); + verify(player).openInventory(capture.capture()); + return capture.getValue(); + } + + @Test + void validIconsAreClonedDecoratedAndRemainingSlotsFilled() { + project("ring", "minecraft.DIAMOND", "mAJOR", true); + project("band", "ia.band", null, false); + project("blank", "minecraft.GOLD_INGOT", " ", false); + ItemStack original = new ItemStack(Material.DIAMOND, 6); + when(items.getCreator().getItemFromPath("minecraft.DIAMOND")).thenReturn(original); + when(items.getCreator().getItemFromPath("ia.band")) + .thenReturn(new ItemStack(Material.GOLD_INGOT)); + when(items.getCreator().getItemFromPath("minecraft.GOLD_INGOT")) + .thenReturn(new ItemStack(Material.GOLD_INGOT)); + assertEquals(3, new GoldsmithInventoryManager().openMenu(player)); + Inventory inv = opened(); + ItemStack icon = inv.getItem(0); + assertEquals(1, icon.getAmount()); + assertEquals(6, original.getAmount()); + assertEquals("Fancy ring", icon.getItemMeta().getDisplayName()); + assertTrue(icon.getItemMeta().getLore().contains("§7Tier: §eMajor")); + assertTrue(icon.getItemMeta().getLore().contains("§7Requires §a1 §7infused gem")); + assertEquals( + "ring", + icon.getItemMeta() + .getPersistentDataContainer() + .get(GoldsmithInventoryManager.projectKey(), PersistentDataType.STRING)); + assertTrue(inv.getItem(1).getItemMeta().getLore().contains("§7Tier: §eGreater")); + assertTrue(inv.getItem(2).getItemMeta().getLore().contains("§7Tier: §eGreater")); + for (int slot = 3; slot < 27; slot++) + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inv.getItem(slot).getType()); + } + + @Test + void invalidPathsAndMissingItemsSkipSlotsAndEmptyMenuIsLogged() { + project("null", null, "x", false); + project("blank", " ", "x", false); + project("missing", "missing", "x", false); + project("air", "air", "x", false); + project("ia", "IA.bad", "x", false); + when(items.getCreator().getItemFromPath("missing")).thenReturn(null); + when(items.getCreator().getItemFromPath("air")).thenReturn(new ItemStack(Material.AIR)); + when(items.getCreator().getItemFromPath("IA.bad")).thenReturn(new ItemStack(Material.DIRT)); + try (var log = mockStatic(GoldsmithLog.class)) { + assertEquals(0, new GoldsmithInventoryManager().openMenu(player)); + for (ItemStack item : opened().getContents()) + assertEquals(Material.GRAY_STAINED_GLASS_PANE, item.getType()); + log.verify(() -> GoldsmithLog.warn(contains("zero valid"))); + } + } + + @Test + void menuStopsAtInventoryCapacity() { + when(items.getCreator().getItemFromPath("minecraft.DIRT")) + .thenAnswer(inv -> new ItemStack(Material.DIRT)); + for (int i = 0; i < 30; i++) project("p" + i, "minecraft.DIRT", "greater", false); + assertEquals(27, new GoldsmithInventoryManager().openMenu(player)); + assertEquals(27, opened().getSize()); + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMathTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMathTest.java new file mode 100644 index 0000000..b6d5d9a --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithMathTest.java @@ -0,0 +1,140 @@ +package net.tfminecraft.geminfusion.goldsmith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.tlibs.objects.utils.IntCounter; +import org.junit.jupiter.api.Test; + +class GoldsmithMathTest { + private final GoldsmithHit hit = mock(GoldsmithHit.class); + + private static IntCounter count(int value) { + IntCounter c = new IntCounter(); + c.setCurrent(value); + return c; + } + + @Test + void aggregatesMaterialHitsAndTypesWithoutExposingMutableResults() { + GoldsmithHitType type = mock(GoldsmithHitType.class); + when(hit.getType()).thenReturn(type); + GoldsmithHit other = mock(GoldsmithHit.class); + when(other.getType()).thenReturn(type); + GoldsmithHit untyped = mock(GoldsmithHit.class); + GoldsmithMaterial a = mock(GoldsmithMaterial.class), b = mock(GoldsmithMaterial.class); + when(a.getHits()).thenReturn(Map.of(hit, 2, untyped, 1)); + when(b.getHits()).thenReturn(Map.of(hit, 3, other, 4)); + Map deposited = new LinkedHashMap<>(); + deposited.put(a, 2); + deposited.put(b, 1); + deposited.put(null, 5); + assertEquals(Map.of(hit, 7, other, 4, untyped, 2), GoldsmithMath.requiredHits(deposited)); + assertEquals(Map.of(type, 11), GoldsmithMath.requiredHitsByType(deposited)); + assertThrows( + UnsupportedOperationException.class, () -> GoldsmithMath.requiredHits(deposited).clear()); + for (Integer empty : Arrays.asList(null, -1, 0)) { + deposited.clear(); + deposited.put(a, empty); + assertTrue(GoldsmithMath.requiredHits(deposited).isEmpty()); + } + assertTrue(GoldsmithMath.requiredHits(null).isEmpty()); + assertTrue(GoldsmithMath.requiredHits(Map.of()).isEmpty()); + } + + @Test + void recipePercentWeightsIngredientsEquallyAndCapsExcess() { + JewelryProject project = mock(JewelryProject.class); + GoldsmithMaterial a = mock(GoldsmithMaterial.class), b = mock(GoldsmithMaterial.class); + assertEquals(0, GoldsmithMath.recipePercent(null, null)); + assertEquals(0, GoldsmithMath.recipePercent(project, null)); + when(project.getRecipe()).thenReturn(Map.of(a, 2, b, 4)); + assertEquals(0, GoldsmithMath.recipePercent(project, null)); + assertEquals(50, GoldsmithMath.recipePercent(project, Map.of(a, 1, b, 2))); + assertEquals(100, GoldsmithMath.recipePercent(project, Map.of(a, 20, b, 40))); + assertEquals(0, GoldsmithMath.recipePercent(project, Map.of(a, -1))); + when(project.getRecipe()).thenReturn(Map.of(a, 0, b, -1)); + assertEquals(0, GoldsmithMath.recipePercent(project, Map.of())); + } + + @Test + void totalsIgnoreInvalidRequirementsAndClampOnlyTheCappedVariant() { + GoldsmithHit other = mock(GoldsmithHit.class); + Map required = new LinkedHashMap<>(); + required.put(hit, 4); + required.put(other, null); + Map current = new LinkedHashMap<>(); + current.put(hit, count(7)); + current.put(other, null); + assertEquals(4, GoldsmithMath.totalHitNeeded(required)); + assertEquals(4, GoldsmithMath.totalHitCurrent(required, current)); + assertEquals(7, GoldsmithMath.totalHitCurrentRaw(required, current)); + assertEquals(7, GoldsmithMath.totalHitCurrentAll(current)); + for (Integer invalid : Arrays.asList(null, 0, -1)) { + required.put(other, invalid); + assertEquals(4, GoldsmithMath.totalHitNeeded(required)); + } + for (Map counts : + Arrays.asList(null, Map.of(), Map.of(hit, count(-2)))) { + assertEquals(0, GoldsmithMath.totalHitCurrent(required, counts)); + assertEquals(0, GoldsmithMath.totalHitCurrentRaw(required, counts)); + assertEquals(0, GoldsmithMath.totalHitCurrentAll(counts)); + } + for (Map empty : Arrays.asList(null, Map.of())) { + assertEquals(0, GoldsmithMath.totalHitNeeded(empty)); + assertEquals(0, GoldsmithMath.totalHitCurrent(empty, current)); + assertEquals(0, GoldsmithMath.totalHitCurrentRaw(empty, current)); + } + } + + @Test + void bothHitPercentApisApplyTriangularOverworkPenalty() { + Map required = Map.of(hit, 10); + int[] currents = {-1, 0, 5, 10, 15, 20, 25}; + double[] expected = {0, 0, 50, 100, 50, 0, 0}; + for (int i = 0; i < currents.length; i++) { + assertEquals( + expected[i], GoldsmithMath.hitPercent(required, Map.of(hit, count(currents[i])))); + assertEquals( + expected[i], GoldsmithMath.hitPercentFromCounts(required, Map.of(hit, currents[i]))); + } + assertEquals(0, GoldsmithMath.hitPercent(required, null)); + assertEquals(0, GoldsmithMath.hitPercentFromCounts(required, null)); + assertEquals(0, GoldsmithMath.hitPercent(required, Map.of())); + assertEquals(0, GoldsmithMath.hitPercentFromCounts(required, Map.of())); + for (Map empty : + Arrays.asList(null, Map.of(), Map.of(hit, 0), Map.of(hit, -1))) { + assertEquals(0, GoldsmithMath.hitPercent(empty, null)); + assertEquals(0, GoldsmithMath.hitPercentFromCounts(empty, null)); + } + Map nullRequired = new HashMap<>(); + nullRequired.put(hit, null); + assertEquals(0, GoldsmithMath.hitPercent(nullRequired, null)); + assertEquals(0, GoldsmithMath.hitPercentFromCounts(nullRequired, null)); + GoldsmithHit other = mock(GoldsmithHit.class); + assertEquals( + 75, + GoldsmithMath.hitPercent( + Map.of(hit, 10, other, 2), Map.of(hit, count(5), other, count(2)))); + assertEquals( + 75, + GoldsmithMath.hitPercentFromCounts(Map.of(hit, 10, other, 2), Map.of(hit, 5, other, 2))); + } + + @Test + void finishUsesWeakerComponentAndMinimumThresholdIsInclusive() { + double old = GoldsmithCache.minHitPercent; + try { + GoldsmithCache.minHitPercent = .6; + assertEquals(40, GoldsmithMath.finishedTotal(80, 40)); + assertEquals(100, GoldsmithMath.finishedTotal(150, 200)); + assertEquals(0, GoldsmithMath.finishedTotal(-1, 50)); + assertFalse(GoldsmithMath.meetsMinHitPercent(59)); + assertTrue(GoldsmithMath.meetsMinHitPercent(60)); + assertTrue(GoldsmithMath.meetsMinHitPercent(120)); + } finally { + GoldsmithCache.minHitPercent = old; + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java new file mode 100644 index 0000000..09bc2bd --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java @@ -0,0 +1,510 @@ +package net.tfminecraft.geminfusion.goldsmith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.geminfusion.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.*; +import net.tfminecraft.tlibs.objects.utils.IntCounter; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.block.*; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.*; + +class GoldsmithStationManagerTest { + ServerMock server; + World world; + Location loc; + Player player; + PlayerInventory inventory; + Block block; + GoldsmithStationManager manager; + GoldsmithStation station; + JewelryProject project; + ItemStack hand; + ItemAPI items; + BlockAPI blocks; + MockedStatic libs; + MockedStatic perms; + MockedStatic store; + MockedStatic hits; + MockedStatic materials; + MockedStatic validator; + MockedConstruction menus; + InfusionMain previous; + String oldStation; + String oldTool; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = server.addSimpleWorld("world"); + loc = new Location(world, 1, 2, 3); + block = world.getBlockAt(loc); + previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.getName()).thenReturn("GemInfusion"); + when(InfusionMain.plugin.namespace()).thenReturn("geminfusion"); + player = mock(Player.class); + inventory = mock(PlayerInventory.class); + hand = new ItemStack(Material.STICK); + when(player.getInventory()).thenReturn(inventory); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getLocation()).thenReturn(loc); + when(player.getWorld()).thenReturn(world); + when(inventory.getItemInMainHand()).thenReturn(hand); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + blocks = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + libs = mockStatic(TLibs.class); + libs.when(TLibs::getItemAPI).thenReturn(items); + libs.when(TLibs::getBlockAPI).thenReturn(blocks); + perms = mockStatic(Permissions.class); + perms.when(() -> Permissions.canUseGoldsmith(player)).thenReturn(true); + store = mockStatic(GoldsmithStationStore.class); + hits = mockStatic(GoldsmithHitLoader.class); + materials = mockStatic(GoldsmithMaterialLoader.class); + validator = mockStatic(InfusedGemValidator.class); + menus = mockConstruction(GoldsmithInventoryManager.class); + manager = new GoldsmithStationManager(); + station = mock(GoldsmithStation.class); + project = mock(JewelryProject.class); + when(station.getLoc()).thenReturn(loc); + when(station.hasProject()).thenReturn(true); + when(station.getProject()).thenReturn(project); + when(project.getName()).thenReturn("Ring"); + oldStation = GoldsmithCache.station; + oldTool = GoldsmithCache.brandingTool; + GoldsmithCache.brandingTool = "branding"; + // Reuse configured block representation if set; tests use a controlled checker. + GoldsmithCache.station = "minecraft.SMITHING_TABLE"; + when(blocks.getChecker().checkBlock(eq(block), any())).thenReturn(true); + } + + @AfterEach + void cleanup() { + menus.close(); + validator.close(); + materials.close(); + hits.close(); + store.close(); + perms.close(); + libs.close(); + GoldsmithCache.station = oldStation; + GoldsmithCache.brandingTool = oldTool; + InfusionMain.plugin = previous; + MockBukkit.unmock(); + } + + PlayerInteractEvent click(Action action) { + return new PlayerInteractEvent(player, action, hand, block, BlockFace.UP, EquipmentSlot.HAND); + } + + void reset() { + manager.clear(); + manager.put(station); + } + + void branding(boolean value) { + when(items.getChecker().checkItemWithPath(hand, "branding")).thenReturn(value); + } + + @Test + void lookupNormalizesLocationsAndLifecyclePersistsOnlyWhenDirty() { + assertNull(manager.get(null)); + assertNull(manager.getOrCreate(null)); + assertNull(manager.remove(null)); + assertNull(GoldsmithStationManager.key(null)); + assertEquals( + new Location(null, -1, 2, 3), + GoldsmithStationManager.key(new Location(null, -.1, 2.9, 3.9))); + GoldsmithStation created = manager.getOrCreate(loc.clone().add(.5, .5, .5)); + assertSame(created, manager.get(loc)); + assertSame(created, manager.getOrCreate(loc)); + manager.put(null); + GoldsmithStation noLocation = mock(GoldsmithStation.class); + manager.put(noLocation); + assertEquals(1, manager.getStations().size()); + manager.flush(false); + store.verifyNoInteractions(); + manager.markDirty(); + manager.flush(false); + store.verify(() -> GoldsmithStationStore.saveAll(any()), times(1)); + manager.flush(false); + manager.flush(true); + store.verify(() -> GoldsmithStationStore.saveAll(any()), times(2)); + assertSame(created, manager.remove(loc)); + assertNull(manager.remove(loc)); + store.verify(() -> GoldsmithStationStore.delete(loc)); + store.when(GoldsmithStationStore::loadAll).thenReturn(List.of(station)); + manager.loadPersisted(); + assertSame(station, manager.get(loc)); + manager.clear(); + assertTrue(manager.getStations().isEmpty()); + } + + @Test + void irrelevantInteractionsAndMissingStationConfigurationAreIgnored() { + assertFalse(manager.isGoldsmithStation(null)); + GoldsmithCache.station = null; + assertFalse(manager.isGoldsmithStation(block)); + manager.onInteract( + new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, hand, null, BlockFace.UP)); + manager.onInteract(click(Action.PHYSICAL)); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(player, never()).sendMessage(anyString()); + assertTrue(manager.getStations().isEmpty()); + } + + @Test + void rightClickOpensProjectMenuAndCooldownPreventsDuplicateAction() { + PlayerInteractEvent click = click(Action.RIGHT_CLICK_BLOCK); + manager.onInteract(click); + manager.onInteract(click); + assertTrue(click.isCancelled()); + assertNotNull(manager.get(loc)); + verify(menus.constructed().getFirst()).openMenu(player); + verify(player).sendMessage(contains("No goldsmithing projects")); + manager.clear(); + when(menus.constructed().getFirst().openMenu(player)).thenReturn(2); + manager.onInteract(click); + verify(menus.constructed().getFirst(), times(2)).openMenu(player); + } + + @Test + void existingEmptyBenchReopensMenuAfterCooldownExpires() throws Exception { + manager.getOrCreate(loc); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + Thread.sleep(225); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(menus.constructed().getFirst(), times(2)).openMenu(player); + } + + @Test + void permissionDenialsAreCancelledAndThrottledForBothActions() { + perms.when(() -> Permissions.canUseGoldsmith(player)).thenReturn(false); + PlayerInteractEvent right = click(Action.RIGHT_CLICK_BLOCK); + manager.onInteract(right); + manager.onInteract(right); + assertTrue(right.isCancelled()); + verify(player, times(1)).sendMessage(contains("permission")); + manager.clear(); + branding(true); + PlayerInteractEvent left = click(Action.LEFT_CLICK_BLOCK); + manager.onInteract(left); + manager.onInteract(left); + assertTrue(left.isCancelled()); + verify(player, times(2)).sendMessage(contains("permission")); + manager.clear(); + branding(false); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + verify(player, times(2)).sendMessage(contains("permission")); + } + + @Test + void brandingShowsRecipeMaterialsGemAndHitStatus() { + manager.put(station); + branding(true); + when(project.requiresGem()).thenReturn(true); + when(station.hasGem()).thenReturn(true); + IntCounter counter = new IntCounter(); + counter.setNeeded(2); + counter.setCurrent(1); + when(station.getTypes()).thenReturn(Map.of("gold", counter)); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(player).sendMessage("§7gem: §e1/1"); + reset(); + when(station.hasGem()).thenReturn(false); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(player).sendMessage("§7gem: §e0/1"); + reset(); + when(project.requiresGem()).thenReturn(false); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(player, times(3)).sendMessage("§7Project: Ring"); + } + + @Test + void gemDepositsReportEveryFeedbackAndOnlyConsumeOnSuccess() { + when(project.requiresGem()).thenReturn(true); + validator.when(() -> InfusedGemValidator.isGemstoneCandidate(hand)).thenReturn(true); + for (GoldsmithFeedback feedback : GoldsmithFeedback.values()) { + reset(); + hand.setAmount(3); + when(station.addGem(hand)).thenReturn(feedback); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + assertEquals( + feedback == GoldsmithFeedback.SUCCESS ? 2 : 3, hand.getAmount(), feedback.name()); + } + } + + @Test + void materialDepositsResolveMatchingItemsAndConsumeOnlyOnSuccess() { + GoldsmithMaterial material = mock(GoldsmithMaterial.class); + when(material.getPath()).thenReturn("gold"); + when(material.getType()).thenReturn("gold"); + when(material.getName()).thenReturn("Gold"); + materials + .when(GoldsmithMaterialLoader::get) + .thenReturn(new LinkedHashMap<>(Map.of("gold", material))); + reset(); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(station, never()).addMaterial(any(), any()); + when(items.getChecker().checkItemWithPath(hand, "gold")).thenReturn(true); + for (GoldsmithFeedback feedback : GoldsmithFeedback.values()) { + reset(); + hand.setAmount(3); + when(station.addMaterial(material, hand)).thenReturn(feedback); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + assertEquals( + feedback == GoldsmithFeedback.SUCCESS ? 2 : 3, hand.getAmount(), feedback.name()); + } + IntCounter counter = new IntCounter(); + counter.setNeeded(2); + counter.setCurrent(1); + when(station.getTypes()).thenReturn(Map.of("gold", counter)); + reset(); + when(station.addMaterial(material, hand)).thenReturn(GoldsmithFeedback.SUCCESS); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + reset(); + when(inventory.getItemInMainHand()).thenReturn(null); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR)); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + } + + @Test + void toolHitsReportProgressWarningsAndFeedback() { + GoldsmithHit hit = mock(GoldsmithHit.class); + hits.when(() -> GoldsmithHitLoader.getByItem(hand)).thenReturn(hit); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + verify(player).sendMessage(contains("choose a project")); + reset(); + when(station.hasProject()).thenReturn(false); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + when(station.hasProject()).thenReturn(true); + for (GoldsmithFeedback feedback : GoldsmithFeedback.values()) { + reset(); + when(station.hit(hit)).thenReturn(feedback); + when(station.markOverworkWarnedIfNeeded()).thenReturn(feedback == GoldsmithFeedback.SUCCESS); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + } + reset(); + when(station.hit(hit)).thenReturn(GoldsmithFeedback.SUCCESS); + when(station.markOverworkWarnedIfNeeded()).thenReturn(false); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + verify(station, times(GoldsmithFeedback.values().length + 1)).hit(hit); + } + + @Test + void brandingCancellationRefundsItemsAndDropsInventoryOverflow() { + reset(); + branding(true); + when(player.isSneaking()).thenReturn(true); + ItemStack refund = new ItemStack(Material.GOLD_INGOT); + when(station.cancel()).thenReturn(List.of(refund)); + when(inventory.addItem(refund)).thenReturn(new HashMap<>(Map.of(0, refund))); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + assertNull(manager.get(loc)); + verify(inventory).addItem(refund); + assertEquals(1, world.getEntitiesByClass(org.bukkit.entity.Item.class).size()); + } + + @Test + void finishingReportsMissingRequirementsAndFailedOutputWithoutClearingStation() { + branding(true); + for (GoldsmithFeedback feedback : + List.of( + GoldsmithFeedback.LACKING_ITEMS, + GoldsmithFeedback.LACKING_HITS, + GoldsmithFeedback.NOT_INFUSED)) { + reset(); + when(station.canFinish()).thenReturn(feedback); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + assertSame(station, manager.get(loc)); + } + try (var output = mockStatic(JewelryOutput.class)) { + reset(); + when(station.canFinish()).thenReturn(GoldsmithFeedback.SUCCESS); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + assertSame(station, manager.get(loc)); + verify(station, never()).cancel(); + verify(player).sendMessage(contains("Could not create")); + } + } + + @Test + void successfulCraftDropsOutputAndClearsProjectWithOptionalQuality() { + branding(true); + when(station.canFinish()).thenReturn(GoldsmithFeedback.SUCCESS); + Quality quality = mock(Quality.class); + when(quality.getName()).thenReturn("Fine"); + try (var output = mockStatic(JewelryOutput.class)) { + for (Quality chosen : Arrays.asList(null, quality)) { + reset(); + JewelryCraftResult result = + new JewelryCraftResult(new ItemStack(Material.DIAMOND), 90, 80, 80, 60, chosen); + output.when(() -> JewelryOutput.build(station, player)).thenReturn(result); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + assertNull(manager.get(loc)); + } + assertEquals(2, world.getEntitiesByClass(org.bukkit.entity.Item.class).size()); + verify(station, times(2)).cancel(); + verify(player).sendMessage("§7Quality: Fine"); + } + } + + @Test + void breakingActiveStationRefundsProjectAndRemovesPersistence() { + reset(); + ItemStack refund = new ItemStack(Material.GOLD_INGOT); + when(station.cancel()).thenReturn(List.of(refund)); + manager.onBreak(new BlockBreakEvent(block, player)); + assertNull(manager.get(loc)); + verify(inventory).addItem(refund); + store.verify(() -> GoldsmithStationStore.delete(loc)); + manager.onBreak(new BlockBreakEvent(block, player)); + reset(); + when(station.hasProject()).thenReturn(false); + manager.onBreak(new BlockBreakEvent(block, player)); + verify(station, times(1)).cancel(); + } + + @Test + void breakWithoutPlayerDropsRefundAtBenchAndUnrelatedBlocksAreIgnored() { + reset(); + ItemStack refund = new ItemStack(Material.GOLD_INGOT); + when(station.cancel()).thenReturn(List.of(refund)); + manager.onBreak(new BlockBreakEvent(block, null)); + assertNull(manager.get(loc)); + assertEquals(1, world.getEntitiesByClass(org.bukkit.entity.Item.class).size()); + when(blocks.getChecker().checkBlock(eq(block), any())).thenReturn(false); + manager.onBreak(new BlockBreakEvent(block, player)); + verify(station, times(1)).cancel(); + } + + @Test + void emptyHandsAreNotStationToolsAndNoncandidateGemsFallThrough() { + reset(); + when(inventory.getItemInMainHand()).thenReturn(null); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR)); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + when(inventory.getItemInMainHand()).thenReturn(hand); + when(project.requiresGem()).thenReturn(true); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + verify(station, never()).addGem(any()); + } + + @Test + void removingBenchInvalidatesItsOpenMenuButPreservesOtherBenchMenus() { + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + Player second = mock(Player.class); + when(second.getUniqueId()).thenReturn(UUID.randomUUID()); + when(second.getInventory()).thenReturn(inventory); + perms.when(() -> Permissions.canUseGoldsmith(second)).thenReturn(true); + Block other = world.getBlockAt(4, 2, 3); + when(blocks.getChecker().checkBlock(eq(other), any())).thenReturn(true); + manager.onInteract( + new PlayerInteractEvent( + second, Action.RIGHT_CLICK_BLOCK, hand, other, BlockFace.UP, EquipmentSlot.HAND)); + manager.remove(loc); + assertNotNull(manager.get(other.getLocation())); + InventoryClickEvent event = mock(InventoryClickEvent.class); + InventoryView view = mock(InventoryView.class); + when(event.getView()).thenReturn(view); + when(view.getTitle()).thenReturn(GoldsmithInventoryManager.TITLE); + ItemStack icon = new ItemStack(Material.DIAMOND); + var meta = icon.getItemMeta(); + meta.getPersistentDataContainer() + .set( + new NamespacedKey(InfusionMain.plugin, "gi_project"), + PersistentDataType.STRING, + "ring"); + icon.setItemMeta(meta); + when(event.getCurrentItem()).thenReturn(icon); + try (var projects = mockStatic(JewelryProjectLoader.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + when(event.getWhoClicked()).thenReturn(player); + manager.onMenuClick(event); + verify(player).sendMessage(contains("no longer available")); + when(event.getWhoClicked()).thenReturn(second); + manager.onMenuClick(event); + assertSame(project, manager.get(other.getLocation()).getProject()); + } + } + + @Test + void cancelledBreakDoesNotClearOrRefundProtectedStation() throws Exception { + reset(); + BlockBreakEvent event = new BlockBreakEvent(block, player); + event.setCancelled(true); + var handler = + GoldsmithStationManager.class + .getMethod("onBreak", BlockBreakEvent.class) + .getAnnotation(org.bukkit.event.EventHandler.class); + if (!handler.ignoreCancelled()) manager.onBreak(event); + assertSame(station, manager.get(loc), "A protected station must survive a cancelled break"); + verify(station, never()).cancel(); + } + + @Test + void menuSelectionRequiresMatchingTitlePermissionMetadataProjectAndBench() { + InventoryClickEvent event = mock(InventoryClickEvent.class); + InventoryView view = mock(InventoryView.class); + when(event.getView()).thenReturn(view); + when(view.getTitle()).thenReturn("other"); + manager.onMenuClick(event); + verify(event, never()).setCancelled(true); + when(view.getTitle()).thenReturn(GoldsmithInventoryManager.TITLE); + manager.onMenuClick(event); + verify(event).setCancelled(true); + when(event.getWhoClicked()).thenReturn(player); + perms.when(() -> Permissions.canUseGoldsmith(player)).thenReturn(false); + manager.onMenuClick(event); + verify(player).closeInventory(); + perms.when(() -> Permissions.canUseGoldsmith(player)).thenReturn(true); + manager.onMenuClick(event); + ItemStack withoutMeta = mock(ItemStack.class); + when(event.getCurrentItem()).thenReturn(withoutMeta); + manager.onMenuClick(event); + ItemStack icon = new ItemStack(Material.DIAMOND); + var meta = icon.getItemMeta(); + meta.setDisplayName("Ring"); + icon.setItemMeta(meta); + when(event.getCurrentItem()).thenReturn(icon); + manager.onMenuClick(event); + meta.getPersistentDataContainer() + .set( + new NamespacedKey(InfusionMain.plugin, "gi_project"), + PersistentDataType.STRING, + "ring"); + icon.setItemMeta(meta); + try (var projects = mockStatic(JewelryProjectLoader.class)) { + manager.onMenuClick(event); + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + manager.onMenuClick(event); + verify(player).sendMessage(contains("no longer available")); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + GoldsmithStation actual = manager.get(loc); + actual.setProject(project); + manager.onMenuClick(event); + verify(player).sendMessage(contains("already has a project")); + manager.clear(); + manager.onInteract(click(Action.RIGHT_CLICK_BLOCK)); + manager.onMenuClick(event); + assertSame(project, manager.get(loc).getProject()); + verify(player).sendMessage(contains("Selected")); + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStoreTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStoreTest.java new file mode 100644 index 0000000..faea568 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationStoreTest.java @@ -0,0 +1,364 @@ +package net.tfminecraft.geminfusion.goldsmith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.geminfusion.InfusionMain; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class GoldsmithStationStoreTest { + @TempDir Path temp; + ServerMock server; + World world; + InfusionMain previous; + JewelryProject project; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = server.addSimpleWorld("world"); + previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.getDataFolder()).thenReturn(temp.toFile()); + when(InfusionMain.plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + project = mock(JewelryProject.class); + when(project.getId()).thenReturn("ring"); + when(project.getMaterialsByType()).thenReturn(Map.of("gold", 1)); + } + + @AfterEach + void cleanup() { + InfusionMain.plugin = previous; + MockBukkit.unmock(); + } + + Path file(String name, String contents) throws IOException { + Path folder = GoldsmithStationStore.folder().toPath(); + Files.createDirectories(folder); + return Files.writeString(folder.resolve(name), contents); + } + + String data(String rest) { + return "{\"world\":\"world\",\"project\":\"ring\",\"x\":1,\"y\":2,\"z\":3" + rest + "}"; + } + + @Test + void filenamesNormalizeWorldAndBlockCoordinatesAndDeleteIsIdempotent() throws Exception { + World oddlyNamed = mock(World.class); + when(oddlyNamed.getName()).thenReturn("world / !"); + assertEquals( + "world_____-1_2_3.json", + GoldsmithStationStore.fileName(new Location(oddlyNamed, -.1, 2.9, 3))); + Location loc = new Location(null, 1, 2, 3); + assertEquals("unknown_1_2_3.json", GoldsmithStationStore.fileName(loc)); + Path path = file("unknown_1_2_3.json", "{}"); + GoldsmithStationStore.delete(null); + GoldsmithStationStore.delete(loc); + assertFalse(Files.exists(path)); + GoldsmithStationStore.delete(loc); + } + + @Test + void savesAndRestoresRealItemStacksProgressAndGem() throws Exception { + GoldsmithMaterial material = mock(GoldsmithMaterial.class); + GoldsmithHit hit = mock(GoldsmithHit.class); + GoldsmithHitType type = mock(GoldsmithHitType.class); + when(material.getId()).thenReturn("gold"); + when(material.getType()).thenReturn("gold"); + when(material.getHits()).thenReturn(Map.of(hit, 2)); + when(hit.getId()).thenReturn("hammer"); + when(hit.getType()).thenReturn(type); + when(project.requiresGem()).thenReturn(true); + GoldsmithStation station = new GoldsmithStation(new Location(world, 1, 2, 3)); + station.setProject(project); + ItemStack metal = new ItemStack(Material.GOLD_INGOT, 12), + gem = new ItemStack(Material.DIAMOND, 3); + try (var projects = mockStatic(JewelryProjectLoader.class); + var materials = mockStatic(GoldsmithMaterialLoader.class); + var hits = mockStatic(GoldsmithHitLoader.class); + var validator = mockStatic(InfusedGemValidator.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + materials.when(() -> GoldsmithMaterialLoader.getByString("gold")).thenReturn(material); + hits.when(() -> GoldsmithHitLoader.getByString("hammer")).thenReturn(hit); + validator.when(() -> InfusedGemValidator.isInfused(any())).thenReturn(true); + station.addMaterial(material, metal); + station.addGem(gem); + station.hit(hit); + GoldsmithStationStore.saveAll(List.of(station)); + Path stored = GoldsmithStationStore.fileFor(station.getLoc()).toPath(); + assertTrue(Files.exists(stored)); + assertTrue(Files.readString(stored).contains("hammer")); + List loaded = GoldsmithStationStore.loadAll(); + assertEquals(1, loaded.size()); + GoldsmithStation restored = loaded.getFirst(); + assertEquals(station.getLoc(), restored.getLoc()); + assertSame(project, restored.getProject()); + assertEquals(1, restored.getTotalHitCount()); + assertEquals(1, restored.getDepositedByMaterial().get(material)); + assertEquals(List.of(new ItemStack(Material.GOLD_INGOT)), restored.getDeposited()); + assertEquals(new ItemStack(Material.DIAMOND), restored.getGem()); + assertEquals(12, metal.getAmount()); + assertEquals(3, gem.getAmount()); + } + } + + @Test + void savesSkipInactiveStationsAndRemoveOnlyObsoleteJsonFiles() throws Exception { + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + Path old = file("old.json", "{}"), note = file("note.txt", "keep"); + Path sub = GoldsmithStationStore.folder().toPath().resolve("sub.json"); + Files.createDirectory(sub); + GoldsmithStation empty = new GoldsmithStation(new Location(world, 0, 0, 0)); + GoldsmithStation noLocation = new GoldsmithStation(null); + noLocation.setProject(project); + GoldsmithStation noWorld = new GoldsmithStation(new Location(null, 0, 0, 0)); + noWorld.setProject(project); + GoldsmithStationStore.saveAll(Arrays.asList(null, empty, noLocation, noWorld)); + assertFalse(Files.exists(old)); + assertTrue(Files.exists(note)); + assertTrue(Files.isDirectory(sub)); + GoldsmithStationStore.saveAll(null); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + } + + @Test + void incompleteOrUnavailableRecordsStayOnDiskAndDoNotRestore() throws Exception { + List paths = + List.of( + file("null.json", "null"), + file("missing-world.json", "{\"project\":\"ring\"}"), + file("missing-project.json", "{\"world\":\"world\"}"), + file("unknown-world.json", "{\"world\":\"absent\",\"project\":\"ring\"}"), + file("unknown-project.json", data(""))); + try (var log = mockStatic(GoldsmithLog.class); + var projects = mockStatic(JewelryProjectLoader.class)) { + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + for (Path path : paths) assertTrue(Files.exists(path)); + log.verify(() -> GoldsmithLog.warn(anyString()), times(5)); + } + } + + @Test + void invalidAndNonItemSerializedValuesAreSkippedWithoutLosingStation() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject("not an item"); + } + String nonItem = Base64.getEncoder().encodeToString(bytes.toByteArray()); + file( + "station.json", + data( + ",\"deposited\":[null,\"\",\"not-base64!\",\"AA==\",\"" + + nonItem + + "\"],\"gem\":\" \"")); + try (var projects = mockStatic(JewelryProjectLoader.class); + var log = mockStatic(GoldsmithLog.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + List loaded = GoldsmithStationStore.loadAll(); + assertEquals(1, loaded.size()); + assertTrue(loaded.getFirst().getDeposited().isEmpty()); + assertFalse(loaded.getFirst().hasGem()); + file("station.json", data(",\"deposited\":null")); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + } + } + + @Test + void nonDirectoryStoragePathDoesNotThrow() throws Exception { + Files.createDirectories(GoldsmithStationStore.folder().toPath().getParent()); + Files.writeString(GoldsmithStationStore.folder().toPath(), "not a directory"); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + GoldsmithStation station = new GoldsmithStation(new Location(world, 1, 2, 3)); + station.setProject(project); + try (var log = mockStatic(GoldsmithLog.class)) { + GoldsmithStationStore.saveAll(List.of(station)); + log.verify(() -> GoldsmithLog.warn(contains("Failed to save"))); + } + } + + @Test + void failedDeletionIsLoggedAndDoesNotRemoveContents() throws Exception { + Location loc = new Location(world, 1, 2, 3); + Path dir = GoldsmithStationStore.fileFor(loc).toPath(); + Files.createDirectories(dir); + Path child = Files.writeString(dir.resolve("child"), "keep"); + try (var log = mockStatic(GoldsmithLog.class)) { + GoldsmithStationStore.delete(loc); + assertTrue(Files.exists(child)); + log.verify(() -> GoldsmithLog.warn(contains("Failed to delete goldsmith station"))); + } + } + + @Test + void unserializableDepositedItemIsLoggedAndSkipped() throws Exception { + GoldsmithStation station = mock(GoldsmithStation.class); + ItemStack broken = mock(ItemStack.class); + when(station.hasProject()).thenReturn(true); + when(station.getProject()).thenReturn(project); + when(station.getLoc()).thenReturn(new Location(world, 1, 2, 3)); + when(broken.serialize()).thenReturn(Map.of("invalid", new Object())); + when(station.getDeposited()).thenReturn(Arrays.asList(null, broken)); + try (var log = mockStatic(GoldsmithLog.class)) { + GoldsmithStationStore.saveAll(List.of(station)); + assertTrue(GoldsmithStationStore.fileFor(station.getLoc()).exists()); + log.verify(() -> GoldsmithLog.warn(contains("Failed to encode goldsmith item"))); + } + } + + @Test + void filesystemPermissionsProtectFailedReadAndReportFailedCleanup() throws Exception { + Path denied = file("denied.json", data("")); + var original = Files.getPosixFilePermissions(denied); + try (var log = mockStatic(GoldsmithLog.class)) { + try { + Files.setPosixFilePermissions(denied, Set.of()); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + log.verify(() -> GoldsmithLog.warn(contains("Failed to read"))); + } finally { + Files.setPosixFilePermissions(denied, original); + } + Files.delete(denied); + GoldsmithStationStore.loadAll(); + Path stale = file("stale.json", "{}"); + Path dir = stale.getParent(); + var permissions = Files.getPosixFilePermissions(dir); + try { + Files.setPosixFilePermissions( + dir, java.nio.file.attribute.PosixFilePermissions.fromString("r-x------")); + GoldsmithStationStore.saveAll(List.of()); + assertTrue(Files.exists(stale)); + log.verify(() -> GoldsmithLog.warn(contains("Failed to delete leftover"))); + } finally { + Files.setPosixFilePermissions(dir, permissions); + } + } + } + + @Test + void malformedJsonIsIgnoredSoOtherStationsCanStillLoad() throws Exception { + Path broken = file("broken.json", "{"); + file("valid.json", data("")); + try (var projects = mockStatic(JewelryProjectLoader.class); + var log = mockStatic(GoldsmithLog.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + assertTrue(Files.exists(broken)); + GoldsmithStationStore.saveAll(List.of()); + assertEquals("{", Files.readString(broken)); + Files.writeString(broken, data("")); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + GoldsmithStationStore.saveAll(List.of()); + assertFalse(Files.exists(broken)); + } + } + + @Test + void replacementStationQuarantinesRejectedBytesAndSurvivesDeleteRecreate() throws Exception { + Location loc = new Location(world, 4, 5, 6); + byte[] rejected = new byte[] {'{', 0, (byte) 0xff}; + Path stored = file(GoldsmithStationStore.fileName(loc), ""); + Files.write(stored, rejected); + GoldsmithMaterial material = mock(GoldsmithMaterial.class); + when(material.getId()).thenReturn("gold"); + when(material.getType()).thenReturn("gold"); + try (var projects = mockStatic(JewelryProjectLoader.class); + var materials = mockStatic(GoldsmithMaterialLoader.class); + var log = mockStatic(GoldsmithLog.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + materials.when(() -> GoldsmithMaterialLoader.getByString("gold")).thenReturn(material); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + GoldsmithStationStore.delete(loc); + assertArrayEquals(rejected, Files.readAllBytes(stored)); + GoldsmithStation replacement = new GoldsmithStation(loc); + replacement.setProject(project); + replacement.addMaterial(material, new ItemStack(Material.GOLD_INGOT)); + GoldsmithStationStore.saveAll(List.of(replacement)); + List backups; + try (var paths = Files.list(stored.getParent())) { + backups = paths.filter(path -> !path.equals(stored)).toList(); + } + assertEquals(1, backups.size()); + Path backup = backups.getFirst(); + assertFalse(backup.getFileName().toString().endsWith(".json")); + assertArrayEquals(rejected, Files.readAllBytes(backup)); + GoldsmithStation restored = GoldsmithStationStore.loadAll().getFirst(); + assertEquals(loc, restored.getLoc()); + assertEquals(List.of(new ItemStack(Material.GOLD_INGOT)), restored.getDeposited()); + assertEquals(1, restored.getDepositedByMaterial().get(material)); + GoldsmithStationStore.delete(loc); + assertFalse(Files.exists(stored)); + GoldsmithStationStore.saveAll(List.of(replacement)); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + assertArrayEquals(rejected, Files.readAllBytes(backup)); + // A later rejected file at the same coordinates gets its own backup. + Files.writeString(stored, "{ second rejected record"); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + GoldsmithStationStore.saveAll(List.of(replacement)); + try (var paths = Files.list(stored.getParent())) { + assertEquals(3, paths.count()); + } + assertArrayEquals(rejected, Files.readAllBytes(backup)); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + GoldsmithStationStore.saveAll(List.of()); + assertFalse(Files.exists(stored)); + assertTrue(Files.exists(backup)); + } + } + + @Test + void failedQuarantinePreservesRejectedFileAndRetriesAfterPermissionsRestore() throws Exception { + Location loc = new Location(world, 1, 2, 3); + Path stored = file(GoldsmithStationStore.fileName(loc), "{ original"); + GoldsmithStation station = new GoldsmithStation(loc); + station.setProject(project); + Path dir = stored.getParent(); + var permissions = Files.getPosixFilePermissions(dir); + try (var log = mockStatic(GoldsmithLog.class); + var projects = mockStatic(JewelryProjectLoader.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + try { + Files.setPosixFilePermissions( + dir, java.nio.file.attribute.PosixFilePermissions.fromString("r-x------")); + GoldsmithStationStore.saveAll(List.of(station)); + assertEquals("{ original", Files.readString(stored)); + log.verify(() -> GoldsmithLog.warn(contains("Failed to quarantine"))); + GoldsmithStationStore.delete(loc); + assertEquals("{ original", Files.readString(stored)); + } finally { + Files.setPosixFilePermissions(dir, permissions); + } + GoldsmithStationStore.saveAll(List.of(station)); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + try (var paths = Files.list(dir)) { + Path backup = paths.filter(path -> !path.equals(stored)).findFirst().orElseThrow(); + assertEquals("{ original", Files.readString(backup)); + } + } + } + + @Test + void externallyRemovedRejectedFileDoesNotPreventSavingReplacement() throws Exception { + Location loc = new Location(world, 1, 2, 3); + Path stored = file(GoldsmithStationStore.fileName(loc), "{"); + assertTrue(GoldsmithStationStore.loadAll().isEmpty()); + Files.delete(stored); + GoldsmithStation station = new GoldsmithStation(loc); + station.setProject(project); + GoldsmithStationStore.saveAll(List.of(station)); + try (var projects = mockStatic(JewelryProjectLoader.class)) { + projects.when(() -> JewelryProjectLoader.getByString("ring")).thenReturn(project); + assertEquals(1, GoldsmithStationStore.loadAll().size()); + } + try (var paths = Files.list(stored.getParent())) { + assertEquals(1, paths.count()); + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationTest.java new file mode 100644 index 0000000..a822ba1 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationTest.java @@ -0,0 +1,234 @@ +package net.tfminecraft.geminfusion.goldsmith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.Location; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; + +class GoldsmithStationTest { + private final Location location = new Location(null, 1, 2, 3); + private final GoldsmithStation station = new GoldsmithStation(location); + private final JewelryProject project = mock(JewelryProject.class); + private final GoldsmithMaterial material = mock(GoldsmithMaterial.class); + private final GoldsmithHit hit = mock(GoldsmithHit.class); + private final GoldsmithHitType type = mock(GoldsmithHitType.class); + private double oldMinimum, oldWarning; + + @BeforeEach + void setup() { + oldMinimum = GoldsmithCache.minHitPercent; + oldWarning = GoldsmithCache.hitOvershootWarnPercent; + GoldsmithCache.minHitPercent = .5; + GoldsmithCache.hitOvershootWarnPercent = 50; + when(project.getMaterialsByType()).thenReturn(Map.of("gold", 1)); + when(project.getRecipe()).thenReturn(Map.of(material, 1)); + when(material.getType()).thenReturn("gold"); + when(material.getHits()).thenReturn(Map.of(hit, 2)); + when(hit.getType()).thenReturn(type); + } + + @AfterEach + void restore() { + GoldsmithCache.minHitPercent = oldMinimum; + GoldsmithCache.hitOvershootWarnPercent = oldWarning; + } + + private ItemStack stack() { + ItemStack s = mock(ItemStack.class), copy = mock(ItemStack.class); + when(s.clone()).thenReturn(copy); + when(copy.clone()).thenReturn(copy); + return s; + } + + private void ready() { + station.setProject(project); + assertEquals(GoldsmithFeedback.SUCCESS, station.addMaterial(material, null)); + } + + @Test + void emptyStationRejectsActionsAndProjectResetClearsAllState() { + assertSame(location, station.getLoc()); + assertNull(station.getProject()); + assertFalse(station.hasProject()); + assertNull(station.getGem()); + assertFalse(station.hasGem()); + assertFalse(station.checkItems()); + assertEquals(GoldsmithFeedback.NO_PROJECT, station.addMaterial(material, null)); + assertEquals(GoldsmithFeedback.NO_PROJECT, station.addGem(null)); + assertEquals(GoldsmithFeedback.NO_PROJECT, station.hit(hit)); + assertEquals(GoldsmithFeedback.NO_PROJECT, station.canFinish()); + assertEquals(0, station.getRecipePercent()); + assertEquals(0, station.getFinishedTotal()); + assertFalse(station.markOverworkWarnedIfNeeded()); + station.setProject(project); + assertTrue(station.hasProject()); + assertSame(project, station.getProject()); + assertFalse(station.checkItems()); + assertEquals(GoldsmithFeedback.LACKING_ITEMS, station.hit(hit)); + assertEquals(GoldsmithFeedback.LACKING_ITEMS, station.canFinish()); + assertEquals(GoldsmithFeedback.WRONG_TYPE, station.addMaterial(null, null)); + assertEquals( + GoldsmithFeedback.WRONG_TYPE, station.addMaterial(mock(GoldsmithMaterial.class), null)); + station.setProject(null); + assertTrue(station.getTypes().isEmpty()); + } + + @Test + void materialDepositsCopyOneItemEnforceTypeCapacityAndRefundOnce() { + station.setProject(project); + ItemStack input = stack(); + assertEquals(GoldsmithFeedback.SUCCESS, station.addMaterial(material, input)); + verify(input.clone()).setAmount(1); + assertEquals(List.of(input.clone()), station.getDeposited()); + assertEquals(Map.of(material, 1), station.getDepositedByMaterial()); + assertEquals(1, station.getTypes().get("gold").getCurrent()); + assertEquals(2, station.getTotalHitNeeded()); + assertEquals(100, station.getRecipePercent()); + assertEquals(2, station.getHitTypes().get(type).getNeeded()); + assertTrue(station.checkItems()); + assertEquals(GoldsmithFeedback.CAPACITY, station.addMaterial(material, input)); + assertThrows(UnsupportedOperationException.class, () -> station.getTypes().clear()); + assertThrows(UnsupportedOperationException.class, () -> station.getHits().clear()); + assertThrows(UnsupportedOperationException.class, () -> station.getHitTypes().clear()); + assertThrows(UnsupportedOperationException.class, () -> station.getDeposited().clear()); + assertThrows( + UnsupportedOperationException.class, () -> station.getDepositedByMaterial().clear()); + assertEquals(List.of(input.clone()), station.cancel()); + assertTrue(station.cancel().isEmpty()); + assertFalse(station.hasProject()); + assertEquals(0, station.getTotalHitNeeded()); + } + + @Test + void gemsRequireInfusionAreCopiedAndReturnedOnCancel() { + ItemStack gem = stack(); + station.setProject(project); + assertEquals(GoldsmithFeedback.WRONG_TYPE, station.addGem(gem)); + when(project.requiresGem()).thenReturn(true); + assertEquals(GoldsmithFeedback.WRONG_TYPE, station.addGem(null)); + try (var validator = mockStatic(InfusedGemValidator.class)) { + assertEquals(GoldsmithFeedback.NOT_INFUSED, station.addGem(gem)); + validator.when(() -> InfusedGemValidator.isInfused(gem)).thenReturn(true); + validator.when(() -> InfusedGemValidator.isInfused(gem.clone())).thenReturn(true); + assertEquals(GoldsmithFeedback.SUCCESS, station.addGem(gem)); + assertTrue(station.hasGem()); + assertSame(gem.clone(), station.getGem()); + assertEquals(GoldsmithFeedback.CAPACITY, station.addGem(gem)); + station.addMaterial(material, null); + assertTrue(station.checkItems()); + station.hit(hit); + assertEquals(GoldsmithFeedback.SUCCESS, station.canFinish()); + validator.when(() -> InfusedGemValidator.isInfused(gem.clone())).thenReturn(false); + assertFalse(station.checkItems()); + assertEquals(GoldsmithFeedback.NOT_INFUSED, station.canFinish()); + assertEquals(List.of(gem.clone()), station.cancel()); + } + ready(); + assertFalse(station.checkItems()); + assertEquals(GoldsmithFeedback.LACKING_ITEMS, station.canFinish()); + } + + @Test + void hitProgressOverworkAndThresholdsFollowDepositedMaterials() { + ready(); + assertEquals(GoldsmithFeedback.LACKING_HITS, station.canFinish()); + assertEquals(GoldsmithFeedback.WRONG_TYPE, station.hit(null)); + assertEquals(GoldsmithFeedback.WRONG_TYPE, station.hit(mock(GoldsmithHit.class))); + assertFalse(station.markOverworkWarnedIfNeeded()); + assertEquals(GoldsmithFeedback.SUCCESS, station.hit(hit)); + assertEquals(50, station.getHitPercent()); + assertEquals(50, station.getFinishedTotal()); + assertEquals(GoldsmithFeedback.SUCCESS, station.canFinish()); + station.hit(hit); + assertEquals(100, station.getHitPercent()); + station.hit(hit); + assertFalse(station.markOverworkWarnedIfNeeded()); + station.hit(hit); + assertTrue(station.markOverworkWarnedIfNeeded()); + assertTrue(station.isOverworkWarned()); + assertFalse(station.markOverworkWarnedIfNeeded()); + assertEquals(4, station.getTotalHitCount()); + assertEquals(4, station.getHitTypes().get(type).getCurrent()); + assertEquals(GoldsmithFeedback.LACKING_HITS, station.canFinish()); + station.cancel(); + assertFalse(station.isOverworkWarned()); + GoldsmithCache.hitOvershootWarnPercent = 0; + assertFalse(station.markOverworkWarnedIfNeeded()); + } + + @Test + void validUnneededToolsCountTowardTotalButNotRequiredHitPercent() { + ready(); + GoldsmithHit other = mock(GoldsmithHit.class); + when(other.getType()).thenReturn(mock(GoldsmithHitType.class)); + assertEquals(GoldsmithFeedback.SUCCESS, station.hit(other)); + assertEquals(1, station.getTotalHitCount()); + assertEquals(0, station.getHitPercent()); + } + + @Test + void restoringProgressRebuildsCountersAndCopiesGem() { + station.setProject(project); + ItemStack gem = stack(), deposited = stack(); + GoldsmithMaterial unrelated = mock(GoldsmithMaterial.class); + when(unrelated.getType()).thenReturn("silver"); + GoldsmithHit other = mock(GoldsmithHit.class), untyped = mock(GoldsmithHit.class); + when(other.getType()).thenReturn(mock(GoldsmithHitType.class)); + Map materials = new LinkedHashMap<>(); + materials.put("gold", 1); + materials.put("unknown", 1); + materials.put("null", null); + materials.put("silver", -1); + Map hits = new LinkedHashMap<>(); + hits.put("hit", 2); + hits.put("other", 3); + hits.put("untyped", 1); + hits.put("unknown", 1); + hits.put("null", null); + try (var ml = mockStatic(GoldsmithMaterialLoader.class); + var hl = mockStatic(GoldsmithHitLoader.class); + var validator = mockStatic(InfusedGemValidator.class); + var log = mockStatic(GoldsmithLog.class)) { + ml.when(() -> GoldsmithMaterialLoader.getByString("gold")).thenReturn(material); + ml.when(() -> GoldsmithMaterialLoader.getByString("null")).thenReturn(material); + ml.when(() -> GoldsmithMaterialLoader.getByString("silver")).thenReturn(unrelated); + hl.when(() -> GoldsmithHitLoader.getByString("hit")).thenReturn(hit); + hl.when(() -> GoldsmithHitLoader.getByString("null")).thenReturn(hit); + hl.when(() -> GoldsmithHitLoader.getByString("other")).thenReturn(other); + hl.when(() -> GoldsmithHitLoader.getByString("untyped")).thenReturn(untyped); + validator.when(() -> InfusedGemValidator.isInfused(gem)).thenReturn(true); + station.applySavedProgress(materials, hits, List.of(deposited), gem, true); + assertSame(gem.clone(), station.getGem()); + assertTrue(station.isOverworkWarned()); + assertEquals(100, station.getHitPercent()); + assertEquals(6, station.getTotalHitCount()); + assertEquals(2, station.getHitTypes().get(type).getCurrent()); + assertEquals(List.of(deposited), station.getDeposited()); + assertEquals(0, station.getDepositedByMaterial().get(unrelated)); + station.getTypes().get("gold").setNeeded(2); + assertEquals(GoldsmithFeedback.SUCCESS, station.addMaterial(material, null)); + assertEquals(2, station.getHitTypes().get(type).getCurrent()); + station.applySavedProgress(null, null, null, null, false); + assertFalse(station.hasGem()); + assertTrue(station.getDeposited().isEmpty()); + assertEquals(0, station.getTotalHitCount()); + validator.when(() -> InfusedGemValidator.isInfused(gem)).thenReturn(false); + station.applySavedProgress(null, null, null, gem, false); + assertFalse(station.hasGem()); + log.verify(() -> GoldsmithLog.warn(contains("Skipped restoring non-infused gem"))); + } + } + + @Test + void additionalMaterialRecomputesRequirementsWithoutLosingExistingHits() { + when(project.getMaterialsByType()).thenReturn(Map.of("gold", 2)); + station.setProject(project); + station.addMaterial(material, null); + station.addMaterial(material, null); + assertEquals(4, station.getHits().get(hit).getNeeded()); + assertEquals(4, station.getHitTypes().get(type).getNeeded()); + } +}