diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1c9405d..846523f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -66,3 +66,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 4d20ff2..3e3902e 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,15 @@ Returning to the safe area clears the warning, keeping the border feedback tied
Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs).
+## Tests and coverage
+
+Run `mvn clean verify` with Java 21 and the pinned plugin dependencies installed.
+The build runs the tests and enforces **100% executable-line coverage** across
+all production Java classes, with no coverage exclusions. JaCoCo's HTML and XML
+reports are written to `target/site/jacoco/` and uploaded by the Build workflow.
+Branch and instruction coverage are reported separately; the enforced threshold
+is line coverage. Build and maintenance scripts are outside this runtime-code metric.
+
## License
Copyright (c) 2026 TF-Minecraft contributors.
diff --git a/pom.xml b/pom.xml
index cf310db..6e1f74b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,9 +12,28 @@
21
UTF-8
full
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.14.0
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.23.0
+ test
+
+
+ org.mockbukkit.mockbukkit
+ mockbukkit-v1.21
+ 4.95.0
+ test
+
io.papermc.paper
paper-api
@@ -48,6 +67,55 @@
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ 3.7.0
+
+ properties
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
+ @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off
+ true
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.15
+
+ prepare-agent
+
+ coverage-report
+ verify
+ report
+
+
+ coverage-check
+ verify
+ check
+
+
+
+ BUNDLE
+
+
+ LINE
+ MISSEDCOUNT
+ 0
+
+
+
+
+
+
+
+
org.apache.maven.plugins
maven-compiler-plugin
diff --git a/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java b/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java
new file mode 100644
index 0000000..ca46619
--- /dev/null
+++ b/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java
@@ -0,0 +1,117 @@
+package net.tfminecraft.worldborder;
+
+import net.tfminecraft.worldborder.cache.Cache;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.*;
+
+class WorldBorderTest {
+ @TempDir Path directory;
+ private ServerMock server;
+ private WorldBorder plugin;
+
+ @BeforeEach
+ void setUp() {
+ Cache.worlds = new ArrayList<>();
+ Cache.borders = new LinkedHashMap<>();
+ Cache.tickIntervalTicks = 10;
+ Cache.damage = 8.0;
+ Cache.graceInset = 20;
+ Cache.titleWarning = "§cTurn back";
+ Cache.subtitleWarning = "§cYou feel continuing further would be very dangerous...";
+ server = MockBukkit.mock();
+ plugin = MockBukkit.load(WorldBorder.class);
+ }
+
+ @AfterEach
+ void tearDown() {
+ MockBukkit.unmock();
+ WorldBorder.plugin = null;
+ Cache.worlds = new ArrayList<>();
+ Cache.borders = new LinkedHashMap<>();
+ }
+
+ @Test
+ void enablesWithBundledConfigAndCommand() {
+ assertSame(plugin, WorldBorder.plugin);
+ assertTrue(Files.isRegularFile(plugin.getDataFolder().toPath().resolve("config.yml")));
+ assertEquals(10, Cache.tickIntervalTicks);
+ assertEquals(8.0, Cache.damage);
+ assertNotNull(plugin.getCommand("worldborder").getExecutor());
+ assertSame(plugin.getCommand("worldborder").getExecutor(),
+ plugin.getCommand("worldborder").getTabCompleter());
+ server.getScheduler().performTicks(10);
+ }
+
+ @Test
+ void reloadsExistingConfigWithoutReplacingIt() throws Exception {
+ Path config = plugin.getDataFolder().toPath().resolve("config.yml");
+ String custom = "tick-interval-ticks: 5\ndamage: 3\nborders: {}\n";
+ Files.writeString(config, custom);
+ plugin.reloadAll();
+ assertEquals(5, Cache.tickIntervalTicks);
+ assertEquals(3, Cache.damage);
+ plugin.onEnable();
+ assertEquals(custom, Files.readString(config));
+ plugin.onDisable();
+ plugin.onDisable();
+ }
+
+ @Test
+ void createsDataDirectoryAndReportsMissingCommand() throws Exception {
+ WorldBorder controlled = spy(plugin);
+ Path data = directory.resolve("new-data");
+ doReturn(data.toFile()).when(controlled).getDataFolder();
+ doReturn(null).when(controlled).getCommand("worldborder");
+ Logger logger = mock(Logger.class);
+ doReturn(logger).when(controlled).getLogger();
+ controlled.onEnable();
+ assertTrue(Files.isRegularFile(data.resolve("config.yml")));
+ verify(logger).severe("Command 'worldborder' missing from plugin.yml");
+ controlled.onDisable();
+ }
+
+ @Test
+ void reportsMissingBundledResource() {
+ WorldBorder controlled = spy(plugin);
+ doReturn(directory.toFile()).when(controlled).getDataFolder();
+ doReturn(null).when(controlled).getResource("config.yml");
+ Logger logger = mock(Logger.class);
+ doReturn(logger).when(controlled).getLogger();
+ controlled.onEnable();
+ assertFalse(Files.exists(directory.resolve("config.yml")));
+ verify(logger).warning("Missing bundled resource: config.yml");
+ verify(logger).severe(contains("Failed to load config.yml:"));
+ controlled.onDisable();
+ }
+
+ @Test
+ void reportsResourceReadFailureAndClosesItsStream() throws Exception {
+ WorldBorder controlled = spy(plugin);
+ doReturn(directory.toFile()).when(controlled).getDataFolder();
+ InputStream broken = mock(InputStream.class);
+ when(broken.transferTo(any())).thenThrow(new IOException("read failed"));
+ doReturn(broken).when(controlled).getResource("config.yml");
+ Logger logger = mock(Logger.class);
+ doReturn(logger).when(controlled).getLogger();
+ controlled.onEnable();
+ verify(logger).severe("Failed to copy default resource config.yml: read failed");
+ verify(broken).close();
+ controlled.onDisable();
+ }
+}
diff --git a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java
new file mode 100644
index 0000000..a3f5ede
--- /dev/null
+++ b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java
@@ -0,0 +1,286 @@
+package net.tfminecraft.worldborder.border;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyDouble;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Logger;
+
+import org.bukkit.Bukkit;
+import org.bukkit.GameMode;
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.entity.Player;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+
+import net.tfminecraft.worldborder.WorldBorder;
+import net.tfminecraft.worldborder.cache.Cache;
+
+@SuppressWarnings("deprecation")
+class BorderManagerTest {
+
+ private final int savedInterval = Cache.tickIntervalTicks;
+ private final double savedDamage = Cache.damage;
+ private final int savedInset = Cache.graceInset;
+ private final String savedTitle = Cache.titleWarning;
+ private final String savedSubtitle = Cache.subtitleWarning;
+ private final List savedWorlds = Cache.worlds;
+ private final Map savedBorders = Cache.borders;
+
+ private final AtomicReference scheduledTick = new AtomicReference<>();
+ private final List onlinePlayers = new ArrayList<>();
+ private MockedStatic bukkit;
+ private WorldBorder plugin;
+ private BukkitScheduler scheduler;
+ private BukkitTask task;
+ private BorderManager manager;
+ private World world;
+
+ @BeforeEach
+ void setUp() {
+ Cache.tickIntervalTicks = 7;
+ Cache.damage = 3.5;
+ Cache.graceInset = 10;
+ Cache.titleWarning = "Border warning";
+ Cache.subtitleWarning = "Return to safety";
+ Cache.worlds = new ArrayList<>();
+ Cache.borders = new LinkedHashMap<>();
+ Cache.borders.put("overworld", new Region("overworld", 0, 100, 0, 100));
+
+ bukkit = mockStatic(Bukkit.class);
+ scheduler = mock(BukkitScheduler.class);
+ task = mock(BukkitTask.class);
+ plugin = mock(WorldBorder.class);
+ when(plugin.getLogger()).thenReturn(mock(Logger.class));
+ bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(ignored -> onlinePlayers);
+ when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong()))
+ .thenAnswer(invocation -> {
+ scheduledTick.set(invocation.getArgument(1));
+ return task;
+ });
+ world = mock(World.class);
+ when(world.getName()).thenReturn("overworld");
+ manager = new BorderManager(plugin);
+ manager.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (manager != null) {
+ manager.stop();
+ }
+ if (bukkit != null) {
+ bukkit.close();
+ }
+ Cache.tickIntervalTicks = savedInterval;
+ Cache.damage = savedDamage;
+ Cache.graceInset = savedInset;
+ Cache.titleWarning = savedTitle;
+ Cache.subtitleWarning = savedSubtitle;
+ Cache.worlds = savedWorlds;
+ Cache.borders = savedBorders;
+ }
+
+ @Test
+ void startUsesConfiguredIntervalAndRestartCancelsPreviousTask() {
+ verify(scheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(7L), eq(7L));
+ verify(plugin.getLogger()).info("Border tick started (interval=7 ticks).");
+ assertNotNull(scheduledTick.get());
+
+ BukkitTask previousTask = task;
+ task = mock(BukkitTask.class);
+ Cache.tickIntervalTicks = 25;
+ manager.start();
+
+ verify(previousTask).cancel();
+ verify(scheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(25L), eq(25L));
+ manager.stop();
+ manager.stop();
+ verify(task, times(1)).cancel();
+ verify(previousTask, times(1)).cancel();
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = GameMode.class, names = {"SURVIVAL", "ADVENTURE"})
+ void warningRepeatsUntilSafeAndOnlyResetsTitleOnce(GameMode mode) {
+ Player player = playerAt(5, 50);
+ when(player.getGameMode()).thenReturn(mode);
+
+ tick();
+ tick();
+ verify(player, times(2)).sendTitle("Border warning", "Return to safety", 0, 15, 0);
+ verify(player, never()).resetTitle();
+ verify(player, never()).damage(anyDouble());
+
+ move(player, 50, 50);
+ tick();
+ tick();
+ verify(player, times(1)).resetTitle();
+ verify(player, never()).damage(anyDouble());
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = GameMode.class, names = {"SURVIVAL", "ADVENTURE"})
+ void outsideClearsWarningAndAppliesOnlyPositiveDamage(GameMode mode) {
+ Player player = playerAt(5, 50);
+ when(player.getGameMode()).thenReturn(mode);
+ tick();
+
+ move(player, 101, 50);
+ tick();
+ verify(player).resetTitle();
+ verify(player).damage(3.5);
+
+ Cache.damage = 0;
+ tick();
+ Cache.damage = -2;
+ tick();
+ verify(player, times(1)).damage(anyDouble());
+ verify(player, times(1)).resetTitle();
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = GameMode.class, names = {"CREATIVE", "SPECTATOR"})
+ void exemptModesClearAnExistingWarningAndPreventDamage(GameMode mode) {
+ Player player = playerAt(5, 50);
+ tick();
+ when(player.getGameMode()).thenReturn(mode);
+ move(player, -1, 50);
+
+ tick();
+ tick();
+ verify(player, times(1)).resetTitle();
+ verify(player, times(1)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ verify(player, never()).damage(anyDouble());
+ }
+
+ @Test
+ void bypassPermissionClearsAnExistingWarningAndPreventsDamage() {
+ Player player = playerAt(5, 50);
+ tick();
+ when(player.hasPermission("worldborder.bypass")).thenReturn(true);
+ move(player, 101, 50);
+
+ tick();
+ tick();
+ verify(player, times(1)).resetTitle();
+ verify(player, times(1)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ verify(player, never()).damage(anyDouble());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void missingOrFilteredWorldClearsWarningAndPreventsDamage(boolean filterWorld) {
+ Player player = playerAt(5, 50);
+ tick();
+ if (filterWorld) {
+ Cache.worlds.add("another-world");
+ } else {
+ Cache.borders.clear();
+ }
+ move(player, 101, 50);
+
+ tick();
+ tick();
+ verify(player, times(1)).resetTitle();
+ verify(player, never()).damage(anyDouble());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void offlineWarningsArePrunedBeforeThePlayerReturns(boolean missingPlayer) {
+ Player player = playerAt(5, 50);
+ tick();
+ onlinePlayers.clear();
+ if (missingPlayer) {
+ bukkit.when(() -> Bukkit.getPlayer(player.getUniqueId())).thenReturn(null);
+ } else {
+ when(player.isOnline()).thenReturn(false);
+ }
+ tick();
+
+ onlinePlayers.add(player);
+ when(player.isOnline()).thenReturn(true);
+ bukkit.when(() -> Bukkit.getPlayer(player.getUniqueId())).thenReturn(player);
+ move(player, 50, 50);
+ tick();
+ verify(player, never()).resetTitle();
+ verify(player, times(1)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ }
+
+ @Test
+ void stoppingClearsWarningsBeforeTheNextStart() {
+ Player player = playerAt(5, 50);
+ tick();
+ manager.stop();
+ manager.start();
+ move(player, 50, 50);
+
+ tick();
+ verify(player, never()).resetTitle();
+ verify(player, times(1)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ }
+
+ @Test
+ void deadPlayersAreSkippedWithoutBorderChecksOrDamage() {
+ Player dead = mock(Player.class);
+ when(dead.isDead()).thenReturn(true);
+ onlinePlayers.add(dead);
+ Player alive = playerAt(101, 50);
+
+ tick();
+ verify(dead, never()).getGameMode();
+ verify(dead, never()).getWorld();
+ verify(dead, never()).getLocation();
+ verify(dead, never()).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ verify(dead, never()).resetTitle();
+ verify(dead, never()).damage(anyDouble());
+ verify(alive).damage(3.5);
+ }
+
+ private Player playerAt(double x, double z) {
+ Player player = mock(Player.class);
+ UUID id = UUID.randomUUID();
+ when(player.getUniqueId()).thenReturn(id);
+ when(player.isOnline()).thenReturn(true);
+ when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
+ when(player.getWorld()).thenReturn(world);
+ move(player, x, z);
+ bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player);
+ onlinePlayers.add(player);
+ return player;
+ }
+
+ private void move(Player player, double x, double z) {
+ when(player.getLocation()).thenReturn(new Location(world, x, 64, z));
+ }
+
+ private void tick() {
+ scheduledTick.get().run();
+ }
+}
diff --git a/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java b/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java
new file mode 100644
index 0000000..e1d6d2f
--- /dev/null
+++ b/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java
@@ -0,0 +1,71 @@
+package net.tfminecraft.worldborder.border;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class RegionTest {
+
+ private final Region region = new Region("overworld", -12, 23, -31, 47);
+
+ @Test
+ void retainsWorldAndAsymmetricBounds() {
+ assertAll(
+ () -> assertEquals("overworld", region.world),
+ () -> assertEquals(-12, region.minX),
+ () -> assertEquals(23, region.maxX),
+ () -> assertEquals(-31, region.minZ),
+ () -> assertEquals(47, region.maxZ));
+ }
+
+ @ParameterizedTest(name = "({0}, {1}), inset {2}: {3}")
+ @CsvSource({
+ "-12.01, 0, 3, OUTSIDE",
+ "23.01, 0, 3, OUTSIDE",
+ "0, -31.01, 3, OUTSIDE",
+ "0, 47.01, 3, OUTSIDE",
+ "-12.01, 47.01, 3, OUTSIDE",
+ "-12, 0, 3, WARNING",
+ "23, 0, 3, WARNING",
+ "0, -31, 3, WARNING",
+ "0, 47, 3, WARNING",
+ "-12, -31, 3, WARNING",
+ "23, 47, 3, WARNING",
+ "-9, 0, 3, WARNING",
+ "20, 0, 3, WARNING",
+ "0, -28, 3, WARNING",
+ "0, 44, 3, WARNING",
+ "-8.99, 0, 3, SAFE",
+ "19.99, 0, 3, SAFE",
+ "0, -27.99, 3, SAFE",
+ "0, 43.99, 3, SAFE",
+ "0, 0, 3, SAFE",
+ "-12, 0, 0, WARNING",
+ "-11.99, 0, 0, SAFE",
+ "0, 0, 100, WARNING",
+ "24, 0, 100, OUTSIDE"
+ })
+ void classifiesAllEdgesAndInclusiveWarningInset(double x, double z, int inset, Region.Zone expected) {
+ assertEquals(expected, region.zoneAt(x, z, inset));
+ }
+
+ @Test
+ void zeroWidthRegionWarnsOnTheBorderAndDamagesOutside() {
+ Region point = new Region("point", 7, 7, -4, -4);
+ assertEquals(Region.Zone.WARNING, point.zoneAt(7, -4, 0));
+ assertEquals(Region.Zone.OUTSIDE, point.zoneAt(7.01, -4, 0));
+ assertEquals(Region.Zone.OUTSIDE, point.zoneAt(7, -4.01, 0));
+ }
+
+ @Test
+ void supportsIntegerExtremeBoundsWithoutOverflow() {
+ Region enormous = new Region("large", Integer.MIN_VALUE, Integer.MAX_VALUE,
+ Integer.MIN_VALUE, Integer.MAX_VALUE);
+ assertEquals(Region.Zone.SAFE, enormous.zoneAt(0, 0, 20));
+ assertEquals(Region.Zone.WARNING, enormous.zoneAt(Integer.MAX_VALUE, 0, 20));
+ assertEquals(Region.Zone.OUTSIDE, enormous.zoneAt((double) Integer.MAX_VALUE + 1, 0, 20));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/worldborder/cache/CacheTest.java b/src/test/java/net/tfminecraft/worldborder/cache/CacheTest.java
new file mode 100644
index 0000000..160d451
--- /dev/null
+++ b/src/test/java/net/tfminecraft/worldborder/cache/CacheTest.java
@@ -0,0 +1,120 @@
+package net.tfminecraft.worldborder.cache;
+
+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 java.lang.reflect.Constructor;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.worldborder.border.Region;
+
+class CacheTest {
+ private int originalInterval;
+ private double originalDamage;
+ private int originalInset;
+ private String originalTitle;
+ private String originalSubtitle;
+ private List originalWorlds;
+ private Map originalBorders;
+
+ @BeforeEach
+ void resetState() {
+ originalInterval = Cache.tickIntervalTicks;
+ originalDamage = Cache.damage;
+ originalInset = Cache.graceInset;
+ originalTitle = Cache.titleWarning;
+ originalSubtitle = Cache.subtitleWarning;
+ originalWorlds = Cache.worlds;
+ originalBorders = Cache.borders;
+ Cache.tickIntervalTicks = 10;
+ Cache.damage = 8.0;
+ Cache.graceInset = 20;
+ Cache.titleWarning = "§cTurn back";
+ Cache.subtitleWarning = "§cYou feel continuing further would be very dangerous...";
+ Cache.worlds = new ArrayList<>();
+ Cache.borders = new LinkedHashMap<>();
+ }
+
+ @AfterEach
+ void restoreState() {
+ Cache.tickIntervalTicks = originalInterval;
+ Cache.damage = originalDamage;
+ Cache.graceInset = originalInset;
+ Cache.titleWarning = originalTitle;
+ Cache.subtitleWarning = originalSubtitle;
+ Cache.worlds = originalWorlds;
+ Cache.borders = originalBorders;
+ }
+
+ @Test
+ void emptyAllowListReturnsAnyConfiguredWorld() {
+ Region overworld = new Region("world", -100, 100, -100, 100);
+ Region nether = new Region("world_nether", -20, 20, -20, 20);
+ Cache.borders.put("world", overworld);
+ Cache.borders.put("world_nether", nether);
+
+ assertSame(overworld, Cache.borderForWorld("world"));
+ assertSame(nether, Cache.borderForWorld("world_nether"));
+ assertNull(Cache.borderForWorld("unconfigured"));
+ }
+
+ @Test
+ void nonemptyAllowListRequiresBothMembershipAndABorder() {
+ Region allowed = new Region("allowed", 0, 100, 0, 100);
+ Cache.worlds.add("allowed");
+ Cache.worlds.add("missing-border");
+ Cache.borders.put("allowed", allowed);
+ Cache.borders.put("excluded", new Region("excluded", 0, 100, 0, 100));
+
+ assertSame(allowed, Cache.borderForWorld("allowed"));
+ assertNull(Cache.borderForWorld("missing-border"));
+ assertNull(Cache.borderForWorld("excluded"));
+ }
+
+ @Test
+ void worldNamesMatchExactly() {
+ Region border = new Region("World", 0, 100, 0, 100);
+ Cache.borders.put("World", border);
+
+ assertSame(border, Cache.borderForWorld("World"));
+ assertNull(Cache.borderForWorld("world"));
+ Cache.worlds.add("world");
+ assertNull(Cache.borderForWorld("World"));
+ }
+
+ @Test
+ void lookupsUseTheCurrentConfigurationAfterReplacement() {
+ Cache.worlds.add("old");
+ Cache.borders.put("old", new Region("old", 0, 100, 0, 100));
+ Region replacement = new Region("new", -30, 30, -30, 30);
+ Cache.worlds = new ArrayList<>(List.of("new"));
+ Cache.borders = new LinkedHashMap<>(Map.of("new", replacement));
+
+ assertNull(Cache.borderForWorld("old"));
+ assertSame(replacement, Cache.borderForWorld("new"));
+ }
+
+ @Test
+ void utilityConstructorIsPrivateAndDoesNotReplaceSharedState() throws Exception {
+ Constructor constructor = Cache.class.getDeclaredConstructor();
+ assertTrue(Modifier.isPrivate(constructor.getModifiers()));
+ List worlds = Cache.worlds;
+ Map borders = Cache.borders;
+
+ constructor.setAccessible(true);
+ assertNotNull(constructor.newInstance());
+
+ assertSame(worlds, Cache.worlds);
+ assertSame(borders, Cache.borders);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java
new file mode 100644
index 0000000..20795ff
--- /dev/null
+++ b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java
@@ -0,0 +1,262 @@
+package net.tfminecraft.worldborder.command;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InOrder;
+
+import net.tfminecraft.worldborder.WorldBorder;
+import net.tfminecraft.worldborder.border.Region;
+import net.tfminecraft.worldborder.cache.Cache;
+
+class CommandManagerTest {
+ private int originalInterval;
+ private double originalDamage;
+ private int originalInset;
+ private String originalTitle;
+ private String originalSubtitle;
+ private List originalWorlds;
+ private Map originalBorders;
+ private WorldBorder originalPlugin;
+ private Locale originalLocale;
+ private CommandManager manager;
+ private Command command;
+ private WorldBorder plugin;
+
+ @BeforeEach
+ void setUp() {
+ originalInterval = Cache.tickIntervalTicks;
+ originalDamage = Cache.damage;
+ originalInset = Cache.graceInset;
+ originalTitle = Cache.titleWarning;
+ originalSubtitle = Cache.subtitleWarning;
+ originalWorlds = Cache.worlds;
+ originalBorders = Cache.borders;
+ originalPlugin = WorldBorder.plugin;
+ originalLocale = Locale.getDefault();
+ Cache.tickIntervalTicks = 10;
+ Cache.damage = 8.0;
+ Cache.graceInset = 20;
+ Cache.titleWarning = "§cTurn back";
+ Cache.subtitleWarning = "§cYou feel continuing further would be very dangerous...";
+ Cache.worlds = new ArrayList<>();
+ Cache.borders = new LinkedHashMap<>();
+ plugin = mock(WorldBorder.class);
+ WorldBorder.plugin = plugin;
+ manager = new CommandManager();
+ command = mock(Command.class);
+ }
+
+ @AfterEach
+ void restoreState() {
+ Cache.tickIntervalTicks = originalInterval;
+ Cache.damage = originalDamage;
+ Cache.graceInset = originalInset;
+ Cache.titleWarning = originalTitle;
+ Cache.subtitleWarning = originalSubtitle;
+ Cache.worlds = originalWorlds;
+ Cache.borders = originalBorders;
+ WorldBorder.plugin = originalPlugin;
+ Locale.setDefault(originalLocale);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"reload", "debug", "unknown"})
+ void permissionIsRequiredBeforeDispatch(String subcommand) {
+ CommandSender sender = mock(CommandSender.class);
+
+ assertTrue(execute(sender, subcommand));
+
+ verify(sender).hasPermission("worldborder.admin");
+ verify(sender).sendMessage("§cNo permission.");
+ verifyNoMoreInteractions(sender);
+ verifyNoInteractions(plugin);
+ }
+
+ @Test
+ void noArgumentsListsBothCommands() {
+ CommandSender sender = admin();
+
+ assertEquals("worldborder", manager.cmd);
+ assertTrue(execute(sender));
+
+ InOrder messages = inOrder(sender);
+ messages.verify(sender).sendMessage("§e/worldborder reload");
+ messages.verify(sender).sendMessage("§e/worldborder debug");
+ verifyNoInteractions(plugin);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"reload", "ReLoAd"})
+ void reloadReportsTheNewBorderCount(String subcommand) {
+ CommandSender sender = admin();
+ Cache.borders.put("old", new Region("old", 0, 1, 0, 1));
+ doAnswer(invocation -> {
+ Cache.borders.clear();
+ Cache.borders.put("world", new Region("world", -100, 100, -100, 100));
+ Cache.borders.put("nether", new Region("nether", -50, 50, -50, 50));
+ return null;
+ }).when(plugin).reloadAll();
+
+ assertTrue(execute(sender, subcommand));
+
+ InOrder reload = inOrder(plugin, sender);
+ reload.verify(plugin).reloadAll();
+ reload.verify(sender).sendMessage("§aWorldBorder reloaded. Loaded 2 world border(s).");
+ }
+
+ @Test
+ void reloadReportsAnEmptyConfiguration() {
+ CommandSender sender = admin();
+
+ assertTrue(execute(sender, "reload"));
+
+ verify(plugin).reloadAll();
+ verify(sender).sendMessage("§aWorldBorder reloaded. Loaded 0 world border(s).");
+ }
+
+ @Test
+ void debugRejectsConsoleSenders() {
+ CommandSender sender = admin();
+
+ assertTrue(execute(sender, "DeBuG"));
+
+ verify(sender).sendMessage("§cPlayers only.");
+ verifyNoInteractions(plugin);
+ }
+
+ @Test
+ void unknownSubcommandExplainsValidChoices() {
+ CommandSender sender = admin();
+
+ assertTrue(execute(sender, "missing"));
+
+ verify(sender).sendMessage("§cUnknown subcommand. Use §e/worldborder reload §cor §e/worldborder debug");
+ verifyNoInteractions(plugin);
+ }
+
+ @Test
+ void debugReportsWorldAndCoordinatesWithoutABorder() {
+ Player player = player("unconfigured", -12, 34.26);
+ Locale.setDefault(Locale.GERMANY);
+
+ assertTrue(execute(player, "debug"));
+
+ InOrder messages = inOrder(player);
+ messages.verify(player).sendMessage("§7World: §funconfigured");
+ messages.verify(player).sendMessage("§7Position: §f-12, 34.3");
+ messages.verify(player).sendMessage("§7Zone: §f(none)");
+ messages.verify(player).sendMessage("§7Border: §fno border for this world");
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "0, 0, '§aSAFE', '0, 0'",
+ "80, 0, '§cWARNING', '80, 0'",
+ "100.5, -0.25, '§cOUTSIDE', '100.5, -0.3'"
+ })
+ void debugReportsTheCurrentZoneAndBorder(double x, double z, String zone, String position) {
+ Cache.borders.put("world", new Region("world", -100, 100, -100, 100));
+ Player player = player("world", x, z);
+
+ assertTrue(execute(player, "DeBuG"));
+
+ InOrder messages = inOrder(player);
+ messages.verify(player).sendMessage("§7World: §fworld");
+ messages.verify(player).sendMessage("§7Position: §f" + position);
+ messages.verify(player).sendMessage("§7Zone: " + zone);
+ messages.verify(player).sendMessage("§7Border: §fx -100-100, z -100-100");
+ }
+
+ @Test
+ void debugHonorsTheAllowedWorldList() {
+ Cache.worlds.add("another-world");
+ Cache.borders.put("world", new Region("world", -100, 100, -100, 100));
+ Player player = player("world", 0, 0);
+
+ assertTrue(execute(player, "debug"));
+
+ verify(player).sendMessage("§7Zone: §f(none)");
+ verify(player).sendMessage("§7Border: §fno border for this world");
+ }
+
+ @Test
+ void completionRequiresPermissionAndExactlyOneArgument() {
+ CommandSender denied = mock(CommandSender.class);
+ CommandSender allowed = admin();
+
+ assertEquals(List.of(), complete(denied, ""));
+ assertEquals(List.of(), complete(allowed));
+ assertEquals(List.of(), complete(allowed, "reload", ""));
+ verifyNoInteractions(plugin);
+ }
+
+ @ParameterizedTest
+ @MethodSource("prefixMatches")
+ void completionFiltersCaseInsensitively(String prefix, List matches) {
+ assertEquals(matches, complete(admin(), prefix));
+ }
+
+ private static Stream prefixMatches() {
+ return Stream.of(
+ Arguments.of("", List.of("reload", "debug")),
+ Arguments.of("r", List.of("reload")),
+ Arguments.of("RE", List.of("reload")),
+ Arguments.of("reload", List.of("reload")),
+ Arguments.of("d", List.of("debug")),
+ Arguments.of("DeBuG", List.of("debug")),
+ Arguments.of("unknown", List.of()),
+ Arguments.of("reload-more", List.of()));
+ }
+
+ private CommandSender admin() {
+ CommandSender sender = mock(CommandSender.class);
+ when(sender.hasPermission("worldborder.admin")).thenReturn(true);
+ return sender;
+ }
+
+ private Player player(String worldName, double x, double z) {
+ Player player = mock(Player.class);
+ World world = mock(World.class);
+ when(player.hasPermission("worldborder.admin")).thenReturn(true);
+ when(world.getName()).thenReturn(worldName);
+ when(player.getWorld()).thenReturn(world);
+ when(player.getLocation()).thenReturn(new Location(world, x, 64, z));
+ return player;
+ }
+
+ private boolean execute(CommandSender sender, String... args) {
+ return manager.onCommand(sender, command, "worldborder", args);
+ }
+
+ private List complete(CommandSender sender, String... args) {
+ return manager.onTabComplete(sender, command, "worldborder", args);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java
new file mode 100644
index 0000000..2547292
--- /dev/null
+++ b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java
@@ -0,0 +1,154 @@
+package net.tfminecraft.worldborder.loader;
+
+import net.tfminecraft.worldborder.WorldBorder;
+import net.tfminecraft.worldborder.border.Region;
+import net.tfminecraft.worldborder.cache.Cache;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.*;
+
+class ConfigLoaderTest {
+ @TempDir Path directory;
+ private final ConfigLoader loader = new ConfigLoader();
+ private Logger logger;
+ private WorldBorder previousPlugin;
+ private int previousTicks;
+ private double previousDamage;
+ private int previousInset;
+ private String previousTitle;
+ private String previousSubtitle;
+ private List previousWorlds;
+ private java.util.Map previousBorders;
+
+ @BeforeEach
+ void setUp() {
+ previousPlugin = WorldBorder.plugin;
+ previousTicks = Cache.tickIntervalTicks;
+ previousDamage = Cache.damage;
+ previousInset = Cache.graceInset;
+ previousTitle = Cache.titleWarning;
+ previousSubtitle = Cache.subtitleWarning;
+ previousWorlds = Cache.worlds;
+ previousBorders = Cache.borders;
+ WorldBorder.plugin = mock(WorldBorder.class);
+ logger = mock(Logger.class);
+ when(WorldBorder.plugin.getLogger()).thenReturn(logger);
+ Cache.titleWarning = "default title";
+ Cache.subtitleWarning = "default subtitle";
+ Cache.worlds = new ArrayList<>(List.of("old-world"));
+ Cache.borders = new LinkedHashMap<>();
+ Cache.borders.put("old-world", new Region("old-world", 0, 10, 0, 10));
+ }
+
+ @AfterEach
+ void tearDown() {
+ WorldBorder.plugin = previousPlugin;
+ Cache.tickIntervalTicks = previousTicks;
+ Cache.damage = previousDamage;
+ Cache.graceInset = previousInset;
+ Cache.titleWarning = previousTitle;
+ Cache.subtitleWarning = previousSubtitle;
+ Cache.worlds = previousWorlds;
+ Cache.borders = previousBorders;
+ }
+
+ private void load(String yaml) throws Exception {
+ Path file = directory.resolve("config.yml");
+ Files.writeString(file, yaml);
+ loader.load(file.toFile());
+ }
+
+ @Test
+ void absentSettingsUseDefaultsAndRemovePreviousWorlds() throws Exception {
+ load("{}");
+ assertEquals(10, Cache.tickIntervalTicks);
+ assertEquals(8.0, Cache.damage);
+ assertEquals(20, Cache.graceInset);
+ assertEquals("default title", Cache.titleWarning);
+ assertEquals("default subtitle", Cache.subtitleWarning);
+ assertTrue(Cache.worlds.isEmpty());
+ assertTrue(Cache.borders.isEmpty());
+ }
+
+ @Test
+ void loadsScalarsWorldFilterAndInclusiveCoordinates() throws Exception {
+ load("""
+ tick-interval-ticks: 30
+ damage: 2.5
+ grace-inset: 4
+ title-warning: 'Turn back'
+ subtitle-warning: 'Danger ahead'
+ worlds: [overworld]
+ borders:
+ overworld: {min-x: -10, max-x: 20, min-z: -30, max-z: 40}
+ point: {min-x: 1, max-x: 1, min-z: 2, max-z: 2}
+ """);
+ assertEquals(30, Cache.tickIntervalTicks);
+ assertEquals(2.5, Cache.damage);
+ assertEquals(4, Cache.graceInset);
+ assertEquals("Turn back", Cache.titleWarning);
+ assertEquals("Danger ahead", Cache.subtitleWarning);
+ assertEquals(List.of("overworld"), Cache.worlds);
+ assertEquals(List.of("overworld", "point"), new ArrayList<>(Cache.borders.keySet()));
+ Region border = Cache.borders.get("overworld");
+ assertEquals("overworld", border.world);
+ assertEquals(-10, border.minX);
+ assertEquals(20, border.maxX);
+ assertEquals(-30, border.minZ);
+ assertEquals(40, border.maxZ);
+ Region point = Cache.borders.get("point");
+ assertEquals(Region.Zone.WARNING, point.zoneAt(1, 2, 0));
+ verifyNoInteractions(logger);
+ }
+
+ @Test
+ void clampsNegativeSettingsAndSkipsEachInvertedAxis() throws Exception {
+ load("""
+ tick-interval-ticks: 0
+ damage: -2
+ grace-inset: -1
+ borders:
+ bad-x: {min-x: 5, max-x: 4, min-z: 0, max-z: 1}
+ bad-z: {min-x: 0, max-x: 1, min-z: 5, max-z: 4}
+ valid: {min-x: 0, max-x: 1, min-z: 0, max-z: 1}
+ """);
+ assertEquals(1, Cache.tickIntervalTicks);
+ assertEquals(0.0, Cache.damage);
+ assertEquals(0, Cache.graceInset);
+ assertEquals(List.of("valid"), new ArrayList<>(Cache.borders.keySet()));
+ verify(logger).warning(contains("world 'bad-x'"));
+ verify(logger).warning(contains("world 'bad-z'"));
+ }
+
+ @Test
+ void missingFileLeavesActiveConfigurationUntouched() {
+ Cache.tickIntervalTicks = 37;
+ loader.load(directory.resolve("missing.yml").toFile());
+ assertEquals(37, Cache.tickIntervalTicks);
+ assertEquals(List.of("old-world"), Cache.worlds);
+ assertEquals(List.of("old-world"), new ArrayList<>(Cache.borders.keySet()));
+ verify(logger).severe(contains("Failed to load config.yml:"));
+ }
+
+ @Test
+ void malformedYamlLeavesActiveConfigurationUntouched() throws Exception {
+ Cache.tickIntervalTicks = 37;
+ load("borders: [unterminated");
+ assertEquals(37, Cache.tickIntervalTicks);
+ assertEquals(List.of("old-world"), Cache.worlds);
+ assertEquals(List.of("old-world"), new ArrayList<>(Cache.borders.keySet()));
+ verify(logger).severe(contains("Failed to load config.yml:"));
+ }
+}