From da97056579621cc3f68cc75a3c25d0b47a0e328d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:12:53 +0000 Subject: [PATCH] test: enforce complete runtime line coverage --- .github/workflows/build.yml | 8 + README.md | 9 + pom.xml | 68 +++++ .../worldborder/WorldBorderTest.java | 117 +++++++ .../worldborder/border/BorderManagerTest.java | 286 ++++++++++++++++++ .../worldborder/border/RegionTest.java | 71 +++++ .../worldborder/cache/CacheTest.java | 120 ++++++++ .../command/CommandManagerTest.java | 262 ++++++++++++++++ .../worldborder/loader/ConfigLoaderTest.java | 154 ++++++++++ 9 files changed, 1095 insertions(+) create mode 100644 src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java create mode 100644 src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java create mode 100644 src/test/java/net/tfminecraft/worldborder/border/RegionTest.java create mode 100644 src/test/java/net/tfminecraft/worldborder/cache/CacheTest.java create mode 100644 src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java create mode 100644 src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java 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:")); + } +}