diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..48d130d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,3 +74,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index 3249fac..8c3409e 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ mvn -B --no-transfer-progress clean verify ``` JUnit 5 tests cover configuration loading, commands, the GUI, plugin hooks, -listeners and activity progress, using proxy and Objenesis fixtures for server -and plugin APIs. CI runs the same command on every push and pull request to `main` and -uploads the Surefire reports; no coverage gate is enforced. The suite does not -start a live Paper server. +listeners and activity progress, using MockBukkit, real file fixtures, and scoped +server/provider boundaries. Verification requires 100% production line coverage +with no excluded classes, plus the JaCoCo execution data and XML report. CI runs +the same command on every push and pull request to `main` and uploads the Surefire +and coverage reports. The suite does not start a live Paper server. The description update tool has its own Python tests, which CI does not run: diff --git a/pom.xml b/pom.xml index c2ff00a..c2ef95e 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,37 @@ 2.0.0 + + + + org.junit + junit-bom + 5.11.4 + pom + import + + + + + + org.junit.platform + junit-platform-launcher + 1.11.4 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + + + org.mockito + mockito-core + 5.20.0 + test + io.papermc.paper @@ -321,6 +351,63 @@ maven-surefire-plugin 3.5.2 + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + coverage-agent + prepare-agent + + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.2 + + + require-coverage-evidence + verify + enforce + + + + + ${project.build.directory}/jacoco.exec + ${project.reporting.outputDirectory}/jacoco/jacoco.xml + + + + + + + diff --git a/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java b/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java index 6c73b41..ee031e6 100644 --- a/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java +++ b/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java @@ -68,7 +68,11 @@ public void onDisable() { if (placeholderHook != null) { try { - placeholderHook.unregister(); + // PlaceholderAPI removes by identifier; only remove the instance we registered. + if (placeholderHook.getPlaceholderAPI().getLocalExpansionManager() + .getExpansion(placeholderHook.getIdentifier()) == placeholderHook) { + placeholderHook.unregister(); + } } catch (Throwable t) { getLogger().warning("Failed to unregister the PlaceholderAPI expansion: " + t.getMessage()); } @@ -170,9 +174,13 @@ private void registerHooks() { } if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { - placeholderHook = new PlaceholderHook(ActivityManager.getInstance()); - placeholderHook.register(); - getLogger().info("Hooked into PlaceholderAPI."); + PlaceholderHook hook = new PlaceholderHook(ActivityManager.getInstance()); + if (hook.register()) { + placeholderHook = hook; + getLogger().info("Hooked into PlaceholderAPI."); + } else { + getLogger().warning("Failed to register the PlaceholderAPI expansion."); + } } } } diff --git a/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java b/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java index dd8da46..1945479 100644 --- a/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java +++ b/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java @@ -79,34 +79,20 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } switch (sub) { - case "reload": + case "reload" -> { manager.reload(); sender.sendMessage(messages().get("admin.reloaded")); rewardStatus(sender); audit(sender, "action=reload result=done"); - return true; - case "rewards": - handleRewards(sender, args); - return true; - case "check": - handleCheck(sender, args); - return true; - case "reset": - handleReset(sender, args); - return true; - case "givereroll": - handleGiveReroll(sender, args); - return true; - case "add": - handleAdd(sender, args); - return true; - case "addpoints": - handleAddPoints(sender, args); - return true; - default: - usage(sender); - return true; - } + } + case "rewards" -> handleRewards(sender, args); + case "check" -> handleCheck(sender, args); + case "reset" -> handleReset(sender, args); + case "givereroll" -> handleGiveReroll(sender, args); + case "add" -> handleAdd(sender, args); + case "addpoints" -> handleAddPoints(sender, args); + } + return true; } private void handleRewards(CommandSender sender, String[] args) { diff --git a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java index 121a49e..071ee0b 100644 --- a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java +++ b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java @@ -2,6 +2,7 @@ import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.Sound; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -1293,7 +1294,19 @@ private String soundKey(String name) { if (name == null || name.isBlank()) { return null; } - return name.trim().toLowerCase(Locale.ROOT).replace('_', '.'); + String value = name.trim(); + if (org.bukkit.NamespacedKey.fromString(value.toLowerCase(Locale.ROOT)) == null) { + log().warning("Invalid sound '" + value + "' - disabling it."); + return null; + } + if (value.indexOf('.') >= 0 || value.indexOf(':') >= 0) { + return value.toLowerCase(Locale.ROOT); + } + try { + return Sound.valueOf(value.toUpperCase(Locale.ROOT)).getKey().getKey(); + } catch (IllegalArgumentException ignored) { + return value.toLowerCase(Locale.ROOT); + } } public Messages messages() { @@ -1414,7 +1427,7 @@ public int nonVoteDailyMax() { } static int nonVoteDailyMax(int dailyMax, int voteShare) { - return dailyMax * (100 - voteShare) / 100; + return (int) ((long) dailyMax * (100 - voteShare) / 100); } int parseVoteShare(ConfigurationSection config) { diff --git a/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java b/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java index 9e7d71e..a4c5238 100644 --- a/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java +++ b/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java @@ -380,12 +380,9 @@ private void reroll(Player player, InventoryClickEvent event) { ActivityConfiguration config = manager.getConfiguration(); Messages messages = config.messages(); - if (config.rerollsPerDay() <= 0) { - player.sendMessage(messages.get("reroll-disabled")); - return; - } - - if (!player.hasPermission("activity.reroll")) { + // Disabled rerolls still report DISABLED before the permission check. + // The manager returns it before reading or changing player data. + if (config.rerollsPerDay() > 0 && !player.hasPermission("activity.reroll")) { player.sendMessage(messages.get("reroll-locked")); return; } diff --git a/src/main/java/net/tfminecraft/activitytf/hooks/PlaceholderHook.java b/src/main/java/net/tfminecraft/activitytf/hooks/PlaceholderHook.java index c0b71dc..90ad485 100644 --- a/src/main/java/net/tfminecraft/activitytf/hooks/PlaceholderHook.java +++ b/src/main/java/net/tfminecraft/activitytf/hooks/PlaceholderHook.java @@ -60,7 +60,7 @@ public String onRequest(OfflinePlayer player, String params) { case "max": return String.valueOf(config.barMax()); case "percent": - return String.valueOf(points * 100 / config.barMax()); + return String.valueOf(points * 100L / config.barMax()); case "claimable": return String.valueOf(currentWeek ? data.claimable(config.milestones()) : 0); case "daily_points": diff --git a/src/main/java/net/tfminecraft/activitytf/hooks/TLibsItems.java b/src/main/java/net/tfminecraft/activitytf/hooks/TLibsItems.java index ae3b3f7..3b6dacc 100644 --- a/src/main/java/net/tfminecraft/activitytf/hooks/TLibsItems.java +++ b/src/main/java/net/tfminecraft/activitytf/hooks/TLibsItems.java @@ -51,6 +51,12 @@ static boolean isUnresolved(Material type) { || type == Material.VOID_AIR || type == Material.DIRT; } + /** Retry unresolved paths after administrators reload item definitions. */ + public static void reset() { + Default.INSTANCE.unresolvedPaths.clear(); + Default.INSTANCE.reportedPaths.clear(); + } + public static ItemStack item(String path) { return Default.INSTANCE.resolve(path); } diff --git a/src/main/java/net/tfminecraft/activitytf/listeners/BattleListener.java b/src/main/java/net/tfminecraft/activitytf/listeners/BattleListener.java index 6d25051..857dc8a 100644 --- a/src/main/java/net/tfminecraft/activitytf/listeners/BattleListener.java +++ b/src/main/java/net/tfminecraft/activitytf/listeners/BattleListener.java @@ -20,10 +20,6 @@ public BattleListener(ActivityManager manager) { @EventHandler(priority = EventPriority.MONITOR) public void onBattleEnded(BattleEndedEvent event) { Set participants = event.getParticipantIds(); - if (participants == null) { - return; - } - for (UUID participant : participants) { if (participant != null) { manager.recordAction(participant, "battle_joined", 1); diff --git a/src/main/java/net/tfminecraft/activitytf/listeners/ProfessionXpListener.java b/src/main/java/net/tfminecraft/activitytf/listeners/ProfessionXpListener.java index 7af40c5..d7a1cb2 100644 --- a/src/main/java/net/tfminecraft/activitytf/listeners/ProfessionXpListener.java +++ b/src/main/java/net/tfminecraft/activitytf/listeners/ProfessionXpListener.java @@ -22,10 +22,6 @@ public ProfessionXpListener(ActivityManager manager) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onExperienceGain(PlayerExperienceGainEvent event) { - if (!event.hasProfession()) { - return; - } - Profession profession = event.getProfession(); if (profession == null) { return; diff --git a/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java b/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java index 45dc6ed..fa28918 100644 --- a/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java +++ b/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java @@ -803,9 +803,6 @@ void runClickCommandsNow(Player player, List commands, String id, Predic } static List runnableClickCommands(ActivityDef def, String name, int max, Logger logger) { - if (def == null) { - return List.of(); - } List runnable = new ArrayList<>(); for (String command : def.clickCommands()) { if (!canRunCommand(command, name)) { @@ -884,6 +881,7 @@ public void reload() { warnedEmptyPools.clear(); reportedItemPaths.clear(); ItemsAdderItems.reset(); + TLibsItems.reset(); reportedBrokenCommands.clear(); } diff --git a/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java b/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java index cfc8082..394d6f6 100644 --- a/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java +++ b/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java @@ -71,6 +71,9 @@ private boolean read() { ConfigurationSection root = yaml.getConfigurationSection("players"); if (root == null) { + if (yaml.contains("players")) { + return quarantine("has a 'players' value that is not a section"); + } Set stray = yaml.getKeys(false); stray.remove("players"); if (!stray.isEmpty()) { diff --git a/src/test/java/net/tfminecraft/activitytf/ActivityAPITest.java b/src/test/java/net/tfminecraft/activitytf/ActivityAPITest.java new file mode 100644 index 0000000..6fb3a7b --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/ActivityAPITest.java @@ -0,0 +1,28 @@ +package net.tfminecraft.activitytf; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.UUID; +import net.tfminecraft.activitytf.managers.ActivityManager; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; + +class ActivityAPITest { + @Test void mainThreadRecordsReachTheActiveManagerAndShutdownCallsAreIgnored() { + ActivityManager manager=mock(ActivityManager.class); UUID player=UUID.randomUUID(); + try(var bukkit=mockStatic(Bukkit.class);var singleton=mockStatic(ActivityManager.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + singleton.when(ActivityManager::getInstance).thenReturn(manager); + ActivityAPI.record(player,"craft",7); verify(manager).recordAction(player,"craft",7); + singleton.when(ActivityManager::getInstance).thenReturn(null); + assertDoesNotThrow(()->ActivityAPI.record(player,"craft",7)); verifyNoMoreInteractions(manager); + } + } + @Test void asynchronousCallersAreRejectedBeforeLookingUpOrChangingPlayerState() { + try(var bukkit=mockStatic(Bukkit.class);var singleton=mockStatic(ActivityManager.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertThrows(IllegalStateException.class,()->ActivityAPI.record(UUID.randomUUID(),"craft",1)); + singleton.verifyNoInteractions(); + } + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/ActivityPluginTest.java b/src/test/java/net/tfminecraft/activitytf/ActivityPluginTest.java new file mode 100644 index 0000000..0f7cac1 --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/ActivityPluginTest.java @@ -0,0 +1,297 @@ +package net.tfminecraft.activitytf; + +import me.clip.placeholderapi.PlaceholderAPI; +import me.clip.placeholderapi.PlaceholderAPIPlugin; +import me.clip.placeholderapi.configuration.PlaceholderAPIConfig; +import me.clip.placeholderapi.events.ExpansionRegisterEvent; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import me.clip.placeholderapi.expansion.manager.LocalExpansionManager; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.exception.UnimplementedOperationException; +import org.objenesis.ObjenesisStd; +import net.tfminecraft.activitytf.commands.ActivityCommand; +import net.tfminecraft.activitytf.gui.ActivityGui; +import net.tfminecraft.activitytf.hooks.PlaceholderHook; +import net.tfminecraft.activitytf.listeners.CraftListener; +import net.tfminecraft.activitytf.listeners.JoinListener; +import net.tfminecraft.activitytf.managers.ActivityManager; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; + +@ExtendWith(ActivityPluginTest.FailUnimplemented.class) +class ActivityPluginTest { + @TempDir + Path directory; + + private Server previousServer; + private Object previousManager; + private Object previousPlaceholderApi; + private ServerMock server; + private ActivityPlugin plugin; + private final List logs = new ArrayList<>(); + + @BeforeEach + void startServer() throws Exception { + previousServer = Bukkit.getServer(); + previousManager = field(ActivityManager.class, "instance").get(null); + field(Bukkit.class, "server").set(null, null); + field(ActivityManager.class, "instance").set(null, null); + server = MockBukkit.mock(); + previousPlaceholderApi = field(PlaceholderAPIPlugin.class, "instance").get(null); + } + + @AfterEach + void stopServer() throws Exception { + try { + MockBukkit.unmock(); + } finally { + field(PlaceholderAPIPlugin.class, "instance").set(null, previousPlaceholderApi); + field(ActivityManager.class, "instance").set(null, previousManager); + field(Bukkit.class, "server").set(null, previousServer); + } + } + + private ActivityPlugin loadPlugin() { + plugin = (ActivityPlugin) server.getPluginManager().loadPlugin(ActivityPlugin.class); + plugin.getLogger().addHandler(new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }); + return plugin; + } + + private void enablePlugin() { + loadPlugin(); + server.getPluginManager().enablePlugin(plugin); + } + + @Test + void startupInstallsCommandsListenersAndTimersAndShutdownPersistsState() throws Exception { + enablePlugin(); + ActivityManager manager = ActivityManager.getInstance(); + assertTrue(plugin.isEnabled()); + assertTrue(manager.getStore().isLoaded()); + assertTrue(Files.isRegularFile(plugin.getDataFolder().toPath().resolve("config.yml"))); + assertTrue(Files.isRegularFile(plugin.getDataFolder().toPath().resolve("messages.yml"))); + assertInstanceOf(ActivityCommand.class, plugin.getCommand("activity").getExecutor()); + assertSame(plugin.getCommand("activity").getExecutor(), plugin.getCommand("activity").getTabCompleter()); + assertEquals(Set.of(JoinListener.class, CraftListener.class, ActivityGui.class), listeners()); + assertTrue(server.dispatchCommand(server.getConsoleSender(), "activity")); + assertNotNull(server.getConsoleSender().nextMessage()); + server.getScheduler().performOneTick(); + assertEquals(Set.of(JoinListener.class, CraftListener.class, ActivityGui.class), listeners()); + UUID uuid = UUID.randomUUID(); + manager.getStore().get(uuid).addPoints(7, manager.getConfiguration().barMax()); + server.getScheduler().performTicks(manager.getConfiguration().saveIntervalMinutes() * 1200L); + server.getScheduler().waitAsyncTasksFinished(); + Path dataFile = plugin.getDataFolder().toPath().resolve("players.yml"); + assertEquals(7, YamlConfiguration.loadConfiguration(dataFile.toFile()).getInt("players." + uuid + ".points")); + manager.getStore().get(uuid).addPoints(1, manager.getConfiguration().barMax()); + + server.getPluginManager().disablePlugin(plugin); + + assertFalse(plugin.isEnabled()); + assertNull(ActivityManager.getInstance()); + assertTrue(listeners().isEmpty()); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(plugin.getDataFolder().toPath().resolve("players.yml").toFile()); + assertEquals(8, saved.getInt("players." + uuid + ".points")); + String savedBytes = Files.readString(dataFile); + server.getScheduler().performTicks(manager.getConfiguration().saveIntervalMinutes() * 1200L); + assertEquals(savedBytes, Files.readString(dataFile)); + plugin.onDisable(); + assertNull(ActivityManager.getInstance()); + assertTrue(logs.contains("activity has been enabled!")); + } + + @Test + void rejectedPlaceholderRegistrationMustNotRemoveAProviderInstalledLater() throws Exception { + LocalExpansionManager expansions = installPlaceholderApi(); + JavaPlugin guard = MockBukkit.createMockPlugin("ExpansionGuard"); + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void rejectActivity(ExpansionRegisterEvent event) { + if (event.getExpansion() instanceof PlaceholderHook) { + event.setCancelled(true); + } + } + }, guard); + enablePlugin(); + server.getScheduler().performOneTick(); + assertNull(expansions.getExpansion("activity")); + PlaceholderExpansion replacement = replacement(); + assertTrue(replacement.register()); + + server.getPluginManager().disablePlugin(plugin); + + assertSame(replacement, expansions.getExpansion("activity"), "shutdown must not unregister a rejected hook's replacement"); + assertFalse(logs.contains("Hooked into PlaceholderAPI."), "a rejected registration must not be reported as successful"); + assertTrue(logs.stream().anyMatch(line -> line.contains("Failed to register the PlaceholderAPI expansion"))); + } + + @Test + void shutdownMustNotUnregisterAProviderThatReplacedTheActivityExpansion() throws Exception { + LocalExpansionManager expansions = installPlaceholderApi(); + enablePlugin(); + server.getScheduler().performOneTick(); + PlaceholderExpansion registered = expansions.getExpansion("activity"); + assertInstanceOf(PlaceholderHook.class, registered); + PlaceholderExpansion replacement = new PlaceholderHook(ActivityManager.getInstance()); + assertEquals(registered, replacement, "matching expansion metadata does not establish instance ownership"); + assertTrue(replacement.register()); + + server.getPluginManager().disablePlugin(plugin); + + assertSame(replacement, expansions.getExpansion("activity")); + } + + @Test + void optionalPluginsRegisterTheirListenersOnTheNextTickAndTheOwnedExpansionIsRemovedAtShutdown() throws Exception { + for (String name : List.of("VotingPlugin", "MusicalInstruments", "geiger_counter", "MMOCore", "MMOItems", + "VFBuilders", "RPCharacters", "InteractibleFurniture", "SimpleFactions", "AdvancedCrafting", "Archaeo", + "MarketBlock", "Games", "Cooking", "BreweryX")) { + MockBukkit.createMockPlugin(name); + } + LocalExpansionManager expansions = installPlaceholderApi(); + enablePlugin(); + assertEquals(Set.of(JoinListener.class, CraftListener.class, ActivityGui.class), listeners()); + assertNull(expansions.getExpansion("activity")); + + server.getScheduler().performOneTick(); + + assertEquals(Set.of("JoinListener", "CraftListener", "ActivityGui", "VoteListener", "InstrumentListener", + "GeigerListener", "ProfessionXpListener", "MmoItemsStationListener", "VehicleBuildListener", + "CharacterChatListener", "InjuryListener", "ProfessionUpgradeListener", "FurnitureListener", "BattleListener", + "AdvancedCraftListener", "ArchaeologyListener", "MarketSaleListener", "CasinoWinListener", "DishCookedListener", + "BreweryListener"), listeners().stream().map(Class::getSimpleName).collect(Collectors.toSet())); + assertInstanceOf(PlaceholderHook.class, expansions.getExpansion("activity")); + var player = server.addPlayer("Steve"); + ActivityManager.getInstance().getStore().get(player.getUniqueId()).addPoints(5, 50); + assertEquals("5", PlaceholderAPI.setPlaceholders(player, "%activity_points%")); + assertEquals(16, logs.stream().filter(line -> line.startsWith("Hooked into ")).count()); + + server.getPluginManager().disablePlugin(plugin); + + assertNull(expansions.getExpansion("activity")); + assertEquals("%activity_points%", PlaceholderAPI.setPlaceholders(player, "%activity_points%")); + assertTrue(listeners().isEmpty()); + } + + @Test + void itemPluginsEnabledLaterCauseOneDeferredConfigurationReload() { + enablePlugin(); + ActivityManager manager = ActivityManager.getInstance(); + assertFalse(manager.getConfiguration().itemsAdderUsable()); + plugin.getConfig().set("bar.max", 31); + plugin.saveConfig(); + MockBukkit.createMockPlugin("ItemsAdder"); + assertEquals(50, manager.getConfiguration().barMax()); + + server.getScheduler().performOneTick(); + + assertTrue(manager.getConfiguration().itemsAdderUsable()); + assertEquals(31, manager.getConfiguration().barMax()); + assertFalse(manager.getConfiguration().itemPluginsChanged()); + server.getScheduler().performOneTick(); + assertEquals(1, logs.stream().filter(line -> line.contains("An item plugin finished enabling after activity")).count()); + } + + @Test + void placeholderApiFailureDuringShutdownIsLoggedOnceAndDoesNotPreventStoreSave() throws Exception { + LocalExpansionManager expansions = installPlaceholderApi(); + enablePlugin(); + server.getScheduler().performOneTick(); + assertNotNull(expansions.getExpansion("activity")); + UUID uuid = UUID.randomUUID(); + ActivityManager.getInstance().getStore().get(uuid).addPoints(4, 50); + + try (var api = mockStatic(PlaceholderAPIPlugin.class)) { + api.when(PlaceholderAPIPlugin::getInstance).thenThrow(new IllegalStateException("provider unavailable")); + server.getPluginManager().disablePlugin(plugin); + plugin.onDisable(); + api.verify(PlaceholderAPIPlugin::getInstance); + } + + assertNull(ActivityManager.getInstance()); + assertEquals(1, logs.stream().filter(line -> line.equals( + "Failed to unregister the PlaceholderAPI expansion: provider unavailable")).count()); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(plugin.getDataFolder().toPath().resolve("players.yml").toFile()); + assertEquals(4, saved.getInt("players." + uuid + ".points")); + } + + private LocalExpansionManager installPlaceholderApi() throws Exception { + MockBukkit.createMockPlugin("PlaceholderAPI"); + PlaceholderAPIPlugin api = new ObjenesisStd().newInstance(PlaceholderAPIPlugin.class); + field(JavaPlugin.class, "dataFolder").set(api, directory.toFile()); + field(JavaPlugin.class, "logger").set(api, Logger.getAnonymousLogger()); + field(JavaPlugin.class, "newConfig").set(api, new YamlConfiguration()); + field(PlaceholderAPIPlugin.class, "config").set(api, new PlaceholderAPIConfig(api)); + LocalExpansionManager expansions = new LocalExpansionManager(api); + field(PlaceholderAPIPlugin.class, "localExpansionManager").set(api, expansions); + field(PlaceholderAPIPlugin.class, "instance").set(null, api); + return expansions; + } + + private PlaceholderExpansion replacement() { + return new PlaceholderExpansion() { + @Override public String getIdentifier() { return "activity"; } + @Override public String getAuthor() { return "Other provider"; } + @Override public String getVersion() { return "1"; } + }; + } + + private Set> listeners() { + return HandlerList.getRegisteredListeners(plugin).stream().map(listener -> listener.getListener().getClass()) + .collect(Collectors.toSet()); + } + + private static Field field(Class owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + static final class FailUnimplemented implements TestExecutionExceptionHandler, LifecycleMethodExecutionExceptionHandler { + @Override public void handleTestExecutionException(ExtensionContext context, Throwable thrown) throws Throwable { throw failed(thrown); } + @Override public void handleBeforeEachMethodExecutionException(ExtensionContext context, Throwable thrown) throws Throwable { throw failed(thrown); } + private static Throwable failed(Throwable thrown) { + return thrown instanceof UnimplementedOperationException + ? new AssertionError("MockBukkit does not implement a lifecycle operation required by this test", thrown) : thrown; + } + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/commands/ActivityCommandTest.java b/src/test/java/net/tfminecraft/activitytf/commands/ActivityCommandTest.java index 0643c9a..60536cc 100644 --- a/src/test/java/net/tfminecraft/activitytf/commands/ActivityCommandTest.java +++ b/src/test/java/net/tfminecraft/activitytf/commands/ActivityCommandTest.java @@ -1,10 +1,19 @@ package net.tfminecraft.activitytf.commands; import org.bukkit.Material; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.PluginManager; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import net.tfminecraft.activitytf.config.ActivityConfiguration; +import net.tfminecraft.activitytf.models.PlayerData; import net.tfminecraft.activitytf.commands.ActivityCommand.Outcome; import net.tfminecraft.activitytf.gui.ActivityGui; import net.tfminecraft.activitytf.managers.ActivityManager; @@ -13,6 +22,12 @@ import net.tfminecraft.activitytf.models.RewardEntry; import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -20,6 +35,11 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -286,4 +306,308 @@ public Inventory build(Player who) { assertTrue(chat.stream().anyMatch(line -> line.contains("could not be handed over")), chat.toString()); } + + @TempDir + Path directory; + + @Test + void menuAccessRejectsConsoleAndPlayersWithoutUsePermission() { + ActivityManager manager = manager(); + TestManagers.messages(manager); + Sender console = new Sender(Set.of("activity.admin")); + assertTrue(command(manager).onCommand(console.bukkit, null, "activity", new String[0])); + assertEquals(List.of(manager.getConfiguration().messages().get("admin.players-only")), console.sent); + List chat = new ArrayList<>(); + Player player = TestManagers.player(UUID.randomUUID(), Set.of(), chat); + assertTrue(command(manager).onCommand(player, null, "activity", new String[0])); + assertEquals(List.of(manager.getConfiguration().messages().get("admin.no-permission")), chat); + } + + @Test + void rewardsBeforeConfigurationInitializationHasNoLockToReport() { + ActivityManager manager = manager(); + TestManagers.messages(manager); + Sender admin = new Sender(Set.of("activity.admin")); + assertTrue(command(manager).onCommand(admin.bukkit, null, "activity", new String[] {"rewards"})); + assertTrue(admin.sent.isEmpty()); + } + + @Test + void reloadUsesDiskConfigurationButKeepsRewardsLockedUntilAnExplicitApply() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + f.settings.set("bar.max", 40); + f.settings.set("rewards.pool", List.of(Map.of("display", "New reward", "commands", List.of("say new")))); + f.saveSettings(); + Sender admin = f.admin(); + + f.run(admin, "ReLoAd"); + + assertEquals(40, f.manager.getConfiguration().barMax()); + assertEquals("Original reward", f.manager.getConfiguration().rewardPool().getFirst().display()); + assertTrue(f.manager.getConfiguration().rewardsPending()); + assertEquals(f.message("admin.reloaded"), admin.sent.getFirst()); + assertTrue(admin.plain().contains("rewards are locked")); + assertTrue(admin.plain().contains("different rewards")); + assertEquals(List.of("ACTIVITY-AUDIT sender=\"Operator\" action=reload result=done"), f.audit()); + admin.sent.clear(); + f.logs.clear(); + + f.run(admin, "rewards", "ApPlY"); + + ActivityConfiguration config = f.manager.getConfiguration(); + assertEquals("New reward", config.rewardPool().getFirst().display()); + assertFalse(config.rewardsPending()); + assertEquals(List.of(config.messages().get("admin.rewards-applied", "%week%", config.rewardWeek())), + admin.sent); + assertTrue(Files.readString(f.rewardLock()).contains("New reward")); + assertEquals(List.of("ACTIVITY-AUDIT sender=\"Operator\" action=rewards-apply week=" + + config.rewardWeek() + " result=done"), f.audit()); + } + } + + @Test + void applyingRewardsReportsAnUnwritableLockAndKeepsThePreviouslyLockedRewards() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + String original = Files.readString(f.rewardLock()); + f.settings.set("rewards.pool", List.of(Map.of("display", "Uncommitted", "commands", List.of("say new")))); + f.saveSettings(); + org.junit.jupiter.api.Assumptions.assumeTrue(Files.getFileStore(f.rewardLock()).supportsFileAttributeView("posix")); + Set permissions = Files.getPosixFilePermissions(f.rewardLock()); + Sender admin = f.admin(); + try { + Files.setPosixFilePermissions(f.rewardLock(), Set.of(PosixFilePermission.OWNER_READ)); + org.junit.jupiter.api.Assumptions.assumeFalse(Files.isWritable(f.rewardLock())); + + f.run(admin, "rewards", "apply"); + + } finally { + Files.setPosixFilePermissions(f.rewardLock(), permissions); + } + assertEquals(List.of(f.message("admin.rewards-apply-failed")), admin.sent); + assertEquals("Original reward", f.manager.getConfiguration().rewardPool().getFirst().display()); + assertTrue(f.manager.getConfiguration().rewardsPending()); + assertEquals(original, Files.readString(f.rewardLock())); + assertEquals(List.of("ACTIVITY-AUDIT sender=\"Operator\" action=rewards-apply week=" + + f.manager.getConfiguration().rewardWeek() + " result=save-failed"), f.audit()); + } + } + + @Test + void playerResolutionUsesExactOnlineNamesAndOnlyPreviouslyPlayedCachedPlayers() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + Player online = f.online("Steve"); + OfflinePlayer cached = f.cached("Alex", true); + f.cached("NeverJoined", false); + Sender admin = f.admin(); + + f.manager.getStore().get(online.getUniqueId()).addPoints(3, 50); + f.cached("Steve", false); + f.run(admin, "check", "Steve"); + assertEquals(f.manager.getConfiguration().messages().get("admin.check-header", "%player%", "Steve"), + admin.sent.getFirst(), "an exact online player must take precedence over the offline cache"); + admin.sent.clear(); + f.run(admin, "check", "Alex"); + assertEquals(List.of(f.manager.getConfiguration().messages().get("admin.check-no-data", "%player%", "Alex")), + admin.sent); + admin.sent.clear(); + f.run(admin, "check", "NeverJoined"); + f.run(admin, "check", "Unknown"); + + assertEquals(List.of(f.manager.getConfiguration().messages().get("admin.unknown-player", "%player%", "NeverJoined"), + f.manager.getConfiguration().messages().get("admin.unknown-player", "%player%", "Unknown")), admin.sent); + assertNull(f.manager.getStore().peek(cached.getUniqueId())); + } + } + + @Test + void resetCanUseACachedPlayerWhoseNameIsMissingAndRejectsMissingArguments() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + OfflinePlayer target = f.cached("TypedName", true); + when(target.getName()).thenReturn(null); + PlayerData data = f.manager.getStore().get(target.getUniqueId()); + data.addPoints(8, 50); + Sender admin = f.admin(); + + f.run(admin, "reset", "TypedName"); + + assertEquals(0, data.points()); + assertEquals(List.of(f.manager.getConfiguration().messages().get("admin.reset-done", "%player%", "TypedName")), + admin.sent); + assertTrue(f.audit().getFirst().contains("target=\"TypedName\" uuid=" + target.getUniqueId())); + admin.sent.clear(); + f.logs.clear(); + f.run(admin, "reset"); + assertEquals(List.of(f.message("admin.usage"), f.message("admin.usage-addpoints")), admin.sent); + assertEquals(List.of("ACTIVITY-AUDIT sender=\"Operator\" action=reset result=usage"), f.audit()); + } + } + + @Test + void addRejectsMalformedArgumentsAndInvalidCountsWithoutCreatingPlayerData() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + Player target = f.online("Steve"); + for (String[] args : List.of(new String[] {"add"}, new String[] {"add", "Steve", "vote"}, + new String[] {"add", "Steve", "vote", "1", "--FORCE"})) { + Sender admin = f.admin(); + f.logs.clear(); + f.run(admin, args); + assertEquals(List.of(f.message("admin.usage"), f.message("admin.usage-addpoints")), admin.sent); + assertTrue(f.audit().getFirst().endsWith("action=add result=usage")); + } + for (String value : List.of("many", "2147483648", "0", "-1", "1000001")) { + Sender admin = f.admin(); + f.logs.clear(); + f.run(admin, "add", "Steve", "vote", value); + boolean number = value.equals("many") || value.equals("2147483648"); + assertEquals(List.of(number + ? f.manager.getConfiguration().messages().get("admin.invalid-number", "%value%", value) + : f.manager.getConfiguration().messages().get("admin.out-of-range", "%max%", 1_000_000)), admin.sent); + assertTrue(f.audit().getFirst().endsWith(number ? "result=invalid-number" : "result=out-of-range")); + assertNull(f.manager.getStore().peek(target.getUniqueId())); + } + } + } + + @Test + void addReportsUnknownActivitiesHiddenTasksAndTheReservedVoteBudget() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + Player target = f.online("Steve"); + UUID uuid = target.getUniqueId(); + Sender admin = f.admin(); + f.run(admin, "add", "Steve", "gone", "1"); + assertEquals(List.of(f.manager.getConfiguration().messages().get("admin.unknown-activity", "%activity%", "gone")), admin.sent); + admin.sent.clear(); + f.run(admin, "add", "Steve", "quest", "1"); + assertEquals(List.of(f.manager.getConfiguration().messages().get("admin.add-not-a-task", + "%activity%", "quest", "%player%", "Steve")), admin.sent); + assertNull(f.manager.getStore().peek(uuid)); + f.manager.reveal(uuid, f.manager.tasks(uuid).tasks().indexOf("quest")); + f.run(admin, "add", "Steve", "quest", "5"); + admin.sent.clear(); + f.logs.clear(); + + f.run(admin, "add", "Steve", "quest", "1"); + + assertEquals(5, f.manager.getStore().peek(uuid).points()); + assertEquals(List.of(f.manager.getConfiguration().messages().get("admin.add-capped-vote-share", + "%count%", 1, "%activity%", "quest", "%player%", "Steve", "%points%", 0, "%max%", 50)), admin.sent); + assertTrue(f.audit().getFirst().endsWith("result=CAPPED_VOTE_SHARE")); + } + } + + @Test + void completionsUseOnlineNamesLoadedActivitiesAndExactForceFlagWithoutMutatingData() throws Exception { + try (CommandFixture f = new CommandFixture(directory)) { + Player steve = f.online("Steve"); + Player alex = f.online("Alex"); + f.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(steve, alex)); + Sender admin = f.admin(); + assertEquals(List.of("Steve"), f.complete(admin, "reset", "sT")); + assertEquals(List.of("Steve", "Alex"), f.complete(admin, "check", "")); + assertEquals(List.of("quest"), f.complete(admin, "ADD", "Steve", "Q")); + assertEquals(List.of("vote", "quest"), f.complete(admin, "add", "Steve", "")); + assertEquals(List.of("--force"), f.complete(admin, "add", "Steve", "vote", "1", "--F")); + assertEquals(List.of(), f.complete(admin, "add", "Steve", "vote", "1", "typo")); + assertEquals(List.of(), f.complete(admin, "add", "Steve", "vote", "")); + assertEquals(List.of(), f.complete(admin, "unknown", "")); + assertEquals(List.of(), f.complete(admin, "rewards", "apply", "")); + Sender checker = new Sender(Set.of("activity.check")); + assertEquals(List.of("check"), f.complete(checker, "C")); + assertEquals(List.of(), f.complete(checker, "add", "Steve", "")); + assertEquals(List.of(), f.complete(new Sender(Set.of()), "")); + assertNull(f.manager.getStore().peek(steve.getUniqueId())); + assertTrue(f.audit().isEmpty()); + } + } + + private static final class Sender { + final CommandSender bukkit = mock(CommandSender.class); + final List sent = new ArrayList<>(); + + Sender(Set permissions) { + when(bukkit.getName()).thenReturn("Operator"); + when(bukkit.hasPermission(anyString())).thenAnswer(call -> permissions.contains(call.getArgument(0))); + doAnswer(call -> { sent.add(call.getArgument(0)); return null; }).when(bukkit).sendMessage(anyString()); + } + + String plain() { return net.md_5.bungee.api.ChatColor.stripColor(String.join("\n", sent)); } + } + + private static final class CommandFixture implements AutoCloseable { + final Path folder; + final YamlConfiguration settings = new YamlConfiguration(); + final List logs = new ArrayList<>(); + final MockedStatic bukkit; + final ActivityManager manager; + final ActivityCommand command; + + CommandFixture(Path folder) throws Exception { + this.folder = folder; + Files.copy(Path.of("src/main/resources/messages.yml"), folder.resolve("messages.yml")); + settings.set("bar.max", 50); + settings.set("bar.daily-max", 10); + settings.set("bar.milestones", List.of(10, 20)); + settings.set("bar.vote-share", 50); + settings.set("activities.vote.points", 1); + settings.set("activities.vote.daily-cap", 5); + settings.set("activities.quest.points", 1); + settings.set("rewards.pool", List.of(Map.of("display", "Original reward", "commands", List.of("say original")))); + saveSettings(); + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getDataFolder()).thenReturn(folder.toFile()); + when(plugin.getConfig()).thenAnswer(call -> YamlConfiguration.loadConfiguration(folder.resolve("config.yml").toFile())); + when(plugin.getResource("messages.yml")).thenAnswer(call -> Files.newInputStream(Path.of("src/main/resources/messages.yml"))); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }); + when(plugin.getLogger()).thenReturn(logger); + bukkit = mockStatic(Bukkit.class); + try { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + var constructor = ActivityManager.class.getDeclaredConstructor(JavaPlugin.class); + constructor.setAccessible(true); + manager = constructor.newInstance(plugin); + manager.getConfiguration().load(); + manager.getStore().load(); + command = new ActivityCommand(manager, null); + logs.clear(); + } catch (Exception | Error failure) { + bukkit.close(); + throw failure; + } + } + + void saveSettings() throws Exception { settings.save(folder.resolve("config.yml").toFile()); } + Path rewardLock() { return folder.resolve(ActivityConfiguration.REWARD_LOCK_FILE); } + Sender admin() { return new Sender(Set.of("activity.admin", "activity.check")); } + String message(String key) { return manager.getConfiguration().messages().get(key); } + List audit() { return logs.stream().filter(line -> line.startsWith("ACTIVITY-AUDIT ")).toList(); } + void run(Sender sender, String... args) { assertTrue(command.onCommand(sender.bukkit, null, "activity", args)); } + List complete(Sender sender, String... args) { return command.onTabComplete(sender.bukkit, null, "activity", args); } + + Player online(String name) { + Player player = mock(Player.class); + UUID uuid = UUID.randomUUID(); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(uuid); + bukkit.when(() -> Bukkit.getPlayerExact(name)).thenReturn(player); + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + return player; + } + + OfflinePlayer cached(String name, boolean played) { + OfflinePlayer player = mock(OfflinePlayer.class); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.hasPlayedBefore()).thenReturn(played); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(name)).thenReturn(player); + return player; + } + + @Override public void close() { bukkit.close(); } + } } diff --git a/src/test/java/net/tfminecraft/activitytf/config/ActivityConfigurationTest.java b/src/test/java/net/tfminecraft/activitytf/config/ActivityConfigurationTest.java index f35d2dd..a680cd9 100644 --- a/src/test/java/net/tfminecraft/activitytf/config/ActivityConfigurationTest.java +++ b/src/test/java/net/tfminecraft/activitytf/config/ActivityConfigurationTest.java @@ -1,21 +1,46 @@ package net.tfminecraft.activitytf.config; import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.Bukkit; +import org.bukkit.UnsafeValues; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; - +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemChecker; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.DayOfWeek; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Predicate; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; class ActivityConfigurationTest { @@ -318,4 +343,457 @@ void anUnsetRerollKnobFallsBackToOne() { assertEquals(1, ActivityConfiguration.parseRerollsPerDay(new YamlConfiguration())); assertEquals(1, ActivityConfiguration.parseRerollMaxPoints(new YamlConfiguration())); } + + @Nested + class FileLoading { + @TempDir Path directory; + + final YamlConfiguration settings = new YamlConfiguration(); + final Set enabledPlugins = new HashSet<>(); + final List logs = new ArrayList<>(); + MockedStatic bukkit; + ActivityConfiguration config; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(directory.resolve(Messages.FILE), "test: original\n"); + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getConfig()).thenAnswer(call -> + YamlConfiguration.loadConfiguration(directory.resolve("config.yml").toFile())); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }); + when(plugin.getLogger()).thenReturn(logger); + PluginManager plugins = mock(PluginManager.class); + when(plugins.isPluginEnabled(anyString())).thenAnswer(call -> + enabledPlugins.contains(call.getArgument(0))); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getLogger).thenReturn(logger); + bukkit.when(Bukkit::getUnsafe).thenReturn(mock(UnsafeValues.class)); + config = new ActivityConfiguration(plugin); + + settings.set("bar.vote-share", 0); + settings.set("bar.milestones", List.of(10, 20)); + settings.set("rewards.pool", List.of(Map.of("display", "Original reward", + "commands", List.of("say original")))); + activity("playtime"); + } + + @AfterEach + void tearDown() { + if (bukkit != null) bukkit.close(); + } + + void activity(String id) { + settings.set("activities." + id + ".points", 1); + } + + void load() throws Exception { + settings.save(directory.resolve("config.yml").toFile()); + config.load(); + } + + void warned(String fragment) { + assertTrue(logs.stream().anyMatch(line -> line.contains(fragment)), logs.toString()); + } + + ItemStack item(Material material) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(material); + return item; + } + + @Test + void validFileLoadsPublicSettingsAndMessages() throws Exception { + settings.set("reset.day", " wednesday "); + settings.set("reset.hour", 17); + settings.set("gui.title", "&6Activity board"); + settings.set("bar.max", 60); + settings.set("bar.daily-max", 12); + settings.set("bar.length", 30); + settings.set("playtime.afk-minutes", 8); + settings.set("save-interval-minutes", 3); + settings.set(ActivityConfiguration.REROLLS_PER_DAY_PATH, 2); + settings.set(ActivityConfiguration.REROLL_MAX_POINTS_PATH, 7); + settings.set("sounds.goal-complete", " ENTITY_PLAYER_LEVELUP "); + settings.set("sounds.bar-complete", "block.note_block.bit"); + settings.set("activities.playtime.display", "Stay awhile"); + settings.set("activities.playtime.every", 5); + settings.set("activities.playtime.daily-cap", 4); + settings.set("activities.playtime.material", "v.clock"); + settings.set("activities.playtime.daily-guaranteed", true); + + load(); + + assertEquals(DayOfWeek.WEDNESDAY, config.resetDay()); + assertEquals(17, config.resetHour()); + assertEquals("&6Activity board", config.guiTitle()); + assertEquals(60, config.barMax()); + assertEquals(12, config.dailyMax()); + assertEquals(12, config.nonVoteDailyMax()); + assertEquals(30, config.barLength()); + assertEquals(8, config.afkMinutes()); + assertEquals(3, config.saveIntervalMinutes()); + assertEquals(2, config.rerollsPerDay()); + assertEquals(7, config.rerollMaxPoints()); + assertEquals("entity.player.levelup", config.goalCompleteSound()); + assertEquals("block.note_block.bit", config.barCompleteSound()); + assertEquals("original", config.messages().get("test")); + assertEquals(Material.CLOCK, config.activity("playtime").icon()); + assertEquals("Stay awhile", config.activity("playtime").display()); + assertEquals(5, config.activity("playtime").every()); + assertEquals(4, config.activity("playtime").capPoints()); + assertEquals(List.of("playtime"), config.guaranteed()); + assertEquals("Original reward", config.rewardPool().getFirst().display()); + assertTrue(Files.isRegularFile(directory.resolve(ActivityConfiguration.REWARD_LOCK_FILE))); + assertFalse(config.rewardsPending()); + assertTrue(logs.stream().noneMatch(line -> line.contains(" - ")), logs.toString()); + } + + @Test + void malformedEntriesAreReportedWhileUsableActivitiesSurvive() throws Exception { + settings.set("reset.day", "someday"); + settings.set("reset.hour", 30); + settings.set("bar.length", 101); + settings.set("activities.scalar", "not a section"); + settings.set("activities.fraction.points", 1.5); + settings.set("activities.overflow.points", 2_147_483_648L); + settings.set("activities.playtime.every", "many"); + settings.set("activities.playtime.daily-cap", 3.5); + settings.set("activities.playtime.material", "not_a_material"); + activity("malformed_path"); + settings.set("activities.malformed_path.material", "m.material"); + activity("unsupported"); + settings.set("activities.unsupported.craft", "other.diamond"); + activity("air"); + settings.set("activities.air.craft", "AIR"); + activity("water"); + settings.set("activities.water.craft", "WATER"); + + load(); + + assertEquals(DayOfWeek.MONDAY, config.resetDay()); + assertEquals(23, config.resetHour()); + assertEquals(100, config.barLength()); + assertNull(config.activity("scalar")); + assertNull(config.activity("fraction")); + assertNull(config.activity("overflow")); + assertEquals(1, config.activity("playtime").every()); + assertEquals(0, config.activity("playtime").dailyCap()); + assertEquals(Material.PAPER, config.activity("playtime").icon()); + assertNull(config.activity("malformed_path").iconPath()); + assertNull(config.craftActivity(item(Material.WATER))); + warned("Unknown reset.day 'someday'"); + warned("activities.fraction.points is not a whole number"); + warned("activities.overflow.points is not a whole number"); + warned("activities.playtime.every is not a whole number"); + warned("activities.playtime.daily-cap is not a whole number"); + warned("Unknown material 'not_a_material'"); + warned("Malformed item path 'm.material'"); + warned("Unsupported item path 'other.diamond'"); + warned("'AIR' at activities.air.craft is not an item"); + warned("'WATER' at activities.water.craft is not an item"); + } + + @Test + void numericBoundsAndBlankSoundsUseSafeValues() throws Exception { + settings.set("reset.hour", -2); + settings.set("bar.max", 0); + settings.set("bar.daily-max", -1); + settings.set("bar.length", 0); + settings.set("playtime.afk-minutes", -4); + settings.set("save-interval-minutes", 0); + settings.set("sounds.goal-complete", " "); + settings.set("click-command-cooldown-millis", 1); + settings.set("click-commands-per-second", 1000); + settings.set("click-commands-per-click", "unlimited"); + + load(); + + assertEquals(0, config.resetHour()); + assertEquals(1, config.barMax()); + assertEquals(1, config.dailyMax()); + assertEquals(1, config.barLength()); + assertEquals(0, config.afkMinutes()); + assertEquals(1, config.saveIntervalMinutes()); + assertNull(config.goalCompleteSound()); + assertNull(config.barCompleteSound()); + assertEquals(50, config.clickCommandCooldownMillis()); + assertEquals(200, config.clickCommandsPerSecond()); + assertEquals(5, config.clickCommandsPerClick()); + assertEquals(List.of(1), config.milestones()); + warned("bar.length"); + warned("click-commands-per-click"); + } + + @Test + void defaultBukkitSoundNamesAndCustomResourceKeysStayPlayable() throws Exception { + YamlConfiguration bundled = YamlConfiguration.loadConfiguration( + Path.of("src/main/resources/config.yml").toFile()); + settings.set("sounds.goal-complete", bundled.getString("sounds.goal-complete")); + settings.set("sounds.bar-complete", bundled.getString("sounds.bar-complete")); + load(); + assertEquals(Sound.ENTITY_EXPERIENCE_ORB_PICKUP.getKey().getKey(), config.goalCompleteSound()); + assertEquals(Sound.UI_TOAST_CHALLENGE_COMPLETE.getKey().getKey(), config.barCompleteSound()); + + settings.set("sounds.goal-complete", " custom:theme_win "); + settings.set("sounds.bar-complete", "block.note_block.bit"); + load(); + assertEquals("custom:theme_win", config.goalCompleteSound()); + assertEquals("block.note_block.bit", config.barCompleteSound()); + + settings.set("sounds.goal-complete", "custom_theme_win"); + load(); + assertEquals("custom_theme_win", config.goalCompleteSound()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"level up", "!", "é", "custom:bad key"}) + void malformedSoundNamesDoNotAbortTheRestOfTheReload(String value) throws Exception { + // Paper converts the key in UnsafeValues.get, which rejects a null NamespacedKey. + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.get(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.isNull(org.bukkit.NamespacedKey.class))) + .thenThrow(new NullPointerException("Paper cannot convert a null sound key")); + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + settings.set("sounds.goal-complete", value); + settings.set("sounds.bar-complete", value); + settings.set("bar.max", 123); + load(); + assertNull(config.goalCompleteSound()); + assertNull(config.barCompleteSound()); + assertEquals(123, config.barMax()); + warned("Invalid sound"); + } + + @Test + void duplicateFeedMappingsHaveAnExplicitLastOwnerAndResetActionAmounts() throws Exception { + activity("miner_old"); + settings.set("activities.miner_old.profession", "Mining Skill"); + activity("miner_new"); + settings.set("activities.miner_new.profession", "MINING_SKILL"); + settings.set("activities.miner_new.daily-cap", 5); + activity("forge_old"); + settings.set("activities.forge_old.station", "forge/sword"); + activity("forge_new"); + settings.set("activities.forge_new.station", "forge"); + settings.set("activities.forge_new.station-actions.forge/sword", 4); + activity("blocks"); + settings.set("activities.blocks.craft", "v.diamond_block"); + + load(); + + assertEquals("miner_new", config.professionActivity(" mining skill ")); + assertEquals(new ActivityConfiguration.StationActionCredit("forge_new", 4), + config.stationAction(" FORGE ", " Sword ").orElseThrow()); + assertEquals(new ActivityConfiguration.StationActionCredit("forge_new", 1), + config.stationAction("forge", "unlisted").orElseThrow()); + assertEquals("blocks", config.craftActivity(item(Material.DIAMOND_BLOCK))); + warned("both track profession 'mining-skill' - only 'miner_new'"); + warned("both track station 'forge/sword' - only 'forge_new'"); + + settings.set("activities.forge_new", null); + load(); + assertEquals(new ActivityConfiguration.StationActionCredit("forge_old", 1), + config.stationAction("forge", "sword").orElseThrow()); + assertTrue(config.stationAction("forge", "unlisted").isEmpty()); + } + + @Test + void dependencyChangesAreDetectedAndReloadRestoresPluginPaths() throws Exception { + activity("steel"); + settings.set("activities.steel.material", "m.material.steel"); + settings.set("activities.steel.craft", "m.material.steel"); + activity("custom"); + settings.set("activities.custom.material", "ia.example:icon"); + + load(); + + assertFalse(config.itemPathsUsable()); + assertFalse(config.itemsAdderUsable()); + assertFalse(config.itemPluginsChanged()); + assertNull(config.activity("steel").iconPath()); + assertEquals("ia.example:icon", config.activity("custom").iconPath()); + assertNull(config.craftActivity(item(Material.IRON_INGOT))); + warned("TLibs, MMOItems, MythicLib are not enabled"); + warned("ItemsAdder is not enabled"); + + enabledPlugins.addAll(Set.of("TLibs", "MMOItems", "MythicLib")); + assertTrue(config.itemPluginsChanged()); + load(); + assertEquals("m.material.steel", config.activity("steel").iconPath()); + assertTrue(config.itemPathsUsable()); + assertFalse(config.itemPluginsChanged()); + + enabledPlugins.add("ItemsAdder"); + assertTrue(config.itemPluginsChanged()); + load(); + assertTrue(config.itemsAdderUsable()); + assertFalse(config.itemPluginsChanged()); + enabledPlugins.remove("MMOItems"); + assertTrue(config.itemPluginsChanged()); + } + + @Test + void pluginCraftsMatchTheFirstConfiguredPathThroughTheItemApi() throws Exception { + enabledPlugins.addAll(Set.of("TLibs", "MMOItems", "MythicLib")); + activity("steel"); + settings.set("activities.steel.craft", "m.material.steel"); + activity("steel_later"); + settings.set("activities.steel_later.craft", "m.material.steel_alt"); + activity("vanilla"); + settings.set("activities.vanilla.craft", "IRON_INGOT"); + load(); + ItemStack sword = item(Material.IRON_SWORD); + ItemStack vanilla = item(Material.IRON_INGOT); + ItemStack unmatched = item(Material.DIRT); + ItemAPI api = mock(ItemAPI.class); + ItemChecker checker = mock(ItemChecker.class); + when(api.getChecker()).thenReturn(checker); + when(checker.checkItemWithPath(sword, "m.material.steel")).thenReturn(true); + when(checker.checkItemWithPath(sword, "m.material.steel_alt")).thenReturn(true); + when(checker.checkItemWithPath(vanilla, "m.material.steel")).thenReturn(true); + + try (MockedStatic tlibs = mockStatic(TLibs.class)) { + tlibs.when(TLibs::getItemAPI).thenReturn(api); + assertEquals("steel", config.craftActivity(sword)); + assertEquals("vanilla", config.craftActivity(vanilla)); + assertNull(config.craftActivity(unmatched)); + } + } + + @Test + void reloadReplacesActivityIndexesAndMessagesButKeepsRewardsForTheWeek() throws Exception { + settings.set("activities.playtime.craft", "DIAMOND_BLOCK"); + activity("mining"); + settings.set("activities.mining.profession", "mining"); + activity("forge"); + settings.set("activities.forge.station", "forge"); + load(); + String week = config.rewardWeek(); + + settings.set("activities", null); + activity("quest"); + settings.set("rewards.pool", List.of(Map.of("display", "Next reward", + "commands", List.of("say next")))); + Files.writeString(directory.resolve(Messages.FILE), "test: reloaded\n"); + load(); + + assertEquals(List.of("quest"), config.activities().stream().map(def -> def.id()).toList()); + assertNull(config.craftActivity(item(Material.DIAMOND_BLOCK))); + assertNull(config.professionActivity("mining")); + assertTrue(config.stationActivity("forge", null).isEmpty()); + assertEquals("reloaded", config.messages().get("test")); + assertEquals("Original reward", config.rewardPool().getFirst().display()); + assertEquals(week, config.rewardWeek()); + assertTrue(config.rewardsPending()); + + assertTrue(config.applyRewardsNow()); + assertEquals("Next reward", config.rewardPool().getFirst().display()); + assertFalse(config.rewardsPending()); + load(); + assertEquals("Next reward", config.rewardPool().getFirst().display()); + assertFalse(config.rewardsPending()); + } + + @Test + void removingAllActivitiesClearsEveryPreviouslyLoadedIndex() throws Exception { + settings.set("activities.playtime.craft", "PAPER"); + settings.set("activities.playtime.daily-guaranteed", true); + activity("mining"); + settings.set("activities.mining.profession", "mining"); + activity("forge"); + settings.set("activities.forge.station", "forge"); + load(); + settings.set("activities", null); + settings.set("playtime.afk-minutes", 5); + + load(); + + assertTrue(config.activities().isEmpty()); + assertTrue(config.guaranteed().isEmpty()); + assertNull(config.craftActivity(item(Material.PAPER))); + assertNull(config.professionActivity("mining")); + assertTrue(config.stationActivity("forge", null).isEmpty()); + warned("there is no 'playtime' activity"); + } + + @Test + void malformedRewardSectionsCannotCreatePhantomRewards() throws Exception { + settings.set("rewards", "not a section"); + settings.set("daily-reward.groups", List.of("invalid group")); + + load(); + + assertTrue(config.rewardPool().isEmpty()); + assertTrue(config.dailyRewards().isEmpty()); + assertTrue(config.milestoneDrops().isEmpty()); + warned("rewards.pool is empty"); + warned("daily-reward.groups is not a section"); + assertEquals("not a section", YamlConfiguration.loadConfiguration( + directory.resolve(ActivityConfiguration.REWARD_LOCK_FILE).toFile()) + .getString("rewards-config.rewards")); + } + + @Test + void invalidNamedPoolsAndFixedItemsFallBackToTheUsablePool() throws Exception { + settings.set("rewards.pools.wrong", List.of(Map.of("commands", List.of("say wrong")))); + settings.set("rewards.pools.pool", List.of(Map.of("commands", List.of("say shadow")))); + settings.set("rewards.drops.drop_1", "not_a_material 2"); + settings.set("rewards.drops.drop_2.item", "other.invalid"); + settings.set("rewards.drops.drop_2.amount", 1); + + load(); + + assertEquals(Set.of("pool"), config.activeRewards().pools().keySet()); + assertTrue(config.milestoneDrops().isEmpty()); + assertEquals("Original reward", config.rewardPool().getFirst().display()); + warned("rewards.pools.wrong must use a pool_ name"); + warned("rewards.pools.pool must use a pool_ name"); + warned("rewards.drops.drop_1 has no usable item path"); + warned("rewards.drops.drop_2 has no usable item path"); + + settings.set("rewards.drops", "not a section"); + load(); + assertTrue(config.applyRewardsNow()); + assertTrue(config.milestoneDrops().isEmpty()); + warned("rewards.drops is not a section"); + } + + @Test + void impossibleVoteSharesAndWeeklyTargetsExplainTheUnreachablePoints() throws Exception { + settings.set("bar.vote-share", 90); + settings.set("bar.milestones", List.of(50)); + activity("vote"); + settings.set("activities.vote.daily-cap", 1); + + load(); + + assertEquals(1, config.nonVoteDailyMax()); + warned("'vote' is not daily-guaranteed"); + warned("below the 9 points bar.vote-share keeps for voting"); + warned("This is without voting: bar.vote-share keeps 9 of bar.daily-max for it"); + + settings.set("activities.vote", null); + load(); + warned("there is no 'vote' activity - the share is not applied"); + } + + @Test + void largeDailyCapsKeepTheConfiguredPercentageWithoutIntegerOverflow() throws Exception { + settings.set("bar.daily-max", Integer.MAX_VALUE); + settings.set("bar.vote-share", 50); + activity("vote"); + settings.set("activities.vote.daily-guaranteed", true); + + load(); + + assertEquals(1_073_741_823, config.nonVoteDailyMax()); + } + } } diff --git a/src/test/java/net/tfminecraft/activitytf/config/MessagesCoverageTest.java b/src/test/java/net/tfminecraft/activitytf/config/MessagesCoverageTest.java new file mode 100644 index 0000000..5e66f9c --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/config/MessagesCoverageTest.java @@ -0,0 +1,44 @@ +package net.tfminecraft.activitytf.config; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.logging.Logger; +import net.tfminecraft.activitytf.utils.Utils; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MessagesCoverageTest { + @TempDir Path directory; + @Test void missingAndMalformedPlaceholderRequestsRemainReadableAndAreLogged() throws Exception { + JavaPlugin plugin=mock(JavaPlugin.class); Logger logger=mock(Logger.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getLogger()).thenReturn(logger); + Files.writeString(directory.resolve(Messages.FILE),"hello: '&aHello %name%'\n"); + Messages messages=new Messages(plugin); messages.reload(); + assertEquals("absent",messages.get("absent")); + verify(logger).warning("Missing message 'absent' in messages.yml"); + assertEquals("§aHello %name%",messages.get("hello","%name%")); + verify(logger).warning("Message 'hello' was given an odd number of placeholder arguments."); + assertEquals("§aHello Steve",messages.get("hello","%name%","Steve")); + assertEquals("&aHello %name%",messages.raw("hello")); assertEquals("absent",messages.raw("absent")); + assertEquals("§bLegacy text",new Utils().colorize("&bLegacy text")); + } + @Test void unreadablePackagedDefaultsDoNotDiscardUserMessages() throws Exception { + JavaPlugin plugin=mock(JavaPlugin.class); Logger logger=mock(Logger.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getLogger()).thenReturn(logger); + Files.writeString(directory.resolve(Messages.FILE),"hello: local\n"); + when(plugin.getResource(Messages.FILE)).thenAnswer(call->new ByteArrayInputStream("hello: packaged\n".getBytes(StandardCharsets.UTF_8)) { + @Override public void close() throws IOException { throw new IOException("resource close failed"); } + }); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getLogger).thenReturn(logger); + Messages messages=new Messages(plugin); messages.reload(); + assertEquals("local",messages.get("hello")); + verify(logger).warning(contains("Failed to read the packaged messages.yml: resource close failed")); + } + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/gui/ActivityGuiTest.java b/src/test/java/net/tfminecraft/activitytf/gui/ActivityGuiTest.java index 1150ac8..4d99cb5 100644 --- a/src/test/java/net/tfminecraft/activitytf/gui/ActivityGuiTest.java +++ b/src/test/java/net/tfminecraft/activitytf/gui/ActivityGuiTest.java @@ -1,11 +1,44 @@ package net.tfminecraft.activitytf.gui; import org.bukkit.Material; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.exception.UnimplementedOperationException; +import org.mockito.MockedStatic; import net.tfminecraft.activitytf.config.ActivityConfiguration; import net.tfminecraft.activitytf.config.Messages; +import net.tfminecraft.activitytf.hooks.ItemsAdderItems; +import net.tfminecraft.activitytf.hooks.TLibsItems; import net.tfminecraft.activitytf.managers.ActivityManager; import net.tfminecraft.activitytf.managers.TestManagers; import net.tfminecraft.activitytf.models.ActivityDef; @@ -14,6 +47,7 @@ import net.tfminecraft.activitytf.utils.Bar; import net.tfminecraft.activitytf.utils.Utils; +import java.io.File; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; @@ -26,7 +60,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; class ActivityGuiTest { @@ -499,4 +538,400 @@ void revealingTheLastTaskAsksForTheDailyReward() { gui.revealTask(player, 1); assertTrue(chat.stream().anyMatch(line -> line.contains("could not be handed over")), chat.toString()); } + + @Test + void rewardPreviewsNameOutcomesWhoseLabelsAreMissing() { + assertEquals(List.of("#50d990x2 &r&7DIAMOND", "&7Other reward"), RewardPreview.summarize(List.of( + new RewardEntry(1, null, List.of(), List.of(new RewardEntry.Item("DIAMOND", 2))), + new RewardEntry(1, " ", List.of("reward %uuid%"), List.of())))); + } + + @Nested + @ExtendWith(FailUnimplemented.class) + class MenuInteraction { + @TempDir + File directory; + + Server previousServer; + ServerMock server; + JavaPlugin plugin; + PlayerMock player; + PermissionAttachment permissions; + ActivityManager manager; + ActivityGui gui; + + @BeforeEach + void startServer() throws ReflectiveOperationException { + previousServer = Bukkit.getServer(); + set(Bukkit.class, null, "server", null); + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin("ActivityGuiTest"); + player = server.addPlayer("Steve"); + permissions = player.addAttachment(plugin); + permissions.setPermission("activity.use", true); + permissions.setPermission("activity.reroll", true); + configure(activity("a", 1, 1, 0), activity("b", 1, 1, 0), activity("c", 1, 1, 0)); + } + + @AfterEach + void stopServer() throws ReflectiveOperationException { + try { + MockBukkit.unmock(); + } finally { + set(Bukkit.class, null, "server", previousServer); + } + } + + void configure(ActivityDef... definitions) throws ReflectiveOperationException { + manager = TestManagers.manager(definitions); + TestManagers.messages(manager); + TestManagers.storeLoaded(manager); + TestManagers.storeFile(manager, new File(directory, "players.yml")); + TestManagers.rerollsPerDay(manager, 1); + set(ActivityManager.class, manager, "plugin", plugin); + set(net.tfminecraft.activitytf.store.PlayerStore.class, manager.getStore(), "plugin", plugin); + set(ActivityConfiguration.class, manager.getConfiguration(), "guiTitle", "&8Activities"); + set(ActivityConfiguration.class, manager.getConfiguration(), "barLength", 10); + gui = new ActivityGui(manager); + } + + Inventory open() { + Inventory inventory = gui.build(player); + player.openInventory(inventory); + return inventory; + } + + PlayerData data() { + return manager.tasks(player.getUniqueId()); + } + + InventoryClickEvent click(int rawSlot) { + InventoryClickEvent event = new InventoryClickEvent(player.getOpenInventory(), + InventoryType.SlotType.CONTAINER, rawSlot, ClickType.LEFT, InventoryAction.PICKUP_ALL); + gui.onClick(event); + return event; + } + + String message(String key) { + var legacy = net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection(); + return legacy.serialize(legacy.deserialize(manager.getConfiguration().messages().get(key))); + } + + String rerollLore() { + return gui.build(player).getItem(4).getItemMeta().getLore().get(0); + } + + @Test + void rendersACompleteMenuWithHiddenTasksAndMetadata() { + Inventory inventory = open(); + assertEquals(27, inventory.getSize()); + assertSame(inventory, inventory.getHolder().getInventory()); + assertEquals("\u00a78Activities", player.getOpenInventory().getTitle()); + for (int index = 0; index < inventory.getSize(); index++) { + assertNotNull(inventory.getItem(index), "Empty slot " + index); + } + assertEquals(Material.EXPERIENCE_BOTTLE, inventory.getItem(3).getType()); + assertEquals(Material.NETHER_STAR, inventory.getItem(4).getType()); + assertEquals(Material.EXPERIENCE_BOTTLE, inventory.getItem(5).getType()); + assertEquals(Material.GRAY_DYE, inventory.getItem(10).getType()); + assertEquals(message("gui.hidden-task-name"), inventory.getItem(10).getItemMeta().getDisplayName()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(13).getType()); + assertEquals(message("gui.filler-name"), inventory.getItem(0).getItemMeta().getDisplayName()); + assertTrue(inventory.getItem(10).getItemMeta().getItemFlags().containsAll(Set.of( + ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_STORED_ENCHANTS))); + var attributes = inventory.getItem(10).getItemMeta().getAttributeModifiers(); + assertTrue(attributes == null || attributes.isEmpty()); + } + + @Test + void revealReplacesTheHiddenItemAndPlaysFeedbackOnlyOnce() { + Inventory inventory = open(); + String id = data().tasks().get(0); + assertTrue(click(10).isCancelled()); + assertTrue(data().isRevealed(id)); + assertEquals(Material.PAPER, inventory.getItem(10).getType()); + assertEquals(id, inventory.getItem(10).getItemMeta().getDisplayName()); + assertFalse(inventory.getItem(10).getItemMeta().getLore().isEmpty()); + assertEquals(2, player.getHeardSounds().size()); + assertTrue(click(10).isCancelled()); + assertEquals(2, player.getHeardSounds().size()); + assertSame(inventory, player.getOpenInventory().getTopInventory()); + } + + @Test + void revealingEveryTaskPaysTheDailyRewardOnce() { + permissions.setPermission("group.vip", true); + TestManagers.dailyRewards(manager, Map.of("vip", new RewardEntry(1, "Diamond", List.of(), + List.of(new RewardEntry.Item("DIAMOND", 3))))); + open(); + click(10); + click(11); + assertFalse(data().dailyRewardClaimed()); + assertFalse(player.getInventory().contains(Material.DIAMOND)); + + click(12); + assertTrue(data().dailyRewardClaimed()); + assertTrue(player.getInventory().contains(Material.DIAMOND, 3)); + assertNotNull(player.nextMessage()); + click(12); + assertEquals(3, player.getInventory().all(Material.DIAMOND).values().stream() + .mapToInt(ItemStack::getAmount).sum()); + } + + @Test + void staleDailyDrawRepaintsBarsAndEveryTask() { + open(); + click(10); + String revealed = data().tasks().get(0); + manager.recordAction(player.getUniqueId(), revealed, 1); + Inventory inventory = open(); + assertEquals(1, data().dailyPoints()); + data().reset(manager.getConfiguration().currentKeys().week(), "2000-01-01"); + + click(11); + + assertEquals(Material.EXPERIENCE_BOTTLE, inventory.getItem(3).getType()); + assertEquals(manager.getConfiguration().messages().get("gui.daily-bar-name", "%points%", 0, "%max%", 10), + inventory.getItem(3).getItemMeta().getDisplayName()); + assertEquals(Material.NETHER_STAR, inventory.getItem(4).getType()); + assertEquals(Material.EXPERIENCE_BOTTLE, inventory.getItem(5).getType()); + assertEquals(Material.GRAY_DYE, inventory.getItem(10).getType()); + assertEquals(Material.PAPER, inventory.getItem(11).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(13).getType()); + assertEquals(0, data().dailyPoints()); + } + + @Test + void removedClickedTaskRepaintsTheDrawWithoutRevealingAnotherTask() { + Inventory inventory = open(); + String removed = data().tasks().get(0); + TestManagers.unload(manager, removed); + + assertTrue(click(10).isCancelled()); + + assertFalse(data().tasks().contains(removed)); + assertTrue(data().revealed().isEmpty()); + assertEquals(Material.GRAY_DYE, inventory.getItem(10).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(12).getType()); + assertTrue(player.getHeardSounds().isEmpty()); + } + + @Test + void cancelsBottomOutsideAndFillerClicksWithoutChangingTasks() { + open(); + assertTrue(click(27).isCancelled()); + assertTrue(click(-999).isCancelled()); + assertTrue(click(0).isCancelled()); + assertTrue(data().revealed().isEmpty()); + assertTrue(player.getHeardSounds().isEmpty()); + } + + @Test + void ignoresOtherInventoriesAndPlayersWithoutUsePermission() { + player.openInventory(server.createInventory(null, 27)); + assertFalse(click(10).isCancelled()); + open(); + permissions.setPermission("activity.use", false); + assertTrue(click(10).isCancelled()); + assertTrue(data().revealed().isEmpty()); + assertTrue(player.getHeardSounds().isEmpty()); + } + + @Test + void ignoresANonPlayerViewingTheMenu() { + Inventory inventory = open(); + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(inventory); + when(view.getInventory(10)).thenReturn(inventory); + when(view.getPlayer()).thenReturn(mock(HumanEntity.class)); + InventoryClickEvent event = new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, + 10, ClickType.LEFT, InventoryAction.PICKUP_ALL); + + gui.onClick(event); + + assertTrue(event.isCancelled()); + assertTrue(data().revealed().isEmpty()); + } + + @ParameterizedTest + @CsvSource({"true,0,true", "true,30,false", "false,0,false"}) + void dragProtectsOnlyTheActivityTopInventory(boolean activityMenu, int rawSlot, boolean cancelled) { + if (activityMenu) { + open(); + } else { + player.openInventory(server.createInventory(null, 27)); + } + ItemStack item = new ItemStack(Material.STONE); + InventoryDragEvent event = new InventoryDragEvent(player.getOpenInventory(), item, item, false, + Map.of(rawSlot, item)); + + gui.onDrag(event); + + assertEquals(cancelled, event.isCancelled()); + } + + @Test + void rerollLoreExplainsDisabledLockedLateAndAvailableStates() { + TestManagers.rerollsPerDay(manager, 0); + assertEquals(message("gui.reroll-lore-disabled"), rerollLore()); + TestManagers.rerollsPerDay(manager, 1); + permissions.setPermission("activity.reroll", false); + assertEquals(message("gui.reroll-lore-locked"), rerollLore()); + permissions.setPermission("activity.reroll", true); + data().record(2, activity("a", 1, 1, 0), 50, 10, List.of()); + assertEquals(manager.getConfiguration().messages().get("gui.reroll-lore-too-late", "%points%", 1), + rerollLore()); + data().reset(manager.getConfiguration().currentKeys().week(), manager.getConfiguration().currentKeys().day()); + assertEquals(manager.getConfiguration().messages().get("gui.reroll-lore-left", "%left%", 1, "%max%", 1), + rerollLore()); + } + + @Test + void rerollRejectsDisabledLockedUnavailableAndTooLateRequests() throws ReflectiveOperationException { + open(); + TestManagers.rerollsPerDay(manager, 0); + click(4); + assertEquals(message("reroll-disabled"), player.nextMessage()); + TestManagers.rerollsPerDay(manager, 1); + permissions.setPermission("activity.reroll", false); + click(4); + assertEquals(message("reroll-locked"), player.nextMessage()); + permissions.setPermission("activity.reroll", true); + set(net.tfminecraft.activitytf.store.PlayerStore.class, manager.getStore(), "loaded", false); + click(4); + assertEquals(message("reroll-failed"), player.nextMessage()); + TestManagers.storeLoaded(manager); + data().record(2, activity("a", 1, 1, 0), 50, 10, List.of()); + click(4); + assertEquals(manager.getConfiguration().messages().get("reroll-too-late", "%points%", 1), player.nextMessage()); + assertEquals(0, data().rerolls()); + } + + @Test + void successfulRerollHidesTasksAndRefreshesTheRemainingAllowance() { + Inventory inventory = open(); + click(10); + click(4); + + assertEquals(message("reroll-done"), player.nextMessage()); + assertEquals(1, data().rerolls()); + assertTrue(data().revealed().isEmpty()); + assertEquals(Material.GRAY_DYE, inventory.getItem(10).getType()); + assertEquals(manager.getConfiguration().messages().get("gui.reroll-lore-left", "%left%", 0, "%max%", 1), + inventory.getItem(4).getItemMeta().getLore().get(0)); + + click(4); + assertEquals(message("reroll-none-left"), player.nextMessage()); + assertEquals(1, data().rerolls()); + } + + @Test + void weeklyClaimHandsOverRewardsAndRefreshesTheBar() throws ReflectiveOperationException { + set(ActivityConfiguration.class, manager.getConfiguration(), "milestoneDrops", Map.of( + 10, new RewardEntry(1, "Diamond", List.of(), List.of(new RewardEntry.Item("DIAMOND", 2))), + 20, new RewardEntry(1, "Iron", List.of(), List.of(new RewardEntry.Item("IRON_INGOT", 3))))); + data().addPoints(20, 50); + Inventory inventory = open(); + + assertTrue(click(5).isCancelled()); + + assertEquals(20, data().claimedPoints()); + assertTrue(player.getInventory().contains(Material.DIAMOND, 2)); + assertTrue(player.getInventory().contains(Material.IRON_INGOT, 3)); + assertTrue(inventory.getItem(5).getItemMeta().getLore().contains(message("gui.bar-lore-none"))); + assertNotNull(player.nextMessage()); + assertNotNull(player.nextMessage()); + click(5); + assertNull(player.nextMessage()); + assertEquals(2, player.getInventory().all(Material.DIAMOND).values().stream() + .mapToInt(ItemStack::getAmount).sum()); + } + + @Test + void clickingAnEmptyWeeklyBarPaysNothing() { + Inventory inventory = open(); + ItemStack before = inventory.getItem(5).clone(); + click(5); + assertEquals(before, inventory.getItem(5)); + assertEquals(0, data().claimedPoints()); + assertNull(player.nextMessage()); + } + + @Test + void revealedTaskCommandsCloseTheMenuAndQuitClearsTheirCooldown() throws ReflectiveOperationException { + configure(new ActivityDef("a", "a", Material.PAPER, null, 1, 1, 0, + List.of("taskclick %player%"), List.of("&eClick for an action"))); + List dispatched = new ArrayList<>(); + server.getCommandMap().register("activitytest", new Command("taskclick") { + @Override + public boolean execute(CommandSender sender, String label, String[] args) { + Inventory top = player.getOpenInventory().getTopInventory(); + assertTrue(top == null || !(top.getHolder() instanceof ActivityGui.Marker)); + dispatched.add(String.join(" ", args)); + return true; + } + }); + open(); + click(10); + click(10); + assertEquals(3, player.getHeardSounds().size()); + assertTrue(dispatched.isEmpty()); + server.getScheduler().performOneTick(); + assertEquals(List.of("Steve"), dispatched); + + gui.onQuit(new PlayerQuitEvent(player, (net.kyori.adventure.text.Component) null, + PlayerQuitEvent.QuitReason.DISCONNECTED)); + open(); + click(10); + server.getScheduler().performOneTick(); + assertEquals(List.of("Steve", "Steve"), dispatched); + } + + @Test + void customIconsResolveThroughTheirAvailableProvidersAndFallbackWhenMissing() + throws ReflectiveOperationException { + configure(new ActivityDef("a", "Custom", Material.PAPER, "m.material.icon", 1, 1, 0)); + manager.reveal(player.getUniqueId(), 0); + set(ActivityConfiguration.class, manager.getConfiguration(), "itemPathsUsable", true); + try (MockedStatic items = mockStatic(TLibsItems.class)) { + items.when(() -> TLibsItems.item("m.material.icon")).thenReturn(new ItemStack(Material.EMERALD)); + assertEquals(Material.EMERALD, gui.build(player).getItem(10).getType()); + items.when(() -> TLibsItems.item("m.material.icon")).thenReturn(new ItemStack(Material.AIR)); + assertEquals(Material.PAPER, gui.build(player).getItem(10).getType()); + } + + configure(new ActivityDef("a", "Custom", Material.PAPER, "ia.test:icon", 1, 1, 0)); + manager.reveal(player.getUniqueId(), 0); + set(ActivityConfiguration.class, manager.getConfiguration(), "itemsAdderUsable", true); + try (MockedStatic items = mockStatic(ItemsAdderItems.class)) { + items.when(() -> ItemsAdderItems.item("test:icon")).thenReturn(new ItemStack(Material.DIAMOND)); + assertEquals(Material.DIAMOND, gui.build(player).getItem(10).getType()); + items.when(() -> ItemsAdderItems.item("test:icon")).thenReturn(null); + assertEquals(Material.PAPER, gui.build(player).getItem(10).getType()); + } + } + } + + static final class FailUnimplemented implements TestExecutionExceptionHandler, LifecycleMethodExecutionExceptionHandler { + @Override + public void handleTestExecutionException(ExtensionContext context, Throwable thrown) throws Throwable { + throw failed(thrown); + } + + @Override + public void handleBeforeEachMethodExecutionException(ExtensionContext context, Throwable thrown) throws Throwable { + throw failed(thrown); + } + + private static Throwable failed(Throwable thrown) { + return thrown instanceof UnimplementedOperationException + ? new AssertionError("MockBukkit does not implement a call this GUI test needs", thrown) : thrown; + } + } + + private static void set(Class owner, Object target, String name, Object value) throws ReflectiveOperationException { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } } diff --git a/src/test/java/net/tfminecraft/activitytf/hooks/ItemsAdderItemsTest.java b/src/test/java/net/tfminecraft/activitytf/hooks/ItemsAdderItemsTest.java index 7c3bb70..72f637d 100644 --- a/src/test/java/net/tfminecraft/activitytf/hooks/ItemsAdderItemsTest.java +++ b/src/test/java/net/tfminecraft/activitytf/hooks/ItemsAdderItemsTest.java @@ -251,4 +251,59 @@ void anInvocationTargetExceptionFromItemsAdderIsRetriedOnEveryCall() { ItemsAdderItems.reset(); } } + @Test + void reflectedProviderReturnsDetachedItemsAndRetriesMissingOrUnexpectedResults() { + TestManagers.bukkit(); + var provider = org.mockito.Mockito.mock(dev.lone.itemsadder.api.CustomStack.class); + ItemStack original = new TestStack(Material.DIAMOND, 64); + try (var stacks = org.mockito.Mockito.mockStatic(dev.lone.itemsadder.api.CustomStack.class)) { + ItemsAdderItems.reset(); + assertNull(ItemsAdderItems.item("tfmc:missing")); + stacks.when(() -> dev.lone.itemsadder.api.CustomStack.getInstance("tfmc:missing")).thenReturn(provider); + org.mockito.Mockito.when(provider.getItemStack()).thenReturn("invalid provider result", original); + assertNull(ItemsAdderItems.item("tfmc:missing")); + ItemStack resolved = ItemsAdderItems.item("tfmc:missing"); + assertNotSame(original, resolved); + assertEquals(Material.DIAMOND, resolved.getType()); + assertEquals(1, resolved.getAmount()); + assertEquals(64, original.getAmount()); + } finally { + ItemsAdderItems.reset(); + } + } + + @Test + void absentOptionalProviderFailsClosedAndLogsOnce() throws Exception { + TestManagers.bukkit(); + Logger logger = org.mockito.Mockito.mock(Logger.class); + ClassLoader parent = ItemsAdderItems.class.getClassLoader(); + ClassLoader withoutProvider = new ClassLoader(parent) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (name.equals("dev.lone.itemsadder.api.CustomStack")) throw new ClassNotFoundException(name); + if (!name.startsWith(ItemsAdderItems.class.getName())) return super.loadClass(name, resolve); + Class result = findLoadedClass(name); + if (result == null) { + try (var stream = parent.getResourceAsStream(name.replace('.', '/') + ".class")) { + byte[] bytes = stream.readAllBytes(); + result = defineClass(name, bytes, 0, bytes.length, ItemsAdderItems.class.getProtectionDomain()); + } catch (java.io.IOException e) { + throw new ClassNotFoundException(name, e); + } + } + if (resolve) resolveClass(result); + return result; + } + } + }; + try (var bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getLogger).thenReturn(logger); + Class isolated = withoutProvider.loadClass(ItemsAdderItems.class.getName()); + var item = isolated.getMethod("item", String.class); + assertNull(item.invoke(null, "tfmc:optional")); + assertNull(item.invoke(null, "tfmc:optional")); + org.mockito.Mockito.verify(logger).warning(org.mockito.ArgumentMatchers.contains("IllegalStateException")); + } + } + } diff --git a/src/test/java/net/tfminecraft/activitytf/hooks/PlaceholderHookTest.java b/src/test/java/net/tfminecraft/activitytf/hooks/PlaceholderHookTest.java new file mode 100644 index 0000000..b519168 --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/hooks/PlaceholderHookTest.java @@ -0,0 +1,249 @@ +package net.tfminecraft.activitytf.hooks; + +import me.clip.placeholderapi.PlaceholderAPI; +import me.clip.placeholderapi.PlaceholderAPIPlugin; +import me.clip.placeholderapi.configuration.PlaceholderAPIConfig; +import me.clip.placeholderapi.expansion.manager.LocalExpansionManager; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.PluginManager; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.objenesis.ObjenesisStd; +import net.tfminecraft.activitytf.config.ActivityConfiguration; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.managers.TestManagers; +import net.tfminecraft.activitytf.models.ActivityDef; +import net.tfminecraft.activitytf.models.PlayerData; +import net.tfminecraft.activitytf.store.PlayerStore; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlaceholderHookTest { + + private static final ActivityDef CAPPED = new ActivityDef("capped", "Capped", Material.PAPER, null, 2, 3, 2); + private static final ActivityDef UNLIMITED = new ActivityDef("unlimited", "Unlimited", Material.PAPER, null, 2, 1, 0); + + private final UUID uuid = UUID.randomUUID(); + private final OfflinePlayer player = (OfflinePlayer) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[] {OfflinePlayer.class}, (proxy, method, args) -> switch (method.getName()) { + case "getUniqueId" -> uuid; + case "toString" -> "placeholder-player"; + case "hashCode" -> uuid.hashCode(); + case "equals" -> proxy == args[0]; + default -> throw new UnsupportedOperationException("Unexpected OfflinePlayer#" + method.getName()); + }); + + private ActivityManager manager; + private ActivityConfiguration.Keys keys; + private PlaceholderHook hook; + + @BeforeEach + void setUp() throws ReflectiveOperationException { + manager = TestManagers.manager(CAPPED, UNLIMITED); + TestManagers.messages(manager); + field(ActivityConfiguration.class, "barLength").set(manager.getConfiguration(), 10); + keys = manager.getConfiguration().currentKeys(); + hook = new PlaceholderHook(manager); + } + + @ParameterizedTest + @CsvSource({ + "30000000, 30000000, 100", + "2147483647, 2147483647, 100", + "1073741824, 2147483647, 50", + "2147483647, 30000000, 100", + "0, 30000000, 0", + "1, 3, 33" + }) + void percentageHandlesTheFullPositiveIntegerRange(int points, int maximum, String expected) + throws ReflectiveOperationException { + TestManagers.limits(manager, maximum, 10); + stored(points, 0, keys.week(), keys.day(), 0, Map.of()); + + assertEquals(expected, hook.onRequest(player, "percent")); + } + + @Test + void metadataIdentifiesAPersistentActivityExpansion() { + assertEquals("activity", hook.getIdentifier()); + assertEquals("Justin", hook.getAuthor()); + assertEquals("1.0", hook.getVersion()); + assertTrue(hook.persist()); + } + + @Test + void nullPlayerProducesEmptyTextWithoutReadingTheManager() { + assertEquals("", new PlaceholderHook(null).onRequest(null, "points")); + } + + @Test + void unknownPlayerReadsAsZeroWithoutCreatingStoredData() { + assertEquals("0", hook.onRequest(player, "points")); + assertEquals("0", hook.onRequest(player, "claimable")); + assertEquals("0", hook.onRequest(player, "daily_points")); + assertEquals("0", hook.onRequest(player, "percent")); + assertEquals("\u00a7c✘", hook.onRequest(player, "done_capped")); + assertNull(manager.getStore().peek(uuid)); + } + + @Test + void currentValuesIncludeLimitsClaimableMilestonesAndColoredBar() throws ReflectiveOperationException { + stored(20, 7, keys.week(), keys.day(), 10, Map.of()); + + assertEquals("20", hook.onRequest(player, "PoInTs")); + assertEquals("50", hook.onRequest(player, "max")); + assertEquals("40", hook.onRequest(player, "percent")); + assertEquals("1", hook.onRequest(player, "claimable")); + assertEquals("7", hook.onRequest(player, "daily_points")); + assertEquals("10", hook.onRequest(player, "daily_max")); + String bar = hook.onRequest(player, "bar"); + assertEquals("[||||||||||]", org.bukkit.ChatColor.stripColor(bar)); + assertTrue(bar.contains("\u00a7")); + assertFalse(bar.contains("#")); + } + + @Test + void lowerConfiguredLimitsClampDisplaysWithoutChangingStoredValues() throws ReflectiveOperationException { + PlayerData data = stored(70, 15, keys.week(), keys.day(), 0, Map.of()); + + assertEquals("50", hook.onRequest(player, "points")); + assertEquals("100", hook.onRequest(player, "percent")); + assertEquals("10", hook.onRequest(player, "daily_points")); + assertEquals(70, data.points()); + assertEquals(15, data.dailyPoints()); + assertSame(data, manager.getStore().peek(uuid)); + } + + @Test + void staleWeekDisplaysZeroWithoutRollingOrClearingTheStoredRecord() throws ReflectiveOperationException { + PlayerData data = stored(20, 7, "2000-01-03", keys.day(), 0, Map.of("capped", 4)); + + assertEquals("0", hook.onRequest(player, "points")); + assertEquals("0", hook.onRequest(player, "claimable")); + assertEquals("0", hook.onRequest(player, "daily_points")); + assertEquals("\u00a7c✘", hook.onRequest(player, "done_capped")); + assertEquals("2000-01-03", data.weekKey()); + assertEquals(20, data.points()); + assertEquals(7, data.dailyPoints()); + assertEquals(4, data.count("capped")); + } + + @Test + void staleDayKeepsWeeklyValuesButHidesYesterdayProgress() throws ReflectiveOperationException { + PlayerData data = stored(20, 7, keys.week(), "2000-01-03", 0, Map.of("capped", 4)); + + assertEquals("20", hook.onRequest(player, "points")); + assertEquals("2", hook.onRequest(player, "claimable")); + assertEquals("0", hook.onRequest(player, "daily_points")); + assertEquals("\u00a7c✘", hook.onRequest(player, "done_capped")); + assertEquals("2000-01-03", data.dayKey()); + assertEquals(7, data.dailyPoints()); + assertEquals(4, data.count("capped")); + } + + @Test + void cappedActivitiesAreDoneOnlyAfterAllDailyCompletions() throws ReflectiveOperationException { + stored(3, 3, keys.week(), keys.day(), 0, Map.of("capped", 2)); + assertEquals("\u00a7c✘", hook.onRequest(player, "done_capped")); + + stored(6, 6, keys.week(), keys.day(), 0, Map.of("capped", 4)); + assertEquals("\u00a7a✔", hook.onRequest(player, "done_capped")); + } + + @Test + void uncappedActivitiesAreDoneAfterTheFirstEarnedPoint() throws ReflectiveOperationException { + stored(0, 0, keys.week(), keys.day(), 0, Map.of("unlimited", 1)); + assertEquals("\u00a7c✘", hook.onRequest(player, "done_unlimited")); + + stored(1, 1, keys.week(), keys.day(), 0, Map.of("unlimited", 2)); + assertEquals("\u00a7a✔", hook.onRequest(player, "DONE_unlimited")); + } + + @Test + void unknownActivitiesReturnEmptyAndUnknownPlaceholdersRemainUnresolved() { + assertEquals("", hook.onRequest(player, "done_missing")); + assertNull(hook.onRequest(player, "unsupported")); + } + + @Test + void registeredProviderResolvesPlaceholderApiText(@TempDir File directory) throws ReflectiveOperationException { + stored(20, 7, keys.week(), keys.day(), 10, Map.of("capped", 4)); + Server previousServer = Bukkit.getServer(); + PluginManager pluginManager = (PluginManager) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] {PluginManager.class}, (proxy, method, args) -> { + if (method.getName().equals("callEvent")) { + return null; + } + throw new UnsupportedOperationException("Unexpected PluginManager#" + method.getName()); + }); + Server server = (Server) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {Server.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getPluginManager" -> pluginManager; + case "getBukkitVersion" -> "1.21.10-R0.1-SNAPSHOT"; + default -> throw new UnsupportedOperationException("Unexpected Server#" + method.getName()); + }); + Field serverField = field(Bukkit.class, "server"); + serverField.set(null, server); + try { + Field instanceField = field(PlaceholderAPIPlugin.class, "instance"); + Object previousPlugin = instanceField.get(null); + try { + PlaceholderAPIPlugin plugin = new ObjenesisStd().newInstance(PlaceholderAPIPlugin.class); + field(JavaPlugin.class, "dataFolder").set(plugin, directory); + field(JavaPlugin.class, "logger").set(plugin, Logger.getAnonymousLogger()); + field(JavaPlugin.class, "newConfig").set(plugin, new YamlConfiguration()); + field(PlaceholderAPIPlugin.class, "config").set(plugin, new PlaceholderAPIConfig(plugin)); + LocalExpansionManager expansions = new LocalExpansionManager(plugin); + field(PlaceholderAPIPlugin.class, "localExpansionManager").set(plugin, expansions); + instanceField.set(null, plugin); + + assertTrue(hook.register()); + assertTrue(hook.isRegistered()); + assertSame(hook, expansions.getExpansion("activity")); + assertEquals("20/50 (40%) 1 \u00a7a✔ %activity_unknown%", PlaceholderAPI.setPlaceholders(player, + "%activity_points%/%activity_max% (%activity_percent%%) %activity_claimable%" + + " %activity_done_capped% %activity_unknown%")); + assertTrue(hook.unregister()); + assertFalse(hook.isRegistered()); + } finally { + instanceField.set(null, previousPlugin); + } + } finally { + serverField.set(null, previousServer); + } + } + + @SuppressWarnings("unchecked") + private PlayerData stored(int points, int daily, String week, String day, int claimed, Map counts) + throws ReflectiveOperationException { + PlayerData data = new PlayerData(points, daily, week, day, claimed, counts); + Map players = (Map) field(PlayerStore.class, "players").get(manager.getStore()); + players.put(uuid, data); + return data; + } + + private static Field field(Class owner, String name) throws ReflectiveOperationException { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field; + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/hooks/TLibsItemsTest.java b/src/test/java/net/tfminecraft/activitytf/hooks/TLibsItemsTest.java index 05e1679..437189a 100644 --- a/src/test/java/net/tfminecraft/activitytf/hooks/TLibsItemsTest.java +++ b/src/test/java/net/tfminecraft/activitytf/hooks/TLibsItemsTest.java @@ -177,4 +177,38 @@ void aResolvablePathIsInvokedEveryTime() { assertEquals(3, calls[0]); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"missing","placeholder","exception"}) + void configurationReloadRetriesItemProvidersThatHaveRecovered(String failure) throws ReflectiveOperationException { + org.bukkit.Server previousServer=org.bukkit.Bukkit.getServer(); + var serverField=org.bukkit.Bukkit.class.getDeclaredField("server"); serverField.setAccessible(true); + serverField.set(null,null); + java.util.concurrent.atomic.AtomicBoolean ready=new java.util.concurrent.atomic.AtomicBoolean(); + String path="m.reload."+failure; + net.tfminecraft.activitytf.managers.ActivityManager manager=null; + try { + org.mockbukkit.mockbukkit.MockBukkit.mock(); + try (var api=org.mockito.Mockito.mockStatic(net.tfminecraft.tlibs.TLibs.class,org.mockito.Mockito.RETURNS_DEEP_STUBS); + var configs=org.mockito.Mockito.mockConstruction(net.tfminecraft.activitytf.config.ActivityConfiguration.class); + var stores=org.mockito.Mockito.mockConstruction(net.tfminecraft.activitytf.store.PlayerStore.class)) { + org.mockito.Mockito.when(net.tfminecraft.tlibs.TLibs.getItemAPI().getCreator().getItemFromPath(path)).thenAnswer(call->{ + if(ready.get()) return new org.bukkit.inventory.ItemStack(Material.DIAMOND); + if(failure.equals("exception")) throw new IllegalStateException("provider not ready"); + return failure.equals("placeholder") ? new org.bukkit.inventory.ItemStack(Material.DIRT) : null; + }); + manager=net.tfminecraft.activitytf.managers.ActivityManager.getInstance(org.mockito.Mockito.mock(org.bukkit.plugin.java.JavaPlugin.class)); + assertNull(TLibsItems.item(path)); + ready.set(true); manager.reload(); + assertEquals(Material.DIAMOND,TLibsItems.item(path).getType()); + org.mockito.Mockito.when(net.tfminecraft.tlibs.TLibs.getItemAPI().getChecker().checkItemWithPath(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq(path))).thenReturn(true); + assertEquals("recovered",TLibsItems.match(new org.bukkit.inventory.ItemStack(Material.DIAMOND),paths(path,"recovered"))); + } + } finally { + try { + if(manager!=null)manager.shutdown(); + org.mockbukkit.mockbukkit.MockBukkit.unmock(); + } finally { serverField.set(null,previousServer); } + } + } + } diff --git a/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerHandlersTest.java b/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerHandlersTest.java index 920ff20..b81bd92 100644 --- a/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerHandlersTest.java +++ b/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerHandlersTest.java @@ -1,12 +1,66 @@ package net.tfminecraft.activitytf.listeners; +import com.dre.brewery.BIngredients; +import com.dre.brewery.BreweryPlugin; +import com.dre.brewery.configuration.ConfigManager; +import com.dre.brewery.configuration.files.Config; +import com.dre.brewery.configuration.files.Lang; +import com.dre.brewery.utility.MinecraftVersion; +import com.dre.brewery.Barrel; +import com.dre.brewery.Brew; +import com.dre.brewery.api.events.IngedientAddEvent; +import com.dre.brewery.api.events.brew.BrewModifyEvent; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.NamespacedKey; import org.bukkit.event.EventHandler; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.inventory.InventoryMock; +import org.mockbukkit.mockbukkit.exception.UnimplementedOperationException; +import org.mockito.MockedStatic; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.managers.TestManagers; +import net.tfminecraft.activitytf.models.ActivityDef; import org.bukkit.event.EventPriority; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; +import java.lang.reflect.Field; +import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.junit.jupiter.api.Assertions.assertTrue; class BreweryListenerHandlersTest { @@ -26,4 +80,313 @@ void everyHandlerSkipsCancelledEventsAtMonitor() { } assertTrue(handlers > 0); } + + @Nested + @ExtendWith(FailUnimplemented.class) + class RuntimeEvents { + @TempDir Path directory; + Server previousServer; + ServerMock server; + JavaPlugin plugin; + PlayerMock player; + ActivityManager manager; + Brew brew; + MockedStatic brews; + MockedStatic breweryPlugins; + MockedStatic breweryConfigs; + MockedStatic providers; + + @BeforeEach + void startServer() throws Exception { + previousServer = Bukkit.getServer(); + serverField().set(null, null); + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin("BreweryListenerTest"); + player = server.addPlayer("Steve"); + manager = TestManagers.manager( + activity("brew_ingredient"), activity("brew_bottle"), activity("brew_distill"), activity("brew_age")); + TestManagers.limits(manager, 100, 100); + TestManagers.messages(manager); + TestManagers.storeLoaded(manager); + TestManagers.storeFile(manager, directory.resolve("players.yml").toFile()); + reveal(player); + server.getPluginManager().registerEvents(new BreweryListener(manager), plugin); + // Brewery caches its configuration while Brew initializes, before our API lookup stub exists. + BreweryPlugin brewery = mock(BreweryPlugin.class); + when(brewery.getDataFolder()).thenReturn(directory.toFile()); + breweryPlugins = mockStatic(BreweryPlugin.class); + breweryPlugins.when(BreweryPlugin::getInstance).thenReturn(brewery); + breweryPlugins.when(BreweryPlugin::getMCVersion).thenReturn(MinecraftVersion.V1_21_10); + breweryConfigs = mockStatic(ConfigManager.class); + Config config = new Config(); + Lang lang = new Lang(); + breweryConfigs.when(() -> ConfigManager.getConfig(Config.class)).thenReturn(config); + breweryConfigs.when(() -> ConfigManager.getConfig(Lang.class)).thenReturn(lang); + brew = mock(Brew.class); + when(brew.getDistillRuns()).thenReturn((byte) 1); + when(brew.getAgeTime()).thenReturn(1.0f); + brews = mockStatic(Brew.class); + brews.when(() -> Brew.get(any(ItemStack.class))).thenAnswer(call -> brew); + // MockBukkit loads plugin classes through proxies; bridge the Paper classloader lookup only. + providers = mockStatic(JavaPlugin.class, CALLS_REAL_METHODS); + providers.when(() -> JavaPlugin.getProvidingPlugin(BreweryListener.class)).thenReturn(plugin); + } + + @AfterEach + void stopServer() throws Throwable { + Throwable failure = null; + for (AutoCloseable resource : new AutoCloseable[] {providers, brews, breweryConfigs, + breweryPlugins, MockBukkit::unmock, () -> serverField().set(null, previousServer)}) { + try { + if (resource != null) resource.close(); + } catch (Throwable next) { + if (failure == null) failure = next; + else failure.addSuppressed(next); + } + } + if (failure != null) throw failure; + } + + void reveal(PlayerMock who) { + for (int slot = 0; slot < manager.tasks(who.getUniqueId()).tasks().size(); slot++) { + manager.reveal(who.getUniqueId(), slot); + } + } + + int count(PlayerMock who, String id) { return manager.getStore().peek(who.getUniqueId()).count(id); } + + Inventory brewing() { return new SnapshotInventory(null, InventoryType.BREWING); } + Inventory barrel() { return new SnapshotInventory(mock(Barrel.class), InventoryType.CHEST); } + + InventoryClickEvent click(PlayerMock who, Inventory inventory, int rawSlot, InventoryAction action, boolean cancelled) { + who.openInventory(inventory); + InventoryClickEvent event = new InventoryClickEvent(who.getOpenInventory(), InventoryType.SlotType.CONTAINER, + rawSlot, ClickType.LEFT, action); + event.setCancelled(cancelled); + server.getPluginManager().callEvent(event); + return event; + } + + InventoryClickEvent take(PlayerMock who, Inventory inventory) { + return click(who, inventory, 0, InventoryAction.PICKUP_ALL, false); + } + + ItemStack remove(Inventory inventory) { + ItemStack item = inventory.getItem(0).clone(); + inventory.setItem(0, null); + return item; + } + + Long tag(ItemStack item, NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.LONG); + } + + @Test + void ingredientEventsCreditOnlyUncancelledPlayerActions() { + IngedientAddEvent cancelled = new IngedientAddEvent(player, null, null, new ItemStack(Material.WHEAT), null); + cancelled.setCancelled(true); + server.getPluginManager().callEvent(cancelled); + assertEquals(0, count(player, "brew_ingredient")); + server.getPluginManager().callEvent(new IngedientAddEvent(player, null, null, new ItemStack(Material.WHEAT), null)); + server.getPluginManager().callEvent(new IngedientAddEvent(null, null, null, new ItemStack(Material.WHEAT), null)); + assertEquals(1, count(player, "brew_ingredient")); + } + + @Test + void fillEventsRequireACookedBrewAndIgnoreCancelledAndOtherModifications() { + BIngredients ingredients = mock(BIngredients.class); + when(brew.getIngredients()).thenReturn(ingredients); + var meta = new ItemStack(Material.POTION).getItemMeta(); + server.getPluginManager().callEvent(new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.DISTILL, player)); + server.getPluginManager().callEvent(new BrewModifyEvent(null, meta, BrewModifyEvent.Type.FILL, player)); + server.getPluginManager().callEvent(new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.FILL, player)); + when(brew.getIngredients()).thenReturn(null); + server.getPluginManager().callEvent(new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.FILL, player)); + when(brew.getIngredients()).thenReturn(ingredients); + when(ingredients.getCookedTime()).thenReturn(1); + BrewModifyEvent cancelled = new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.FILL, player); + cancelled.setCancelled(true); + server.getPluginManager().callEvent(cancelled); + server.getPluginManager().callEvent(new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.FILL)); + assertEquals(0, count(player, "brew_bottle")); + server.getPluginManager().callEvent(new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.FILL, player)); + when(ingredients.getCookedTime()).thenReturn(12); + server.getPluginManager().callEvent(new BrewModifyEvent(brew, meta, BrewModifyEvent.Type.FILL, player)); + assertEquals(2, count(player, "brew_bottle")); + } + + @Test + void aRemovedDistilledBottleIsCreditedOnTheNextTickOnlyOnce() { + Inventory inventory = brewing(); + inventory.setItem(0, new ItemStack(Material.POTION)); + take(player, inventory); + assertEquals(0, count(player, "brew_distill")); + ItemStack taken = remove(inventory); + assertNotNull(tag(taken, BreweryListener.DISTILLED)); + + server.getScheduler().performOneTick(); + + assertEquals(1, count(player, "brew_distill")); + inventory.setItem(0, taken); + take(player, inventory); + remove(inventory); + server.getScheduler().performOneTick(); + assertEquals(1, count(player, "brew_distill")); + } + + @Test + void aBottleThatDidNotMoveLosesItsPendingTagAndCanBeTakenLater() { + Inventory inventory = brewing(); + inventory.setItem(0, new ItemStack(Material.POTION)); + click(player, inventory, 0, InventoryAction.MOVE_TO_OTHER_INVENTORY, false); + assertNotNull(tag(inventory.getItem(0), BreweryListener.DISTILLED)); + + server.getScheduler().performOneTick(); + + assertEquals(0, count(player, "brew_distill")); + assertNull(tag(inventory.getItem(0), BreweryListener.DISTILLED)); + take(player, inventory); + remove(inventory); + server.getScheduler().performOneTick(); + assertEquals(1, count(player, "brew_distill")); + } + + @Test + void aSecondTakeBeforeConfirmationCreditsOnlyThePlayerWhoActuallyRemovedIt() { + PlayerMock other = server.addPlayer("Alex"); + reveal(other); + Inventory inventory = brewing(); + inventory.setItem(0, new ItemStack(Material.POTION)); + take(player, inventory); + Long first = tag(inventory.getItem(0), BreweryListener.DISTILLED); + take(other, inventory); + Long second = tag(inventory.getItem(0), BreweryListener.DISTILLED); + assertFalse(first.equals(second)); + remove(inventory); + + server.getScheduler().performOneTick(); + + assertEquals(0, count(player, "brew_distill")); + assertEquals(1, count(other, "brew_distill")); + } + + @Test + void agedAndDistilledCreditUseSeparatePersistentTags() { + Inventory aged = barrel(); + aged.setItem(0, new ItemStack(Material.POTION)); + take(player, aged); + ItemStack bottle = remove(aged); + server.getScheduler().performOneTick(); + assertEquals(1, count(player, "brew_age")); + assertNotNull(tag(bottle, BreweryListener.AGED)); + Inventory distilled = brewing(); + distilled.setItem(0, bottle); + take(player, distilled); + bottle = remove(distilled); + server.getScheduler().performOneTick(); + assertEquals(1, count(player, "brew_age")); + assertEquals(1, count(player, "brew_distill")); + assertNotNull(tag(bottle, BreweryListener.AGED)); + assertNotNull(tag(bottle, BreweryListener.DISTILLED)); + } + + @Test + void replacingTheSourceSlotDoesNotEraseTheReplacementItemsMetadata() { + Inventory inventory = brewing(); + inventory.setItem(0, new ItemStack(Material.POTION)); + take(player, inventory); + ItemStack replacement = new ItemStack(Material.PAPER); + var meta = replacement.getItemMeta(); + meta.setDisplayName("Replacement"); + replacement.setItemMeta(meta); + inventory.setItem(0, replacement); + + server.getScheduler().performOneTick(); + + assertEquals(1, count(player, "brew_distill")); + assertEquals(replacement, inventory.getItem(0)); + } + + @Test + void aPotionWithUnavailableMetadataIsIgnoredWithoutAWriteOrCredit() { + Inventory inventory = brewing(); + inventory.setItem(0, new ItemStack(Material.POTION)); + player.openInventory(inventory); + ItemStack incomplete = mock(ItemStack.class); + when(incomplete.getType()).thenReturn(Material.POTION); + when(incomplete.getItemMeta()).thenReturn(null); + InventoryClickEvent event = spy(new InventoryClickEvent(player.getOpenInventory(), + InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL)); + // getItemMeta is nullable at the Bukkit boundary, even though ordinary potions supply it. + doReturn(incomplete).when(event).getCurrentItem(); + + server.getPluginManager().callEvent(event); + server.getScheduler().performOneTick(); + + verify(incomplete, never()).setItemMeta(any()); + assertNull(tag(inventory.getItem(0), BreweryListener.DISTILLED)); + assertEquals(0, count(player, "brew_distill")); + } + + @Test + void cancelledNonTakingOutsideBottomAndNonBrewClicksNeverEarnCredit() { + Inventory inventory = brewing(); + inventory.setItem(0, new ItemStack(Material.POTION)); + click(player, inventory, 0, InventoryAction.PICKUP_ALL, true); + click(player, inventory, 0, InventoryAction.PLACE_ALL, false); + click(player, inventory, -999, InventoryAction.PICKUP_ALL, false); + click(player, inventory, inventory.getSize(), InventoryAction.PICKUP_ALL, false); + inventory.setItem(0, null); + take(player, inventory); + inventory.setItem(0, new ItemStack(Material.PAPER)); + take(player, inventory); + inventory.setItem(3, new ItemStack(Material.POTION)); + click(player, inventory, 3, InventoryAction.PICKUP_ALL, false); + inventory.setItem(0, new ItemStack(Material.POTION)); + when(brew.getDistillRuns()).thenReturn((byte) 0); + take(player, inventory); + Brew known = brew; + brew = null; + take(player, inventory); + Inventory aged = barrel(); + aged.setItem(0, new ItemStack(Material.POTION)); + take(player, aged); + brew = known; + when(brew.getAgeTime()).thenReturn(0.99f); + take(player, aged); + Inventory ordinary = new SnapshotInventory(null, InventoryType.CHEST); + ordinary.setItem(0, new ItemStack(Material.POTION)); + take(player, ordinary); + + server.getScheduler().performOneTick(); + + assertEquals(0, count(player, "brew_distill")); + assertEquals(0, count(player, "brew_age")); + assertNull(tag(inventory.getItem(0), BreweryListener.DISTILLED)); + assertNull(tag(aged.getItem(0), BreweryListener.AGED)); + } + } + + private static ActivityDef activity(String id) { return new ActivityDef(id, id, Material.POTION, null, 1, 1, 0); } + + /** MockBukkit has no getHolder(boolean) implementation; these live inventories have no block snapshot. */ + private static final class SnapshotInventory extends InventoryMock { + SnapshotInventory(InventoryHolder holder, InventoryType type) { super(holder, type); } + @Override public InventoryHolder getHolder(boolean useSnapshot) { return getHolder(); } + } + + private static Field serverField() throws Exception { + Field field = Bukkit.class.getDeclaredField("server"); + field.setAccessible(true); + return field; + } + + static final class FailUnimplemented implements TestExecutionExceptionHandler, LifecycleMethodExecutionExceptionHandler { + @Override public void handleTestExecutionException(ExtensionContext context, Throwable thrown) throws Throwable { throw failed(thrown); } + @Override public void handleBeforeEachMethodExecutionException(ExtensionContext context, Throwable thrown) throws Throwable { throw failed(thrown); } + private static Throwable failed(Throwable thrown) { + return thrown instanceof UnimplementedOperationException + ? new AssertionError("MockBukkit does not implement a required Brewery event operation", thrown) : thrown; + } + } } diff --git a/src/test/java/net/tfminecraft/activitytf/listeners/CharacterChatListenerTest.java b/src/test/java/net/tfminecraft/activitytf/listeners/CharacterChatListenerTest.java index e926f7e..974143f 100644 --- a/src/test/java/net/tfminecraft/activitytf/listeners/CharacterChatListenerTest.java +++ b/src/test/java/net/tfminecraft/activitytf/listeners/CharacterChatListenerTest.java @@ -245,4 +245,9 @@ void historyIsPerPlayer() { assertTrue(say("I walk into the tavern and sit down")); assertTrue(listener.process(UUID.randomUUID(), "I walk into the tavern and sit down")); } + @Test void emptyTextHasNoPeriodAndMatchesAnotherEmptyText() { + assertFalse(CharacterChatListener.isPeriodic("")); + assertTrue(CharacterChatListener.similar("", "")); + } + } diff --git a/src/test/java/net/tfminecraft/activitytf/listeners/ListenerBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/activitytf/listeners/ListenerBoundaryCoverageTest.java new file mode 100644 index 0000000..1f54187 --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/listeners/ListenerBoundaryCoverageTest.java @@ -0,0 +1,498 @@ +package net.tfminecraft.activitytf.listeners; + +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; +import net.Indyuce.mmocore.api.event.PlayerExperienceGainEvent; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmocore.experience.EXPSource; +import net.Indyuce.mmocore.experience.Profession; +import net.tfminecraft.activitytf.config.ActivityConfiguration; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.models.Recorded; +import net.tfminecraft.advancedcrafting.lifecycle.AlloyCraftedEvent; +import net.tfminecraft.advancedcrafting.lifecycle.ItemCraftedEvent; +import net.tfminecraft.geigercounters.events.GeigerSourceCollectEvent; +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; +import net.tfminecraft.musicalinstruments.events.InstrumentPlayEvent; +import net.tfminecraft.rpcharacters.chat.CharacterChatEvent; +import net.tfminecraft.rpcharacters.injuries.CharacterInjuredEvent; +import net.tfminecraft.rpcharacters.professions.ProfessionUpgradePurchasedEvent; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; +import net.tfminecraft.vfbuilders.events.VehicleConstructEvent; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.CraftingInventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.plugin.IllegalPluginAccessException; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.exception.UnimplementedOperationException; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Handler; +import java.util.logging.LogRecord; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +@ExtendWith(ListenerBoundaryCoverageTest.FailUnimplemented.class) +class ListenerBoundaryCoverageTest { + record Credit(UUID player, String activity, int amount) {} + + Server previousServer; + ServerMock server; + JavaPlugin plugin; + PlayerMock player; + ActivityManager manager; + ActivityConfiguration configuration; + final List credits = new ArrayList<>(); + final List joined = new ArrayList<>(); + final List logs = new ArrayList<>(); + final Map crafts = new HashMap<>(); + final Map professions = new HashMap<>(); + final Set untracked = new HashSet<>(); + ActivityConfiguration.Keys keys = new ActivityConfiguration.Keys("2026-10-05", "2026-10-07"); + + @BeforeEach + void setUp() throws Exception { + previousServer = Bukkit.getServer(); + installServer(null); + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin("ListenerBoundaryTest"); + plugin.getLogger().addHandler(new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }); + player = server.addPlayer("Crafter"); + manager = mock(ActivityManager.class); + configuration = mock(ActivityConfiguration.class); + when(manager.getConfiguration()).thenReturn(configuration); + when(configuration.craftActivity(any(ItemStack.class))).thenAnswer(call -> + crafts.get(((ItemStack) call.getArgument(0)).getType())); + when(configuration.professionActivity(anyString())).thenAnswer(call -> professions.get(call.getArgument(0))); + when(configuration.currentKeys()).thenAnswer(call -> keys); + when(manager.isTracked(any(UUID.class), anyString())).thenAnswer(call -> + !untracked.contains(call.getArgument(1))); + doAnswer(call -> { + credits.add(new Credit(call.getArgument(0), call.getArgument(1), call.getArgument(2))); + return Recorded.RECORDED; + }).when(manager).recordAction(any(UUID.class), anyString(), anyInt()); + doAnswer(call -> { joined.add(call.getArgument(0)); return null; }).when(manager).onJoin(any(Player.class)); + } + + @AfterEach + void tearDown() throws Exception { + try { + MockBukkit.unmock(); + } finally { + installServer(previousServer); + } + } + + private static void installServer(Server server) throws Exception { + Field field = Bukkit.class.getDeclaredField("server"); + field.setAccessible(true); + field.set(null, server); + } + + void listen(Listener listener) { + server.getPluginManager().registerEvents(listener, plugin); + } + + void fire(Event event) { + server.getPluginManager().callEvent(event); + } + + Credit credit(String activity, int amount) { + return new Credit(player.getUniqueId(), activity, amount); + } + + @Test + void furniturePlacementCreditsThePlacerOnlyAfterSuccessfulPlacement() { + listen(new FurnitureListener(manager)); + FurniturePlaceEvent cancelled = new FurniturePlaceEvent(null, player); + cancelled.setCancelled(true); + fire(cancelled); + fire(new FurniturePlaceEvent(null, null)); + assertTrue(credits.isEmpty()); + + fire(new FurniturePlaceEvent(null, player)); + assertEquals(List.of(credit("furniture_place", 1)), credits); + } + + @Test + void endedBattleCreditsEveryParticipantOnceIncludingOfflinePlayers() { + listen(new BattleListener(manager)); + UUID offline = UUID.randomUUID(); + fire(new BattleEndedEvent("empty", BattleType.FIELD, null, null, Map.of(), null)); + assertTrue(credits.isEmpty()); + fire(new BattleEndedEvent("siege", BattleType.SIEGE, 4, "defenders", Map.of("attackers", 3), + Set.of(player.getUniqueId(), offline))); + assertEquals(Set.of(credit("battle_joined", 1), new Credit(offline, "battle_joined", 1)), + Set.copyOf(credits)); + assertEquals(2, credits.size()); + } + + @Test + void advancedCraftAndAlloyCreditTheirRecordedOwnerWithoutAnOnlinePlayer() { + listen(new AdvancedCraftListener(manager)); + UUID offline = UUID.randomUUID(); + fire(new ItemCraftedEvent(player, null, "test", "metal")); + fire(new AlloyCraftedEvent(null, null, "steel")); + assertTrue(credits.isEmpty()); + fire(new ItemCraftedEvent(null, offline, "sword", "metal")); + fire(new AlloyCraftedEvent(player, player.getUniqueId(), "steel")); + assertEquals(List.of(new Credit(offline, "advcraft_item", 1), credit("advcraft_item", 1)), credits); + } + + @Test + void vehicleConstructionCreditsTheRecordedConstructorEvenAfterLogout() { + listen(new VehicleBuildListener(manager)); + UUID offline = UUID.randomUUID(); + fire(new VehicleConstructEvent(null, null, null, null, null)); + assertTrue(credits.isEmpty()); + fire(new VehicleConstructEvent(offline, null, null, player.getLocation(), null)); + assertEquals(List.of(new Credit(offline, "vehicle_build", 1)), credits); + } + + @Test + void instrumentProfessionAndGeigerEventsCreditOneActionEach() { + listen(new InstrumentListener(manager)); + listen(new ProfessionUpgradeListener(manager)); + listen(new GeigerListener(manager)); + fire(new InstrumentPlayEvent(null, "flute", "block.note_block.flute")); + fire(new ProfessionUpgradePurchasedEvent(null, null, "smithing", 25)); + fire(new GeigerSourceCollectEvent(null, player.getLocation())); + assertTrue(credits.isEmpty()); + fire(new InstrumentPlayEvent(player, "flute", "block.note_block.flute")); + fire(new ProfessionUpgradePurchasedEvent(player, null, "smithing", 25)); + fire(new GeigerSourceCollectEvent(player, player.getLocation())); + assertEquals(List.of(credit("instrument", 1), credit("profession_upgrade", 1), credit("geiger", 1)), credits); + } + + @Test + void injuryCreditsTheVictimAndJoinPassesTheJoiningPlayer() { + listen(new InjuryListener(manager)); + listen(new JoinListener(manager)); + PlayerMock attacker = server.addPlayer("Attacker"); + joined.clear(); + fire(new CharacterInjuredEvent(null, attacker, null, "broken_arm")); + assertTrue(credits.isEmpty()); + fire(new CharacterInjuredEvent(player, attacker, null, "broken_arm")); + fire(new PlayerJoinEvent(player, "welcome")); + assertEquals(List.of(credit("injured", 1)), credits); + assertEquals(List.of(player), joined); + } + + @Test + void cancelledAndMalformedChatCannotPolluteTheValidRoleplayHistory() { + CharacterChatListener listener = new CharacterChatListener(manager); + listen(listener); + String text = "I carefully open the weathered wooden door"; + CharacterChatEvent cancelled = chat(player, "rp", text); + cancelled.setCancelled(true); + fire(cancelled); + fire(chat(null, "rp", text)); + fire(chat(player, "rp", null)); + fire(chat(player, "ooc", text)); + assertEquals(0, listener.historySize(player.getUniqueId())); + assertTrue(credits.isEmpty()); + fire(chat(player, "rp", text)); + fire(chat(player, "rp", text)); + assertEquals(List.of(credit("ic_chat", 1)), credits); + assertEquals(2, listener.historySize(player.getUniqueId())); + } + + CharacterChatEvent chat(Player sender, String channel, String text) { + return new CharacterChatEvent(sender, null, channel, text, "Character", Set.of(player), false, false); + } + + @Test + void realVotesCreditOnTheMainTickUsingTheUsersUuidBeforeTheDisplayName() { + VoteListener listener = new VoteListener(plugin, manager); + PlayerVoteEvent event = vote("not the username", true); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getJavaUUID()).thenReturn(player.getUniqueId()); + event.setVotingPluginUser(user); + + CompletableFuture.runAsync(() -> listener.onVote(event)).join(); + + assertTrue(credits.isEmpty()); + server.getScheduler().performOneTick(); + assertEquals(List.of(credit("vote", 1)), credits); + server.getScheduler().performOneTick(); + assertEquals(1, credits.size()); + } + + @Test + void votesWithoutUuidResolveOnlineAndPreviouslySeenOfflinePlayers() { + VoteListener listener = new VoteListener(plugin, manager); + PlayerMock offline = server.addPlayer("OfflineVoter"); + assertTrue(offline.disconnect()); + listener.onVote(vote(player.getName(), true)); + PlayerVoteEvent cached = vote(offline.getName(), true); + cached.setVotingPluginUser(mock(VotingPluginUser.class)); + listener.onVote(cached); + assertTrue(credits.isEmpty()); + + server.getScheduler().performOneTick(); + + assertEquals(List.of(credit("vote", 1), new Credit(offline.getUniqueId(), "vote", 1)), credits); + } + + @Test + void cancelledSyntheticAndDisabledPluginVotesDoNotScheduleCredits() { + VoteListener listener = new VoteListener(plugin, manager); + PlayerVoteEvent cancelled = vote(player.getName(), true); + cancelled.setCancelled(true); + listener.onVote(cancelled); + listener.onVote(vote(player.getName(), false)); + server.getPluginManager().disablePlugin(plugin); + listener.onVote(vote(player.getName(), true)); + server.getScheduler().performOneTick(); + assertTrue(credits.isEmpty()); + } + + @Test + void unknownOrMissingVoterNamesAreRejectedWithoutCreatingOfflineProfiles() { + VoteListener listener = new VoteListener(plugin, manager); + OfflinePlayer neverJoined = mock(OfflinePlayer.class); + when(neverJoined.getName()).thenReturn("NeverJoined"); + server.getPlayerList().addOfflinePlayer(neverJoined); + int cachedPlayers = server.getOfflinePlayers().length; + listener.onVote(vote(null, true)); + listener.onVote(vote(" ", true)); + listener.onVote(vote("Ghost\nName", true)); + listener.onVote(vote("NeverJoined", true)); + server.getScheduler().performOneTick(); + assertTrue(credits.isEmpty()); + assertEquals(cachedPlayers, server.getOfflinePlayers().length); + assertTrue(logs.stream().anyMatch(line -> line.contains("neither a user nor a player name")), logs.toString()); + assertTrue(logs.stream().anyMatch(line -> line.contains("Ghost?Name")), logs.toString()); + } + + @Test + void schedulerRejectionDuringShutdownDoesNotCreditOrEscapeTheVoteHandler() { + VoteListener listener = new VoteListener(plugin, manager); + for (RuntimeException failure : List.of(new IllegalPluginAccessException("disabled while scheduling"), + new IllegalStateException("scheduler shutting down"))) { + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTask(any(JavaPlugin.class), any(Runnable.class))).thenThrow(failure); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + assertDoesNotThrow(() -> listener.onVote(vote(player.getName(), true))); + } + } + server.getScheduler().performOneTick(); + assertTrue(credits.isEmpty()); + } + + PlayerVoteEvent vote(String name, boolean real) { + return new PlayerVoteEvent(null, name, "TestVoteSite", real); + } + + @Test + void professionXpCombinesFractionsButNeverCreditsCancelledOrUnmappedXp() { + listen(new ProfessionXpListener(manager)); + professions.put("mining", "profession_mining"); + fire(xp(player, null, 10)); + fire(xp(player, "unmapped", 10)); + fire(xp(null, "mining", 10)); + PlayerExperienceGainEvent cancelled = xp(player, "mining", 10); + cancelled.setCancelled(true); + fire(cancelled); + assertTrue(credits.isEmpty()); + fire(xp(player, "mining", 0.6)); + assertTrue(credits.isEmpty()); + fire(xp(player, "mining", 0.6)); + fire(xp(player, "mining", 1.8)); + assertEquals(List.of(credit("profession_mining", 1), credit("profession_mining", 2)), credits); + } + + @Test + void professionFractionCarryIsForgottenWhenUntrackedOnQuitAndAcrossDays() { + listen(new ProfessionXpListener(manager)); + professions.put("mining", "profession_mining"); + fire(xp(player, "mining", 0.8)); + untracked.add("profession_mining"); + fire(xp(player, "mining", 1)); + untracked.clear(); + fire(xp(player, "mining", 0.3)); + assertTrue(credits.isEmpty()); + fire(new PlayerQuitEvent(player, "bye")); + fire(xp(player, "mining", 0.8)); + assertTrue(credits.isEmpty()); + keys = new ActivityConfiguration.Keys("2026-10-05", "2026-10-08"); + fire(xp(player, "mining", 0.3)); + assertTrue(credits.isEmpty()); + fire(xp(player, "mining", 0.7)); + assertEquals(List.of(credit("profession_mining", 1)), credits); + } + + PlayerExperienceGainEvent xp(Player owner, String professionId, double amount) { + PlayerData data = mock(PlayerData.class); + when(data.getPlayer()).thenReturn(owner); + Profession profession = null; + if (professionId != null) { + profession = mock(Profession.class); + when(profession.getId()).thenReturn(professionId); + } + return new PlayerExperienceGainEvent(data, profession, amount, EXPSource.SOURCE); + } + + @Test + void normalCraftCreditsOneBatchAndShiftCraftUsesTheShortestIngredient() { + listen(new CraftListener(manager)); + crafts.put(Material.TORCH, "craft_torch"); + ItemStack result = new ItemStack(Material.TORCH, 4); + ItemStack[] matrix = {null, new ItemStack(Material.AIR), new ItemStack(Material.COAL, 7), + new ItemStack(Material.STICK, 3)}; + fire(craft(player, result, matrix, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1)); + fire(craft(player, result, matrix, 0, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1)); + assertEquals(List.of(credit("craft_torch", 4), credit("craft_torch", 12)), credits); + } + + @Test + void shiftCraftIsBoundedByMatchingStackSpaceAndNeedsACompleteBatch() { + listen(new CraftListener(manager)); + crafts.put(Material.TORCH, "craft_torch"); + ItemStack[] full = new ItemStack[36]; + Arrays.setAll(full, index -> new ItemStack(Material.STONE, 64)); + full[0] = new ItemStack(Material.TORCH, 58); + player.getInventory().setStorageContents(full); + ItemStack result = new ItemStack(Material.TORCH, 4); + ItemStack[] ingredients = {new ItemStack(Material.COAL, 16), new ItemStack(Material.STICK, 16)}; + fire(craft(player, result, ingredients, 0, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1)); + assertEquals(List.of(credit("craft_torch", 4)), credits); + player.getInventory().setItem(0, new ItemStack(Material.TORCH, 63)); + fire(craft(player, result, ingredients, 0, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1)); + assertEquals(1, credits.size()); + player.getInventory().setItem(0, new ItemStack(Material.TORCH, 64)); + fire(craft(player, result, ingredients, 0, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1)); + assertEquals(1, credits.size()); + } + + @Test + void absentCancelledUnmappedAndNonTakingCraftEventsCannotCredit() { + listen(new CraftListener(manager)); + crafts.put(Material.TORCH, "craft_torch"); + ItemStack result = new ItemStack(Material.TORCH, 4); + ItemStack[] ingredients = {new ItemStack(Material.STICK)}; + CraftItemEvent cancelled = craft(player, result, ingredients, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1); + cancelled.setCancelled(true); + fire(cancelled); + fire(craft(player, null, ingredients, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1)); + fire(craft(player, new ItemStack(Material.AIR), ingredients, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1)); + fire(craft(player, result, ingredients, 1, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1)); + fire(craft(player, result, ingredients, 0, ClickType.DOUBLE_CLICK, InventoryAction.COLLECT_TO_CURSOR, -1)); + fire(craft(player, result, ingredients, 0, ClickType.LEFT, InventoryAction.NOTHING, -1)); + fire(craft(player, new ItemStack(Material.PAPER), ingredients, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1)); + fire(craft(player, result, new ItemStack[4], 0, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1)); + assertTrue(credits.isEmpty()); + } + + @Test + void craftEventsRequireAPlayerInventoryActor() { + listen(new CraftListener(manager)); + crafts.put(Material.TORCH, "craft_torch"); + HumanEntity otherActor = mock(HumanEntity.class); + fire(craft(otherActor, new ItemStack(Material.TORCH, 4), new ItemStack[] {new ItemStack(Material.STICK)}, + 0, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1)); + assertTrue(credits.isEmpty()); + } + + @Test + void hotbarAndOffhandCraftSwapsOnlyCreditAnEmptyDestination() { + listen(new CraftListener(manager)); + crafts.put(Material.TORCH, "craft_torch"); + ItemStack result = new ItemStack(Material.TORCH, 4); + ItemStack[] ingredients = {new ItemStack(Material.STICK)}; + player.getInventory().setItem(2, new ItemStack(Material.STONE)); + player.getInventory().setItemInOffHand(new ItemStack(Material.STONE)); + fire(craft(player, result, ingredients, 0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2)); + fire(craft(player, result, ingredients, 0, ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, -1)); + assertTrue(credits.isEmpty()); + player.getInventory().setItem(2, null); + player.getInventory().setItemInOffHand(null); + fire(craft(player, result, ingredients, 0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2)); + fire(craft(player, result, ingredients, 0, ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, -1)); + assertEquals(List.of(credit("craft_torch", 4), credit("craft_torch", 4)), credits); + } + + CraftItemEvent craft(HumanEntity actor, ItemStack result, ItemStack[] matrix, int slot, + ClickType click, InventoryAction action, int hotbar) { + CraftingInventory inventory = mock(CraftingInventory.class); + when(inventory.getMatrix()).thenReturn(matrix); + InventoryView view = mock(InventoryView.class); + when(view.getPlayer()).thenReturn(actor); + when(view.getTopInventory()).thenReturn(inventory); + when(view.getItem(slot)).thenReturn(result); + ItemStack recipeResult = result == null || result.getType().isAir() ? new ItemStack(Material.TORCH) : result; + ShapelessRecipe recipe = new ShapelessRecipe(NamespacedKey.minecraft("activity_test"), recipeResult); + return new CraftItemEvent(recipe, view, InventoryType.SlotType.RESULT, slot, click, action, hotbar); + } + + static final class FailUnimplemented implements TestExecutionExceptionHandler, + LifecycleMethodExecutionExceptionHandler { + @Override + public void handleTestExecutionException(ExtensionContext context, Throwable failure) throws Throwable { + throw checked(failure); + } + + @Override + public void handleBeforeEachMethodExecutionException(ExtensionContext context, Throwable failure) throws Throwable { + throw checked(failure); + } + + private static Throwable checked(Throwable failure) { + return failure instanceof UnimplementedOperationException + ? new AssertionError("Listener fixture requires an unsupported MockBukkit operation", failure) : failure; + } + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerCoverageTest.java b/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerCoverageTest.java new file mode 100644 index 0000000..9486709 --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerCoverageTest.java @@ -0,0 +1,555 @@ +package net.tfminecraft.activitytf.managers; + +import net.tfminecraft.activitytf.hooks.TLibsItems; +import net.tfminecraft.activitytf.models.PlayerData; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.entity.Item; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.exception.UnimplementedOperationException; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.logging.Handler; +import java.util.logging.LogRecord; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.*; + +/** Integration coverage uses real configuration, storage, inventory and scheduled work. */ +@ExtendWith(ActivityManagerCoverageTest.FailUnimplemented.class) +class ActivityManagerCoverageTest { + private Server previousServer; + private Object previousManager; + private ServerMock server; + private JavaPlugin plugin; + private ActivityManager manager; + private PlayerMock player; + private Path folder; + private YamlConfiguration settings; + private final List logs = new ArrayList<>(); + + @BeforeEach + void startServer() throws Exception { + previousServer = Bukkit.getServer(); + previousManager = field(ActivityManager.class, "instance").get(null); + field(Bukkit.class, "server").set(null, null); + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin("ManagerCoverage"); + folder = plugin.getDataFolder().toPath(); + Files.createDirectories(folder); + Files.copy(Path.of("src/main/resources/messages.yml"), folder.resolve("messages.yml")); + plugin.getLogger().addHandler(new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }); + settings = new YamlConfiguration(); + settings.set("activities.playtime.display", "Playtime"); + settings.set("activities.playtime.material", "CLOCK"); + settings.set("activities.playtime.every", 1); + settings.set("activities.playtime.points", 1); + settings.set("bar.max", 50); + settings.set("bar.daily-max", 50); + settings.set("bar.milestones", List.of(10, 20)); + settings.set("playtime.afk-minutes", 5); + settings.set("save-interval-minutes", 60); + settings.set("sounds.goal-complete", "ENTITY_EXPERIENCE_ORB_PICKUP"); + settings.set("sounds.bar-complete", "UI_TOAST_CHALLENGE_COMPLETE"); + settings.set("rewards.drops.drop_1", "DIAMOND 2"); + settings.set("rewards.drops.drop_2", "EMERALD 3"); + ActivityManager.reportedItemPaths.clear(); + ActivityManager.reportedBrokenCommands.clear(); + player = server.addPlayer("Steve"); + } + + @AfterEach + void stopServer() throws Exception { + try { + if (manager != null) { + server.getScheduler().waitAsyncTasksFinished(); + manager.shutdown(); + } + MockBukkit.unmock(); + } finally { + field(ActivityManager.class, "instance").set(null, previousManager); + field(Bukkit.class, "server").set(null, previousServer); + } + } + + private void load(boolean loadStore) throws Exception { + settings.save(folder.resolve("config.yml").toFile()); + Constructor constructor = ActivityManager.class.getDeclaredConstructor(JavaPlugin.class); + constructor.setAccessible(true); + manager = constructor.newInstance(plugin); + manager.getConfiguration().load(); + if (loadStore) manager.getStore().load(); + } + + private PlayerData data() { return manager.getStore().get(player.getUniqueId()); } + private Path playersFile() { return folder.resolve("players.yml"); } + private YamlConfiguration saved() { return YamlConfiguration.loadConfiguration(playersFile().toFile()); } + private int claimedOnDisk() { return saved().getInt("players." + player.getUniqueId() + ".claimed-points"); } + private long warnings(String part) { return logs.stream().filter(line -> line.contains(part)).count(); } + private void points(int count) { data().addPoints(count, manager.getConfiguration().barMax()); } + private void reveal(Player target) { + for (int i = 0; i < manager.tasks(target.getUniqueId()).tasks().size(); i++) { + manager.reveal(target.getUniqueId(), i); + } + } + private void pool(String command) { + settings.set("rewards.pool", List.of(Map.of("weight", 1, "display", "Console reward", "commands", List.of(command)))); + } + private void command(String name, Consumer execute) { + server.getCommandMap().register("coverage", new Command(name) { + @Override public boolean execute(CommandSender sender, String label, String[] args) { + assertSame(server.getConsoleSender(), sender); + execute.accept(args); + return true; + } + }); + } + private void assertFailureMessage() { + assertEquals(manager.getConfiguration().messages().get("reward-failed"), player.nextMessage()); + } + private void makePlayersPathUnwritable() throws Exception { + Files.deleteIfExists(playersFile()); + Files.createDirectory(playersFile()); + } + private void restorePlayersPath() throws Exception { Files.delete(playersFile()); } + + @Test + void unloadedStoreRejectsClaimsOnceWithoutDispatchOrWriting() throws Exception { + load(false); + points(20); + assertEquals(0, manager.claim(player)); + assertEquals(0, manager.claim(player)); + assertEquals(0, data().claimedPoints()); + assertTrue(player.getInventory().isEmpty()); + assertFalse(Files.exists(playersFile())); + assertEquals(1, warnings("Refusing every reward claim")); + assertFailureMessage(); + assertFailureMessage(); + } + + @Test + void missingDefaultAndNamedPoolsStayClaimableAndWarnOnlyOnce() throws Exception { + settings.set("rewards.drops.drop_1", "pool"); + settings.set("rewards.drops.drop_2", "pool_missing"); + load(true); + points(20); + logs.clear(); + assertEquals(0, manager.claim(player)); + assertEquals(0, manager.claim(player)); + assertEquals(0, data().claimedPoints()); + assertEquals(1, warnings("Handed nothing")); + assertTrue(logs.stream().anyMatch(line -> line.contains("rewards.pool") && line.contains("rewards.pools.pool_missing"))); + assertEquals(manager.getConfiguration().messages().get("reward-unconfigured"), player.nextMessage()); + assertTrue(player.getInventory().isEmpty()); + } + + @Test + void unsafeCommandOnlyPoolCannotClaimEitherMilestoneOrDailyReward() throws Exception { + player = server.addPlayer("Unsafe Name"); + settings.set("rewards.drops.drop_1", "pool"); + settings.set("daily-reward.groups.vip", "pool"); + pool("reward %player%"); + load(true); + player.addAttachment(plugin, "group.vip", true); + points(10); + reveal(player); + assertEquals(0, manager.claim(player)); + assertFalse(manager.claimDailyReward(player)); + assertEquals(0, data().claimedPoints()); + assertFalse(data().dailyRewardClaimed()); + assertTrue(player.getInventory().isEmpty()); + assertFailureMessage(); + assertFailureMessage(); + assertEquals(1, warnings("No reward command could be run")); + assertEquals(1, warnings("No daily reward command could be run")); + assertFalse(Files.exists(playersFile())); + } + + @Test + void failedPrepaymentSaveRestoresClaimAndHandsNothingOver() throws Exception { + load(true); + points(20); + makePlayersPathUnwritable(); + try { + assertEquals(0, manager.claim(player)); + assertEquals(0, data().claimedPoints()); + assertTrue(player.getInventory().isEmpty()); + assertFailureMessage(); + assertEquals(1, warnings("nothing was dispatched")); + } finally { restorePlayersPath(); } + assertEquals(2, manager.claim(player)); + assertEquals(20, claimedOnDisk()); + assertEquals(2, player.getInventory().getItem(0).getAmount()); + assertEquals(Material.EMERALD, player.getInventory().getItem(1).getType()); + } + + @Test + void successfulPoolCommandsObserveSavedClaimAndCannotBePaidTwice() throws Exception { + settings.set("rewards.drops.drop_1", "pool"); + pool("reward %player% %uuid%"); + AtomicInteger paid = new AtomicInteger(); + command("reward", args -> { + assertEquals(20, claimedOnDisk(), "all due milestones must be saved before the first payout"); + assertArrayEquals(new String[]{player.getName(), player.getUniqueId().toString()}, args); + paid.incrementAndGet(); + }); + load(true); + points(20); + assertEquals(2, manager.claim(player)); + assertEquals(1, paid.get()); + assertEquals(20, data().claimedPoints()); + assertEquals(Material.EMERALD, player.getInventory().getItem(0).getType()); + assertNotNull(player.nextMessage()); + assertNotNull(player.nextMessage()); + assertEquals(1, player.getHeardSounds().size()); + assertEquals(0, manager.claim(player)); + assertEquals(1, paid.get()); + } + + @Test + void noSuccessfulPayoutQueuesRollbackToDiskAndCanRetry() throws Exception { + settings.set("rewards.drops.drop_1", "pool"); + pool("reward"); + AtomicInteger attempts = new AtomicInteger(); + command("reward", args -> { + assertEquals(10, claimedOnDisk()); + if (attempts.getAndIncrement() == 0) throw new IllegalStateException("provider failed before payment"); + }); + load(true); + points(10); + assertEquals(0, manager.claim(player)); + assertEquals(0, data().claimedPoints()); + server.getScheduler().waitAsyncTasksFinished(); + assertEquals(0, claimedOnDisk()); + assertFailureMessage(); + assertEquals(1, warnings("paid none of the milestones")); + assertEquals(1, manager.claim(player)); + assertEquals(10, claimedOnDisk()); + assertEquals(2, attempts.get()); + } + + @Test + void partiallyPaidMilestonesKeepThePaidClaimAndPersistTheUnpaidRemainder() throws Exception { + settings.set("rewards.drops.drop_2", "m.material.missing"); + load(true); + points(20); + assertEquals(1, manager.claim(player)); + assertEquals(10, data().claimedPoints()); + assertEquals(10, claimedOnDisk()); + assertEquals(Material.DIAMOND, player.getInventory().getItem(0).getType()); + assertEquals(2, player.getInventory().getItem(0).getAmount()); + assertNotNull(player.nextMessage()); + assertFailureMessage(); + assertEquals(0, manager.claim(player)); + server.getScheduler().waitAsyncTasksFinished(); + assertEquals(10, claimedOnDisk()); + assertEquals(2, player.getInventory().getItem(0).getAmount()); + } + + @Test + void failedPartialRollbackReportsDiskMismatchWithoutRepeatingPaidMilestone() throws Exception { + settings.set("rewards.drops.drop_1", "pool"); + settings.set("rewards.drops.drop_2", "m.material.missing"); + pool("reward"); + AtomicInteger paid = new AtomicInteger(); + command("reward", args -> { + assertEquals(20, claimedOnDisk()); + paid.incrementAndGet(); + try { makePlayersPathUnwritable(); } catch (Exception e) { throw new AssertionError(e); } + }); + load(true); + points(20); + try { + assertEquals(1, manager.claim(player)); + assertEquals(10, data().claimedPoints()); + assertEquals(1, paid.get()); + assertEquals(1, warnings("is out of sync")); + assertNotNull(player.nextMessage()); + assertFailureMessage(); + } finally { restorePlayersPath(); } + assertEquals(0, manager.claim(player)); + server.getScheduler().waitAsyncTasksFinished(); + assertEquals(1, paid.get()); + assertEquals(10, claimedOnDisk()); + } + + @Test + void partiallyPaidSpinsKeepMilestoneClaimedAndReportTheOwedSpin() throws Exception { + settings.set("rewards.drops.drop_1", "pool"); + settings.set("rewards.multiplier", 2); + settings.set("rewards.multiplier-pools", List.of("pool")); + pool("reward"); + AtomicInteger attempts = new AtomicInteger(); + command("reward", args -> { + if (attempts.incrementAndGet() == 2) throw new IllegalStateException("second spin failed"); + }); + load(true); + points(10); + assertEquals(1, manager.claim(player)); + assertEquals(2, attempts.get()); + assertEquals(10, claimedOnDisk()); + assertEquals(10, data().claimedPoints()); + assertEquals(1, warnings("1 spin is owed")); + assertNotNull(player.nextMessage()); + assertFailureMessage(); + assertTrue(player.getHeardSounds().isEmpty(), "incomplete payouts must not play the success sound"); + assertEquals(0, manager.claim(player)); + assertEquals(2, attempts.get()); + } + + private void enableTLibsDependencies() { + for (String name : List.of("TLibs", "MMOItems", "MythicLib")) MockBukkit.createMockPlugin(name); + } + + @Test + void dailyRewardProviderFailureLeavesRewardUnclaimedAndCanRecover() throws Exception { + enableTLibsDependencies(); + settings.set("daily-reward.groups.vip", "m.material.steel 3"); + settings.set("rewards.multiplier", 2); + load(true); + reveal(player); + player.addAttachment(plugin, "group.vip", true); + try (var items = mockStatic(TLibsItems.class)) { + items.when(() -> TLibsItems.item("m.material.steel")).thenThrow(new LinkageError("provider unavailable")); + assertFalse(manager.claimDailyReward(player)); + assertFalse(data().dailyRewardClaimed()); + assertTrue(player.getInventory().isEmpty()); + assertFailureMessage(); + items.when(() -> TLibsItems.item("m.material.steel")).thenReturn(new ItemStack(Material.IRON_INGOT)); + assertTrue(manager.claimDailyReward(player)); + } + assertEquals(6, player.getInventory().getItem(0).getAmount()); + assertTrue(data().dailyRewardClaimed()); + assertTrue(saved().getBoolean("players." + player.getUniqueId() + ".daily-reward-claimed")); + assertTrue(player.nextMessage().contains("x6")); + } + + @Test + void inventoryResyncFailureDoesNotUndoAlreadyDeliveredReward() throws Exception { + load(true); + points(10); + Player boundary = mock(Player.class, delegatesTo(player)); + doThrow(new IllegalStateException("resync failed")).when(boundary).updateInventory(); + assertEquals(1, manager.claim(boundary)); + assertEquals(2, player.getInventory().getItem(0).getAmount()); + assertEquals(10, claimedOnDisk()); + assertEquals(1, warnings("Could not resync the inventory")); + assertEquals(0, manager.claim(boundary)); + } + + @Test + void fullInventoryDropsEveryRewardStackWithTheRecipientsOwnership() throws Exception { + settings.set("rewards.drops.drop_1", "DIAMOND 64"); + settings.set("rewards.multiplier", 2); + load(true); + points(10); + for (int slot = 0; slot < player.getInventory().getStorageContents().length; slot++) { + player.getInventory().setItem(slot, new ItemStack(Material.DIRT, 64)); + } + // MockBukkit addItem also scans equipment/extra slots. Model Bukkit's full-storage + // result at that API boundary while retaining the real items, player and world drops. + PlayerInventory inventory = mock(PlayerInventory.class, delegatesTo(player.getInventory())); + doAnswer(call -> { + ItemStack[] offered = (ItemStack[]) call.getRawArguments()[0]; + assertEquals(1, offered.length); + assertTrue(java.util.Arrays.stream(player.getInventory().getStorageContents()) + .allMatch(stack -> stack != null && stack.getType() == Material.DIRT && stack.getAmount() == 64)); + return new HashMap<>(Map.of(0, offered[0].clone())); + }).when(inventory).addItem(any(ItemStack[].class)); + Player boundary = mock(Player.class, delegatesTo(player)); + doReturn(inventory).when(boundary).getInventory(); + // ItemMock also leaves ownership setters unimplemented. Spawn the real entity, + // then verify the ownership calls on an API adapter passed to the production callback. + World world = mock(World.class, delegatesTo(player.getWorld())); + List ownershipCalls = new ArrayList<>(); + doAnswer(call -> { + Item actual = player.getWorld().dropItemNaturally(call.getArgument(0), call.getArgument(1)); + Item drop = mock(Item.class, delegatesTo(actual)); + doNothing().when(drop).setOwner(any()); + doNothing().when(drop).setThrower(any()); + Consumer initialize = call.getArgument(2); + initialize.accept(drop); + ownershipCalls.add(drop); + return drop; + }).when(world).dropItemNaturally(any(Location.class), any(ItemStack.class), any()); + doReturn(world).when(boundary).getWorld(); + assertEquals(1, manager.claim(boundary)); + var drops = player.getWorld().getEntitiesByClass(Item.class); + assertEquals(2, drops.size()); + for (Item drop : drops) { + assertEquals(Material.DIAMOND, drop.getItemStack().getType()); + assertEquals(64, drop.getItemStack().getAmount()); + assertTrue(drop.getLocation().distance(player.getLocation()) < 2); + } + assertEquals(2, ownershipCalls.size()); + for (Item drop : ownershipCalls) { + verify(drop).setOwner(player.getUniqueId()); + verify(drop).setThrower(player.getUniqueId()); + } + assertEquals(0, warnings("threw for")); + assertEquals(10, claimedOnDisk()); + assertFalse(player.getInventory().contains(Material.DIAMOND)); + assertEquals(0, manager.claim(boundary)); + assertEquals(2, player.getWorld().getEntitiesByClass(Item.class).size()); + } + + @Test + void minuteTimerCreditsOnlyRevealedActivePlayersAndCanDisableTheAfkCheck() throws Exception { + IdlePlayer active = new IdlePlayer(server, "Active", Duration.ofMinutes(4)); + IdlePlayer idle = new IdlePlayer(server, "Idle", Duration.ofMinutes(5)); + IdlePlayer hidden = new IdlePlayer(server, "Hidden", Duration.ZERO); + server.addPlayer(active); + server.addPlayer(idle); + server.addPlayer(hidden); + player.disconnect(); + load(true); + manager.initialize(); + manager.initialize(); // Reinitialization must replace, not multiply, the playtime timer. + reveal(active); + reveal(idle); + manager.tasks(hidden.getUniqueId()); + server.getScheduler().performTicks(1200); + assertEquals(1, manager.tasks(active.getUniqueId()).count("playtime")); + assertEquals(0, manager.tasks(idle.getUniqueId()).count("playtime")); + assertEquals(0, manager.tasks(hidden.getUniqueId()).count("playtime")); + assertEquals(1, active.getHeardSounds().size()); + settings.set("playtime.afk-minutes", 0); + settings.save(folder.resolve("config.yml").toFile()); + manager.reload(); + server.getScheduler().performTicks(1200); + assertEquals(2, manager.tasks(active.getUniqueId()).count("playtime")); + assertEquals(1, manager.tasks(idle.getUniqueId()).count("playtime")); + assertEquals(0, manager.tasks(hidden.getUniqueId()).count("playtime")); + manager.shutdown(); + server.getScheduler().performTicks(1200); + assertEquals(2, manager.tasks(active.getUniqueId()).count("playtime")); + } + + @Test + void joiningPlayerSeesOnlyOutstandingClaimAndMilestoneCreditPlaysBothSounds() throws Exception { + load(true); + manager.onJoin(player); + assertNull(player.nextMessage()); + manager.recordPoints(player.getUniqueId(), 10); + assertNotNull(player.nextMessage()); + assertEquals(manager.getConfiguration().messages().get("reward-ready"), player.nextMessage()); + assertEquals(2, player.getHeardSounds().size()); + manager.onJoin(player); + assertEquals(manager.getConfiguration().messages().get("reward-ready"), player.nextMessage()); + assertEquals(1, manager.claim(player)); + assertNotNull(player.nextMessage()); + manager.onJoin(player); + assertNull(player.nextMessage()); + } + + @Test + void clickCommandsEnforcePlayerCooldownWithoutDebitingAnotherPlayersBudget() throws Exception { + settings.set("activities.playtime.click-commands", List.of("clickreward")); + settings.set("click-command-cooldown-millis", 60_000); + settings.set("click-commands-per-second", 200); + AtomicInteger runs = new AtomicInteger(); + command("clickreward", args -> runs.incrementAndGet()); + load(true); + // MockBukkit's closed view has no top inventory; Paper supplies a crafting inventory. + player.openInventory(server.createInventory(null, 9)); + assertFalse(manager.runClickCommands(player, -1)); + assertFalse(manager.runClickCommands(player, 1)); + assertFalse(manager.runClickCommands(player, 0)); + reveal(player); + assertTrue(manager.runClickCommands(player, 0)); + assertFalse(manager.runClickCommands(player, 0)); + assertEquals(0, runs.get(), "commands wait for the next server tick"); + server.getScheduler().performOneTick(); + assertEquals(1, runs.get()); + manager.forgetClickCooldown(player.getUniqueId()); + assertTrue(manager.runClickCommands(player, 0)); + server.getScheduler().performOneTick(); + assertEquals(2, runs.get()); + } + + @Test + void clickCommandsUseOneServerWideBudgetAcrossPlayers() throws Exception { + settings.set("activities.playtime.click-commands", List.of("clickreward")); + settings.set("click-commands-per-second", 1); + AtomicInteger runs = new AtomicInteger(); + command("clickreward", args -> runs.incrementAndGet()); + load(true); + // MockBukkit's closed view has no top inventory; Paper supplies a crafting inventory. + player.openInventory(server.createInventory(null, 9)); + PlayerMock second = server.addPlayer("Alex"); + reveal(player); + reveal(second); + assertTrue(manager.runClickCommands(player, 0)); + assertFalse(manager.runClickCommands(second, 0)); + server.getScheduler().performOneTick(); + assertEquals(1, runs.get()); + assertEquals(1, manager.clickCooldownEntries(), "budget rejection must not start the second player's cooldown"); + } + + @Test + void revealedTaskWithoutCommandsDoesNotScheduleAnything() throws Exception { + load(true); + reveal(player); + assertFalse(manager.runClickCommands(player, 0)); + assertEquals(0, manager.clickCooldownEntries()); + server.getScheduler().performOneTick(); + assertNull(player.nextMessage()); + } + + private static final class IdlePlayer extends PlayerMock { + private final Duration idle; + private IdlePlayer(ServerMock server, String name, Duration idle) { + super(server, name); + this.idle = idle; + } + @Override public Duration getIdleDuration() { return idle; } + } + + private static Field field(Class owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + static final class FailUnimplemented implements TestExecutionExceptionHandler { + @Override public void handleTestExecutionException(ExtensionContext context, Throwable failure) throws Throwable { + if (failure instanceof UnimplementedOperationException) { + throw new AssertionError("Fixture encountered an unimplemented Bukkit API", failure); + } + throw failure; + } + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerRewardItemsTest.java b/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerRewardItemsTest.java index 1efa4fb..431ac09 100644 --- a/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerRewardItemsTest.java +++ b/src/test/java/net/tfminecraft/activitytf/managers/ActivityManagerRewardItemsTest.java @@ -399,9 +399,18 @@ void theClientIsResyncedOnceAfterAHandover() { } @Test - void theBlockOnlyGuardPassesAnythingThroughWithoutARegistry() { + void theBlockOnlyGuardUsesTheRealRegistryWhenAvailable() { assertTrue(ActivityManager.isItem(new TestStack(Material.DIAMOND, 1))); - assertTrue(ActivityManager.isItem(new TestStack(Material.CARROTS, 1))); + assertFalse(ActivityManager.isItem(new TestStack(Material.CARROTS, 1))); + } + + @Test + void theBlockOnlyGuardRetainsCompatibilityWhenTheRegistryIsUnavailable() { + Material unresolved = org.mockito.Mockito.mock(Material.class); + org.mockito.Mockito.when(unresolved.isItem()).thenThrow(new IllegalStateException("Registry unavailable")); + ItemStack stack = org.mockito.Mockito.mock(ItemStack.class); + org.mockito.Mockito.when(stack.getType()).thenReturn(unresolved); + assertTrue(ActivityManager.isItem(stack)); } @Test diff --git a/src/test/java/net/tfminecraft/activitytf/models/PlayerDataTest.java b/src/test/java/net/tfminecraft/activitytf/models/PlayerDataTest.java index b8b6549..556b989 100644 --- a/src/test/java/net/tfminecraft/activitytf/models/PlayerDataTest.java +++ b/src/test/java/net/tfminecraft/activitytf/models/PlayerDataTest.java @@ -1104,4 +1104,11 @@ void allRevealedNeedsEveryTaskOfANonEmptyDraw() { assertTrue(data.allRevealed()); assertFalse(new PlayerData("w", "d").allRevealed()); } + @Test void moreGuaranteedTasksThanAvailableSlotsLeaveTheExistingGuaranteedDrawStable() { + List selected=ids(PlayerData.TASKS_PER_DAY); + PlayerData data=new PlayerData(0,0,WEEK,DAY,0,Map.of(),selected,List.of()); + assertFalse(data.ensureTasks(ids(30),ids(9),new Random(1))); + assertEquals(selected,data.tasks()); + } + } diff --git a/src/test/java/net/tfminecraft/activitytf/store/PlayerStoreTest.java b/src/test/java/net/tfminecraft/activitytf/store/PlayerStoreTest.java index cd8a3a3..dc05c43 100644 --- a/src/test/java/net/tfminecraft/activitytf/store/PlayerStoreTest.java +++ b/src/test/java/net/tfminecraft/activitytf/store/PlayerStoreTest.java @@ -1,20 +1,48 @@ package net.tfminecraft.activitytf.store; import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import net.tfminecraft.activitytf.config.ActivityConfiguration; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.managers.TestManagers; import net.tfminecraft.activitytf.models.ActivityDef; import net.tfminecraft.activitytf.models.PlayerData; +import java.nio.file.Files; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.StandardCopyOption; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; import java.util.List; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.function.Predicate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -406,4 +434,386 @@ void theDailyRewardFlagSurvivesASaveAndLoad() { ConfigurationSection root = PlayerStore.snapshot(Map.of(id, data)).getConfigurationSection("players"); assertTrue(PlayerStore.readEntry(root, id.toString(), BAR_MAX, DAILY_MAX, KNOWN).dailyRewardClaimed()); } + + @TempDir + Path directory; + + private static final ActivityDef VOTE = new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 5); + + @ParameterizedTest + @ValueSource(strings = {"players: erased\n", "players: [alice, bob]\n", "players: 42\n"}) + void malformedPlayersValuesAreBackedUpBeforeAnEmptyStoreCanReplaceThem(String original) throws Exception { + Fixture f = fixture(); + Files.writeString(f.file(), original); + + f.store.load(); + + assertTrue(f.store.isLoaded()); + List backups = f.backups(); + assertEquals(1, backups.size(), "a non-section players value must never be silently discarded"); + assertEquals(original, Files.readString(backups.getFirst())); + UUID id = UUID.randomUUID(); + f.store.get(id).recordForced(3, VOTE, BAR_MAX, List.of()); + assertTrue(f.store.saveNow()); + assertEquals(3, f.savedPoints(id)); + assertEquals(original, Files.readString(backups.getFirst())); + } + + @ParameterizedTest + @ValueSource(strings = {"players: [\n", "unexpected: valuable-data\n"}) + void invalidYamlAndUnknownRootDataArePreservedInBackups(String original) throws Exception { + Fixture f = fixture(); + Files.writeString(f.file(), original); + + f.store.load(); + + assertTrue(f.store.isLoaded()); + assertEquals(1, f.backups().size()); + assertEquals(original, Files.readString(f.backups().getFirst())); + assertEquals(original, Files.readString(f.file())); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("kept a copy"))); + } + + @ParameterizedTest + @ValueSource(strings = {"", "players: {}\n"}) + void emptyAndExplicitlyEmptyStoresAreAcceptedWithoutQuarantine(String original) throws Exception { + Fixture f = fixture(); + Files.writeString(f.file(), original); + + f.store.load(); + + assertTrue(f.store.isLoaded()); + assertTrue(f.backups().isEmpty()); + assertNull(f.store.peek(UUID.randomUUID())); + } + + @Test + void loadReadsValidEntriesAndSkipsScalarEntriesAndMalformedUuids() throws Exception { + Fixture f = fixture(); + UUID id = UUID.randomUUID(); + UUID scalarId = UUID.randomUUID(); + YamlConfiguration yaml = PlayerStore.snapshot(Map.of(id, + new PlayerData(7, 2, "old-week", "old-day", 3, Map.of("vote", 2), + List.of("vote", "gone"), List.of("vote", "gone"), 1))); + yaml.set("players.not-a-uuid.points", 8); + yaml.set("players." + scalarId, "not-an-entry"); + yaml.save(f.file().toFile()); + + f.store.load(); + + PlayerData data = f.store.peek(id); + assertEquals(7, data.points()); + assertEquals(3, data.claimedPoints()); + assertEquals(2, data.count("vote")); + assertEquals(List.of("vote"), data.tasks()); + assertTrue(data.isRevealed("vote")); + assertFalse(data.isRevealed("gone")); + assertEquals(1, data.rerolls()); + assertEquals("old-week", data.weekKey(), "peek must not roll stored data"); + assertNull(f.store.peek(scalarId)); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("Skipping malformed UUID 'not-a-uuid'"))); + assertTrue(f.backups().isEmpty()); + } + + @Test + void failedQuarantineDisablesEveryWriteAndPreservesTheOriginalFile() throws Exception { + Fixture f = fixture(); + String original = "players: lost-data\n"; + Files.writeString(f.file(), original); + org.junit.jupiter.api.Assumptions.assumeTrue(Files.getFileStore(f.folder).supportsFileAttributeView("posix")); + Set permissions = Files.getPosixFilePermissions(f.folder); + try { + Files.setPosixFilePermissions(f.folder, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_EXECUTE)); + org.junit.jupiter.api.Assumptions.assumeFalse(Files.isWritable(f.folder)); + f.store.load(); + assertFalse(f.store.isLoaded()); + assertTrue(f.backups().isEmpty()); + } finally { + Files.setPosixFilePermissions(f.folder, permissions); + } + + f.store.get(UUID.randomUUID()).recordForced(4, VOTE, BAR_MAX, List.of()); + assertFalse(f.store.saveNow()); + f.store.saveSoon(); + f.store.shutdown(); + assertEquals(original, Files.readString(f.file())); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("copying it aside failed"))); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("Saving is disabled"))); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("Not writing"))); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("Not saving"))); + } + + @Test + void aMissingStoreStartsEmptyAndDisabledPluginsSaveSynchronously() throws Exception { + Fixture f = fixture(); + UUID id = UUID.randomUUID(); + assertFalse(f.store.isLoaded()); + assertNull(f.store.rolled(id)); + f.store.load(); + assertTrue(f.store.isLoaded()); + assertFalse(Files.exists(f.file())); + PlayerData first = f.store.get(id); + assertSame(first, f.store.get(id)); + assertEquals(f.config.currentKeys().week(), first.weekKey()); + first.recordForced(4, VOTE, BAR_MAX, List.of()); + + f.store.saveSoon(); + + assertEquals(4, f.savedPoints(id)); + assertTrue(f.pending.isEmpty()); + assertFalse(Files.exists(f.folder.resolve("players.yml.tmp"))); + PlayerStore reloaded = new PlayerStore(f.plugin, f.config); + reloaded.load(); + assertEquals(4, reloaded.peek(id).points()); + assertEquals(first.daily(), reloaded.peek(id).daily()); + } + + @Test + void autoSaveUsesTheConfiguredIntervalAndCapturesOnlyDirtySnapshots() throws Exception { + Fixture f = fixture(); + when(f.plugin.isEnabled()).thenReturn(true); + f.store.load(); + f.store.startAutoSave(); + verify(f.scheduler).runTaskTimer(eq(f.plugin), any(Runnable.class), eq(2400L), eq(2400L)); + f.tick.run(); + assertTrue(f.pending.isEmpty()); + UUID id = UUID.randomUUID(); + PlayerData data = f.store.get(id); + data.recordForced(2, VOTE, BAR_MAX, List.of()); + f.tick.run(); + assertEquals(1, f.pending.size()); + data.recordForced(3, VOTE, BAR_MAX, List.of()); + assertFalse(Files.exists(f.file())); + f.pending.getFirst().run(); + assertEquals(2, f.savedPoints(id), "queued save must retain its original snapshot"); + f.tick.run(); + assertEquals(1, f.pending.size()); + + f.store.markDirty(); + f.tick.run(); + assertEquals(2, f.pending.size()); + f.pending.get(1).run(); + assertEquals(5, f.savedPoints(id)); + } + + @Test + void olderAsyncSnapshotsCannotOverwriteANewerCompletedSave() throws Exception { + Fixture f = fixture(); + when(f.plugin.isEnabled()).thenReturn(true); + f.store.load(); + UUID id = UUID.randomUUID(); + PlayerData data = f.store.get(id); + data.recordForced(2, VOTE, BAR_MAX, List.of()); + f.store.saveSoon(); + data.recordForced(3, VOTE, BAR_MAX, List.of()); + f.store.saveSoon(); + + f.pending.get(1).run(); + f.pending.getFirst().run(); + + assertEquals(5, f.savedPoints(id)); + data.recordForced(1, VOTE, BAR_MAX, List.of()); + f.store.saveSoon(); + data.recordForced(1, VOTE, BAR_MAX, List.of()); + assertTrue(f.store.saveNow()); + f.pending.get(2).run(); + assertEquals(7, f.savedPoints(id)); + } + + @Test + void shutdownCancelsAutoSaveWritesLatestStateAndRejectsLateSaves() throws Exception { + Fixture f = fixture(); + when(f.plugin.isEnabled()).thenReturn(true); + f.store.load(); + f.store.startAutoSave(); + UUID id = UUID.randomUUID(); + PlayerData data = f.store.get(id); + data.recordForced(2, VOTE, BAR_MAX, List.of()); + f.store.saveSoon(); + data.recordForced(3, VOTE, BAR_MAX, List.of()); + + f.store.shutdown(); + + verify(f.timer).cancel(); + assertEquals(5, f.savedPoints(id)); + f.pending.getFirst().run(); + data.recordForced(1, VOTE, BAR_MAX, List.of()); + f.store.saveSoon(); + assertEquals(1, f.pending.size()); + assertEquals(5, f.savedPoints(id)); + } + + @Test + void failedWritesKeepTheLastGoodFileAndRetryOnTheNextAutoSave() throws Exception { + Fixture f = fixture(); + when(f.plugin.isEnabled()).thenReturn(true); + f.store.load(); + UUID id = UUID.randomUUID(); + PlayerData data = f.store.get(id); + data.recordForced(3, VOTE, BAR_MAX, List.of()); + assertTrue(f.store.saveNow()); + String original = Files.readString(f.file()); + data.recordForced(2, VOTE, BAR_MAX, List.of()); + Path temp = Files.createDirectory(f.folder.resolve("players.yml.tmp")); + Path blocker = Files.writeString(temp.resolve("keep"), "occupied"); + assertFalse(f.store.saveNow()); + f.store.saveSoon(); + f.pending.getFirst().run(); + assertEquals(original, Files.readString(f.file())); + assertTrue(f.logs.stream().anyMatch(line -> line.contains("Failed to write players.yml"))); + f.store.startAutoSave(); + f.tick.run(); + assertEquals(2, f.pending.size(), "a failed async write must restore the dirty flag"); + + Files.delete(blocker); + Files.delete(temp); + f.pending.get(1).run(); + + assertEquals(5, f.savedPoints(id)); + f.tick.run(); + assertEquals(2, f.pending.size()); + } + + @Test + void aBlockedDestinationIsPreservedAndCanBeSavedAfterItIsRepaired() throws Exception { + Fixture f = fixture(); + f.store.load(); + UUID id = UUID.randomUUID(); + f.store.get(id).recordForced(3, VOTE, BAR_MAX, List.of()); + Files.createDirectory(f.file()); + Path blocker = Files.writeString(f.file().resolve("keep"), "preserved"); + + assertFalse(f.store.saveNow()); + assertEquals("preserved", Files.readString(blocker)); + Files.delete(blocker); + Files.delete(f.file()); + assertTrue(f.store.saveNow()); + assertEquals(3, f.savedPoints(id)); + } + + @Test + void anUnsupportedAtomicMoveFallsBackToReplacingTheRealFile() throws Exception { + Fixture f = fixture(); + f.store.load(); + UUID id = UUID.randomUUID(); + PlayerData data = f.store.get(id); + data.recordForced(2, VOTE, BAR_MAX, List.of()); + assertTrue(f.store.saveNow()); + data.recordForced(3, VOTE, BAR_MAX, List.of()); + Path temp = f.folder.resolve("players.yml.tmp"); + Path destination = f.file(); + + // This filesystem supports atomic moves. Simulate only that capability rejection; + // serialization, the fallback move, and the reloaded file all use the real filesystem. + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.move(temp, destination, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)) + .thenThrow(new AtomicMoveNotSupportedException(temp.toString(), destination.toString(), + "atomic rename unsupported")); + + assertTrue(f.store.saveNow()); + + files.verify(() -> Files.move(temp, destination, StandardCopyOption.REPLACE_EXISTING)); + } + assertEquals(5, f.savedPoints(id)); + assertFalse(Files.exists(temp)); + assertTrue(f.logs.isEmpty()); + } + + @Test + void rollingAndClampingLoadedPlayersMakesAutoSavePersistTheUpdatedState() throws Exception { + Fixture f = fixture(); + when(f.plugin.isEnabled()).thenReturn(true); + UUID old = UUID.randomUUID(); + UUID current = UUID.randomUUID(); + ActivityConfiguration.Keys keys = f.config.currentKeys(); + PlayerStore.snapshot(Map.of( + old, new PlayerData(8, 2, "old-week", "old-day", 3, Map.of("vote", 2)), + current, new PlayerData(8, 2, keys.week(), keys.day(), 3, Map.of("vote", 2)))) + .save(f.file().toFile()); + f.store.load(); + f.store.startAutoSave(); + f.tick.run(); + assertTrue(f.pending.isEmpty()); + assertEquals(8, f.store.peek(old).points()); + PlayerData rolled = f.store.rolled(old); + assertEquals(0, rolled.points()); + assertEquals(keys.week(), rolled.weekKey()); + assertEquals(keys.day(), rolled.dayKey()); + TestManagers.limits(f.manager, 4, DAILY_MAX); + assertEquals(4, f.store.rolled(current).points()); + + f.tick.run(); + f.pending.getFirst().run(); + + assertEquals(0, f.savedPoints(old)); + assertEquals(4, f.savedPoints(current)); + assertSame(rolled, f.store.rolled(old)); + f.tick.run(); + assertEquals(1, f.pending.size()); + } + + private Fixture fixture() throws Exception { + return new Fixture(Files.createDirectories(directory.resolve("plugin"))); + } + + private static final class Fixture { + final Path folder; + final JavaPlugin plugin = mock(JavaPlugin.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final BukkitTask timer = mock(BukkitTask.class); + final ActivityManager manager = TestManagers.manager(VOTE); + final ActivityConfiguration config = manager.getConfiguration(); + final PlayerStore store; + final List pending = new ArrayList<>(); + final List logs = new ArrayList<>(); + Runnable tick; + + Fixture(Path folder) throws Exception { + this.folder = folder; + TestManagers.limits(manager, BAR_MAX, DAILY_MAX); + var interval = ActivityConfiguration.class.getDeclaredField("saveIntervalMinutes"); + interval.setAccessible(true); + interval.set(config, 2); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }); + Server server = mock(Server.class); + when(plugin.getDataFolder()).thenReturn(folder.toFile()); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(scheduler); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(call -> { + tick = call.getArgument(1); + return timer; + }); + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))) + .thenAnswer(call -> { + pending.add(call.getArgument(1)); + return mock(BukkitTask.class); + }); + store = new PlayerStore(plugin, config); + } + + Path file() { return folder.resolve(PlayerStore.FILE); } + + List backups() throws Exception { + try (var paths = Files.list(folder)) { + return paths.filter(path -> path.getFileName().toString().startsWith("players.yml.corrupt-")) + .toList(); + } + } + + int savedPoints(UUID id) throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.load(file().toFile()); + return yaml.getInt("players." + id + ".points"); + } + } } diff --git a/src/test/java/net/tfminecraft/activitytf/testsupport/RegistryBootstrap.java b/src/test/java/net/tfminecraft/activitytf/testsupport/RegistryBootstrap.java new file mode 100644 index 0000000..a6dd6f9 --- /dev/null +++ b/src/test/java/net/tfminecraft/activitytf/testsupport/RegistryBootstrap.java @@ -0,0 +1,36 @@ +package net.tfminecraft.activitytf.testsupport; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.inventory.ItemStack; +import org.junit.platform.launcher.LauncherSession; +import org.junit.platform.launcher.LauncherSessionListener; +import org.mockbukkit.mockbukkit.MockBukkit; + +/** Populates Paper's cached registries before proxy-only tests can observe empty registries. */ +public final class RegistryBootstrap implements LauncherSessionListener { + + private static boolean initialized; + + @Override + public void launcherSessionOpened(LauncherSession session) { + synchronized (RegistryBootstrap.class) { + if (initialized) { + return; + } + if (Bukkit.getServer() != null) { + throw new IllegalStateException("Registry bootstrap must run before a test installs Bukkit.server"); + } + try { + MockBukkit.mock(); + // MockBukkit loads registry entries lazily; resolve them before clearing its server. + new ItemStack(Material.PAPER); + Sound.BLOCK_NOTE_BLOCK_BIT.getKey(); + } finally { + MockBukkit.unmock(); + } + initialized = true; + } + } +} diff --git a/src/test/java/net/tfminecraft/activitytf/utils/ItemPathTest.java b/src/test/java/net/tfminecraft/activitytf/utils/ItemPathTest.java index 7c50de0..928ca75 100644 --- a/src/test/java/net/tfminecraft/activitytf/utils/ItemPathTest.java +++ b/src/test/java/net/tfminecraft/activitytf/utils/ItemPathTest.java @@ -123,4 +123,9 @@ void theBarePrefixIsNotAnItemsAdderPath() { assertNull(ItemPath.itemsAdderId("m.material.steel")); assertNull(ItemPath.itemsAdderId("IRON_INGOT")); } + @Test void missingOrNonAlphabeticPrefixesAreNotClassifiedAsUnsupportedPlugins() { + assertFalse(ItemPath.isUnsupportedPath(null)); + assertFalse(ItemPath.isUnsupportedPath("123.example")); + } + } diff --git a/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener b/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener new file mode 100644 index 0000000..7a2b3e9 --- /dev/null +++ b/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener @@ -0,0 +1 @@ +net.tfminecraft.activitytf.testsupport.RegistryBootstrap