diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256 index 124b111..41c3692 100644 --- a/.github/dependencies.sha256 +++ b/.github/dependencies.sha256 @@ -1,3 +1,5 @@ 4e69696892b88b41c55d49ab2fdcc21eead92bf54acc588c0050596c3b75199c libs/json-simple-1.1.1.jar 2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f libs/gson-2.14.0.jar 5a01b37bd7442fb0f2c809cadac82ba70f75173956e04efac3336595cc070368 libs/ItemsAdder-4.0.18.jar +44ee292392dd86b4aa4824c2e11668a9dee0333b83ae013987e1ba78d19f51a0 libs/ModelEngine-R4.1.1.jar +c571502e768640c7cff10c49b3eef904ccf3fd95bd81803913abfe43cfc09746 libs/NBTAPI-2.16.1.jar diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh index 9acb11e..b52ebea 100644 --- a/.github/scripts/install-local-dependencies.sh +++ b/.github/scripts/install-local-dependencies.sh @@ -13,3 +13,10 @@ mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1. mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/ItemsAdder-4.0.18.jar" -DgroupId="local" -DartifactId="ItemsAdder" \ -Dversion="4.0.18-tfmc-5a01b37bd744" -Dpackaging=jar -DgeneratePom=true "$@" + +mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="libs/NBTAPI-2.16.1.jar" -DgroupId="local" -DartifactId="item-nbt-api-plugin" \ + -Dversion="2.16.1-tfmc-c571502e7686" -Dpackaging=jar -DgeneratePom=true "$@" +mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="libs/ModelEngine-R4.1.1.jar" -DgroupId="local" -DartifactId="ModelEngine" \ + -Dversion="R4.1.1-tfmc-44ee292392dd" -Dpackaging=jar -DgeneratePom=true "$@" diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index e934545..817edda 100644 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -6,4 +6,6 @@ mkdir -p libs curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/4e69696892b8/json-simple-1.1.1.jar?ref=$ref" > "libs/json-simple-1.1.1.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/2cbd119bf196/gson-2.14.0.jar?ref=$ref" > "libs/gson-2.14.0.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/5a01b37bd744/ItemsAdder-4.0.18.jar?ref=$ref" > "libs/ItemsAdder-4.0.18.jar" +curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/44ee292392dd/ModelEngine-R4.1.1.jar?ref=$ref" > "libs/ModelEngine-R4.1.1.jar" +curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/c571502e7686/NBTAPI-2.16.1.jar?ref=$ref" > "libs/NBTAPI-2.16.1.jar" bash .github/scripts/install-local-dependencies.sh "$@" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..3d42f99 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 reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index afd209d..24e4b28 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,14 @@ Copyright (c) 2026 TF-Minecraft contributors. TF-Minecraft-authored material in this repository is licensed under the [Artistic License 2.0](LICENSE). Third-party dependencies and bundled material retain their own licenses. + +## Tests and coverage + +Run `mvn clean verify` with Java 21 after preparing the pinned dependencies. +The build enforces 100% production line, branch and instruction coverage with +JaCoCo, without exclusions. CI uploads the HTML and XML coverage reports. + +The tests cover construction state, material accounting, menus, permissions, +placement timers, reloads, persistence, displays and plugin lifecycle. Bukkit +and external plugin boundaries use MockBukkit/Mockito; configuration and item +metadata use real serialization. Live Minecraft integration remains separate. diff --git a/pom.xml b/pom.xml index b13cf66..9ab1bec 100644 --- a/pom.xml +++ b/pom.xml @@ -12,11 +12,56 @@ 21 UTF-8 full + 25.0.0 2.0.0 2.0.1 + + local + ModelEngine + R4.1.1-tfmc-44ee292392dd + test + + + it.unimi.dsi + fastutil + 8.5.15 + test + + + local + item-nbt-api-plugin + 2.16.1-tfmc-c571502e7686 + test + + + net.coreprotect + CoreProtect + ${coreprotect.version} + + provided + ** + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + local json-simple @@ -93,6 +138,55 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + prepare-agent + + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 1.0 + + + LINE + COVEREDRATIO + 1.0 + + + BRANCH + COVEREDRATIO + 1.0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/net/tfminecraft/vfbuilders/VFBuilders.java b/src/main/java/net/tfminecraft/vfbuilders/VFBuilders.java index 3e66185..ffe5c98 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/VFBuilders.java +++ b/src/main/java/net/tfminecraft/vfbuilders/VFBuilders.java @@ -79,7 +79,7 @@ public void loadConfigs() { } VFLogger.info(this, "Loading blueprints..."); for (File file : files) { - if (file != null && file.isFile()) { + if (file.isFile()) { blueprintLoader.load(file); } } diff --git a/src/main/java/net/tfminecraft/vfbuilders/display/StationTimerDisplay.java b/src/main/java/net/tfminecraft/vfbuilders/display/StationTimerDisplay.java index d8e8b18..7e844e5 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/display/StationTimerDisplay.java +++ b/src/main/java/net/tfminecraft/vfbuilders/display/StationTimerDisplay.java @@ -65,13 +65,7 @@ public static TextDisplay spawn(World world, Location stationBlock, String text) return null; } Location anchor = displayAnchor(stationBlock); - if (anchor == null) { - return null; - } String key = blockKey(stationBlock); - if (key == null) { - return null; - } return world.spawn(anchor, TextDisplay.class, display -> apply(display, anchor, text, key)); } @@ -82,7 +76,7 @@ public static void update(TextDisplay display, Location stationBlock, String tex return; } Location anchor = displayAnchor(stationBlock); - if (anchor == null || anchor.getWorld() == null) { + if (anchor.getWorld() == null) { return; } display.teleport(anchor); @@ -122,13 +116,7 @@ public static void purgeAtStationBlock(Location stationBlock, double radius) { return; } String key = blockKey(stationBlock); - if (key == null) { - return; - } Location anchor = displayAnchor(stationBlock); - if (anchor == null) { - return; - } World world = stationBlock.getWorld(); int chunkX = stationBlock.getBlockX() >> 4; int chunkZ = stationBlock.getBlockZ() >> 4; @@ -158,9 +146,6 @@ public static void purgeLegacyArmorStands(Location stationBlock, double radius) return; } Location anchor = displayAnchor(stationBlock); - if (anchor == null) { - return; - } World world = stationBlock.getWorld(); int chunkX = stationBlock.getBlockX() >> 4; int chunkZ = stationBlock.getBlockZ() >> 4; @@ -205,9 +190,6 @@ private static boolean hasStationTimerTag(Entity entity) { } private static String getStationTimerTag(Entity entity) { - if (entity == null) { - return null; - } PersistentDataContainer container = entity.getPersistentDataContainer(); return container.get(stationTimerKey(), PersistentDataType.STRING); } diff --git a/src/main/java/net/tfminecraft/vfbuilders/managers/InventoryManager.java b/src/main/java/net/tfminecraft/vfbuilders/managers/InventoryManager.java index 39c9baa..48e5156 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/vfbuilders/managers/InventoryManager.java @@ -106,7 +106,7 @@ private ItemStack getCategoryItem(Player p, BlueprintCategory cat) { ItemMeta m = i.getItemMeta(); NamespacedKey key = new NamespacedKey(VFBuilders.plugin, "vfb_category_id"); m.getPersistentDataContainer().set(key, PersistentDataType.STRING, cat.getId()); - List lore = new ArrayList<>(m.getLore()); + List lore = m.hasLore() ? new ArrayList<>(m.getLore()) : new ArrayList<>(); lore.add(" "); lore.add(StringFormatter.formatHex("#53db9c"+getBlueprintCount(p, cat)+" #ccbf8fBlueprints")); m.setLore(lore); @@ -124,7 +124,7 @@ private ItemStack getBlueprintItem(Blueprint b) { m.setDisplayName(v.getName()); NamespacedKey key = new NamespacedKey(VFBuilders.plugin, "vfb_blueprint_id"); m.getPersistentDataContainer().set(key, PersistentDataType.STRING, b.getId()); - List lore = new ArrayList<>(m.getLore()); + List lore = m.hasLore() ? new ArrayList<>(m.getLore()) : new ArrayList<>(); lore.add(" "); lore.add(StringFormatter.formatHex("#ccbf8fSeats§e: #86d672"+v.getSeatHandler().getSeats().size())); if(v.getWeapons().size() > 0) lore.add(StringFormatter.formatHex("#ccbf8fWeapons§e: #86d672"+v.getWeapons().size())); diff --git a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java index 299e789..23437a5 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java +++ b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java @@ -137,9 +137,6 @@ public void onChunkUnload(ChunkUnloadEvent e) { private Iterable stationsInChunk(Chunk chunk) { List inChunk = new ArrayList<>(); - if (chunk == null) { - return inChunk; - } int chunkX = chunk.getX(); int chunkZ = chunk.getZ(); for (ActiveStation station : stations.values()) { @@ -247,7 +244,8 @@ public void onPlayerLeftClick(PlayerInteractEvent e) { Location stationLoc = placement.getStation().getLocation(); Location clickLoc = p.getLocation(); - if (clickLoc.distanceSquared(stationLoc) > Math.pow(Cache.constructionDistance, 2)) { + if (!java.util.Objects.equals(clickLoc.getWorld(), stationLoc.getWorld()) + || clickLoc.distanceSquared(stationLoc) > Math.pow(Cache.constructionDistance, 2)) { p.sendMessage("§cToo far from the station! (max "+Cache.constructionDistance+" blocks)"); return; } @@ -295,7 +293,7 @@ private void startParticleTrail(ActivePlacement placement) { new BukkitRunnable() { @Override public void run() { - if (!activePlacements.containsKey(uuid)) { + if (activePlacements.get(uuid) != placement) { cancel(); return; } if (placement.getStation().hasBlueprint()) { @@ -303,7 +301,8 @@ public void run() { cancel(); return; } Location playerLoc = player.getLocation(); - if (playerLoc.distanceSquared(stationLoc) > Math.pow(Cache.constructionDistance, 2)) return; + if (!java.util.Objects.equals(playerLoc.getWorld(), stationLoc.getWorld()) + || playerLoc.distanceSquared(stationLoc) > Math.pow(Cache.constructionDistance, 2)) return; drawParticleLine(stationLoc.clone().add(0.5, 1, 0.5), playerLoc.clone().add(0, 1.5, 0)); @@ -314,7 +313,7 @@ public void run() { new BukkitRunnable() { @Override public void run() { - if (activePlacements.containsKey(uuid)) { + if (activePlacements.get(uuid) == placement) { activePlacements.remove(uuid); player.sendMessage("§cVehicle placement cancelled (timeout)."); } @@ -353,6 +352,7 @@ public void onPlayerQuit(PlayerQuitEvent e) { @EventHandler public void onBlockBreak(BlockBreakEvent e) { + if (e.isCancelled()) return; Block block = e.getBlock(); Location loc = block.getLocation(); @@ -363,6 +363,7 @@ public void onBlockBreak(BlockBreakEvent e) { @EventHandler public void onFurnitureBreak(FurnitureBreakEvent e) { + if (e.isCancelled()) return; Location loc = e.getBukkitEntity().getLocation().getBlock().getLocation(); if (!stations.containsKey(loc)) return; diff --git a/src/main/resources/categories.yml b/src/main/resources/categories.yml new file mode 100644 index 0000000..942f99a --- /dev/null +++ b/src/main/resources/categories.yml @@ -0,0 +1,2 @@ +# Blueprint categories are configured here. Existing server configuration is preserved. +{} diff --git a/src/main/resources/stations.yml b/src/main/resources/stations.yml new file mode 100644 index 0000000..9b94b9e --- /dev/null +++ b/src/main/resources/stations.yml @@ -0,0 +1,2 @@ +# Construction stations are configured here. Existing server configuration is preserved. +{} diff --git a/src/test/java/net/tfminecraft/vfbuilders/DatabaseFixture.java b/src/test/java/net/tfminecraft/vfbuilders/DatabaseFixture.java new file mode 100644 index 0000000..4ebf571 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/DatabaseFixture.java @@ -0,0 +1,22 @@ +package net.tfminecraft.vfbuilders; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** One data directory per JVM, matching Database's static path lifetime. */ +final class DatabaseFixture { + private static final Path ROOT; + + static { + try { + ROOT = Files.createTempDirectory("vfbuilders-database-"); + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + } + + static Path folder() throws IOException { + return Files.createDirectories(ROOT); + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java new file mode 100644 index 0000000..c3a0406 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java @@ -0,0 +1,78 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.data.Database; +import net.tfminecraft.vfbuilders.loaders.*; +import org.bukkit.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class DatabaseTest { + @Test + void savesRestoresLegacyAndOwnedProjectsAndHandlesIoFailures() throws Exception { + var server = MockBukkit.mock(); + Path temp = DatabaseFixture.folder(); + try { + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(temp.toFile()); + Files.createDirectories(temp.resolve("data")); + var file = temp.resolve("data/stations.json"); + var world = server.addSimpleWorld("build-world"); + var loc = new Location(world, 3, 64, 5); + var definition = mock(Station.class); + when(definition.getId()).thenReturn("dock"); + StationLoader.get().clear(); + BlueprintLoader.get().clear(); + StationLoader.get().put("dock", definition); + new Database(); + assertTrue(Database.loadStations().isEmpty()); + var empty = new ActiveStation(loc, definition); + Database.saveStations(Map.of(loc, empty)); + var loaded = Database.loadStations(); + assertEquals(1, loaded.size()); + assertNull(loaded.get(loc).getConstructorUuid()); + assertFalse(loaded.get(loc).hasBlueprint()); + var blueprint = mock(Blueprint.class); + when(blueprint.getId()).thenReturn("boat"); + when(blueprint.getTime()).thenReturn(40); + BlueprintLoader.get().put("boat", blueprint); + try (var display = mockStatic(net.tfminecraft.vfbuilders.display.StationTimerDisplay.class)) { + world.unloadChunk(0, 0); + empty.selectBlueprint(blueprint, null); + empty.setSpawnLocation(loc.clone().add(2, 0, 0)); + Database.saveStations(Map.of(loc, empty)); + assertTrue(Database.loadStations().get(loc).hasBlueprint()); + var uuid = UUID.randomUUID(); + empty.setConstructorUuid(uuid); + empty.setTimeLeft(12); + Database.saveStations(Map.of(loc, empty)); + var restored = Database.loadStations().get(loc); + assertEquals(uuid, restored.getConstructorUuid()); + assertEquals(12, restored.getTimeLeft()); + assertEquals(empty.getSpawnLocation(), restored.getSpawnLocation()); + String valid = Files.readString(file); + Files.writeString(file, valid.replace(uuid.toString(), "invalid")); + assertNull(Database.loadStations().get(loc).getConstructorUuid()); + Files.writeString(file, valid.replace(uuid.toString(), "")); + assertNull(Database.loadStations().get(loc).getConstructorUuid()); + StationLoader.get().clear(); + assertTrue(Database.loadStations().isEmpty()); + } + Files.delete(file); + Files.createDirectory(file); + assertTrue(Database.loadStations().isEmpty()); + assertDoesNotThrow(() -> Database.saveStations(Map.of())); + } finally { + VFBuilders.plugin = null; + MockBukkit.unmock(); + try (var paths = Files.walk(temp)) { + for (var p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/DisplayTest.java b/src/test/java/net/tfminecraft/vfbuilders/DisplayTest.java new file mode 100644 index 0000000..8454358 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/DisplayTest.java @@ -0,0 +1,156 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.function.Consumer; +import net.tfminecraft.vfbuilders.display.StationTimerDisplay; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.persistence.PersistentDataContainerMock; + +class DisplayTest { + ServerMock server; + World world; + Location loc; + TextDisplay display; + NamespacedKey key; + Chunk chunk; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getName()).thenReturn("VFBuilders"); + when(VFBuilders.plugin.namespace()).thenReturn("vfbuilders"); + world = mock(World.class); + when(world.getName()).thenReturn("world"); + loc = new Location(world, 2, 64, 3); + display = mock(TextDisplay.class); + when(display.getPersistentDataContainer()).thenReturn(new PersistentDataContainerMock()); + key = new NamespacedKey(VFBuilders.plugin, "station_timer"); + chunk = mock(Chunk.class); + when(world.getChunkAt(0, 0)).thenReturn(chunk); + when(world.isChunkLoaded(0, 0)).thenReturn(true); + when(chunk.getEntities()).thenReturn(new Entity[0]); + } + + @AfterEach + void cleanup() { + VFBuilders.plugin = null; + MockBukkit.unmock(); + } + + @Test + void labelsAndAnchorsPreserveInput() { + assertNull(StationTimerDisplay.blockKey(null)); + assertNull(StationTimerDisplay.blockKey(new Location(null, 0, 0, 0))); + assertEquals("world:2:64:3", StationTimerDisplay.blockKey(loc)); + assertNull(StationTimerDisplay.displayAnchor(null)); + assertEquals(loc.clone().add(.5, 1.35, .5), StationTimerDisplay.displayAnchor(loc)); + assertEquals(64, loc.getY()); + assertTrue(StationTimerDisplay.formatText(null, 60).contains("Vehicle")); + assertTrue(StationTimerDisplay.formatText("boat", 1, "war").contains("Paused: §7war")); + } + + @Test + void spawnValidatesWorldAndConfiguresTaggedDisplay() { + assertNull(StationTimerDisplay.spawn(null, loc, "t")); + assertNull(StationTimerDisplay.spawn(world, null, "t")); + assertNull(StationTimerDisplay.spawn(world, loc, null)); + assertNull(StationTimerDisplay.spawn(world, new Location(null, 0, 0, 0), "t")); + when(world.spawn(any(Location.class), eq(TextDisplay.class), any(Consumer.class))) + .thenAnswer( + a -> { + Consumer action = a.getArgument(2); + action.accept(display); + return display; + }); + assertSame(display, StationTimerDisplay.spawn(world, loc, "timer")); + verify(display).setText("timer"); + verify(display).setBillboard(Display.Billboard.CENTER); + verify(display).setAlignment(TextDisplay.TextAlignment.CENTER); + verify(display).setShadowed(true); + verify(display).setSeeThrough(false); + verify(display).setGravity(false); + verify(display).setInvulnerable(true); + verify(display).setPersistent(false); + assertEquals( + "world:2:64:3", display.getPersistentDataContainer().get(key, PersistentDataType.STRING)); + } + + @Test + void updateAndRemoveHandleMissingAndDeadEntities() { + StationTimerDisplay.update(null, loc, "t"); + StationTimerDisplay.update(display, null, "t"); + StationTimerDisplay.update(display, loc, null); + StationTimerDisplay.update(display, new Location(null, 0, 0, 0), "t"); + when(display.isDead()).thenReturn(true); + StationTimerDisplay.update(display, loc, "t"); + StationTimerDisplay.remove(display); + verify(display, never()).remove(); + when(display.isDead()).thenReturn(false); + StationTimerDisplay.update(display, loc, "t"); + verify(display).setText("t"); + StationTimerDisplay.remove(display); + verify(display).remove(); + StationTimerDisplay.remove(null); + StationTimerDisplay.removeById(null); + StationTimerDisplay.removeById(UUID.randomUUID()); + var entity = server.addPlayer(); + StationTimerDisplay.removeById(entity.getUniqueId()); + assertTrue(entity.isValid()); + try (var b = mockStatic(Bukkit.class)) { + var id = UUID.randomUUID(); + b.when(() -> Bukkit.getEntity(id)).thenReturn(display); + StationTimerDisplay.removeById(id); + verify(display, times(2)).remove(); + } + } + + @Test + void purgeRemovesOnlyNearbyMatchingTimersAndInvisibleMarkers() { + StationTimerDisplay.purgeTaggedInChunk(null, 0, 0); + when(world.isChunkLoaded(0, 0)).thenReturn(false); + StationTimerDisplay.purgeTaggedInChunk(world, 0, 0); + StationTimerDisplay.purgeAtStationBlock(loc); + StationTimerDisplay.purgeLegacyArmorStands(loc); + when(world.isChunkLoaded(0, 0)).thenReturn(true); + StationTimerDisplay.purgeAtStationBlock(null); + StationTimerDisplay.purgeAtStationBlock(new Location(null, 0, 0, 0)); + StationTimerDisplay.purgeLegacyArmorStands(null); + StationTimerDisplay.purgeLegacyArmorStands(new Location(null, 0, 0, 0)); + var other = mock(TextDisplay.class); + when(other.getPersistentDataContainer()).thenReturn(new PersistentDataContainerMock()); + var stand = mock(ArmorStand.class); + when(stand.getPersistentDataContainer()).thenReturn(new PersistentDataContainerMock()); + when(stand.getLocation()).thenReturn(loc.clone().add(.5, 1.35, .5)); + when(display.getLocation()).thenReturn(loc.clone().add(.5, 1.35, .5)); + when(chunk.getEntities()).thenReturn(new Entity[] {display, other, stand}); + StationTimerDisplay.purgeAtStationBlock(loc); + verify(display, never()).remove(); + display.getPersistentDataContainer().set(key, PersistentDataType.STRING, "world:2:64:3"); + StationTimerDisplay.purgeAtStationBlock(loc); + verify(display).remove(); + when(display.getLocation()).thenReturn(loc.clone().add(10, 0, 0)); + StationTimerDisplay.purgeAtStationBlock(loc); + verify(display, times(1)).remove(); + when(stand.isVisible()).thenReturn(true); + StationTimerDisplay.purgeLegacyArmorStands(loc); + when(stand.isVisible()).thenReturn(false); + StationTimerDisplay.purgeLegacyArmorStands(loc); + when(stand.isMarker()).thenReturn(true); + StationTimerDisplay.purgeLegacyArmorStands(loc); + verify(stand).remove(); + when(stand.getLocation()).thenReturn(loc.clone().add(10, 0, 0)); + StationTimerDisplay.purgeLegacyArmorStands(loc); + verify(stand, times(1)).remove(); + StationTimerDisplay.purgeTaggedInChunk(world, 0, 0); + verify(display, times(2)).remove(); + verify(other, never()).remove(); + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java b/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java new file mode 100644 index 0000000..ed5cdb0 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java @@ -0,0 +1,252 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vfbuilders.api.*; +import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.enums.*; +import net.tfminecraft.vfbuilders.events.*; +import net.tfminecraft.vfbuilders.holders.*; +import net.tfminecraft.vfbuilders.loaders.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +class DomainTest { + ServerMock server; + ItemAPI api; + MockedStatic libs; + MockedStatic logger; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs = mockStatic(TLibs.class); + libs.when(TLibs::getItemAPI).thenReturn(api); + logger = mockStatic(VFLogger.class); + StationLoader.get().clear(); + CategoryLoader.get().clear(); + BlueprintLoader.get().clear(); + } + + @AfterEach + void teardown() { + logger.close(); + libs.close(); + MockBukkit.unmock(); + } + + YamlConfiguration yaml(String text) throws Exception { + var y = new YamlConfiguration(); + y.loadFromString(text); + return y; + } + + @Test + void stationCategoryAndBlueprintDefinitions() throws Exception { + var station = new Station("forge", yaml("block: v.stone")); + StationLoader.get().put("forge", station); + assertEquals("forge", station.getId()); + assertEquals("v.stone", station.getBlock()); + assertEquals("v.bedrock", new Station("default", yaml("")).getBlock()); + var category = new BlueprintCategory("boats", yaml("station: forge\npermission: boats")); + CategoryLoader.get().put("boats", category); + assertSame(station, category.getStation()); + assertEquals("boats", category.getId()); + assertEquals(Material.DIRT, category.getItem().getType()); + assertTrue(category.hasPermission()); + assertEquals("boats", category.getPermission()); + var b = + new Blueprint( + "boat", + yaml("category: boats\ntime: 17\npermission: build\ninputs: [v.iron 4, malformed]")); + assertEquals("boat", b.getId()); + assertEquals(17, b.getTime()); + assertTrue(b.hasPermission()); + assertEquals("build", b.getPermission()); + assertFalse(b.hasVehicle()); + assertNull(b.getVehicle()); + assertEquals(Material.DIRT, b.getItem().getType()); + assertEquals(Map.of("v.iron", 4, "malformed", 1), b.getInputs()); + category.addBlueprint(b); + assertEquals(List.of(b), category.getBlueprints()); + assertNull(StationLoader.getByString("missing")); + assertSame(station, StationLoader.getByString("forge")); + assertSame(category, CategoryLoader.getByString("boats")); + assertNull(CategoryLoader.getByString("missing")); + BlueprintLoader.get().put("boat", b); + assertSame(b, BlueprintLoader.getByString("boat")); + assertNull(BlueprintLoader.getByString("missing")); + } + + @Test + void timeDefaultsInvalidAndConfiguredIcons() throws Exception { + assertEquals(10, new Blueprint("b", yaml("")).getTime()); + assertEquals(0, new Blueprint("b", yaml("time: -10")).getTime()); + assertEquals(120, new Blueprint("b", yaml("time: 2m")).getTime()); + assertEquals(10, new Blueprint("b", yaml("time: nonsense")).getTime()); + var icon = new ItemStack(Material.PAPER); + when(api.getCreator().getItemFromConfig(any())).thenReturn(icon); + var b = new Blueprint("b", yaml("item: {type: paper}")); + assertSame(icon, b.getItem()); + assertFalse(b.hasPermission()); + assertNull(b.getPermission()); + var c = new BlueprintCategory("c", yaml("item: {type: paper}")); + assertSame(icon, c.getItem()); + assertFalse(c.hasPermission()); + assertNull(c.getPermission()); + assertNull(c.getStation()); + } + + @Test + void materialAccountingConsumesAcrossStacksAndLeavesUnrelatedItems() throws Exception { + var player = server.addPlayer(); + var b = new Blueprint("b", yaml("inputs: [v.iron 4, v.gold 2]")); + when(api.getChecker().getAsStringPath(any())) + .thenAnswer( + a -> + "v." + + ((ItemStack) a.getArgument(0)) + .getType() + .name() + .toLowerCase() + .replace("_ingot", "")); + player.getInventory().setItem(0, new ItemStack(Material.IRON_INGOT, 2)); + player.getInventory().setItem(1, new ItemStack(Material.STONE, 3)); + player.getInventory().setItem(2, new ItemStack(Material.IRON_INGOT, 5)); + assertFalse(b.hasInputs(player)); + player.getInventory().setItem(3, new ItemStack(Material.GOLD_INGOT, 2)); + assertTrue(b.hasInputs(player)); + b.takeInputs(player); + assertEquals(3, player.getInventory().getItem(1).getAmount()); + assertEquals(3, player.getInventory().getItem(2).getAmount()); + assertTrue( + player.getInventory().getItem(0) == null + || player.getInventory().getItem(0).getAmount() == 0); + assertFalse(b.hasInputs(player)); + var free = new Blueprint("free", yaml("")); + assertTrue(free.hasInputs(player)); + free.takeInputs(player); + player.getInventory().clear(); + assertTrue(free.hasInputs(player)); + b.takeInputs(player); + assertTrue(player.getInventory().isEmpty()); + } + + @Test + void cancellationDropsConfiguredMaterials() throws Exception { + var b = new Blueprint("b", yaml("inputs: [v.iron 4]")); + when(api.getCreator().getItemFromPath("v.iron")).thenReturn(new ItemStack(Material.IRON_INGOT)); + World world = mock(World.class); + var loc = new Location(world, 1, 2, 3); + b.drop(loc); + verify(world) + .dropItemNaturally( + eq(new Location(world, 1.5, 3, 3.5)), + argThat(i -> i.getType() == Material.IRON_INGOT && i.getAmount() == 4)); + assertEquals(1, loc.getX()); + } + + @Test + void eventsExposeConstructionIdentityAndCancellationPolicy() { + var player = server.addPlayer(); + var station = new ActiveStation(player.getLocation(), null); + var b = mock(Blueprint.class); + var loc = player.getLocation(); + var e = new BeginVehicleConstructionEvent(player, b, station, loc); + assertSame(player, e.getConstructor()); + assertSame(b, e.getBlueprint()); + assertSame(station, e.getStation()); + assertSame(loc, e.getSpawnLocation()); + assertFalse(e.isCancelled()); + assertFalse(e.isKeepPlacement()); + e.setCancelled(true); + e.setKeepPlacement(true); + assertTrue(e.isCancelled()); + assertTrue(e.isKeepPlacement()); + assertSame(BeginVehicleConstructionEvent.getHandlerList(), e.getHandlers()); + var complete = new VehicleConstructEvent(player.getUniqueId(), null, b, loc, station); + assertSame(player, complete.getConstructor()); + assertEquals(player.getUniqueId(), complete.getConstructorUuid()); + assertNull(complete.getVehicle()); + assertSame(b, complete.getBlueprint()); + assertSame(station, complete.getStation()); + assertSame(loc, complete.getSpawnLocation()); + assertSame(VehicleConstructEvent.getHandlerList(), complete.getHandlers()); + assertNull(new VehicleConstructEvent(null, null, b, loc, station).getConstructor()); + var cancel = new VehicleConstructionCancelEvent(player.getUniqueId(), b, station); + assertEquals(player.getUniqueId(), cancel.getConstructorUuid()); + assertSame(b, cancel.getBlueprint()); + assertSame(station, cancel.getStation()); + assertSame(VehicleConstructionCancelEvent.getHandlerList(), cancel.getHandlers()); + var placement = new ActivePlacement(player, station, b); + assertSame(player, placement.getPlayer()); + assertSame(station, placement.getStation()); + assertSame(b, placement.getBlueprint()); + assertNull(placement.getFinalSpawnLocation()); + placement.setFinalSpawnLocation(loc); + assertSame(loc, placement.getFinalSpawnLocation()); + var holder = new VFBHolder(station, VFBGUI.BLUEPRINT); + assertSame(station, holder.getStation()); + assertEquals(VFBGUI.BLUEPRINT, holder.getType()); + assertNull(holder.getInventory()); + new Cache(); + } + + @Test + void freezeProvidersFailIndependentlyAndFirstReasonWins() { + assertNull(ConstructionFreezes.reason(null)); + var uuid = UUID.randomUUID(); + assertNull(ConstructionFreezes.reason(uuid)); + var plugin = MockBukkit.createMockPlugin(); + ConstructionFreeze broken = + id -> { + throw new IllegalStateException("failure"); + }; + ConstructionFreeze pass = id -> null; + ConstructionFreeze paused = id -> "under siege"; + server + .getServicesManager() + .register(ConstructionFreeze.class, broken, plugin, ServicePriority.Highest); + server + .getServicesManager() + .register(ConstructionFreeze.class, pass, plugin, ServicePriority.High); + assertNull(ConstructionFreezes.reason(uuid)); + server + .getServicesManager() + .register(ConstructionFreeze.class, paused, plugin, ServicePriority.Normal); + assertEquals("under siege", ConstructionFreezes.reason(uuid)); + } + + @Test + void airSlotsAreIgnoredAndKnownVehicleIsResolved() throws Exception { + var player = mock(Player.class); + var inventory = mock(org.bukkit.inventory.PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getContents()).thenReturn(new ItemStack[] {new ItemStack(Material.AIR)}); + var b = new Blueprint("b", yaml("inputs: [v.iron 1]")); + assertFalse(b.hasInputs(player)); + b.takeInputs(player); + verify(player).updateInventory(); + try (var loader = mockStatic(net.tfminecraft.vehicleframework.loaders.VehicleLoader.class)) { + var vehicle = mock(net.tfminecraft.vehicleframework.vehicles.Vehicle.class); + loader + .when(() -> net.tfminecraft.vehicleframework.loaders.VehicleLoader.getByString("boat")) + .thenReturn(vehicle); + var known = new Blueprint("b", yaml("vehicle: boat")); + assertTrue(known.hasVehicle()); + assertSame(vehicle, known.getVehicle()); + } + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/InventoryTest.java b/src/test/java/net/tfminecraft/vfbuilders/InventoryTest.java new file mode 100644 index 0000000..ce27df8 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/InventoryTest.java @@ -0,0 +1,139 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.VehicleComponent; +import net.tfminecraft.vehicleframework.weapons.Weapon; +import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.loaders.*; +import net.tfminecraft.vfbuilders.managers.*; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class InventoryTest { + ServerMock server; + Player player; + ActiveStation station; + Station definition; + InventoryManager manager; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + player = server.addPlayer(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.namespace()).thenReturn("vfbuilders"); + when(VFBuilders.plugin.getServer()).thenReturn(server); + definition = mock(Station.class); + station = new ActiveStation(player.getLocation(), definition); + manager = new InventoryManager(); + CategoryLoader.get().clear(); + } + + @AfterEach + void teardown() { + MockBukkit.unmock(); + VFBuilders.plugin = null; + } + + BlueprintCategory category(String id, Station base, boolean perm) { + var cat = mock(BlueprintCategory.class); + when(cat.getId()).thenReturn(id); + when(cat.getStation()).thenReturn(base); + when(cat.getItem()).thenReturn(new ItemStack(Material.PAPER)); + when(cat.hasPermission()).thenReturn(perm); + when(cat.getPermission()).thenReturn("boats"); + CategoryLoader.get().put(id, cat); + return cat; + } + + Blueprint blueprint(String id, boolean perm, boolean vehicle) { + var b = mock(Blueprint.class); + when(b.getId()).thenReturn(id); + when(b.getItem()).thenReturn(new ItemStack(Material.PAPER)); + when(b.hasPermission()).thenReturn(perm); + when(b.getPermission()).thenReturn("boats"); + when(b.hasVehicle()).thenReturn(vehicle); + return b; + } + + @Test + void categoriesWithPlainIconsDoNotCrashAndRespectStationAndPermission() { + var cat = category("boats", definition, false); + category("other", mock(Station.class), false); + category("locked", definition, true); + var free = blueprint("free", false, false); + var gated = blueprint("locked", true, false); + when(cat.getBlueprints()).thenReturn(List.of(free, gated)); + assertDoesNotThrow(() -> manager.categoryView(null, player, station, true)); + var inv = player.getOpenInventory().getTopInventory(); + assertEquals( + "boats", + inv.getItem(0) + .getItemMeta() + .getPersistentDataContainer() + .get( + new NamespacedKey(VFBuilders.plugin, "vfb_category_id"), + PersistentDataType.STRING)); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inv.getItem(1).getType()); + assertTrue(inv.getItem(0).getItemMeta().getLore().toString().contains("1")); + var icon = cat.getItem(); + var meta = icon.getItemMeta(); + meta.setLore(List.of("Original")); + icon.setItemMeta(meta); + player.setOp(true); + manager.categoryView(server.createInventory(null, 27), player, station, false); + } + + @Test + void blueprintsWithPlainIconsRenderVehicleAndIngredientDetails() { + try (var libs = mockStatic(TLibs.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + var cat = category("boats", definition, false); + var b = blueprint("boat", false, true); + var invalid = blueprint("bad", false, false); + var locked = blueprint("locked", true, true); + when(cat.getBlueprints()).thenReturn(List.of(b, invalid, locked)); + var v = mock(Vehicle.class, RETURNS_DEEP_STUBS); + when(v.getName()).thenReturn("Boat"); + when(b.getVehicle()).thenReturn(v); + var component = mock(VehicleComponent.class, RETURNS_DEEP_STUBS); + when(component.getAlias()).thenReturn("Hull"); + when(v.getComponentHandler().getComponents()).thenReturn(new ArrayList<>(List.of(component))); + when(b.getInputs()).thenReturn(new HashMap<>(Map.of("v.iron", 3, "v.gold", 1))); + var named = new ItemStack(Material.GOLD_INGOT); + var meta = named.getItemMeta(); + meta.setDisplayName("Special Gold"); + named.setItemMeta(meta); + when(api.getCreator().getItemFromPath("v.gold")).thenReturn(named); + when(api.getCreator().getItemFromPath("v.iron")) + .thenReturn(new ItemStack(Material.IRON_INGOT)); + assertDoesNotThrow(() -> manager.blueprintView(null, player, cat, station, true)); + var inv = player.getOpenInventory().getTopInventory(); + assertEquals("Boat", inv.getItem(0).getItemMeta().getDisplayName()); + assertEquals(Material.BARRIER, inv.getItem(26).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inv.getItem(1).getType()); + assertTrue(inv.getItem(0).getItemMeta().getLore().toString().contains("Special Gold")); + var weapon = mock(Weapon.class, RETURNS_DEEP_STUBS); + when(v.getWeapons()).thenReturn(new ArrayList<>(List.of(weapon))); + when(weapon.getName()).thenReturn("Cannon"); + var icon = b.getItem(); + var original = icon.getItemMeta(); + original.setLore(List.of("Original")); + icon.setItemMeta(original); + player.setOp(true); + when(locked.getVehicle()).thenReturn(v); + manager.blueprintView(server.createInventory(null, 27), player, cat, station, false); + } + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/LifecycleTest.java b/src/test/java/net/tfminecraft/vfbuilders/LifecycleTest.java new file mode 100644 index 0000000..5935d6b --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/LifecycleTest.java @@ -0,0 +1,65 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vfbuilders.data.Database; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class LifecycleTest { + @Test + void enableReloadDisableAndExistingConfiguration() throws Exception { + var server = MockBukkit.mock(); + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("VehicleFramework"); + // The persistence test exercises real files; lifecycle isolates that boundary because its + // legacy path is static. + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(DatabaseFixture.folder().toFile()); + try (var db = mockStatic(Database.class); + var log = mockStatic(VFLogger.class)) { + db.when(Database::loadStations).thenReturn(new HashMap<>()); + var p = MockBukkit.load(VFBuilders.class); + assertSame(p, VFBuilders.plugin); + assertNotNull(p.getCommand("vfbuilders").getExecutor()); + assertNotNull(p.getCommand("vfbuilders").getTabCompleter()); + assertTrue(p.getDataFolder().toPath().resolve("blueprints").toFile().isDirectory()); + p.createFolders(); + p.createConfigs(); + p.reload(); + server.getScheduler().performTicks(21); + p.onDisable(); + db.verify(() -> Database.saveStations(anyMap())); + var folder = p.getDataFolder().toPath().resolve("blueprints"); + Files.writeString(folder.resolve("empty.yml"), ""); + Files.createDirectory(folder.resolve("ignored")); + p.loadConfigs(); + Files.setPosixFilePermissions(folder, java.util.Set.of()); + try { + p.loadConfigs(); + } finally { + Files.setPosixFilePermissions( + folder, java.nio.file.attribute.PosixFilePermissions.fromString("rwx------")); + } + Files.delete(folder.resolve("empty.yml")); + Files.delete(folder.resolve("ignored")); + Files.delete(folder); + p.loadConfigs(); + Files.writeString(folder, "not a directory"); + p.loadConfigs(); + try (var paths = Files.walk(p.getDataFolder().toPath())) { + for (var path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) + Files.delete(path); + } + p.createFolders(); + assertTrue(p.getDataFolder().isDirectory()); + } finally { + MockBukkit.unmock(); + VFBuilders.plugin = null; + } + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/LoadersTest.java b/src/test/java/net/tfminecraft/vfbuilders/LoadersTest.java new file mode 100644 index 0000000..7c0355e --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/LoadersTest.java @@ -0,0 +1,57 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vfbuilders.loaders.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class LoadersTest { + @TempDir Path temp; + + @Test + void yamlLoadersReadEntriesAndHandleMissingOrMalformedFiles() throws Exception { + MockBukkit.mock(); + try (var libs = mockStatic(TLibs.class); + var logger = mockStatic(VFLogger.class)) { + libs.when(TLibs::getItemAPI).thenReturn(mock(ItemAPI.class, RETURNS_DEEP_STUBS)); + StationLoader.get().clear(); + CategoryLoader.get().clear(); + BlueprintLoader.get().clear(); + var file = + Files.writeString(temp.resolve("config.yml"), "construction-max-distance: 12").toFile(); + new ConfigLoader().load(file); + assertEquals(12, Cache.constructionDistance); + Files.writeString(file.toPath(), "dock: {block: v.stone}\n"); + new StationLoader().load(file); + assertEquals("v.stone", StationLoader.getByString("dock").getBlock()); + Files.writeString(file.toPath(), "boats: {station: dock}\n"); + new CategoryLoader().load(file); + assertEquals("dock", CategoryLoader.getByString("boats").getStation().getId()); + Files.writeString(file.toPath(), "boat: {category: boats, time: 30}\n"); + new BlueprintLoader().load(file); + assertEquals(30, BlueprintLoader.getByString("boat").getTime()); + for (String text : new String[] {"broken: [", ""}) { + Files.writeString(file.toPath(), text); + assertDoesNotThrow(() -> new ConfigLoader().load(file)); + assertDoesNotThrow(() -> new StationLoader().load(file)); + assertDoesNotThrow(() -> new CategoryLoader().load(file)); + assertDoesNotThrow(() -> new BlueprintLoader().load(file)); + } + Files.delete(file.toPath()); + new ConfigLoader().load(file); + new StationLoader().load(file); + new CategoryLoader().load(file); + new BlueprintLoader().load(file); + assertEquals(8, Cache.constructionDistance); + } finally { + MockBukkit.unmock(); + } + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java new file mode 100644 index 0000000..a66828d --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java @@ -0,0 +1,341 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.loaders.*; +import net.tfminecraft.vfbuilders.managers.*; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.block.*; +import org.bukkit.event.player.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +class ManagerTest { + ServerMock server; + StationManager manager; + MockedStatic libs; + BlockAPI api; + Station definition; + Block block; + Player player; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + player = server.addPlayer(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getName()).thenReturn("VFBuilders"); + when(VFBuilders.plugin.namespace()).thenReturn("vfbuilders"); + when(VFBuilders.plugin.isEnabled()).thenReturn(true); + when(VFBuilders.plugin.getServer()).thenReturn(server); + api = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + libs = mockStatic(TLibs.class); + libs.when(TLibs::getBlockAPI).thenReturn(api); + StationLoader.get().clear(); + definition = mock(Station.class); + when(definition.getBlock()).thenReturn("v.stone"); + StationLoader.get().put("dock", definition); + block = player.getWorld().getBlockAt(1, 64, 1); + when(api.getChecker().checkBlock(block, "v.stone")).thenReturn(true); + manager = new StationManager(); + } + + @AfterEach + void teardown() { + libs.close(); + MockBukkit.unmock(); + VFBuilders.plugin = null; + } + + @SuppressWarnings("unchecked") + Map placements() throws Exception { + var f = StationManager.class.getDeclaredField("activePlacements"); + f.setAccessible(true); + return (Map) f.get(manager); + } + + @Test + void lookupCachesOnlyMatchingStations() { + assertNull(manager.getStation(player.getWorld().getBlockAt(9, 1, 1))); + var s = manager.getStation(block); + assertSame(s, manager.getStation(block)); + assertSame(definition, s.getStation()); + } + + @Test + void cancelledBlockBreakPreservesStationAndConstruction() { + var s = manager.getStation(block); + var b = mock(Blueprint.class); + s.selectBlueprint(b, null); + var event = new BlockBreakEvent(block, player); + event.setCancelled(true); + manager.onBlockBreak(event); + assertSame(s, manager.getStation(block)); + verify(b, never()).drop(any()); + } + + @Test + void worldChangeCannotCrashPlacementOrConsumeMaterials() throws Exception { + var s = manager.getStation(block); + var b = mock(Blueprint.class); + when(b.hasInputs(player)).thenReturn(true); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, b)); + player.teleport(new Location(server.addSimpleWorld("elsewhere"), 0, 64, 0)); + var event = + new PlayerInteractEvent( + player, Action.LEFT_CLICK_AIR, null, null, org.bukkit.block.BlockFace.SELF); + assertDoesNotThrow(() -> manager.onPlayerLeftClick(event)); + verify(b, never()).takeInputs(player); + } + + @Test + void commandsRespectUsagePermissionAndCompletion() { + var m = new CommandManager(); + var cmd = mock(org.bukkit.command.Command.class); + assertTrue(m.onCommand(player, cmd, "vfbuilders", new String[0])); + assertTrue(m.onCommand(player, cmd, "vfbuilders", new String[] {"wrong"})); + assertTrue(m.onCommand(player, cmd, "vfbuilders", new String[] {"reload"})); + verify(VFBuilders.plugin, never()).reload(); + assertTrue(m.onTabComplete(player, cmd, "", new String[] {"r"}).isEmpty()); + player.setOp(true); + assertEquals(List.of("reload"), m.onTabComplete(player, cmd, "", new String[] {"RE"})); + assertTrue(m.onTabComplete(player, cmd, "", new String[] {"x"}).isEmpty()); + assertTrue(m.onTabComplete(player, cmd, "", new String[0]).isEmpty()); + assertTrue(m.onCommand(player, cmd, "", new String[] {"RELOAD"})); + verify(VFBuilders.plugin).reload(); + player.setOp(false); + var permitted = mock(Player.class); + when(permitted.hasPermission("vfbuilders.reload")).thenReturn(true); + assertTrue(m.onCommand(permitted, cmd, "", new String[] {"reload"})); + assertTrue(m.onCommand(server.getConsoleSender(), cmd, "", new String[] {"reload"})); + verify(VFBuilders.plugin, times(3)).reload(); + } + + org.bukkit.event.inventory.InventoryClickEvent click( + ActiveStation station, + org.bukkit.inventory.ItemStack item, + net.tfminecraft.vfbuilders.enums.VFBGUI type) { + var e = mock(org.bukkit.event.inventory.InventoryClickEvent.class, RETURNS_DEEP_STUBS); + when(e.getView().getTopInventory().getHolder()) + .thenReturn(new net.tfminecraft.vfbuilders.holders.VFBHolder(station, type)); + when(e.getWhoClicked()).thenReturn(player); + when(e.getCurrentItem()).thenReturn(item); + return e; + } + + org.bukkit.inventory.ItemStack tagged(String key, String id) { + var i = new org.bukkit.inventory.ItemStack(Material.PAPER); + var m = i.getItemMeta(); + m.getPersistentDataContainer() + .set( + new NamespacedKey(VFBuilders.plugin, key), + org.bukkit.persistence.PersistentDataType.STRING, + id); + i.setItemMeta(m); + return i; + } + + PlayerInteractEvent left() { + return new PlayerInteractEvent( + player, Action.LEFT_CLICK_AIR, null, null, org.bukkit.block.BlockFace.SELF); + } + + @Test + void menuClicksDispatchCategoriesAndStartPlacementOnlyWithMaterials() throws Exception { + var field = StationManager.class.getDeclaredField("inv"); + field.setAccessible(true); + var menus = mock(InventoryManager.class); + field.set(manager, menus); + var s = manager.getStation(block); + manager.inventoryClick( + mock(org.bukkit.event.inventory.InventoryClickEvent.class, RETURNS_DEEP_STUBS)); + manager.inventoryClick(click(s, null, net.tfminecraft.vfbuilders.enums.VFBGUI.CATEGORY)); + manager.inventoryClick( + click( + s, + new org.bukkit.inventory.ItemStack(Material.BARRIER), + net.tfminecraft.vfbuilders.enums.VFBGUI.BLUEPRINT)); + verify(menus).categoryView(null, player, s, true); + manager.inventoryClick( + click( + s, + new org.bukkit.inventory.ItemStack(Material.BARRIER), + net.tfminecraft.vfbuilders.enums.VFBGUI.CATEGORY)); + manager.inventoryClick( + click( + s, + new org.bukkit.inventory.ItemStack(Material.PAPER), + net.tfminecraft.vfbuilders.enums.VFBGUI.CATEGORY)); + var category = mock(BlueprintCategory.class); + CategoryLoader.get().put("boats", category); + manager.inventoryClick( + click( + s, + tagged("vfb_category_id", "boats"), + net.tfminecraft.vfbuilders.enums.VFBGUI.CATEGORY)); + verify(menus).blueprintView(null, player, category, s, true); + var b = mock(Blueprint.class); + BlueprintLoader.get().put("boat", b); + var item = tagged("vfb_blueprint_id", "boat"); + manager.inventoryClick(click(s, item, net.tfminecraft.vfbuilders.enums.VFBGUI.BLUEPRINT)); + assertTrue(placements().isEmpty()); + when(b.hasInputs(player)).thenReturn(true); + manager.inventoryClick(click(s, item, net.tfminecraft.vfbuilders.enums.VFBGUI.BLUEPRINT)); + assertSame(b, placements().get(player.getUniqueId()).getBlueprint()); + server.getScheduler().performTicks(601); + assertTrue(placements().isEmpty()); + } + + @Test + void placementDistanceMaterialsAndSuccessfulCommit() throws Exception { + var s = manager.getStation(block); + var b = mock(Blueprint.class); + var vehicle = mock(net.tfminecraft.vehicleframework.vehicles.Vehicle.class); + when(vehicle.getName()).thenReturn("Boat"); + when(b.getVehicle()).thenReturn(vehicle); + manager.onPlayerLeftClick(left()); + manager.onPlayerLeftClick( + new PlayerInteractEvent( + player, Action.RIGHT_CLICK_AIR, null, null, org.bukkit.block.BlockFace.SELF)); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, b)); + player.teleport(block.getLocation().clone().add(20, 0, 0)); + manager.onPlayerLeftClick(left()); + assertFalse(placements().isEmpty()); + player.teleport(block.getLocation()); + manager.onPlayerLeftClick(left()); + assertTrue(placements().isEmpty()); + verify(b, never()).takeInputs(player); + when(b.hasInputs(player)).thenReturn(true); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, b)); + manager.onPlayerLeftClick(left()); + verify(b).takeInputs(player); + assertTrue(placements().isEmpty()); + assertSame(b, s.getBlueprint()); + assertEquals(player.getUniqueId(), s.getConstructorUuid()); + assertEquals(player.getLocation(), s.getSpawnLocation()); + } + + @Test + void cancelledBeginCanKeepPlacementForConfirmation() throws Exception { + var s = manager.getStation(block); + var b = mock(Blueprint.class); + when(b.hasInputs(player)).thenReturn(true); + player.teleport(block.getLocation()); + var plugin = MockBukkit.createMockPlugin(); + var keep = new java.util.concurrent.atomic.AtomicBoolean(true); + server + .getPluginManager() + .registerEvent( + net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent.class, + new org.bukkit.event.Listener() {}, + org.bukkit.event.EventPriority.NORMAL, + (listener, event) -> { + var e = (net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent) event; + e.setCancelled(true); + e.setKeepPlacement(keep.get()); + }, + plugin); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, b)); + manager.onPlayerLeftClick(left()); + assertFalse(placements().isEmpty()); + keep.set(false); + manager.onPlayerLeftClick(left()); + assertTrue(placements().isEmpty()); + verify(b, never()).takeInputs(player); + } + + @Test + void quitAndBreakRemoveOnlyTheirOwnState() throws Exception { + var s = manager.getStation(block); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, mock(Blueprint.class))); + manager.onPlayerQuit(new PlayerQuitEvent(player, "quit")); + assertTrue(placements().isEmpty()); + manager.onPlayerQuit(new PlayerQuitEvent(player, "quit")); + manager.onBlockBreak(new BlockBreakEvent(player.getWorld().getBlockAt(50, 64, 50), player)); + manager.onBlockBreak(new BlockBreakEvent(block, player)); + assertNotSame(s, manager.getStation(block)); + var furniture = mock(dev.lone.itemsadder.api.Events.FurnitureBreakEvent.class); + when(furniture.isCancelled()).thenReturn(true); + manager.onFurnitureBreak(furniture); + when(furniture.isCancelled()).thenReturn(false); + var entity = mock(org.bukkit.entity.Entity.class); + when(entity.getLocation()).thenReturn(block.getLocation()); + when(furniture.getBukkitEntity()).thenReturn(entity); + when(furniture.getPlayer()).thenReturn(player); + manager.onFurnitureBreak(furniture); + manager.onFurnitureBreak(furniture); + } + + @Test + void rightClickOpensOnlyAnIdleStation() throws Exception { + var field = StationManager.class.getDeclaredField("inv"); + field.setAccessible(true); + var menus = mock(InventoryManager.class); + field.set(manager, menus); + manager.stationInteract(left()); + var missing = + new PlayerInteractEvent( + player, + Action.RIGHT_CLICK_BLOCK, + null, + player.getWorld().getBlockAt(50, 64, 50), + org.bukkit.block.BlockFace.UP); + manager.stationInteract(missing); + var e = + new PlayerInteractEvent( + player, Action.RIGHT_CLICK_BLOCK, null, block, org.bukkit.block.BlockFace.UP); + manager.stationInteract(e); + var s = manager.getStation(block); + verify(menus).categoryView(null, player, s, true); + assertTrue(e.isCancelled()); + s.selectBlueprint(mock(Blueprint.class), null); + manager.stationInteract(e); + verifyNoMoreInteractions(menus); + } + + @Test + void oldPlacementTimeoutMustNotCancelNewerSelection() throws Exception { + var s = manager.getStation(block); + var b = mock(Blueprint.class); + when(b.hasInputs(player)).thenReturn(true); + BlueprintLoader.get().put("boat", b); + var item = tagged("vfb_blueprint_id", "boat"); + player.teleport(block.getLocation()); + manager.inventoryClick(click(s, item, net.tfminecraft.vfbuilders.enums.VFBGUI.BLUEPRINT)); + server.getScheduler().performTicks(10); + manager.inventoryClick(click(s, item, net.tfminecraft.vfbuilders.enums.VFBGUI.BLUEPRINT)); + var current = placements().get(player.getUniqueId()); + server.getScheduler().performTicks(591); + assertSame(current, placements().get(player.getUniqueId())); + server.getScheduler().performTicks(10); + assertTrue(placements().isEmpty()); + } + + @Test + void particleTrailStopsAfterCommitOrAnotherConstructionAndIgnoresOtherWorlds() throws Exception { + var s = manager.getStation(block); + var b = mock(Blueprint.class); + when(b.hasInputs(player)).thenReturn(true); + BlueprintLoader.get().put("boat", b); + var item = tagged("vfb_blueprint_id", "boat"); + player.teleport(block.getLocation()); + manager.inventoryClick(click(s, item, net.tfminecraft.vfbuilders.enums.VFBGUI.BLUEPRINT)); + server.getScheduler().performTicks(1); + player.teleport(new Location(server.addSimpleWorld("away"), 0, 64, 0)); + assertDoesNotThrow(() -> server.getScheduler().performTicks(5)); + s.selectBlueprint(b, null); + server.getScheduler().performTicks(5); + assertTrue(placements().isEmpty()); + server.getScheduler().performTicks(600); + manager.onBlockBreak(new BlockBreakEvent(block, player)); + verify(b).drop(any()); + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java b/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java new file mode 100644 index 0000000..5195258 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java @@ -0,0 +1,83 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.data.Database; +import net.tfminecraft.vfbuilders.display.StationTimerDisplay; +import net.tfminecraft.vfbuilders.managers.StationManager; +import org.bukkit.*; +import org.bukkit.event.world.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class SchedulerTest { + @TempDir Path temp; + + @Test + void ticksPauseIncompleteWorkFinishReadyWorkAndRefreshOnlyLoadedChunks() throws Exception { + var server = MockBukkit.mock(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(DatabaseFixture.folder().toFile()); + when(VFBuilders.plugin.isEnabled()).thenReturn(true); + when(VFBuilders.plugin.getName()).thenReturn("VFBuilders"); + try (var db = mockStatic(Database.class); + var display = mockStatic(StationTimerDisplay.class)) { + var world = mock(World.class); + var otherWorld = mock(World.class); + when(world.isChunkLoaded(0, 0)).thenReturn(true); + var chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + var ready = mock(ActiveStation.class); + when(ready.hasBlueprint()).thenReturn(true); + when(ready.tick()).thenReturn(true); + when(ready.getLocation()).thenReturn(new Location(world, 1, 64, 1)); + when(ready.hasSpawnLocation()).thenReturn(true); + when(ready.getSpawnLocation()).thenReturn(new Location(world, 3, 64, 3)); + var idle = mock(ActiveStation.class); + when(idle.getLocation()).thenReturn(new Location(world, 2, 64, 2)); + var waiting = mock(ActiveStation.class); + when(waiting.hasBlueprint()).thenReturn(true); + when(waiting.getLocation()).thenReturn(new Location(world, 50, 64, 1)); + when(waiting.hasSpawnLocation()).thenReturn(true); + when(waiting.getSpawnLocation()).thenReturn(new Location(world, 50, 64, 2)); + var z = mock(ActiveStation.class); + when(z.getLocation()).thenReturn(new Location(world, 1, 64, 50)); + var elsewhere = mock(ActiveStation.class); + when(elsewhere.getLocation()).thenReturn(new Location(otherWorld, 1, 64, 1)); + var detached = mock(ActiveStation.class); + when(detached.hasBlueprint()).thenReturn(true); + when(detached.getLocation()).thenReturn(new Location(null, 0, 0, 0)); + when(detached.hasSpawnLocation()).thenReturn(true); + when(detached.getSpawnLocation()).thenReturn(new Location(null, 0, 0, 0)); + var map = new HashMap(); + for (var s : List.of(ready, idle, waiting, z, elsewhere, detached)) + map.put(s.getLocation(), s); + db.when(Database::loadStations).thenReturn(map); + var manager = new StationManager(); + manager.start(); + server.getScheduler().performTicks(21); + verify(ready, atLeastOnce()).complete(); + verify(waiting, never()).complete(); + verify(idle, never()).tick(); + verify(ready, atLeastOnce()).ensureDisplay(); + verify(detached, atLeastOnce()).removeDisplay(); + manager.onChunkLoad(new ChunkLoadEvent(chunk, false)); + manager.onChunkUnload(new ChunkUnloadEvent(chunk)); + verify(ready).removeDisplay(); + var reload = mock(Runnable.class); + manager.rebindAfterReload(reload); + verify(reload).run(); + verify(waiting).rebindDefinitions(); + manager.stop(); + db.verify(() -> Database.saveStations(map)); + } finally { + MockBukkit.unmock(); + VFBuilders.plugin = null; + } + } +} diff --git a/src/test/java/net/tfminecraft/vfbuilders/StationTest.java b/src/test/java/net/tfminecraft/vfbuilders/StationTest.java new file mode 100644 index 0000000..bcf0184 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/StationTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.vfbuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.vehicles.*; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vfbuilders.api.*; +import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.display.*; +import net.tfminecraft.vfbuilders.loaders.*; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +class StationTest { + ServerMock server; + World world; + Location loc; + Blueprint b; + Station definition; + MockedStatic displays; + MockedStatic freezes; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = mock(World.class); + loc = new Location(world, 1, 64, 3); + b = mock(Blueprint.class); + definition = mock(Station.class); + when(b.getTime()).thenReturn(2); + when(b.getId()).thenReturn("boat"); + when(definition.getId()).thenReturn("dock"); + displays = mockStatic(StationTimerDisplay.class); + freezes = mockStatic(ConstructionFreezes.class); + BlueprintLoader.get().clear(); + StationLoader.get().clear(); + } + + @AfterEach + void cleanup() { + displays.close(); + freezes.close(); + MockBukkit.unmock(); + } + + @Test + void selectionCountdownFreezeAndCancellation() { + var s = new ActiveStation(loc, definition); + assertNotNull(s.getUuid()); + assertSame(loc, s.getLocation()); + assertSame(definition, s.getStation()); + assertFalse(s.hasBlueprint()); + assertFalse(s.hasSpawnLocation()); + assertNull(s.getSpawnLocation()); + assertNull(s.getBlueprint()); + assertEquals(0, s.getTimeLeft()); + assertNull(s.getConstructorUuid()); + assertTrue(s.tick()); + s.ensureDisplay(); + s.complete(); + var id = UUID.randomUUID(); + s.selectBlueprint(b, id); + assertTrue(s.hasBlueprint()); + assertEquals(2, s.getTimeLeft()); + assertEquals(id, s.getConstructorUuid()); + freezes.when(() -> ConstructionFreezes.reason(id)).thenReturn("pause"); + assertFalse(s.tick()); + assertEquals(2, s.getTimeLeft()); + freezes.when(() -> ConstructionFreezes.reason(id)).thenReturn(null); + assertFalse(s.tick()); + assertTrue(s.tick()); + assertEquals(0, s.getTimeLeft()); + s.setSpawnLocation(loc); + assertTrue(s.hasSpawnLocation()); + s.cancelConstruction(); + verify(b).drop(eq(loc.clone().add(.5, 1, .5))); + assertFalse(s.hasBlueprint()); + assertFalse(s.hasSpawnLocation()); + assertNull(s.getConstructorUuid()); + s.cancelConstruction(); + s.setTimeLeft(-1); + assertTrue(s.tick()); + s.setConstructorUuid(id); + assertEquals(id, s.getConstructorUuid()); + } + + @Test + void restorationAndReloadRebindOrRefund() { + var old = new ActiveStation(loc, definition, null, 3, null); + assertFalse(old.hasBlueprint()); + var missing = new ActiveStation(loc, null, "missing", 4, loc); + assertFalse(missing.hasBlueprint()); + assertSame(loc, missing.getSpawnLocation()); + missing.rebindDefinitions(); + missing.complete(); + old.rebindDefinitions(); + BlueprintLoader.get().put("boat", b); + var s = new ActiveStation(loc, definition, "boat", 5, loc, UUID.randomUUID()); + assertSame(b, s.getBlueprint()); + s.rebindDefinitions(); + assertSame(definition, s.getStation()); + var replacement = mock(Station.class); + StationLoader.get().put("dock", replacement); + var updated = mock(Blueprint.class); + when(updated.getId()).thenReturn("boat"); + BlueprintLoader.get().put("boat", updated); + s.rebindDefinitions(); + assertSame(replacement, s.getStation()); + assertSame(updated, s.getBlueprint()); + BlueprintLoader.get().clear(); + s.rebindDefinitions(); + assertFalse(s.hasBlueprint()); + verify(updated).drop(any()); + } + + @Test + void displaysWaitForLoadedChunksAndReuseLivingEntity() { + var noWorld = new ActiveStation(new Location(null, 0, 0, 0), definition); + noWorld.selectBlueprint(b, null); + when(world.isChunkLoaded(0, 0)).thenReturn(true); + var vehicle = mock(Vehicle.class); + when(vehicle.getName()).thenReturn("boat"); + when(b.getVehicle()).thenReturn(vehicle); + displays.when(() -> StationTimerDisplay.formatText("boat", 2, null)).thenReturn("timer"); + TextDisplay display = mock(TextDisplay.class); + displays.when(() -> StationTimerDisplay.spawn(world, loc, "timer")).thenReturn(display); + var s = new ActiveStation(loc, definition); + s.selectBlueprint(b, null); + s.ensureDisplay(); + displays.verify(() -> StationTimerDisplay.update(display, loc, "timer")); + when(display.isDead()).thenReturn(true); + s.ensureDisplay(); + displays.verify(() -> StationTimerDisplay.spawn(world, loc, "timer"), times(2)); + s.removeHolograms(); + } + + @Test + void completionWaitsForNearbyPlayersAndRetriesFailedSpawn() { + var s = new ActiveStation(loc, definition); + s.selectBlueprint(b, null); + s.complete(); + s.setSpawnLocation(loc); + when(world.getPlayers()).thenReturn(List.of()); + s.complete(); + assertTrue(s.hasBlueprint()); + var p = mock(Player.class); + when(p.getLocation()).thenReturn(loc.clone().add(97, 0, 0)); + when(world.getPlayers()).thenReturn(List.of(p)); + s.complete(); + assertTrue(s.hasBlueprint()); + when(p.getLocation()).thenReturn(loc.clone().add(96, 0, 0)); + try (var vf = mockStatic(VehicleFramework.class); + var logger = mockStatic(VFLogger.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + s.complete(); + assertTrue(s.hasBlueprint()); + var v = mock(ActiveVehicle.class); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(v); + s.complete(); + assertFalse(s.hasBlueprint()); + assertFalse(s.hasSpawnLocation()); + assertNull(s.getConstructorUuid()); + verify(world) + .playSound(any(Location.class), eq(Sound.BLOCK_BEACON_ACTIVATE), eq(.8f), eq(1.6f)); + } + } +}