From 908d7ccd72848f4fad72b05d3d0283c24414d640 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:06:35 +0000 Subject: [PATCH 1/3] test: cover woodworking and preserve failed station restores --- .github/workflows/build.yml | 8 + .github/workflows/maven-release.yml | 8 + README.md | 6 + pom.xml | 67 +++ .../woodworking/command/CommandManager.java | 1 - .../woodworking/database/StationStore.java | 65 ++- .../woodworking/project/WoodProject.java | 1 - .../woodworking/station/StationManager.java | 51 +- .../woodworking/station/WoodStation.java | 4 +- .../tfminecraft/woodworking/DomainTest.java | 235 +++++++++ .../woodworking/InventoryTest.java | 91 ++++ .../woodworking/LoadersCommandsTest.java | 191 ++++++++ .../woodworking/StationManagerTest.java | 444 ++++++++++++++++++ .../woodworking/StationStoreTest.java | 239 ++++++++++ .../tfminecraft/woodworking/TestSupport.java | 87 ++++ 15 files changed, 1436 insertions(+), 62 deletions(-) create mode 100644 src/test/java/net/tfminecraft/woodworking/DomainTest.java create mode 100644 src/test/java/net/tfminecraft/woodworking/InventoryTest.java create mode 100644 src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java create mode 100644 src/test/java/net/tfminecraft/woodworking/StationManagerTest.java create mode 100644 src/test/java/net/tfminecraft/woodworking/StationStoreTest.java create mode 100644 src/test/java/net/tfminecraft/woodworking/TestSupport.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..efebcd7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,6 +67,14 @@ jobs: include-hidden-files: true if-no-files-found: error + - name: Upload coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + - name: Upload unit test reports if: ${{ !cancelled() && hashFiles('target/surefire-reports/*.xml') != '' }} uses: actions/upload-artifact@v7 diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 87d147c..4d71c46 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -83,6 +83,14 @@ jobs: --stage "$RUNNER_TEMP/plugin-release" \ --dependencies .build/plugin-dependencies.json + - name: Upload coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + - uses: actions/upload-artifact@v7 with: name: plugin-release-${{ github.sha }} diff --git a/README.md b/README.md index 9c55965..9e8cf88 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Each workbench holds a project while its materials and required actions are comp Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Tests and coverage + +Run `mvn -B --no-transfer-progress clean verify` with Java 21 after installing the pinned plugin dependencies used by CI. JUnit 5, Mockito and MockBukkit exercise project definitions and progress, YAML loaders, GUI pagination, permission checks, bench events, plugin lifecycle, and persisted station recovery. + +JaCoCo enforces **100% line, branch and instruction coverage** across all production classes, without exclusions. HTML/XML reports appear in `target/site/jacoco/` and are uploaded by build and release CI. External plugin APIs are mocked; a live Minecraft integration run remains useful for server-specific behavior. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/pom.xml b/pom.xml index f0bd1c2..e08ccdf 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,24 @@ + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + local gson @@ -86,6 +104,55 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + prepare-agent + + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 1.0 + + + LINE + COVEREDRATIO + 1.0 + + + BRANCH + COVEREDRATIO + 1.0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/net/tfminecraft/woodworking/command/CommandManager.java b/src/main/java/net/tfminecraft/woodworking/command/CommandManager.java index 5e8f5f0..4397952 100644 --- a/src/main/java/net/tfminecraft/woodworking/command/CommandManager.java +++ b/src/main/java/net/tfminecraft/woodworking/command/CommandManager.java @@ -50,7 +50,6 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String sender.sendMessage("§cOnly players can select a project on a bench."); return true; } - if (!Permissions.requireUse(p)) return true; if (args.length < 2) { p.sendMessage("§cUsage: /woodworking select "); return true; diff --git a/src/main/java/net/tfminecraft/woodworking/database/StationStore.java b/src/main/java/net/tfminecraft/woodworking/database/StationStore.java index 2f323c7..d43cb44 100644 --- a/src/main/java/net/tfminecraft/woodworking/database/StationStore.java +++ b/src/main/java/net/tfminecraft/woodworking/database/StationStore.java @@ -46,6 +46,8 @@ public final class StationStore { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Set retained = new HashSet<>(); + private StationStore() { } @@ -66,6 +68,7 @@ public static File fileFor(Location loc) { public static void delete(Location loc) { if (loc == null) return; File file = fileFor(loc); + if (retained.contains(file.toPath().toAbsolutePath())) return; if (file.exists() && !file.delete()) { Log.warn("Failed to delete station file " + file.getName()); } @@ -90,6 +93,7 @@ public static void saveAll(Collection stations) { if (files == null) return; for (File file : files) { if (!file.isFile() || !file.getName().endsWith(".json")) continue; + if (retained.contains(file.toPath().toAbsolutePath())) continue; if (!keep.contains(file.getName()) && !file.delete()) { Log.warn("Failed to delete leftover station file " + file.getName()); } @@ -108,7 +112,12 @@ public static List loadAll() { for (File file : files) { if (!file.isFile() || !file.getName().endsWith(".json")) continue; WoodStation station = loadFile(file); - if (station != null) out.add(station); + if (station != null) { + retained.remove(file.toPath().toAbsolutePath()); + out.add(station); + } else { + retained.add(file.toPath().toAbsolutePath()); + } } return out; } @@ -117,6 +126,15 @@ private static void write(WoodStation station, File file) { StationData data = toData(station); if (data == null) return; file.getParentFile().mkdirs(); + if (retained.contains(file.toPath().toAbsolutePath())) { + try { + Files.move(file.toPath(), file.toPath().resolveSibling(file.getName() + ".rejected-" + java.util.UUID.randomUUID())); + retained.remove(file.toPath().toAbsolutePath()); + } catch (IOException ex) { + Log.warn("Failed to preserve rejected station " + file.getName() + ": " + ex.getMessage()); + return; + } + } try (Writer writer = Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8)) { GSON.toJson(data, writer); } catch (IOException ex) { @@ -125,32 +143,33 @@ private static void write(WoodStation station, File file) { } private static WoodStation loadFile(File file) { + StationData data; try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { - StationData data = GSON.fromJson(reader, StationData.class); - if (data == null || data.world == null || data.project == null) { - Log.warn("Invalid station file " + file.getName() + ", leaving it on disk."); - return null; - } - World world = Bukkit.getWorld(data.world); - if (world == null) { - Log.warn("Station file " + file.getName() + " world '" + data.world + "' is missing, leaving it on disk."); - return null; - } - WoodProject project = ProjectLoader.getByString(data.project); - if (project == null) { - Log.warn("Station file " + file.getName() + " unknown project '" + data.project - + "', leaving it on disk."); - return null; - } - Location loc = StationManager.key(new Location(world, data.x, data.y, data.z)); - WoodStation station = new WoodStation(loc); - station.setProject(project); - station.applySavedProgress(data.materials, data.hits, decodeItems(data.deposited)); - return station; - } catch (IOException ex) { + data = GSON.fromJson(reader, StationData.class); + } catch (IOException | com.google.gson.JsonParseException ex) { Log.warn("Failed to read station file " + file.getName() + ": " + ex.getMessage()); return null; } + if (data == null || data.world == null || data.project == null) { + Log.warn("Invalid station file " + file.getName() + ", leaving it on disk."); + return null; + } + World world = Bukkit.getWorld(data.world); + if (world == null) { + Log.warn("Station file " + file.getName() + " world '" + data.world + "' is missing, leaving it on disk."); + return null; + } + WoodProject project = ProjectLoader.getByString(data.project); + if (project == null) { + Log.warn("Station file " + file.getName() + " unknown project '" + data.project + + "', leaving it on disk."); + return null; + } + Location loc = StationManager.key(new Location(world, data.x, data.y, data.z)); + WoodStation station = new WoodStation(loc); + station.setProject(project); + station.applySavedProgress(data.materials, data.hits, decodeItems(data.deposited)); + return station; } private static StationData toData(WoodStation station) { diff --git a/src/main/java/net/tfminecraft/woodworking/project/WoodProject.java b/src/main/java/net/tfminecraft/woodworking/project/WoodProject.java index bdc2bab..c409d93 100644 --- a/src/main/java/net/tfminecraft/woodworking/project/WoodProject.java +++ b/src/main/java/net/tfminecraft/woodworking/project/WoodProject.java @@ -60,7 +60,6 @@ public WoodProject(String key, String categoryId, ConfigurationSection config) { /** Splits "whittle.3" into id and amount. Returns null when malformed. */ private static String[] split(String entry) { - if (entry == null) return null; int i = entry.lastIndexOf('.'); if (i <= 0 || i == entry.length() - 1) return null; String amount = entry.substring(i + 1); diff --git a/src/main/java/net/tfminecraft/woodworking/station/StationManager.java b/src/main/java/net/tfminecraft/woodworking/station/StationManager.java index b0a544c..b73eb40 100644 --- a/src/main/java/net/tfminecraft/woodworking/station/StationManager.java +++ b/src/main/java/net/tfminecraft/woodworking/station/StationManager.java @@ -144,6 +144,7 @@ public static Location key(Location loc) { @EventHandler public void onInteract(PlayerInteractEvent e) { + if (e.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) return; if (e.getClickedBlock() == null) return; Action action = e.getAction(); if (action != Action.RIGHT_CLICK_BLOCK && action != Action.LEFT_CLICK_BLOCK) return; @@ -281,27 +282,15 @@ private void handleRightClick(PlayerInteractEvent e) { e.setCancelled(true); markCooldown(p); - StationFeedback feedback = existing.addMaterial(material, hand); - switch (feedback) { - case SUCCESS: - consumeOne(p); - markDirty(); - IntCounter bucket = existing.getTypes().get(material.getType()); - String progress = bucket == null ? "" : bucket.getCurrent() + "/" + bucket.getNeeded(); - p.sendTitle("§aAdded " + material.getName(), MaterialTypeLoader.display(material.getType()) + " §e" + progress, 5, 20, 5); - playWorkFx(existing.getLoc(), Material.OAK_LOG); - p.getWorld().playSound(existing.getLoc(), Sound.ITEM_AXE_WAX_OFF, 0.7f, 2f); - break; - case WRONG_TYPE: - p.sendMessage("§cThis item type is not needed for the project"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - break; - case NO_PROJECT: - p.sendMessage("§cThis bench has no project. Right-click the table to choose one."); - break; - default: - break; - } + existing.addMaterial(material, hand); + consumeOne(p); + markDirty(); + IntCounter bucket = existing.getTypes().get(material.getType()); + String progress = bucket.getCurrent() + "/" + bucket.getNeeded(); + p.sendTitle("§aAdded " + material.getName(), MaterialTypeLoader.display(material.getType()) + " §e" + progress, 5, 20, 5); + playWorkFx(existing.getLoc(), Material.OAK_LOG); + p.getWorld().playSound(existing.getLoc(), Sound.ITEM_AXE_WAX_OFF, 0.7f, 2f); + } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @@ -350,27 +339,21 @@ private void handleLeftClick(PlayerInteractEvent e) { e.setCancelled(true); markCooldown(p); StationFeedback feedback = station.hit(hit); - switch (feedback) { - case SUCCESS: + if (feedback == StationFeedback.SUCCESS) { markDirty(); IntCounter typeCounter = station.getHitTypes().get(hit.getType()); HitType type = hit.getType(); - String typeName = type == null ? "Hits" : type.getName(); - String progress = typeCounter == null ? "" : typeCounter.getCurrent() + "/" + typeCounter.getNeeded(); + String typeName = type.getName(); + String progress = typeCounter.getCurrent() + "/" + typeCounter.getNeeded(); p.sendTitle("§a+1 " + hit.getName(), typeName + " §e" + progress, 5, 20, 5); playWorkFx(station.getLoc(), Material.OAK_PLANKS); p.getWorld().playSound(station.getLoc(), Sound.BLOCK_ANVIL_USE, 0.4f, 1f); - break; - case LACKING_ITEMS: + } else if (feedback == StationFeedback.LACKING_ITEMS) { p.sendMessage("§cYou have to add all the items before working"); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - break; - case WRONG_TYPE: + } else { p.sendMessage("§cThis item cannot be used for woodworking hits"); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - break; - default: - break; } } @@ -393,7 +376,7 @@ private void completeCraft(Player p, WoodStation station) { WoodProject project = station.getProject(); String path = project.getItem(); ItemStack output = TLibs.getItemAPI().getCreator().getItemFromPath(path); - if (output == null || (path != null && path.toLowerCase().startsWith("ia.") && output.getType() == Material.DIRT)) { + if (output == null || output.getType().isAir() || (path != null && path.toLowerCase().startsWith("ia.") && output.getType() == Material.DIRT)) { Log.warn("Could not build output for project " + project.getId() + " (" + path + "). Station left intact."); p.sendMessage("§cCould not create that item. Contact an administrator."); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); @@ -439,7 +422,6 @@ private boolean isStationFurniture(FurnitureBreakEvent e) { /** Resolves the MMOItems TYPE.ID of a held tool to its hit, or null when it is not a hit tool. */ private CraftingHit matchHit(ItemStack item) { - if (item == null || item.getType().isAir()) return null; NBTItem nbt = NBTItem.get(item); if (!nbt.hasType()) return null; return HitLoader.getByTool(nbt.getType() + "." + nbt.getString("MMOITEMS_ITEM_ID")); @@ -463,7 +445,6 @@ private WoodMaterial matchMaterial(ItemStack item) { private void consumeOne(Player p) { ItemStack hand = p.getInventory().getItemInMainHand(); - if (hand == null || hand.getType().isAir()) return; hand.setAmount(hand.getAmount() - 1); } diff --git a/src/main/java/net/tfminecraft/woodworking/station/WoodStation.java b/src/main/java/net/tfminecraft/woodworking/station/WoodStation.java index 133e9e8..c376998 100644 --- a/src/main/java/net/tfminecraft/woodworking/station/WoodStation.java +++ b/src/main/java/net/tfminecraft/woodworking/station/WoodStation.java @@ -201,7 +201,7 @@ public boolean checkExactHits() { if (project == null) return false; for (CraftingHit required : project.getHits().keySet()) { IntCounter c = hits.get(required); - if (c == null || !c.isEqual()) return false; + if (!c.isEqual()) return false; } for (Map.Entry e : hits.entrySet()) { if (!e.getValue().isEqual()) return false; @@ -245,7 +245,7 @@ public 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); } if (counter == 0) return 100.0; return Math.round(amount / counter); diff --git a/src/test/java/net/tfminecraft/woodworking/DomainTest.java b/src/test/java/net/tfminecraft/woodworking/DomainTest.java new file mode 100644 index 0000000..1ba764a --- /dev/null +++ b/src/test/java/net/tfminecraft/woodworking/DomainTest.java @@ -0,0 +1,235 @@ +package net.tfminecraft.woodworking; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.woodworking.hit.*; +import net.tfminecraft.woodworking.loader.*; +import net.tfminecraft.woodworking.project.*; +import net.tfminecraft.woodworking.station.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class DomainTest { + ServerMock server; + WoodMaterial wood, extra; + CraftingHit hit; + HitType type; + WoodProject project; + + YamlConfiguration yaml(String text) throws Exception { + var y = new YamlConfiguration(); + y.loadFromString(text); + return y; + } + + @BeforeEach + void setup() throws Exception { + server = MockBukkit.mock(); + MaterialLoader.get().clear(); + HitLoader.get().clear(); + HitTypeLoader.get().clear(); + QualityLoader.get().clear(); + wood = new WoodMaterial("oak", yaml("path: v.oak_log\ntype: wood")); + extra = new WoodMaterial("birch", yaml("path: v.birch_log\ntype: wood")); + MaterialLoader.get().put("oak", wood); + MaterialLoader.get().put("birch", extra); + type = new HitType("cut", yaml("")); + HitTypeLoader.get().put("cut", type); + hit = new CraftingHit("whittle", yaml("tool: TOOL.KNIFE\ntype: cut")); + HitLoader.get().put("whittle", hit); + project = + new WoodProject( + "chair", "furniture", yaml("item: v.oak_stairs\nhits: [whittle.2]\nrecipe: [oak.2]")); + } + + @AfterEach + void teardown() { + MockBukkit.unmock(); + } + + @Test + void definitionsExposeConfiguredAndDefaultValues() throws Exception { + assertEquals("oak", wood.getId()); + assertEquals("oak", wood.getName()); + assertEquals("v.oak_log", wood.getPath()); + assertEquals("wood", wood.getType()); + assertEquals("other", new WoodMaterial("x", yaml("")).getType()); + var mat = new MaterialType("wood", yaml("name: Lumber")); + assertEquals("wood", mat.getId()); + assertEquals("Lumber", mat.getName()); + assertEquals("cut", type.getId()); + assertEquals("cut", type.getName()); + assertEquals("whittle", hit.getId()); + assertEquals("whittle", hit.getName()); + assertEquals("TOOL.KNIFE", hit.getTool()); + assertSame(type, hit.getType()); + var q = new Quality("good", yaml("amount: 90\nvalue: 2\nname: Good")); + assertEquals("good", q.getId()); + assertEquals("Good", q.getName()); + assertEquals(90, q.getAmount()); + assertEquals(2, q.getValue()); + assertTrue(q.isValid(90)); + assertFalse(q.isValid(89)); + var cat = new WoodCategory("furniture", yaml("item: v.chest")); + assertEquals("furniture", cat.getId()); + assertEquals("furniture", cat.getName()); + assertEquals("v.chest", cat.getItem()); + cat.addProject(project); + assertEquals(List.of(project), cat.getProjects()); + cat.clearProjects(); + assertTrue(cat.getProjects().isEmpty()); + assertEquals("chair", project.getId()); + assertEquals("chair", project.getName()); + assertEquals("furniture", project.getCategoryId()); + assertEquals("v.oak_stairs", project.getItem()); + assertEquals(2, project.getTotalHits()); + assertEquals(2, project.getTotalMaterials()); + assertEquals(Map.of(type, 2), project.getHitsByType()); + assertEquals(Map.of("wood", 2), project.getMaterialsByType()); + assertThrows(UnsupportedOperationException.class, () -> project.getHits().clear()); + assertThrows(UnsupportedOperationException.class, () -> project.getRecipe().clear()); + } + + @Test + void malformedRequirementsAreSkippedAndDuplicatesCombined() throws Exception { + var untyped = new CraftingHit("untyped", yaml("tool: TOOL.X")); + HitLoader.get().put("untyped", untyped); + var p = + new WoodProject( + "test", + "furniture", + yaml( + "hits: [invalid, '.1', 'whittle.', whittle.no, whittle.0, whittle.-2, missing.1," + + " whittle.1, whittle.2, untyped.1]\n" + + "recipe: [bad, '.1', 'oak.', oak.no, oak.0, oak.-2, missing.1, oak.1, oak.2," + + " birch.1]")); + assertEquals(4, p.getTotalHits()); + assertEquals(Map.of(type, 3), p.getHitsByType()); + assertEquals(4, p.getTotalMaterials()); + assertEquals(Map.of("wood", 4), p.getMaterialsByType()); + } + + @Test + void stationRequiresMaterialsAndExactHitsBeforeFinishing() { + var loc = new Location(null, 1, 2, 3); + var s = new WoodStation(loc); + assertSame(loc, s.getLoc()); + assertFalse(s.hasProject()); + assertNull(s.getProject()); + assertEquals(StationFeedback.NO_PROJECT, s.canFinish()); + assertEquals(StationFeedback.NO_PROJECT, s.addMaterial(wood, null)); + assertEquals(StationFeedback.NO_PROJECT, s.hit(hit)); + assertFalse(s.materialsAdded()); + assertFalse(s.hitsDone()); + assertFalse(s.checkExactHits()); + assertFalse(s.checkExactRecipe()); + assertEquals(100, s.calculatePercentage()); + s.setProject(project); + assertSame(project, s.getProject()); + assertTrue(s.hasProject()); + assertEquals(StationFeedback.LACKING_ITEMS, s.canFinish()); + assertEquals(StationFeedback.LACKING_ITEMS, s.hit(hit)); + assertEquals(StationFeedback.WRONG_TYPE, s.addMaterial(null, null)); + assertFalse(s.checkExactHits()); + var stack = new ItemStack(Material.OAK_LOG, 8); + assertEquals(StationFeedback.SUCCESS, s.addMaterial(wood, stack)); + assertEquals(8, stack.getAmount()); + assertEquals(1, s.getDeposited().getFirst().getAmount()); + s.addMaterial(wood, stack); + assertEquals(StationFeedback.LACKING_HITS, s.canFinish()); + assertEquals(StationFeedback.WRONG_TYPE, s.hit(null)); + assertEquals(StationFeedback.SUCCESS, s.hit(hit)); + assertEquals(50, s.calculatePercentage()); + s.hit(hit); + assertEquals(StationFeedback.SUCCESS, s.canFinish()); + assertTrue(s.checkExactRecipe()); + assertTrue(s.checkExactHits()); + assertEquals(100, s.calculatePercentage()); + assertEquals(2, s.getTypes().get("wood").getCurrent()); + assertEquals(2, s.getHits().get(hit).getCurrent()); + assertEquals(2, s.getHitTypes().get(type).getCurrent()); + assertEquals(2, s.getDepositedByMaterial().get(wood)); + s.hit(hit); + assertEquals(50, s.calculatePercentage()); + assertEquals(StationFeedback.RECIPE_MISMATCH, s.canFinish()); + s.hit(hit); + assertEquals(0, s.calculatePercentage()); + assertEquals(2, s.cancel().size()); + assertFalse(s.hasProject()); + assertTrue(s.getDeposited().isEmpty()); + s.setProject(null); + assertTrue(s.getTypes().isEmpty()); + } + + @Test + void wrongIngredientsAndUnexpectedHitsCountButFailExactRecipe() throws Exception { + var s = new WoodStation(new Location(null, 0, 0, 0), project); + s.addMaterial(extra, null); + s.addMaterial(extra, null); + assertTrue(s.materialsAdded()); + assertFalse(s.checkExactRecipe()); + s.hit(hit); + s.hit(hit); + assertEquals(StationFeedback.RECIPE_MISMATCH, s.canFinish()); + s.addMaterial(wood, null); + assertFalse(s.checkExactRecipe()); + var otherType = new HitType("hammer", yaml("")); + HitTypeLoader.get().put("hammer", otherType); + var otherHit = new CraftingHit("hammer", yaml("type: hammer\ntool: TOOL.HAMMER")); + assertEquals(StationFeedback.SUCCESS, s.hit(otherHit)); + assertFalse(s.checkExactHits()); + assertEquals(StationFeedback.WRONG_TYPE, s.hit(new CraftingHit("none", yaml("")))); + } + + @Test + void restoredProgressIgnoresInvalidIdsAndClampsNegativeCounts() throws Exception { + var s = new WoodStation(new Location(null, 0, 0, 0), project); + s.applySavedProgress(null, null, null); + var mats = new HashMap(); + mats.put("oak", 2); + mats.put("missing", 1); + mats.put("birch", null); + var hits = new HashMap(); + hits.put("whittle", 2); + hits.put("missing", 1); + s.applySavedProgress(mats, hits, List.of(new ItemStack(Material.OAK_LOG, 2))); + assertEquals(StationFeedback.SUCCESS, s.canFinish()); + assertEquals(1, s.getDeposited().size()); + hits.put("whittle", null); + s.applySavedProgress(Map.of("oak", -10), hits, List.of()); + assertEquals(0, s.getTypes().get("wood").getCurrent()); + var exotic = new WoodMaterial("iron", yaml("path: v.iron_ingot\ntype: metal")); + MaterialLoader.get().put("iron", exotic); + var untyped = new CraftingHit("untyped", yaml("")); + HitLoader.get().put("untyped", untyped); + s.applySavedProgress(Map.of("iron", 1), Map.of("untyped", -2), null); + assertEquals(1, s.getTypes().get("metal").getCurrent()); + assertEquals(0, s.getHits().get(untyped).getCurrent()); + var q = new Quality("ok", yaml("value: 1")); + QualityLoader.get().put("ok", q); + assertSame(q, s.getQuality()); + } + + @Test + void extraHitTypesAndMaterialCountsRemainAccountedFor() throws Exception { + var s = new WoodStation(new Location(null, 0, 0, 0), project); + s.addMaterial(wood, null); + s.addMaterial(wood, null); + s.addMaterial(wood, null); + assertFalse(s.checkExactRecipe()); + var unexpected = new CraftingHit("extra", yaml("type: cut")); + HitLoader.get().put("extra", unexpected); + s.applySavedProgress(Map.of("oak", 2), Map.of("whittle", 2, "extra", 1), null); + assertFalse(s.checkExactHits()); + var otherType = new HitType("hammer", yaml("")); + HitTypeLoader.get().put("hammer", otherType); + var other = new CraftingHit("hammer", yaml("type: hammer")); + HitLoader.get().put("hammer", other); + s.applySavedProgress(Map.of(), Map.of("hammer", 1), null); + assertEquals(1, s.getHitTypes().get(otherType).getCurrent()); + } +} diff --git a/src/test/java/net/tfminecraft/woodworking/InventoryTest.java b/src/test/java/net/tfminecraft/woodworking/InventoryTest.java new file mode 100644 index 0000000..00666a7 --- /dev/null +++ b/src/test/java/net/tfminecraft/woodworking/InventoryTest.java @@ -0,0 +1,91 @@ +package net.tfminecraft.woodworking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.woodworking.gui.*; +import net.tfminecraft.woodworking.hit.*; +import net.tfminecraft.woodworking.loader.*; +import net.tfminecraft.woodworking.project.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; + +class InventoryTest extends TestSupport { + @Test + void categoriesSkipInvalidIconsAndLimitInventorySize() throws Exception { + var m = new InventoryManager(); + var p = server.addPlayer(); + for (String key : Arrays.asList("missing", "blank", "null", "badia", "air")) { + var c = + new WoodCategory( + key, + config( + "item: " + + switch (key) { + case "missing" -> "null"; + case "blank" -> "' '"; + case "badia" -> "ia.bad"; + default -> key; + })); + CategoryLoader.get().put(key, c); + } + when(api.getCreator().getItemFromPath("ia.bad")).thenReturn(item(Material.DIRT)); + when(api.getCreator().getItemFromPath("air")).thenReturn(item(Material.AIR)); + CategoryLoader.get().put("goodia", new WoodCategory("goodia", config("item: ia.good"))); + when(api.getCreator().getItemFromPath("ia.good")).thenReturn(item(Material.CHEST)); + when(api.getCreator().getItemFromPath("v.chest")).thenAnswer(i -> item(Material.CHEST)); + for (int i = 0; i < 30; i++) + CategoryLoader.get().put("c" + i, new WoodCategory("c" + i, config("item: v.chest"))); + m.openCategories(p); + var inv = p.getOpenInventory().getTopInventory(); + assertEquals( + 27, Arrays.stream(inv.getContents()).filter(x -> x.getType() == Material.CHEST).count()); + assertEquals( + "goodia", + inv.getItem(0) + .getItemMeta() + .getPersistentDataContainer() + .get(InventoryManager.categoryKey(), org.bukkit.persistence.PersistentDataType.STRING)); + } + + @Test + void projectPaginationCountsOnlyValidIconsAndDecoratesRequirements() throws Exception { + var m = new InventoryManager(); + var p = server.addPlayer(); + var cat = new WoodCategory("chairs", config("")); + HitTypeLoader.get().put("cut", new HitType("cut", config("name: Cut"))); + HitLoader.get().put("cut", new CraftingHit("cut", config("type: cut"))); + MaterialLoader.get().put("oak", new WoodMaterial("oak", config("type: wood\npath: v.oak_log"))); + cat.addProject(new WoodProject("bad", "chairs", config("item: missing"))); + when(api.getCreator().getItemFromPath("v.chest")).thenAnswer(i -> item(Material.CHEST)); + for (int i = 0; i < 50; i++) + cat.addProject( + new WoodProject( + "p" + i, "chairs", config("item: v.chest\nhits: [cut.1]\nrecipe: [oak.2]"))); + m.openProjects(p, cat, -1); + var inv = p.getOpenInventory().getTopInventory(); + assertEquals(Material.ARROW, inv.getItem(53).getType()); + assertEquals( + 45, Arrays.stream(inv.getContents()).filter(x -> x.getType() == Material.CHEST).count()); + assertTrue(inv.getItem(0).getItemMeta().getLore().getFirst().contains("2")); + m.openProjects(p, cat, 1); + inv = p.getOpenInventory().getTopInventory(); + assertEquals(Material.ARROW, inv.getItem(45).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inv.getItem(53).getType()); + m.openProjects(p, new WoodCategory("empty", config("")), 0); + assertEquals( + Material.GRAY_STAINED_GLASS_PANE, + p.getOpenInventory().getTopInventory().getItem(0).getType()); + var air = item(Material.AIR); + assertSame( + air, + invoke( + m, + "decorateProject", + new Class[] {ItemStack.class, WoodProject.class}, + air, + cat.getProjects().getFirst())); + } +} diff --git a/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java b/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java new file mode 100644 index 0000000..53a3637 --- /dev/null +++ b/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java @@ -0,0 +1,191 @@ +package net.tfminecraft.woodworking; + +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.woodworking.cache.Cache; +import net.tfminecraft.woodworking.command.*; +import net.tfminecraft.woodworking.loader.*; +import net.tfminecraft.woodworking.project.*; +import net.tfminecraft.woodworking.station.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class LoadersCommandsTest extends TestSupport { + @Test + void loadersHandleMissingMalformedValidAndInvalidDefinitions() throws Exception { + var loaders = + List.of( + new ConfigLoader(), + new HitTypeLoader(), + new HitLoader(), + new MaterialLoader(), + new MaterialTypeLoader(), + new CategoryLoader(), + new QualityLoader()); + for (var l : loaders) { + l.load(null); + l.load(dir.resolve("absent").toFile()); + l.load(yaml("bad.yml", "x: [").toFile()); + } + new HitTypeLoader().load(yaml("types.yml", "cut: {name: Cutting}").toFile()); + new HitLoader() + .load( + yaml( + "hits.yml", + "badtype: {type: absent}\n" + + "missingtool: {type: cut}\n" + + "cut: {type: cut, tool: TOOL.KNIFE}") + .toFile()); + assertEquals(1, HitLoader.get().size()); + assertNull(HitLoader.getByString(null)); + assertNull(HitLoader.getByTool(null)); + assertNull(HitLoader.getByTool("absent")); + assertSame(HitLoader.getByString("cut"), HitLoader.getByTool("tool.knife")); + assertNull(HitTypeLoader.getByString(null)); + new MaterialLoader() + .load(yaml("mats.yml", "bad: {name: Bad}\noak: {path: v.oak_log, type: wood}").toFile()); + assertEquals(1, MaterialLoader.get().size()); + assertNull(MaterialLoader.getByString(null)); + new MaterialTypeLoader().load(yaml("mattypes.yml", "wood: {name: Lumber}").toFile()); + assertEquals(1, MaterialTypeLoader.get().size()); + new Cache(); + assertEquals("Lumber", MaterialTypeLoader.display("wood")); + assertEquals("Unknown Materials", MaterialTypeLoader.display("unknown")); + assertEquals("Materials", MaterialTypeLoader.display(null)); + assertEquals("Materials", MaterialTypeLoader.display(" ")); + new CategoryLoader().load(yaml("cats.yml", "furniture: {item: v.chest}").toFile()); + assertNull(CategoryLoader.getByString(null)); + new QualityLoader() + .load( + yaml( + "quality.yml", + "good: {amount: 90, value: 2}\n" + + "low: {amount: 0, value: 1}\n" + + "invalid: {amount: 0, value: -2}") + .toFile()); + assertNull(QualityLoader.getByString(null)); + assertEquals("good", QualityLoader.getByAmount(100).getId()); + assertEquals("low", QualityLoader.getByAmount(50).getId()); + assertNull(QualityLoader.getByAmount(-10)); + assertNotNull(QualityLoader.getByString("good")); + for (String text : List.of("{}", "permission: ' '", "permission: woodworking.use")) + new ConfigLoader().load(yaml("config.yml", text).toFile()); + assertEquals("woodworking.use", Cache.permission); + var projects = new ProjectLoader(); + projects.loadFolder(null); + projects.loadFolder(dir.resolve("absent").toFile()); + var folder = Files.createDirectory(dir.resolve("projects")); + projects.loadFolder(folder.toFile()); + File inaccessible = mock(File.class); + when(inaccessible.isDirectory()).thenReturn(true); + projects.loadFolder(inaccessible); + Files.writeString( + folder.resolve("a.yml"), + "chair: {category: furniture, item: v.oak_stairs, hits: [cut.1], recipe: [oak.1]}\n" + + "unknown: {category: missing}\n" + + "noitem: {category: furniture}\n" + + "empty: {category: furniture, item: v.chest}"); + Files.writeString(folder.resolve("b.yml"), "chair: {category: furniture, item: v.dirt}"); + Files.writeString(folder.resolve("c.yml"), "x: ["); + Files.writeString(folder.resolve("ignored.txt"), ""); + projects.loadFolder(folder.toFile()); + assertEquals(2, ProjectLoader.get().size()); + assertNull(ProjectLoader.getByString(null)); + assertEquals("v.oak_stairs", ProjectLoader.getByString("chair").getItem()); + var previous = Woodworking.plugin; + Woodworking.plugin = null; + assertNotNull(net.tfminecraft.woodworking.utils.Log.get()); + net.tfminecraft.woodworking.utils.Log.info("fallback"); + Woodworking.plugin = previous; + } + + @Test + void commandsPermissionsSelectionAndCompletion() throws Exception { + var c = new CommandManager(); + var cmd = mock(Command.class); + var sender = mock(CommandSender.class); + when(cmd.getName()).thenReturn("other"); + assertFalse(c.onCommand(sender, cmd, "w", new String[0])); + assertNull(c.onTabComplete(sender, cmd, "w", new String[0])); + when(cmd.getName()).thenReturn("woodworking"); + c.onCommand(sender, cmd, "w", new String[0]); + c.onCommand(sender, cmd, "w", new String[] {"other"}); + c.onCommand(sender, cmd, "w", new String[] {"reload"}); + c.onCommand(sender, cmd, "w", new String[] {"select"}); + when(sender.hasPermission(Permissions.ADMIN)).thenReturn(true); + c.onCommand(sender, cmd, "w", new String[] {"reload"}); + verify(plugin).reload(); + c.onCommand(sender, cmd, "w", new String[] {"select"}); + var p = mock(Player.class); + Cache.permission = "woodworking.use"; + assertFalse(Permissions.requireUse(p)); + when(p.hasPermission(Permissions.ADMIN)).thenReturn(true); + assertTrue(Permissions.canUse(p)); + c.onCommand(p, cmd, "w", new String[] {"select"}); + c.onCommand(p, cmd, "w", new String[] {"select", "missing"}); + var project = new WoodProject("chair", "furniture", config("item: v.chest")); + ProjectLoader.get().put("chair", project); + var manager = mock(StationManager.class); + when(plugin.getStations()).thenReturn(manager); + c.onCommand(p, cmd, "w", new String[] {"select", "chair"}); + var block = mock(org.bukkit.block.Block.class); + when(p.getTargetBlockExact(6)).thenReturn(block); + c.onCommand(p, cmd, "w", new String[] {"select", "chair"}); + when(manager.isWoodworkingStation(block)).thenReturn(true); + var station = new WoodStation(new Location(null, 0, 0, 0)); + when(manager.getOrCreate(any())).thenReturn(station); + c.onCommand(p, cmd, "w", new String[] {"select", "chair"}); + assertTrue(station.hasProject()); + c.onCommand(p, cmd, "w", new String[] {"select", "chair"}); + verify(manager).markDirty(); + when(p.hasPermission(Permissions.ADMIN)).thenReturn(false); + Cache.permission = null; + assertTrue(Permissions.canUse(p)); + Cache.permission = " "; + assertTrue(Permissions.canUse(p)); + Cache.permission = "woodworking.use"; + when(p.hasPermission(Cache.permission)).thenReturn(true); + assertTrue(Permissions.requireUse(p)); + assertEquals(List.of("reload"), c.onTabComplete(sender, cmd, "w", new String[] {"re"})); + assertTrue(c.onTabComplete(p, cmd, "w", new String[] {"select", ""}).isEmpty()); + assertEquals( + List.of("chair"), c.onTabComplete(sender, cmd, "w", new String[] {"select", "ch"})); + for (int i = 0; i < 50; i++) ProjectLoader.get().put("x" + i, project); + assertEquals(40, c.onTabComplete(sender, cmd, "w", new String[] {"select", ""}).size()); + assertTrue(c.onTabComplete(sender, cmd, "w", new String[] {"select", "nope"}).isEmpty()); + assertTrue(c.onTabComplete(sender, cmd, "w", new String[] {"other", ""}).isEmpty()); + assertTrue(c.onTabComplete(sender, cmd, "w", new String[0]).isEmpty()); + } + + @Test + void lifecycleLoadsReloadsAndFlushes() throws Exception { + for (String name : List.of("TLibs", "MMOItems", "MythicLib", "ItemsAdder")) + MockBukkit.createMockPlugin(name); + try (var managers = mockConstruction(StationManager.class)) { + var loaded = MockBukkit.load(Woodworking.class); + assertSame(loaded, Woodworking.plugin); + assertNotNull(loaded.getCommand("woodworking").getExecutor()); + assertNotNull(loaded.getCommand("woodworking").getTabCompleter()); + assertTrue(loaded.loadSummary().startsWith("Loaded")); + loaded.createFolders(); + loaded.createConfigs(); + loaded.reload(); + verify(loaded.getStations()).clear(); + server.getScheduler().performTicks(1200); + verify(loaded.getStations()).flush(false); + loaded.onDisable(); + verify(loaded.getStations(), atLeastOnce()).flush(true); + var spy = spy(loaded); + doReturn(dir.resolve("new").toFile()).when(spy).getDataFolder(); + spy.createFolders(); + assertTrue(Files.isDirectory(dir.resolve("new/data/stations"))); + } + } +} diff --git a/src/test/java/net/tfminecraft/woodworking/StationManagerTest.java b/src/test/java/net/tfminecraft/woodworking/StationManagerTest.java new file mode 100644 index 0000000..488d047 --- /dev/null +++ b/src/test/java/net/tfminecraft/woodworking/StationManagerTest.java @@ -0,0 +1,444 @@ +package net.tfminecraft.woodworking; + +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.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import net.tfminecraft.woodworking.cache.Cache; +import net.tfminecraft.woodworking.database.*; +import net.tfminecraft.woodworking.gui.InventoryManager; +import net.tfminecraft.woodworking.hit.*; +import net.tfminecraft.woodworking.loader.*; +import net.tfminecraft.woodworking.project.*; +import net.tfminecraft.woodworking.station.*; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockito.*; + +class StationManagerTest extends TestSupport { + StationManager manager; + Player p; + PlayerInventory inv; + World world; + Location loc; + Block block; + WoodProject project; + WoodMaterial oak; + CraftingHit cut; + MockedStatic nbtApi; + NBTItem nbt; + + @BeforeEach + void stationSetup() throws Exception { + Cache.station = "iaf(test:bench)"; + Cache.brandingTool = "v.stick"; + manager = new StationManager(); + p = mock(Player.class); + inv = mock(PlayerInventory.class); + world = mock(World.class); + loc = new Location(world, 1, 2, 3); + block = mock(Block.class); + when(world.getName()).thenReturn("world"); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + when(block.getLocation()).thenReturn(loc); + when(p.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p.getInventory()).thenReturn(inv); + when(p.getWorld()).thenReturn(world); + when(p.getLocation()).thenReturn(loc); + when(inv.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + when(inv.getItemInMainHand()).thenReturn(item(Material.AIR)); + var blockApi = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + tlibs.when(TLibs::getBlockAPI).thenReturn(blockApi); + when(blockApi.getChecker().checkBlock(block, Cache.station)).thenReturn(true); + Cache.permission = null; + var type = new HitType("cut", config("")); + HitTypeLoader.get().clear(); + HitTypeLoader.get().put("cut", type); + cut = new CraftingHit("cut", config("tool: TOOL.KNIFE\ntype: cut")); + HitLoader.get().clear(); + HitLoader.get().put("cut", cut); + oak = new WoodMaterial("oak", config("path: v.oak_log\ntype: wood")); + MaterialLoader.get().clear(); + MaterialLoader.get().put("oak", oak); + project = + new WoodProject( + "chair", "furniture", config("item: v.chest\nhits: [cut.1]\nrecipe: [oak.1]")); + ProjectLoader.get().put("chair", project); + nbt = mock(NBTItem.class); + nbtApi = mockStatic(NBTItem.class); + nbtApi.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + } + + @AfterEach + void closeNbt() { + nbtApi.close(); + } + + void cooldownReset() throws Exception { + var f = StationManager.class.getDeclaredField("clickCooldown"); + f.setAccessible(true); + ((Map) f.get(manager)).clear(); + } + + PlayerInteractEvent event(Action a) { + var e = mock(PlayerInteractEvent.class); + when(e.getClickedBlock()).thenReturn(block); + when(e.getPlayer()).thenReturn(p); + when(e.getAction()).thenReturn(a); + when(e.getHand()).thenReturn(EquipmentSlot.HAND); + return e; + } + + void click(Action a) throws Exception { + cooldownReset(); + manager.onInteract(event(a)); + } + + WoodStation station() { + var s = manager.getOrCreate(loc); + s.setProject(project); + return s; + } + + void hand(ItemStack s, boolean branding) { + when(inv.getItemInMainHand()).thenReturn(s); + when(api.getChecker().checkItemWithPath(s, Cache.brandingTool)).thenReturn(branding); + } + + @Test + void registryPersistenceKeysAndFurniturePaths() throws Exception { + assertNull(manager.get(null)); + assertNull(manager.getOrCreate(null)); + assertNull(manager.remove(null)); + assertNull(StationManager.key(null)); + assertEquals( + new Location(null, 1, 2, 3), StationManager.key(new Location(null, 1.5, 2.3, 3.9))); + manager.put(null); + manager.put(new WoodStation(null)); + var s = manager.getOrCreate(loc); + assertSame(s, manager.getOrCreate(loc)); + assertSame(s, manager.get(loc)); + assertEquals(1, manager.getStations().size()); + assertSame(s, manager.getMap().get(loc)); + try (var store = mockStatic(StationStore.class)) { + manager.flush(false); + store.verifyNoInteractions(); + manager.markDirty(); + manager.flush(false); + manager.flush(true); + store.verify(() -> StationStore.saveAll(any()), times(2)); + store.when(StationStore::loadAll).thenReturn(List.of(s)); + manager.clear(); + manager.loadPersisted(); + assertSame(s, manager.get(loc)); + assertSame(s, manager.remove(loc)); + assertNull(manager.remove(loc)); + store.verify(() -> StationStore.delete(loc)); + } + assertFalse(manager.isWoodworkingStation(null)); + String old = Cache.station; + Cache.station = null; + assertFalse(manager.isWoodworkingStation(block)); + assertEquals("", StationManager.furnitureId()); + for (String path : List.of("iaf(test:bench)", "test:bench", "raw", "iaf(broken")) { + Cache.station = path; + assertEquals( + path.equals("iaf(test:bench)") ? "test:bench" : path, StationManager.furnitureId()); + } + Cache.station = old; + } + + @Test + void interactionGuardsPermissionsCooldownAndMenuOpening() throws Exception { + var e = event(Action.PHYSICAL); + manager.onInteract(e); + when(e.getClickedBlock()).thenReturn(null); + manager.onInteract(e); + when(e.getClickedBlock()).thenReturn(mock(Block.class)); + when(e.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + manager.onInteract(e); + Cache.permission = "use"; + e = event(Action.RIGHT_CLICK_BLOCK); + manager.onInteract(e); + manager.onInteract(e); + verify(p).sendMessage(contains("permission")); + Cache.permission = null; + manager.onInteract(e); + verify(p, never()).openInventory(any(Inventory.class)); + cooldownReset(); + manager.onInteract(e); + verify(p).openInventory(any(Inventory.class)); + assertNotNull(manager.get(loc)); + click(Action.RIGHT_CLICK_BLOCK); + verify(p, times(2)).openInventory(any(Inventory.class)); + } + + @Test + void materialsStatusAndToolHitsFollowProgress() throws Exception { + var s = station(); + var wood = new ItemStack(Material.OAK_LOG, 3); + hand(wood, false); + when(api.getChecker().checkItemWithPath(wood, "v.oak_log")).thenReturn(true); + click(Action.RIGHT_CLICK_BLOCK); + assertEquals(2, wood.getAmount()); + assertEquals(1, s.getDeposited().size()); + var other = new WoodMaterial("metal", config("path: v.iron_ingot\ntype: metal")); + MaterialLoader.get().put("metal", other); + var iron = item(Material.IRON_INGOT); + hand(iron, false); + when(api.getChecker().checkItemWithPath(iron, "v.iron_ingot")).thenReturn(true); + click(Action.RIGHT_CLICK_BLOCK); + assertEquals(2, s.getDeposited().size()); + hand(item(Material.STICK), true); + click(Action.RIGHT_CLICK_BLOCK); + verify(p).sendMessage("§7Project: chair"); + var knife = item(Material.IRON_AXE); + hand(knife, false); + click(Action.LEFT_CLICK_BLOCK); + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("TOOL"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("UNKNOWN"); + click(Action.LEFT_CLICK_BLOCK); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("KNIFE"); + click(Action.LEFT_CLICK_BLOCK); + assertTrue(s.hitsDone()); + var untyped = new CraftingHit("none", config("tool: TOOL.BAD")); + HitLoader.get().put("none", untyped); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("BAD"); + click(Action.LEFT_CLICK_BLOCK); + verify(p).sendMessage(contains("cannot be used")); + var empty = new WoodStation(loc, project); + manager.put(empty); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("KNIFE"); + click(Action.LEFT_CLICK_BLOCK); + verify(p).sendMessage("§cYou have to add all the items before working"); + } + + @Test + void brandingFinishesCancelsOrFailsWorkAndValidatesOutput() throws Exception { + hand(item(Material.STICK), true); + click(Action.LEFT_CLICK_BLOCK); + manager.getOrCreate(loc); + click(Action.LEFT_CLICK_BLOCK); + var s = station(); + click(Action.LEFT_CLICK_BLOCK); + verify(p).sendMessage(contains("all the items before finishing")); + s.addMaterial(oak, item(Material.OAK_LOG)); + click(Action.LEFT_CLICK_BLOCK); + verify(p).sendMessage(contains("all the hits before finishing")); + s.hit(cut); + click(Action.LEFT_CLICK_BLOCK); + assertSame(s, manager.get(loc)); + when(api.getCreator().getItemFromPath("v.chest")).thenReturn(item(Material.CHEST)); + click(Action.LEFT_CLICK_BLOCK); + assertNull(manager.get(loc)); + verify(world) + .dropItemNaturally(any(Location.class), argThat(x -> x.getType() == Material.CHEST)); + s = station(); + s.addMaterial(oak, item(Material.OAK_LOG)); + s.hit(cut); + s.hit(cut); + click(Action.LEFT_CLICK_BLOCK); + assertNull(manager.get(loc)); + s = station(); + s.addMaterial(oak, item(Material.OAK_LOG)); + when(p.isSneaking()).thenReturn(true); + when(inv.addItem(any(ItemStack.class))) + .thenAnswer(i -> new HashMap<>(Map.of(0, (ItemStack) i.getArgument(0)))); + click(Action.LEFT_CLICK_BLOCK); + assertNull(manager.get(loc)); + verify(world, atLeastOnce()) + .dropItemNaturally(any(Location.class), argThat(x -> x.getType() == Material.OAK_LOG)); + } + + @Test + void furnitureBreakProtectsToolsAndRefundsValidDestruction() throws Exception { + var e = mock(FurnitureBreakEvent.class); + manager.onToolBreak(e); + manager.onFurnitureBreak(e); + when(e.getNamespacedID()).thenReturn("wrong"); + manager.onToolBreak(e); + manager.onFurnitureBreak(e); + when(e.getNamespacedID()).thenReturn(StationManager.furnitureId()); + var entity = mock(Entity.class); + when(entity.getLocation()).thenReturn(loc); + when(e.getBukkitEntity()).thenReturn(entity); + manager.onToolBreak(e); + manager.onFurnitureBreak(e); + when(e.getPlayer()).thenReturn(p); + hand(null, false); + manager.onToolBreak(e); + hand(item(Material.AIR), false); + manager.onToolBreak(e); + hand(item(Material.STICK), true); + manager.onToolBreak(e); + verify(e).setCancelled(true); + hand(item(Material.IRON_AXE), false); + manager.onToolBreak(e); + when(nbt.hasType()).thenReturn(true); + when(nbt.getType()).thenReturn("TOOL"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("KNIFE"); + manager.onToolBreak(e); + verify(e, times(2)).setCancelled(true); + var s = station(); + s.addMaterial(oak, item(Material.OAK_LOG)); + manager.onFurnitureBreak(e); + assertNull(manager.get(loc)); + verify(inv).addItem(any(ItemStack.class)); + manager.getOrCreate(loc); + manager.onFurnitureBreak(e); + when(e.getPlayer()).thenReturn(null); + s = station(); + s.addMaterial(oak, item(Material.OAK_LOG)); + manager.onFurnitureBreak(e); + verify(world).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + + @Test + void menusRouteCategoriesPagesAndSelectedProjects() throws Exception { + var view = mock(InventoryView.class); + var e = mock(InventoryClickEvent.class); + when(e.getView()).thenReturn(view); + when(view.getTitle()).thenReturn("other"); + manager.onMenuClick(e); + when(view.getTitle()).thenReturn(InventoryManager.CATEGORY_TITLE); + when(e.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + manager.onMenuClick(e); + when(e.getWhoClicked()).thenReturn(p); + Cache.permission = "use"; + manager.onMenuClick(e); + Cache.permission = null; + manager.onMenuClick(e); + var plain = mock(ItemStack.class); + when(e.getCurrentItem()).thenReturn(plain); + manager.onMenuClick(e); + var icon = item(Material.CHEST); + when(e.getCurrentItem()).thenReturn(icon); + manager.onMenuClick(e); + var meta = icon.getItemMeta(); + meta.getPersistentDataContainer() + .set(InventoryManager.pageKey(), PersistentDataType.INTEGER, 1); + icon.setItemMeta(meta); + manager.onMenuClick(e); + meta.getPersistentDataContainer() + .set(InventoryManager.categoryKey(), PersistentDataType.STRING, "furniture"); + icon.setItemMeta(meta); + manager.onMenuClick(e); + CategoryLoader.get().put("furniture", new WoodCategory("furniture", config(""))); + manager.onMenuClick(e); + meta.getPersistentDataContainer().remove(InventoryManager.pageKey()); + icon.setItemMeta(meta); + manager.onMenuClick(e); + CategoryLoader.get().clear(); + manager.onMenuClick(e); + meta.getPersistentDataContainer().remove(InventoryManager.categoryKey()); + meta.getPersistentDataContainer() + .set(InventoryManager.projectKey(), PersistentDataType.STRING, "missing"); + icon.setItemMeta(meta); + when(view.getTitle()).thenReturn(InventoryManager.PROJECT_TITLE); + manager.onMenuClick(e); + meta.getPersistentDataContainer() + .set(InventoryManager.projectKey(), PersistentDataType.STRING, "chair"); + icon.setItemMeta(meta); + manager.onMenuClick(e); + verify(p).sendMessage(contains("no longer available")); + click(Action.RIGHT_CLICK_BLOCK); + manager.onMenuClick(e); + assertSame(project, manager.get(loc).getProject()); + var f = StationManager.class.getDeclaredField("openMenu"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + var menus = (Map) f.get(manager); + menus.put(p.getUniqueId(), manager.get(loc)); + manager.onMenuClick(e); + verify(p).sendMessage(contains("already has a project")); + menus.put(UUID.randomUUID(), new WoodStation(new Location(world, 9, 2, 3))); + manager.remove(loc); + assertEquals(1, menus.size()); + } + + @Test + void offhandInteractionDoesNotDoubleDepositOrOpenMenus() throws Exception { + var e = event(Action.RIGHT_CLICK_BLOCK); + when(e.getHand()).thenReturn(EquipmentSlot.OFF_HAND); + manager.onInteract(e); + verify(p, never()).openInventory(any(Inventory.class)); + } + + @Test + void airOutputDoesNotConsumeFinishedWork() throws Exception { + var s = station(); + s.addMaterial(oak, item(Material.OAK_LOG)); + s.hit(cut); + hand(item(Material.STICK), true); + when(api.getCreator().getItemFromPath("v.chest")).thenReturn(item(Material.AIR)); + click(Action.LEFT_CLICK_BLOCK); + assertSame(s, manager.get(loc)); + assertTrue(s.hasProject()); + } + + @Test + void heldItemAbsenceAndCooldownExpiryDoNotInventActions() throws Exception { + station(); + hand(null, false); + click(Action.LEFT_CLICK_BLOCK); + click(Action.RIGHT_CLICK_BLOCK); + hand(item(Material.AIR), false); + click(Action.LEFT_CLICK_BLOCK); + click(Action.RIGHT_CLICK_BLOCK); + hand(item(Material.DIRT), false); + click(Action.RIGHT_CLICK_BLOCK); + assertTrue(manager.get(loc).getDeposited().isEmpty()); + var field = StationManager.class.getDeclaredField("clickCooldown"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + var times = (Map) field.get(manager); + times.put(p.getUniqueId(), 0L); + manager.onInteract(event(Action.RIGHT_CLICK_BLOCK)); + } + + @Test + void outputFallbacksQualityAndDetachedWorldEffects() throws Exception { + QualityLoader.get().clear(); + QualityLoader.get().put("good", new Quality("good", config("name: Good\nvalue: 2\namount: 0"))); + var method = new Class[] {Player.class, WoodStation.class}; + for (String path : Arrays.asList("ia.bad", "ia.good", "v.dirt", null)) { + var configuration = config(""); + configuration.set("item", path); + var proj = new WoodProject("x", "furniture", configuration); + var station = new WoodStation(new Location(null, 1, 2, 3), proj); + var output = item(path != null && path.equals("ia.bad") ? Material.DIRT : Material.CHEST); + when(api.getCreator().getItemFromPath(path)).thenReturn(output); + invoke(manager, "completeCraft", method, p, station); + if ("ia.bad".equals(path)) assertTrue(station.hasProject()); + else assertFalse(station.hasProject()); + } + var detached = new WoodStation(new Location(null, 1, 2, 3), project); + invoke(manager, "failCraft", method, p, detached); + assertFalse(detached.hasProject()); + invoke( + manager, + "dropAt", + new Class[] {Location.class, List.class}, + new Location(null, 0, 0, 0), + List.of(item(Material.DIAMOND))); + invoke( + manager, + "playWorkFx", + new Class[] {Location.class, Material.class}, + new Location(null, 0, 0, 0), + Material.OAK_LOG); + } +} diff --git a/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java b/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java new file mode 100644 index 0000000..c4f95cc --- /dev/null +++ b/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java @@ -0,0 +1,239 @@ +package net.tfminecraft.woodworking; + +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.woodworking.database.*; +import net.tfminecraft.woodworking.hit.*; +import net.tfminecraft.woodworking.loader.*; +import net.tfminecraft.woodworking.project.*; +import net.tfminecraft.woodworking.station.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; + +class StationStoreTest extends TestSupport { + WoodProject project() throws Exception { + var hit = new CraftingHit("cut", config("type: cut\ntool: TOOL.KNIFE")); + HitTypeLoader.get().put("cut", new HitType("cut", config(""))); + hit = new CraftingHit("cut", config("type: cut\ntool: TOOL.KNIFE")); + HitLoader.get().put("cut", hit); + MaterialLoader.get().put("oak", new WoodMaterial("oak", config("path: v.oak_log\ntype: wood"))); + var p = + new WoodProject( + "chair", "furniture", config("item: v.chest\nhits: [cut.1]\nrecipe: [oak.1]")); + ProjectLoader.get().put("chair", p); + return p; + } + + @Test + void roundtripRestoresProgressAndExactDepositedItems() throws Exception { + var world = server.addSimpleWorld("world"); + var loc = new Location(world, 1, 2, 3); + var s = new WoodStation(loc, project()); + var stack = new ItemStack(Material.OAK_LOG, 3); + var meta = stack.getItemMeta(); + meta.setDisplayName("Named wood"); + stack.setItemMeta(meta); + s.addMaterial(MaterialLoader.getByString("oak"), stack); + s.hit(HitLoader.getByString("cut")); + assertTrue(StationStore.loadAll().isEmpty()); + StationStore.saveAll( + Arrays.asList(null, new WoodStation(loc), new WoodStation(null, project()), s)); + assertTrue(StationStore.fileFor(loc).exists()); + var loaded = StationStore.loadAll(); + assertEquals(1, loaded.size()); + assertEquals(StationFeedback.SUCCESS, loaded.getFirst().canFinish()); + assertEquals( + "Named wood", loaded.getFirst().getDeposited().getFirst().getItemMeta().getDisplayName()); + assertEquals("unknown_1_2_3.json", StationStore.fileName(new Location(null, 1, 2, 3))); + var folder = StationStore.folder().toPath(); + Files.createDirectory(folder.resolve("nested")); + Files.writeString(folder.resolve("ignore.txt"), "leave"); + StationStore.saveAll(List.of(s)); + assertTrue(Files.exists(folder.resolve("ignore.txt"))); + StationStore.delete(null); + StationStore.delete(loc); + StationStore.delete(loc); + assertFalse(StationStore.fileFor(loc).exists()); + StationStore.saveAll(List.of(s)); + StationStore.saveAll(null); + assertFalse(StationStore.fileFor(loc).exists()); + } + + @Test + void corruptAndUnavailableFilesRemainOnDiskAcrossSave() throws Exception { + var world = server.addSimpleWorld("world"); + project(); + StationStore.folder().mkdirs(); + for (var e : + Map.of( + "bad.json", + "{broken", + "null.json", + "null", + "missingworld.json", + "{\"project\":\"chair\"}", + "missingproject.json", + "{\"world\":\"world\"}", + "otherworld.json", + "{\"world\":\"offline\",\"project\":\"chair\"}", + "unknown.json", + "{\"world\":\"world\",\"project\":\"missing\"}") + .entrySet()) + Files.writeString(StationStore.folder().toPath().resolve(e.getKey()), e.getValue()); + assertDoesNotThrow(StationStore::loadAll); + StationStore.saveAll(List.of()); + assertEquals(6, Files.list(StationStore.folder().toPath()).count()); + } + + @Test + void missingFolderOrFileAndInvalidEncodedDataAreHandled() throws Exception { + assertTrue(StationStore.loadAll().isEmpty()); + var world = server.addSimpleWorld("world"); + var p = project(); + var f = StationStore.folder().toPath().resolve("invaliditem.json"); + Files.writeString( + f, + "{\"world\":\"world\",\"project\":\"chair\",\"deposited\":[null,\"" + + " \",\"notbase64\",\"eA==\"]}"); + assertEquals(1, StationStore.loadAll().size()); + Files.writeString( + f, + "{\"world\":\"world\",\"project\":\"chair\",\"materials\":null,\"hits\":null,\"deposited\":null}"); + assertEquals(1, StationStore.loadAll().size()); + assertNull( + invoke( + StationStore.class, + "loadFile", + new Class[] {File.class}, + dir.resolve("absent").toFile())); + assertNull( + invoke( + StationStore.class, + "toData", + new Class[] {WoodStation.class}, + new WoodStation(new Location(null, 0, 0, 0), p))); + assertNull( + invoke( + StationStore.class, + "toData", + new Class[] {WoodStation.class}, + new WoodStation(new Location(world, 0, 0, 0)))); + var bytes = new ByteArrayOutputStream(); + try (var out = new org.bukkit.util.io.BukkitObjectOutputStream(bytes)) { + out.writeObject("not an item"); + } + assertNull( + invoke( + StationStore.class, + "decodeItem", + new Class[] {String.class}, + Base64.getEncoder().encodeToString(bytes.toByteArray()))); + assertEquals( + List.of(), + invoke(StationStore.class, "encodeItems", new Class[] {List.class}, (Object) null)); + assertEquals( + List.of(), + invoke( + StationStore.class, + "encodeItems", + new Class[] {List.class}, + Arrays.asList((ItemStack) null))); + } + + @Test + void failedWritesAndDirectoryListingDoNotCrash() throws Exception { + var p = project(); + var loc = new Location(server.addSimpleWorld("world"), 1, 2, 3); + Files.createDirectories(StationStore.fileFor(loc).toPath()); + StationStore.saveAll(List.of(new WoodStation(loc, p))); + Files.delete(StationStore.fileFor(loc).toPath()); + Files.delete(StationStore.folder().toPath()); + Files.writeString(StationStore.folder().toPath(), "not a directory"); + StationStore.saveAll(null); + assertTrue(StationStore.loadAll().isEmpty()); + } + + @Test + void rejectedFileCollisionIsQuarantinedBeforeSavingNewBench() throws Exception { + var loc = new Location(server.addSimpleWorld("world"), 1, 2, 3); + var s = new WoodStation(loc, project()); + StationStore.folder().mkdirs(); + var file = StationStore.fileFor(loc).toPath(); + Files.writeString(file, "{broken"); + assertTrue(StationStore.loadAll().isEmpty()); + StationStore.delete(loc); + assertEquals("{broken", Files.readString(file)); + StationStore.saveAll(List.of(s)); + assertEquals(1, StationStore.loadAll().size()); + try (var files = Files.list(file.getParent())) { + var rejected = + files + .filter(x -> x.getFileName().toString().contains(".rejected-")) + .findFirst() + .orElseThrow(); + assertEquals("{broken", Files.readString(rejected)); + } + } + + @Test + void failedQuarantineRetainsOriginalAndSuccessfulRetryWorks() throws Exception { + var loc = new Location(server.addSimpleWorld("world"), 1, 2, 3); + var s = new WoodStation(loc, project()); + StationStore.folder().mkdirs(); + var file = StationStore.fileFor(loc).toPath(); + Files.writeString(file, "{broken"); + StationStore.loadAll(); + try (var fs = mockStatic(Files.class, CALLS_REAL_METHODS)) { + fs.when(() -> Files.move(eq(file), any(Path.class))).thenThrow(new IOException("blocked")); + StationStore.saveAll(List.of(s)); + assertEquals("{broken", Files.readString(file)); + } + StationStore.saveAll(List.of(s)); + assertEquals(1, StationStore.loadAll().size()); + } + + @Test + void deleteFailuresWarnAndSerializationFailuresDoNotCrash() throws Exception { + var loc = new Location(server.addSimpleWorld("world"), 1, 2, 3); + File fail = mock(File.class); + when(fail.exists()).thenReturn(true); + when(fail.isFile()).thenReturn(true); + when(fail.getName()).thenReturn("fail.json"); + when(fail.toPath()).thenReturn(dir.resolve("fail.json")); + var folder = mock(File.class); + when(folder.exists()).thenReturn(true); + when(folder.listFiles()).thenReturn(new File[] {fail}); + try (var store = mockStatic(StationStore.class, CALLS_REAL_METHODS)) { + store.when(() -> StationStore.fileFor(loc)).thenReturn(fail); + store.when(StationStore::folder).thenReturn(folder); + StationStore.delete(loc); + StationStore.saveAll(null); + verify(fail, times(2)).delete(); + } + try (var output = + mockConstruction( + org.bukkit.util.io.BukkitObjectOutputStream.class, + (stream, c) -> doThrow(new IOException("write")).when(stream).writeObject(any()))) { + assertNull( + invoke( + StationStore.class, + "encodeItem", + new Class[] {ItemStack.class}, + item(Material.OAK_LOG))); + } + } + + @Test + void saveCreatesDirectoryAndSkipsDetachedWorldData() throws Exception { + StationStore.saveAll(List.of(new WoodStation(new Location(null, 0, 0, 0), project()))); + assertTrue(StationStore.folder().isDirectory()); + Files.createDirectory(StationStore.folder().toPath().resolve("nested")); + Files.writeString(StationStore.folder().toPath().resolve("ignore.txt"), "text"); + assertTrue(StationStore.loadAll().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/woodworking/TestSupport.java b/src/test/java/net/tfminecraft/woodworking/TestSupport.java new file mode 100644 index 0000000..76e2243 --- /dev/null +++ b/src/test/java/net/tfminecraft/woodworking/TestSupport.java @@ -0,0 +1,87 @@ +package net.tfminecraft.woodworking; + +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; +import org.mockito.*; + +class TestSupport { + @TempDir Path dir; + ServerMock server; + Woodworking plugin; + ItemAPI api; + MockedStatic tlibs; + Map cache = new HashMap<>(); + + @BeforeEach + void baseSetup() throws Exception { + server = MockBukkit.mock(); + plugin = mock(Woodworking.class); + Woodworking.plugin = plugin; + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(plugin.getDataFolder()).thenReturn(dir.toFile()); + when(plugin.getServer()).thenReturn(server); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getName()).thenReturn("Woodworking"); + when(plugin.namespace()).thenReturn("woodworking"); + net.tfminecraft.woodworking.loader.QualityLoader.get().clear(); + net.tfminecraft.woodworking.loader.CategoryLoader.get().clear(); + net.tfminecraft.woodworking.loader.ProjectLoader.get().clear(); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(null); + when(api.getChecker().getAsStringPath(any())).thenReturn(null); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + for (Class type : List.of(net.tfminecraft.woodworking.cache.Cache.class)) + for (var f : type.getFields()) cache.put(f, f.get(null)); + } + + @AfterEach + void baseCleanup() throws Exception { + tlibs.close(); + MockBukkit.unmock(); + Woodworking.plugin = null; + for (var e : cache.entrySet()) e.getKey().set(null, e.getValue()); + } + + ItemStack item(Material m) { + return new ItemStack(m); + } + + Path yaml(String name, String text) throws Exception { + var p = dir.resolve(name); + Files.writeString(p, text); + return p; + } + + static Object invoke(Object target, String name, Class[] types, Object... args) + throws Exception { + Class c = target instanceof Class cls ? cls : target.getClass(); + java.lang.reflect.Method m; + while (true) { + try { + m = c.getDeclaredMethod(name, types); + break; + } catch (NoSuchMethodException ex) { + c = c.getSuperclass(); + if (c == null) throw ex; + } + } + m.setAccessible(true); + return m.invoke(target instanceof Class ? null : target, args); + } + + org.bukkit.configuration.file.YamlConfiguration config(String text) throws Exception { + var c = new org.bukkit.configuration.file.YamlConfiguration(); + c.loadFromString(text); + return c; + } +} From 7385df8c1be3b56172377952008566ec644fd36f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:51:03 +0000 Subject: [PATCH 2/3] fix: save stations after rejected file removal --- .../woodworking/database/StationStore.java | 8 ++++--- .../woodworking/StationStoreTest.java | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/woodworking/database/StationStore.java b/src/main/java/net/tfminecraft/woodworking/database/StationStore.java index d43cb44..a89df4f 100644 --- a/src/main/java/net/tfminecraft/woodworking/database/StationStore.java +++ b/src/main/java/net/tfminecraft/woodworking/database/StationStore.java @@ -129,11 +129,13 @@ private static void write(WoodStation station, File file) { if (retained.contains(file.toPath().toAbsolutePath())) { try { Files.move(file.toPath(), file.toPath().resolveSibling(file.getName() + ".rejected-" + java.util.UUID.randomUUID())); - retained.remove(file.toPath().toAbsolutePath()); } catch (IOException ex) { - Log.warn("Failed to preserve rejected station " + file.getName() + ": " + ex.getMessage()); - return; + if (!Files.notExists(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + Log.warn("Failed to preserve rejected station " + file.getName() + ": " + ex.getMessage()); + return; + } } + retained.remove(file.toPath().toAbsolutePath()); } try (Writer writer = Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8)) { GSON.toJson(data, writer); diff --git a/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java b/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java index c4f95cc..bd7d050 100644 --- a/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java +++ b/src/test/java/net/tfminecraft/woodworking/StationStoreTest.java @@ -180,6 +180,27 @@ void rejectedFileCollisionIsQuarantinedBeforeSavingNewBench() throws Exception { } } + @Test + void administratorDeletedRejectedFileAllowsReplacementAndLaterSaves() throws Exception { + var loc = new Location(server.addSimpleWorld("world"), 1, 2, 3); + var station = new WoodStation(loc, project()); + StationStore.folder().mkdirs(); + var file = StationStore.fileFor(loc).toPath(); + Files.writeString(file, "{broken"); + assertTrue(StationStore.loadAll().isEmpty()); + + Files.delete(file); + StationStore.saveAll(List.of(station)); + assertTrue(Files.isRegularFile(file)); + StationStore.saveAll(List.of(station)); + try (var files = Files.list(file.getParent())) { + assertEquals(List.of(file), files.toList()); + } + assertEquals(1, StationStore.loadAll().size()); + StationStore.delete(loc); + assertFalse(Files.exists(file)); + } + @Test void failedQuarantineRetainsOriginalAndSuccessfulRetryWorks() throws Exception { var loc = new Location(server.addSimpleWorld("world"), 1, 2, 3); From c37ad0289a24fa1e392edb8beaf6989071aa2956 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:59:08 +0000 Subject: [PATCH 3/3] test: cover admin-only project selection --- .../woodworking/LoadersCommandsTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java b/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java index 53a3637..83ef257 100644 --- a/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java +++ b/src/test/java/net/tfminecraft/woodworking/LoadersCommandsTest.java @@ -164,6 +164,41 @@ void commandsPermissionsSelectionAndCompletion() throws Exception { assertTrue(c.onTabComplete(sender, cmd, "w", new String[0]).isEmpty()); } + @Test + void usePermissionDoesNotAuthorizeAdminProjectSelection() throws Exception { + var command = mock(Command.class); + when(command.getName()).thenReturn("woodworking"); + var player = mock(Player.class); + Cache.permission = "woodworking.use"; + when(player.hasPermission(Cache.permission)).thenReturn(true); + when(player.hasPermission(Permissions.ADMIN)).thenReturn(false); + assertTrue(Permissions.canUse(player)); + + var project = new WoodProject("chair", "furniture", config("item: v.chest")); + ProjectLoader.get().put("chair", project); + var location = new Location(server.addSimpleWorld("world"), 1, 2, 3); + var station = new WoodStation(location); + var block = mock(org.bukkit.block.Block.class); + when(block.getLocation()).thenReturn(location); + when(player.getTargetBlockExact(6)).thenReturn(block); + var manager = mock(StationManager.class); + when(plugin.getStations()).thenReturn(manager); + when(manager.isWoodworkingStation(block)).thenReturn(true); + when(manager.getOrCreate(location)).thenReturn(station); + + var commands = new CommandManager(); + assertTrue(commands.onCommand(player, command, "w", new String[] {"select", "chair"})); + verify(player).sendMessage("§cYou do not have access to this command!"); + assertFalse(station.hasProject()); + verify(manager, never()).getOrCreate(any()); + verify(manager, never()).markDirty(); + + when(player.hasPermission(Permissions.ADMIN)).thenReturn(true); + assertTrue(commands.onCommand(player, command, "w", new String[] {"select", "chair"})); + assertTrue(station.hasProject()); + verify(manager).markDirty(); + } + @Test void lifecycleLoadsReloadsAndFlushes() throws Exception { for (String name : List.of("TLibs", "MMOItems", "MythicLib", "ItemsAdder"))