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));
+ }
+ }
+}