diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..1374bf7 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 code coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: code-coverage-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 87d147c..7fdf860 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -90,6 +90,14 @@ jobs: if-no-files-found: error retention-days: 30 + - name: Upload code coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: code-coverage-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + publish: needs: build runs-on: ubuntu-24.04 diff --git a/README.md b/README.md index e2413d5..1e70d5d 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,23 @@ Copyright (c) 2026 TF-Minecraft contributors. TF-Minecraft-authored material in this repository is licensed under the [Artistic License 2.0](LICENSE). Third-party dependencies and bundled material retain their own licenses. + +## Tests and coverage + +Use Java 21 and Maven 3.9. Install the pinned plugin dependencies with the +repository's existing `.github/scripts/prepare-release.sh` workflow, then run: + +```sh +mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false +``` + +The JUnit 5 suite uses MockBukkit for server state and Mockito at external plugin +boundaries. JaCoCo measures every production class; no production packages, +classes, or methods are excluded. `verify` requires **100% instruction, branch, +and line coverage**, and fails when any counter falls below that threshold. +The HTML report is `target/site/jacoco/index.html`; XML/CSV are alongside it. +Build and release CI upload the coverage report as an artifact, including failed +runs when a report was generated. + +Coverage proves the exercised Java behavior. It does not replace a live Paper +server integration check with the pinned ItemsAdder/MMOItems/TLibs versions. diff --git a/pom.xml b/pom.xml index 6a952f7..e6296fc 100644 --- a/pom.xml +++ b/pom.xml @@ -17,12 +17,32 @@ + - junit - junit - 4.13.2 + beer.devs + FastNbt-jar + 1.4.22 test + + 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 @@ -103,6 +123,56 @@ + + 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/advancedcrafting/AdvancedCrafting.java b/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java index cd7da6a..ecd964d 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/AdvancedCrafting.java @@ -146,6 +146,8 @@ public void createFolders() { if(!subFolder.exists()) subFolder.mkdir(); subFolder = new File(getDataFolder(), "colour-schemes"); if(!subFolder.exists()) subFolder.mkdir(); + subFolder = new File(getDataFolder(), "model-schemes"); + if(!subFolder.exists()) subFolder.mkdir(); subFolder = new File(getDataFolder(), "recipes"); if(!subFolder.exists()) subFolder.mkdir(); subFolder = new File(getDataFolder(), "data"); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyRecipeStore.java b/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyRecipeStore.java index c120b53..7f36222 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyRecipeStore.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyRecipeStore.java @@ -54,7 +54,7 @@ public AlloyRecipe getRecipeByResult(String resultId) { public void upsert(AlloyRecipe recipe, String resultId) { File indexFile = recipe.resolveIndexFile(root); File parent = indexFile.getParentFile(); - if (parent != null && !parent.exists()) { + if (!parent.exists()) { parent.mkdirs(); } try (PrintWriter writer = new PrintWriter(indexFile, StandardCharsets.UTF_8)) { @@ -122,7 +122,7 @@ public Map loadAllComboResults() { Map map = new LinkedHashMap<>(); for (File indexFile : listIndexFiles()) { IndexEntry entry = readIndexEntry(indexFile); - if (entry == null || entry.comboKey == null || entry.resultId == null) { + if (entry == null || entry.comboKey == null) { continue; } map.put(entry.comboKey, entry.resultId); @@ -199,10 +199,10 @@ private AlloyRecipe recipeFromIndexFile(File indexFile) { if (fileBase.equals(baseId)) { return new AlloyRecipe(baseId, java.util.List.of()); } - String catalystPart = fileBase.substring(baseId.length() + 2); if (!fileBase.startsWith(baseId + "__")) { return null; } + String catalystPart = fileBase.substring(baseId.length() + 2); return new AlloyRecipe(baseId, java.util.Arrays.asList(catalystPart.split("__"))); } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/database/Database.java b/src/main/java/net/tfminecraft/advancedcrafting/database/Database.java index bc5af0e..883740f 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/database/Database.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/database/Database.java @@ -6,6 +6,8 @@ import java.io.InputStreamReader; import java.io.PrintWriter; import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; import java.util.TreeMap; import java.util.UUID; @@ -19,6 +21,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import net.tfminecraft.advancedcrafting.AdvancedCrafting; import net.tfminecraft.advancedcrafting.loaders.HitLoader; import net.tfminecraft.advancedcrafting.loaders.RecipeLoader; import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe; @@ -27,15 +30,20 @@ public class Database { private JSONObject json; // org.json.simple + private final Set rejectedStationFiles = new HashSet<>(); JSONParser parser = new JSONParser(); public HashMap loadStations() { HashMap map = new HashMap<>(); - File folder = new File("plugins/AdvancedCrafting/data/stations"); - for(final File file : folder.listFiles()) { + File folder = new File(AdvancedCrafting.plugin.getDataFolder(), "data/stations"); + File[] files = folder.listFiles(); + if (files == null) return map; + for(final File file : files) { if(!file.isDirectory()) { try { json = (JSONObject) parser.parse(new InputStreamReader(new FileInputStream(file), "UTF-8")); - Location loc = new Location(Bukkit.getServer().getWorld((String) json.get("world")), (Double) json.get("xPos"),(Double) json.get("yPos"),(Double) json.get("zPos")); + org.bukkit.World world = Bukkit.getServer().getWorld((String) json.get("world")); + if (world == null) throw new IllegalStateException("Station world is not loaded: " + json.get("world")); + Location loc = new Location(world, (Double) json.get("xPos"),(Double) json.get("yPos"),(Double) json.get("zPos")); CraftingRecipe r = RecipeLoader.getByString((String) json.get("recipe")); HashMap materials = new HashMap<>(); int i = 0; @@ -58,8 +66,10 @@ public HashMap loadStations() { i++; } map.put(loc, new CraftingStation(loc, r, materials, hits)); + rejectedStationFiles.remove(file); } catch (Exception ex) { + rejectedStationFiles.add(file); ex.printStackTrace(); } } @@ -67,9 +77,11 @@ public HashMap loadStations() { return map; } public void clear() { - File folder = new File("plugins/AdvancedCrafting/data/stations"); - for(final File file : folder.listFiles()) { - if(!file.isDirectory()) { + File folder = new File(AdvancedCrafting.plugin.getDataFolder(), "data/stations"); + File[] files = folder.listFiles(); + if (files == null) return; + for(final File file : files) { + if(!file.isDirectory() && !rejectedStationFiles.contains(file)) { file.delete(); } } @@ -78,7 +90,12 @@ public void clear() { public void saveStation(CraftingStation s) { if(!s.hasRecipe()) return; try { - File file = new File("plugins/AdvancedCrafting/data/stations",UUID.randomUUID().toString()+".json"); + File folder = new File(AdvancedCrafting.plugin.getDataFolder(), "data/stations"); + folder.mkdirs(); + File file = new File(folder, UUID.randomUUID().toString()+".json"); + while (rejectedStationFiles.contains(file)) { + file = new File(folder, UUID.randomUUID().toString()+".json"); + } if(file.exists() == true) { file.delete(); } @@ -90,7 +107,7 @@ public void saveStation(CraftingStation s) { pw.close(); HashMap defaults = new HashMap(); json = (JSONObject) parser.parse(new InputStreamReader(new FileInputStream(file), "UTF-8")); - defaults.put("world", s.getLoc().getWorld().toString().replace("CraftWorld{name=", "").replace("}", "")); + defaults.put("world", s.getLoc().getWorld().getName()); defaults.put("xPos", s.getLoc().getX()); defaults.put("yPos", s.getLoc().getY()); defaults.put("zPos", s.getLoc().getZ()); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java index 1ea3c26..717ab00 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java @@ -121,6 +121,9 @@ private void parseGlobalStatOffset(String entry) { if (entry == null || entry.isBlank() || !entry.contains("(")) { return; } + if (entry.lastIndexOf(')') < entry.indexOf('(')) { + return; + } String statId = entry.substring(0, entry.indexOf('(')).trim().toLowerCase(); String amountPart = entry.substring(entry.indexOf('(') + 1, entry.lastIndexOf(')')).trim(); try { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyManager.java index 13850f6..d83227b 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/AlloyManager.java @@ -15,6 +15,7 @@ import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.EquipmentSlot; import org.bukkit.scheduler.BukkitRunnable; import dev.lone.itemsadder.api.CustomStack; @@ -104,6 +105,7 @@ public void run() { @SuppressWarnings("deprecation") @EventHandler public void addIngredient(PlayerInteractEvent e) { + if (e.isCancelled() || e.getHand() != EquipmentSlot.HAND) return; if(!e.getAction().equals(Action.RIGHT_CLICK_BLOCK)) return; Block b = e.getClickedBlock(); if (!isAlloyStation(b)) { @@ -130,6 +132,10 @@ public void addIngredient(PlayerInteractEvent e) { return; } Ingredient ing = cs.getIngredient(); + if (ing == null) { + p.sendMessage("§cThis ingredient is no longer configured."); + return; + } if (!ProfessionPermissions.canUseIngredient(p, ing)) { p.sendMessage(ProfessionPermissions.missingIngredientPermissionMessage( ing.getIngredientData().getPermission())); @@ -205,8 +211,13 @@ public void nameAlloy(Player p, String s) { String name = StringFormatter.formatHex(new String(s).replace("_", " ")); NamableAlloy alloy = naming.get(p); String oldId = alloy.getAlloy().getId(); + String id = StringFormatter.clean(s).toLowerCase(java.util.Locale.ROOT); + Alloy existing = getAlloyById(id); + if (existing != null && existing != alloy.getAlloy()) { + p.sendMessage("§cAn alloy with that name already exists."); + return; + } removeAlloy(oldId); - String id = StringFormatter.clean(s); alloy.getAlloy().setId(id); alloy.getAlloy().setName(name); ItemStack i = alloy.getItem(); @@ -232,6 +243,7 @@ public void nameAlloy(Player p, String s) { @EventHandler public void breakStation(BlockBreakEvent e) { + if (e.isCancelled()) return; Block b = e.getBlock(); if (!isValidAlloyStation(b)) { return; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java index 8f75eb1..b56812a 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CommandManager.java @@ -137,6 +137,10 @@ private boolean handleAcCommand(CommandSender sender, String[] args) { p.sendMessage("§cInvalid quality percent. Usage: /ac craft "); return true; } + if (!Double.isFinite(percent)) { + p.sendMessage("§cInvalid quality percent. Usage: /ac craft "); + return true; + } if (percent < 0) { percent = 0; } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java index cb33192..16dfede 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java @@ -99,14 +99,13 @@ private ItemStack getArmorPiece(Player player, ArmorType type) { } private void setArmorPiece(Player player, ArmorType type, ItemStack item) { - switch (type) { - case HELMET -> player.getInventory().setHelmet(item); - case CHESTPLATE -> player.getInventory().setChestplate(item); - case LEGGINGS -> player.getInventory().setLeggings(item); - case BOOTS -> player.getInventory().setBoots(item); - default -> { - } - } + java.util.function.Consumer writer = switch (type) { + case HELMET -> player.getInventory()::setHelmet; + case CHESTPLATE -> player.getInventory()::setChestplate; + case LEGGINGS -> player.getInventory()::setLeggings; + case BOOTS -> player.getInventory()::setBoots; + }; + writer.accept(item); } @FunctionalInterface diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java index 4e077be..e20f523 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java @@ -132,12 +132,12 @@ public List getStations() { public void setAdminCraftPending(Player player, double qualityPercent) { long expiresAt = System.currentTimeMillis() + 30_000L; - adminCraftPending.put(player.getUniqueId(), new AdminCraftPending(qualityPercent, expiresAt)); + AdminCraftPending pending = new AdminCraftPending(qualityPercent, expiresAt); + adminCraftPending.put(player.getUniqueId(), pending); new BukkitRunnable() { @Override public void run() { - AdminCraftPending pending = adminCraftPending.remove(player.getUniqueId()); - if (pending != null && player.isOnline()) { + if (adminCraftPending.remove(player.getUniqueId(), pending) && player.isOnline()) { player.sendMessage("§cAdmin craft timed out. Run §f/ac craft §c again."); } } @@ -215,17 +215,7 @@ public void openStation(PlayerInteractEvent e) { inv.categoryView(p); return; } - if(Cache.brandingTool != null) { - if(i == null) return; - if(i.getType().equals(Material.AIR)) return; - if(api.getChecker().checkItemWithPath(i, Cache.brandingTool) && !station.hasRecipe()) { - currentStation.put(p, station); - InventoryManager inv = new InventoryManager(); - inv.categoryView(p); - return; - } - } - if(i == null) return; + if(i.getType().equals(Material.AIR)) return; StationFeedback f = station.addMaterial(p, i); switch (f) { @@ -317,7 +307,7 @@ public void applyHit(PlayerInteractEvent e) { e.setCancelled(true); } if (!hasStation(b.getLocation())) return; - if (i == null || i.getType().equals(Material.AIR)) return; + if (i.getType().equals(Material.AIR)) return; CraftingStation station = get(b.getLocation()); @@ -439,6 +429,7 @@ public void invenClick(InventoryClickEvent e) { @EventHandler(priority = EventPriority.HIGH) public void breakStation(BlockBreakEvent e) { + if (e.isCancelled()) return; Block b = e.getBlock(); Player p = e.getPlayer(); if (p != null diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java index 7d0f370..3a703c7 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java @@ -45,7 +45,7 @@ public void convertItem(PlayerInteractEvent e) { if(!isIngredientStation(b)) return; Player p = e.getPlayer(); ItemStack i = p.getInventory().getItemInMainHand(); - if(i == null || i.getType().isAir()) return; + if(i.getType().isAir()) return; CraftStack cs = new CraftStack(i); StatData source = resolveStatData(cs, i); @@ -55,7 +55,6 @@ public void convertItem(PlayerInteractEvent e) { if(!cs.isIngredient() && !cs.isAlloy()) { Ingredient ing = getFromItem(i); - if(ing == null) return; ing.buildTo(i); } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java index 9f38157..519af6c 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java @@ -96,6 +96,9 @@ public void templatePreviewView(Player p, StatData source) { if (!StatTemplateMath.hasOverlap(source, template)) { continue; } + if (slot >= i.getSize()) { + break; + } i.setItem(slot, getTemplatePreviewItem(source, template)); slot++; } @@ -117,7 +120,8 @@ public void templatePreviewView(Player p, StatData source) { @SuppressWarnings("deprecation") private ItemStack getTemplatePreviewItem(StatData source, StatTemplate template) { ItemStack icon = template.getIcon(); - ItemStack item = icon != null ? icon.clone() : new ItemStack(Material.BARRIER, 1); + ItemStack item = icon != null && !icon.getType().isAir() + ? icon.clone() : new ItemStack(Material.BARRIER, 1); ItemMeta meta = item.getItemMeta(); meta.setDisplayName(template.getName()); List lore = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/MMOItemRebuildListener.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/MMOItemRebuildListener.java index 1eb7afc..1d9b221 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/MMOItemRebuildListener.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/MMOItemRebuildListener.java @@ -29,6 +29,7 @@ public void onRebuild(MMOItemRebuildEvent event) { loreIndex = readTierLoreStart(event.getOldItem()); } if (loreIndex != null) { + CraftTierLore.applyPdc(result, loreIndex, tier); CraftTierLore.refreshTierLine(result, tier); } else { CraftTierLore.applyTierLine(result, tier); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java index db04763..19539e1 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForger.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.DoubleSupplier; import java.util.jar.Attributes.Name; import org.bukkit.Location; @@ -28,12 +29,18 @@ public class AlloyForger { private int value; private AlloyStation station; private StatData stats; + private final DoubleSupplier random; private HashMap hits = new HashMap<>(); public AlloyForger(AlloyStation station) { + this(station, Math::random); + } + + AlloyForger(AlloyStation station, DoubleSupplier random) { this.station = station; this.value = station.getTotalValue(); + this.random = random; } public NamableAlloy forge(Player p) { @@ -75,7 +82,7 @@ public NamableAlloy forge(Player p, int i) { a = db.loadAlloy(result); if(a == null) { db.deleteRecipe(station); - return forge(p, i); + return forge(p, i + 1); } else { AlloyManager.addAlloy(a); } @@ -103,7 +110,7 @@ private void generateHits() { for(Ingredient i : station.getIngredients()) { for(CraftingHit h : i.getIngredientData().getHits().keySet()) { if(!hits.containsKey(h)) { - int r = (int) (Math.floor(Math.random()*3)+1); + int r = (int) (Math.floor(random.getAsDouble()*3)+1); hits.put(h, r); } } @@ -127,7 +134,7 @@ public String getXP() { if(current < min) min = current; if(current > max) max = current; } - double randomValue = min + (Math.random() * (max - min)); + double randomValue = min + (random.getAsDouble() * (max - min)); randomValue = Math.round(randomValue*100)/100.0; return type+"("+randomValue+")"; } @@ -151,17 +158,17 @@ private String getName() { backups.add(name); } } - int i = (int) Math.round(Math.random()*(names.size()-1)); + int i = (int) Math.round(random.getAsDouble()*(names.size()-1)); String name = names.get(i); String id = name.replace(" ", "_").toLowerCase(); while(AlloyManager.getAlloyById(id) != null && names.size() > 0) { - i = (int) Math.round(Math.random()*(names.size()-1)); + i = (int) Math.round(random.getAsDouble()*(names.size()-1)); name = names.get(i); id = new String(name.replace(" ", "_").toLowerCase()); names.remove(i); } while(AlloyManager.getAlloyById(id) != null && backups.size() > 0) { - i = (int) Math.round(Math.random()*(backups.size()-1)); + i = (int) Math.round(random.getAsDouble()*(backups.size()-1)); name = backups.get(i); id = new String(name.replace(" ", "_").toLowerCase()); backups.remove(i); @@ -188,7 +195,7 @@ public void mergeStats() { if(baseItem.getIngredientData().statIsProtected(m)) continue; StatModifier baseModifier = base.get(m.getType()); if(gemstone) { - if(Math.random()*100 >= gemstoneInheritanceChance(i.getIngredientData().getValue())) continue; + if(random.getAsDouble()*100 >= gemstoneInheritanceChance(i.getIngredientData().getValue())) continue; merge.add(modifierForMerge(m, baseModifier, true)); if(baseModifier == null) { StatModifier limit = m.copy(); @@ -200,7 +207,7 @@ public void mergeStats() { } else if(baseModifier != null) { merge.add(modifierForMerge(m, baseModifier, false)); } else { - if(Math.random()*100 < (20+i.getIngredientData().getValue()*3)) { + if(random.getAsDouble()*100 < (20+i.getIngredientData().getValue()*3)) { merge.add(m.copy()); StatModifier copy = m.copy(); copy.setAmount(copy.getAmount()*Cache.maxFactor); @@ -220,7 +227,7 @@ public void mergeStats() { for(StatModifier m : stats.getModifiers()) { if(!o.getType().equalsIgnoreCase(m.getType())) continue; if(o.getAmount() == m.getAmount()) { - double percent = 0.9 + (Math.random() * 0.2); // Between 0.9 and 1.1 + double percent = 0.9 + (random.getAsDouble() * 0.2); // Between 0.9 and 1.1 m.setAmount(Math.round(m.getAmount() * percent * 100.0) / 100.0); } } @@ -244,14 +251,14 @@ static StatModifier modifierForMerge(StatModifier catalyst, StatModifier base, b private void merge(HashMap base, HashMap max, List list) { for(StatModifier m : list) { if(m.getAmount() < 0) { - if(Math.floor(Math.random()*value) < 8) { + if(Math.floor(random.getAsDouble()*value) < 8) { if(base.containsKey(m.getType())) { base.get(m.getType()).modify(randomize(m.getType(), m.getAmount())); continue; } } } - if(Math.floor(Math.random()*100)+value > 25) { + if(Math.floor(random.getAsDouble()*100)+value > 25) { if(base.containsKey(m.getType())) { base.get(m.getType()).modify(randomize(m.getType(), m.getAmount())); continue; @@ -271,14 +278,14 @@ private void merge(HashMap base, HashMap 65) { + if(Math.floor(random.getAsDouble()*100)+value > 65) { d = d/2*-1; } } if(d > 0) { - multiplier = (Math.floor(Math.random()*100)+10+(value*2))/100; + multiplier = (Math.floor(random.getAsDouble()*100)+10+(value*2))/100; } else { - multiplier = (Math.floor(Math.random()*100)-(value*2))/100; + multiplier = (Math.floor(random.getAsDouble()*100)-(value*2))/100; } if(multiplier > 1.0) multiplier = 1.0; if(multiplier < 0.0) multiplier = 0.0; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java index 0b23ae2..e73d8d1 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -190,6 +190,7 @@ public StationFeedback addMaterial(Player p, ItemStack i) { int materialTier = 0; if(c.isIngredient()) { Ingredient ing = c.getIngredient(); + if (ing == null) return StationFeedback.NOT_INGREDIENT; if (!ProfessionPermissions.canUseIngredient(p, ing)) { p.sendMessage(ProfessionPermissions.missingIngredientPermissionMessage( ing.getIngredientData().getPermission())); @@ -202,6 +203,7 @@ public StationFeedback addMaterial(Player p, ItemStack i) { } if(c.isAlloy()) { Alloy a = c.getAlloy(); + if (a == null) return StationFeedback.NOT_INGREDIENT; key = "alloy."+a.getId(); type = a.getData().getType(); mergeHits = a.getData().getHits(); @@ -352,7 +354,7 @@ private double calculatePercentage() { double d = hits.get(h).getPercentage(); if(d >= 200.0) continue; if(d <= 100.0) amount = amount+d; - if(d > 100.0 && d <= 200.0) amount = amount+(200.0-d); + if(d > 100.0) amount = amount+(200.0-d); } return Math.round((amount/counter)); } @@ -413,6 +415,8 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent) { Alloy a = AlloyManager.getAlloyById(mId); name = a.getName(); scheme = a.getData().getModelScheme(); + } else { + return StationFeedback.NOT_INGREDIENT; } // ✅ New logic starts here — replaces the old itemName.setString(...) line @@ -566,6 +570,9 @@ public void drop() { } else if(type.equalsIgnoreCase("alloy")) { Alloy a = AlloyManager.getAlloyById(mId); i = a.build(); + } else { + Bukkit.getLogger().warning("AC: Cannot refund unknown material kind: " + s); + continue; } i.setAmount(currentMaterials.get(s)); loc.getWorld().dropItem(loc, i); @@ -583,6 +590,9 @@ public void drop(int offset) { } else if(type.equalsIgnoreCase("alloy")) { Alloy a = AlloyManager.getAlloyById(mId); i = a.build(); + } else { + Bukkit.getLogger().warning("AC: Cannot refund unknown material kind: " + s); + continue; } i.setAmount(currentMaterials.get(s)); loc.getWorld().dropItem(loc.clone().add(0, offset, 0), i); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/RecipeCategory.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/RecipeCategory.java index 9203cae..9bf234b 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/RecipeCategory.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/RecipeCategory.java @@ -16,10 +16,7 @@ public class RecipeCategory { public RecipeCategory(String key, ConfigurationSection config) { this.id = key; this.name = StringFormatter.formatHex(config.getString("name")); - if(config.contains("permission")) { - this.permission = config.getString("permission"); - } - this.permission = "none"; + this.permission = config.getString("permission", "none"); } public String getId() { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/IngredientData.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/IngredientData.java index a074653..ec630d4 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/IngredientData.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/IngredientData.java @@ -119,7 +119,7 @@ public String getPermission() { } public boolean hasPermission() { - return permission != null && !permission.isBlank(); + return permission != null; } public IngredientType getType() { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java index 4b072ba..57f1af2 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java @@ -32,7 +32,7 @@ public StatTemplate(String key, ConfigurationSection config) { id = key; name = StringFormatter.formatHex(config.getString("name", key)); iconPath = config.getString("icon", "v.paper"); - if (iconPath == null || !iconPath.contains(".")) { + if (!iconPath.contains(".")) { Bukkit.getLogger().warning("AC: Stat template " + key + " icon must be a TLibs path (e.g. v.iron_sword), got: " + iconPath); } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemLoreRefresher.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemLoreRefresher.java index 3ce376f..335d602 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemLoreRefresher.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemLoreRefresher.java @@ -50,7 +50,7 @@ public static RefreshResult refresh(ItemStack item) { } Kind kind = AcItemTags.getKind(item); String id = AcItemTags.getId(item); - if (kind == null || id == null) { + if (kind == null) { return RefreshResult.unchanged(); } @@ -107,7 +107,7 @@ public static RefreshResult refresh(ItemStack item) { private static int getLiveRevision(ItemStack item) { Kind kind = AcItemTags.getKind(item); String id = AcItemTags.getId(item); - if (kind == null || id == null) { + if (kind == null) { return 0; } if (kind == Kind.INGREDIENT) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemTags.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemTags.java index 7fabf34..cbb2533 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemTags.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemTags.java @@ -35,7 +35,7 @@ public static Kind getKind(ItemStack item) { public static String getId(ItemStack item) { Kind kind = getKind(item); - if (kind == null || !item.hasItemMeta()) { + if (kind == null) { return null; } ItemMeta meta = item.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftInspectFormatter.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftInspectFormatter.java index 7c8782d..3e7826f 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftInspectFormatter.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftInspectFormatter.java @@ -32,10 +32,6 @@ public static void send(CommandSender sender, ItemStack item) { return; } CraftProvenance provenance = cs.getProvenance(); - if (provenance == null) { - sender.sendMessage("§cNo provenance data on this item."); - return; - } sender.sendMessage("§e--- Craft inspect ---"); String recipeId = provenance.getRecipeId(); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/MajorityTierResolver.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/MajorityTierResolver.java index 20964de..b6b5260 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/MajorityTierResolver.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/MajorityTierResolver.java @@ -34,10 +34,7 @@ public static String resolveMajorityKey(CraftingRecipe recipe, Map materials) { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index aebfb6b..8bcfe79 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,10 +8,10 @@ depend: [TLibs, MMOCore, MMOItems, MythicLib] commands: ac: - useage: / + usage: / description: AdvancedCrafting Command alloy: - useage: / + usage: / description: Alloy Command permissions: diff --git a/src/test/java/net/tfminecraft/advancedcrafting/AlloyDatabaseCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/AlloyDatabaseCoverageTest.java new file mode 100644 index 0000000..2d0e4a9 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/AlloyDatabaseCoverageTest.java @@ -0,0 +1,147 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.Location; +import org.json.simple.*; +import org.junit.jupiter.api.Test; + +class AlloyDatabaseCoverageTest extends CoverageSupport { + AlloyRecipeStore store() throws Exception { + var store = new AlloyRecipeStore(temp.resolve("recipes").toFile()); + var field = AdvancedCrafting.class.getDeclaredField("alloyRecipeStore"); + field.setAccessible(true); + field.set(plugin, store); + return store; + } + + Alloy alloy(String id, boolean withRecipe) throws Exception { + var ingredient = ingredient("iron", "base: true\ntier: 3"); + var data = + new AlloyData( + ingredient, + new StatData(List.of("armor(2)")), + new HashMap<>(Map.of(HitLoader.getByString("strike"), 2)), + withRecipe ? "smith(4)" : null); + if (withRecipe) data.setRecipe(new AlloyRecipe("iron", List.of("zinc"))); + return new Alloy(id, id, data); + } + + @Test + void databaseRoundTripsAlloyStatsHitsRecipeAndRevisions() throws Exception { + var store = store(); + var db = new AlloyDatabase(); + db.loadAlloys(); + assertNull(db.loadAlloy("missing")); + var alloy = alloy("bronze", true); + db.saveAlloy(alloy); + assertEquals(1, alloy.getRevision()); + db.saveAlloy(alloy); + var loaded = db.loadAlloy("BRONZE"); + assertNotNull(loaded); + assertEquals("bronze", loaded.getName()); + assertEquals(2, loaded.getData().getModel()); + assertEquals(3, loaded.getData().getTier()); + assertEquals("smith(4)", loaded.getData().getXP()); + assertEquals(alloy.getData().getRecipe(), loaded.getData().getRecipe()); + assertEquals(2, loaded.getData().getStatData().getModifiers().getFirst().getAmount()); + assertEquals(2, loaded.getData().getHits().get(HitLoader.getByString("strike"))); + Files.createDirectory(temp.resolve("data/alloys/ignored")); + Files.writeString(temp.resolve("data/alloys/invalid.json"), "invalid"); + db.loadAlloys(); + assertNotNull(AlloyManager.getAlloyById("bronze")); + assertNull(db.loadAlloy("invalid")); + var station = new AlloyStation(new Location(server.addSimpleWorld("world"), 0, 1, 2)); + db.saveRecipe(station, "scrap"); + assertNull(db.getResult(station)); + station.addIngredient(IngredientLoader.getByString("iron")); + db.saveRecipe(station, "scrap"); + assertEquals("scrap", db.getResult(station)); + db.deleteRecipe(station); + assertNull(db.getResult(station)); + loaded.setId("brass"); + db.editAlloy(loaded, "bronze"); + assertNull(db.loadAlloy("bronze")); + assertNotNull(db.loadAlloy("brass")); + assertEquals("brass", store.getResultByCombo("iron|zinc")); + db.editAlloy(loaded, "missing"); + db.saveAlloy(alloy("untagged", false)); + assertNull(db.loadAlloy("untagged").getData().getRecipe()); + } + + @Test + void databaseParsesOptionalLegacyRecipeAndBucketFields() throws Exception { + store(); + var db = new AlloyDatabase(); + db.saveAlloy(alloy("bronze", true)); + var file = temp.resolve("data/alloys/bronze.json"); + var root = com.google.gson.JsonParser.parseString(Files.readString(file)).getAsJsonObject(); + root.remove("statMergeBucketId"); + root.remove("xp"); + root.getAsJsonObject("recipe").remove("catalysts"); + Files.writeString(file, root.toString()); + assertEquals("metal", db.loadAlloy("bronze").getData().getStatMergeBucketId()); + root.add("recipe", com.google.gson.JsonNull.INSTANCE); + root.addProperty("statMergeBucketId", " "); + Files.writeString(file, root.toString()); + assertNotNull(db.loadAlloy("bronze")); + root.add("recipe", new com.google.gson.JsonObject()); + root.addProperty("type", "missing"); + Files.writeString(file, root.toString()); + assertNull(db.loadAlloy("bronze").getData().getStatMergeBucketId()); + root.remove("recipe"); + root.addProperty("statMergeBucketId", "CUSTOM"); + Files.writeString(file, root.toString()); + assertEquals("custom", db.loadAlloy("bronze").getData().getStatMergeBucketId()); + } + + @Test + void genericJsonAccessorsPreserveDefaultsAndRejectBadNumericValues() throws Exception { + store(); + var db = new AlloyDatabase(); + db.saveAlloy(alloy("bronze", false)); + db.loadAlloy("bronze"); + var defaults = new HashMap(); + defaults.put("text", "&cRed"); + defaults.put("double", 2.5); + defaults.put("integer", 3); + defaults.put("bool", true); + defaults.put("object", new JSONObject()); + defaults.put("array", new JSONArray()); + assertEquals("bronze", db.getRawData("id", defaults)); + assertEquals("missing", db.getRawData("missing", defaults)); + assertEquals("§cRed", db.getString("text", defaults)); + assertTrue(db.getBoolean("bool", defaults)); + assertEquals(2.5, db.getDouble("double", defaults)); + assertEquals(3, db.getInteger("integer", defaults)); + assertEquals(-1, db.getDouble("missing", defaults)); + assertEquals(-1, db.getInteger("missing", defaults)); + assertEquals(new JSONObject(), db.getObject("object", defaults)); + assertEquals(new JSONObject(), db.getObject("missing", defaults)); + assertEquals(new JSONArray(), db.getArray("array", defaults)); + assertEquals(new JSONArray(), db.getArray("missing", defaults)); + assertNotNull(db.getArray("stats", defaults)); + assertTrue(db.save(temp.resolve("generic.json").toFile(), defaults)); + assertFalse(db.save(temp.toFile(), defaults)); + var blocked = temp.resolve("data/alloys/bad.json"); + Files.createDirectory(blocked); + Files.writeString(blocked.resolve("child"), "keep"); + var bad = alloy("bad", false); + db.saveAlloy(bad); + assertTrue(Files.isDirectory(blocked)); + try (var main = mockStatic(AdvancedCrafting.class, CALLS_REAL_METHODS)) { + main.when(AdvancedCrafting::getAlloyRecipeStore) + .thenThrow(new IllegalStateException("unavailable")); + db.editAlloy(bad, "missing"); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/AlloyDomainCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/AlloyDomainCoverageTest.java new file mode 100644 index 0000000..b77c40b --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/AlloyDomainCoverageTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.enums.StationFeedback; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class AlloyDomainCoverageTest extends CoverageSupport { + @Test + void stationRejectsDuplicateIncompatibleAndOverCapacityIngredients() throws Exception { + var world = server.addSimpleWorld("world"); + var loc = new Location(world, 1, 2, 3); + var station = new AlloyStation(loc); + assertSame(loc, station.getLocation()); + assertNull(station.getBaseItem()); + assertEquals(0, station.getElementAmount()); + assertEquals(0, station.getTotalValue()); + assertTrue(station.getIngredients().isEmpty()); + assertTrue(station.getStatus().contains("0/1")); + station.drop(); + var base = ingredient("iron", "base: true\nvalue: 2"); + var catalyst = ingredient("copper", "value: 3"); + assertEquals(StationFeedback.WRONG_BASE, station.addIngredient(catalyst)); + assertEquals(StationFeedback.SUCCESS, station.addIngredient(base)); + assertSame(base, station.getBaseItem()); + assertEquals(StationFeedback.EXISTS, station.addIngredient(base)); + assertEquals(StationFeedback.SUCCESS, station.addIngredient(catalyst)); + assertEquals(StationFeedback.EXISTS, station.addIngredient(catalyst)); + TypeLoader.map.put("wood", new IngredientType("wood", yaml("name: Wood"))); + Cache.combinations.put(base.getIngredientData().getType(), new ArrayList<>()); + assertEquals( + StationFeedback.INCOMPATIBLE_TYPE, station.addIngredient(ingredient("wood", "type: wood"))); + assertEquals(StationFeedback.SUCCESS, station.addIngredient(ingredient("a", ""))); + assertEquals(StationFeedback.SUCCESS, station.addIngredient(ingredient("b", ""))); + assertEquals(StationFeedback.SUCCESS, station.addIngredient(ingredient("c", ""))); + assertEquals(StationFeedback.CAPACITY, station.addIngredient(ingredient("d", ""))); + assertEquals(5, station.getElementAmount()); + assertEquals(8, station.getTotalValue()); + assertEquals(5, station.getIngredients().size()); + assertEquals(4, station.getCatalysts().size()); + assertTrue(station.getStatus().contains("1/1")); + station.drop(); + assertEquals(5, world.getEntities().size()); + } + + @Test + void alloyDataAndNamingWindowRetainRecipeAndCanonicalBuckets() throws Exception { + var ingredient = ingredient("iron", "base: true\ntier: 3\nstat-merge-key: metals"); + var stats = new StatData(List.of("armor(3)")); + var hits = new HashMap<>(Map.of(HitLoader.getByString("strike"), 2)); + var data = new AlloyData(ingredient, stats, hits, "smith(3)"); + assertTrue(data.hasXP()); + assertEquals("smith(3)", data.getXP()); + assertEquals(3, data.getTier()); + assertEquals("metals", data.getStatMergeBucketId()); + assertSame(stats, data.getStatData()); + assertSame(hits, data.getHits()); + assertSame(ingredient.getIngredientData().getType(), data.getType()); + assertSame(ingredient.getIngredientData().getModelScheme(), data.getModelScheme()); + assertEquals(2, data.getModel()); + var recipe = new AlloyRecipe("iron", List.of("copper")); + data.setRecipe(recipe); + assertSame(recipe, data.getRecipe()); + assertTrue(data.buildRevisionContent().contains("armor(3.0)")); + var none = + new AlloyData( + data.getColourScheme(), + 2, + null, + data.getModelScheme(), + new StatData(), + new HashMap<>(), + null, + null, + 1, + null); + assertFalse(none.hasXP()); + assertNull(none.getStatMergeBucketId()); + assertTrue(none.buildRevisionContent().contains("xp=;")); + var normalized = + new AlloyData( + data.getColourScheme(), + 2, + data.getType(), + data.getModelScheme(), + stats, + hits, + null, + recipe, + 3, + " CUSTOM "); + assertEquals("custom", normalized.getStatMergeBucketId()); + var fallback = + new AlloyData( + data.getColourScheme(), + 2, + data.getType(), + data.getModelScheme(), + stats, + hits, + null, + recipe, + 3, + " "); + assertEquals("metal", fallback.getStatMergeBucketId()); + var alloy = new Alloy("Test Alloy", ingredient, stats, hits, null, recipe); + assertEquals("test_alloy", alloy.getId()); + assertTrue(alloy.getName().contains("Test Alloy")); + alloy.setName("New"); + alloy.setId("new"); + alloy.setRevision(4); + assertEquals("New", alloy.getName()); + assertEquals("new", alloy.getId()); + assertEquals(4, alloy.getRevision()); + assertEquals(recipe, alloy.getData().getRecipe()); + var item = new ItemStack(Material.IRON_INGOT); + var named = new NamableAlloy(alloy, item); + assertSame(alloy, named.getAlloy()); + assertSame(item, named.getItem()); + assertEquals(0, named.getTime()); + for (int i = 0; i < 59; i++) assertFalse(named.tick()); + assertTrue(named.tick()); + assertEquals(60, named.getTime()); + } + + @Test + void alloyBuildUsesMmoNameLoreModelAndPersistentIdentity() throws Exception { + var ing = ingredient("iron", "base: true\ntier: 3"); + var alloy = + new Alloy( + "bronze", + "Bronze", + new AlloyData(ing, new StatData(List.of("armor(2)")), new HashMap<>(), null)); + alloy.setRevision(5); + for (boolean history : List.of(false, true)) + try (var nbts = mockStatic(io.lumine.mythic.lib.api.item.NBTItem.class); + var models = mockStatic(net.tfminecraft.advancedcrafting.util.LegacyModelData.class); + var mmos = + mockConstruction( + net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(net.Indyuce.mmoitems.ItemStats.NAME)) + .thenReturn(new net.Indyuce.mmoitems.stat.data.StringData("Old")); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_INGOT)); + if (history) { + var hist = mock(net.Indyuce.mmoitems.stat.type.StatHistory.class); + when(hist.getOriginalData()) + .thenReturn(new net.Indyuce.mmoitems.stat.type.NameData("Old")); + when(mmo.computeStatHistory(net.Indyuce.mmoitems.ItemStats.NAME)) + .thenReturn(hist); + } else + when(mmo.computeStatHistory(net.Indyuce.mmoitems.ItemStats.NAME)) + .thenReturn(null); + })) { + var result = alloy.build(); + assertEquals("bronze", net.tfminecraft.advancedcrafting.utils.AcItemTags.getId(result)); + assertEquals( + 5, net.tfminecraft.advancedcrafting.utils.AcItemTags.getStoredRevision(result)); + assertTrue(result.getItemMeta().hasEnchants()); + models.verify( + () -> net.tfminecraft.advancedcrafting.util.LegacyModelData.set(any(), eq(2))); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/AlloyManagerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/AlloyManagerCoverageTest.java new file mode 100644 index 0000000..0aa4f70 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/AlloyManagerCoverageTest.java @@ -0,0 +1,293 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import dev.lone.itemsadder.api.CustomStack; +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.utils.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.entity.Player; +import org.bukkit.event.block.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; + +class AlloyManagerCoverageTest extends CoverageSupport { + @SuppressWarnings("unchecked") + Map map(AlloyManager m, String name) throws Exception { + var f = AlloyManager.class.getDeclaredField(name); + f.setAccessible(true); + return (Map) f.get(m); + } + + PlayerInteractEvent event(Player p, Block b) { + return new PlayerInteractEvent( + p, + Action.RIGHT_CLICK_BLOCK, + p.getInventory().getItemInMainHand(), + b, + BlockFace.UP, + EquipmentSlot.HAND); + } + + BlockAPI blocks(Block b) { + Cache.alloyStation = "v.blast_furnace"; + var api = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + tlibs.when(TLibs::getBlockAPI).thenReturn(api); + when(api.getChecker().checkBlock(b, Cache.alloyStation)).thenReturn(true); + return api; + } + + void clearCooldown(AlloyManager m) throws Exception { + map(m, "cooldown").clear(); + } + + @Test + void interactionValidatesStructureMaterialPermissionsCapacityAndCooldown() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + var b = server.addSimpleWorld("world").getBlockAt(0, 1, 0); + var api = blocks(b); + assertFalse(m.hasStation(b.getLocation())); + assertNull(m.get(b.getLocation())); + assertNull(AlloyManager.getAlloyById(null)); + AlloyManager.removeAlloy("absent"); + m.addIngredient(new PlayerInteractEvent(p, Action.LEFT_CLICK_BLOCK, null, b, BlockFace.UP)); + when(api.getChecker().checkBlock(b, Cache.alloyStation)).thenReturn(false); + m.addIngredient(event(p, b)); + assertFalse(m.isValidAlloyStation(b)); + when(api.getChecker().checkBlock(b, Cache.alloyStation)).thenReturn(true); + when(api.getChecker().checkBlock(b.getRelative(BlockFace.DOWN), Cache.alloyStation)) + .thenReturn(true); + m.addIngredient(event(p, b)); + assertFalse(m.hasStation(b.getLocation())); + when(api.getChecker().checkBlock(b.getRelative(BlockFace.DOWN), Cache.alloyStation)) + .thenReturn(false); + p.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + m.addIngredient(event(p, b)); + assertTrue(p.nextMessage().contains("not an ingredient")); + var base = ingredient("iron", "base: true\npermission: smith"); + p.getInventory().setItemInMainHand(base.build()); + m.addIngredient(event(p, b)); + assertFalse(m.hasStation(b.getLocation())); + clearCooldown(m); + m.addIngredient(event(p, b)); + assertTrue(p.nextMessage().contains("permission")); + p.setOp(true); + clearCooldown(m); + p.getInventory().setItemInMainHand(base.build()); + m.addIngredient(event(p, b)); + assertTrue(m.hasStation(b.getLocation())); + assertEquals(1, m.get(b.getLocation()).getElementAmount()); + assertEquals(0, p.getInventory().getItemInMainHand().getAmount()); + clearCooldown(m); + p.getInventory().setItemInMainHand(base.build()); + m.addIngredient(event(p, b)); + assertEquals(1, m.get(b.getLocation()).getElementAmount()); + for (String id : List.of("a", "b", "c", "d", "e")) { + clearCooldown(m); + p.getInventory().setItemInMainHand(ingredient(id, "").build()); + m.addIngredient(event(p, b)); + } + assertEquals(5, m.get(b.getLocation()).getElementAmount()); + m.removeStation(m.get(b.getLocation())); + clearCooldown(m); + m.addIngredient(event(p, b)); + assertEquals(0, m.get(b.getLocation()).getElementAmount()); + clearCooldown(m); + p.getInventory().setItemInMainHand(base.build()); + m.addIngredient(event(p, b)); + net.tfminecraft.advancedcrafting.loaders.TypeLoader.map.put( + "wood", new IngredientType("wood", yaml("name: Wood"))); + Cache.combinations.put(base.getIngredientData().getType(), List.of()); + clearCooldown(m); + p.getInventory().setItemInMainHand(ingredient("wood", "type: wood").build()); + m.addIngredient(event(p, b)); + assertEquals(1, m.get(b.getLocation()).getElementAmount()); + map(m, "cooldown").put(p, 0L); + m.addIngredient(event(p, b)); + } + + @Test + void forgeConsumesLavaOnlyForReadyPermittedStationsAndTracksNaming() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + var b = server.addSimpleWorld("world").getBlockAt(0, 1, 0); + blocks(b); + var e = event(p, b); + m.forgeAlloy(e); + assertTrue(e.isCancelled()); + var base = ingredient("iron", "base: true\npermission: smith"); + var catalyst = ingredient("copper", ""); + var station = new AlloyStation(b.getLocation()); + station.addIngredient(base); + map(m, "stations").put(b.getLocation(), station); + m.forgeAlloy(event(p, b)); + assertTrue(m.hasStation(b.getLocation())); + station.addIngredient(catalyst); + m.forgeAlloy(event(p, b)); + assertTrue(m.hasStation(b.getLocation())); + p.setOp(true); + try (var nbts = mockStatic(NBTItem.class); + var custom = mockStatic(CustomStack.class); + var forgers = mockConstruction(AlloyForger.class)) { + var nbt = mock(NBTItem.class); + nbts.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + p.getInventory().setItemInMainHand(new ItemStack(Material.LAVA_BUCKET)); + when(nbt.hasType()).thenReturn(true); + m.addIngredient(event(p, b)); + assertEquals(0, forgers.constructed().size()); + clearCooldown(m); + when(nbt.hasType()).thenReturn(false); + custom.when(() -> CustomStack.byItemStack(any())).thenReturn(mock(CustomStack.class)); + m.addIngredient(event(p, b)); + assertEquals(0, forgers.constructed().size()); + clearCooldown(m); + custom.when(() -> CustomStack.byItemStack(any())).thenReturn(null); + m.addIngredient(event(p, b)); + assertEquals(Material.BUCKET, p.getInventory().getItemInMainHand().getType()); + assertFalse(m.hasStation(b.getLocation())); + } + var alloy = mock(Alloy.class); + var named = new NamableAlloy(alloy, new ItemStack(Material.IRON_INGOT)); + map(m, "stations").put(b.getLocation(), station); + p.getInventory().setItemInMainHand(new ItemStack(Material.LAVA_BUCKET)); + try (var forgers = + mockConstruction(AlloyForger.class, (mock, ctx) -> when(mock.forge(p)).thenReturn(named))) { + m.forgeAlloy(event(p, b)); + assertSame(named, map(m, "naming").get(p)); + } + when(plugin.isEnabled()).thenReturn(true); + m.start(); + server.getScheduler().performTicks(1201); + assertFalse(map(m, "naming").containsKey(p)); + } + + @Test + void namingRenamesBothTrackedDropAndMatchingInventoryStack() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + m.nameAlloy(p, "new"); + assertTrue(p.nextMessage().contains("no alloy")); + var base = ingredient("iron", "base: true"); + var alloy = + spy(new Alloy("old", "Old", new AlloyData(base, new StatData(), new HashMap<>(), null))); + var old = new ItemStack(Material.IRON_INGOT); + var renamed = new ItemStack(Material.GOLD_INGOT); + doReturn(renamed).when(alloy).build(); + AlloyManager.addAlloy(alloy); + map(m, "naming").put(p, new NamableAlloy(alloy, old)); + m.nameAlloy(p, "invalid1"); + assertTrue(map(m, "naming").containsKey(p)); + p.getInventory().setItem(1, new ItemStack(Material.PAPER)); + p.getInventory().setItem(3, old); + try (var db = mockConstruction(AlloyDatabase.class)) { + m.nameAlloy(p, "new_alloy"); + assertNull(AlloyManager.getAlloyById("old")); + assertSame(alloy, AlloyManager.getAlloyById("new_alloy")); + assertEquals(Material.GOLD_INGOT, p.getInventory().getItem(3).getType()); + assertEquals(Material.GOLD_INGOT, old.getType()); + verify(db.constructed().getFirst()).editAlloy(alloy, "old"); + } + var other = spy(new Alloy("other", "Other", alloy.getData())); + doReturn(renamed).when(other).build(); + map(m, "naming").put(p, new NamableAlloy(other, new ItemStack(Material.DIAMOND))); + try (var db = mockConstruction(AlloyDatabase.class)) { + m.nameAlloy(p, "another"); + assertFalse(map(m, "naming").containsKey(p)); + } + } + + @Test + void breakDropsExistingStationOnlyAtValidBaseBlock() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + var b = server.addSimpleWorld("world").getBlockAt(0, 1, 0); + var api = blocks(b); + var station = mock(AlloyStation.class); + when(station.getLocation()).thenReturn(b.getLocation()); + m.breakStation(new BlockBreakEvent(b, p)); + map(m, "stations").put(b.getLocation(), station); + when(api.getChecker().checkBlock(b, Cache.alloyStation)).thenReturn(false); + m.breakStation(new BlockBreakEvent(b, p)); + verify(station, never()).drop(); + when(api.getChecker().checkBlock(b, Cache.alloyStation)).thenReturn(true); + m.breakStation(new BlockBreakEvent(b, p)); + verify(station).drop(); + assertFalse(m.hasStation(b.getLocation())); + } + + @Test + void namingCollisionMustPreserveBothDefinitions() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + var base = ingredient("iron", ""); + var a = + spy(new Alloy("old", "Old", new AlloyData(base, new StatData(), new HashMap<>(), null))); + doReturn(new ItemStack(Material.IRON_INGOT)).when(a).build(); + var existing = new Alloy("taken", "Taken", a.getData()); + AlloyManager.addAlloy(a); + AlloyManager.addAlloy(existing); + map(m, "naming").put(p, new NamableAlloy(a, new ItemStack(Material.IRON_INGOT))); + try (var db = mockConstruction(AlloyDatabase.class)) { + m.nameAlloy(p, "taken"); + assertSame(a, AlloyManager.getAlloyById("old")); + assertSame(existing, AlloyManager.getAlloyById("taken")); + assertTrue(db.constructed().isEmpty()); + } + try (var db = mockConstruction(AlloyDatabase.class)) { + m.nameAlloy(p, "old"); + assertSame(a, AlloyManager.getAlloyById("old")); + assertFalse(map(m, "naming").containsKey(p)); + } + } + + @Test + void cancelledBreakAndOffhandInteractionCannotAlterForge() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + var b = server.addSimpleWorld("world").getBlockAt(0, 1, 0); + blocks(b); + var station = mock(AlloyStation.class); + map(m, "stations").put(b.getLocation(), station); + var breaking = new BlockBreakEvent(b, p); + breaking.setCancelled(true); + m.breakStation(breaking); + verify(station, never()).drop(); + assertTrue(m.hasStation(b.getLocation())); + var iron = ingredient("iron", "base: true"); + p.getInventory().setItemInMainHand(iron.build()); + var offhand = + new PlayerInteractEvent( + p, Action.RIGHT_CLICK_BLOCK, null, b, BlockFace.UP, EquipmentSlot.OFF_HAND); + offhand.setCancelled(true); + m.addIngredient(offhand); + offhand.setCancelled(false); + m.addIngredient(offhand); + verify(station, never()).addIngredient(any()); + } + + @Test + void staleTaggedIngredientDoesNotCrashForgeInteraction() throws Exception { + var m = new AlloyManager(); + var p = server.addPlayer(); + var b = server.addSimpleWorld("world").getBlockAt(0, 1, 0); + blocks(b); + var iron = ingredient("iron", "base: true"); + p.getInventory().setItemInMainHand(iron.build()); + net.tfminecraft.advancedcrafting.loaders.IngredientLoader.oList.clear(); + assertDoesNotThrow(() -> m.addIngredient(event(p, b))); + assertFalse(m.hasStation(b.getLocation())); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/BridgeCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/BridgeCoverageTest.java new file mode 100644 index 0000000..efcad40 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/BridgeCoverageTest.java @@ -0,0 +1,139 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class BridgeCoverageTest extends CoverageSupport { + @Test + void bridgeValueTotalsRespectRecipesQuantitiesAndMissingDefinitions() throws Exception { + var base = ingredient("iron", "base: true\ntier: 2\nvalue: 3"); + var gem = ingredient("gem", "value: 4"); + var recipe = new AlloyRecipe("iron", List.of("gem", "unknown")); + var data = new AlloyData(base, new StatData(), new HashMap<>(), null); + data.setRecipe(recipe); + var alloy = new Alloy("bronze", "Bronze", data); + AlloyManager.addAlloy(alloy); + assertEquals(0, ThieveryBridge.sumForgeInputValues(null)); + assertEquals(0, ThieveryBridge.sumForgeInputValues(new AlloyRecipe("unknown", List.of()))); + assertEquals(7, ThieveryBridge.sumForgeInputValues(recipe)); + assertEquals(0, ThieveryBridge.sumAlloyIngredientValues(null)); + assertEquals(0, ThieveryBridge.sumAlloyIngredientValues(mock(Alloy.class))); + assertEquals(7, ThieveryBridge.sumAlloyIngredientValues(alloy)); + assertEquals(0, ThieveryBridge.sumProvenanceInputValues(null)); + assertEquals(0, ThieveryBridge.sumProvenanceInputValues(List.of())); + assertEquals( + 20, + ThieveryBridge.sumProvenanceInputValues( + List.of( + new CraftInput("ingredient", "iron", 2, 0), + new CraftInput("ingredient", "missing", 2, 0), + new CraftInput("alloy", "bronze", 2, 0), + new CraftInput("alloy", "missing", 2, 0), + new CraftInput("other", "x", 2, 0)))); + assertSame(base, ThieveryBridge.getIngredientById("iron")); + assertSame(IngredientLoader.get(), ThieveryBridge.getAllIngredients()); + assertSame(CategoryLoader.get(), ThieveryBridge.getRecipeCategories()); + assertSame(TypeLoader.map.get("metal"), ThieveryBridge.getIngredientType("metal")); + var craft = recipe("stat-template: blade"); + RecipeLoader.map.put("sword", craft); + var template = new StatTemplate("blade", yaml("")); + StatTemplateLoader.get().put("blade", template); + var quality = new Quality("fine", yaml("name: Fine")); + QualityLoader.map.put("fine", quality); + assertSame(craft, ThieveryBridge.getRecipeById("sword")); + assertSame(template, ThieveryBridge.getStatTemplate("blade")); + assertSame(quality, ThieveryBridge.getQualityById("fine")); + assertEquals( + 2, + ThieveryBridge.resolveMajorityTier( + craft, List.of(new CraftInput("ingredient", "iron", 2, 0)))); + assertNull(ThieveryBridge.findRecipeByStatTemplate(null)); + assertNull(ThieveryBridge.findRecipeByStatTemplate(" ")); + assertNull(ThieveryBridge.findRecipeByStatTemplate("missing")); + assertSame(craft, ThieveryBridge.findRecipeByStatTemplate("BLADE")); + RecipeLoader.map.put("none", recipe("")); + assertNull(ThieveryBridge.findRecipeByStatTemplate("missing")); + assertFalse(ThieveryBridge.hasBaseIngredientForType(null, 2)); + assertFalse(ThieveryBridge.hasBaseIngredientForType(" ", 2)); + assertFalse(ThieveryBridge.hasBaseIngredientForType("metal", 0)); + assertTrue(ThieveryBridge.hasBaseIngredientForType("METAL", 2)); + assertFalse(ThieveryBridge.hasBaseIngredientForType("metal", 3)); + assertFalse(ThieveryBridge.hasBaseIngredientForType("wood", 2)); + ingredient("untiered", "base: true"); + assertFalse(ThieveryBridge.hasBaseIngredientForType("metal", 4)); + assertEquals("", ThieveryBridge.normalizeCraftCategoryId(null)); + assertEquals("armor", ThieveryBridge.normalizeCraftCategoryId("ARMOUR")); + assertEquals("weapons", ThieveryBridge.normalizeCraftCategoryId("WEAPONS")); + } + + @Test + void integrationRequiresEnabledPluginAndIngredientManager() throws Exception { + assertFalse(ThieveryBridge.isPluginReady()); + assertNull(ThieveryBridge.resolveIngredient(new ItemStack(Material.PAPER))); + assertNull(ThieveryBridge.resolveAlloy(null)); + assertNull(ThieveryBridge.readProvenance(null)); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + var pm = mock(org.bukkit.plugin.PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pm); + when(pm.getPlugin("AdvancedCrafting")).thenReturn(plugin); + assertFalse(ThieveryBridge.isPluginReady()); + when(plugin.isEnabled()).thenReturn(true); + AdvancedCrafting.plugin = null; + assertFalse(ThieveryBridge.isPluginReady()); + AdvancedCrafting.plugin = plugin; + assertFalse(ThieveryBridge.isPluginReady()); + var manager = mock(IngredientManager.class); + when(plugin.getIngredientManager()).thenReturn(manager); + assertTrue(ThieveryBridge.isPluginReady()); + assertNull(ThieveryBridge.resolveIngredient(null)); + assertNull(ThieveryBridge.resolveIngredient(new ItemStack(Material.AIR))); + assertNull(ThieveryBridge.resolveAlloy(null)); + assertNull(ThieveryBridge.readProvenance(null)); + var iron = ingredient("iron", ""); + var item = iron.build(); + assertSame(iron, ThieveryBridge.resolveIngredient(item)); + var plain = new ItemStack(Material.IRON_INGOT); + when(manager.getFromItem(plain)).thenReturn(iron); + assertSame(iron, ThieveryBridge.resolveIngredient(plain)); + assertNull(ThieveryBridge.resolveAlloy(plain)); + assertNull(ThieveryBridge.readProvenance(plain)); + } + } + + @Test + void craftingStatCalculatorRoutesLiveInputsThroughTemplate() throws Exception { + var template = new StatTemplate("blade", yaml("stats: [armor]\nbase-stats: ['health(4)']")); + StatTemplateLoader.get().put("blade", template); + var craft = recipe("stat-template: blade"); + ingredient("iron", "stats: ['armor(6)']"); + assertEquals( + 6, + CraftStatCalculator.compute(craft, Map.of("ingredient.iron", 1)) + .getModifiers() + .getFirst() + .getAmount()); + assertEquals( + 2, + CraftStatCalculator.compute(craft, List.of(new CraftInput("ingredient", "iron", 1, 0))) + .getModifiers() + .size()); + assertFalse(CraftStatCalculator.compute(null, Map.of()).hasModifiers()); + assertFalse(CraftStatCalculator.compute(null, List.of()).hasModifiers()); + assertFalse(CraftStatCalculator.compute(recipe(""), Map.of()).hasModifiers()); + assertEquals(Set.of("armor", "health"), CraftStatCalculator.collectManagedStatIds(craft)); + assertTrue(CraftStatCalculator.collectManagedStatIds(null).isEmpty()); + assertTrue(CraftStatCalculator.collectManagedStatIds(recipe("")).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java new file mode 100644 index 0000000..73227ef --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/CommandCoverageTest.java @@ -0,0 +1,215 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class CommandCoverageTest extends CoverageSupport { + Command cmd(String name) { + var c = mock(Command.class); + when(c.getName()).thenReturn(name); + return c; + } + + boolean run(CommandManager m, CommandSender sender, String... args) { + return m.onCommand(sender, cmd("ac"), "ac", args); + } + + @Test + void dispatchRequiresAdminAndPlayerWhereApplicable() throws Exception { + var manager = new CommandManager(); + var p = server.addPlayer(); + var console = mock(CommandSender.class); + assertFalse(run(manager, p)); + assertFalse(run(manager, p, "unknown")); + assertFalse(manager.onCommand(console, cmd("other"), "x", new String[] {"name", "x"})); + assertFalse(manager.onCommand(p, cmd("other"), "x", new String[] {"name", "x"})); + for (String[] args : + List.of( + new String[] {"reload"}, + new String[] {"sync", "recipes"}, + new String[] {"refresh"}, + new String[] {"inspect"}, + new String[] {"give", "alloy", "iron"}, + new String[] {"alloy", "info", "iron"}, + new String[] {"craft", "10"})) assertTrue(run(manager, p, args)); + p.setOp(true); + when(console.hasPermission(AdminPermissions.PERMISSION)).thenReturn(true); + assertTrue(run(manager, p, "reload")); + verify(plugin).reloadMessage(p); + assertTrue(run(manager, console, "reload")); + verify(plugin).reload(); + assertTrue(run(manager, console, "refresh")); + assertTrue(run(manager, p, "refresh")); + assertTrue(run(manager, console, "inspect")); + assertTrue(run(manager, console, "craft", "10")); + assertTrue(run(manager, p, "craft", "bad")); + try (var sync = mockStatic(AlloyRecipeSync.class); + var info = mockStatic(AlloyInfoFormatter.class); + var inspect = mockStatic(CraftInspectFormatter.class); + var ac = mockStatic(AdvancedCrafting.class, CALLS_REAL_METHODS)) { + var crafting = mock(CraftingManager.class); + var alloys = mock(AlloyManager.class); + ac.when(AdvancedCrafting::getCraftingManager).thenReturn(crafting); + ac.when(AdvancedCrafting::getAlloyManager).thenReturn(alloys); + assertTrue(run(manager, p, "sync", "recipes")); + assertTrue(run(manager, p, "sync", "recipes", "other")); + assertTrue(run(manager, p, "sync", "recipes", "repair")); + sync.verify(() -> AlloyRecipeSync.run(p, true)); + assertTrue(run(manager, p, "alloy", "info", "iron")); + info.verify(() -> AlloyInfoFormatter.send(p, "iron")); + assertTrue(run(manager, p, "inspect")); + inspect.verify(() -> CraftInspectFormatter.send(p)); + for (String value : List.of("-5", "50", "150")) assertTrue(run(manager, p, "craft", value)); + verify(crafting).setAdminCraftPending(p, 0); + verify(crafting).setAdminCraftPending(p, 50); + verify(crafting).setAdminCraftPending(p, 100); + assertFalse(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {})); + assertFalse(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"other"})); + assertFalse(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"name"})); + assertTrue(manager.onCommand(p, cmd("alloy"), "alloy", new String[] {"name", "steel"})); + verify(alloys).nameAlloy(p, "steel"); + } + for (String[] args : + List.of( + new String[] {"give", "other", "x"}, + new String[] {"alloy", "other", "x"}, + new String[] {"sync", "other"}, + new String[] {"craft"}, + new String[] {"give"})) assertFalse(run(manager, p, args)); + } + + @Test + void refreshReportsErrorsUnchangedAndUpdatedStacks() { + var manager = new CommandManager(); + var p = server.addPlayer(); + p.setOp(true); + var item = new ItemStack(Material.IRON_SWORD); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(PDCKeys.craftRecipe(), PersistentDataType.STRING, "sword"); + item.setItemMeta(meta); + p.getInventory().setItemInMainHand(item); + try (var refresh = mockStatic(CraftStatRefresher.class)) { + refresh + .when(() -> CraftStatRefresher.refresh(any(ItemStack.class), eq(true))) + .thenReturn(CraftStatRefresher.RefreshResult.failed("unknown")); + assertTrue(run(manager, p, "refresh")); + assertTrue(p.nextMessage().contains("Refresh failed")); + refresh + .when(() -> CraftStatRefresher.refresh(any(ItemStack.class), eq(true))) + .thenReturn(CraftStatRefresher.RefreshResult.unchanged()); + run(manager, p, "refresh"); + assertTrue(p.nextMessage().contains("No changes")); + var updated = new ItemStack(Material.DIAMOND_SWORD); + refresh + .when(() -> CraftStatRefresher.refresh(any(ItemStack.class), eq(true))) + .thenReturn(CraftStatRefresher.RefreshResult.updated(updated, List.of())); + run(manager, p, "refresh"); + assertEquals(updated, p.getInventory().getItemInMainHand()); + } + } + + @Test + void alloyGiveSupportsConsoleTargetsSelfAndOverflow() { + var manager = new CommandManager(); + var p = server.addPlayer("Admin"); + p.setOp(true); + var target = server.addPlayer("Target"); + var console = mock(CommandSender.class); + when(console.hasPermission(AdminPermissions.PERMISSION)).thenReturn(true); + assertTrue(run(manager, p, "give", "alloy", "missing")); + var alloy = mock(Alloy.class); + when(alloy.getId()).thenReturn("bronze"); + when(alloy.getName()).thenReturn("Bronze"); + when(alloy.build()).thenAnswer(i -> new ItemStack(Material.IRON_INGOT)); + AlloyManager.addAlloy(alloy); + assertTrue(run(manager, console, "give", "alloy", "bronze")); + assertTrue(run(manager, console, "give", "alloy", "bronze", "missing")); + assertTrue(run(manager, p, "give", "alloy", "bronze")); + assertEquals(Material.IRON_INGOT, p.getInventory().getItem(0).getType()); + assertTrue(run(manager, p, "give", "alloy", "bronze", "Target")); + assertEquals(Material.IRON_INGOT, target.getInventory().getItem(0).getType()); + var full = new ItemStack[target.getInventory().getSize()]; + Arrays.fill(full, new ItemStack(Material.STONE, 64)); + target.getInventory().setContents(full); + run(manager, p, "give", "alloy", "bronze", "Target"); + assertFalse(target.getWorld().getEntitiesByClass(org.bukkit.entity.Item.class).isEmpty()); + AlloyManager.removeAlloy("bronze"); + try (var db = + mockConstruction( + AlloyDatabase.class, (mock, ctx) -> when(mock.loadAlloy("bronze")).thenReturn(alloy))) { + assertTrue(run(manager, p, "give", "alloy", "bronze")); + assertSame(alloy, AlloyManager.getAlloyById("bronze")); + } + } + + @Test + void tabCompletionFiltersCommandsIdsAndOnlinePlayers() { + var manager = new CommandManager(); + var p = server.addPlayer("Alice"); + assertTrue(manager.onTabComplete(p, cmd("ac"), "ac", new String[] {}).isEmpty()); + p.setOp(true); + assertEquals(7, manager.onTabComplete(p, cmd("ac"), "ac", new String[] {}).size()); + assertEquals( + List.of("reload", "refresh"), + manager.onTabComplete(p, cmd("ac"), "ac", new String[] {"RE"})); + assertEquals(7, manager.onTabComplete(p, cmd("ac"), "ac", new String[] {null}).size()); + var alloy = mock(Alloy.class); + when(alloy.getId()).thenReturn("bronze"); + AlloyManager.addAlloy(alloy); + Map, List> cases = new LinkedHashMap<>(); + cases.put(List.of("sync", ""), List.of("recipes")); + cases.put(List.of("give", ""), List.of("alloy")); + cases.put(List.of("alloy", ""), List.of("info")); + cases.put(List.of("craft", "1"), List.of("100")); + cases.put(List.of("other", ""), List.of()); + cases.put(List.of("sync", "recipes", ""), List.of("repair")); + cases.put(List.of("sync", "bad", ""), List.of()); + cases.put(List.of("give", "alloy", ""), List.of("bronze")); + cases.put(List.of("give", "bad", ""), List.of()); + cases.put(List.of("alloy", "info", ""), List.of("bronze")); + cases.put(List.of("alloy", "bad", ""), List.of()); + cases.put(List.of("other", "bad", ""), List.of()); + cases.put(List.of("give", "alloy", "bronze", "A"), List.of("Alice")); + cases.put(List.of("give", "bad", "bronze", ""), List.of()); + cases.put(List.of("other", "bad", "bronze", ""), List.of()); + cases.put(List.of("other", "bad", "bronze", "", ""), List.of()); + for (var entry : cases.entrySet()) + assertEquals( + entry.getValue(), + manager.onTabComplete(p, cmd("ac"), "ac", entry.getKey().toArray(String[]::new)), + entry.getKey().toString()); + assertEquals(List.of("name"), manager.onTabComplete(p, cmd("alloy"), "alloy", new String[] {})); + assertEquals( + List.of("name"), manager.onTabComplete(p, cmd("alloy"), "alloy", new String[] {"n"})); + assertTrue( + manager.onTabComplete(p, cmd("alloy"), "alloy", new String[] {"name", ""}).isEmpty()); + assertTrue(manager.onTabComplete(p, cmd("other"), "x", new String[] {}).isEmpty()); + } + + @Test + void adminCraftRejectsNonFiniteQuality() { + var manager = new CommandManager(); + var p = server.addPlayer(); + p.setOp(true); + try (var ac = mockStatic(AdvancedCrafting.class, CALLS_REAL_METHODS)) { + var crafting = mock(CraftingManager.class); + ac.when(AdvancedCrafting::getCraftingManager).thenReturn(crafting); + for (String value : List.of("NaN", "Infinity", "-Infinity")) + assertTrue(run(manager, p, "craft", value)); + verifyNoInteractions(crafting); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CompatibilityCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/CompatibilityCoverageTest.java new file mode 100644 index 0000000..30fe49a --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/CompatibilityCoverageTest.java @@ -0,0 +1,44 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.meta.*; +import org.bukkit.inventory.meta.components.CustomModelDataComponent; +import org.junit.jupiter.api.Test; + +class CompatibilityCoverageTest { + @Test + void legacyModelSetterReplacesTheEntireComponentAndSupportsClearing() { + var meta = mock(ItemMeta.class); + var component = mock(CustomModelDataComponent.class); + when(meta.getCustomModelDataComponent()).thenReturn(component); + when(component.getFloats()).thenReturn(List.of()); + assertFalse(LegacyModelData.has(meta)); + assertThrows(IllegalStateException.class, () -> LegacyModelData.get(meta)); + when(component.getFloats()).thenReturn(List.of(7.8f)); + assertTrue(LegacyModelData.has(meta)); + assertEquals(7, LegacyModelData.get(meta)); + LegacyModelData.set(meta, 9); + verify(component).setFloats(List.of(9f)); + verify(component).setFlags(List.of()); + verify(component).setStrings(List.of()); + verify(component).setColors(List.of()); + verify(meta).setCustomModelDataComponent(component); + LegacyModelData.set(meta, null); + verify(meta).setCustomModelDataComponent(null); + } + + @Test + void adminPermissionCheckExplainsRejection() { + var sender = mock(CommandSender.class); + assertFalse(AdminPermissions.require(sender)); + verify(sender).sendMessage("§cNo permission."); + when(sender.hasPermission(AdminPermissions.PERMISSION)).thenReturn(true); + assertTrue(AdminPermissions.require(sender)); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java b/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java new file mode 100644 index 0000000..348f684 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java @@ -0,0 +1,130 @@ +package net.tfminecraft.advancedcrafting; + +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import net.Indyuce.mmoitems.MMOItems; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.crafting.hits.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.schemes.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +abstract class CoverageSupport { + @TempDir Path temp; + ServerMock server; + MockedStatic tlibs; + ItemAPI items; + AdvancedCrafting plugin; + Map cacheBefore = new HashMap<>(); + + @BeforeEach + void setup() throws Exception { + for (var field : Cache.class.getFields()) { + Object value = field.get(null); + cacheBefore.put(field, value instanceof Map ? new HashMap<>((Map) value) : value); + } + server = MockBukkit.mock(); + plugin = mock(AdvancedCrafting.class); + when(plugin.getName()).thenReturn("AdvancedCrafting"); + when(plugin.namespace()).thenReturn("advancedcrafting"); + when(plugin.getLogger()).thenReturn(Logger.getLogger("AdvancedCraftingTest")); + when(plugin.getDataFolder()).thenReturn(temp.toFile()); + AdvancedCrafting.plugin = plugin; + io.lumine.mythic.lib.MythicLib.plugin = + mock(io.lumine.mythic.lib.MythicLib.class, RETURNS_DEEP_STUBS); + when(io.lumine.mythic.lib.MythicLib.plugin.namespace()).thenReturn("mythiclib"); + MMOItems.plugin = mock(MMOItems.class, RETURNS_DEEP_STUBS); + when(MMOItems.plugin.namespace()).thenReturn("mmoitems"); + when(MMOItems.plugin.getConfig()).thenReturn(yaml("gem-sockets.uncolored: Uncolored")); + items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(items); + when(items.getCreator().getItemFromPath(anyString())) + .thenAnswer( + i -> { + String path = i.getArgument(0); + Material material = Material.matchMaterial(path.substring(path.lastIndexOf('.') + 1)); + return new ItemStack(material == null ? Material.PAPER : material); + }); + TypeLoader.map.clear(); + TypeLoader.hMap.clear(); + SchemeLoader.names.clear(); + SchemeLoader.colours.clear(); + SchemeLoader.models.clear(); + IngredientLoader.oList.clear(); + HitLoader.map.clear(); + QualityLoader.map.clear(); + RecipeLoader.map.clear(); + CategoryLoader.categories.clear(); + StatTemplateLoader.get().clear(); + SocketGroupLoader.map.clear(); + for (String id : + new ArrayList<>(net.tfminecraft.advancedcrafting.managers.AlloyManager.getAlloyIds())) + net.tfminecraft.advancedcrafting.managers.AlloyManager.removeAlloy(id); + Cache.globalStatOffsets.clear(); + Cache.permissionNamespaces.clear(); + Cache.combinations.clear(); + Cache.alloyForgeBaseBonus.clear(); + Cache.showIngredientStats = true; + AdvancedCrafting.getRevisionTracker().load(temp.toFile()); + TypeLoader.map.put("metal", new IngredientType("metal", yaml("name: Metal"))); + TypeLoader.hMap.put("hammer", new HitType("hammer", yaml("name: Hammer"))); + SchemeLoader.colours.put( + "default", new ColourScheme("default", yaml("models: [2]\ncolours: ['#FFFFFF']"))); + SchemeLoader.models.put("default", new ModelScheme("default", yaml("models: ['sword(7)']"))); + SchemeLoader.names.put( + "default", new NamingScheme("default", yaml("names: [Iron]\ncolour-scheme: default"))); + HitLoader.map.put( + "strike", new CraftingHit("strike", yaml("name: Strike\ntype: hammer\ntool: v.iron_axe"))); + CategoryLoader.categories.put("weapons", new RecipeCategory("weapons", yaml("name: Weapons"))); + } + + @AfterEach + void cleanup() throws Exception { + MockBukkit.unmock(); + tlibs.close(); + AdvancedCrafting.plugin = null; + for (var entry : cacheBefore.entrySet()) entry.getKey().set(null, entry.getValue()); + } + + static YamlConfiguration yaml(String text) throws Exception { + var result = new YamlConfiguration(); + result.loadFromString(text); + return result; + } + + Ingredient ingredient(String id, String extra) throws Exception { + var result = new Ingredient(id, yaml("path: v.iron_ingot\ntype: metal\n" + extra)); + IngredientLoader.oList.add(result); + return result; + } + + CraftingRecipe recipe(String extra) throws Exception { + return new CraftingRecipe( + "sword", + yaml( + "name: '%material% Sword'\ncategory: weapons\ntemplate: SWORD.TEST\ntype: smith\n" + + extra)); + } + + static Object invoke(Object instance, String method, Class[] types, Object... args) + throws Exception { + var m = instance.getClass().getDeclaredMethod(method, types); + m.setAccessible(true); + return m.invoke(instance, args); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java new file mode 100644 index 0000000..ccb6c1f --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java @@ -0,0 +1,430 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import dev.lone.itemsadder.api.Events.FurnitureBreakEvent; +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.enums.StationFeedback; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.entity.*; +import org.bukkit.event.block.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class CraftingManagerCoverageTest extends CoverageSupport { + @SuppressWarnings("unchecked") + Map map(CraftingManager m, String name) throws Exception { + var f = CraftingManager.class.getDeclaredField(name); + f.setAccessible(true); + return (Map) f.get(m); + } + + Player player() { + var p = spy(server.addPlayer()); + doNothing() + .when(p) + .spawnParticle( + any(Particle.class), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble()); + doNothing() + .when(p) + .spawnParticle( + any(Particle.class), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + any(org.bukkit.block.data.BlockData.class)); + return p; + } + + PlayerInteractEvent event(Player p, Block b, Action action) { + return new PlayerInteractEvent( + p, action, p.getInventory().getItemInMainHand(), b, BlockFace.UP, EquipmentSlot.HAND); + } + + Block block() { + var b = mock(Block.class); + var loc = new Location(server.addSimpleWorld("world"), 0, 1, 0); + when(b.getLocation()).thenAnswer(a -> loc.clone()); + when(b.getWorld()).thenReturn(loc.getWorld()); + return b; + } + + BlockAPI blocks(Block b) { + Cache.craftingStation = "v.anvil"; + var api = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + tlibs.when(TLibs::getBlockAPI).thenReturn(api); + when(api.getChecker().checkBlock(b, Cache.craftingStation)).thenReturn(true); + return api; + } + + CraftingStation station(CraftingManager m, Block b) { + var s = mock(CraftingStation.class); + when(s.getLoc()).thenAnswer(a -> b.getLocation()); + m.set(new HashMap<>(Map.of(b.getLocation(), s))); + return s; + } + + @Test + void openingStationCreatesSelectionThenReportsMaterialFeedback() throws Exception { + var p = player(); + var b = block(); + var api = blocks(b); + var m = new CraftingManager(); + assertFalse(m.hasStation(b.getLocation())); + assertNull(m.get(b.getLocation())); + assertTrue(m.getStations().isEmpty()); + try (var menus = mockConstruction(InventoryManager.class)) { + m.openStation(event(p, b, Action.LEFT_CLICK_BLOCK)); + m.openStation(event(p, null, Action.RIGHT_CLICK_BLOCK)); + Cache.craftingStation = null; + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + Cache.craftingStation = "v.anvil"; + when(api.getChecker().checkBlock(b, Cache.craftingStation)).thenReturn(false); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + when(api.getChecker().checkBlock(b, Cache.craftingStation)).thenReturn(true); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + assertTrue(m.hasStation(b.getLocation())); + assertEquals(1, m.getStations().size()); + verify(menus.constructed().getFirst()).categoryView(p); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + assertEquals(1, menus.constructed().size()); + map(m, "cooldown").put(p, 0L); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + assertEquals(2, menus.constructed().size()); + var s = station(m, b); + when(s.hasRecipe()).thenReturn(true); + Cache.brandingTool = "v.stick"; + map(m, "cooldown").clear(); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + Cache.brandingTool = null; + map(m, "cooldown").clear(); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + p.getInventory().setItemInMainHand(new ItemStack(Material.IRON_INGOT)); + for (StationFeedback fb : + List.of( + StationFeedback.NOT_INGREDIENT, + StationFeedback.WRONG_TYPE, + StationFeedback.CAPACITY, + StationFeedback.NO_PERMS, + StationFeedback.SUCCESS)) { + map(m, "cooldown").clear(); + when(s.addMaterial(eq(p), any())).thenReturn(fb); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + } + verify(s, times(5)).addMaterial(eq(p), any()); + } + } + + @Test + void brandingCancelsFinishesOrReportsIncompleteCraftAndToolsRouteHits() throws Exception { + var p = player(); + var b = block(); + blocks(b); + var m = new CraftingManager(); + var s = station(m, b); + p.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + Cache.brandingTool = "v.stick"; + when(items.getChecker().checkItemWithPath(any(), eq(Cache.brandingTool))).thenReturn(true); + m.applyHit(event(p, b, Action.RIGHT_CLICK_BLOCK)); + m.applyHit(event(p, null, Action.LEFT_CLICK_BLOCK)); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + verify(s, never()).craft(p); + when(s.hasRecipe()).thenReturn(true); + for (StationFeedback fb : + List.of( + StationFeedback.LACKING_HITS, + StationFeedback.LACKING_ITEMS, + StationFeedback.NONE, + StationFeedback.SUCCESS)) { + when(s.craft(p)).thenReturn(fb); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + } + assertFalse(m.hasStation(b.getLocation())); + s = station(m, b); + p.setSneaking(true); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + verify(s).cancel(); + assertFalse(m.hasStation(b.getLocation())); + p.setSneaking(false); + s = station(m, b); + Cache.brandingTool = null; + try (var nbts = mockStatic(NBTItem.class)) { + var nbt = mock(NBTItem.class); + nbts.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + for (StationFeedback fb : + List.of( + StationFeedback.LACKING_ITEMS, + StationFeedback.WRONG_TYPE, + StationFeedback.NONE, + StationFeedback.CAPACITY, + StationFeedback.SUCCESS)) { + when(s.hit(eq(p), any())).thenReturn(fb); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + } + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("v"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("iron_axe"); + when(s.hit(eq(p), any())).thenReturn(StationFeedback.CAPACITY); + var e = event(p, b, Action.LEFT_CLICK_BLOCK); + m.applyHit(e); + assertTrue(e.isCancelled()); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("missing"); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + p.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + m.set(new HashMap<>()); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + } + } + + @Test + void adminPendingChecksReadinessAndCompletesWithoutNormalMaterialAdmission() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + var p = player(); + var b = block(); + blocks(b); + var m = new CraftingManager(); + m.setAdminCraftPending(p, 70); + assertTrue( + (Boolean) + invoke(m, "tryCompleteAdminCraft", new Class[] {Player.class, Block.class}, p, b)); + var s = station(m, b); + invoke(m, "tryCompleteAdminCraft", new Class[] {Player.class, Block.class}, p, b); + when(s.hasRecipe()).thenReturn(true); + invoke(m, "tryCompleteAdminCraft", new Class[] {Player.class, Block.class}, p, b); + when(s.hasAllMaterials(p)).thenReturn(true); + for (StationFeedback fb : + List.of(StationFeedback.LACKING_ITEMS, StationFeedback.NONE, StationFeedback.SUCCESS)) { + m.setAdminCraftPending(p, 70); + when(s.craft(p, 70.)).thenReturn(fb); + map(m, "cooldown").clear(); + m.openStation(event(p, b, Action.RIGHT_CLICK_BLOCK)); + } + assertFalse(m.hasStation(b.getLocation())); + server.getScheduler().performTicks(601); + m.setAdminCraftPending(p, 50); + var pending = map(m, "adminCraftPending").get(p.getUniqueId()); + var expiry = pending.getClass().getDeclaredField("expiresAtMs"); + expiry.setAccessible(true); + expiry.setLong(pending, 0L); + assertNull(invoke(m, "getValidAdminCraftPending", new Class[] {Player.class}, p)); + m.setAdminCraftPending(p, 50); + server.getScheduler().performTicks(601); + assertNull(invoke(m, "getValidAdminCraftPending", new Class[] {Player.class}, p)); + } + + @Test + void replacedAdminPendingSurvivesOlderTimeout() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + var p = player(); + var m = new CraftingManager(); + m.setAdminCraftPending(p, 20); + server.getScheduler().performTicks(300); + m.setAdminCraftPending(p, 80); + server.getScheduler().performTicks(301); + assertNotNull(invoke(m, "getValidAdminCraftPending", new Class[] {Player.class}, p)); + } + + @Test + void recipeSelectionHonorsNamespaceAndPreventsReplacingChosenRecipe() throws Exception { + var p = player(); + var b = block(); + var m = new CraftingManager(); + var station = station(m, b); + map(m, "currentStation").put(p, station); + var e = mock(InventoryClickEvent.class); + when(e.getWhoClicked()).thenReturn(p); + var view = mock(InventoryView.class); + when(e.getView()).thenReturn(view); + when(view.getTitle()).thenReturn(InventoryManager.STAT_PREVIEW_TITLE); + m.invenClick(e); + verify(e).setCancelled(true); + when(view.getTitle()).thenReturn("Other"); + m.invenClick(e); + try (var menus = mockConstruction(InventoryManager.class)) { + when(view.getTitle()).thenReturn("§7Select Category"); + m.invenClick(e); + when(e.getCurrentItem()).thenReturn(new ItemStack(Material.PAPER)); + m.invenClick(e); + var icon = new ItemStack(Material.PAPER); + var meta = icon.getItemMeta(); + meta.getPersistentDataContainer() + .set(new NamespacedKey(plugin, "ac_category"), PersistentDataType.STRING, "weapons"); + icon.setItemMeta(meta); + when(e.getCurrentItem()).thenReturn(icon); + m.invenClick(e); + verify(menus.constructed().getFirst()).recipeView(p, CategoryLoader.getByString("weapons")); + } + when(view.getTitle()).thenReturn("§7Select Recipe"); + when(e.getCurrentItem()).thenReturn(null); + m.invenClick(e); + when(e.getCurrentItem()).thenReturn(new ItemStack(Material.PAPER)); + m.invenClick(e); + var recipe = recipe("permission-namespace: smith"); + RecipeLoader.map.put("sword", recipe); + var icon = new ItemStack(Material.PAPER); + var meta = icon.getItemMeta(); + meta.getPersistentDataContainer() + .set(new NamespacedKey(plugin, "ac_recipe"), PersistentDataType.STRING, "sword"); + icon.setItemMeta(meta); + when(e.getCurrentItem()).thenReturn(icon); + m.invenClick(e); + verify(station, never()).setRecipe(any()); + p.setOp(true); + m.invenClick(e); + verify(station).setRecipe(recipe); + when(station.hasRecipe()).thenReturn(true); + m.invenClick(e); + verify(station).setRecipe(recipe); + RecipeLoader.map.put("sword", recipe("")); + m.invenClick(e); + } + + @Test + void breakingWithToolPreservesStationWhileOtherBreaksRefund() throws Exception { + var p = player(); + var b = block(); + blocks(b); + var m = new CraftingManager(); + var s = station(m, b); + p.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + Cache.brandingTool = "v.stick"; + when(items.getChecker().checkItemWithPath(any(), eq(Cache.brandingTool))).thenReturn(true); + var e = new BlockBreakEvent(b, p); + m.breakStation(e); + assertTrue(e.isCancelled()); + verify(s, never()).drop(); + Cache.brandingTool = null; + try (var nbts = mockStatic(NBTItem.class)) { + nbts.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(mock(NBTItem.class)); + m.breakStation(new BlockBreakEvent(b, p)); + verify(s).drop(); + assertFalse(m.hasStation(b.getLocation())); + m.breakStation(new BlockBreakEvent(b, p)); + } + s = station(m, b); + m.breakStation(new BlockBreakEvent(b, null)); + verify(s).drop(); + } + + @Test + void cancelledBreakMustKeepMaterials() { + var p = player(); + var b = block(); + blocks(b); + var m = new CraftingManager(); + var s = station(m, b); + var e = new BlockBreakEvent(b, p); + e.setCancelled(true); + m.breakStation(e); + verify(s, never()).drop(); + assertTrue(m.hasStation(b.getLocation())); + } + + @Test + void furnitureBreakHonorsConfiguredIdToolsAndClosesAffectedMenus() throws Exception { + var p = player(); + var b = block(); + var m = new CraftingManager(); + var e = mock(FurnitureBreakEvent.class); + when(e.getPlayer()).thenReturn(p); + Cache.craftingStation = null; + m.onIaFurnitureBreak(e); + Cache.craftingStation = "v.anvil"; + m.onIaFurnitureBreak(e); + Cache.craftingStation = "iaf(forge:anvil)"; + m.onIaFurnitureBreak(e); + when(e.getNamespacedID()).thenReturn("other:anvil"); + m.onIaFurnitureBreak(e); + when(e.getNamespacedID()).thenReturn("FORGE:ANVIL"); + Cache.craftingStation = "iaf(forge:anvil"; + m.onIaFurnitureBreak(e); + Cache.craftingStation = "iaf(forge:anvil)"; + p.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + Cache.brandingTool = "v.stick"; + when(items.getChecker().checkItemWithPath(any(), eq(Cache.brandingTool))).thenReturn(true); + m.onIaFurnitureBreak(e); + verify(e).setCancelled(true); + p.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + m.onIaFurnitureBreak(e); + var entity = mock(Entity.class); + when(entity.getLocation()).thenAnswer(a -> b.getLocation()); + when(e.getBukkitEntity()).thenReturn(entity); + m.onIaFurnitureBreak(e); + var st = station(m, b); + map(m, "currentStation").put(p, st); + p.openInventory(Bukkit.createInventory(null, 9, "§7Select Category")); + var other = player(); + var same = mock(CraftingStation.class); + when(same.getLoc()).thenAnswer(a -> b.getLocation()); + map(m, "currentStation").put(other, same); + other.openInventory(Bukkit.createInventory(null, 9, "§7Select Recipe")); + var unrelated = player(); + var elsewhere = mock(CraftingStation.class); + map(m, "currentStation").put(unrelated, elsewhere); + var elsewhere2 = mock(CraftingStation.class); + when(elsewhere2.getLoc()).thenAnswer(a -> b.getLocation().add(4, 0, 0)); + map(m, "currentStation").put(player(), elsewhere2); + m.onIaFurnitureBreak(e); + verify(st).drop(); + assertFalse(m.hasStation(b.getLocation())); + assertEquals(2, map(m, "currentStation").size()); + var noWorld = new Location(null, 2, 3, 4); + var orphan = mock(CraftingStation.class); + m.set(new HashMap<>(Map.of(noWorld, orphan))); + map(m, "currentStation").put(p, orphan); + invoke(m, "discardBrokenStation", new Class[] {Location.class}, noWorld); + verify(orphan).drop(); + when(e.getPlayer()).thenReturn(null); + m.onIaFurnitureBreak(e); + Cache.craftingStation = null; + assertNull(invoke(m, "getConfiguredIaFurnitureId", new Class[] {})); + Cache.craftingStation = "invalid"; + assertNull(invoke(m, "getConfiguredIaFurnitureId", new Class[] {})); + assertFalse((Boolean) invoke(m, "isStationTool", new Class[] {ItemStack.class}, (Object) null)); + } + + @Test + void unrelatedToolsBlocksAndOfflineTimeoutsDoNotConsumeStation() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + var p = player(); + var b = block(); + var api = blocks(b); + var m = new CraftingManager(); + var st = station(m, b); + Cache.brandingTool = "v.stick"; + p.getInventory().setItemInMainHand(new ItemStack(Material.IRON_AXE)); + when(items.getChecker().checkItemWithPath(any(), eq(Cache.brandingTool))).thenReturn(false); + try (var nbt = mockStatic(NBTItem.class)) { + nbt.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(mock(NBTItem.class)); + when(st.hit(eq(p), any())).thenReturn(StationFeedback.WRONG_TYPE); + m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); + when(api.getChecker().checkBlock(b, Cache.craftingStation)).thenReturn(false); + m.breakStation(new BlockBreakEvent(b, p)); + verify(st).drop(); + } + m.setAdminCraftPending(p, 20); + doReturn(false).when(p).isOnline(); + server.getScheduler().performTicks(601); + assertNull(invoke(m, "getValidAdminCraftPending", new Class[] {Player.class}, p)); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/DomainCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/DomainCoverageTest.java new file mode 100644 index 0000000..a60f7bd --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/DomainCoverageTest.java @@ -0,0 +1,224 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.schemes.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.junit.jupiter.api.Test; + +class DomainCoverageTest extends CoverageSupport { + @Test + void statMergingCopiesInputsAndAveragesByUnits() { + var source = new StatData(List.of("attack(1.23)", "speed(-2)")); + assertTrue(source.hasModifiers()); + assertFalse(new StatData().hasModifiers()); + var m = source.getModifiers().getFirst(); + assertEquals("attack", m.getType()); + assertEquals(1.23, m.copy().getAmount()); + m.setAmount(2); + m.modify(.127); + assertEquals(2.13, m.getAmount()); + assertEquals(0, source.getAmount(new StatModifier("unknown", 4))); + source.addModifier(new StatModifier("ATTACK", 1)); + source.mergeFrom(new StatData(List.of("speed(1)", "armor(4)"))); + assertEquals(3.13, source.getAmount(new StatModifier("attack", 0))); + assertEquals(-1, source.getAmount(new StatModifier("speed", 0))); + var merge = new MergeStatModifier("attack"); + assertEquals(0, merge.create().getAmount()); + assertEquals(0, merge.createWithDenominator(0).getAmount()); + merge.addWeighted(3, 2); + merge.modify(6); + assertEquals("attack", merge.getType()); + assertEquals(3, merge.getAmount()); + assertEquals(12, merge.getValue()); + assertEquals(4, merge.create().getAmount()); + assertEquals(2, merge.createWithDenominator(6).getAmount()); + assertEquals( + 3.13, new MergeStatModifier(source.getModifiers().getFirst()).create().getAmount()); + } + + @Test + void schemeModelsAndIngredientDefaultsAndOverrides() throws Exception { + var defaults = ingredient("iron", ""); + var d = defaults.getIngredientData(); + assertEquals(1, d.getWeight()); + assertEquals(1, d.getValue()); + assertFalse(d.canBeBase()); + assertEquals(0, d.getTier()); + assertFalse(d.hasTier()); + assertFalse(d.hasPermission()); + assertNull(d.getPermission()); + assertFalse(d.hasXP()); + assertNull(d.getXP()); + assertEquals("metal", d.getStatMergeBucketId()); + assertEquals("metal", d.getType().getId()); + assertEquals("Metal", d.getType().getName()); + assertFalse(d.statIsProtected(new StatModifier("attack", 1))); + assertTrue(d.buildRevisionContent().contains("tier=0")); + assertTrue(defaults.hasHex()); + assertEquals("#FFFFFF", defaults.getHex()); + assertEquals("v.iron_ingot", defaults.getPath()); + defaults.setRevision(4); + assertEquals(4, defaults.getRevision()); + var full = + ingredient( + "steel", + "weight: 3\n" + + "value: 5\n" + + "base: true\n" + + "tier: 4\n" + + "permission: ' Smith '\n" + + "stat-merge-key: ' METALS '\n" + + "xp: smith.5\n" + + "stats: ['armor(2)']\n" + + "hits: ['strike.2']\n" + + "protected-stats: [armor]\n" + + "hex: none"); + d = full.getIngredientData(); + assertEquals(3, d.getWeight()); + assertEquals(5, d.getValue()); + assertTrue(d.canBeBase()); + assertEquals(4, d.getTier()); + assertTrue(d.hasTier()); + assertTrue(d.hasPermission()); + assertEquals("smith", d.getPermission()); + assertEquals("metals", d.getStatMergeBucketId()); + assertTrue(d.hasXP()); + assertEquals("smith.5", d.getXP()); + assertTrue(d.statIsProtected(new StatModifier("armor", 2))); + assertEquals(2, d.getHits().get(HitLoader.getByString("strike"))); + assertFalse(full.hasHex()); + assertTrue(d.buildRevisionContent().contains("hits=strike.2")); + assertEquals( + "legacy", + ingredient("old", "permission: ' '\npermission-namespace: LEGACY") + .getIngredientData() + .getPermission()); + assertFalse( + ingredient("blank", "permission-namespace: ' '\nstat-merge-key: ' '") + .getIngredientData() + .hasPermission()); + assertNull( + ingredient("unknown", "type: unknown\nmodel-scheme: missing") + .getIngredientData() + .getStatMergeBucketId()); + var colour = d.getScheme().getColourScheme(); + assertEquals("default", colour.getId()); + assertEquals("v.IRON_INGOT", colour.getItem()); + assertEquals(List.of(2), colour.getModels()); + assertEquals(List.of("#FFFFFF"), colour.getHexCodes()); + assertEquals(2, colour.randomModel()); + assertEquals("#FFFFFF", colour.randomColour()); + assertEquals("default", d.getScheme().getId()); + assertEquals(List.of("Iron"), d.getScheme().getNames()); + assertEquals("default", d.getModelScheme().getId()); + assertEquals(List.of("sword(7)"), d.getModelScheme().getModels()); + assertEquals("7", d.getModelScheme().getModel("SWORD")); + assertNull(d.getModelScheme().getModel("axe")); + var namespace = new PermissionNamespace("SMITH", "Smithing"); + assertEquals("smith", namespace.getId()); + assertEquals("Smithing", namespace.getDisplay()); + } + + @Test + void recipeKeysCanonicalizeAndRetainDuplicateCatalysts() { + var r = new AlloyRecipe("IRON", List.of("ZINC", "copper", "ZINC")); + assertEquals("iron", r.getBaseId()); + assertEquals(List.of("copper", "zinc", "zinc"), r.getCatalystIds()); + assertThrows(UnsupportedOperationException.class, () -> r.getCatalystIds().add("x")); + assertEquals("iron|copper,zinc,zinc", r.comboKey()); + assertEquals("copper,zinc,zinc", r.catalystsJson()); + assertEquals("iron__copper__zinc__zinc", r.fileBaseName()); + assertEquals( + temp.resolve("iron/iron__copper__zinc__zinc.idx").toFile(), + r.resolveIndexFile(temp.toFile())); + assertEquals(r, AlloyRecipe.fromComboKey(r.comboKey().toUpperCase())); + assertEquals(r.hashCode(), AlloyRecipe.fromComboKey(r.comboKey()).hashCode()); + assertFalse(r.equals("iron")); + assertFalse(r.matches(null)); + assertFalse(r.matches(new AlloyRecipe("copper", r.getCatalystIds()))); + assertFalse(r.matches(new AlloyRecipe("iron", List.of()))); + assertNull(AlloyRecipe.fromComboKey(null)); + assertNull(AlloyRecipe.fromComboKey(" ")); + assertEquals("iron", AlloyRecipe.fromComboKey("IRON").comboKey()); + assertEquals("iron", AlloyRecipe.fromComboKey("IRON|").fileBaseName()); + var input = new CraftInput("ingredient", "iron", 3, 2); + input.setRevision(4); + assertEquals("ingredient", input.getKind()); + assertEquals("iron", input.getId()); + assertEquals(3, input.getAmount()); + assertEquals(4, input.getRevision()); + assertNull(new CraftInput().getId()); + } + + @Test + void recipesSocketGroupsAndQualityExposeConfiguration() throws Exception { + var group = new SocketGroup("default", yaml("slots:\n fine: [red, blue]")); + assertEquals("default", group.getId()); + assertEquals(List.of("red", "blue"), group.getSlots("fine")); + assertTrue(group.getSlots(null).isEmpty()); + assertTrue(group.getSlots("bad").isEmpty()); + assertEquals(1, group.getAllSlots().size()); + assertTrue(new SocketGroup("empty", yaml("")).getAllSlots().isEmpty()); + var recipe = + recipe( + "recipe: ['metal.2']\n" + + "permission-namespace: SMITH\n" + + "icon: v.iron_sword\n" + + "model-type: sword\n" + + "stat-template: missing"); + assertEquals("sword", recipe.getId()); + assertEquals("%material% Sword", recipe.getName()); + assertEquals("Sword", recipe.getCleanedName()); + assertEquals("SWORD.TEST", recipe.getTemplate()); + assertEquals("v.iron_sword", recipe.getIconPath()); + assertEquals("v.iron_sword", recipe.resolveMenuIconPath()); + assertEquals("smith", recipe.getType()); + assertEquals("metal", recipe.getMainType()); + assertEquals("sword", recipe.getModelType()); + assertEquals("gemstones", recipe.getSocketGroupId()); + assertEquals("smith", recipe.getPermissionNamespace()); + assertTrue(recipe.hasPermissionNamespace()); + assertEquals("missing", recipe.getStatTemplateId()); + assertNull(recipe.getStatTemplate()); + assertEquals(Map.of("metal", 2), recipe.getRecipe()); + assertEquals("weapons", recipe.getCategoryId()); + assertEquals("m.SWORD.TEST", recipe("").resolveMenuIconPath()); + assertEquals("m.SWORD.TEST", recipe("icon: ' '").resolveMenuIconPath()); + assertFalse(recipe("").hasPermissionNamespace()); + assertFalse(recipe("permission-namespace: ' '").hasPermissionNamespace()); + recipe("icon: invalid"); + var quality = new Quality("fine", yaml("name: Fine\namount: 4\nvalue: 3")); + assertEquals("fine", quality.getId()); + assertEquals("Fine", quality.getName()); + assertEquals(4, quality.getAmount()); + assertEquals(3, quality.getValue()); + assertFalse(quality.isValid(3)); + assertTrue(quality.isValid(4)); + var hit = HitLoader.getByString("strike"); + assertEquals("strike", hit.getId()); + assertEquals("Strike", hit.getName()); + assertEquals("v.iron_axe", hit.getTool()); + assertEquals("hammer", hit.getType().getId()); + assertEquals("Hammer", hit.getType().getName()); + var category = CategoryLoader.getByString("weapons"); + assertEquals("weapons", category.getId()); + assertEquals("Weapons", category.getName()); + assertTrue(category.getRecipes().contains(recipe)); + assertEquals("none", category.getPermission()); + } + + @Test + void configuredCategoryPermissionMustNotBeDiscarded() throws Exception { + assertEquals( + "professions.smith", + new RecipeCategory("smith", yaml("name: Smith\npermission: professions.smith")) + .getPermission()); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/EdgeCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/EdgeCoverageTest.java new file mode 100644 index 0000000..f6b3f80 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/EdgeCoverageTest.java @@ -0,0 +1,323 @@ +package net.tfminecraft.advancedcrafting; + +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.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class EdgeCoverageTest extends CoverageSupport { + static Object call(Class cls, String name, Class[] types, Object... args) throws Exception { + var m = cls.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + ItemStack alloyTag(String id) { + var item = new ItemStack(Material.IRON_INGOT); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.alloyId(), PersistentDataType.STRING, id); + item.setItemMeta(meta); + return item; + } + + @Test + void bareItemsAndMetadataDefaultsRemainUnmanaged() { + new Cache(); + new StatToString(); + var item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.PAPER); + assertEquals(0, AcItemTags.getStoredRevision(item)); + assertEquals(-1, AcItemTags.getLoreStart(item)); + assertEquals(-1, AcItemTags.getLoreLen(item)); + assertFalse(AcItemTags.hasStatsLoreFlag(null)); + assertFalse(AcItemTags.hasStatsLoreFlag(item)); + assertFalse(AcItemTags.getStatsLore(item)); + assertFalse(AcItemRefresher.isOutdated(new ItemStack(Material.AIR))); + assertFalse(AcItemLoreRefresher.refresh(item).isChanged()); + assertFalse(AcItemLoreRefresher.refresh(item).isChanged()); + assertTrue(StatToString.getFullString(new StatModifier("damage", -2)).contains("-2")); + } + + @Test + void loreRefreshLoadsMissingAlloysAndSplicesExistingBlocks() throws Exception { + var iron = ingredient("iron", "tier: 2"); + var alloy = + new Alloy("bronze", "Bronze", new AlloyData(iron, new StatData(), new HashMap<>(), null)); + alloy.setRevision(3); + var item = alloyTag("bronze"); + Cache.showIngredientStats = false; + try (var db = + mockConstruction( + AlloyDatabase.class, (m, c) -> when(m.loadAlloy("bronze")).thenReturn(alloy))) { + var changed = AcItemLoreRefresher.refresh(item); + assertTrue(changed.isChanged()); + assertSame(alloy, AlloyManager.getAlloyById("bronze")); + assertFalse(AcItemLoreRefresher.isOutdated(changed.getItem())); + alloy.setRevision(4); + assertTrue(AcItemLoreRefresher.isOutdated(changed.getItem())); + assertTrue(AcItemLoreRefresher.refresh(changed.getItem()).isChanged()); + AlloyManager.removeAlloy("bronze"); + assertEquals( + 4, + call( + AcItemLoreRefresher.class, + "getLiveRevision", + new Class[] {ItemStack.class}, + changed.getItem())); + } + var missing = alloyTag("unknown"); + try (var db = mockConstruction(AlloyDatabase.class)) { + assertTrue(AcItemLoreRefresher.refresh(missing).getError().contains("unknown alloy")); + assertEquals( + 0, + call( + AcItemLoreRefresher.class, + "getLiveRevision", + new Class[] {ItemStack.class}, + missing)); + } + assertEquals( + 0, + call( + AcItemLoreRefresher.class, + "getLiveRevision", + new Class[] {ItemStack.class}, + new ItemStack(Material.PAPER))); + var stale = iron.build(); + IngredientLoader.oList.clear(); + assertEquals( + 0, + call(AcItemLoreRefresher.class, "getLiveRevision", new Class[] {ItemStack.class}, stale)); + } + + @Test + void bucketAndRevisionResolversHandleMissingUnknownAndUnmappedSources() throws Exception { + var iron = ingredient("iron", ""); + var alloy = + new Alloy("bronze", "Bronze", new AlloyData(iron, new StatData(), new HashMap<>(), null)); + AlloyManager.addAlloy(alloy); + for (String key : List.of("invalid", "ingredient.missing", "alloy.missing", "unknown.foo")) + assertNull( + call(BucketStatAverager.class, "resolveStatData", new Class[] {String.class}, key)); + assertSame( + alloy.getData().getStatData(), + call( + BucketStatAverager.class, + "resolveStatData", + new Class[] {String.class}, + "alloy.bronze")); + assertNull( + call( + BucketStatAverager.class, + "normalizeBucketId", + new Class[] {String.class}, + (Object) null)); + assertNull( + call(BucketStatAverager.class, "normalizeBucketId", new Class[] {String.class}, " ")); + assertTrue( + BucketStatAverager.compute(Map.of("alloy.missing", 1, "unknown.foo", 1)) + .getModifiers() + .isEmpty()); + assertEquals( + "alloy.bronze", + MajorityTierResolver.resolveMajorityKey(recipe(""), Map.of("alloy.bronze", 1))); + assertEquals( + "alloy.bronze", + MajorityTierResolver.resolveMajorityKey( + recipe("main-type: wood"), Map.of("alloy.bronze", 1))); + assertEquals( + "ingredient.iron", + MajorityTierResolver.resolveMajorityKey( + recipe("main-type: wood"), Map.of("ingredient.iron", 1))); + for (String key : List.of("ingredient.missing", "alloy.missing", "unknown.foo")) + assertEquals(key, MajorityTierResolver.resolveMajorityKey(recipe(""), Map.of(key, 1))); + assertEquals( + 0., + call( + StatTemplateMath.class, + "getSourceAmount", + new Class[] {StatData.class, String.class}, + new StatData(), + "damage")); + var data = new StatData(); + data.addModifier(new StatModifier("speed", 1)); + assertEquals( + 0., + call( + StatTemplateMath.class, + "getSourceAmount", + new Class[] {StatData.class, String.class}, + data, + "damage")); + for (CraftInput input : + List.of( + new CraftInput("ingredient", "missing", 1, 1), + new CraftInput("alloy", "bronze", 1, 1), + new CraftInput("alloy", "missing", 1, 1), + new CraftInput("other", "missing", 1, 1))) + assertNotNull( + call(StatRefreshDebug.class, "liveRevision", new Class[] {CraftInput.class}, input)); + } + + @Test + void malformedIndexesAndFilesystemFailuresStayRecoverable() throws Exception { + var root = temp.resolve("indexes"); + var store = new AlloyRecipeStore(root.toFile()); + Files.createDirectories(root.resolve("longbase")); + Files.writeString(root.resolve("longbase/x.idx"), "result=old"); + store.updateResultId("old", "new"); + assertTrue(store.loadAllComboResults().isEmpty()); + assertNull( + invoke(store, "recipeFromIndexFile", new Class[] {File.class}, new File("no-parent.idx"))); + assertNull( + invoke( + store, + "recipeFromIndexFile", + new Class[] {File.class}, + root.resolve("file.txt").toFile())); + var index = root.resolve("iron/iron.idx"); + Files.createDirectories(index); + Files.writeString(index.resolve("occupied"), "x"); + store.deleteByCombo("iron"); + assertTrue(Files.exists(index)); + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files + .when(() -> Files.walk(root)) + .thenThrow(new IOException("simulated unavailable filesystem")); + assertTrue(store.loadAllComboResults().isEmpty()); + } + try (var digest = mockStatic(java.security.MessageDigest.class)) { + digest + .when(() -> java.security.MessageDigest.getInstance("SHA-256")) + .thenThrow(new java.security.NoSuchAlgorithmException("unavailable provider")); + assertThrows(RuntimeException.class, () -> RevisionTracker.sha256("x")); + } + } + + @Test + void publicMetadataGuardsHandleEmptyItemsAndUnknownDefinitions() throws Exception { + var plain = mock(ItemStack.class); + when(plain.getType()).thenReturn(Material.PAPER); + CraftTierLore.applyPdc(plain, 0, 1); + CraftTierLore.applyTierLine(plain, 1); + CraftTierLore.refreshTierLine(plain, 1); + assertNull(ScrapProvenance.readBaseId(plain)); + var listener = new MMOItemRebuildListener(); + assertNull(invoke(listener, "readMajorityTier", new Class[] {ItemStack.class}, plain)); + assertNull(invoke(listener, "readTierLoreStart", new Class[] {ItemStack.class}, (Object) null)); + assertNull(invoke(listener, "readTierLoreStart", new Class[] {ItemStack.class}, plain)); + var db = new AlloyDatabase(); + var json = new org.json.simple.JSONObject(); + var jsonField = AlloyDatabase.class.getDeclaredField("json"); + jsonField.setAccessible(true); + jsonField.set(db, new org.json.simple.JSONObject()); + var defaults = new HashMap(); + defaults.put("object", json); + assertSame(json, db.getObject("object", defaults)); + var stored = new org.json.simple.JSONObject(); + stored.put("stored", json); + jsonField.set(db, stored); + assertSame(json, db.getObject("stored", defaults)); + var dataFolder = temp.resolve("new-plugin").toFile(); + when(plugin.getDataFolder()).thenReturn(dataFolder); + doCallRealMethod().when(plugin).createFolders(); + plugin.createFolders(); + assertTrue(dataFolder.isDirectory()); + } + + @Test + void bucketAccumulationSkipsUnavailableStatsAndNonpositiveOverflowTotals() throws Exception { + var iron = ingredient("iron", "stats: ['armor(2)']"); + var copper = ingredient("copper", "stats: ['armor(4)']"); + assertTrue( + BucketStatAverager.compute( + Map.of("ingredient.iron", Integer.MAX_VALUE, "ingredient.copper", 1)) + .getModifiers() + .isEmpty()); + var missing = new Alloy("missing", "Missing", new AlloyData(iron, null, new HashMap<>(), null)); + AlloyManager.addAlloy(missing); + assertTrue(BucketStatAverager.compute(Map.of("alloy.missing", 1)).getModifiers().isEmpty()); + } + + @Test + void stationPersistenceHandlesUuidCollisionAndUnwritableRoot() throws Exception { + var world = server.addSimpleWorld("world"); + var station = + new net.tfminecraft.advancedcrafting.objects.crafting.CraftingStation( + new Location(world, 0, 1, 0)); + station.setRecipe(recipe("")); + var db = new Database(); + var id = UUID.randomUUID(); + try (var ids = mockStatic(UUID.class, CALLS_REAL_METHODS)) { + ids.when(UUID::randomUUID).thenReturn(id); + db.saveStation(station); + db.saveStation(station); + assertEquals(1, Files.list(temp.resolve("data/stations")).count()); + } + var blocked = temp.resolve("blocked"); + Files.writeString(blocked, "file"); + when(plugin.getDataFolder()).thenReturn(blocked.toFile()); + assertDoesNotThrow(() -> db.saveStation(station)); + } + + @Test + void validSocketRegistryAndDuplicateJsonMappingsAreAccepted() throws Exception { + var file = temp.resolve("sockets.yml"); + Files.writeString(file, "gemstones:\n slots:\n fine: [Red]"); + try (var registry = mockStatic(net.tfminecraft.tlibs.socket.SocketTierRegistry.class)) { + registry + .when(() -> net.tfminecraft.tlibs.socket.SocketTierRegistry.getGroup("Red")) + .thenReturn("gemstones"); + new SocketGroupLoader().load(file.toFile()); + assertEquals(List.of("Red"), SocketGroupLoader.getByString("gemstones").getSlots("fine")); + } + var store = new AlloyRecipeStore(temp.resolve("recipes").toFile()); + var field = AdvancedCrafting.class.getDeclaredField("alloyRecipeStore"); + field.setAccessible(true); + field.set(plugin, store); + store.upsert(new AlloyRecipe("iron", List.of()), "bronze"); + var root = temp.resolve("data/alloys"); + Files.createDirectories(root); + for (String filename : List.of("one.json", "two.json")) + Files.writeString( + root.resolve(filename), "{\"id\":\"bronze\",\"recipe\":{\"base\":\"iron\"}}"); + var sender = mock(org.bukkit.command.CommandSender.class); + AlloyRecipeSync.run(sender, false); + verify(sender).sendMessage("§a[AC] No recipe issues found."); + var index = temp.resolve("recipes/iron/iron.idx"); + Files.writeString(index, "combo=iron\nignored\nresult=bronze\n"); + assertEquals("bronze", store.getResultByCombo("iron")); + Files.writeString(index, "unknown=value\nresult=bronze\n"); + assertEquals("bronze", store.getResultByCombo("iron")); + } + + @Test + void craftedRefreshDetectionTracksRevisionChanges() { + try { + var iron = ingredient("iron", ""); + iron.setRevision(1); + var item = new ItemStack(Material.IRON_SWORD); + new CraftProvenance("unknown", "fine", List.of(new CraftInput("ingredient", "iron", 1, 1)), 0) + .applyTo(item); + assertFalse(AcItemRefresher.isOutdated(item)); + iron.setRevision(2); + assertTrue(AcItemRefresher.isOutdated(item)); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java new file mode 100644 index 0000000..06725a1 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/ForgerCoverageTest.java @@ -0,0 +1,277 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import org.bukkit.Location; +import org.junit.jupiter.api.Test; + +class ForgerCoverageTest extends CoverageSupport { + @Test + void xpRangeUsesOnlyMatchingProfessionAndAllowsMissingXp() throws Exception { + var station = new AlloyStation(new Location(server.addSimpleWorld("world"), 0, 1, 2)); + station.addIngredient(ingredient("iron", "base: true")); + assertNull(new AlloyForger(station).getXP()); + station = new AlloyStation(station.getLocation()); + station.addIngredient(ingredient("base", "base: true\nxp: smith(3)")); + station.addIngredient(ingredient("less", "xp: smith(1)")); + station.addIngredient(ingredient("more", "xp: smith(5)")); + station.addIngredient(ingredient("other", "xp: mine(20)")); + station.addIngredient(ingredient("none", "")); + var xp = new AlloyForger(station).getXP(); + assertTrue(xp.startsWith("smith(")); + double value = Double.parseDouble(xp.substring(6, xp.length() - 1)); + assertTrue(value >= 1 && value <= 5); + } + + @Test + void failedStaleRecipeDeletionRespectsRetryLimit() { + var station = mock(AlloyStation.class); + var p = server.addPlayer(); + try (var dbs = + mockConstruction( + AlloyDatabase.class, (db, ctx) -> when(db.getResult(station)).thenReturn("missing"))) { + assertNull(new AlloyForger(station).forge(p, 100)); + assertEquals(1, dbs.constructed().size()); + } + } + + AlloyForger forger(AlloyStation station, double random) throws Exception { + var ctor = + AlloyForger.class.getDeclaredConstructor( + AlloyStation.class, java.util.function.DoubleSupplier.class); + ctor.setAccessible(true); + return ctor.newInstance(station, (java.util.function.DoubleSupplier) () -> random); + } + + StatData stats(AlloyForger f) throws Exception { + var field = AlloyForger.class.getDeclaredField("stats"); + field.setAccessible(true); + return (StatData) field.get(f); + } + + @Test + void forgeReturnsScrapKnownAlloysNewDiscoveriesAndRecoversStaleIndexes() throws Exception { + var p = server.addPlayer(); + var loc = new Location(server.addSimpleWorld("world"), 0, 1, 2); + var station = new AlloyStation(loc); + station.addIngredient(ingredient("iron", "base: true\nhits: ['strike.2']")); + station.addIngredient(ingredient("copper", "hits: ['strike.1']")); + Cache.scrap = "MATERIAL.SCRAP"; + when(net.Indyuce.mmoitems + .MMOItems + .plugin + .getItems() + .getMMOItem(any(), eq("SCRAP")) + .newBuilder() + .build()) + .thenAnswer(i -> new org.bukkit.inventory.ItemStack(org.bukkit.Material.IRON_NUGGET)); + Cache.alloyForgeBaseSuccess = 0; + Cache.alloyForgeBonusPerSqrtValue = 0; + Cache.alloyForgeMaxSuccess = 0; + try (var dbs = mockConstruction(AlloyDatabase.class)) { + assertNull(forger(station, 0).forge(p)); + verify(dbs.constructed().getFirst()).saveRecipe(station, "scrap"); + } + try (var dbs = + mockConstruction( + AlloyDatabase.class, (db, ctx) -> when(db.getResult(station)).thenReturn("scrap"))) { + assertNull(forger(station, 0).forge(p)); + } + var known = mock(Alloy.class); + when(known.getId()).thenReturn("known"); + when(known.build()) + .thenAnswer(i -> new org.bukkit.inventory.ItemStack(org.bukkit.Material.IRON_INGOT)); + AlloyManager.addAlloy(known); + try (var dbs = + mockConstruction( + AlloyDatabase.class, (db, ctx) -> when(db.getResult(station)).thenReturn("known"))) { + assertNull(forger(station, 0).forge(p)); + } + AlloyManager.removeAlloy("known"); + try (var dbs = + mockConstruction( + AlloyDatabase.class, + (db, ctx) -> { + when(db.getResult(station)).thenReturn("known"); + when(db.loadAlloy("known")).thenReturn(known); + })) { + assertNull(forger(station, 0).forge(p)); + assertSame(known, AlloyManager.getAlloyById("known")); + } + Cache.alloyForgeBaseSuccess = 100; + Cache.alloyForgeMaxSuccess = 100; + try (var dbs = mockConstruction(AlloyDatabase.class); + var alloys = + mockConstruction( + Alloy.class, + (a, ctx) -> { + when(a.getId()).thenReturn("new"); + when(a.build()) + .thenReturn( + new org.bukkit.inventory.ItemStack(org.bukkit.Material.GOLD_INGOT)); + })) { + assertNotNull(forger(station, 0).forge(p)); + verify(dbs.constructed().getFirst()).saveAlloy(alloys.constructed().getFirst()); + } + var taken = mock(Alloy.class); + when(taken.getId()).thenReturn("iron"); + AlloyManager.addAlloy(taken); + try (var dbs = mockConstruction(AlloyDatabase.class)) { + assertNull(forger(station, 0).forge(p)); + assertTrue(p.nextMessage().contains("no free alloy names")); + } + AlloyManager.removeAlloy("iron"); + try (var dbs = + mockConstruction( + AlloyDatabase.class, + (db, ctx) -> { + if (ctx.getCount() == 1) when(db.getResult(station)).thenReturn("missing"); + }); + var alloys = + mockConstruction( + Alloy.class, + (a, ctx) -> { + when(a.getId()).thenReturn("new2"); + when(a.build()) + .thenReturn( + new org.bukkit.inventory.ItemStack(org.bukkit.Material.GOLD_INGOT)); + })) { + assertNotNull(forger(station, 0).forge(p)); + verify(dbs.constructed().getFirst()).deleteRecipe(station); + assertEquals(2, dbs.constructed().size()); + } + assertNull(forger(station, 0).forge(p, 101)); + } + + @Test + void deterministicMergeCoversGuaranteedInheritanceCapsAndProtectedStats() throws Exception { + Cache.maxFactor = 1.5; + Cache.gemstoneStatBaseChance = 100; + Cache.gemstoneStatBonusPerValue = 0; + var station = new AlloyStation(new Location(server.addSimpleWorld("world"), 0, 1, 2)); + station.addIngredient( + ingredient( + "iron", + "base: true\n" + + "value: 100\n" + + "stats: ['armor(10)', 'protected(2)']\n" + + "protected-stats: [protected]")); + station.addIngredient( + ingredient( + "gem", "path: m.gemstones.ruby\nstats: ['armor(2)', 'speed(4)', 'protected(3)']")); + station.addIngredient(ingredient("gem2", "path: m.gemstones.emerald\nstats: ['speed(2)']")); + station.addIngredient(ingredient("gem3", "path: m.gemstones.sapphire\nstats: ['speed(8)']")); + var f = forger(station, .5); + f.mergeStats(); + assertEquals( + 12, + stats(f) + .getAmount( + new net.tfminecraft.advancedcrafting.objects.stats.StatModifier("armor", 0))); + assertEquals( + 12, + stats(f) + .getAmount( + new net.tfminecraft.advancedcrafting.objects.stats.StatModifier("speed", 0))); + assertEquals( + 2, + stats(f) + .getAmount( + new net.tfminecraft.advancedcrafting.objects.stats.StatModifier("protected", 0))); + Cache.gemstoneStatBaseChance = 0; + f = forger(station, .5); + f.mergeStats(); + assertEquals( + 0, + stats(f) + .getAmount( + new net.tfminecraft.advancedcrafting.objects.stats.StatModifier("speed", 0))); + for (int value : List.of(0, 100)) + for (double random : List.of(0., .99)) { + var s = new AlloyStation(station.getLocation()); + s.addIngredient( + ingredient( + "base" + value + random, "base: true\nvalue: " + value + "\nstats: ['armor(10)']")); + s.addIngredient( + ingredient( + "catalyst" + value + random, + "value: 0\nstats: ['armor(2)', 'new(3)', 'negative(-2)']")); + var forged = forger(s, random); + forged.mergeStats(); + assertTrue(stats(forged).hasModifiers()); + } + } + + @Test + void deterministicXpAndNameFallbacksRetainOriginalSelectionRules() throws Exception { + var loc = new Location(server.addSimpleWorld("world"), 0, 1, 2); + var station = new AlloyStation(loc); + station.addIngredient(ingredient("iron", "base: true\nxp: smith(3)")); + assertEquals("smith(3.0)", forger(station, 0).getXP()); + var names = net.tfminecraft.advancedcrafting.loaders.SchemeLoader.names.get("default"); + names.getNames().add("Second"); + var taken = mock(Alloy.class); + when(taken.getId()).thenReturn("iron"); + AlloyManager.addAlloy(taken); + assertEquals("Second", invoke(forger(station, .99), "getName", new Class[] {})); + names.getNames().remove("Second"); + net.tfminecraft.advancedcrafting.loaders.SchemeLoader.names.put( + "backup", + new net.tfminecraft.advancedcrafting.objects.schemes.NamingScheme( + "backup", yaml("names: [Backup]\ncolour-scheme: default"))); + station.addIngredient(ingredient("copper", "scheme: backup")); + assertEquals("Backup", invoke(forger(station, 0), "getName", new Class[] {})); + } + + @Test + void mergeHelperPreservesMissingBaseAndClampsNegativeMultiplier() throws Exception { + var catalyst = new net.tfminecraft.advancedcrafting.objects.stats.StatModifier("armor", 3); + var method = + AlloyForger.class.getDeclaredMethod( + "modifierForMerge", catalyst.getClass(), catalyst.getClass(), boolean.class); + method.setAccessible(true); + assertEquals( + 3, + ((net.tfminecraft.advancedcrafting.objects.stats.StatModifier) + method.invoke(null, catalyst, null, false)) + .getAmount()); + var station = new AlloyStation(new Location(server.addSimpleWorld("world"), 0, 1, 0)); + var f = forger(station, 0); + var value = AlloyForger.class.getDeclaredField("value"); + value.setAccessible(true); + value.setInt(f, 10); + assertEquals( + 0., invoke(f, "randomize", new Class[] {String.class, double.class}, "negative", -2.)); + var base = new HashMap(); + base.put("uncapped", catalyst); + invoke( + f, + "merge", + new Class[] {HashMap.class, HashMap.class, List.class}, + base, + new HashMap<>(), + List.of()); + assertSame(catalyst, base.get("uncapped")); + Cache.scrap = "MATERIAL.SCRAP"; + when(net.Indyuce.mmoitems + .MMOItems + .plugin + .getItems() + .getMMOItem(any(), eq("SCRAP")) + .newBuilder() + .build()) + .thenReturn(new org.bukkit.inventory.ItemStack(org.bukkit.Material.IRON_NUGGET)); + try (var dbs = + mockConstruction( + AlloyDatabase.class, (db, ctx) -> when(db.getResult(station)).thenReturn("scrap"))) { + assertNull(f.forge(server.addPlayer())); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/FormatterCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/FormatterCoverageTest.java new file mode 100644 index 0000000..f655c7c --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/FormatterCoverageTest.java @@ -0,0 +1,162 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class FormatterCoverageTest extends CoverageSupport { + AlloyRecipeStore store() throws Exception { + var store = new AlloyRecipeStore(temp.resolve("recipes").toFile()); + var field = AdvancedCrafting.class.getDeclaredField("alloyRecipeStore"); + field.setAccessible(true); + field.set(plugin, store); + return store; + } + + @Test + void craftInspectorReportsRecipeInputsAndRevisionChanges() throws Exception { + var p = server.addPlayer(); + CraftInspectFormatter.send(p); + assertTrue(p.nextMessage().contains("Hold")); + var iron = ingredient("iron", ""); + iron.setRevision(2); + var alloy = + new Alloy("bronze", "Bronze", new AlloyData(iron, new StatData(), new HashMap<>(), null)); + alloy.setRevision(3); + AlloyManager.addAlloy(alloy); + var inputs = + List.of( + new CraftInput("ingredient", "iron", 1, 1), + new CraftInput("ingredient", "missing", 1, 0), + new CraftInput("alloy", "bronze", 1, 3), + new CraftInput("alloy", "missing", 1, 0), + new CraftInput("other", "missing", 1, 0)); + var provenance = new CraftProvenance("sword", "fine", inputs, 0); + var item = new ItemStack(Material.IRON_SWORD); + provenance.applyTo(item); + CraftInspectFormatter.send(p, item); + assertTrue(p.nextMessage().contains("Craft inspect")); + var recipe = recipe(""); + RecipeLoader.map.put("sword", recipe); + provenance.syncInputRevisions(); + provenance.applyTo(item); + CraftInspectFormatter.send(p, item); + var messages = new ArrayList(); + String msg; + while ((msg = p.nextMessage()) != null) messages.add(msg); + assertTrue(messages.stream().anyMatch(s -> s.contains("(unknown)"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("(Sword)"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("Outdated: §aNo"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("live 2"))); + } + + @Test + void alloyInfoExplainsMissingMappingsCollisionsAndTruncatesRecipeLists() throws Exception { + var store = store(); + var p = server.addPlayer(); + AlloyInfoFormatter.send(p, "MISSING"); + assertTrue(p.nextMessage().contains("Unknown alloy")); + var iron = ingredient("iron", ""); + var data = new AlloyData(iron, new StatData(), new HashMap<>(), null); + var alloy = new Alloy("bronze", "Bronze", data); + AlloyManager.addAlloy(alloy); + AlloyInfoFormatter.send(p, "BRONZE"); + var recipe = new AlloyRecipe("iron", List.of("zinc")); + data.setRecipe(recipe); + AlloyInfoFormatter.send(p, "bronze"); + store.upsert(recipe, "wrong"); + AlloyInfoFormatter.send(p, "bronze"); + store.upsert(recipe, "bronze"); + for (int i = 0; i < 22; i++) { + var craft = + new CraftingRecipe( + "sword" + i, yaml("name: Sword\ncategory: weapons\nrecipe: ['metal.2']")); + RecipeLoader.map.put(craft.getId(), craft); + } + RecipeLoader.map.put("empty", recipe("")); + AlloyInfoFormatter.send(p, "bronze"); + var messages = new ArrayList(); + String msg; + while ((msg = p.nextMessage()) != null) messages.add(msg); + assertTrue(messages.stream().anyMatch(s -> s.contains("not set"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("no row"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("mismatch"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("this alloy"))); + assertTrue(messages.stream().anyMatch(s -> s.contains("2 §7more"))); + Files.createDirectories(temp.resolve("data/alloys")); + data.setRecipe(null); + Path file = temp.resolve("data/alloys/bronze.json"); + for (String content : + List.of( + "{}", + "bad", + "{\"recipe\":{\"base\":\"iron\"}}", + "{\"recipe\":{\"base\":\"iron\",\"catalysts\":[\"zinc\"]}}")) { + Files.writeString(file, content); + AlloyInfoFormatter.send(p, "bronze"); + } + AlloyManager.removeAlloy("bronze"); + try (var db = + mockConstruction( + AlloyDatabase.class, (mock, ctx) -> when(mock.loadAlloy("bronze")).thenReturn(alloy))) { + AlloyInfoFormatter.send(p, "bronze"); + } + } + + @Test + void recipeSyncAuditsRepairsAndDetectsMalformedOrConflictingJson() throws Exception { + var store = store(); + var sender = mock(CommandSender.class); + AlloyRecipeSync.run(sender, false); + verify(sender).sendMessage("§cAlloys folder not found."); + var root = temp.resolve("data/alloys"); + Files.createDirectories(root.getParent()); + Files.writeString(root, "file"); + AlloyRecipeSync.run(sender, false); + verify(sender).sendMessage("§cCould not read alloys folder."); + Files.delete(root); + Files.createDirectories(root); + AlloyRecipeSync.run(sender, false); + verify(sender).sendMessage("§a[AC] No recipe issues found."); + Files.createDirectory(root.resolve("dir")); + Files.writeString(root.resolve("ignored.txt"), "ignored"); + Files.writeString(root.resolve("bad.json"), "bad"); + Files.writeString(root.resolve("norecipe.json"), "{\"id\":\"none\"}"); + Files.writeString(root.resolve("nullrecipe.json"), "{\"id\":\"null\",\"recipe\":null}"); + Files.writeString(root.resolve("emptyrecipe.json"), "{\"id\":\"empty\",\"recipe\":{}}"); + Files.writeString( + root.resolve("bronze.json"), + "{\"id\":\"bronze\",\"recipe\":{\"base\":\"iron\",\"catalysts\":[\"zinc\"]}}"); + Files.writeString( + root.resolve("steel.json"), + "{\"id\":\"steel\",\"recipe\":{\"base\":\"iron\",\"catalysts\":[\"zinc\"]}}"); + Files.writeString( + root.resolve("bronze2.json"), "{\"id\":\"bronze\",\"recipe\":{\"base\":\"iron\"}}"); + Files.writeString( + root.resolve("scrap.json"), "{\"id\":\"scrap\",\"recipe\":{\"base\":\"wood\"}}"); + store.upsert(new AlloyRecipe("unrelated", List.of()), "bronze"); + store.upsert(new AlloyRecipe("extra", List.of()), "bronze"); + store.upsert(new AlloyRecipe("scrap", List.of()), "scrap"); + store.upsert(new AlloyRecipe("iron", List.of("zinc")), "wrong"); + AlloyRecipeSync.run(sender, false); + assertEquals("wrong", store.getResultByCombo("iron|zinc")); + AlloyRecipeSync.run(sender, true); + assertNotEquals("wrong", store.getResultByCombo("iron|zinc")); + assertEquals("bronze", store.getResultByCombo("iron")); + verify(sender, atLeastOnce()).sendMessage(contains("issue(s)")); + verify(sender).sendMessage(contains("Repaired")); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java new file mode 100644 index 0000000..235cbae --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java @@ -0,0 +1,111 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import dev.lone.itemsadder.api.CustomStack; +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.utils.PDCKeys; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import org.bukkit.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class IngredientManagerCoverageTest extends CoverageSupport { + @Test + void conversionResolvesVanillaMmoAndItemsAdderItems() throws Exception { + var manager = new IngredientManager(); + var iron = ingredient("iron", ""); + var map = + new HashMap(); + map.put("v.iron_ingot", iron); + map.put("m.material.iron", iron); + map.put("ia.tfmc:iron", iron); + manager.set(map); + assertNull(manager.get("missing")); + var item = new ItemStack(Material.IRON_INGOT); + try (var nbt = mockStatic(NBTItem.class); + var custom = mockStatic(CustomStack.class)) { + var data = mock(NBTItem.class); + nbt.when(() -> NBTItem.get(item)).thenReturn(data); + assertSame(iron, manager.getFromItem(item)); + when(data.hasType()).thenReturn(true); + when(data.getType()).thenReturn("MATERIAL"); + when(data.getString("MMOITEMS_ITEM_ID")).thenReturn("IRON"); + assertSame(iron, manager.getFromItem(item)); + when(data.hasType()).thenReturn(false); + var stack = mock(CustomStack.class); + when(stack.getNamespacedID()).thenReturn("tfmc:iron"); + custom.when(() -> CustomStack.byItemStack(item)).thenReturn(stack); + assertSame(iron, manager.getFromItem(item)); + } + } + + @Test + void converterIgnoresOtherBlocksAndShowsPreviewForManagedOrConvertibleMaterials() + throws Exception { + var manager = new IngredientManager(); + var player = server.addPlayer(); + var block = server.addSimpleWorld("world").getBlockAt(0, 1, 0); + Cache.ingredientStation = "v.observer"; + var blocks = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + tlibs.when(net.tfminecraft.tlibs.TLibs::getBlockAPI).thenReturn(blocks); + var event = + new PlayerInteractEvent( + player, Action.LEFT_CLICK_BLOCK, null, block, org.bukkit.block.BlockFace.UP); + manager.convertItem(event); + assertFalse(event.isCancelled()); + event = + new PlayerInteractEvent( + player, Action.RIGHT_CLICK_BLOCK, null, block, org.bukkit.block.BlockFace.UP); + manager.convertItem(event); + assertFalse(event.isCancelled()); + when(blocks.getChecker().checkBlock(block, Cache.ingredientStation)).thenReturn(true); + manager.convertItem(event); + assertFalse(event.isCancelled()); + var iron = ingredient("iron", "stats: ['armor(3)']"); + manager.set(new HashMap<>(Map.of("v.iron_ingot", iron))); + try (var nbt = mockStatic(NBTItem.class); + var custom = mockStatic(CustomStack.class); + var menu = mockConstruction(InventoryManager.class)) { + var data = mock(NBTItem.class); + nbt.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(data); + player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + manager.convertItem(event); + assertFalse(event.isCancelled()); + player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_INGOT)); + manager.convertItem(event); + assertTrue(event.isCancelled()); + verify(menu.constructed().getFirst()) + .templatePreviewView(player, iron.getIngredientData().getStatData()); + manager.convertItem(event); + assertEquals(2, menu.constructed().size()); + var alloy = + new Alloy("bronze", "Bronze", new AlloyData(iron, new StatData(), new HashMap<>(), null)); + AlloyManager.addAlloy(alloy); + var item = new ItemStack(Material.IRON_INGOT); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.alloyId(), PersistentDataType.STRING, "bronze"); + item.setItemMeta(meta); + player.getInventory().setItemInMainHand(item); + manager.convertItem(event); + assertEquals(3, menu.constructed().size()); + AlloyManager.removeAlloy("bronze"); + manager.convertItem(event); + assertEquals(4, menu.constructed().size()); + item = iron.build(); + net.tfminecraft.advancedcrafting.loaders.IngredientLoader.oList.clear(); + player.getInventory().setItemInMainHand(item); + manager.convertItem(event); + assertEquals(5, menu.constructed().size()); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java new file mode 100644 index 0000000..7ae22fe --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java @@ -0,0 +1,316 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.objects.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class ItemMetadataCoverageTest extends CoverageSupport { + ItemStack tagged(NamespacedKey key, String value) { + var item = new ItemStack(Material.IRON_INGOT); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(key, PersistentDataType.STRING, value); + item.setItemMeta(meta); + return item; + } + + @Test + void ingredientBuildingPreservesCustomNamesAndTracksLoreRevision() throws Exception { + var ingredient = ingredient("iron", "base: true\ntier: 3\nstats: ['attack(3)']"); + ingredient.setRevision(2); + var item = ingredient.build(); + assertEquals(Material.IRON_INGOT, item.getType()); + assertTrue(item.getItemMeta().hasDisplayName()); + assertEquals(AcItemTags.Kind.INGREDIENT, AcItemTags.getKind(item)); + assertEquals("iron", AcItemTags.getId(item)); + assertEquals(2, AcItemTags.getStoredRevision(item)); + assertEquals(0, AcItemTags.getLoreStart(item)); + assertEquals(3, AcItemTags.getLoreLen(item)); + assertTrue(AcItemTags.hasStatsLoreFlag(item)); + assertTrue(AcItemTags.getStatsLore(item)); + var meta = item.getItemMeta(); + meta.setDisplayName("Custom"); + meta.setLore(List.of("Story")); + item.setItemMeta(meta); + ingredient.buildTo(item); + assertEquals("Custom", item.getItemMeta().getDisplayName()); + assertEquals("Story", item.getItemMeta().getLore().getFirst()); + assertEquals(2, AcItemTags.getLoreStart(item)); + var bare = new ItemStack(Material.IRON_INGOT); + assertNull(AcItemTags.getKind(null)); + assertNull(AcItemTags.getKind(bare)); + assertNull(AcItemTags.getId(bare)); + assertEquals(0, AcItemTags.getStoredRevision(bare)); + assertEquals(-1, AcItemTags.getLoreStart(bare)); + assertEquals(-1, AcItemTags.getLoreLen(bare)); + assertFalse(AcItemTags.hasStatsLoreFlag(null)); + assertFalse(AcItemTags.hasStatsLoreFlag(bare)); + assertFalse(AcItemTags.getStatsLore(bare)); + bare = tagged(PDCKeys.alloyId(), "bronze"); + assertEquals(AcItemTags.Kind.ALLOY, AcItemTags.getKind(bare)); + assertEquals("bronze", AcItemTags.getId(bare)); + assertEquals(0, AcItemTags.getStoredRevision(bare)); + assertEquals(-1, AcItemTags.getLoreStart(bare)); + assertEquals(-1, AcItemTags.getLoreLen(bare)); + assertFalse(AcItemTags.getStatsLore(bare)); + Cache.showIngredientStats = false; + AcItemTags.write(bare, 7, new IngredientLore.Block(1, 2)); + assertFalse(AcItemTags.getStatsLore(bare)); + AcItemTags.write((ItemStack) null, 0, new IngredientLore.Block(0, 0)); + AcItemTags.write(new ItemStack(Material.AIR), 0, new IngredientLore.Block(0, 0)); + } + + @Test + void loreSplicingPreservesAdjacentTextAndSupportsAllTierFormats() throws Exception { + var base = ingredient("iron", "base: true\ntier: 2\nstats: ['armor(2)']").getIngredientData(); + for (List initial : + List.of(List.of(), List.of("§7 ", "keep"), List.of("keep"))) { + var lore = new ArrayList<>(initial); + var block = IngredientLore.applyTypeAndRole(lore, base); + assertEquals(3, block.length); + assertTrue(lore.get(block.start).contains("Type:")); + var replaced = IngredientLore.spliceTypeAndRole(lore, block.start, block.length, base); + assertEquals(block.start, replaced.start); + } + var lore = new ArrayList(); + lore.add(null); + IngredientLore.appendTypeAndRole(lore, base); + assertTrue(lore.getFirst().contains("Type:")); + assertEquals(0, IngredientLore.spliceTypeAndRole(lore, -1, -2, base).start); + int size = lore.size(); + assertEquals(size, IngredientLore.spliceTypeAndRole(lore, 100, 2, base).start); + assertEquals( + 3, + IngredientLore.applyAlloyLore(new ArrayList<>(), base.getType(), 4, base.getStatData()) + .length); + assertEquals( + 2, IngredientLore.spliceAlloyLore(new ArrayList<>(), 0, 2, base.getType(), 1, null).length); + assertEquals( + 2, + IngredientLore.applyAlloyLore(new ArrayList<>(), base.getType(), 1, new StatData()).length); + Cache.showIngredientStats = false; + assertEquals(2, IngredientLore.applyTypeAndRole(new ArrayList<>(), base).length); + for (int tier : List.of(1, 2, 3, 4, 5)) + assertTrue(IngredientLore.formatTierLine(tier).contains("Tier")); + assertTrue(IngredientLore.formatCatalystLine().contains("Catalyst")); + var noTier = ingredient("copper", "base: true").getIngredientData(); + assertTrue(IngredientLore.applyTypeAndRole(new ArrayList<>(), noTier).length > 0); + assertEquals(1, IngredientLore.resolveAlloyTier(null, "none")); + assertEquals(1, IngredientLore.resolveAlloyTier(new AlloyRecipe("missing", List.of()), "none")); + assertEquals(1, IngredientLore.resolveAlloyTier(new AlloyRecipe("copper", List.of()), "none")); + assertEquals(2, IngredientLore.resolveAlloyTier(new AlloyRecipe("iron", List.of()), "steel")); + } + + @Test + void scrapProvenanceAndCraftStackRecognizeIndependentTags() throws Exception { + ItemStack bare = new ItemStack(Material.IRON_NUGGET); + ScrapProvenance.applyTo(null, "iron"); + ScrapProvenance.applyTo(bare, null); + ScrapProvenance.applyTo(bare, " "); + ScrapProvenance.applyTo(new ItemStack(Material.AIR), "iron"); + assertNull(ScrapProvenance.readBaseId(null)); + assertNull(ScrapProvenance.readBaseId(bare)); + ScrapProvenance.applyTo(bare, "IRON"); + assertEquals("iron", ScrapProvenance.readBaseId(bare)); + for (ItemStack item : + Arrays.asList(null, new ItemStack(Material.AIR), new ItemStack(Material.IRON_INGOT))) { + var stack = new CraftStack(item); + assertFalse(stack.isIngredient()); + assertFalse(stack.isAlloy()); + assertFalse(stack.isCrafted()); + assertNull(stack.getIngredient()); + assertNull(stack.getAlloy()); + assertFalse(stack.hasOutdatedInputs()); + assertTrue(stack.getOutdatedInputs().isEmpty()); + } + var ingredient = ingredient("iron", ""); + var stack = new CraftStack(ingredient.build()); + assertTrue(stack.isIngredient()); + assertSame(ingredient, stack.getIngredient()); + assertFalse(stack.isCrafted()); + assertFalse(stack.hasOutdatedInputs()); + var alloy = + new Alloy( + "bronze", "Bronze", new AlloyData(ingredient, new StatData(), new HashMap<>(), null)); + AlloyManager.addAlloy(alloy); + stack = new CraftStack(tagged(PDCKeys.alloyId(), "bronze")); + assertTrue(stack.isAlloy()); + assertSame(alloy, stack.getAlloy()); + } + + @Test + void craftProvenanceRoundTripsAndDetectsIngredientAndTemplateRevisions() throws Exception { + var ingredient = ingredient("iron", ""); + ingredient.setRevision(3); + var alloy = + new Alloy( + "bronze", "Bronze", new AlloyData(ingredient, new StatData(), new HashMap<>(), null)); + alloy.setRevision(2); + AlloyManager.addAlloy(alloy); + var template = new StatTemplate("blade", yaml("stats: [attack]")); + template.setRevision(4); + StatTemplateLoader.get().put("blade", template); + var recipe = recipe("stat-template: blade"); + RecipeLoader.map.put("sword", recipe); + var quality = new Quality("fine", yaml("name: Fine")); + var materials = + new HashMap( + Map.of( + "invalid", + 1, + "ingredient.iron", + 2, + "ingredient.missing", + 1, + "alloy.bronze", + 1, + "alloy.missing", + 1, + "other.thing", + 1)); + var provenance = CraftProvenance.from(recipe, materials, quality); + assertEquals("sword", provenance.getRecipeId()); + assertEquals("fine", provenance.getQualityId()); + assertEquals(5, provenance.getInputs().size()); + assertEquals(4, provenance.getStatTemplateRevision()); + assertFalse(provenance.isOutdated()); + ingredient.setRevision(4); + assertTrue(provenance.isOutdated()); + assertEquals(1, provenance.getOutdatedInputs().size()); + var item = new ItemStack(Material.IRON_SWORD); + provenance.applyTo(item); + assertEquals(5, CraftProvenance.readFrom(item).getInputs().size()); + var stack = new CraftStack(item); + assertTrue(stack.isCrafted()); + assertTrue(stack.hasOutdatedInputs()); + assertEquals(1, stack.getOutdatedInputs().size()); + provenance.syncInputRevisions(); + assertFalse(provenance.isOutdated()); + template.setRevision(5); + assertTrue(provenance.isStatTemplateOutdated()); + assertTrue(provenance.isOutdated()); + provenance.syncRevisions(); + assertFalse(provenance.isOutdated()); + RecipeLoader.map.clear(); + provenance.syncStatTemplateRevision(); + assertEquals(0, provenance.getStatTemplateRevision()); + assertFalse(provenance.isStatTemplateOutdated()); + assertEquals("", CraftProvenance.from(recipe(""), new HashMap<>(), null).getQualityId()); + provenance.applyTo(null); + provenance.applyTo(new ItemStack(Material.AIR)); + assertNull(CraftProvenance.readFrom(null)); + assertNull(CraftProvenance.readFrom(new ItemStack(Material.IRON_SWORD))); + assertNull(CraftProvenance.readFrom(tagged(PDCKeys.alloyId(), "bronze"))); + var minimal = tagged(PDCKeys.craftRecipe(), "sword"); + var parsed = CraftProvenance.readFrom(minimal); + assertEquals("", parsed.getQualityId()); + assertTrue(parsed.getInputs().isEmpty()); + assertEquals(0, parsed.getStatTemplateRevision()); + var meta = minimal.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.craftInputs(), PersistentDataType.STRING, "null"); + minimal.setItemMeta(meta); + assertTrue(CraftProvenance.readFrom(minimal).getInputs().isEmpty()); + assertNotNull(new CraftProvenance().getInputs()); + } + + @Test + void tierLoreUsesFreeLinesAndRefreshesTheRecordedSlot() { + for (var initial : + List.of(List.of(), List.of(" "), List.of("keep", " "), List.of("keep"))) { + var lore = new ArrayList<>(initial); + int at = CraftTierLore.insertTierLine(lore, 2); + assertTrue(lore.get(at).contains("Tier")); + CraftTierLore.updateTierLine(lore, at, 3); + assertTrue(lore.get(at).contains("III")); + } + var lore = new ArrayList(); + lore.add(null); + assertEquals(0, CraftTierLore.insertTierLine(lore, 1)); + assertEquals(-1, CraftTierLore.insertTierLine(lore, 0)); + CraftTierLore.updateTierLine(lore, -1, 2); + CraftTierLore.updateTierLine(lore, 2, 0); + CraftTierLore.updateTierLine(lore, 4, 4); + assertEquals(5, lore.size()); + for (ItemStack item : Arrays.asList(null, new ItemStack(Material.PAPER))) { + CraftTierLore.applyPdc(item, 0, 1); + CraftTierLore.applyTierLine(item, 1); + CraftTierLore.refreshTierLine(item, 1); + } + var item = tagged(PDCKeys.craftRecipe(), "sword"); + CraftTierLore.applyPdc(item, -1, 1); + CraftTierLore.applyPdc(item, 0, 0); + CraftTierLore.applyTierLine(item, 0); + CraftTierLore.refreshTierLine(item, 0); + CraftTierLore.refreshTierLine(item, 1); + CraftTierLore.applyTierLine(item, 1); + CraftTierLore.applyTierLine(item, 2); + CraftTierLore.refreshTierLine(item, 3); + assertTrue(item.getItemMeta().getLore().getFirst().contains("III")); + var meta = item.getItemMeta(); + meta.setLore(null); + item.setItemMeta(meta); + CraftTierLore.refreshTierLine(item, 4); + assertTrue(item.getItemMeta().getLore().getFirst().contains("IV")); + } + + @Test + void itemLoreRefreshPreservesStackSizeAndUserText() throws Exception { + var ingredient = ingredient("iron", "base: true\ntier: 2"); + ingredient.setRevision(1); + var item = ingredient.build(); + item.setAmount(7); + assertFalse(AcItemLoreRefresher.refreshIfOutdated(item).isChanged()); + ingredient.setRevision(2); + assertTrue(AcItemLoreRefresher.isOutdated(item)); + var result = AcItemLoreRefresher.refreshIfOutdated(item); + assertTrue(result.isChanged()); + assertNull(result.getError()); + assertEquals(7, result.getItem().getAmount()); + assertEquals(2, AcItemTags.getStoredRevision(result.getItem())); + item = tagged(PDCKeys.ingredientId(), "iron"); + assertTrue(AcItemLoreRefresher.isOutdated(item)); + assertTrue(AcItemLoreRefresher.refresh(item).isChanged()); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.loreStart(), PersistentDataType.INTEGER, 0); + item.setItemMeta(meta); + assertTrue(AcItemLoreRefresher.isOutdated(item)); + Cache.showIngredientStats = false; + assertTrue(AcItemLoreRefresher.isOutdated(result.getItem())); + assertTrue( + AcItemLoreRefresher.refresh(tagged(PDCKeys.ingredientId(), "missing")) + .getError() + .contains("unknown ingredient")); + for (ItemStack invalid : + Arrays.asList( + null, + new ItemStack(Material.AIR), + new ItemStack(Material.PAPER), + tagged(PDCKeys.craftRecipe(), "x"))) { + assertFalse(AcItemLoreRefresher.isOutdated(invalid)); + assertFalse(AcItemLoreRefresher.refreshIfOutdated(invalid).isChanged()); + assertFalse(AcItemLoreRefresher.refresh(invalid).isChanged()); + } + assertTrue(AcItemRefresher.isManaged(ingredient.build())); + assertTrue(AcItemRefresher.isOutdated(item)); + assertNotSame(item, AcItemRefresher.refreshIfOutdated(item)); + for (ItemStack invalid : + Arrays.asList(null, new ItemStack(Material.AIR), new ItemStack(Material.PAPER))) { + assertFalse(AcItemRefresher.isManaged(invalid)); + assertFalse(AcItemRefresher.isOutdated(invalid)); + assertSame(invalid, AcItemRefresher.refreshIfOutdated(invalid)); + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/LifecycleCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/LifecycleCoverageTest.java new file mode 100644 index 0000000..e96e296 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/LifecycleCoverageTest.java @@ -0,0 +1,65 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.lifecycle.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class LifecycleCoverageTest extends CoverageSupport { + @Test + void eventPayloadsAndHandlerListsMatchTheCraftingOutcome() { + var p = server.addPlayer(); + var id = p.getUniqueId(); + var a = new AlloyCraftedEvent(p, id, "bronze"); + assertSame(p, a.getPlayer()); + assertEquals(id, a.getPlayerUuid()); + assertEquals("bronze", a.getAlloyId()); + assertSame(AlloyCraftedEvent.getHandlerList(), a.getHandlers()); + var d = new AlloyDiscoveredEvent(p, id, "bronze"); + assertSame(p, d.getPlayer()); + assertEquals(id, d.getPlayerUuid()); + assertEquals("bronze", d.getAlloyId()); + assertSame(AlloyDiscoveredEvent.getHandlerList(), d.getHandlers()); + var h = new SmithingHitEvent(p, id, "hammer"); + assertSame(p, h.getPlayer()); + assertEquals(id, h.getPlayerUuid()); + assertEquals("hammer", h.getHitId()); + assertSame(SmithingHitEvent.getHandlerList(), h.getHandlers()); + var c = new ItemCraftedEvent(p, id, "sword", "weapons"); + assertSame(p, c.getPlayer()); + assertEquals(id, c.getPlayerUuid()); + assertEquals("sword", c.getRecipeId()); + assertEquals("weapons", c.getCategoryId()); + assertSame(ItemCraftedEvent.getHandlerList(), c.getHandlers()); + } + + @Test + void lifecycleEmitsDiscoveryOnceAndRejectsMissingIdentifiers() { + var p = server.addPlayer(); + CraftLifecycle.init(null); + assertNull(CraftLifecycle.getTracker()); + CraftLifecycle.fireAlloyOutcome(p, "BRONZE"); + CraftLifecycle.fireAlloyCrafted(p, "BRONZE"); + var tracker = new PlayerAlloyForgeTracker(temp.toFile()); + CraftLifecycle.init(tracker); + assertSame(tracker, CraftLifecycle.getTracker()); + CraftLifecycle.fireAlloyOutcome(p, "STEEL"); + CraftLifecycle.fireAlloyOutcome(p, "steel"); + CraftLifecycle.fireItemCrafted(p, "sword", null); + CraftLifecycle.fireItemCrafted(p, "sword", "WEAPONS"); + CraftLifecycle.fireSmithingHit(p, "HAMMER"); + for (Player player : Arrays.asList(null, p)) + for (String id : Arrays.asList(null, " ")) { + CraftLifecycle.fireAlloyOutcome(player, id); + CraftLifecycle.fireAlloyCrafted(player, id); + CraftLifecycle.fireItemCrafted(player, id, "weapons"); + CraftLifecycle.fireSmithingHit(player, id); + } + server.getPluginManager().assertEventFired(AlloyCraftedEvent.class); + server.getPluginManager().assertEventFired(AlloyDiscoveredEvent.class); + server.getPluginManager().assertEventFired(ItemCraftedEvent.class); + server.getPluginManager().assertEventFired(SmithingHitEvent.class); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java new file mode 100644 index 0000000..9407302 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java @@ -0,0 +1,240 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.junit.jupiter.api.Test; + +class LoaderCoverageTest extends CoverageSupport { + File config(String name, String text) throws Exception { + var file = temp.resolve(name); + Files.writeString(file, text); + return file.toFile(); + } + + @Test + void yamlLoadersPopulateRegistriesAndOfferMissingLookups() throws Exception { + var schemes = new SchemeLoader(); + schemes.loadColourSchemes(config("colour.yml", "blue:\n models: [5]\n colours: [aabbcc]")); + schemes.loadNamingSchemes(config("name.yml", "blue:\n colour-scheme: blue\n names: [Blue]")); + schemes.loadModelSchemes(config("model.yml", "blue:\n models: ['sword(5)']")); + assertNotNull(SchemeLoader.getNamingSchemes().get("blue")); + assertNotNull(SchemeLoader.getColourSchemes().get("blue")); + assertNull(SchemeLoader.getNamingSchemeByString("missing")); + assertNull(SchemeLoader.getColourSchemeByString("missing")); + assertNull(SchemeLoader.getModelSchemeByString("missing")); + var types = new TypeLoader(); + types.loadIngredientTypes(config("types.yml", "wood:\n name: Wood")); + types.loadHitTypes(config("hits.yml", "bend:\n name: Bend")); + assertNotNull(TypeLoader.getIngredientTypes().get("wood")); + assertNull(TypeLoader.getIngredientTypeByString("unknown")); + assertNull(TypeLoader.getHitTypeByString("unknown")); + new HitLoader().load(config("tools.yml", "bend:\n name: Bend\n type: bend\n tool: v.stick")); + assertNotNull(HitLoader.get().get("bend")); + assertNotNull(HitLoader.getByTool("V.STICK")); + assertNull(HitLoader.getByString("missing")); + assertNull(HitLoader.getByTool("missing")); + new QualityLoader() + .load( + config( + "qualities.yml", + "poor:\n" + + " name: Poor\n" + + " amount: 0\n" + + " value: 0\n" + + "fine:\n" + + " name: Fine\n" + + " amount: 10\n" + + " value: 1")); + assertEquals("fine", QualityLoader.getByAmount(10).getId()); + assertEquals("poor", QualityLoader.getByAmount(9).getId()); + assertNull(QualityLoader.getByAmount(-1)); + assertNotNull(QualityLoader.get().get("fine")); + assertNotNull(QualityLoader.getByString("fine")); + assertNull(QualityLoader.getByString("missing")); + new CategoryLoader() + .load(config("categories.yml", "armor:\n name: Armor\n permission: armor")); + assertEquals("armor", CategoryLoader.get().get("armor").getPermission()); + assertNull(CategoryLoader.getByString("missing")); + new StatTemplateLoader().load(config("stats.yml", "blade:\n name: Blade\n stats: [attack]")); + assertEquals(1, StatTemplateLoader.getAll().size()); + assertNotNull(StatTemplateLoader.get().get("blade")); + assertNotNull(StatTemplateLoader.getByString("BLADE")); + assertNull(StatTemplateLoader.getByString(null)); + new SocketGroupLoader().load(config("socket.yml", "gemstones:\n slots:\n fine: [unknown]")); + assertNotNull(SocketGroupLoader.get().get("gemstones")); + assertNotNull(SocketGroupLoader.getByString("gemstones")); + assertNull(SocketGroupLoader.getByString("missing")); + new RecipeLoader() + .load( + config( + "recipes.yml", + "blade:\n" + + " name: Blade\n" + + " category: armor\n" + + " stat-template: blade\n" + + " socket-group: gemstones")); + assertSame( + StatTemplateLoader.getByString("blade"), RecipeLoader.get().get("blade").getStatTemplate()); + assertNotNull(RecipeLoader.getByString("blade")); + assertNull(RecipeLoader.getByString("missing")); + new IngredientLoader() + .load(config("ingredients.yml", "iron:\n type: metal\n path: v.iron_ingot")); + assertEquals(1, IngredientLoader.get().size()); + assertNotNull(IngredientLoader.getByString("IRON")); + assertNull(IngredientLoader.getByString("missing")); + assertSame( + IngredientLoader.getByString("iron"), + new ConversionLoader() + .load(config("conversions.yml", "conversions: ['v.iron_ingot(iron)']")) + .get("v.iron_ingot")); + File missing = temp.resolve("missing.yml").toFile(); + schemes.loadColourSchemes(missing); + schemes.loadNamingSchemes(missing); + schemes.loadModelSchemes(missing); + types.loadIngredientTypes(missing); + types.loadHitTypes(missing); + new HitLoader().load(missing); + new QualityLoader().load(missing); + new CategoryLoader().load(missing); + new StatTemplateLoader().load(missing); + new SocketGroupLoader().load(missing); + new IngredientLoader().load(missing); + new RecipeLoader().load(missing); + assertTrue(new ConversionLoader().load(missing).isEmpty()); + } + + @Test + void nullYamlIconsUseDefaultAndDoNotPreventLaterTemplatesLoading() throws Exception { + String content = + """ + explicit-null: + name: Explicit null + icon: null + tilde-null: + name: Tilde null + icon: ~ + empty-null: + name: Empty null + icon: + omitted: + name: Omitted icon + later-template: + name: Later template + icon: v.iron_sword + """; + // Use Bukkit's actual YAML parser and ConfigurationSection, not a mocked null getter. + var parsed = yaml(content); + for (String id : List.of("explicit-null", "tilde-null", "empty-null", "omitted")) { + var section = parsed.getConfigurationSection(id); + assertNotNull(section); + assertNull(section.getString("icon")); + assertEquals("v.paper", section.getString("icon", "v.paper")); + } + new StatTemplateLoader().load(config("nullable-icons.yml", content)); + assertEquals( + List.of("explicit-null", "tilde-null", "empty-null", "omitted", "later-template"), + StatTemplateLoader.getAll().stream().map(template -> template.getId()).toList()); + for (String id : List.of("explicit-null", "tilde-null", "empty-null", "omitted")) { + assertEquals( + org.bukkit.Material.PAPER, StatTemplateLoader.getByString(id).getIcon().getType()); + } + assertEquals( + org.bukkit.Material.IRON_SWORD, + StatTemplateLoader.getByString("later-template").getIcon().getType()); + verify(items.getCreator(), times(4)).getItemFromPath("v.paper"); + verify(items.getCreator()).getItemFromPath("v.iron_sword"); + } + + @Test + void configurationClampsPercentagesAndParsesOffsetsCombinationsAndPermissions() throws Exception { + var loader = new ConfigLoader(); + loader.load(config("defaults.yml", "")); + assertEquals(2, Cache.alloyForgeBaseSuccess); + loader.load( + config( + "full.yml", + """ + scrap-path: v.iron_nugget + alloy-forge: + base-success-percent: 200 + max-success-percent: -2 + success-bonus-per-sqrt-value: -1 + gem-stat-base-percent: -2 + gem-stat-bonus-per-value: -1 + base-bonus-percent: + IRON: 10 + copper: 0 + hit-overshoot-warn-message: custom + global-stat-offsets: + ATTACK: 100 + speed: 0 + permission-namespaces: + SMITH: + display: Smith + stat-aliases: ['attack->Damage'] + combinations: + metal: [metal, missing] + missing: [metal] + """)); + assertEquals(100, Cache.alloyForgeBaseSuccess); + assertEquals(100, Cache.alloyForgeMaxSuccess); + assertEquals(0, Cache.alloyForgeBonusPerSqrtValue); + assertEquals(0, Cache.gemstoneStatBaseChance); + assertEquals(0, Cache.gemstoneStatBonusPerValue); + var metal = ingredient("iron", ""); + assertEquals(10, Cache.getAlloyForgeBaseBonus(metal)); + assertEquals(0, Cache.getAlloyForgeBaseBonus(null)); + assertEquals(100, Cache.globalStatOffsets.get("attack")); + assertFalse(Cache.globalStatOffsets.containsKey("speed")); + assertEquals("Smith", Cache.permissionNamespaces.get("smith").getDisplay()); + assertEquals("Damage", StatToString.get("attack")); + assertTrue( + Cache.canCombine(metal.getIngredientData().getType(), metal.getIngredientData().getType())); + var wood = new IngredientType("wood", yaml("name: Wood")); + assertFalse(Cache.canCombine(metal.getIngredientData().getType(), wood)); + assertTrue(Cache.canCombine(wood, metal.getIngredientData().getType())); + Cache.combinations.get(metal.getIngredientData().getType()).add(wood); + assertTrue(Cache.canCombine(metal.getIngredientData().getType(), wood)); + loader.load( + config( + "list.yml", + "hit-overshoot-warn-message: ' '\n" + + "global-stat-offsets: ['', invalid, 'speed(0)', 'attack(100)', 'broken(x)']")); + assertEquals(Map.of("attack", 100.), Cache.globalStatOffsets); + assertTrue(Cache.hitOvershootWarnMessage.contains("%hit%")); + invoke(loader, "parseGlobalStatOffset", new Class[] {String.class}, (Object) null); + loader.load(temp.resolve("missing").toFile()); + } + + @Test + void malformedOffsetCannotAbortConfigurationLoad() throws Exception { + new ConfigLoader() + .load(config("broken.yml", "global-stat-offsets: ['attack(10', 'speed(100)']")); + assertEquals(Map.of("speed", 100.), Cache.globalStatOffsets); + } + + @Test + void freshInstallCreatesEveryDirectoryUsedByLoadConfigs() { + doCallRealMethod().when(plugin).createFolders(); + plugin.createFolders(); + plugin.createFolders(); + for (String dir : + List.of( + "naming-schemes", + "colour-schemes", + "model-schemes", + "recipes", + "data/players", + "data/stations", + "data/alloys", + "data/alloy-recipes")) assertTrue(Files.isDirectory(temp.resolve(dir)), dir); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/MathCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/MathCoverageTest.java new file mode 100644 index 0000000..1ff55f7 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/MathCoverageTest.java @@ -0,0 +1,183 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class MathCoverageTest extends CoverageSupport { + @Test + void templateScalingFiltersAddsBaseStatsAndRendersPreview() throws Exception { + var template = + new StatTemplate( + "sword", + yaml( + "name: Sword\n" + + "stats: [attack, speed, unused, armor]\n" + + "factors:\n" + + " attack: 1.5\n" + + " speed: 0.5\n" + + "base-stats: ['attack(1)', 'armor(4)', 'health(2)']")); + assertEquals("sword", template.getId()); + assertEquals("Sword", template.getName()); + assertNotNull(template.getIcon()); + assertEquals(List.of("attack", "speed", "unused", "armor"), template.getStats()); + assertTrue(template.allowsIngredientStat("ATTACK")); + assertFalse(template.allowsIngredientStat("health")); + assertEquals(1, template.getFactor("health")); + assertEquals(1.5, template.getFactor("attack")); + assertEquals(0, template.getBaseAmount("missing")); + assertEquals(1, template.getBaseAmount("ATTACK")); + template.setRevision(5); + assertEquals(5, template.getRevision()); + assertTrue(template.buildRevisionContent().contains("attack(1.5)")); + var source = new StatData(List.of("attack(3)", "speed(-2)", "health(7)")); + assertTrue(StatTemplateMath.hasOverlap(source, template)); + assertFalse(StatTemplateMath.hasOverlap(new StatData(), template)); + var result = StatTemplateMath.filterAndApply(source, template); + assertEquals(6, result.getAmount(new StatModifier("attack", 0))); + assertEquals(2, result.getAmount(new StatModifier("health", 0))); + assertEquals(4, StatTemplateMath.getPreviewLines(source, template).size()); + assertEquals("", StatTemplateMath.formatPercentSuffix(1)); + assertTrue(StatTemplateMath.formatPercentSuffix(1.5).contains("+50%")); + assertTrue(StatTemplateMath.formatPercentSuffix(.5).contains("-50%")); + assertTrue(StatTemplateMath.formatPercentSuffix(1.001).contains("0%")); + assertEquals(.005, StatTemplateMath.applyFactor(.01, .5)); + assertEquals(0, StatTemplateMath.applyFactor(0, 3)); + assertEquals(2, StatTemplateMath.applyGlobalOffset(2, "missing")); + Cache.globalStatOffsets.put("attack", 0.); + assertEquals(2, StatTemplateMath.applyGlobalOffset(2, "attack")); + assertEquals(3, StatTemplateMath.applyScaledValueForPreview(2, "attack", template)); + Cache.globalStatOffsets.put("attack", 100.); + assertEquals(.02, StatTemplateMath.applyGlobalOffset(2, "ATTACK")); + assertEquals(3, StatTemplateMath.applyScaledValueForPreview(2, "attack", template)); + assertEquals(0, StatTemplateMath.getFactor(template, "attack") - 1.5); + assertNotNull(new StatTemplate("invalid", yaml("icon: bad")).getName()); + assertNotNull(new StatTemplate("default", yaml("")).getIcon()); + } + + @Test + void bucketAveragesCountMissingStatsAsZeroAndSumSeparateBuckets() throws Exception { + ingredient("iron", "stats: ['armor(6)', 'speed(2)']"); + ingredient("copper", "stats: ['armor(2)']"); + ingredient("gem", "stats: ['armor(3)']\nstat-merge-key: gemstones"); + ingredient("missingtype", "type: invalid"); + var result = + BucketStatAverager.compute( + Map.of("ingredient.iron", 1, "ingredient.copper", 3, "ingredient.gem", 1)); + assertEquals(6, result.getAmount(new StatModifier("armor", 0))); + assertEquals(.5, result.getAmount(new StatModifier("speed", 0))); + for (Map materials : + Arrays.asList( + null, + Map.of(), + Map.of("bad", 1), + Map.of("thing.iron", 1), + Map.of("ingredient.unknown", 1), + Map.of("alloy.unknown", 1), + Map.of("ingredient.missingtype", 1), + Map.of("ingredient.iron", 0))) { + assertFalse(BucketStatAverager.compute(materials).hasModifiers()); + } + assertFalse(BucketStatAverager.computeFromInputs(null).hasModifiers()); + assertFalse(BucketStatAverager.computeFromInputs(List.of()).hasModifiers()); + result = + BucketStatAverager.computeFromInputs( + Arrays.asList( + null, + new CraftInput("ingredient", "iron", 0, 0), + new CraftInput(null, "iron", 1, 0), + new CraftInput("INGREDIENT", "IRON", 1, 0), + new CraftInput("ingredient", "iron", 2, 0))); + assertEquals(6, result.getAmount(new StatModifier("armor", 0))); + } + + @Test + void professionPermissionsRequireExactTierAndSupportUnrestrictedMaterials() throws Exception { + Player player = mock(Player.class); + Cache.permissionPrefix = "professions."; + assertEquals("professions.smith", ProfessionPermissions.flatPermission("SMITH")); + assertEquals("professions.smith_2", ProfessionPermissions.fullPermission("SMITH", 2)); + for (String ns : Arrays.asList(null, " ")) { + assertFalse(ProfessionPermissions.hasIngredientPerm(player, ns)); + assertFalse(ProfessionPermissions.hasAnyNamespacePerm(player, ns)); + assertFalse(ProfessionPermissions.hasExactTierPerm(player, ns, 2)); + } + assertFalse(ProfessionPermissions.hasIngredientPerm(null, "smith")); + assertFalse(ProfessionPermissions.hasAnyNamespacePerm(null, "smith")); + assertFalse(ProfessionPermissions.hasExactTierPerm(null, "smith", 2)); + assertFalse(ProfessionPermissions.hasIngredientPerm(player, "smith")); + assertFalse(ProfessionPermissions.hasAnyNamespacePerm(player, "smith")); + assertFalse(ProfessionPermissions.hasExactTierPerm(player, "smith", 0)); + when(player.hasPermission("professions.smith_4")).thenReturn(true); + when(player.hasPermission("professions.smith")).thenReturn(true); + assertTrue(ProfessionPermissions.hasIngredientPerm(player, "smith")); + assertTrue(ProfessionPermissions.hasAnyNamespacePerm(player, "smith")); + assertTrue(ProfessionPermissions.hasExactTierPerm(player, "smith", 4)); + assertFalse(ProfessionPermissions.hasExactTierPerm(player, "smith", 2)); + assertEquals("", ProfessionPermissions.getDisplayName(null)); + assertEquals("unknown", ProfessionPermissions.getDisplayName("unknown")); + Cache.permissionNamespaces.put("smith", new PermissionNamespace("smith", "Smithing")); + assertEquals("Smithing", ProfessionPermissions.getDisplayName("SMITH")); + assertTrue(ProfessionPermissions.missingNamespaceMessage("smith").contains("Smithing")); + assertTrue(ProfessionPermissions.missingExactTierMessage("smith", 3).contains("tier 3")); + assertTrue( + ProfessionPermissions.missingIngredientPermissionMessage("smith").contains("Smithing")); + Ingredient base = ingredient("iron", "base: true\ntier: 3\npermission: smith"); + Ingredient catalyst = ingredient("copper", "tier: 2"); + Ingredient unranked = ingredient("wood", "base: true"); + assertEquals(3, ProfessionPermissions.resolveTier(base)); + assertEquals(3, ProfessionPermissions.resolveTier("iron")); + assertEquals(0, ProfessionPermissions.resolveTier(catalyst)); + assertEquals(0, ProfessionPermissions.resolveTier(unranked)); + assertEquals(2, ProfessionPermissions.resolveIngredientTier(catalyst)); + assertEquals(0, ProfessionPermissions.resolveIngredientTier(unranked)); + assertEquals(0, ProfessionPermissions.resolveTier((Ingredient) null)); + assertEquals(0, ProfessionPermissions.resolveIngredientTier((Ingredient) null)); + assertEquals(0, ProfessionPermissions.resolveTier((IngredientData) null)); + assertEquals(0, ProfessionPermissions.resolveIngredientTier((IngredientData) null)); + assertTrue(ProfessionPermissions.canUseIngredient(player, base)); + assertTrue(ProfessionPermissions.canUseIngredient(player, catalyst)); + assertFalse(ProfessionPermissions.canUseIngredient(null, base)); + assertFalse(ProfessionPermissions.canUseIngredient(player, null)); + assertFalse(ProfessionPermissions.canUseIngredient(player, mock(Ingredient.class))); + assertEquals(0, ProfessionPermissions.resolveAlloyTier("unknown")); + } + + @Test + void mainTypeWinsTierSelectionAndUnknownInputsFallBack() throws Exception { + var recipe = recipe(""); + ingredient("iron", "base: true\ntier: 3"); + ingredient("copper", "base: true\ntier: 2"); + var materials = new LinkedHashMap(); + materials.put("ingredient.iron", 1); + materials.put("ingredient.copper", 2); + materials.put("unknown.thing", 10); + assertEquals("ingredient.copper", MajorityTierResolver.resolveMajorityKey(recipe, materials)); + assertEquals(2, MajorityTierResolver.resolveTier(recipe, materials)); + assertEquals( + 3, + MajorityTierResolver.resolveTier( + recipe, List.of(new CraftInput("INGREDIENT", "IRON", 1, 0)))); + assertEquals("", MajorityTierResolver.resolveMajorityKey(null, materials)); + assertEquals("", MajorityTierResolver.resolveMajorityKey(recipe, null)); + assertEquals("", MajorityTierResolver.resolveMajorityKey(recipe, Map.of())); + assertEquals(0, MajorityTierResolver.resolveTier(null, List.of())); + assertEquals(0, MajorityTierResolver.resolveTier(recipe, (List) null)); + assertEquals(0, MajorityTierResolver.resolveTier(recipe, List.of())); + for (String key : + Arrays.asList(null, " ", "bad", "other.unknown", "ingredient.unknown", "alloy.unknown")) + assertEquals(0, MajorityTierResolver.resolveTierFromKey(key)); + for (String key : + List.of("bad", "other.unknown", "ingredient.unknown", "alloy.unknown", "ingredient.iron")) + assertEquals(key, MajorityTierResolver.resolveMajorityKey(recipe, Map.of(key, 0))); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/MenuCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/MenuCoverageTest.java new file mode 100644 index 0000000..b279c99 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/MenuCoverageTest.java @@ -0,0 +1,113 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import org.bukkit.Material; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; + +class MenuCoverageTest extends CoverageSupport { + void serverPlugin() { + when(plugin.getServer()).thenReturn(server); + } + + @Test + void categoryAndRecipeMenusHaveTaggedIconsAndFillers() throws Exception { + serverPlugin(); + try (var models = mockStatic(LegacyModelData.class)) { + var p = server.addPlayer(); + var manager = new InventoryManager(); + manager.categoryView(p); + var inv = p.getOpenInventory().getTopInventory(); + assertEquals(Material.BARRIER, inv.getItem(0).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inv.getItem(1).getType()); + var r = recipe("recipe: ['metal.2']"); + manager.categoryView(p); + assertEquals(Material.PAPER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + var template = new ItemStack(Material.IRON_SWORD); + var meta = template.getItemMeta(); + models.when(() -> LegacyModelData.has(any())).thenReturn(true); + models.when(() -> LegacyModelData.get(any())).thenReturn(7); + when(items.getCreator().getItemFromPath("m.SWORD.TEST")).thenReturn(template); + manager.categoryView(p); + models.verify(() -> LegacyModelData.set(any(), eq(7))); + manager.recipeView(p, CategoryLoader.getByString("weapons")); + inv = p.getOpenInventory().getTopInventory(); + assertEquals(Material.IRON_SWORD, inv.getItem(0).getType()); + assertTrue(inv.getItem(0).getItemMeta().getLore().toString().contains("x2")); + when(items.getCreator().getItemFromPath("m.SWORD.TEST")).thenReturn(null); + manager.categoryView(p); + assertEquals(Material.BARRIER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + manager.recipeView(p, CategoryLoader.getByString("weapons")); + assertEquals(Material.BARRIER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + CategoryLoader.getByString("weapons").getRecipes().clear(); + recipe("icon: ia.missing"); + when(items.getCreator().getItemFromPath("ia.missing")) + .thenReturn(new ItemStack(Material.DIRT)); + manager.recipeView(p, CategoryLoader.getByString("weapons")); + assertEquals(Material.BARRIER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + when(items.getCreator().getItemFromPath("ia.missing")) + .thenReturn(new ItemStack(Material.AIR)); + manager.recipeView(p, CategoryLoader.getByString("weapons")); + assertEquals(Material.BARRIER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + } + } + + @Test + void statPreviewShowsOnlyMatchingTemplatesAndHandlesMissingIcons() throws Exception { + serverPlugin(); + var p = server.addPlayer(); + var manager = new InventoryManager(); + manager.templatePreviewView(p, new StatData()); + assertTrue(p.nextMessage().contains("no stats")); + var file = + Files.writeString( + temp.resolve("templates.yml"), + "blade:\n" + + " name: Blade\n" + + " icon: v.iron_sword\n" + + " stats: [armor]\n" + + "no:\n" + + " name: Other\n" + + " stats: [health]"); + new StatTemplateLoader().load(file.toFile()); + var source = new StatData(List.of("armor(4)")); + manager.templatePreviewView(p, source); + assertEquals(9, p.getOpenInventory().getTopInventory().getSize()); + assertEquals(Material.IRON_SWORD, p.getOpenInventory().getTopInventory().getItem(0).getType()); + when(items.getCreator().getItemFromPath("v.iron_sword")).thenReturn(null); + manager.templatePreviewView(p, source); + assertEquals(Material.BARRIER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + } + + @Test + void previewOfMoreThanOneInventoryOfTemplatesIsBounded() throws Exception { + serverPlugin(); + var config = new StringBuilder(); + for (int i = 0; i < 55; i++) config.append("t").append(i).append(":\n stats: [armor]\n"); + new StatTemplateLoader().load(Files.writeString(temp.resolve("large.yml"), config).toFile()); + var p = server.addPlayer(); + new InventoryManager().templatePreviewView(p, new StatData(List.of("armor(2)"))); + assertEquals(54, p.getOpenInventory().getTopInventory().getSize()); + } + + @Test + void invalidAirTemplateIconUsesBarrier() throws Exception { + serverPlugin(); + new StatTemplateLoader() + .load( + Files.writeString(temp.resolve("air.yml"), "blade:\n icon: v.air\n stats: [armor]") + .toFile()); + var p = server.addPlayer(); + new InventoryManager().templatePreviewView(p, new StatData(List.of("armor(2)"))); + assertEquals(Material.BARRIER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java new file mode 100644 index 0000000..4d1da98 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java @@ -0,0 +1,156 @@ +package net.tfminecraft.advancedcrafting; + +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.*; +import net.Indyuce.mmoitems.api.item.mmoitem.*; +import net.Indyuce.mmoitems.stat.data.*; +import net.Indyuce.mmoitems.stat.type.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class MmoCoverageTest extends CoverageSupport { + @Test + void externalStatsReplaceOwnedValuesWithoutDuplicatingOriginalLayer() { + var mmo = mock(MMOItem.class); + var armor = mock(ItemStat.class); + var speed = mock(ItemStat.class); + var other = mock(ItemStat.class); + when(MMOItems.plugin.getStats().get("ARMOR")).thenReturn(armor); + when(MMOItems.plugin.getStats().get("SPEED")).thenReturn(speed); + when(MMOItems.plugin.getStats().get("OTHER")).thenReturn(other); + when(MMOItems.plugin.getStats().get("MISSING")).thenReturn(null); + var hist = mock(StatHistory.class); + var original = new DoubleData(5); + when(hist.getOriginalData()).thenReturn(original); + when(mmo.computeStatHistory(armor)).thenReturn(hist); + var nonnumeric = mock(StatHistory.class); + when(nonnumeric.getOriginalData()).thenReturn(new StringData("text")); + when(mmo.computeStatHistory(other)).thenReturn(nonnumeric); + var stats = + new StatData( + List.of("armor(3)", "ARMOR(4)", "durability(20)", "max_item_damage(40)", "missing(2)")); + var managed = Set.of("armor", "speed", "other", "missing", "durability"); + MMOStatApplicator.applyExternalLayer(mmo, stats, managed, true); + assertEquals(0, original.getValue()); + verify(hist).clearExternalData(); + verify(hist).registerExternalData(any(DoubleData.class)); + verify(mmo).setData(eq(ItemStats.MAX_DURABILITY), any(DoubleData.class)); + verify(mmo).setData(eq(ItemStats.CUSTOM_DURABILITY), any(DoubleData.class)); + MMOStatApplicator.applyExternalLayer(mmo, null, null, true); + MMOStatApplicator.applyExternalLayer(mmo, new StatData(), Set.of("max_item_damage")); + assertTrue(MMOStatApplicator.isDurabilityStat("MAX_ITEM_DAMAGE")); + assertTrue(MMOStatApplicator.isDurabilityStat("DURABILITY")); + assertFalse(MMOStatApplicator.isDurabilityStat("armor")); + } + + @Test + void craftedRefreshPreservesAmountSynchronizesProvenanceAndAppliesTier() throws Exception { + Cache.debugStatRefresh = false; + var item = new ItemStack(Material.IRON_SWORD, 3); + var ing = ingredient("iron", "base: true\ntier: 3\nstats: ['armor(4)']"); + ing.setRevision(2); + var template = new StatTemplate("blade", yaml("stats: [armor]")); + template.setRevision(2); + StatTemplateLoader.get().put("blade", template); + var recipe = recipe("stat-template: blade"); + RecipeLoader.map.put("sword", recipe); + var provenance = + new CraftProvenance( + "sword", "fine", List.of(new CraftInput("ingredient", "iron", 2, 1)), 1); + provenance.applyTo(item); + var rebuilt = new ItemStack(Material.IRON_SWORD); + var nbt = mock(NBTItem.class); + try (var nbts = mockStatic(NBTItem.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mock, ctx) -> when(mock.newBuilder().build()).thenReturn(rebuilt))) { + nbts.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + var result = CraftStatRefresher.refreshIfOutdated(item); + assertTrue(result.isChanged()); + assertNull(result.getError()); + assertEquals(1, result.getOutdatedInputs().size()); + assertEquals(3, result.getItem().getAmount()); + assertEquals(2, CraftProvenance.readFrom(result.getItem()).getStatTemplateRevision()); + assertFalse(CraftProvenance.readFrom(result.getItem()).isOutdated()); + assertTrue(AcItemRefresher.isManaged(item)); + assertTrue(AcItemRefresher.isOutdated(item)); + assertEquals(rebuilt, AcItemRefresher.refreshIfOutdated(item)); + assertFalse(CraftStatRefresher.refresh(result.getItem()).isChanged()); + assertFalse(CraftStatRefresher.refreshIfOutdated(result.getItem()).isChanged()); + RecipeLoader.map.clear(); + assertTrue(CraftStatRefresher.refresh(item, true).getError().contains("unknown recipe")); + var empty = recipe(""); + RecipeLoader.map.put("sword", empty); + new CraftProvenance("sword", "", List.of(), 0).applyTo(item); + assertTrue(CraftStatRefresher.refresh(item, true).isChanged()); + } + assertFalse(CraftStatRefresher.refresh(null).isChanged()); + assertFalse(CraftStatRefresher.refresh(new ItemStack(Material.AIR)).isChanged()); + assertFalse(CraftStatRefresher.refresh(new ItemStack(Material.PAPER)).isChanged()); + assertTrue(CraftStatRefresher.RefreshResult.updated(item, null).getOutdatedInputs().isEmpty()); + } + + @Test + void debugReportsComputedOriginalAndExternalStatLayers() throws Exception { + Cache.debugStatRefresh = false; + StatRefreshDebug.logBefore(null, null, null, null); + StatRefreshDebug.logAfter(null, null); + Cache.debugStatRefresh = true; + var ingredient = ingredient("iron", ""); + ingredient.setRevision(2); + var template = + new StatTemplate("blade", yaml("stats: [armor, speed, health, durability, missing]")); + StatTemplateLoader.get().put("blade", template); + var recipe = recipe("stat-template: blade"); + RecipeLoader.map.put("sword", recipe); + var provenance = + new CraftProvenance( + "sword", + "", + List.of( + new CraftInput("ingredient", "iron", 1, 0), + new CraftInput("ingredient", "iron", 1, 0)), + 0); + var item = new ItemStack(Material.IRON_SWORD); + var armor = mock(ItemStat.class); + var speed = mock(ItemStat.class); + var health = mock(ItemStat.class); + when(MMOItems.plugin.getStats().get("ARMOR")).thenReturn(armor); + when(MMOItems.plugin.getStats().get("SPEED")).thenReturn(speed); + when(MMOItems.plugin.getStats().get("HEALTH")).thenReturn(health); + when(MMOItems.plugin.getStats().get("MISSING")).thenReturn(null); + try (var nbts = mockStatic(NBTItem.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + (mock, ctx) -> { + when(mock.getData(armor)).thenReturn(new DoubleData(4)); + var hist = mock(StatHistory.class); + when(hist.getOriginalData()).thenReturn(new DoubleData(2)); + when(mock.computeStatHistory(armor)).thenReturn(hist); + var other = mock(StatHistory.class); + when(other.getOriginalData()).thenReturn(new StringData("text")); + when(mock.computeStatHistory(speed)).thenReturn(other); + })) { + StatRefreshDebug.logBefore( + item, provenance, recipe, new StatData(List.of("armor(5)", "speed(1)"))); + StatRefreshDebug.logAfter(item, recipe); + StatRefreshDebug.logBefore(item, provenance, recipe, null); + StatRefreshDebug.logBefore(item, provenance, recipe(""), new StatData()); + } finally { + Cache.debugStatRefresh = false; + } + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/PersistenceCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/PersistenceCoverageTest.java new file mode 100644 index 0000000..d393041 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/PersistenceCoverageTest.java @@ -0,0 +1,157 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.lifecycle.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.utils.*; +import org.junit.jupiter.api.Test; + +class PersistenceCoverageTest extends CoverageSupport { + @Test + void revisionsRoundTripAndIncrementOnlyForChangedContent() throws Exception { + var tracker = new RevisionTracker(); + tracker.flush(); + tracker.resolveIngredient("early", "x"); + tracker.flush(); + tracker.load(temp.toFile()); + tracker.flush(); + assertEquals(1, tracker.resolveIngredient("IRON", "first")); + assertEquals(1, tracker.resolveIngredient("iron", "first")); + assertEquals(2, tracker.resolveIngredient("iron", "changed")); + assertEquals(1, tracker.resolveAlloy("BRONZE", "alloy")); + assertEquals(1, tracker.resolveStatTemplate("SWORD", "template")); + tracker.flush(); + tracker.flush(); + var other = new RevisionTracker(); + other.load(temp.toFile()); + assertEquals(2, other.resolveIngredient("iron", "changed")); + assertEquals(1, other.resolveAlloy("bronze", "alloy")); + assertEquals(1, other.resolveStatTemplate("sword", "template")); + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + RevisionTracker.sha256("abc")); + Files.writeString( + temp.resolve("data/revisions.json"), "{\"ingredients\":{\"legacy\":{}},\"alloys\":[]}"); + other.load(temp.toFile()); + assertEquals(1, other.resolveIngredient("legacy", "")); + Files.writeString(temp.resolve("data/revisions.json"), "invalid"); + other.load(temp.toFile()); + assertEquals(1, other.resolveAlloy("new", "new")); + Files.delete(temp.resolve("data/revisions.json")); + Files.createDirectory(temp.resolve("data/revisions.json")); + other.flush(); + assertTrue(Files.isDirectory(temp.resolve("data/revisions.json"))); + } + + @Test + void recipeStorePersistsCanonicalCombosFindsDuplicatesAndRenamesResults() throws Exception { + File root = temp.resolve("recipes").toFile(); + var store = new AlloyRecipeStore(root); + new AlloyRecipeStore(root); + var a = new AlloyRecipe("IRON", List.of("copper")); + var b = new AlloyRecipe("iron", List.of("zinc")); + var c = new AlloyRecipe("iron", List.of()); + assertNull(store.getResultByCombo(null)); + assertNull(store.getResultByCombo(a.comboKey())); + store.upsert(a, "BRONZE"); + store.upsert(b, "bronze"); + store.upsert(c, "scrap"); + assertEquals("bronze", store.getResultByCombo(a.comboKey())); + assertEquals(Map.of("bronze", 2), store.findDuplicateResultsExcludingScrap()); + assertTrue(List.of(a, b).contains(store.getRecipeByResult("BRONZE"))); + assertNull(store.getRecipeByResult("unknown")); + store.updateResultId("bronze", "BRASS"); + assertEquals("brass", store.getResultByCombo(a.comboKey())); + assertEquals( + Map.of(a.comboKey(), "brass", b.comboKey(), "brass", "iron", "scrap"), + store.loadAllComboResults()); + store.deleteByCombo(a.comboKey()); + store.deleteByCombo(a.comboKey()); + store.deleteByCombo(null); + assertNull(store.getResultByCombo(a.comboKey())); + var station = mock(AlloyStation.class); + assertNull(store.getResult(station)); + store.deleteByStation(station); + var base = ingredient("iron", "base: true"); + when(station.getBaseItem()).thenReturn(base); + when(station.getCatalysts()).thenReturn(new ArrayList<>()); + assertEquals("scrap", store.getResult(station)); + assertEquals(c, AlloyRecipe.fromStation(station)); + store.deleteByStation(station); + assertNull(store.getResult(station)); + when(station.getCatalysts()).thenReturn(new ArrayList<>(List.of(ingredient("zinc", "")))); + assertEquals(b, AlloyRecipe.fromStation(station)); + } + + @Test + void recipeStoreReadsLegacyFilesAndIgnoresIncompleteOrUnreadableEntries() throws Exception { + var root = temp.resolve("recipes"); + var store = new AlloyRecipeStore(root.toFile()); + Files.createDirectories(root.resolve("iron")); + Files.writeString(root.resolve("iron/iron.idx"), "\nBRONZE\nignored\nunknown=value\n"); + Files.writeString(root.resolve("iron/iron__zinc.idx"), "BRONZE"); + Files.writeString(root.resolve("iron/iron__empty.idx"), "combo=iron|empty\n"); + Files.writeString(root.resolve("iron/iron__none.idx"), "combo=\nresult=brass"); + Files.createDirectory(root.resolve("broken.idx")); + assertEquals("bronze", store.getResultByCombo("iron")); + assertTrue( + List.of(new AlloyRecipe("iron", List.of()), new AlloyRecipe("iron", List.of("zinc"))) + .contains(store.getRecipeByResult("bronze"))); + store.updateResultId("bronze", "steel"); + assertEquals("steel", store.getResultByCombo("iron|zinc")); + assertEquals(2, store.findDuplicateResultsExcludingScrap().get("steel")); + assertNotNull(store.getRecipeByResult("brass")); + assertTrue(store.loadAllComboResults().containsKey("iron")); + var removed = temp.resolve("removed"); + var missing = new AlloyRecipeStore(removed.toFile()); + Files.delete(removed); + assertTrue(missing.loadAllComboResults().isEmpty()); + var blocked = temp.resolve("blocked"); + Files.writeString(blocked, "file"); + new AlloyRecipeStore(blocked.toFile()).upsert(new AlloyRecipe("x", List.of()), "bad"); + } + + @Test + void malformedLegacyIndexNameCannotAbortLookup() throws Exception { + var root = temp.resolve("recipes"); + var store = new AlloyRecipeStore(root.toFile()); + Files.createDirectories(root.resolve("longbasename")); + Files.writeString(root.resolve("longbasename/x.idx"), "bronze"); + assertNull(store.getRecipeByResult("bronze")); + assertTrue(store.loadAllComboResults().isEmpty()); + } + + @Test + void playerForgeTrackerPersistsFirstDiscoveryAndLoadsLegacyData() throws Exception { + var tracker = new PlayerAlloyForgeTracker(temp.toFile()); + new PlayerAlloyForgeTracker(temp.toFile()); + UUID id = UUID.randomUUID(); + assertFalse(tracker.recordForge(null, "x")); + assertFalse(tracker.recordForge(id, null)); + assertFalse(tracker.recordForge(id, " ")); + assertTrue(tracker.recordForge(id, "BRONZE")); + assertFalse(tracker.recordForge(id, "bronze")); + assertTrue(tracker.recordForge(id, "steel")); + assertTrue(Files.readString(temp.resolve("forged-alloys/" + id + ".json")).contains("bronze")); + UUID legacy = UUID.randomUUID(); + Files.writeString( + temp.resolve("forged-alloys/" + legacy + ".json"), "[null,\" \",\" BRONZE \"]"); + assertFalse(tracker.recordForge(legacy, "bronze")); + UUID bad = UUID.randomUUID(); + Files.writeString(temp.resolve("forged-alloys/" + bad + ".json"), "bad"); + assertTrue(tracker.recordForge(bad, "x")); + UUID obj = UUID.randomUUID(); + Files.writeString(temp.resolve("forged-alloys/" + obj + ".json"), "{}"); + assertTrue(tracker.recordForge(obj, "x")); + UUID directory = UUID.randomUUID(); + Files.createDirectory(temp.resolve("forged-alloys/" + directory + ".json")); + assertTrue(tracker.recordForge(directory, "x")); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/PluginLifecycleCoverageTest.java new file mode 100644 index 0000000..0bf33b6 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/PluginLifecycleCoverageTest.java @@ -0,0 +1,49 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import org.bukkit.Location; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class PluginLifecycleCoverageTest extends CoverageSupport { + @Test + void freshPluginBootReloadAndShutdownUsePersistentDataFolder() throws Exception { + for (String name : List.of("TLibs", "MMOCore", "MMOItems", "MythicLib")) + MockBukkit.createMockPlugin(name); + var running = MockBukkit.load(AdvancedCrafting.class); + assertSame(running, AdvancedCrafting.plugin); + assertNotNull(running.getIngredientManager()); + assertNotNull(AdvancedCrafting.getAlloyManager()); + assertNotNull(AdvancedCrafting.getAlloyRecipeStore()); + assertNotNull(AdvancedCrafting.getCraftingManager()); + var data = running.getDataFolder().toPath(); + assertTrue(Files.exists(data.resolve("config.yml"))); + running.createConfigs(); + running.createFolders(); + for (String folder : List.of("colour-schemes", "naming-schemes", "model-schemes", "recipes")) { + Files.createDirectories(data.resolve(folder + "/ignored")); + Files.copy( + Path.of( + "src/main/resources", folder, folder.equals("recipes") ? "weapons.yml" : "basic.yml"), + data.resolve(folder + "/default.yml")); + } + running.reload(); + var player = server.addPlayer(); + running.reloadMessage(player); + assertTrue(player.nextMessage().contains("Reloading plugin")); + assertTrue(player.nextMessage().contains("Reloading complete")); + var recipe = RecipeLoader.get().values().iterator().next(); + var station = new CraftingStation(new Location(server.addSimpleWorld("world"), 0, 1, 2)); + station.setRecipe(recipe); + AdvancedCrafting.getCraftingManager().set(new HashMap<>(Map.of(station.getLoc(), station))); + running.onDisable(); + assertTrue( + Files.list(data.resolve("data/stations")).anyMatch(p -> p.toString().endsWith(".json"))); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java new file mode 100644 index 0000000..094b8e9 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java @@ -0,0 +1,126 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.utils.*; +import net.tfminecraft.tlibs.armour.*; +import net.tfminecraft.tlibs.event.MMOItemRebuildEvent; +import org.bukkit.Material; +import org.bukkit.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class RefreshListenerCoverageTest extends CoverageSupport { + @Test + void deferredRefreshUsesTheCurrentSlotAndUpdatesDropCursorAndArmor() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + var p = server.addPlayer(); + var listener = new CraftRefreshListener(); + listener.start(plugin); + listener.stop(); + var iron = ingredient("iron", ""); + iron.setRevision(1); + var stale = iron.build(); + iron.setRevision(2); + var fresh = iron.build(); + var drop = mock(Item.class); + when(drop.getItemStack()).thenReturn(new ItemStack(Material.PAPER)); + listener.onDrop(new PlayerDropItemEvent(p, drop)); + server.getScheduler().performOneTick(); + verify(drop, never()).setItemStack(any()); + when(drop.getItemStack()).thenReturn(fresh); + listener.onDrop(new PlayerDropItemEvent(p, drop)); + server.getScheduler().performOneTick(); + verify(drop, never()).setItemStack(any()); + when(drop.getItemStack()).thenReturn(stale); + listener.onDrop(new PlayerDropItemEvent(p, drop)); + server.getScheduler().performOneTick(); + verify(drop).setItemStack(any()); + var event = mock(InventoryClickEvent.class); + when(event.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + listener.onInventoryClick(event); + when(event.getWhoClicked()).thenReturn(p); + when(event.getSlot()).thenReturn(0); + p.setItemOnCursor(stale); + listener.onInventoryClick(event); + server.getScheduler().performOneTick(); + assertEquals(2, AcItemTags.getStoredRevision(p.getItemOnCursor())); + when(event.getClickedInventory()).thenReturn(p.getInventory()); + p.getInventory().setItem(0, stale); + listener.onInventoryClick(event); + server.getScheduler().performOneTick(); + assertEquals(2, AcItemTags.getStoredRevision(p.getInventory().getItem(0))); + for (ItemStack item : + Arrays.asList( + null, new ItemStack(Material.AIR), new ItemStack(Material.PAPER), fresh, stale)) { + p.getInventory().setItem(1, item); + listener.onHotbarSelect(new PlayerItemHeldEvent(p, 0, 1)); + server.getScheduler().performOneTick(); + } + var equip = mock(ArmorEquipEvent.class); + when(equip.getPlayer()).thenReturn(p); + listener.onArmorEquip(equip); + when(equip.getNewArmorPiece()).thenReturn(stale); + for (ArmorType type : ArmorType.values()) { + when(equip.getType()).thenReturn(type); + p.getInventory().setArmorContents(new ItemStack[] {stale, stale, stale, stale}); + listener.onArmorEquip(equip); + server.getScheduler().performOneTick(); + } + } + + @Test + void rebuildListenerKeepsTierMetadataAndSupportsUntaggedItems() { + var listener = new MMOItemRebuildListener(); + var event = mock(MMOItemRebuildEvent.class); + listener.onRebuild(event); + var plain = new ItemStack(Material.IRON_SWORD); + when(event.getNewItem()).thenReturn(plain); + when(event.getOldItem()).thenReturn(plain); + listener.onRebuild(event); + var meta = plain.getItemMeta(); + meta.getPersistentDataContainer() + .set(PDCKeys.craftMajorityTier(), PersistentDataType.INTEGER, 0); + plain.setItemMeta(meta); + listener.onRebuild(event); + meta = plain.getItemMeta(); + meta.getPersistentDataContainer() + .set(PDCKeys.craftMajorityTier(), PersistentDataType.INTEGER, 3); + plain.setItemMeta(meta); + listener.onRebuild(event); + assertTrue(plain.getItemMeta().getLore().getFirst().contains("III")); + listener.onRebuild(event); + verify(event, times(2)).setNewItem(plain); + } + + @Test + void rebuildRestoresTierFromOldMetadataWhenBuilderOmitsIt() { + var old = new ItemStack(Material.IRON_SWORD); + var meta = old.getItemMeta(); + meta.setDisplayName("Sword"); + old.setItemMeta(meta); + CraftTierLore.applyTierLine(old, 3); + var rebuilt = new ItemStack(Material.IRON_SWORD); + meta = rebuilt.getItemMeta(); + meta.setDisplayName("Sword"); + meta.setLore(List.of("")); + rebuilt.setItemMeta(meta); + var event = mock(MMOItemRebuildEvent.class); + when(event.getOldItem()).thenReturn(old); + when(event.getNewItem()).thenReturn(rebuilt); + new MMOItemRebuildListener().onRebuild(event); + assertTrue(rebuilt.getItemMeta().getLore().getFirst().contains("III")); + assertEquals( + 3, + rebuilt + .getItemMeta() + .getPersistentDataContainer() + .get(PDCKeys.craftMajorityTier(), PersistentDataType.INTEGER)); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java new file mode 100644 index 0000000..2b77490 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -0,0 +1,561 @@ +package net.tfminecraft.advancedcrafting; + +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.*; +import net.Indyuce.mmoitems.api.item.mmoitem.*; +import net.Indyuce.mmoitems.stat.data.*; +import net.Indyuce.mmoitems.stat.type.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.enums.StationFeedback; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.*; +import net.tfminecraft.advancedcrafting.objects.alloys.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.crafting.hits.*; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.schemes.*; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import net.tfminecraft.advancedcrafting.utils.*; +import net.tfminecraft.tlibs.objects.utils.IntCounter; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +class StationCoverageTest extends CoverageSupport { + Location loc() { + return new Location(server.addSimpleWorld("world"), 1, 2, 3); + } + + ItemStack alloyItem(String id) { + var i = new ItemStack(Material.IRON_INGOT); + var m = i.getItemMeta(); + m.getPersistentDataContainer().set(PDCKeys.alloyId(), PersistentDataType.STRING, id); + i.setItemMeta(m); + return i; + } + + @Test + void materialAdmissionChecksItemTypePermissionsRecipeAndCapacity() throws Exception { + var p = server.addPlayer(); + var station = new CraftingStation(loc()); + assertFalse(station.hasRecipe()); + assertNull(station.getRecipe()); + var r = recipe("recipe: ['metal.3']\npermission-namespace: smith"); + station.setRecipe(r); + assertTrue(station.hasRecipe()); + assertSame(r, station.getRecipe()); + assertEquals(3, station.getTypes().get(TypeLoader.map.get("metal")).getNeeded()); + assertFalse(station.hasAllMaterials(p)); + assertEquals( + StationFeedback.NOT_INGREDIENT, station.addMaterial(p, new ItemStack(Material.PAPER))); + var iron = ingredient("iron", "base: true\ntier: 3\npermission: smith\nhits: ['strike.2']"); + var item = iron.build(); + item.setAmount(5); + p.getInventory().setItemInMainHand(item); + assertEquals(StationFeedback.NO_PERMS, station.addMaterial(p, item)); + p.addAttachment( + org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin(), "professions.smith", true); + assertEquals(StationFeedback.NO_PERMS, station.addMaterial(p, item)); + p.setOp(true); + assertEquals(StationFeedback.SUCCESS, station.addMaterial(p, item)); + assertEquals(StationFeedback.SUCCESS, station.addMaterial(p, item)); + assertEquals(2, station.getCurrentMaterials().get("ingredient.iron")); + assertEquals(4, station.getHits().get(HitLoader.map.get("strike")).getNeeded()); + var alloy = + new Alloy( + "bronze", + "Bronze", + new AlloyData( + iron, + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(Map.of(HitLoader.map.get("strike"), 1)), + null)); + AlloyManager.addAlloy(alloy); + var a = alloyItem("bronze"); + p.getInventory().setItemInMainHand(a); + assertEquals(StationFeedback.SUCCESS, station.addMaterial(p, a)); + assertTrue(station.hasAllMaterials(p)); + assertEquals(StationFeedback.CAPACITY, station.addMaterial(p, iron.build())); + TypeLoader.map.put("wood", new IngredientType("wood", yaml("name: Wood"))); + assertEquals( + StationFeedback.WRONG_TYPE, + station.addMaterial(p, ingredient("wood", "type: wood").build())); + var catalyst = ingredient("gem", ""); + var other = new CraftingStation(station.getLoc()); + other.setRecipe(recipe("recipe: ['metal.1']")); + p.getInventory().setItemInMainHand(catalyst.build()); + assertEquals(StationFeedback.SUCCESS, other.addMaterial(p, catalyst.build())); + } + + @Test + void restoredStationsAggregateMaterialAndHitCounts() throws Exception { + var loc = loc(); + var iron = ingredient("iron", "hits: ['strike.2']"); + var copper = ingredient("copper", "hits: ['strike.1']"); + var alloy = + new Alloy( + "bronze", + "Bronze", + new AlloyData( + iron, + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(Map.of(HitLoader.map.get("strike"), 3)), + null)); + AlloyManager.addAlloy(alloy); + var unknownHit = new CraftingHit("other", yaml("name: Other\ntype: unknown\ntool: other.tool")); + var sameType = new CraftingHit("tap", yaml("name: Tap\ntype: hammer\ntool: material.tap")); + var materials = + new HashMap( + Map.of( + "ingredient.iron", 1, "ingredient.copper", 1, "alloy.bronze", 1, "unknown.x", 1)); + var station = + new CraftingStation( + loc, + recipe("recipe: ['metal.3']"), + materials, + new HashMap<>(Map.of(HitLoader.map.get("strike"), 1, unknownHit, 1, sameType, 1))); + assertEquals(3, station.getTypes().get(TypeLoader.map.get("metal")).getCurrent()); + assertEquals(6, station.getHits().get(HitLoader.map.get("strike")).getNeeded()); + assertEquals(1, station.getHits().get(HitLoader.map.get("strike")).getCurrent()); + assertSame(materials, station.getCurrentMaterials()); + TypeLoader.map.put("wood", new IngredientType("wood", yaml("name: Wood"))); + var wood = ingredient("wood", "type: wood"); + var wa = + new Alloy( + "woodalloy", + "Wood", + new AlloyData( + wood, + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(), + null)); + AlloyManager.addAlloy(wa); + new CraftingStation( + loc, + recipe("recipe: ['metal.1']"), + new HashMap<>(Map.of("ingredient.wood", 1, "alloy.woodalloy", 1)), + new HashMap<>()); + } + + @Test + void hitSelectionUsesRegisteredMmoToolsAndSharedTypeCapacity() throws Exception { + var p = server.addPlayer(); + var iron = ingredient("iron", "hits: ['strike.2']"); + var station = new CraftingStation(loc()); + station.setRecipe(recipe("recipe: ['metal.1']")); + var tool = new ItemStack(Material.IRON_AXE); + assertEquals(StationFeedback.LACKING_ITEMS, station.hit(p, tool)); + p.getInventory().setItemInMainHand(iron.build()); + station.addMaterial(p, iron.build()); + try (var nbt = mockStatic(NBTItem.class)) { + var data = mock(NBTItem.class); + nbt.when(() -> NBTItem.get(tool)).thenReturn(data); + assertEquals(StationFeedback.WRONG_TYPE, station.hit(p, tool)); + when(data.hasType()).thenReturn(true); + when(data.getType()).thenReturn("v"); + when(data.getString("MMOITEMS_ITEM_ID")).thenReturn("missing"); + assertEquals(StationFeedback.WRONG_TYPE, station.hit(p, tool)); + var different = + new CraftingHit("different", yaml("name: Other\ntype: missing\ntool: v.other")); + HitLoader.map.put("different", different); + when(data.getString("MMOITEMS_ITEM_ID")).thenReturn("other"); + assertEquals(StationFeedback.NONE, station.hit(p, tool)); + var extra = new CraftingHit("extra", yaml("name: Extra\ntype: hammer\ntool: v.extra")); + HitLoader.map.put("extra", extra); + when(data.getString("MMOITEMS_ITEM_ID")).thenReturn("extra"); + assertEquals(StationFeedback.SUCCESS, station.hit(p, tool)); + when(data.getString("MMOITEMS_ITEM_ID")).thenReturn("iron_axe"); + assertEquals(StationFeedback.SUCCESS, station.hit(p, tool)); + assertEquals(StationFeedback.CAPACITY, station.hit(p, tool)); + assertEquals(1, station.getHits().get(extra).getCurrent()); + } + } + + @Test + void qualityReflectsUnderAndOverWorkAndWarningsUseConfiguredThreshold() throws Exception { + var p = server.addPlayer(); + var station = new CraftingStation(loc()); + var counter = new IntCounter(); + counter.setNeeded(4); + station.getHits().put(HitLoader.map.get("strike"), counter); + for (int current : List.of(0, 2, 4, 6, 8, 10)) { + counter.setCurrent(current); + double expected = current <= 4 ? current * 25 : current < 8 ? 200 - current * 25 : 0; + assertEquals( + expected, + ((Number) invoke(station, "calculatePercentage", new Class[] {})).doubleValue()); + } + Cache.hitOvershootWarnPercent = 0; + invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); + Cache.hitOvershootWarnPercent = 30; + Cache.hitOvershootWarnMessage = null; + invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); + Cache.hitOvershootWarnMessage = " "; + invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); + Cache.hitOvershootWarnMessage = "Too many %hit%"; + counter.setNeeded(0); + invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); + counter.setNeeded(4); + for (int n : List.of(2, 5, 6)) { + counter.setCurrent(n); + invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); + } + assertTrue(p.nextMessage().contains("Strike")); + } + + @Test + void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception { + var p = server.addPlayer(); + var iron = ingredient("iron", "base: true\ntier: 2\nxp: smith(2)\nhits: ['strike.1']"); + var station = new CraftingStation(loc()); + station.setRecipe(recipe("recipe: ['metal.1']")); + assertEquals(StationFeedback.LACKING_ITEMS, station.craft(p)); + p.getInventory().setItemInMainHand(iron.build()); + station.addMaterial(p, iron.build()); + assertEquals(StationFeedback.LACKING_HITS, station.craft(p)); + QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); + try (var nbt = mockStatic(NBTItem.class); + var models = mockStatic(LegacyModelData.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + }); + var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.dispatchCommand(any(), anyString())).thenReturn(true); + assertEquals(StationFeedback.SUCCESS, station.craft(p, 50.)); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 2.0"))); + station.getHits().get(HitLoader.map.get("strike")).setCurrent(1); + var ht = CraftingStation.class.getDeclaredField("hitTypes"); + ht.setAccessible(true); + ((Map) ht.get(station)).values().forEach(c -> c.setCurrent(c.getNeeded())); + assertEquals(StationFeedback.SUCCESS, station.craft(p)); + assertFalse(station.getLoc().getWorld().getEntities().isEmpty()); + } + } + + @Test + void dropAndCancelRefundBothIngredientAndAlloyQuantities() throws Exception { + var p = server.addPlayer(); + var iron = ingredient("iron", ""); + var alloy = mock(Alloy.class); + when(alloy.getId()).thenReturn("bronze"); + when(alloy.build()).thenAnswer(i -> new ItemStack(Material.GOLD_INGOT)); + AlloyManager.addAlloy(alloy); + var station = new CraftingStation(loc()); + station.setRecipe(recipe("")); + station.getCurrentMaterials().put("ingredient.iron", 2); + station.getCurrentMaterials().put("alloy.bronze", 3); + station.drop(); + station.drop(2); + station.cancel(); + assertFalse(station.hasRecipe()); + assertTrue(station.getCurrentMaterials().isEmpty()); + assertEquals(6, station.getLoc().getWorld().getEntities().size()); + } + + @Test + void obsoleteMaterialTagsDoNotCrashOrConsumeItems() throws Exception { + var p = server.addPlayer(); + var station = new CraftingStation(loc()); + station.setRecipe(recipe("recipe: ['metal.1']")); + var missing = ingredient("removed", "").build(); + IngredientLoader.oList.removeIf(i -> i.getId().equals("removed")); + p.getInventory().setItemInMainHand(missing); + assertEquals(StationFeedback.NOT_INGREDIENT, station.addMaterial(p, missing)); + assertEquals(1, p.getInventory().getItemInMainHand().getAmount()); + assertEquals(StationFeedback.NOT_INGREDIENT, station.addMaterial(p, alloyItem("removed"))); + } + + @Test + void modelApplicationHandlesVanillaItemsAdderMissingAndOtherSchemes() throws Exception { + var station = new CraftingStation(loc()); + station.setRecipe(recipe("")); + var original = new ItemStack(Material.IRON_SWORD); + try (var models = mockStatic(LegacyModelData.class)) { + var vanilla = new ModelScheme("vanilla", yaml("models: ['smith(v.golden_sword.7)']")); + assertSame( + original, + invoke( + station, + "applyModel", + new Class[] {ItemStack.class, ModelScheme.class}, + original, + vanilla)); + assertEquals(Material.GOLDEN_SWORD, original.getType()); + models.verify(() -> LegacyModelData.set(any(), eq(7))); + var ia = new ModelScheme("ia", yaml("models: ['smith(ia.custom:sword)']")); + var merged = new ItemStack(Material.DIAMOND_SWORD); + when(items.getArmorMerger().merge(original, Optional.empty(), "ia.custom:sword")) + .thenReturn(merged); + assertSame( + merged, + invoke( + station, + "applyModel", + new Class[] {ItemStack.class, ModelScheme.class}, + original, + ia)); + for (String path : List.of("models: ['smith(other.item)']", "models: []")) + assertSame( + original, + invoke( + station, + "applyModel", + new Class[] {ItemStack.class, ModelScheme.class}, + original, + new ModelScheme("empty", yaml(path)))); + } + } + + @Test + void experienceAggregatesOnlyExistingPositiveValidDefinitions() throws Exception { + var p = server.addPlayer(); + var station = new CraftingStation(loc()); + var iron = ingredient("iron", "xp: smith(1.25)"); + ingredient("copper", "xp: broken"); + ingredient("zero", "xp: smith(0)"); + ingredient("none", ""); + var bronze = + new Alloy( + "bronze", + "Bronze", + new AlloyData( + iron, + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(), + "smith(1.25)")); + AlloyManager.addAlloy(bronze); + var noxp = + new Alloy( + "none", + "None", + new AlloyData( + IngredientLoader.getByString("none"), + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(), + null)); + AlloyManager.addAlloy(noxp); + station + .getCurrentMaterials() + .putAll( + Map.of( + "ingredient.iron", + 2, + "ingredient.copper", + 1, + "ingredient.zero", + 1, + "ingredient.none", + 1, + "ingredient.missing", + 1, + "alloy.bronze", + 3, + "alloy.none", + 1, + "alloy.missing", + 1, + "unknown.value", + 1)); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.dispatchCommand(any(), anyString())).thenReturn(true); + invoke(station, "giveXP", new Class[] {Player.class}, p); + bukkit.verify( + () -> + Bukkit.dispatchCommand( + any(), eq("mmocore admin exp give " + p.getName() + " smith 6.25"))); + } + } + + @Test + void alloyMajorityNamesModelsHistoryAndGemSocketsReachFinalItem() throws Exception { + var p = server.addPlayer(); + var iron = ingredient("iron", ""); + var location = loc(); + QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); + SocketGroupLoader.map.put( + "gemstones", new SocketGroup("gemstones", yaml("slots:\n fine: ['Red']"))); + for (String name : List.of("Bronze", "§x", "§x§1§2§3§4§5§6Bronze")) { + var data = + new AlloyData( + iron, + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(), + null); + var alloy = new Alloy("bronze", name, data); + AlloyManager.addAlloy(alloy); + var station = + new CraftingStation( + location, + recipe("recipe: ['metal.2']\nmodel-type: metal"), + new HashMap<>(Map.of("alloy.bronze", 1, "ingredient.iron", 1)), + new HashMap<>()); + try (var nbt = mockStatic(NBTItem.class); + var models = mockStatic(LegacyModelData.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + var history = mock(StatHistory.class); + when(history.getOriginalData()).thenReturn(new NameData("Original")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(history); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + })) { + assertEquals(StationFeedback.SUCCESS, station.craft(p, 75.)); + var mmo = mmos.constructed().getFirst(); + verify(mmo).setStatHistory(eq(ItemStats.NAME), any()); + verify(mmo).setData(eq(ItemStats.GEM_SOCKETS), any(GemSocketsData.class)); + } + } + } + + @Test + void appearanceSupportsUnnamedIngredientAndOptionalOrAlternateModelTypes() throws Exception { + var p = server.addPlayer(); + var location = loc(); + var unnamed = + new Ingredient("plain", yaml("path: v.iron_ingot\ntype: metal")) { + @Override + public ItemStack build() { + return new ItemStack(Material.IRON_INGOT); + } + }; + IngredientLoader.oList.add(unnamed); + QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); + var alloy = + new Alloy( + "bronze", + "§x§1§2§3§4§5§6Bronze", + new AlloyData( + unnamed, + new net.tfminecraft.advancedcrafting.objects.data.StatData(), + new HashMap<>(), + null)); + AlloyManager.addAlloy(alloy); + for (String extra : + List.of( + "recipe: ['metal.1']", + "recipe: ['metal.1']\nmodel-type: wood", + "recipe: ['metal.1']\nname: '§x§1§2§3§4§5§6%material% Sword'")) + for (String key : List.of("ingredient.plain", "alloy.bronze")) { + var station = + new CraftingStation( + location, recipe(extra), new HashMap<>(Map.of(key, 1)), new HashMap<>()); + try (var nbt = mockStatic(NBTItem.class); + var models = mockStatic(LegacyModelData.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + })) { + assertEquals(StationFeedback.SUCCESS, station.craft(p, 50.)); + } + } + SchemeLoader.models.clear(); + var noModel = ingredient("unmodeled", ""); + var station = + new CraftingStation( + location, + recipe("recipe: ['metal.1']"), + new HashMap<>(Map.of("ingredient.unmodeled", 1)), + new HashMap<>()); + try (var nbt = mockStatic(NBTItem.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + })) { + assertEquals(StationFeedback.SUCCESS, station.craft(p, 50.)); + } + assertTrue( + new CraftingStation(location, recipe(""), new HashMap<>(), new HashMap<>()) + .hasAllMaterials(p)); + } + + @Test + void damagedStationCannotCraftFromAnUnknownMaterialKindAndRefundsKnownInputs() throws Exception { + var p = server.addPlayer(); + var location = loc(); + var station = + new CraftingStation( + location, recipe(""), new HashMap<>(Map.of("unknown.removed", 1)), new HashMap<>()); + QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); + try (var nbt = mockStatic(NBTItem.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + })) { + assertEquals(StationFeedback.NOT_INGREDIENT, station.craft(p, 50.)); + } + ingredient("iron", ""); + station.getCurrentMaterials().put("ingredient.iron", 2); + assertDoesNotThrow(() -> station.drop()); + assertDoesNotThrow(() -> station.drop(1)); + assertEquals(2, location.getWorld().getEntities().size()); + } + + @Test + void extraUnknownMaterialDoesNotReplaceSelectedModel() throws Exception { + var p = server.addPlayer(); + var iron = ingredient("iron", ""); + var location = loc(); + var station = + new CraftingStation( + location, + recipe("recipe: ['metal.1']\nmodel-type: metal"), + new HashMap<>(Map.of("ingredient.iron", 1, "unknown.removed", 1)), + new HashMap<>()); + QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); + try (var nbt = mockStatic(NBTItem.class); + var models = mockStatic(LegacyModelData.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + })) { + assertEquals(StationFeedback.SUCCESS, station.craft(p, 50.)); + } + } + + @Test + void refundSkipsUnknownKindsWhilePreservingKnownQuantities() throws Exception { + var location = loc(); + ingredient("iron", ""); + var station = new CraftingStation(location); + station.getCurrentMaterials().putAll(Map.of("ingredient.iron", 2, "unknown.removed", 1)); + assertDoesNotThrow(() -> station.drop()); + assertDoesNotThrow(() -> station.drop(1)); + assertEquals(2, location.getWorld().getEntities().size()); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationDatabaseCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationDatabaseCoverageTest.java new file mode 100644 index 0000000..5778fb8 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationDatabaseCoverageTest.java @@ -0,0 +1,130 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.database.Database; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.crafting.*; +import net.tfminecraft.advancedcrafting.objects.crafting.hits.*; +import org.bukkit.Location; +import org.json.simple.*; +import org.junit.jupiter.api.Test; + +class StationDatabaseCoverageTest extends CoverageSupport { + @Test + void absentStationDirectoryIsAnEmptyDatabase() { + var db = new Database(); + assertTrue(db.loadStations().isEmpty()); + assertDoesNotThrow(db::clear); + } + + @Test + void stationRoundTripUsesPluginDataDirectoryAndWorldName() throws Exception { + var db = new Database(); + var world = server.addSimpleWorld("custom_world"); + var loc = new Location(world, 1.5, 2.5, 3.5); + var iron = ingredient("iron", "hits: ['strike.2']"); + var recipe = recipe("recipe: ['metal.2']"); + RecipeLoader.map.put("sword", recipe); + var station = + new CraftingStation( + loc, + recipe, + new HashMap<>(Map.of("ingredient.iron", 2)), + new HashMap<>(Map.of(HitLoader.getByString("strike"), 1))); + db.saveStation(new CraftingStation(loc)); + db.saveStation(station); + var loaded = db.loadStations(); + assertEquals(1, loaded.size()); + assertEquals(loc, loaded.values().iterator().next().getLoc()); + assertEquals( + Map.of("ingredient.iron", 2), loaded.values().iterator().next().getCurrentMaterials()); + var dir = temp.resolve("data/stations"); + Files.createDirectories(dir.resolve("ignored")); + Files.writeString(dir.resolve("broken.json"), "broken"); + assertEquals(1, db.loadStations().size()); + db.clear(); + assertTrue(db.loadStations().isEmpty()); + assertTrue(Files.isDirectory(dir.resolve("ignored"))); + } + + @Test + void jsonConversionHandlesValuesDefaultsAndWriteFailures() throws Exception { + var db = new Database(); + var field = Database.class.getDeclaredField("json"); + field.setAccessible(true); + var current = new JSONObject(); + current.put("existing", "old"); + current.put("obj", new JSONObject()); + current.put("arr", new JSONArray()); + field.set(db, current); + var defaults = new HashMap(); + defaults.put("text", "&aGreen"); + defaults.put("double", 2.5); + defaults.put("integer", 3); + defaults.put("bool", true); + defaults.put("obj2", new JSONObject()); + defaults.put("arr2", new JSONArray()); + assertEquals("old", db.getRawData("existing", defaults)); + assertEquals("missing", db.getRawData("missing", defaults)); + assertEquals("§aGreen", db.getString("text", defaults)); + assertTrue(db.getBoolean("bool", defaults)); + assertEquals(2.5, db.getDouble("double", defaults)); + assertEquals(3, db.getInteger("integer", defaults)); + assertEquals(-1, db.getDouble("bad", defaults)); + assertEquals(-1, db.getInteger("bad", defaults)); + for (String key : List.of("obj", "obj2", "missing")) assertNotNull(db.getObject(key, defaults)); + for (String key : List.of("arr", "arr2", "missing")) assertNotNull(db.getArray(key, defaults)); + assertTrue(db.save(temp.resolve("test.json").toFile(), defaults)); + assertFalse(db.save(temp.toFile(), defaults)); + } + + @Test + void unreadableStationFilesSurviveClearAndBecomePrunableOnlyAfterSuccessfulLoad() + throws Exception { + var db = new Database(); + var dir = temp.resolve("data/stations"); + Files.createDirectories(dir); + var broken = dir.resolve("broken.json"); + Files.writeString(broken, "invalid original bytes"); + assertTrue(db.loadStations().isEmpty()); + db.clear(); + assertEquals("invalid original bytes", Files.readString(broken)); + var recipe = recipe(""); + RecipeLoader.map.put("sword", recipe); + String station = + "{\"world\":\"offline_world\",\"xPos\":0.0,\"yPos\":1.0,\"zPos\":2.0,\"recipe\":\"sword\",\"materials\":[],\"hits\":[]}"; + Files.writeString(broken, station); + assertTrue(db.loadStations().isEmpty()); + db.clear(); + assertEquals(station, Files.readString(broken)); + server.addSimpleWorld("offline_world"); + assertEquals(1, db.loadStations().size()); + db.clear(); + assertFalse(Files.exists(broken)); + } + + @Test + void savingWithACollidingUuidCannotOverwriteARejectedStation() throws Exception { + var dir = temp.resolve("data/stations"); + Files.createDirectories(dir); + var collision = UUID.randomUUID(); + var next = UUID.randomUUID(); + var rejected = dir.resolve(collision + ".json"); + Files.writeString(rejected, "original rejected bytes"); + var db = new Database(); + db.loadStations(); + db.clear(); + var world = server.addSimpleWorld("world"); + var station = new CraftingStation(new Location(world, 1, 2, 3)); + station.setRecipe(recipe("")); + try (var ids = org.mockito.Mockito.mockStatic(UUID.class)) { + ids.when(UUID::randomUUID).thenReturn(collision, next); + db.saveStation(station); + } + assertEquals("original rejected bytes", Files.readString(rejected)); + assertTrue(Files.exists(dir.resolve(next + ".json"))); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForgerTest.java b/src/test/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForgerTest.java index 90c9a6c..8227df9 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForgerTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/objects/alloys/AlloyForgerTest.java @@ -1,15 +1,15 @@ package net.tfminecraft.advancedcrafting.objects.alloys; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import org.junit.Test; +import org.junit.jupiter.api.Test; import net.tfminecraft.advancedcrafting.objects.stats.StatModifier;