From 1f81d889b6d048957b06106ac1453b28d4abce94 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:26:13 +0000 Subject: [PATCH 1/2] test: cover geiger behavior and fix collection regressions --- .github/workflows/build.yml | 8 + README.md | 8 + pom.xml | 67 ++++ .../commands/GeigerCommand.java | 3 + .../geigercounters/config/ConfigMigrator.java | 21 +- .../config/GeigerConfiguration.java | 2 +- .../geigercounters/config/Messages.java | 9 +- .../handlers/SourceHandler.java | 28 +- .../geigercounters/hooks/WorldGuardHook.java | 4 +- .../managers/DropLimitManager.java | 16 +- .../managers/GeigerManager.java | 13 +- .../LifecycleUtilitiesTest.java | 246 ++++++++++++++ .../commands/GeigerCommandTest.java | 230 ++++++++++++++ .../config/ConfigMigratorTest.java | 263 +++++++++++++++ .../config/GeigerConfigurationTest.java | 218 +++++++++++++ .../handlers/GeigerEffectsTest.java | 218 +++++++++++++ .../handlers/SourceHandlerTest.java | 300 ++++++++++++++++++ .../managers/DropLimitManagerTest.java | 194 +++++++++++ .../managers/GeigerManagerTest.java | 187 +++++++++++ .../validators/SpawnLocationFilterTest.java | 244 ++++++++++++++ 20 files changed, 2246 insertions(+), 33 deletions(-) create mode 100644 src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/managers/DropLimitManagerTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilterTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3099946..21a0bfa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,3 +70,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index d91e58a..baf1b99 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ Originally created by [Justinas Launikonis](https://github.com/JustinasLa). Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Tests + +With Java 21 and the pinned plugin dependencies installed, run `mvn clean verify`. +Tests use JUnit, Mockito, and MockBukkit; JaCoCo reports are written to +`target/site/jacoco/index.html` and uploaded by CI. Tests run locally without a live +Minecraft server. Verification requires 100% line, branch, and instruction +coverage of production code, with no coverage exclusions. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/pom.xml b/pom.xml index e8a6d5b..cb9e4c9 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,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 + io.papermc.paper @@ -89,6 +107,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/geigercounters/commands/GeigerCommand.java b/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java index 3c0c5ab..0526a5f 100644 --- a/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java +++ b/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java @@ -103,6 +103,9 @@ private void handleMove(CommandSender sender, String[] args) { try { x = Double.parseDouble(args[1]); z = Double.parseDouble(args[2]); + if (!Double.isFinite(x) || !Double.isFinite(z)) { + throw new NumberFormatException("Coordinates must be finite"); + } } catch (NumberFormatException e) { sender.sendMessage(messages().get("admin.move-invalid-coords")); return; diff --git a/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java b/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java index 09851fd..cd13b63 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java @@ -116,7 +116,8 @@ public void migrate() { // ==================================== private void migrateMessages() { File messagesFile = new File(plugin.getDataFolder(), MESSAGES_FILE); - if (!messagesFile.exists()) { + boolean createdMessagesFile = !messagesFile.exists(); + if (createdMessagesFile) { plugin.saveResource(MESSAGES_FILE, false); } @@ -135,7 +136,11 @@ private void migrateMessages() { if (oldSection.isConfigurationSection(key)) { continue; } - messages.set(key, oldSection.get(key)); + // A newly installed file already contains player.* defaults. + // Legacy custom wording must replace those shipped values. + String destination = createdMessagesFile && List.of(LEGACY_ROOT_MESSAGES).contains(key) + ? "player." + key : key; + messages.set(destination, oldSection.get(key)); moved.add(key); } @@ -262,12 +267,12 @@ private YamlConfiguration loadPackagedConfig() { } private YamlConfiguration loadPackagedYaml(String resource) { - try (InputStream stream = plugin.getResource(resource)) { - if (stream == null) { - plugin.getLogger().warning("No " + resource + " packaged in the jar - skipping its migration."); - return null; - } - + InputStream stream = plugin.getResource(resource); + if (stream == null) { + plugin.getLogger().warning("No " + resource + " packaged in the jar - skipping its migration."); + return null; + } + try (stream) { try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { return YamlConfiguration.loadConfiguration(reader); } diff --git a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java index 1bb4f19..de50700 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java @@ -197,7 +197,7 @@ private void loadSoundSettings() { // onto the same event once lowercased, since the enum name is the event ID // with dots turned into underscores. private String normalizeSoundName(String raw) { - if (raw == null || raw.trim().isEmpty()) { + if (raw.trim().isEmpty()) { return DEFAULT_SOUND; } diff --git a/src/main/java/net/tfminecraft/geigercounters/config/Messages.java b/src/main/java/net/tfminecraft/geigercounters/config/Messages.java index af64a7c..27fdccf 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/Messages.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/Messages.java @@ -75,10 +75,11 @@ public static String coordinate(double value) { } private YamlConfiguration loadPackaged() { - try (InputStream stream = plugin.getResource(ConfigMigrator.MESSAGES_FILE)) { - if (stream == null) { - return null; - } + InputStream stream = plugin.getResource(ConfigMigrator.MESSAGES_FILE); + if (stream == null) { + return null; + } + try (stream) { try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { return YamlConfiguration.loadConfiguration(reader); } diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java index 21bab0d..6c55e6a 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java @@ -104,7 +104,7 @@ private void attemptRandomPlacement(int attempt, CompletableFuture res loadChunkFor(x, z).whenComplete((chunk, error) -> { // Paper completes chunk futures on the main thread, so everything // below is safe to run against the world directly - if (!plugin.isEnabled()) { + if (pendingMove != result || !plugin.isEnabled()) { result.complete(null); return; } @@ -141,11 +141,12 @@ private void attemptRandomPlacement(int attempt, CompletableFuture res // filter check is logged so the admin knows the spot is normally excluded // ==================================== public CompletableFuture moveSourceToLocation(double x, double z) { + sourceLocation = null; CompletableFuture result = new CompletableFuture<>(); pendingMove = result; loadChunkFor(x, z).whenComplete((chunk, error) -> { - if (!plugin.isEnabled()) { + if (pendingMove != result || !plugin.isEnabled()) { result.complete(null); return; } @@ -207,6 +208,18 @@ public void tryCollectSource(Player player, double distance, EquipmentSlot geige return; } + // Callers may hold a distance from before another player's collection. + // Validate against the current source, including its world, before consuming it. + Location playerLocation = player.getLocation(); + if (!sourceLocation.getWorld().equals(playerLocation.getWorld())) { + return; + } + double deltaX = playerLocation.getX() - sourceLocation.getX(); + double deltaZ = playerLocation.getZ() - sourceLocation.getZ(); + if (Math.hypot(deltaX, deltaZ) > config.getCollectionDistance()) { + return; + } + // Player is out of collections for this window - the source stays put // so somebody else can still claim it if (!dropLimits.canCollect(player)) { @@ -284,7 +297,7 @@ private void giveReward(Player player) { // Select a tier based on weights TierReward selectedTier = selectRandomTier(tiers); - if (selectedTier == null || selectedTier.isEmpty()) { + if (selectedTier.isEmpty()) { return; } @@ -312,15 +325,16 @@ private TierReward selectRandomTier(List tiers) { // Find which tier this value falls into double cumulativeWeight = 0.0; - for (TierReward tier : tiers) { + for (int index = 0; index < tiers.size() - 1; index++) { + TierReward tier = tiers.get(index); cumulativeWeight += tier.getWeight(); if (randomValue <= cumulativeWeight) { return tier; } } - - // Fallback to last tier (shouldnt happen but why not) - return tiers.get(tiers.size() - 1); + + // Every remaining draw selects the final tier, including rounding fallback. + return tiers.getLast(); } // ==================================== diff --git a/src/main/java/net/tfminecraft/geigercounters/hooks/WorldGuardHook.java b/src/main/java/net/tfminecraft/geigercounters/hooks/WorldGuardHook.java index 65b4a50..fbd6cee 100644 --- a/src/main/java/net/tfminecraft/geigercounters/hooks/WorldGuardHook.java +++ b/src/main/java/net/tfminecraft/geigercounters/hooks/WorldGuardHook.java @@ -1,6 +1,6 @@ package net.tfminecraft.geigercounters.hooks; -import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.BukkitWorld; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldguard.WorldGuard; import com.sk89q.worldguard.protection.managers.RegionManager; @@ -30,7 +30,7 @@ public boolean isInAnyRegion(Location location, Set regionIds) { RegionManager regions = WorldGuard.getInstance() .getPlatform() .getRegionContainer() - .get(BukkitAdapter.adapt(location.getWorld())); + .get(new BukkitWorld(location.getWorld())); if (regions == null) { return false; diff --git a/src/main/java/net/tfminecraft/geigercounters/managers/DropLimitManager.java b/src/main/java/net/tfminecraft/geigercounters/managers/DropLimitManager.java index 39795e3..40f046b 100644 --- a/src/main/java/net/tfminecraft/geigercounters/managers/DropLimitManager.java +++ b/src/main/java/net/tfminecraft/geigercounters/managers/DropLimitManager.java @@ -14,6 +14,7 @@ import java.util.Deque; import java.util.HashMap; import java.util.List; +import java.util.Iterator; import java.util.Map; import java.util.UUID; @@ -91,19 +92,22 @@ public int getRemaining(UUID playerId) { // ==================================== // Millis until the next collection slot frees up, or 0 if one is free. - // That is the oldest stamp in the window plus the window length. + // If the configured limit was lowered, enough stamps must expire to + // bring the retained count below the new limit. // ==================================== public long getMillisUntilNextDrop(UUID playerId) { - if (getRemaining(playerId) > 0) { + if (!config.isLimitEnabled() || getRemaining(playerId) > 0) { return 0L; } + // Enabled limits are positive; zero remaining guarantees a nonempty history. Deque stamps = collections.get(playerId); - if (stamps == null || stamps.isEmpty()) { - return 0L; + Iterator timestamps = stamps.iterator(); + long nextSlot = timestamps.next(); + for (int excess = stamps.size() - config.getLimitDrops(); excess > 0; excess--) { + nextSlot = timestamps.next(); } - - long readyAt = stamps.peekFirst() + config.getLimitWindowMillis(); + long readyAt = nextSlot + config.getLimitWindowMillis(); return Math.max(0L, readyAt - System.currentTimeMillis()); } diff --git a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java index af43676..014e452 100644 --- a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java +++ b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java @@ -141,12 +141,15 @@ private void startClickTask() { // in either hand // ==================================== private void checkAllPlayers() { - Location source = sourceHandler.getSourceLocation(); - if (source == null) { - return; - } - for (Player player : Bukkit.getOnlinePlayers()) { + // A preceding player can collect and relocate the source immediately. + Location source = sourceHandler.getSourceLocation(); + if (source == null) { + return; + } + if (!player.getLocation().getWorld().equals(source.getWorld())) { + continue; + } EquipmentSlot geigerSlot = findGeigerSlot(player); if (geigerSlot != null) { diff --git a/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java b/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java new file mode 100644 index 0000000..9ab05b6 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java @@ -0,0 +1,246 @@ +package net.tfminecraft.geigercounters; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.papermc.paper.plugin.configuration.PluginMeta; +import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; +import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import net.tfminecraft.geigercounters.config.*; +import net.tfminecraft.geigercounters.events.GeigerSourceCollectEvent; +import net.tfminecraft.geigercounters.managers.GeigerManager; +import net.tfminecraft.geigercounters.metrics.UsageStats; +import net.tfminecraft.geigercounters.models.TierReward; +import net.tfminecraft.geigercounters.utils.Utils; +import org.bstats.bukkit.Metrics; +import org.bstats.charts.CustomChart; +import org.bukkit.*; +import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +public class LifecycleUtilitiesTest { + @TempDir Path directory; + + @Test + void pluginLifecycleRegistersCommandsMetricsAndShutsDownResources() throws Exception { + GeigerManager manager = mock(GeigerManager.class); + GeigerConfiguration config = mock(GeigerConfiguration.class); + when(manager.getConfiguration()).thenReturn(config); + when(config.getTierRewards()).thenReturn(List.of(new TierReward("common", 1))); + when(config.getMaxX()).thenReturn(2000.0); + when(config.getMaxZ()).thenReturn(3000.0); + try (var bukkit = mockStatic(Bukkit.class); + var managers = mockStatic(GeigerManager.class); + var migrations = mockConstruction(ConfigMigrator.class); + var metrics = mockConstruction(Metrics.class)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(mock(UnsafeValues.class)); + assertNotNull(new TestPluginLoader().definePluginSubclass().getConstructor().newInstance()); + geiger_counter plugin = mock(geiger_counter.class, CALLS_REAL_METHODS); + doReturn(Logger.getAnonymousLogger()).when(plugin).getLogger(); + doNothing().when(plugin).saveDefaultConfig(); + PluginCommand command = mock(PluginCommand.class); + doReturn(command).when(plugin).getCommand("geiger"); + managers.when(() -> GeigerManager.getInstance(plugin)).thenReturn(manager); + managers.when(GeigerManager::getInstance).thenReturn(manager); + plugin.onEnable(); + verify(plugin).saveDefaultConfig(); + verify(migrations.constructed().getFirst()).migrate(); + verify(manager).initialize(); + verify(command).setExecutor(any()); + verify(command).setTabCompleter(any()); + Metrics created = metrics.constructed().getFirst(); + ArgumentCaptor charts = ArgumentCaptor.forClass(CustomChart.class); + verify(created, times(4)).addCustomChart(charts.capture()); + List values = charts.getAllValues(); + assertTrue( + values + .get(0) + .getRequestJsonObject((message, error) -> fail(message, error), true) + .toString() + .contains("1")); + double[] distances = {500, 1000, 2500, 5000, 5001}; + String[] buckets = {"0-500", "501-1000", "1001-2500", "2501-5000", "5000+"}; + for (int index = 0; index < distances.length; index++) { + when(config.getMaxDetectionDistance()).thenReturn(distances[index]); + assertTrue( + values + .get(1) + .getRequestJsonObject((message, error) -> fail(message, error), true) + .toString() + .contains(buckets[index])); + } + UsageStats.getInstance().drainSourcesCollected(); + UsageStats.getInstance().recordSourceCollected(); + assertNotNull( + values.get(2).getRequestJsonObject((message, error) -> fail(message, error), true)); + assertTrue( + values + .get(3) + .getRequestJsonObject((message, error) -> fail(message, error), true) + .toString() + .contains("6")); + plugin.onDisable(); + verify(manager).shutdown(); + verify(created).shutdown(); + } + } + + @Test + void disableBeforeInitializationIsSafe() { + geiger_counter plugin = mock(geiger_counter.class, CALLS_REAL_METHODS); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + try (var managers = mockStatic(GeigerManager.class)) { + assertDoesNotThrow(plugin::onDisable); + } + } + + @Test + void messagesReadLiveValuesFallbackToPackagedDefaultsAndReplaceAllPairs() throws Exception { + JavaPlugin plugin = messagePlugin(); + Files.writeString(directory.resolve("messages.yml"), "custom: '&aFound %item% x%amount%'\n"); + when(plugin.getResource("messages.yml")).thenAnswer(invocation -> stream("default: shipped\n")); + Messages messages = new Messages(plugin); + assertEquals("§aFound gem x3", messages.get("custom", "%item%", "gem", "%amount%", 3)); + assertEquals("shipped", messages.get("default")); + assertEquals("§aFound %item% x%amount%", messages.get("custom", "unpaired")); + assertEquals("absent", messages.get("absent")); + assertEquals("-0.5", Messages.coordinate(-0.5)); + Files.writeString(directory.resolve("messages.yml"), "custom: changed\n"); + messages.reload(); + assertEquals("changed", messages.get("custom")); + } + + JavaPlugin messagePlugin() { + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + return plugin; + } + + static ByteArrayInputStream stream(String text) { + return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void absentOrUnreadablePackagedMessagesDoNotHideUsableLiveMessages() throws Exception { + JavaPlugin plugin = messagePlugin(); + Files.writeString(directory.resolve("messages.yml"), "custom: keep\n"); + assertEquals("keep", new Messages(plugin).get("custom")); + when(plugin.getResource("messages.yml")) + .thenAnswer( + invocation -> + new ByteArrayInputStream(new byte[0]) { + @Override + public void close() throws IOException { + throw new IOException("resource failure"); + } + }); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getAnonymousLogger()); + assertEquals("keep", new Messages(plugin).get("custom")); + } + } + + @Test + void durationParsingHandlesUnitsWhitespaceFractionsAndInvalidInput() { + assertNotNull(new Utils()); + assertEquals(-1, Utils.parseDurationMillis(null)); + for (String invalid : List.of("", "-1h", "1w", "1h30m", "NaN", " ")) { + assertEquals(-1, Utils.parseDurationMillis(invalid), invalid); + } + assertEquals(1500, Utils.parseDurationMillis("1.5s")); + assertEquals(12000, Utils.parseDurationMillis("12")); + assertEquals(TimeUnit.MINUTES.toMillis(45), Utils.parseDurationMillis("45m")); + assertEquals(TimeUnit.HOURS.toMillis(2), Utils.parseDurationMillis(" 2 H ")); + assertEquals(TimeUnit.DAYS.toMillis(1), Utils.parseDurationMillis("1d")); + } + + @Test + void durationFormattingRoundsUpAndUsesAppropriateLargestUnits() { + assertEquals("0s", Utils.formatDuration(-1)); + assertEquals("0s", Utils.formatDuration(0)); + assertEquals("1s", Utils.formatDuration(1)); + assertEquals("1m", Utils.formatDuration(60000)); + assertEquals("1m 1s", Utils.formatDuration(60001)); + assertEquals("1h", Utils.formatDuration(3600000)); + assertEquals("1h 1m", Utils.formatDuration(3661000)); + assertEquals("2d", Utils.formatDuration(TimeUnit.DAYS.toMillis(2) + 60001)); + assertEquals( + "2d 3h", + Utils.formatDuration(TimeUnit.DAYS.toMillis(2) + TimeUnit.HOURS.toMillis(3) + 60001)); + } + + @Test + void colorsAndCollectionEventPreservePublicContract() { + assertEquals("plain", Utils.colorize("plain")); + assertEquals("§aGreen", Utils.colorize("&aGreen")); + assertEquals( + "§x§f§f§0§0§a§aPink §x§f§f§0§0§a§aAgain", Utils.colorize("#ff00aaPink #ff00aaAgain")); + Player player = mock(Player.class); + Location location = new Location(mock(World.class), 1, 2, 3); + var event = new GeigerSourceCollectEvent(player, location); + assertSame(player, event.getPlayer()); + assertSame(location, event.getLocation()); + assertSame(GeigerSourceCollectEvent.getHandlerList(), event.getHandlers()); + } + + public static class PluginSubclass extends geiger_counter {} + + /** Satisfies Paper's plugin construction contract without starting a server. */ + private static final class TestPluginLoader extends ClassLoader + implements ConfiguredPluginClassLoader { + TestPluginLoader() { + super(LifecycleUtilitiesTest.class.getClassLoader()); + } + + Class definePluginSubclass() throws IOException { + String name = LifecycleUtilitiesTest.class.getName() + "$PluginSubclass"; + try (InputStream input = getResourceAsStream(name.replace('.', '/') + ".class")) { + assertNotNull(input); + byte[] bytes = input.readAllBytes(); + return defineClass(name, bytes, 0, bytes.length); + } + } + + @Override + public PluginMeta getConfiguration() { + return null; + } + + @Override + public Class loadClass( + String name, boolean resolve, boolean checkGlobal, boolean checkLibraries) + throws ClassNotFoundException { + return super.loadClass(name, resolve); + } + + @Override + public void init(JavaPlugin plugin) {} + + @Override + public JavaPlugin getPlugin() { + return null; + } + + @Override + public PluginClassLoaderGroup getGroup() { + return null; + } + + @Override + public void close() {} + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java b/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java new file mode 100644 index 0000000..1f14f70 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java @@ -0,0 +1,230 @@ +package net.tfminecraft.geigercounters.commands; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import net.tfminecraft.geigercounters.config.*; +import net.tfminecraft.geigercounters.handlers.SourceHandler; +import net.tfminecraft.geigercounters.managers.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GeigerCommandTest { + GeigerManager manager; + GeigerConfiguration config; + SourceHandler source; + DropLimitManager limits; + JavaPlugin plugin; + CommandSender sender; + GeigerCommand command; + World world; + + @BeforeEach + void setUp() { + manager = mock(GeigerManager.class); + config = mock(GeigerConfiguration.class); + source = mock(SourceHandler.class); + limits = mock(DropLimitManager.class); + plugin = mock(JavaPlugin.class); + sender = mock(CommandSender.class); + world = mock(World.class); + Messages messages = mock(Messages.class); + when(manager.getConfiguration()).thenReturn(config); + when(manager.getSourceHandler()).thenReturn(source); + when(manager.getDropLimitManager()).thenReturn(limits); + when(manager.getPlugin()).thenReturn(plugin); + when(config.getMessages()).thenReturn(messages); + when(messages.get(anyString(), any(Object[].class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(config.getDropListNames()).thenReturn(List.of("default", "Hunt")); + when(config.isLimitEnabled()).thenReturn(true); + when(plugin.getConfig()).thenReturn(new YamlConfiguration()); + when(world.getName()).thenReturn("world"); + command = new GeigerCommand(manager); + } + + void run(String... args) { + assertTrue(command.onCommand(sender, mock(Command.class), "geiger", args)); + } + + List complete(CommandSender actor, String... args) { + return command.onTabComplete(actor, mock(Command.class), "geiger", args); + } + + @Test + void manifestRestrictsCommandToAdministrators() throws Exception { + try (var stream = getClass().getResourceAsStream("/plugin.yml")) { + assertNotNull(stream); + var yaml = + YamlConfiguration.loadConfiguration( + new InputStreamReader(stream, StandardCharsets.UTF_8)); + assertEquals("geiger.admin", yaml.getString("commands.geiger.permission")); + assertEquals("op", yaml.getString("permissions.geiger.admin.default")); + } + } + + @Test + void missingOrUnknownSubcommandShowsUsageAndReloadWorksCaseInsensitively() { + run(); + run("unknown"); + verify(sender, times(2)).sendMessage("admin.usage"); + run("RELOAD"); + verify(manager).reload(); + verify(sender).sendMessage("admin.reloaded"); + } + + @Test + void locateReportsRelocationOrCurrentCoordinates() { + run("locate"); + verify(sender).sendMessage("admin.source-relocating"); + when(source.getSourceLocation()).thenReturn(new Location(world, -0.5, 72, 43.2)); + run("locate"); + verify(sender).sendMessage("admin.source-located"); + } + + @Test + void randomMovementReportsSearchingThenSuccessOrFailureOnCompletion() { + CompletableFuture pending = new CompletableFuture<>(); + when(source.moveSourceToRandomLocation()).thenReturn(pending); + run("move"); + verify(sender).sendMessage("admin.move-searching"); + verify(sender, never()).sendMessage("admin.move-success"); + pending.complete(new Location(world, 12.3, 70, 42)); + verify(sender).sendMessage("admin.move-success"); + when(source.moveSourceToRandomLocation()).thenReturn(CompletableFuture.completedFuture(null)); + run("move"); + verify(sender).sendMessage("admin.move-failed"); + } + + @Test + void movementValidatesArityAndNumbersAndAcceptsFiniteDecimals() { + run("move", "1"); + run("move", "bad", "1"); + run("move", "1", "bad"); + verify(sender).sendMessage("admin.move-usage"); + verify(sender, times(2)).sendMessage("admin.move-invalid-coords"); + when(source.moveSourceToLocation(-0.5, 12.25)) + .thenReturn(CompletableFuture.completedFuture(new Location(world, -0.5, 70, 12.25))); + run("move", "-0.5", "12.25"); + verify(source).moveSourceToLocation(-0.5, 12.25); + verify(sender).sendMessage("admin.move-success"); + } + + @Test + void movementRejectsNonFiniteCoordinatesWithoutLoadingChunks() { + when(source.moveSourceToLocation(anyDouble(), anyDouble())) + .thenReturn(CompletableFuture.completedFuture(null)); + for (String invalid : List.of("NaN", "Infinity", "-Infinity", "1e999")) { + run("move", invalid, "1"); + run("move", "1", invalid); + } + verify(source, never()).moveSourceToLocation(anyDouble(), anyDouble()); + verify(sender, times(8)).sendMessage("admin.move-invalid-coords"); + } + + @Test + void limitsHandleUsageDisabledAndUnknownPlayers() { + run("limits"); + verify(sender).sendMessage("admin.limits-usage"); + when(config.isLimitEnabled()).thenReturn(false); + run("limits", "Hunter"); + verify(sender).sendMessage("admin.limits-disabled"); + when(config.isLimitEnabled()).thenReturn(true); + OfflinePlayer unknown = mock(OfflinePlayer.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Unknown")).thenReturn(unknown); + run("limits", "Unknown"); + run("resetlimits", "Unknown"); + } + verify(sender, times(2)).sendMessage("admin.unknown-player"); + verifyNoInteractions(limits); + } + + @Test + void limitsSupportOfflinePlayersNewOnlinePlayersAliasesAndNextSlotMessage() { + OfflinePlayer target = mock(OfflinePlayer.class); + UUID id = UUID.randomUUID(); + when(target.getUniqueId()).thenReturn(id); + when(target.hasPlayedBefore()).thenReturn(true); + when(limits.getRemaining(id)).thenReturn(2, 0); + when(config.getLimitDrops()).thenReturn(3); + when(config.getLimitWindowMillis()).thenReturn(60000L); + when(limits.getMillisUntilNextDrop(id)).thenReturn(30000L); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Hunter")).thenReturn(target); + run("limit", "Hunter"); + when(target.hasPlayedBefore()).thenReturn(false); + when(target.isOnline()).thenReturn(true); + run("limits", "Hunter"); + run("resetlimit", "Hunter"); + run("resetlimits", "Hunter"); + } + verify(sender, times(2)).sendMessage("admin.limits-status"); + verify(sender).sendMessage("admin.limits-next-drop"); + verify(limits, times(2)).reset(id); + run("resetlimits"); + verify(sender).sendMessage("admin.resetlimits-usage"); + } + + @Test + void droplistShowsStatusValidatesInputAndPersistsSelection() { + run("droplist"); + run("droplist", "a", "b"); + run("droplist", "missing"); + verify(sender).sendMessage("admin.droplist-current"); + verify(sender).sendMessage("admin.droplist-usage"); + verify(sender).sendMessage("admin.droplist-unknown"); + run("droplist", "Hunt"); + assertEquals("Hunt", plugin.getConfig().getString("drops.active-list")); + verify(plugin).saveConfig(); + verify(manager).reload(); + verify(sender).sendMessage("admin.droplist-changed"); + } + + @Test + void completionFiltersSubcommandsAndDroplistsAndIgnoresExtraArguments() { + assertEquals(List.of("locate"), complete(sender, "LO")); + assertEquals(6, complete(sender, "").size()); + assertEquals(List.of("Hunt"), complete(sender, "droplist", "h")); + assertTrue(complete(sender, "unknown", "").isEmpty()); + assertTrue(complete(sender, "move", "1", "2", "").isEmpty()); + assertTrue(complete(sender, "droplist", "Hunt", "").isEmpty()); + } + + @Test + void coordinateCompletionFloorsNegativeNumbersDeduplicatesAndUsesCorrectAxis() { + when(config.getMinX()).thenReturn(-0.5); + when(config.getMaxX()).thenReturn(10.8); + when(config.getMinZ()).thenReturn(-20.3); + when(config.getMaxZ()).thenReturn(40.2); + Player actor = mock(Player.class); + when(actor.getLocation()).thenReturn(new Location(world, -0.5, 64, 30.9)); + assertEquals(List.of("-1", "10"), complete(actor, "move", "")); + assertEquals(List.of("30", "-21", "40"), complete(actor, "move", "0", "")); + assertEquals(List.of("-21"), complete(sender, "move", "0", "-")); + assertEquals(List.of("10"), complete(sender, "move", "1")); + } + + @Test + void allLimitAliasesCompleteOnlinePlayerNamesCaseInsensitively() { + Player first = mock(Player.class), second = mock(Player.class); + when(first.getName()).thenReturn("Hunter"); + when(second.getName()).thenReturn("Other"); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(first, second)); + for (String alias : List.of("limit", "limits", "resetlimit", "resetlimits")) { + assertEquals(List.of("Hunter"), complete(sender, alias, "HU")); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java b/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java new file mode 100644 index 0000000..d0ab9e2 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java @@ -0,0 +1,263 @@ +package net.tfminecraft.geigercounters.config; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ConfigMigratorTest { + @TempDir Path directory; + JavaPlugin plugin; + YamlConfiguration live; + ConfigMigrator migrator; + String packagedConfig = "config-version: 5\nsource:\n world: world\n"; + String packagedMessages = "player:\n found-source: shipped\nadmin:\n help: help\n"; + + @BeforeEach + void setUp() throws Exception { + plugin = mock(JavaPlugin.class); + live = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getConfig()).thenReturn(live); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.getResource("config.yml")).thenAnswer(invocation -> stream(packagedConfig)); + when(plugin.getResource("messages.yml")).thenAnswer(invocation -> stream(packagedMessages)); + doAnswer( + invocation -> { + live.save(directory.resolve("config.yml").toFile()); + return null; + }) + .when(plugin) + .saveConfig(); + doAnswer( + invocation -> { + Files.writeString(directory.resolve("messages.yml"), packagedMessages); + return null; + }) + .when(plugin) + .saveResource("messages.yml", false); + migrator = new ConfigMigrator(plugin); + } + + static ByteArrayInputStream stream(String content) { + return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + } + + void saveLive() throws Exception { + live.save(directory.resolve("config.yml").toFile()); + } + + YamlConfiguration messages() { + return YamlConfiguration.loadConfiguration(directory.resolve("messages.yml").toFile()); + } + + @Test + void freshInstallCreatesMessagesWithoutAttemptingConfigMigration() { + migrator.migrate(); + verify(plugin).saveResource("messages.yml", false); + verify(plugin, never()).saveConfig(); + assertEquals("shipped", messages().getString("player.found-source")); + } + + @Test + void upgradePreservesValuesUnknownKeysAndCommentsAndCreatesExactBackup() throws Exception { + packagedConfig = + "# Version\n" + + "config-version: 5 # version-inline\n" + + "# World section\n" + + "source:\n" + + " # Server world\n" + + " world: world # world-inline\n" + + " enabled: true\n" + + "list: [default]\n"; + live.set("source.world", "custom"); + live.set("list", List.of("custom-item")); + live.set("retired-setting", 42); + live.setComments("source.world", List.of("User comment")); + live.setInlineComments("source.world", List.of("User inline")); + saveLive(); + String previous = Files.readString(directory.resolve("config.yml")); + migrator.migrate(); + assertEquals(previous, Files.readString(directory.resolve("config-v1.yml.bak"))); + assertEquals(5, live.getInt("config-version")); + assertEquals("custom", live.getString("source.world")); + assertEquals(List.of("custom-item"), live.getStringList("list")); + assertEquals(42, live.getInt("retired-setting")); + assertTrue(live.getBoolean("source.enabled")); + assertEquals(List.of("User comment"), live.getComments("source.world")); + assertEquals(List.of("User inline"), live.getInlineComments("source.world")); + assertEquals(List.of("Version"), live.getComments("config-version")); + assertEquals(List.of("version-inline"), live.getInlineComments("config-version")); + assertEquals(List.of("World section"), live.getComments("source")); + } + + @Test + void currentConfigIsIdempotentAndVersionOnlyUpgradeStillSaves() throws Exception { + live.loadFromString(packagedConfig); + live.set("config-version", 4); + saveLive(); + migrator.migrate(); + assertTrue(Files.exists(directory.resolve("config-v4.yml.bak"))); + verify(plugin).saveConfig(); + clearInvocations(plugin); + migrator.migrate(); + verify(plugin, never()).saveConfig(); + } + + @Test + void futureVersionLeavesConfigUnmodified() throws Exception { + live.set("config-version", 999); + live.set("future-setting", "keep"); + saveLive(); + String previous = Files.readString(directory.resolve("config.yml")); + migrator.migrate(); + assertEquals(previous, Files.readString(directory.resolve("config.yml"))); + verify(plugin, never()).saveConfig(); + } + + @Test + void legacyDropListsMoveWithoutOverwritingCustomItems() throws Exception { + packagedConfig += + "drops:\n" + + " active-list: default\n" + + " lists:\n" + + " default:\n" + + " tiers:\n" + + " common: [shipped:1]\n"; + live.set("drops.tiers.common", List.of("m.CUSTOM:4")); + live.set("drops.tiers.rare", List.of("m.RARE:1")); + saveLive(); + migrator.migrate(); + assertEquals(List.of("m.CUSTOM:4"), live.getStringList("drops.lists.default.tiers.common")); + assertEquals(List.of("m.RARE:1"), live.getStringList("drops.lists.default.tiers.rare")); + assertEquals("default", live.getString("drops.active-list")); + assertFalse(live.contains("drops.tiers")); + } + + @Test + void existingNamedListsPreventDestructiveLegacyDropMigration() throws Exception { + live.set("drops.tiers.common", List.of("old")); + live.set("drops.lists.hunt.tiers.common", List.of("new")); + saveLive(); + migrator.migrate(); + assertEquals(List.of("old"), live.getStringList("drops.tiers.common")); + assertEquals(List.of("new"), live.getStringList("drops.lists.hunt.tiers.common")); + } + + @Test + void rootMessagesMoveToPlayerAndExplicitNestedMessagesWin() throws Exception { + Files.writeString( + directory.resolve("messages.yml"), + "found-source: custom\ndead-geiger: old\nplayer:\n dead-geiger: new\n"); + migrator.migrate(); + YamlConfiguration messages = messages(); + assertEquals("custom", messages.getString("player.found-source")); + assertEquals("new", messages.getString("player.dead-geiger")); + assertEquals("help", messages.getString("admin.help")); + assertFalse(messages.contains("found-source")); + assertFalse(messages.contains("dead-geiger")); + } + + @Test + void legacyConfigMessagesRetainCustomizationWhenMessagesFileIsNew() throws Exception { + live.set("messages.found-source", "My custom discovery text"); + live.set("messages.extra.notice", "Nested custom notice"); + saveLive(); + migrator.migrate(); + assertEquals("My custom discovery text", messages().getString("player.found-source")); + assertEquals("Nested custom notice", messages().getString("extra.notice")); + assertFalse(live.contains("messages")); + } + + @Test + void legacyConfigMessagesMoveIntoExistingEmptyMessagesFile() throws Exception { + Files.writeString(directory.resolve("messages.yml"), ""); + live.set("messages.found-source", "custom"); + live.set("messages.extra.notice", "extra"); + saveLive(); + migrator.migrate(); + assertEquals("custom", messages().getString("player.found-source")); + assertEquals("extra", messages().getString("extra.notice")); + assertFalse(live.contains("messages")); + } + + @Test + void missingPackagedResourcesLeaveExistingFilesUntouched() throws Exception { + Files.writeString(directory.resolve("messages.yml"), "player:\n found-source: keep\n"); + saveLive(); + when(plugin.getResource(anyString())).thenReturn(null); + migrator.migrate(); + assertEquals("keep", messages().getString("player.found-source")); + verify(plugin, never()).saveConfig(); + } + + @Test + void resourceCloseFailureIsLoggedAndDoesNotCrashMigration() throws Exception { + saveLive(); + when(plugin.getResource(anyString())) + .thenAnswer( + invocation -> + new ByteArrayInputStream(new byte[0]) { + @Override + public void close() throws IOException { + throw new IOException("unreadable resource"); + } + }); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getAnonymousLogger()); + assertDoesNotThrow(migrator::migrate); + } + verify(plugin, never()).saveConfig(); + } + + @Test + void backupFailureIsLoggedAndMigrationStillCompletes() throws Exception { + saveLive(); + Files.createDirectory(directory.resolve("config-v1.yml.bak")); + Files.writeString( + directory.resolve("config-v1.yml.bak/occupied"), "prevent replacing directory"); + assertDoesNotThrow(migrator::migrate); + assertEquals(5, live.getInt("config-version")); + verify(plugin).saveConfig(); + } + + @Test + void unwritableMessagesDestinationDoesNotCrashMigration() throws Exception { + Files.createDirectory(directory.resolve("messages.yml")); + Files.writeString(directory.resolve("messages.yml/occupied"), "prevent file replacement"); + assertDoesNotThrow(migrator::migrate); + assertTrue(Files.isDirectory(directory.resolve("messages.yml"))); + } + + @Test + void currentVersionStillMigratesLegacyDropListWithoutNewDefaultKeys() throws Exception { + live.loadFromString(packagedConfig); + live.set("drops.tiers.common", List.of("m.OLD:1")); + saveLive(); + migrator.migrate(); + verify(plugin).saveConfig(); + assertEquals(List.of("m.OLD:1"), live.getStringList("drops.lists.default.tiers.common")); + assertFalse(live.contains("drops.tiers")); + } + + @Test + void emptyPackagedSectionsDoNotCreateUnusedLiveSections() throws Exception { + packagedConfig += "empty: {}\n"; + saveLive(); + migrator.migrate(); + assertFalse(live.contains("empty")); + assertEquals(5, live.getInt("config-version")); + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java b/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java new file mode 100644 index 0000000..d1b086b --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java @@ -0,0 +1,218 @@ +package net.tfminecraft.geigercounters.config; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GeigerConfigurationTest { + @TempDir Path directory; + JavaPlugin plugin; + YamlConfiguration yaml; + GeigerConfiguration configuration; + World world; + + @BeforeEach + void setUp() throws Exception { + plugin = mock(JavaPlugin.class); + yaml = new YamlConfiguration(); + world = mock(World.class); + when(plugin.getConfig()).thenReturn(yaml); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.getResource(anyString())) + .thenAnswer(invocation -> getClass().getResourceAsStream("/" + invocation.getArgument(0))); + Files.writeString(directory.resolve("messages.yml"), "player:\n found-source: custom\n"); + yaml.set("source.world", "world"); + configuration = new GeigerConfiguration(plugin); + } + + void load() { + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + configuration.load(); + } + } + + @Test + void defaultsProvideUsableDetectionSoundLimitsColorsAndSpawnFilters() { + load(); + assertSame(world, configuration.getWorld()); + assertEquals(20, configuration.getCollectionDistance()); + assertEquals(2500, configuration.getMaxDetectionDistance()); + assertEquals(200, configuration.getCloseRangeThreshold()); + assertEquals(100, configuration.getThreeRingsDistance()); + assertEquals(300, configuration.getTwoRingsDistance()); + assertTrue(configuration.isSoundEnabled()); + assertEquals("block.note_block.hat", configuration.getSoundName()); + assertEquals(0.35, configuration.getSoundVolume()); + assertEquals(1.7, configuration.getSoundPitch()); + assertEquals(0.15, configuration.getSoundPitchVariance()); + assertEquals(0.5, configuration.getSoundMinRate()); + assertEquals(18, configuration.getSoundMaxRate()); + assertEquals(2, configuration.getSoundCurve()); + assertTrue(configuration.isLimitEnabled()); + assertEquals(3, configuration.getLimitDrops()); + assertEquals(TimeUnit.HOURS.toMillis(12), configuration.getLimitWindowMillis()); + assertColor(configuration.getCloseRangeStartColor(), 255, 255, 255); + assertColor(configuration.getCloseRangeEndColor(), 255, 0, 255); + assertColor(configuration.getFarRangeStartColor(), 255, 0, 255); + assertColor(configuration.getFarRangeEndColor(), 17, 0, 17); + var filters = configuration.getSpawnFilters(); + assertEquals(50, filters.getMaxAttempts()); + assertTrue(filters.isRejectVoid()); + assertTrue(filters.isRejectLiquid()); + assertTrue(filters.isWorldGuardEnabled()); + assertEquals(0, filters.getMinDistanceFromSpawn()); + assertTrue(filters.getBlockedBlocks().isEmpty()); + assertTrue(filters.getBlacklistedRegions().isEmpty()); + assertTrue(configuration.getTierRewards().isEmpty()); + assertEquals("custom", configuration.getMessages().get("player.found-source")); + } + + static void assertColor(GeigerConfiguration.ColorConfig color, int red, int green, int blue) { + assertEquals(red, color.getRed()); + assertEquals(green, color.getGreen()); + assertEquals(blue, color.getBlue()); + } + + @Test + void invertedBoundsAndSoundRatesAreNormalizedAndInvalidSettingsFallBack() { + yaml.set("source.top-left.x", 40); + yaml.set("source.bottom-right.x", -20); + yaml.set("source.top-left.z", 80); + yaml.set("source.bottom-right.z", -10); + yaml.set("sound.min-rate", 12); + yaml.set("sound.max-rate", 2); + yaml.set("sound.curve", 0); + yaml.set("sound.pitch-variance", -1); + yaml.set("limits.time", "nonsense"); + yaml.set("limits.drops", 0); + load(); + assertEquals(-20, configuration.getMinX()); + assertEquals(40, configuration.getMaxX()); + assertEquals(-10, configuration.getMinZ()); + assertEquals(80, configuration.getMaxZ()); + assertEquals(2, configuration.getSoundMinRate()); + assertEquals(12, configuration.getSoundMaxRate()); + assertEquals(2, configuration.getSoundCurve()); + assertEquals(0, configuration.getSoundPitchVariance()); + assertFalse(configuration.isLimitEnabled()); + assertEquals(TimeUnit.HOURS.toMillis(12), configuration.getLimitWindowMillis()); + } + + @Test + void customSpawnFiltersResolveMaterialsAndNormalizeRegionNames() { + yaml.set("source.spawn-filters.max-attempts", 0); + yaml.set("source.spawn-filters.reject-liquid", false); + yaml.set("source.spawn-filters.reject-void", false); + yaml.set("source.spawn-filters.min-distance-from-spawn", 150); + yaml.set("source.spawn-filters.blocked-blocks", List.of("STONE", "not_a_material")); + yaml.set("source.spawn-filters.worldguard.enabled", false); + yaml.set("source.spawn-filters.worldguard.blacklisted-regions", List.of("Spawn", "MARKET")); + load(); + var filters = configuration.getSpawnFilters(); + assertEquals(1, filters.getMaxAttempts()); + assertFalse(filters.isRejectLiquid()); + assertFalse(filters.isRejectVoid()); + assertFalse(filters.isWorldGuardEnabled()); + assertEquals(150, filters.getMinDistanceFromSpawn()); + assertEquals(Set.of(Material.STONE), filters.getBlockedBlocks()); + assertEquals(Set.of("spawn", "market"), filters.getBlacklistedRegions()); + } + + @Test + void supportedSoundNamesAndInvalidNamesHavePredictableResults() { + for (String name : + List.of( + "BLOCK_NOTE_BLOCK_HAT", + " Minecraft:block.note_block.hat ", + "block.note_block.hat", + " ", + "unknown_enum")) { + yaml.set("sound.sound", name); + load(); + assertEquals("block.note_block.hat", configuration.getSoundName(), name); + } + yaml.set("sound.sound", "custom.pack.click"); + load(); + assertEquals("custom.pack.click", configuration.getSoundName()); + } + + @Test + void disabledLimitsDoNotRequirePositiveDropCountAndReloadRefreshesMessages() throws Exception { + yaml.set("limits.enabled", false); + yaml.set("limits.drops", -5); + yaml.set("limits.time", "45m"); + yaml.set("sound.enabled", false); + yaml.set("sound.min-rate", -5); + yaml.set("sound.max-rate", -2); + load(); + Messages messages = configuration.getMessages(); + assertFalse(configuration.isLimitEnabled()); + assertFalse(configuration.isSoundEnabled()); + assertEquals(0, configuration.getSoundMinRate()); + assertEquals(0, configuration.getSoundMaxRate()); + assertEquals(TimeUnit.MINUTES.toMillis(45), configuration.getLimitWindowMillis()); + Files.writeString(directory.resolve("messages.yml"), "player:\n found-source: changed\n"); + load(); + assertSame(messages, configuration.getMessages()); + assertEquals("changed", messages.get("player.found-source")); + } + + @Test + void rewardsChooseNamedListIgnoreDisabledEmptyAndMalformedTiers() { + yaml.set("drops.tier-weights.common", 10); + yaml.set("drops.tier-weights.rare", 3); + yaml.set("drops.tier-weights.epic", 0); + yaml.set( + "drops.lists.hunt.tiers.common", + List.of("m.APPLE:2", "broken", "m.BAD:many", "namespace:item:3")); + yaml.set("drops.lists.hunt.tiers.rare", List.of()); + yaml.set("drops.lists.hunt.tiers.epic", List.of("m.EPIC:1")); + yaml.set("drops.active-list", "hunt"); + load(); + assertEquals("hunt", configuration.getActiveDropList()); + assertEquals(List.of("hunt"), configuration.getDropListNames()); + assertEquals(1, configuration.getTierRewards().size()); + var tier = configuration.getTierRewards().getFirst(); + assertEquals("common", tier.getTierName()); + assertEquals(10, tier.getWeight()); + assertEquals(2, tier.getItems().size()); + assertEquals("m.APPLE", tier.getItems().getFirst().getOutputItem()); + assertEquals(2, tier.getItems().getFirst().getOutputAmount()); + assertEquals("namespace:item", tier.getItems().getLast().getOutputItem()); + assertEquals(3, tier.getItems().getLast().getOutputAmount()); + load(); + assertEquals( + 1, configuration.getTierRewards().size(), "Reload must replace rather than append rewards"); + } + + @Test + void missingListFallsBackToFirstAvailableAndMissingSectionsProduceNoRewards() { + yaml.createSection("drops.tier-weights"); + load(); + assertTrue(configuration.getTierRewards().isEmpty()); + yaml.createSection("drops.lists"); + load(); + assertTrue(configuration.getTierRewards().isEmpty()); + yaml.createSection("drops.lists.alternate"); + yaml.set("drops.active-list", "missing"); + load(); + assertEquals("alternate", configuration.getActiveDropList()); + assertTrue(configuration.getTierRewards().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java b/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java new file mode 100644 index 0000000..89bb7c3 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java @@ -0,0 +1,218 @@ +package net.tfminecraft.geigercounters.handlers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.Random; +import java.util.UUID; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class GeigerEffectsTest { + GeigerConfiguration config; + Player player; + World world; + UUID id; + + @BeforeEach + void setUp() { + config = mock(GeigerConfiguration.class); + player = mock(Player.class); + world = mock(World.class); + id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); + when(player.getLocation()).thenAnswer(invocation -> new Location(world, 10, 64, 20)); + when(config.getMaxDetectionDistance()).thenReturn(1000.0); + when(config.getCloseRangeThreshold()).thenReturn(50.0); + when(config.getThreeRingsDistance()).thenReturn(100.0); + when(config.getTwoRingsDistance()).thenReturn(300.0); + when(config.getCloseRangeStartColor()).thenReturn(new GeigerConfiguration.ColorConfig(0, 0, 0)); + when(config.getCloseRangeEndColor()) + .thenReturn(new GeigerConfiguration.ColorConfig(200, 100, 50)); + when(config.getFarRangeStartColor()) + .thenReturn(new GeigerConfiguration.ColorConfig(200, 100, 50)); + when(config.getFarRangeEndColor()).thenReturn(new GeigerConfiguration.ColorConfig(20, 10, 0)); + when(config.isSoundEnabled()).thenReturn(true); + when(config.getSoundMinRate()).thenReturn(1.0); + when(config.getSoundMaxRate()).thenReturn(20.0); + when(config.getSoundCurve()).thenReturn(2.0); + when(config.getSoundName()).thenReturn("block.note_block.hat"); + when(config.getSoundVolume()).thenReturn(0.35); + when(config.getSoundPitch()).thenReturn(1.0); + } + + @Test + void particlesOutsideDetectionRangeDoNotReadPlayerPosition() { + new ParticleRenderer(config).showParticleEffect(player, 1000.1); + verify(player, never()).getLocation(); + } + + @Test + void closeRangeParticlesInterpolateColorsAndFormThreeRingsAtExpectedHeight() { + new ParticleRenderer(config).showParticleEffect(player, 25); + ArgumentCaptor locations = ArgumentCaptor.forClass(Location.class); + ArgumentCaptor dust = ArgumentCaptor.forClass(Particle.DustOptions.class); + verify(player, times(108)) + .spawnParticle(eq(Particle.DUST), locations.capture(), eq(1), dust.capture()); + for (int index = 0; index < locations.getAllValues().size(); index++) { + Location point = locations.getAllValues().get(index); + assertEquals(65.2, point.getY(), 0.00001); + assertEquals( + 0.3 + (index / 36) * 0.1, Math.hypot(point.getX() - 10, point.getZ() - 20), 0.00001); + assertSame(world, point.getWorld()); + assertEquals(Color.fromRGB(100, 50, 25), dust.getAllValues().get(index).getColor()); + assertEquals(1f, dust.getAllValues().get(index).getSize()); + } + } + + @Test + void farRangeParticlesHonorInclusiveRingThresholdsAndEndColor() { + ParticleRenderer renderer = new ParticleRenderer(config); + double[] distances = {50, 100, 100.1, 300, 300.1, 1000}; + int[] rings = {3, 3, 2, 2, 1, 1}; + for (int index = 0; index < distances.length; index++) { + clearInvocations(player); + renderer.showParticleEffect(player, distances[index]); + ArgumentCaptor dust = + ArgumentCaptor.forClass(Particle.DustOptions.class); + verify(player, times(rings[index] * 36)) + .spawnParticle(eq(Particle.DUST), any(Location.class), eq(1), dust.capture()); + if (distances[index] == 1000) { + assertEquals(Color.fromRGB(20, 10, 0), dust.getValue().getColor()); + } + } + } + + GeigerClickPlayer clicker(double randomValue) throws Exception { + GeigerClickPlayer clicker = new GeigerClickPlayer(config); + Random random = mock(Random.class); + when(random.nextDouble()).thenReturn(randomValue); + Field field = GeigerClickPlayer.class.getDeclaredField("random"); + field.setAccessible(true); + field.set(clicker, random); + return clicker; + } + + @Test + void soundRateFallsWithDistanceAndStopsOutsideDetectionRange() throws Exception { + GeigerClickPlayer clicker = clicker(0.5); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + clicker.updateRate(player, 0); + clicker.tick(); + verify(player) + .playSound( + any(Location.class), + eq("block.note_block.hat"), + eq(SoundCategory.PLAYERS), + eq(0.35f), + eq(1f)); + clearInvocations(player); + clicker.updateRate(player, 1000); + clicker.tick(); + clicker.updateRate(player, 1000.1); + clicker.tick(); + when(config.getMaxDetectionDistance()).thenReturn(0.0); + clicker.updateRate(player, 0); + clicker.tick(); + verify(player, never()) + .playSound( + any(Location.class), anyString(), any(SoundCategory.class), anyFloat(), anyFloat()); + } + } + + @Test + void ratesExpireAfterFifteenTicksAndForgetAndClearRemoveThemImmediately() throws Exception { + GeigerClickPlayer clicker = clicker(0.0); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + clicker.updateRate(player, 0); + for (int tick = 0; tick < 16; tick++) { + clicker.tick(); + } + verify(player, times(15)) + .playSound( + any(Location.class), anyString(), any(SoundCategory.class), anyFloat(), anyFloat()); + clearInvocations(player); + clicker.updateRate(player, 0); + clicker.forget(id); + clicker.tick(); + clicker.updateRate(player, 0); + clicker.clear(); + clicker.tick(); + verify(player, never()) + .playSound( + any(Location.class), anyString(), any(SoundCategory.class), anyFloat(), anyFloat()); + } + } + + @Test + void disabledSoundClearsExistingStatesAndIgnoresRateUpdates() throws Exception { + GeigerClickPlayer clicker = clicker(0.0); + clicker.updateRate(player, 0); + when(config.isSoundEnabled()).thenReturn(false); + clicker.updateRate(player, 0); + clicker.tick(); + clicker.tick(); + when(config.isSoundEnabled()).thenReturn(true); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + clicker.tick(); + bukkit.verify(() -> Bukkit.getPlayer(id), never()); + } + } + + @Test + void missingAndOfflinePlayersAreRemoved() throws Exception { + GeigerClickPlayer clicker = clicker(0.0); + try (var bukkit = mockStatic(Bukkit.class)) { + clicker.updateRate(player, 0); + clicker.tick(); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + when(player.isOnline()).thenReturn(false); + clicker.updateRate(player, 0); + clicker.tick(); + when(player.isOnline()).thenReturn(true); + clicker.tick(); + verify(player, never()) + .playSound( + any(Location.class), anyString(), any(SoundCategory.class), anyFloat(), anyFloat()); + } + } + + @Test + void zeroRatesRemoveStateAndHighRatesCapAtOneClickPerTickWithClampedPitch() throws Exception { + when(config.getSoundMaxRate()).thenReturn(100.0); + when(config.getSoundPitchVariance()).thenReturn(10.0); + for (double randomValue : new double[] {0.0, 0.999}) { + GeigerClickPlayer clicker = clicker(randomValue); + clearInvocations(player); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + clicker.updateRate(player, 0); + clicker.tick(); + verify(player) + .playSound( + any(Location.class), + anyString(), + eq(SoundCategory.PLAYERS), + eq(0.35f), + eq(randomValue == 0 ? 0.5f : 2f)); + } + } + when(config.getSoundMaxRate()).thenReturn(0.0); + when(config.getSoundMinRate()).thenReturn(0.0); + GeigerClickPlayer clicker = clicker(0.0); + clicker.updateRate(player, 0); + try (var bukkit = mockStatic(Bukkit.class)) { + clicker.tick(); + bukkit.verifyNoInteractions(); + } + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java b/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java new file mode 100644 index 0000000..de7b021 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java @@ -0,0 +1,300 @@ +package net.tfminecraft.geigercounters.handlers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +import net.tfminecraft.geigercounters.config.*; +import net.tfminecraft.geigercounters.events.GeigerSourceCollectEvent; +import net.tfminecraft.geigercounters.managers.DropLimitManager; +import net.tfminecraft.geigercounters.models.*; +import net.tfminecraft.geigercounters.validators.SpawnLocationFilter; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.*; + +class SourceHandlerTest { + JavaPlugin plugin; + GeigerConfiguration config; + ItemAPI api; + SpawnLocationFilter filter; + DropLimitManager limits; + World world; + SourceHandler handler; + Player player; + PlayerInventory inventory; + Messages messages; + + @BeforeEach + void setup() { + plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + config = mock(GeigerConfiguration.class); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + filter = mock(SpawnLocationFilter.class); + limits = mock(DropLimitManager.class); + world = mock(World.class); + when(config.getWorld()).thenReturn(world); + when(config.getSpawnFilters()).thenReturn(new GeigerConfiguration.SpawnFilterConfig()); + when(config.getMinX()).thenReturn(-0.5); + when(config.getMaxX()).thenReturn(-0.5); + when(config.getMinZ()).thenReturn(-16.5); + when(config.getMaxZ()).thenReturn(-16.5); + when(world.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(70); + when(world.getChunkAtAsync(anyInt(), anyInt(), eq(true))) + .thenReturn(CompletableFuture.completedFuture(mock(Chunk.class))); + when(config.getCollectionDistance()).thenReturn(20.0); + messages = mock(Messages.class); + when(config.getMessages()).thenReturn(messages); + when(messages.get(anyString(), any(Object[].class))).thenReturn("message"); + player = mock(Player.class); + inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn("Hunter"); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, -0.5, 71, -16.5)); + when(limits.canCollect(player)).thenReturn(true); + handler = new SourceHandler(plugin, config, api, filter, limits); + } + + void place() { + handler.moveSourceToLocation(-0.5, -16.5).join(); + } + + @Test + void randomPlacementFloorsNegativeBlockCoordinatesAndSnapsToSurface() { + Location loc = handler.moveSourceToRandomLocation().join(); + assertEquals(-0.5, loc.getX()); + assertEquals(-16.5, loc.getZ()); + assertEquals(71, loc.getY()); + assertSame(world, loc.getWorld()); + verify(world).getChunkAtAsync(-1, -2, true); + verify(world).getHighestBlockYAt(-1, -17); + assertSame(loc, handler.getSourceLocation()); + } + + @Test + void concurrentRandomMovesShareSearchAndHideOldSource() { + place(); + CompletableFuture future = new CompletableFuture<>(); + when(world.getChunkAtAsync(anyInt(), anyInt(), eq(true))).thenReturn(future); + var first = handler.moveSourceToRandomLocation(); + assertSame(first, handler.moveSourceToRandomLocation()); + assertNull(handler.getSourceLocation()); + assertFalse(first.isDone()); + future.complete(mock(Chunk.class)); + assertNotNull(first.join()); + } + + @Test + void rejectedRandomCandidatesRetryThenAccept() { + when(filter.check(any())).thenReturn(SpawnLocationFilter.Rejection.LIQUID, null); + assertNotNull(handler.moveSourceToRandomLocation().join()); + verify(filter, times(2)).check(any()); + } + + @Test + void exhaustedAttemptsUseLastCandidateAsDocumented() { + when(filter.check(any())).thenReturn(SpawnLocationFilter.Rejection.VOID); + assertNotNull(handler.moveSourceToRandomLocation().join()); + verify(filter, times(config.getSpawnFilters().getMaxAttempts())).check(any()); + } + + @Test + void disabledPluginCompletesBothKindsOfMovementWithoutSource() { + when(plugin.isEnabled()).thenReturn(false); + assertNull(handler.moveSourceToRandomLocation().join()); + assertNull(handler.moveSourceToLocation(1, 2).join()); + assertNull(handler.getSourceLocation()); + verifyNoInteractions(filter); + } + + @Test + void explicitPlacementBypassesFilters() { + when(filter.check(any())).thenReturn(SpawnLocationFilter.Rejection.BLOCKED_BLOCK); + Location loc = handler.moveSourceToLocation(34, 65).join(); + assertEquals(34, loc.getX()); + assertEquals(65, loc.getZ()); + } + + @Test + void chunkFailureStillAttemptsDocumentedSurfaceFallback() { + when(world.getChunkAtAsync(anyInt(), anyInt(), eq(true))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("load failed"))); + assertNotNull(handler.moveSourceToLocation(1, 2).join()); + assertNotNull(handler.moveSourceToRandomLocation().join()); + } + + @Test + void distanceAndRelocationGuardsDoNotConsumeLimits() { + handler.tryCollectSource(player, 1, EquipmentSlot.HAND); + place(); + handler.tryCollectSource(player, 20.01, EquipmentSlot.HAND); + verifyNoInteractions(limits); + } + + @Test + void currentSourceWorldAndDistanceOverrideStaleCallerDistance() { + place(); + when(player.getLocation()).thenReturn(new Location(mock(World.class), -0.5, 71, -16.5)); + handler.tryCollectSource(player, 0, EquipmentSlot.HAND); + when(player.getLocation()).thenReturn(new Location(world, 1000, 71, 1000)); + handler.tryCollectSource(player, 0, EquipmentSlot.HAND); + verifyNoInteractions(limits); + } + + @Test + void pendingManualMoveHidesPreviousSourceAndSupersedesRandomMove() { + place(); + CompletableFuture randomChunk = new CompletableFuture<>(), + manualChunk = new CompletableFuture<>(); + when(world.getChunkAtAsync(anyInt(), anyInt(), eq(true))).thenReturn(randomChunk, manualChunk); + var random = handler.moveSourceToRandomLocation(); + var manual = handler.moveSourceToLocation(30, 40); + assertNull(handler.getSourceLocation()); + manualChunk.complete(mock(Chunk.class)); + randomChunk.complete(mock(Chunk.class)); + assertNull(random.join()); + assertSame(manual.join(), handler.getSourceLocation()); + assertEquals(30, handler.getSourceLocation().getX()); + } + + @Test + void limitedPlayerReceivesOneWarningWithoutMovingSource() { + place(); + Location old = handler.getSourceLocation(); + when(limits.canCollect(player)).thenReturn(false); + when(config.getLimitDrops()).thenReturn(3); + when(config.getLimitWindowMillis()).thenReturn(60000L); + handler.tryCollectSource(player, 10, EquipmentSlot.HAND); + handler.tryCollectSource(player, 10, EquipmentSlot.HAND); + verify(player, times(1)).sendMessage("message"); + assertSame(old, handler.getSourceLocation()); + verify(limits, never()).recordCollection(any()); + } + + void collect(EquipmentSlot slot) { + PluginManager plugins = mock(PluginManager.class); + try (var b = mockStatic(Bukkit.class)) { + b.when(Bukkit::getPluginManager).thenReturn(plugins); + handler.tryCollectSource(player, 1, slot); + verify(plugins).callEvent(any(GeigerSourceCollectEvent.class)); + } + } + + @Test + void collectionConsumesCounterRecordsLimitAndPublishesOriginalLocation() { + place(); + Location old = handler.getSourceLocation(); + PluginManager plugins = mock(PluginManager.class); + try (var b = mockStatic(Bukkit.class)) { + b.when(Bukkit::getPluginManager).thenReturn(plugins); + handler.tryCollectSource(player, 20, EquipmentSlot.HAND); + verify(plugins) + .callEvent( + argThat( + event -> + event instanceof GeigerSourceCollectEvent collected + && collected.getPlayer() == player + && collected.getLocation().equals(old))); + } + verify(limits).recordCollection(player); + verify(inventory).setItemInMainHand(null); + verify(inventory).addItem(any(ItemStack.class)); + verify(player).playSound(any(Location.class), eq(Sound.ENTITY_ITEM_BREAK), eq(1f), eq(1f)); + } + + @Test + void offhandCollectionAlsoGrantsClonedRewardWithConfiguredAmount() { + TierReward tier = new TierReward("rare", 1); + tier.addItem(new ItemReward("m.TEST", 4)); + when(config.getTierRewards()).thenReturn(List.of(tier)); + ItemStack original = mock(ItemStack.class), copy = mock(ItemStack.class); + when(api.getCreator().getItemFromPath("m.test")).thenReturn(original); + when(original.clone()).thenReturn(copy); + place(); + collect(EquipmentSlot.OFF_HAND); + verify(inventory).setItemInOffHand(null); + verify(copy).setAmount(4); + verify(inventory).addItem(copy); + verify(original, never()).setAmount(anyInt()); + } + + @Test + void missingDeadCounterAndInvalidRewardAreLoggedWithoutAbortingCollection() { + TierReward tier = new TierReward("rare", 1); + tier.addItem(new ItemReward("missing", 1)); + when(config.getTierRewards()).thenReturn(List.of(tier)); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(null); + place(); + collect(EquipmentSlot.HAND); + verify(limits).recordCollection(player); + verify(inventory, never()).addItem(any(ItemStack.class)); + } + + @Test + void emptyRewardTierDoesNotAttemptRewardCreation() { + when(config.getTierRewards()).thenReturn(List.of(new TierReward("empty", 1))); + place(); + collect(EquipmentSlot.HAND); + verify(inventory, times(1)).addItem(any(ItemStack.class)); + } + + @Test + void olderManualMovementCannotOverwriteNewerCompletedMovement() { + CompletableFuture older = new CompletableFuture<>(), newer = new CompletableFuture<>(); + when(world.getChunkAtAsync(anyInt(), anyInt(), eq(true))).thenReturn(older, newer); + handler.moveSourceToLocation(1, 2); + handler.moveSourceToLocation(30, 40); + newer.complete(mock(Chunk.class)); + older.complete(mock(Chunk.class)); + assertEquals(30, handler.getSourceLocation().getX()); + assertEquals(40, handler.getSourceLocation().getZ()); + } + + @Test + void limitWarningCanBeSentAgainAfterItsCooldown() throws Exception { + place(); + when(limits.canCollect(player)).thenReturn(false); + handler.tryCollectSource(player, 0, EquipmentSlot.HAND); + var field = SourceHandler.class.getDeclaredField("lastLimitMessage"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map sent = (Map) field.get(handler); + sent.put(player.getUniqueId(), System.currentTimeMillis() - 16000); + handler.tryCollectSource(player, 0, EquipmentSlot.HAND); + verify(player, times(2)).sendMessage("message"); + } + + @Test + void weightedDrawSelectsEachTierAndPreservesLegacyItemSuffixParsing() throws Exception { + List tiers = new ArrayList<>(); + for (String name : List.of("first", "second", "third")) { + TierReward tier = new TierReward(name, 1); + tier.addItem(new ItemReward("m." + name + ":legacy", 2)); + tiers.add(tier); + } + when(config.getTierRewards()).thenReturn(tiers); + Random random = mock(Random.class); + var field = SourceHandler.class.getDeclaredField("random"); + field.setAccessible(true); + field.set(handler, random); + double[] draws = {0.1, 0.5, 0.9}; + for (int index = 0; index < draws.length; index++) { + // Two source coordinates are sampled before the reward draw. + when(random.nextDouble()).thenReturn(draws[index]); + place(); + collect(EquipmentSlot.HAND); + verify(api.getCreator()).getItemFromPath("m." + tiers.get(index).getTierName()); + } + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/managers/DropLimitManagerTest.java b/src/test/java/net/tfminecraft/geigercounters/managers/DropLimitManagerTest.java new file mode 100644 index 0000000..31df6dc --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/managers/DropLimitManagerTest.java @@ -0,0 +1,194 @@ +package net.tfminecraft.geigercounters.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DropLimitManagerTest { + @TempDir Path directory; + JavaPlugin plugin; + GeigerConfiguration config; + Player player; + UUID playerId; + DropLimitManager manager; + final long window = TimeUnit.HOURS.toMillis(12); + + @BeforeEach + void setUp() { + plugin = mock(JavaPlugin.class); + config = mock(GeigerConfiguration.class); + player = mock(Player.class); + playerId = UUID.randomUUID(); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(config.isLimitEnabled()).thenReturn(true); + when(config.getLimitDrops()).thenReturn(3); + when(config.getLimitWindowMillis()).thenReturn(window); + when(player.getUniqueId()).thenReturn(playerId); + manager = new DropLimitManager(plugin, config); + } + + Path dataPath() { + return directory.resolve("drop-limits.yml"); + } + + void writeHistory(List timestamps) throws Exception { + YamlConfiguration data = new YamlConfiguration(); + data.set("collections." + playerId, timestamps); + data.save(dataPath().toFile()); + manager.load(); + } + + @Test + void missingOrNonSectionHistoryLeavesAllSlotsAvailable() throws Exception { + manager.load(); + assertTrue(manager.canCollect(player)); + assertEquals(3, manager.getRemaining(playerId)); + assertEquals(0, manager.getMillisUntilNextDrop(playerId)); + Files.writeString(dataPath(), "collections: invalid\n"); + manager.load(); + assertEquals(3, manager.getRemaining(playerId)); + } + + @Test + void recordsCollectionsPersistsThemAndSurvivesRestart() { + manager.recordCollection(player); + manager.recordCollection(player); + manager.recordCollection(player); + assertFalse(manager.canCollect(player)); + assertEquals(0, manager.getRemaining(playerId)); + long wait = manager.getMillisUntilNextDrop(playerId); + assertTrue( + wait > window - 60000 && wait <= window, "Next slot should open about twelve hours later"); + DropLimitManager restarted = new DropLimitManager(plugin, config); + restarted.load(); + assertEquals(0, restarted.getRemaining(playerId)); + assertFalse(restarted.canCollect(player)); + YamlConfiguration data = YamlConfiguration.loadConfiguration(dataPath().toFile()); + assertEquals(3, data.getLongList("collections." + playerId).size()); + } + + @Test + void disabledLimitsAndBypassPermissionDoNotAccumulateCollections() { + when(config.isLimitEnabled()).thenReturn(false); + assertTrue(manager.canCollect(player)); + manager.recordCollection(player); + assertEquals(3, manager.getRemaining(playerId)); + assertFalse(Files.exists(dataPath())); + when(config.isLimitEnabled()).thenReturn(true); + when(player.hasPermission("geiger.limit.bypass")).thenReturn(true); + assertTrue(manager.canCollect(player)); + manager.recordCollection(player); + assertEquals(3, manager.getRemaining(playerId)); + assertFalse(Files.exists(dataPath())); + } + + @Test + void unsortedHistoryIsSortedAndExpiredEntriesArePrunedBeforeCounting() throws Exception { + long now = System.currentTimeMillis(); + writeHistory(List.of(now - 1000, now - window - 1000, now - 2000)); + assertEquals(1, manager.getRemaining(playerId)); + assertEquals(0, manager.getMillisUntilNextDrop(playerId)); + manager.recordCollection(player); + assertEquals(0, manager.getRemaining(playerId)); + List stamps = + YamlConfiguration.loadConfiguration(dataPath().toFile()) + .getLongList("collections." + playerId); + assertEquals(3, stamps.size()); + assertEquals(now - 2000, stamps.getFirst()); + assertTrue(stamps.getLast() >= now); + } + + @Test + void expiredPlayersDisappearFromDiskAndRemainingNeverBecomesNegative() throws Exception { + long now = System.currentTimeMillis(); + UUID expired = UUID.randomUUID(); + YamlConfiguration data = new YamlConfiguration(); + data.set("collections." + expired, List.of(now - window - 1000)); + data.set("collections." + playerId, List.of(now, now, now, now)); + data.save(dataPath().toFile()); + manager.load(); + assertEquals(0, manager.getRemaining(playerId)); + assertEquals(3, manager.getRemaining(expired)); + manager.save(); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(dataPath().toFile()); + assertFalse(saved.contains("collections." + expired)); + assertEquals(4, saved.getLongList("collections." + playerId).size()); + } + + @Test + void resetOnlyPersistsChangesAndFreesAllSlotsAcrossRestarts() throws Exception { + manager.reset(playerId); + assertFalse(Files.exists(dataPath())); + manager.recordCollection(player); + manager.reset(playerId); + assertEquals(3, manager.getRemaining(playerId)); + manager.load(); + assertEquals(3, manager.getRemaining(playerId)); + assertEquals(0, manager.getMillisUntilNextDrop(playerId)); + } + + @Test + void invalidUuidAndEmptyHistoriesAreIgnoredAndReloadClearsStaleMemory() throws Exception { + YamlConfiguration data = new YamlConfiguration(); + data.set("collections.not-a-uuid", List.of(System.currentTimeMillis())); + data.set("collections." + UUID.randomUUID(), List.of()); + data.set("collections." + playerId, List.of(System.currentTimeMillis())); + data.save(dataPath().toFile()); + manager.load(); + assertEquals(2, manager.getRemaining(playerId)); + Files.delete(dataPath()); + manager.load(); + assertEquals(3, manager.getRemaining(playerId)); + } + + @Test + void saveFailureDoesNotThrowOrLoseCurrentInMemoryHistory() throws Exception { + Files.createDirectory(dataPath()); + Files.writeString(dataPath().resolve("occupied"), "cannot overwrite directory"); + assertDoesNotThrow(() -> manager.recordCollection(player)); + assertEquals(2, manager.getRemaining(playerId)); + } + + @Test + void disabledZeroDropConfigurationReportsNoWaitEvenWithoutHistory() { + when(config.isLimitEnabled()).thenReturn(false); + when(config.getLimitDrops()).thenReturn(0); + assertTrue(manager.canCollect(player)); + assertEquals(0, manager.getMillisUntilNextDrop(playerId)); + } + + @Test + void loweringLimitWaitsUntilEnoughCollectionsHaveExpired() throws Exception { + long now = System.currentTimeMillis(); + writeHistory( + List.of( + now - TimeUnit.HOURS.toMillis(3), + now - TimeUnit.HOURS.toMillis(2), + now - TimeUnit.HOURS.toMillis(1))); + when(config.getLimitDrops()).thenReturn(1); + long wait = manager.getMillisUntilNextDrop(playerId); + assertTrue(wait > TimeUnit.HOURS.toMillis(11) - 60000 && wait <= TimeUnit.HOURS.toMillis(11)); + } + + @Test + void disablingLimitsImmediatelyRemovesWaitEvenWithRecordedHistory() { + manager.recordCollection(player); + when(config.isLimitEnabled()).thenReturn(false); + when(config.getLimitDrops()).thenReturn(0); + assertEquals(0, manager.getMillisUntilNextDrop(playerId)); + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java b/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java new file mode 100644 index 0000000..3d3814c --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java @@ -0,0 +1,187 @@ +package net.tfminecraft.geigercounters.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.handlers.*; +import net.tfminecraft.geigercounters.validators.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.*; +import org.mockito.*; + +class GeigerManagerTest { + JavaPlugin plugin; + GeigerManager manager; + SourceHandler source; + GeigerValidator validator; + ParticleRenderer particles; + GeigerClickPlayer clicks; + World world; + + @BeforeEach + void setup() throws Exception { + set(null, "instance", null); + plugin = mock(JavaPlugin.class); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + manager = GeigerManager.getInstance(plugin); + source = mock(SourceHandler.class); + validator = mock(GeigerValidator.class); + particles = mock(ParticleRenderer.class); + clicks = mock(GeigerClickPlayer.class); + world = mock(World.class); + set(manager, "sourceHandler", source); + set(manager, "validator", validator); + set(manager, "particleRenderer", particles); + set(manager, "clickPlayer", clicks); + } + + @AfterEach + void cleanup() throws Exception { + set(null, "instance", null); + } + + static void set(Object target, String name, Object value) throws Exception { + Field f = GeigerManager.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + void check(Player... players) throws Exception { + try (var b = mockStatic(Bukkit.class)) { + b.when(Bukkit::getOnlinePlayers).thenReturn(Arrays.asList(players)); + Method m = GeigerManager.class.getDeclaredMethod("checkAllPlayers"); + m.setAccessible(true); + m.invoke(manager); + } + } + + Player player(World w, boolean main, boolean off) { + Player p = mock(Player.class); + PlayerInventory inv = mock(PlayerInventory.class); + when(p.getInventory()).thenReturn(inv); + when(p.getLocation()).thenReturn(new Location(w, 3, 150, 4)); + ItemStack a = mock(ItemStack.class), b = mock(ItemStack.class); + when(inv.getItemInMainHand()).thenReturn(a); + when(inv.getItemInOffHand()).thenReturn(b); + when(validator.isGeigerCounter(a)).thenReturn(main); + when(validator.isGeigerCounter(b)).thenReturn(off); + return p; + } + + @Test + void singletonAndGetters() { + assertSame(manager, GeigerManager.getInstance()); + assertSame(manager, GeigerManager.getInstance(mock(JavaPlugin.class))); + assertSame(plugin, manager.getPlugin()); + assertSame(source, manager.getSourceHandler()); + assertSame(clicks, manager.getClickPlayer()); + } + + @Test + void noSourceDoesNotInspectPlayers() throws Exception { + Player p = mock(Player.class); + check(p); + verifyNoInteractions(p, particles, clicks); + } + + @Test + void bothHandsAndNoCounterUseHorizontalDistance() throws Exception { + when(source.getSourceLocation()).thenReturn(new Location(world, 0, -64, 0)); + Player main = player(world, true, true), + off = player(world, false, true), + neither = player(world, false, false); + check(main, off, neither); + verify(source).tryCollectSource(main, 5, EquipmentSlot.HAND); + verify(source).tryCollectSource(off, 5, EquipmentSlot.OFF_HAND); + verify(particles).showParticleEffect(main, 5); + verify(clicks).updateRate(off, 5); + verify(source, never()).tryCollectSource(eq(neither), anyDouble(), any()); + } + + @Test + void playersInOtherWorldCannotDetectOrCollectSource() throws Exception { + when(source.getSourceLocation()).thenReturn(new Location(world, 0, 64, 0)); + Player other = player(mock(World.class), true, false); + check(other); + verify(source, never()).tryCollectSource(eq(other), anyDouble(), any()); + verifyNoInteractions(particles, clicks); + } + + @Test + void refreshesSourceForEachPlayerWhenRelocationCompletesImmediately() throws Exception { + Location first = new Location(world, 0, 64, 0), next = new Location(world, 1003, 64, 4); + when(source.getSourceLocation()).thenReturn(first); + Player a = player(world, true, false), b = player(world, true, false); + doAnswer( + i -> { + when(source.getSourceLocation()).thenReturn(next); + return null; + }) + .when(source) + .tryCollectSource(a, 5, EquipmentSlot.HAND); + check(a, b); + verify(source).tryCollectSource(b, 1000, EquipmentSlot.HAND); + } + + @Test + void shutdownHandlesUninitializedAndInitializedComponents() throws Exception { + set(manager, "clickPlayer", null); + manager.shutdown(); + DropLimitManager limits = mock(DropLimitManager.class); + set(manager, "dropLimitManager", limits); + set(manager, "clickPlayer", clicks); + manager.shutdown(); + verify(limits).save(); + verify(clicks).clear(); + assertSame(limits, manager.getDropLimitManager()); + } + + @Test + void reloadRefreshesConfiguration() throws Exception { + GeigerConfiguration config = mock(GeigerConfiguration.class); + set(manager, "configuration", config); + manager.reload(); + verify(plugin).reloadConfig(); + verify(config).load(); + assertSame(config, manager.getConfiguration()); + } + + @Test + void initializationLoadsPersistenceSpawnsSourceAndSchedulesBothTasks() { + BukkitScheduler scheduler = mock(BukkitScheduler.class); + ItemAPI api = mock(ItemAPI.class); + try (var b = mockStatic(Bukkit.class); + var t = mockStatic(TLibs.class); + var configs = mockConstruction(GeigerConfiguration.class); + var validators = mockConstruction(GeigerValidator.class); + var filters = mockConstruction(SpawnLocationFilter.class); + var renderers = mockConstruction(ParticleRenderer.class); + var clickers = mockConstruction(GeigerClickPlayer.class); + var limits = mockConstruction(DropLimitManager.class); + var sources = mockConstruction(SourceHandler.class)) { + b.when(Bukkit::getScheduler).thenReturn(scheduler); + t.when(TLibs::getItemAPI).thenReturn(api); + manager.initialize(); + verify(configs.constructed().getFirst()).load(); + verify(limits.constructed().getFirst()).load(); + verify(sources.constructed().getFirst()).moveSourceToRandomLocation(); + ArgumentCaptor check = ArgumentCaptor.forClass(Runnable.class), + click = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), check.capture(), eq(0L), eq(5L)); + verify(scheduler).runTaskTimer(eq(plugin), click.capture(), eq(0L), eq(1L)); + check.getValue().run(); + click.getValue().run(); + verify(clickers.constructed().getFirst()).tick(); + } + } +} diff --git a/src/test/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilterTest.java b/src/test/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilterTest.java new file mode 100644 index 0000000..15d5222 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilterTest.java @@ -0,0 +1,244 @@ +package net.tfminecraft.geigercounters.validators; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.sk89q.worldedit.bukkit.BukkitWorld; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldguard.WorldGuard; +import com.sk89q.worldguard.protection.ApplicableRegionSet; +import com.sk89q.worldguard.protection.managers.RegionManager; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; +import java.util.List; +import java.util.Set; +import java.util.logging.Logger; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.hooks.WorldGuardHook; +import net.tfminecraft.geigercounters.metrics.UsageStats; +import net.tfminecraft.geigercounters.models.*; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Waterlogged; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SpawnLocationFilterTest { + JavaPlugin plugin; + GeigerConfiguration config; + GeigerConfiguration.SpawnFilterConfig settings; + World world; + Block ground; + SpawnLocationFilter filter; + + @BeforeEach + void setUp() { + plugin = mock(JavaPlugin.class); + config = mock(GeigerConfiguration.class); + settings = mock(GeigerConfiguration.SpawnFilterConfig.class); + world = mock(World.class); + ground = mock(Block.class); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(config.getSpawnFilters()).thenReturn(settings); + when(settings.isRejectVoid()).thenReturn(true); + when(settings.isRejectLiquid()).thenReturn(true); + when(world.getMinHeight()).thenReturn(-64); + when(world.getMaxHeight()).thenReturn(320); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(ground); + when(world.getSpawnLocation()).thenReturn(new Location(world, 0, 80, 0)); + when(ground.getType()).thenReturn(Material.STONE); + when(ground.getBlockData()).thenReturn(mock(BlockData.class)); + filter = new SpawnLocationFilter(plugin, config); + } + + Location location(double x, double y, double z) { + return new Location(world, x, y, z); + } + + @Test + void missingWorldAndOutOfBoundsGroundAreRejectedWithoutBlockQueries() { + assertEquals(SpawnLocationFilter.Rejection.VOID, filter.check(new Location(null, 0, 70, 0))); + assertEquals(SpawnLocationFilter.Rejection.VOID, filter.check(location(0, -64, 0))); + assertEquals(SpawnLocationFilter.Rejection.VOID, filter.check(location(0, 321, 0))); + verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + when(settings.isRejectVoid()).thenReturn(false); + assertNull(filter.check(location(0, -64, 0))); + assertNull(filter.check(location(0, 321, 0))); + verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + + @Test + void airLiquidWaterloggedAndBlacklistedGroundHaveDistinctRejections() { + when(ground.getType()).thenReturn(Material.AIR); + assertEquals(SpawnLocationFilter.Rejection.VOID, filter.check(location(0, 70, 0))); + when(settings.isRejectVoid()).thenReturn(false); + assertNull(filter.check(location(0, 70, 0))); + when(ground.getType()).thenReturn(Material.WATER); + when(ground.isLiquid()).thenReturn(true); + assertEquals(SpawnLocationFilter.Rejection.LIQUID, filter.check(location(0, 70, 0))); + when(ground.isLiquid()).thenReturn(false); + when(ground.getType()).thenReturn(Material.OAK_STAIRS); + Waterlogged data = mock(Waterlogged.class); + when(ground.getBlockData()).thenReturn(data); + when(data.isWaterlogged()).thenReturn(true); + assertEquals(SpawnLocationFilter.Rejection.LIQUID, filter.check(location(0, 70, 0))); + when(data.isWaterlogged()).thenReturn(false); + assertNull(filter.check(location(0, 70, 0))); + when(settings.isRejectLiquid()).thenReturn(false); + when(settings.getBlockedBlocks()).thenReturn(Set.of(Material.OAK_STAIRS)); + assertEquals(SpawnLocationFilter.Rejection.BLOCKED_BLOCK, filter.check(location(0, 70, 0))); + } + + @Test + void spawnDistanceUsesHorizontalCoordinatesAndAllowsExactBoundary() { + when(settings.getMinDistanceFromSpawn()).thenReturn(5.0); + assertEquals( + SpawnLocationFilter.Rejection.TOO_CLOSE_TO_SPAWN, filter.check(location(0, 200, 0))); + assertNull(filter.check(location(3, 200, 4))); + assertNull(filter.check(location(4, 200, 4))); + } + + @Test + void disabledOrEmptyWorldGuardBlacklistDoesNotResolveOptionalPlugin() { + try (var bukkit = mockStatic(Bukkit.class)) { + assertNull(filter.check(location(0, 70, 0))); + when(settings.isWorldGuardEnabled()).thenReturn(true); + assertNull(filter.check(location(0, 70, 0))); + bukkit.verifyNoInteractions(); + } + } + + void enableRegions() { + when(settings.isWorldGuardEnabled()).thenReturn(true); + when(settings.getBlacklistedRegions()).thenReturn(Set.of("spawn")); + } + + @Test + void missingWorldGuardIsResolvedOnlyOnceAndAllowsLocations() { + enableRegions(); + PluginManager plugins = mock(PluginManager.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + assertNull(filter.check(location(0, 70, 0))); + assertNull(filter.check(location(0, 70, 0))); + verify(plugins).getPlugin("WorldGuard"); + } + } + + @Test + void installedWorldGuardCanRejectAllowOrFailWithoutBreakingChecks() { + enableRegions(); + PluginManager plugins = mock(PluginManager.class); + when(plugins.getPlugin("WorldGuard")).thenReturn(mock(Plugin.class)); + try (var bukkit = mockStatic(Bukkit.class); + var hooks = + mockConstruction( + WorldGuardHook.class, + (hook, context) -> + when(hook.isInAnyRegion(any(), any())) + .thenReturn(true, false) + .thenThrow(new IllegalStateException("integration failed")))) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + assertEquals( + SpawnLocationFilter.Rejection.WORLDGUARD_REGION, filter.check(location(0, 70, 0))); + assertNull(filter.check(location(0, 70, 0))); + assertNull(filter.check(location(0, 70, 0))); + assertEquals(1, hooks.constructed().size()); + } + } + + @Test + void incompatibleWorldGuardHookDoesNotDisableAllPlacement() { + enableRegions(); + PluginManager plugins = mock(PluginManager.class); + when(plugins.getPlugin("WorldGuard")).thenReturn(mock(Plugin.class)); + try (var bukkit = mockStatic(Bukkit.class); + var hooks = + mockConstruction( + WorldGuardHook.class, + (hook, context) -> { + throw new NoClassDefFoundError("incompatible WorldGuard"); + })) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + assertNull(filter.check(location(0, 70, 0))); + } + } + + @Test + void worldGuardHookHandlesMissingWorldEmptyIdsMissingManagerAndRegionMatches() { + WorldGuardHook hook = new WorldGuardHook(); + assertFalse(hook.isInAnyRegion(location(0, 70, 0), Set.of())); + assertFalse(hook.isInAnyRegion(new Location(null, 0, 70, 0), Set.of("spawn"))); + WorldGuard guard = mock(WorldGuard.class, RETURNS_DEEP_STUBS); + try (var guards = mockStatic(WorldGuard.class); + var adapters = mockConstruction(BukkitWorld.class)) { + guards.when(WorldGuard::getInstance).thenReturn(guard); + when(guard.getPlatform().getRegionContainer().get(any(BukkitWorld.class))).thenReturn(null); + assertFalse(hook.isInAnyRegion(location(0, 70, 0), Set.of("spawn"))); + RegionManager regions = mock(RegionManager.class); + when(guard.getPlatform().getRegionContainer().get(any(BukkitWorld.class))) + .thenReturn(regions); + ApplicableRegionSet applicable = mock(ApplicableRegionSet.class); + when(regions.getApplicableRegions(any(BlockVector3.class))).thenReturn(applicable); + ProtectedRegion unrelated = mock(ProtectedRegion.class), + matched = mock(ProtectedRegion.class); + when(unrelated.getId()).thenReturn("other"); + when(matched.getId()).thenReturn("spawn"); + when(applicable.iterator()).thenAnswer(invocation -> List.of(unrelated).iterator()); + assertFalse(hook.isInAnyRegion(location(-0.5, 70, -16.5), Set.of("spawn"))); + verify(regions).getApplicableRegions(BlockVector3.at(-1, 70, -17)); + when(applicable.iterator()).thenAnswer(invocation -> List.of(unrelated, matched).iterator()); + assertTrue(hook.isInAnyRegion(location(0, 70, 0), Set.of("spawn"))); + } + } + + @Test + void counterValidationRejectsMissingMetadataCachesTemplateAndRetriesFailedLookup() { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + GeigerValidator validator = new GeigerValidator(plugin, api); + ItemStack candidate = mock(ItemStack.class); + assertFalse(validator.isGeigerCounter(null)); + assertFalse(validator.isGeigerCounter(candidate)); + when(candidate.hasItemMeta()).thenReturn(true); + when(api.getCreator().getItemFromPath("m.TOOLS.GEIGER_COUNTER")) + .thenThrow(new IllegalArgumentException("missing template")); + assertFalse(validator.isGeigerCounter(candidate)); + ItemStack original = mock(ItemStack.class), template = mock(ItemStack.class); + var creator = api.getCreator(); + doReturn(original).when(creator).getItemFromPath("m.TOOLS.GEIGER_COUNTER"); + when(original.clone()).thenReturn(template); + when(candidate.isSimilar(template)).thenReturn(true, false); + assertTrue(validator.isGeigerCounter(candidate)); + assertFalse(validator.isGeigerCounter(candidate)); + verify(original).clone(); + verify(api.getCreator(), times(2)).getItemFromPath("m.TOOLS.GEIGER_COUNTER"); + } + + @Test + void tierStateAndUsageCounterRemainConsistentAcrossCollectionsAndDrains() { + TierReward tier = new TierReward("rare", 4); + assertTrue(tier.isEmpty()); + ItemReward item = new ItemReward("m.TEST", 2); + tier.addItem(item); + assertFalse(tier.isEmpty()); + assertEquals(List.of(item), tier.getItems()); + assertEquals("rare", tier.getTierName()); + assertEquals(4, tier.getWeight()); + assertEquals("m.TEST", item.getOutputItem()); + assertEquals(2, item.getOutputAmount()); + UsageStats stats = UsageStats.getInstance(); + stats.drainSourcesCollected(); + stats.recordSourceCollected(); + stats.recordSourceCollected(); + assertEquals(2, stats.drainSourcesCollected()); + assertEquals(0, stats.drainSourcesCollected()); + for (var rejection : SpawnLocationFilter.Rejection.values()) { + assertFalse(rejection.getDescription().isBlank()); + } + } +} From 0daa6483f5a49a2262187ff6bfe02db8e20dd46f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:57:05 +0000 Subject: [PATCH 2/2] fix: preserve stacked counters and overflow rewards --- .../handlers/SourceHandler.java | 27 ++++-- .../handlers/SourceHandlerTest.java | 82 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java index 6c55e6a..68bbd52 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java @@ -268,17 +268,26 @@ private void notifyPlayerOfCollection(Player player) { } private void replaceGeigerWithDeadVersion(Player player, EquipmentSlot geigerSlot) { - // Remove active Geiger Counter from whichever hand held it - if (geigerSlot == EquipmentSlot.OFF_HAND) { - player.getInventory().setItemInOffHand(null); + // Consume one counter, preserving any others stacked in the same hand. + boolean offHand = geigerSlot == EquipmentSlot.OFF_HAND; + ItemStack active = offHand ? player.getInventory().getItemInOffHand() + : player.getInventory().getItemInMainHand(); + ItemStack remaining = null; + if (active.getAmount() > 1) { + remaining = active.clone(); + remaining.setAmount(active.getAmount() - 1); + } + if (offHand) { + player.getInventory().setItemInOffHand(remaining); } else { - player.getInventory().setItemInMainHand(null); + player.getInventory().setItemInMainHand(remaining); } // Give dead Geiger Counter try { ItemStack deadGeiger = api.getCreator().getItemFromPath(DEAD_GEIGER_PATH).clone(); - player.getInventory().addItem(deadGeiger); + deadGeiger.setAmount(1); + giveItemOrDropLeftovers(player, deadGeiger); player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1f, 1f); player.sendMessage(config.getMessages().get("player.dead-geiger")); } catch (Exception e) { @@ -343,13 +352,19 @@ private TierReward selectRandomTier(List tiers) { private void giveRewardItem(Player player, ItemReward reward, String tierName) { try { ItemStack rewardItem = createRewardItem(reward); - player.getInventory().addItem(rewardItem); + giveItemOrDropLeftovers(player, rewardItem); plugin.getLogger().info(player.getName() + " received " + tierName + " reward: " + reward.getOutputItem()); } catch (Exception e) { plugin.getLogger().warning("Failed to give reward to " + player.getName() + ": " + e.getMessage()); } } + private void giveItemOrDropLeftovers(Player player, ItemStack item) { + for (ItemStack leftover : player.getInventory().addItem(item).values()) { + player.getWorld().dropItemNaturally(player.getLocation(), leftover); + } + } + // ==================================== // Create an ItemStack for the reward // ==================================== diff --git a/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java b/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java index de7b021..6f0cb8d 100644 --- a/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java @@ -19,6 +19,8 @@ import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class SourceHandlerTest { JavaPlugin plugin; @@ -58,6 +60,10 @@ void setup() { player = mock(Player.class); inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory); + ItemStack singleCounter = mock(ItemStack.class); + when(singleCounter.getAmount()).thenReturn(1); + when(inventory.getItemInMainHand()).thenReturn(singleCounter); + when(inventory.getItemInOffHand()).thenReturn(singleCounter); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.getName()).thenReturn("Hunter"); when(player.getWorld()).thenReturn(world); @@ -297,4 +303,80 @@ void weightedDrawSelectsEachTierAndPreservesLegacyItemSuffixParsing() throws Exc verify(api.getCreator()).getItemFromPath("m." + tiers.get(index).getTierName()); } } + + @ParameterizedTest + @EnumSource( + value = EquipmentSlot.class, + names = {"HAND", "OFF_HAND"}) + void collectionConsumesExactlyOneCounterFromEitherHandStack(EquipmentSlot slot) { + ItemStack active = mock(ItemStack.class), remaining = mock(ItemStack.class); + when(active.getAmount()).thenReturn(5); + when(active.clone()).thenReturn(remaining); + if (slot == EquipmentSlot.HAND) { + when(inventory.getItemInMainHand()).thenReturn(active); + } else { + when(inventory.getItemInOffHand()).thenReturn(active); + } + ItemStack deadTemplate = mock(ItemStack.class), dead = mock(ItemStack.class); + when(api.getCreator().getItemFromPath("m.TOOLS.DEAD_GEIGER_COUNTER")).thenReturn(deadTemplate); + when(deadTemplate.clone()).thenReturn(dead); + place(); + collect(slot); + verify(remaining).setAmount(4); + if (slot == EquipmentSlot.HAND) { + verify(inventory).setItemInMainHand(remaining); + verify(inventory, never()).setItemInOffHand(any()); + } else { + verify(inventory).setItemInOffHand(remaining); + verify(inventory, never()).setItemInMainHand(any()); + } + verify(dead).setAmount(1); + verify(inventory).addItem(dead); + verify(active, never()).setAmount(anyInt()); + verify(deadTemplate, never()).setAmount(anyInt()); + verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + + @Test + void fullInventoryDropsDeadCounterAndCompleteRewardWithoutLosingAmounts() { + ItemStack deadTemplate = mock(ItemStack.class), dead = mock(ItemStack.class); + when(api.getCreator().getItemFromPath("m.TOOLS.DEAD_GEIGER_COUNTER")).thenReturn(deadTemplate); + when(deadTemplate.clone()).thenReturn(dead); + ItemStack rewardTemplate = mock(ItemStack.class), reward = mock(ItemStack.class); + when(api.getCreator().getItemFromPath("m.reward")).thenReturn(rewardTemplate); + when(rewardTemplate.clone()).thenReturn(reward); + TierReward tier = new TierReward("rare", 1); + tier.addItem(new ItemReward("m.reward", 12)); + when(config.getTierRewards()).thenReturn(List.of(tier)); + when(inventory.addItem(dead)).thenReturn(new HashMap<>(Map.of(0, dead))); + when(inventory.addItem(reward)).thenReturn(new HashMap<>(Map.of(0, reward))); + place(); + collect(EquipmentSlot.OFF_HAND); + verify(dead).setAmount(1); + verify(reward).setAmount(12); + verify(inventory).setItemInOffHand(null); + verify(world).dropItemNaturally(player.getLocation(), dead); + verify(world).dropItemNaturally(player.getLocation(), reward); + verify(world, times(2)).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + + @Test + void partiallyFilledInventoryDropsOnlyTheReturnedRewardRemainder() { + ItemStack rewardTemplate = mock(ItemStack.class), reward = mock(ItemStack.class); + ItemStack remainder = mock(ItemStack.class); + when(remainder.getAmount()).thenReturn(3); + when(api.getCreator().getItemFromPath("m.reward")).thenReturn(rewardTemplate); + when(rewardTemplate.clone()).thenReturn(reward); + TierReward tier = new TierReward("rare", 1); + tier.addItem(new ItemReward("m.reward", 12)); + when(config.getTierRewards()).thenReturn(List.of(tier)); + when(inventory.addItem(reward)).thenReturn(new HashMap<>(Map.of(0, remainder))); + place(); + collect(EquipmentSlot.HAND); + verify(reward).setAmount(12); + verify(world).dropItemNaturally(player.getLocation(), remainder); + verify(world, times(1)).dropItemNaturally(any(Location.class), any(ItemStack.class)); + verify(remainder, never()).setAmount(anyInt()); + assertEquals(3, remainder.getAmount()); + } }