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