From 552e57424eb1471ee4ad373a9623b7e859d99ed0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:54:47 +0000 Subject: [PATCH] Add unit tests for external plugin hook implementations Add comprehensive test suites for CoreItemsAdderHook, CoreNexoHook, CoreModelEngineHook, and CoreMythicMobsHook. Tests cover item/block/entity detection, model resolution, drops, block placement/removal, naming fallbacks, and playBlockDropEffects. Uses Unsafe.allocateInstance to bypass PluginHook constructors and reflection to work around unmockable Kotlin classes in Nexo and MythicMobs. Adds testImplementation dependencies for all four hook libraries. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01L9jjQTGj22zgVWetvZMtNn --- build.gradle.kts | 8 + .../itemsadder/CoreItemsAdderHookTest.java | 630 ++++++++++++++++++ .../modelengine/CoreModelEngineHookTest.java | 203 ++++++ .../mythicmobs/CoreMythicMobsHookTest.java | 209 ++++++ .../external/nexo/CoreNexoHookTest.java | 507 ++++++++++++++ 5 files changed, 1557 insertions(+) create mode 100644 src/test/java/com/diamonddagger590/mccore/external/itemsadder/CoreItemsAdderHookTest.java create mode 100644 src/test/java/com/diamonddagger590/mccore/external/modelengine/CoreModelEngineHookTest.java create mode 100644 src/test/java/com/diamonddagger590/mccore/external/mythicmobs/CoreMythicMobsHookTest.java create mode 100644 src/test/java/com/diamonddagger590/mccore/external/nexo/CoreNexoHookTest.java diff --git a/build.gradle.kts b/build.gradle.kts index d548155..84d297a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -96,16 +96,24 @@ dependencies { // Custom items val itemsAdderVersion = "4.0.10" compileOnly("dev.lone:api-itemsadder:$itemsAdderVersion") + testImplementation("dev.lone:api-itemsadder:$itemsAdderVersion") val nexoVersion = "1.16.0" compileOnly("com.nexomc:nexo:$nexoVersion") { exclude(group = "net.byteflux") } + testImplementation("com.nexomc:nexo:$nexoVersion") { + exclude(group = "net.byteflux") + exclude(group = "dev.triumphteam", module = "triumph-gui") + } + testRuntimeOnly("org.jetbrains.kotlin:kotlin-stdlib:2.0.21") val mythicMobsVersion = "5.6.1" compileOnly("io.lumine:Mythic-Dist:$mythicMobsVersion") + testImplementation("io.lumine:Mythic-Dist:$mythicMobsVersion") val modelEngineVersion = "R4.0.4" compileOnly("com.ticxo.modelengine:ModelEngine:$modelEngineVersion") + testImplementation("com.ticxo.modelengine:ModelEngine:$modelEngineVersion") // Command annotations val cloudMinecraftVersion = "2.0.0-beta.14" diff --git a/src/test/java/com/diamonddagger590/mccore/external/itemsadder/CoreItemsAdderHookTest.java b/src/test/java/com/diamonddagger590/mccore/external/itemsadder/CoreItemsAdderHookTest.java new file mode 100644 index 0000000..62cd1cb --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/external/itemsadder/CoreItemsAdderHookTest.java @@ -0,0 +1,630 @@ +package com.diamonddagger590.mccore.external.itemsadder; + +import com.diamonddagger590.mccore.util.item.CustomBlockWrapper; +import com.diamonddagger590.mccore.util.item.CustomItemWrapper; +import dev.lone.itemsadder.api.CustomBlock; +import dev.lone.itemsadder.api.CustomStack; +import net.kyori.adventure.text.Component; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.SoundGroup; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import sun.misc.Unsafe; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CoreItemsAdderHookTest { + + private CoreItemsAdderHook hook; + private MockedStatic customStackMock; + private MockedStatic customBlockMock; + + @BeforeEach + void setUp() throws Exception { + Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Unsafe unsafe = (Unsafe) unsafeField.get(null); + hook = (CoreItemsAdderHook) unsafe.allocateInstance(CoreItemsAdderHook.class); + + customStackMock = mockStatic(CustomStack.class); + customBlockMock = mockStatic(CustomBlock.class); + } + + @AfterEach + void tearDown() { + customStackMock.close(); + customBlockMock.close(); + } + + @Nested + @DisplayName("item") + class Item { + + @Test + @DisplayName("Given a valid item name, when getting item, then returns the ItemStack") + void returnsItemStack_whenItemExists() { + ItemStack expected = mock(ItemStack.class); + CustomStack stack = mock(CustomStack.class); + when(stack.getItemStack()).thenReturn(expected); + customStackMock.when(() -> CustomStack.getInstance("ia:sword")).thenReturn(stack); + + Optional result = hook.item("ia:sword"); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + @DisplayName("Given an invalid item name, when getting item, then returns empty") + void returnsEmpty_whenItemDoesNotExist() { + customStackMock.when(() -> CustomStack.getInstance("unknown")).thenReturn(null); + + Optional result = hook.item("unknown"); + + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("itemModels") + class ItemModels { + + @Test + @DisplayName("Given a custom ItemStack, when getting itemModels, then returns model path set") + void returnsModelPath_whenItemIsCustom() { + ItemStack itemStack = mock(ItemStack.class); + CustomStack stack = mock(CustomStack.class); + when(stack.getModelPath()).thenReturn("ia:sword"); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(stack); + + Optional> result = hook.itemModels(itemStack); + + assertTrue(result.isPresent()); + assertEquals(Set.of("ia:sword"), result.get()); + } + + @Test + @DisplayName("Given a vanilla ItemStack, when getting itemModels, then returns empty") + void returnsEmpty_whenItemIsVanilla() { + ItemStack itemStack = mock(ItemStack.class); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(null); + + Optional> result = hook.itemModels(itemStack); + + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("isItem(String)") + class IsItemByName { + + @Test + @DisplayName("Given a registered item name, when checking isItem, then returns true") + void returnsTrue_whenItemInRegistry() { + customStackMock.when(() -> CustomStack.isInRegistry("ia:sword")).thenReturn(true); + + assertTrue(hook.isItem("ia:sword")); + } + + @Test + @DisplayName("Given an unregistered item name, when checking isItem, then returns false") + void returnsFalse_whenItemNotInRegistry() { + customStackMock.when(() -> CustomStack.isInRegistry("unknown")).thenReturn(false); + + assertFalse(hook.isItem("unknown")); + } + } + + @Nested + @DisplayName("isItem(ItemStack)") + class IsItemByItemStack { + + @Test + @DisplayName("Given a custom ItemStack, when checking isItem, then returns true") + void returnsTrue_whenItemIsCustom() { + ItemStack itemStack = mock(ItemStack.class); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(mock(CustomStack.class)); + + assertTrue(hook.isItem(itemStack)); + } + + @Test + @DisplayName("Given a vanilla ItemStack, when checking isItem, then returns false") + void returnsFalse_whenItemIsVanilla() { + ItemStack itemStack = mock(ItemStack.class); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(null); + + assertFalse(hook.isItem(itemStack)); + } + } + + @Nested + @DisplayName("isItemOfType") + class IsItemOfType { + + @Test + @DisplayName("Given a matching custom ItemStack, when checking isItemOfType, then returns true") + void returnsTrue_whenItemMatchesType() { + ItemStack itemStack = mock(ItemStack.class); + CustomStack stack = mock(CustomStack.class); + when(stack.getModelPath()).thenReturn("ia:sword"); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(stack); + + assertTrue(hook.isItemOfType(itemStack, "ia:sword")); + } + + @Test + @DisplayName("Given a non-matching custom ItemStack, when checking isItemOfType, then returns false") + void returnsFalse_whenItemDoesNotMatchType() { + ItemStack itemStack = mock(ItemStack.class); + CustomStack stack = mock(CustomStack.class); + when(stack.getModelPath()).thenReturn("ia:axe"); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(stack); + + assertFalse(hook.isItemOfType(itemStack, "ia:sword")); + } + + @Test + @DisplayName("Given a vanilla ItemStack, when checking isItemOfType, then returns false") + void returnsFalse_whenItemIsVanilla() { + ItemStack itemStack = mock(ItemStack.class); + customStackMock.when(() -> CustomStack.byItemStack(itemStack)).thenReturn(null); + + assertFalse(hook.isItemOfType(itemStack, "ia:sword")); + } + } + + @Nested + @DisplayName("isCustomBlock(Block)") + class IsCustomBlockByBlock { + + @Test + @DisplayName("Given a custom block, when checking isCustomBlock, then returns true") + void returnsTrue_whenBlockIsCustom() { + Block block = mock(Block.class); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(mock(CustomBlock.class)); + + assertTrue(hook.isCustomBlock(block)); + } + + @Test + @DisplayName("Given a vanilla block, when checking isCustomBlock, then returns false") + void returnsFalse_whenBlockIsVanilla() { + Block block = mock(Block.class); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(null); + + assertFalse(hook.isCustomBlock(block)); + } + } + + @Nested + @DisplayName("isCustomBlock(String)") + class IsCustomBlockByName { + + @Test + @DisplayName("ItemsAdder does not support isCustomBlock by name, always returns false") + void alwaysReturnsFalse() { + assertFalse(hook.isCustomBlock("ia:ore")); + } + } + + @Nested + @DisplayName("isCustomBlockOfType") + class IsCustomBlockOfType { + + @Test + @DisplayName("Given a matching custom block, when checking isCustomBlockOfType, then returns true") + void returnsTrue_whenBlockMatchesType() { + Block block = mock(Block.class); + CustomBlock customBlock = mock(CustomBlock.class); + when(customBlock.getModelPath()).thenReturn("ia:ore"); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(customBlock); + + assertTrue(hook.isCustomBlockOfType(block, "ia:ore")); + } + + @Test + @DisplayName("Given a custom block with non-matching type, when checking isCustomBlockOfType, then returns false") + void returnsFalse_whenBlockDoesNotMatchType() { + Block block = mock(Block.class); + CustomBlock customBlock = mock(CustomBlock.class); + when(customBlock.getModelPath()).thenReturn("ia:log"); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(customBlock); + + assertFalse(hook.isCustomBlockOfType(block, "ia:ore")); + } + + @Test + @DisplayName("Given a vanilla block, when checking isCustomBlockOfType, then returns false") + void returnsFalse_whenBlockIsVanilla() { + Block block = mock(Block.class); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(null); + + assertFalse(hook.isCustomBlockOfType(block, "ia:ore")); + } + } + + @Nested + @DisplayName("blockModels") + class BlockModels { + + @Test + @DisplayName("Given a custom block, when getting blockModels, then returns the model path") + void returnsModelPath_whenBlockIsCustom() { + Block block = mock(Block.class); + CustomBlock customBlock = mock(CustomBlock.class); + when(customBlock.getModelPath()).thenReturn("ia:ore"); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(customBlock); + + Optional> result = hook.blockModels(block); + + assertTrue(result.isPresent()); + assertEquals(Set.of("ia:ore"), result.get()); + } + + @Test + @DisplayName("Given a vanilla block, when getting blockModels, then returns empty") + void returnsEmpty_whenBlockIsVanilla() { + Block block = mock(Block.class); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(null); + + Optional> result = hook.blockModels(block); + + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("placeCustomBlock") + class PlaceCustomBlock { + + @Test + @DisplayName("Given isCustomBlock(String) always returns false for ItemsAdder, when placing, then throws") + void throwsIllegalArgument_becauseIsCustomBlockByStringAlwaysReturnsFalse() { + Location location = mock(Location.class); + + assertThrows(IllegalArgumentException.class, () -> hook.placeCustomBlock(location, "ia:ore")); + } + } + + @Nested + @DisplayName("drops") + class Drops { + + @Test + @DisplayName("Given a custom block, when getting drops, then delegates to CustomBlock.getLoot") + void delegatesToCustomBlockGetLoot_whenBlockIsCustom() { + Block block = mock(Block.class); + ItemStack tool = mock(ItemStack.class); + Entity entity = mock(Entity.class); + List expectedDrops = List.of(mock(ItemStack.class)); + + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(mock(CustomBlock.class)); + customBlockMock.when(() -> CustomBlock.getLoot(block, tool, true)).thenReturn(expectedDrops); + + List result = hook.drops(block, tool, entity); + + assertEquals(expectedDrops, result); + } + + @Test + @DisplayName("Given a vanilla block, when getting drops, then delegates to block.getDrops") + void delegatesToBlockGetDrops_whenBlockIsVanilla() { + Block block = mock(Block.class); + ItemStack tool = mock(ItemStack.class); + Entity entity = mock(Entity.class); + Collection blockDrops = List.of(mock(ItemStack.class)); + + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(null); + when(block.getDrops(tool, entity)).thenReturn(blockDrops); + + List result = hook.drops(block, tool, entity); + + assertEquals(1, result.size()); + } + } + + @Nested + @DisplayName("removeBlock") + class RemoveBlock { + + @Test + @DisplayName("Given a custom block that removes successfully, when removing, then completes without error") + void removesSuccessfully_whenCustomBlockRemoves() { + Block block = mock(Block.class); + CustomBlock customBlock = mock(CustomBlock.class); + when(customBlock.remove()).thenReturn(true); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(customBlock); + + hook.removeBlock(block); + + verify(customBlock).remove(); + } + + @Test + @DisplayName("Given a custom block that fails to remove, when removing, then throws IllegalStateException") + void throwsIllegalState_whenCustomBlockFailsToRemove() { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + CustomBlock customBlock = mock(CustomBlock.class); + when(customBlock.remove()).thenReturn(false); + when(customBlock.getModelPath()).thenReturn("ia:ore"); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(customBlock); + + assertThrows(IllegalStateException.class, () -> hook.removeBlock(block)); + } + + @Test + @DisplayName("Given a vanilla block, when removing, then sets type to AIR") + void setsTypeToAir_whenBlockIsVanilla() { + Block block = mock(Block.class); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(null); + + hook.removeBlock(block); + + verify(block).setType(Material.AIR); + } + } + + @Nested + @DisplayName("playBlockDropEffects") + class PlayBlockDropEffects { + + @Test + @DisplayName("Given a custom block, when playing effects, then delegates to CustomBlock break methods") + void delegatesToCustomBlockBreakMethods_whenBlockIsCustom() { + Block block = mock(Block.class); + CustomBlock customBlock = mock(CustomBlock.class); + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(customBlock); + + hook.playBlockDropEffects(block); + + verify(customBlock).playBreakEffect(); + verify(customBlock).playBreakSound(); + verify(customBlock).playBreakParticles(); + } + + @Test + @DisplayName("Given a vanilla block, when playing effects, then plays sound and spawns particles") + void playsVanillaEffects_whenBlockIsVanilla() { + Block block = mock(Block.class); + World world = mock(World.class); + Location location = mock(Location.class); + Location clonedLocation = mock(Location.class); + BlockData blockData = mock(BlockData.class); + SoundGroup soundGroup = mock(SoundGroup.class); + + customBlockMock.when(() -> CustomBlock.byAlreadyPlaced(block)).thenReturn(null); + when(block.getWorld()).thenReturn(world); + when(block.getLocation()).thenReturn(location); + when(location.clone()).thenReturn(clonedLocation); + when(clonedLocation.add(0.5, 0.5, 0.5)).thenReturn(clonedLocation); + when(block.getBlockData()).thenReturn(blockData); + when(block.getBlockSoundGroup()).thenReturn(soundGroup); + when(soundGroup.getBreakSound()).thenReturn(org.bukkit.Sound.BLOCK_STONE_BREAK); + when(soundGroup.getVolume()).thenReturn(1.0f); + when(soundGroup.getPitch()).thenReturn(1.0f); + + hook.playBlockDropEffects(block); + + verify(world).playSound(location, org.bukkit.Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f); + verify(world).spawnParticle( + eq(Particle.BLOCK), + eq(clonedLocation), + eq(20), + eq(0.25), + eq(0.25), + eq(0.25), + eq(0.05), + eq(blockData)); + } + } + + @Nested + @DisplayName("itemName") + class ItemName { + + @Test + @DisplayName("Given a custom item with Adventure Component name, when getting itemName, then returns serialized name") + void returnsComponentName_whenItemHasComponentName() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(Component.text("Magic Sword")); + customStackMock.when(() -> CustomStack.getInstance("ia:magic_sword")).thenReturn(stack); + + CustomItemWrapper wrapper = new TestCustomItemWrapper("ia:magic_sword"); + + assertEquals("Magic Sword", hook.itemName(wrapper)); + } + + @Test + @DisplayName("Given a custom item with empty Component but legacy name, when getting itemName, then returns stripped legacy name") + void returnsLegacyName_whenComponentNameIsEmpty() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(Component.empty()); + when(stack.getDisplayName()).thenReturn("§6Magic §cSword"); + customStackMock.when(() -> CustomStack.getInstance("ia:magic_sword")).thenReturn(stack); + + CustomItemWrapper wrapper = new TestCustomItemWrapper("ia:magic_sword"); + + assertEquals("Magic Sword", hook.itemName(wrapper)); + } + + @Test + @DisplayName("Given a custom item with null Component and null legacy name, when getting itemName, then returns formatted id") + void returnsFormattedId_whenNoNameAvailable() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(null); + when(stack.getDisplayName()).thenReturn(null); + customStackMock.when(() -> CustomStack.getInstance("ia:cool_sword")).thenReturn(stack); + + CustomItemWrapper wrapper = new TestCustomItemWrapper("ia:cool_sword"); + + assertEquals("Cool Sword", hook.itemName(wrapper)); + } + + @Test + @DisplayName("Given a custom item not found in registry, when getting itemName, then returns formatted id") + void returnsFormattedId_whenItemNotInRegistry() { + customStackMock.when(() -> CustomStack.getInstance("ia:missing_item")).thenReturn(null); + + CustomItemWrapper wrapper = new TestCustomItemWrapper("ia:missing_item"); + + assertEquals("Missing Item", hook.itemName(wrapper)); + } + + @Test + @DisplayName("Given a custom item id without namespace, when getting itemName, then formats the full id") + void returnsFormattedId_whenNoNamespace() { + customStackMock.when(() -> CustomStack.getInstance("cool_sword")).thenReturn(null); + + CustomItemWrapper wrapper = new TestCustomItemWrapper("cool_sword"); + + assertEquals("Cool Sword", hook.itemName(wrapper)); + } + + @Test + @DisplayName("Given a vanilla item wrapper, when getting itemName, then returns formatted material name") + void returnsFormattedMaterialName_whenVanilla() { + CustomItemWrapper wrapper = new CustomItemWrapper(Material.OAK_LOG); + + assertEquals("Oak Log", hook.itemName(wrapper)); + } + + @Test + @DisplayName("Given a custom item with empty legacy name, when getting itemName, then returns formatted id") + void returnsFormattedId_whenLegacyNameIsEmpty() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(null); + when(stack.getDisplayName()).thenReturn(""); + customStackMock.when(() -> CustomStack.getInstance("ia:cool_sword")).thenReturn(stack); + + CustomItemWrapper wrapper = new TestCustomItemWrapper("ia:cool_sword"); + + assertEquals("Cool Sword", hook.itemName(wrapper)); + } + } + + @Nested + @DisplayName("blockName") + class BlockName { + + @Test + @DisplayName("Given a custom block with Adventure Component name, when getting blockName, then returns serialized name") + void returnsComponentName_whenBlockHasComponentName() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(Component.text("Custom Ore")); + customStackMock.when(() -> CustomStack.getInstance("ia:custom_ore")).thenReturn(stack); + + CustomBlockWrapper wrapper = new TestCustomBlockWrapper("ia:custom_ore"); + + assertEquals("Custom Ore", hook.blockName(wrapper)); + } + + @Test + @DisplayName("Given a custom block with legacy name only, when getting blockName, then returns stripped legacy name") + void returnsLegacyName_whenComponentNameIsEmpty() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(Component.empty()); + when(stack.getDisplayName()).thenReturn("§aCustom §bOre"); + customStackMock.when(() -> CustomStack.getInstance("ia:custom_ore")).thenReturn(stack); + + CustomBlockWrapper wrapper = new TestCustomBlockWrapper("ia:custom_ore"); + + assertEquals("Custom Ore", hook.blockName(wrapper)); + } + + @Test + @DisplayName("Given a custom block not in registry, when getting blockName, then returns formatted id") + void returnsFormattedId_whenBlockNotInRegistry() { + customStackMock.when(() -> CustomStack.getInstance("ia:missing_block")).thenReturn(null); + + CustomBlockWrapper wrapper = new TestCustomBlockWrapper("ia:missing_block"); + + assertEquals("Missing Block", hook.blockName(wrapper)); + } + + @Test + @DisplayName("Given a vanilla block wrapper, when getting blockName, then returns formatted material name") + void returnsFormattedMaterialName_whenVanilla() { + CustomBlockWrapper wrapper = new CustomBlockWrapper(Material.STONE); + + assertEquals("Stone", hook.blockName(wrapper)); + } + + @Test + @DisplayName("Given a custom block with null names, when getting blockName, then returns formatted id") + void returnsFormattedId_whenNoNamesAvailable() { + CustomStack stack = mock(CustomStack.class); + when(stack.itemName()).thenReturn(null); + when(stack.getDisplayName()).thenReturn(null); + customStackMock.when(() -> CustomStack.getInstance("ia:fancy_block")).thenReturn(stack); + + CustomBlockWrapper wrapper = new TestCustomBlockWrapper("ia:fancy_block"); + + assertEquals("Fancy Block", hook.blockName(wrapper)); + } + } + + private static class TestCustomItemWrapper extends CustomItemWrapper { + + private final String testCustomItem; + + TestCustomItemWrapper(@NotNull String customItem) { + super(Material.STONE); + this.testCustomItem = customItem; + } + + @NotNull + @Override + public Optional customItem() { + return Optional.of(testCustomItem); + } + } + + private static class TestCustomBlockWrapper extends CustomBlockWrapper { + + private final String testCustomBlock; + + TestCustomBlockWrapper(@NotNull String customBlock) { + super(Material.STONE); + this.testCustomBlock = customBlock; + } + + @NotNull + @Override + public Optional customBlock() { + return Optional.of(testCustomBlock); + } + } +} diff --git a/src/test/java/com/diamonddagger590/mccore/external/modelengine/CoreModelEngineHookTest.java b/src/test/java/com/diamonddagger590/mccore/external/modelengine/CoreModelEngineHookTest.java new file mode 100644 index 0000000..4b9d3d3 --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/external/modelengine/CoreModelEngineHookTest.java @@ -0,0 +1,203 @@ +package com.diamonddagger590.mccore.external.modelengine; + +import com.diamonddagger590.mccore.util.item.CustomEntityWrapper; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.generator.blueprint.ModelBlueprint; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.ModeledEntity; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import sun.misc.Unsafe; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class CoreModelEngineHookTest { + + private CoreModelEngineHook hook; + private MockedStatic modelEngineApiMock; + + private static final UUID KNOWN_UUID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID UNKNOWN_UUID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + + @BeforeEach + void setUp() throws Exception { + Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Unsafe unsafe = (Unsafe) unsafeField.get(null); + hook = (CoreModelEngineHook) unsafe.allocateInstance(CoreModelEngineHook.class); + modelEngineApiMock = mockStatic(ModelEngineAPI.class); + } + + @AfterEach + void tearDown() { + modelEngineApiMock.close(); + } + + @Nested + @DisplayName("isCustomEntity(UUID)") + class IsCustomEntityByUuid { + + @Test + @DisplayName("Given a modeled entity UUID, when checking isCustomEntity, then returns true") + void returnsTrue_whenEntityIsModeled() { + modelEngineApiMock.when(() -> ModelEngineAPI.isModeledEntity(KNOWN_UUID)).thenReturn(true); + + assertTrue(hook.isCustomEntity(KNOWN_UUID)); + } + + @Test + @DisplayName("Given a non-modeled entity UUID, when checking isCustomEntity, then returns false") + void returnsFalse_whenEntityIsNotModeled() { + modelEngineApiMock.when(() -> ModelEngineAPI.isModeledEntity(UNKNOWN_UUID)).thenReturn(false); + + assertFalse(hook.isCustomEntity(UNKNOWN_UUID)); + } + } + + @Nested + @DisplayName("isCustomEntity(String)") + class IsCustomEntityByName { + + @Test + @DisplayName("Given a valid blueprint name, when checking isCustomEntity, then returns true") + void returnsTrue_whenBlueprintExists() { + ModelBlueprint blueprint = mock(ModelBlueprint.class); + modelEngineApiMock.when(() -> ModelEngineAPI.getBlueprint("dragon")).thenReturn(blueprint); + + assertTrue(hook.isCustomEntity("dragon")); + } + + @Test + @DisplayName("Given an invalid blueprint name, when checking isCustomEntity, then returns false") + void returnsFalse_whenBlueprintDoesNotExist() { + modelEngineApiMock.when(() -> ModelEngineAPI.getBlueprint("unknown")).thenReturn(null); + + assertFalse(hook.isCustomEntity("unknown")); + } + } + + @Nested + @DisplayName("isCustomEntityOfType") + class IsCustomEntityOfType { + + @Test + @DisplayName("Given a modeled entity with matching type, when checking isCustomEntityOfType, then returns true") + void returnsTrue_whenEntityHasMatchingModel() { + modelEngineApiMock.when(() -> ModelEngineAPI.isModeledEntity(KNOWN_UUID)).thenReturn(true); + + ModelBlueprint blueprint = mock(ModelBlueprint.class); + when(blueprint.getName()).thenReturn("dragon"); + + ActiveModel activeModel = mock(ActiveModel.class); + when(activeModel.getBlueprint()).thenReturn(blueprint); + + ModeledEntity modeledEntity = mock(ModeledEntity.class); + when(modeledEntity.getModels()).thenReturn(Map.of("dragon", activeModel)); + + modelEngineApiMock.when(() -> ModelEngineAPI.getModeledEntity(KNOWN_UUID)).thenReturn(modeledEntity); + + assertTrue(hook.isCustomEntityOfType(KNOWN_UUID, "dragon")); + } + + @Test + @DisplayName("Given a modeled entity with non-matching type, when checking isCustomEntityOfType, then returns false") + void returnsFalse_whenEntityHasNonMatchingModel() { + modelEngineApiMock.when(() -> ModelEngineAPI.isModeledEntity(KNOWN_UUID)).thenReturn(true); + + ModelBlueprint blueprint = mock(ModelBlueprint.class); + when(blueprint.getName()).thenReturn("golem"); + + ActiveModel activeModel = mock(ActiveModel.class); + when(activeModel.getBlueprint()).thenReturn(blueprint); + + ModeledEntity modeledEntity = mock(ModeledEntity.class); + when(modeledEntity.getModels()).thenReturn(Map.of("golem", activeModel)); + + modelEngineApiMock.when(() -> ModelEngineAPI.getModeledEntity(KNOWN_UUID)).thenReturn(modeledEntity); + + assertFalse(hook.isCustomEntityOfType(KNOWN_UUID, "dragon")); + } + + @Test + @DisplayName("Given a non-modeled entity, when checking isCustomEntityOfType, then returns false") + void returnsFalse_whenEntityIsNotModeled() { + modelEngineApiMock.when(() -> ModelEngineAPI.isModeledEntity(UNKNOWN_UUID)).thenReturn(false); + + assertFalse(hook.isCustomEntityOfType(UNKNOWN_UUID, "dragon")); + } + } + + @Nested + @DisplayName("entityModels") + class EntityModels { + + @Test + @DisplayName("Given a modeled entity, when getting entityModels, then returns the model keys") + void returnsModelKeys_whenEntityIsModeled() { + Entity entity = mock(Entity.class); + when(entity.getUniqueId()).thenReturn(KNOWN_UUID); + + ActiveModel activeModel = mock(ActiveModel.class); + ModeledEntity modeledEntity = mock(ModeledEntity.class); + when(modeledEntity.getModels()).thenReturn(Map.of("dragon", activeModel, "wings", mock(ActiveModel.class))); + + modelEngineApiMock.when(() -> ModelEngineAPI.getModeledEntity(KNOWN_UUID)).thenReturn(modeledEntity); + + Optional> result = hook.entityModels(entity); + + assertTrue(result.isPresent()); + assertEquals(Set.of("dragon", "wings"), result.get()); + } + + @Test + @DisplayName("Given a non-modeled entity, when getting entityModels, then returns empty") + void returnsEmpty_whenEntityIsNotModeled() { + Entity entity = mock(Entity.class); + when(entity.getUniqueId()).thenReturn(UNKNOWN_UUID); + + modelEngineApiMock.when(() -> ModelEngineAPI.getModeledEntity(UNKNOWN_UUID)).thenReturn(null); + + Optional> result = hook.entityModels(entity); + + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("entityName") + class EntityName { + + @Test + @DisplayName("Given a custom entity wrapper, when getting entityName, then returns the custom entity id") + void returnsCustomEntityId_whenPresent() { + CustomEntityWrapper wrapper = new CustomEntityWrapper("dragon"); + + assertEquals("dragon", hook.entityName(wrapper)); + } + + @Test + @DisplayName("Given a vanilla entity wrapper, when getting entityName, then returns Unknown") + void returnsUnknown_whenNoCustomEntity() { + CustomEntityWrapper wrapper = new CustomEntityWrapper(EntityType.ZOMBIE); + + assertEquals("Unknown", hook.entityName(wrapper)); + } + } +} diff --git a/src/test/java/com/diamonddagger590/mccore/external/mythicmobs/CoreMythicMobsHookTest.java b/src/test/java/com/diamonddagger590/mccore/external/mythicmobs/CoreMythicMobsHookTest.java new file mode 100644 index 0000000..cd5c345 --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/external/mythicmobs/CoreMythicMobsHookTest.java @@ -0,0 +1,209 @@ +package com.diamonddagger590.mccore.external.mythicmobs; + +import com.diamonddagger590.mccore.util.item.CustomEntityWrapper; +import io.lumine.mythic.api.mobs.MythicMob; +import io.lumine.mythic.core.mobs.MobExecutor; +import io.lumine.mythic.api.skills.placeholders.PlaceholderString; +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.core.mobs.ActiveMob; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import sun.misc.Unsafe; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class CoreMythicMobsHookTest { + + private CoreMythicMobsHook hook; + private MockedStatic mythicBukkitMock; + private MythicBukkit mythicBukkitInstance; + private MobExecutor mobManager; + + private static final UUID KNOWN_UUID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID UNKNOWN_UUID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + + @BeforeEach + void setUp() throws Exception { + Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Unsafe unsafe = (Unsafe) unsafeField.get(null); + hook = (CoreMythicMobsHook) unsafe.allocateInstance(CoreMythicMobsHook.class); + + mythicBukkitInstance = mock(MythicBukkit.class); + mobManager = mock(MobExecutor.class); + when(mythicBukkitInstance.getMobManager()).thenReturn(mobManager); + + mythicBukkitMock = mockStatic(MythicBukkit.class); + mythicBukkitMock.when(MythicBukkit::inst).thenReturn(mythicBukkitInstance); + } + + @AfterEach + void tearDown() { + mythicBukkitMock.close(); + } + + @Nested + @DisplayName("isCustomEntity(UUID)") + class IsCustomEntityByUuid { + + @Test + @DisplayName("Given an active mob UUID, when checking isCustomEntity, then returns true") + void returnsTrue_whenMobIsActive() { + when(mobManager.isActiveMob(KNOWN_UUID)).thenReturn(true); + + assertTrue(hook.isCustomEntity(KNOWN_UUID)); + } + + @Test + @DisplayName("Given a non-active mob UUID, when checking isCustomEntity, then returns false") + void returnsFalse_whenMobIsNotActive() { + when(mobManager.isActiveMob(UNKNOWN_UUID)).thenReturn(false); + + assertFalse(hook.isCustomEntity(UNKNOWN_UUID)); + } + } + + @Nested + @DisplayName("isCustomEntity(String)") + class IsCustomEntityByName { + + @Test + @DisplayName("Given a registered MythicMob type, when checking isCustomEntity, then returns true") + void returnsTrue_whenMobTypeExists() { + MythicMob mythicMob = mock(MythicMob.class); + when(mobManager.getMythicMob("skeleton_king")).thenReturn(Optional.of(mythicMob)); + + assertTrue(hook.isCustomEntity("skeleton_king")); + } + + @Test + @DisplayName("Given an unregistered MythicMob type, when checking isCustomEntity, then returns false") + void returnsFalse_whenMobTypeDoesNotExist() { + when(mobManager.getMythicMob("unknown_mob")).thenReturn(Optional.empty()); + + assertFalse(hook.isCustomEntity("unknown_mob")); + } + } + + @Nested + @DisplayName("isCustomEntityOfType") + class IsCustomEntityOfType { + + @Test + @DisplayName("Given an active mob with matching type, when checking isCustomEntityOfType, then returns true") + void returnsTrue_whenMobTypeMatches() { + ActiveMob activeMob = mock(ActiveMob.class); + when(activeMob.getMobType()).thenReturn("skeleton_king"); + when(mobManager.getActiveMob(KNOWN_UUID)).thenReturn(Optional.of(activeMob)); + + assertTrue(hook.isCustomEntityOfType(KNOWN_UUID, "skeleton_king")); + } + + @Test + @DisplayName("Given an active mob with non-matching type, when checking isCustomEntityOfType, then returns false") + void returnsFalse_whenMobTypeDoesNotMatch() { + ActiveMob activeMob = mock(ActiveMob.class); + when(activeMob.getMobType()).thenReturn("skeleton_king"); + when(mobManager.getActiveMob(KNOWN_UUID)).thenReturn(Optional.of(activeMob)); + + assertFalse(hook.isCustomEntityOfType(KNOWN_UUID, "dragon_boss")); + } + + @Test + @DisplayName("Given a non-active mob UUID, when checking isCustomEntityOfType, then returns false") + void returnsFalse_whenMobIsNotActive() { + when(mobManager.getActiveMob(UNKNOWN_UUID)).thenReturn(Optional.empty()); + + assertFalse(hook.isCustomEntityOfType(UNKNOWN_UUID, "skeleton_king")); + } + } + + @Nested + @DisplayName("entityModels") + class EntityModels { + + @Test + @DisplayName("Given an active mob entity, when getting entityModels, then returns the mob type") + void returnsMobType_whenEntityIsActive() { + Entity entity = mock(Entity.class); + when(entity.getUniqueId()).thenReturn(KNOWN_UUID); + + ActiveMob activeMob = mock(ActiveMob.class); + when(activeMob.getMobType()).thenReturn("skeleton_king"); + when(mobManager.getActiveMob(KNOWN_UUID)).thenReturn(Optional.of(activeMob)); + + Optional> result = hook.entityModels(entity); + + assertTrue(result.isPresent()); + assertEquals(Set.of("skeleton_king"), result.get()); + } + + @Test + @DisplayName("Given a non-active entity, when getting entityModels, then returns empty") + void returnsEmpty_whenEntityIsNotActive() { + Entity entity = mock(Entity.class); + when(entity.getUniqueId()).thenReturn(UNKNOWN_UUID); + + when(mobManager.getActiveMob(UNKNOWN_UUID)).thenReturn(Optional.empty()); + + Optional> result = hook.entityModels(entity); + + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("entityName") + class EntityName { + + @Test + @DisplayName("Given a custom entity with a registered display name, when getting entityName, then returns the display name") + void returnsDisplayName_whenMobTypeHasDisplayName() { + PlaceholderString displayName = mock(PlaceholderString.class); + when(displayName.get()).thenReturn("Skeleton King"); + + MythicMob mythicMob = mock(MythicMob.class); + when(mythicMob.getDisplayName()).thenReturn(displayName); + + when(mobManager.getMythicMob("skeleton_king")).thenReturn(Optional.of(mythicMob)); + + CustomEntityWrapper wrapper = new CustomEntityWrapper("skeleton_king"); + + assertEquals("Skeleton King", hook.entityName(wrapper)); + } + + @Test + @DisplayName("Given a custom entity with no registered mob type, when getting entityName, then returns the raw id") + void returnsRawId_whenMobTypeNotRegistered() { + when(mobManager.getMythicMob("unknown_mob")).thenReturn(Optional.empty()); + + CustomEntityWrapper wrapper = new CustomEntityWrapper("unknown_mob"); + + assertEquals("unknown_mob", hook.entityName(wrapper)); + } + + @Test + @DisplayName("Given a vanilla entity wrapper, when getting entityName, then returns Unknown") + void returnsUnknown_whenNoCustomEntity() { + CustomEntityWrapper wrapper = new CustomEntityWrapper(EntityType.ZOMBIE); + + assertEquals("Unknown", hook.entityName(wrapper)); + } + } +} diff --git a/src/test/java/com/diamonddagger590/mccore/external/nexo/CoreNexoHookTest.java b/src/test/java/com/diamonddagger590/mccore/external/nexo/CoreNexoHookTest.java new file mode 100644 index 0000000..1adaa34 --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/external/nexo/CoreNexoHookTest.java @@ -0,0 +1,507 @@ +package com.diamonddagger590.mccore.external.nexo; + +import com.diamonddagger590.mccore.util.item.CustomBlockWrapper; +import com.nexomc.nexo.api.NexoBlocks; +import com.nexomc.nexo.mechanics.custom_block.CustomBlockMechanic; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.SoundGroup; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import sun.misc.Unsafe; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link CoreNexoHook} — block-related methods and formatting fallbacks. + *

+ * Nexo's {@code NexoItems} class cannot be statically mocked because its method signatures + * reference {@code ItemBuilder}, whose static initializer requires a running {@code NexoPlugin}. + * Similarly, {@code CustomBlockMechanic} cannot be mocked by Mockito because its parent class + * {@code Mechanic} references {@code ItemBuilder}; instances are created via {@code Unsafe} instead. + */ +class CoreNexoHookTest { + + private CoreNexoHook hook; + private MockedStatic nexoBlocksMock; + private Unsafe unsafe; + + @BeforeEach + void setUp() throws Exception { + Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + unsafe = (Unsafe) unsafeField.get(null); + hook = (CoreNexoHook) unsafe.allocateInstance(CoreNexoHook.class); + + nexoBlocksMock = mockStatic(NexoBlocks.class); + } + + @AfterEach + void tearDown() { + nexoBlocksMock.close(); + } + + private CustomBlockMechanic createMechanicWithItemId(@NotNull String itemId) throws Exception { + Class concreteClass = Class.forName( + "com.nexomc.nexo.mechanics.custom_block.noteblock.NoteBlockMechanic", + false, + getClass().getClassLoader() + ); + CustomBlockMechanic mechanic = (CustomBlockMechanic) unsafe.allocateInstance(concreteClass); + Field itemIdField = mechanic.getClass().getSuperclass().getSuperclass().getDeclaredField("itemID"); + itemIdField.setAccessible(true); + itemIdField.set(mechanic, itemId); + return mechanic; + } + + @Nested + @DisplayName("isCustomBlock(Block)") + class IsCustomBlockByBlock { + + @Test + @DisplayName("Given a Nexo block, when checking isCustomBlock, then returns true") + void returnsTrue_whenBlockIsNexo() { + Block block = mock(Block.class); + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + + assertTrue(hook.isCustomBlock(block)); + } + + @Test + @DisplayName("Given a vanilla block, when checking isCustomBlock, then returns false") + void returnsFalse_whenBlockIsVanilla() { + Block block = mock(Block.class); + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(false); + + assertFalse(hook.isCustomBlock(block)); + } + } + + @Nested + @DisplayName("isCustomBlock(String)") + class IsCustomBlockByName { + + @Test + @DisplayName("Given a valid Nexo block id, when checking isCustomBlock, then returns true") + void returnsTrue_whenBlockExists() { + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock("nexo:ore")).thenReturn(true); + + assertTrue(hook.isCustomBlock("nexo:ore")); + } + + @Test + @DisplayName("Given an invalid block id, when checking isCustomBlock, then returns false") + void returnsFalse_whenBlockDoesNotExist() { + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock("unknown")).thenReturn(false); + + assertFalse(hook.isCustomBlock("unknown")); + } + } + + @Nested + @DisplayName("isCustomBlockOfType") + class IsCustomBlockOfType { + + @Test + @DisplayName("Given a matching Nexo block, when checking isCustomBlockOfType, then returns true") + void returnsTrue_whenBlockMatchesType() throws Exception { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:ore"); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + assertTrue(hook.isCustomBlockOfType(block, "nexo:ore")); + } + + @Test + @DisplayName("Given a non-matching Nexo block, when checking isCustomBlockOfType, then returns false") + void returnsFalse_whenBlockTypeDoesNotMatch() throws Exception { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:stone"); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + assertFalse(hook.isCustomBlockOfType(block, "nexo:ore")); + } + + @Test + @DisplayName("Given a vanilla block, when checking isCustomBlockOfType, then returns false") + void returnsFalse_whenBlockIsVanilla() { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(false); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(null); + + assertFalse(hook.isCustomBlockOfType(block, "nexo:ore")); + } + } + + @Nested + @DisplayName("blockModels") + class BlockModelsTest { + + @Test + @DisplayName("Given a Nexo block, when getting blockModels, then returns the item id") + void returnsItemId_whenBlockIsNexo() throws Exception { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:ore"); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + Optional> result = hook.blockModels(block); + + assertTrue(result.isPresent()); + assertEquals(Set.of("nexo:ore"), result.get()); + } + + @Test + @DisplayName("Given a vanilla block, when getting blockModels, then returns empty") + void returnsEmpty_whenBlockIsVanilla() { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(null); + + Optional> result = hook.blockModels(block); + + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("placeCustomBlock") + class PlaceCustomBlock { + + @Test + @DisplayName("Given a valid Nexo block id, when placing, then delegates to NexoBlocks.place") + void delegatesToNexoBlocksPlace_whenBlockIsValid() { + Location location = mock(Location.class); + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock("nexo:ore")).thenReturn(true); + + hook.placeCustomBlock(location, "nexo:ore"); + + nexoBlocksMock.verify(() -> NexoBlocks.place("nexo:ore", location)); + } + + @Test + @DisplayName("Given an invalid block id, when placing, then throws IllegalArgumentException") + void throwsIllegalArgument_whenBlockIsInvalid() { + Location location = mock(Location.class); + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock("unknown")).thenReturn(false); + + assertThrows(IllegalArgumentException.class, () -> hook.placeCustomBlock(location, "unknown")); + } + } + + @Nested + @DisplayName("drops") + class DropsTest { + + @Test + @DisplayName("Given a vanilla block, when getting drops, then delegates to block.getDrops") + void delegatesToBlockGetDrops_whenBlockIsVanilla() { + Block block = mock(Block.class); + ItemStack tool = mock(ItemStack.class); + Entity entity = mock(Entity.class); + Collection blockDrops = List.of(mock(ItemStack.class)); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(false); + when(block.getDrops(tool, entity)).thenReturn(blockDrops); + + List result = hook.drops(block, tool, entity); + + assertEquals(1, result.size()); + } + + @Test + @DisplayName("Given a custom block with no mechanic, when getting drops, then returns empty list") + void returnsEmptyList_whenCustomBlockHasNoMechanic() { + Block block = mock(Block.class); + ItemStack tool = mock(ItemStack.class); + Entity entity = mock(Entity.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(null); + + List result = hook.drops(block, tool, entity); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("Given a custom block with non-player entity, when getting drops, then returns empty list") + void returnsEmptyList_whenEntityIsNotPlayer() throws Exception { + Block block = mock(Block.class); + ItemStack tool = mock(ItemStack.class); + Entity entity = mock(Entity.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:ore"); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + List result = hook.drops(block, tool, entity); + + assertTrue(result.isEmpty()); + } + } + + @Nested + @DisplayName("removeBlock") + class RemoveBlock { + + @Test + @DisplayName("Given a custom block that removes successfully, when removing, then completes without error") + void removesSuccessfully_whenNexoBlockRemoves() { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + nexoBlocksMock.when(() -> NexoBlocks.remove(location)).thenReturn(true); + + hook.removeBlock(block); + + nexoBlocksMock.verify(() -> NexoBlocks.remove(location)); + } + + @Test + @DisplayName("Given a custom block that fails to remove, when removing, then throws IllegalStateException") + void throwsIllegalState_whenNexoBlockFailsToRemove() throws Exception { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + nexoBlocksMock.when(() -> NexoBlocks.remove(location)).thenReturn(false); + + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:ore"); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + assertThrows(IllegalStateException.class, () -> hook.removeBlock(block)); + } + + @Test + @DisplayName("Given a vanilla block, when removing, then sets type to AIR") + void setsTypeToAir_whenBlockIsVanilla() { + Block block = mock(Block.class); + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(false); + + hook.removeBlock(block); + + verify(block).setType(Material.AIR); + } + } + + @Nested + @DisplayName("playBlockDropEffects") + class PlayBlockDropEffects { + + @Test + @DisplayName("Given a vanilla block, when playing effects, then plays vanilla sound and spawns particles") + void playsVanillaEffects_whenBlockIsVanilla() { + Block block = mock(Block.class); + World world = mock(World.class); + Location location = mock(Location.class); + Location clonedLocation = mock(Location.class); + BlockData blockData = mock(BlockData.class); + SoundGroup soundGroup = mock(SoundGroup.class); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(false); + when(block.getWorld()).thenReturn(world); + when(block.getLocation()).thenReturn(location); + when(location.clone()).thenReturn(clonedLocation); + when(clonedLocation.add(0.5, 0.5, 0.5)).thenReturn(clonedLocation); + when(block.getBlockData()).thenReturn(blockData); + when(block.getBlockSoundGroup()).thenReturn(soundGroup); + when(soundGroup.getBreakSound()).thenReturn(org.bukkit.Sound.BLOCK_STONE_BREAK); + when(soundGroup.getVolume()).thenReturn(1.0f); + when(soundGroup.getPitch()).thenReturn(1.0f); + + hook.playBlockDropEffects(block); + + verify(world).playSound(location, org.bukkit.Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f); + verify(world).spawnParticle( + eq(Particle.BLOCK), + eq(clonedLocation), + eq(20), + eq(0.25), + eq(0.25), + eq(0.25), + eq(0.05), + eq(blockData)); + } + + @Test + @DisplayName("Given a custom block without sounds, when playing effects, then falls back to vanilla sounds") + void playsVanillaEffects_whenCustomBlockHasNoSounds() throws Exception { + Block block = mock(Block.class); + World world = mock(World.class); + Location location = mock(Location.class); + Location clonedLocation = mock(Location.class); + BlockData blockData = mock(BlockData.class); + SoundGroup soundGroup = mock(SoundGroup.class); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:ore"); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + when(block.getWorld()).thenReturn(world); + when(block.getLocation()).thenReturn(location); + when(location.clone()).thenReturn(clonedLocation); + when(clonedLocation.add(0.5, 0.5, 0.5)).thenReturn(clonedLocation); + when(block.getBlockData()).thenReturn(blockData); + when(block.getBlockSoundGroup()).thenReturn(soundGroup); + when(soundGroup.getBreakSound()).thenReturn(org.bukkit.Sound.BLOCK_STONE_BREAK); + when(soundGroup.getVolume()).thenReturn(1.0f); + when(soundGroup.getPitch()).thenReturn(1.0f); + + hook.playBlockDropEffects(block); + + verify(world).playSound(location, org.bukkit.Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f); + } + + @Test + @DisplayName("Given a custom block with null mechanic, when playing effects, then falls back to vanilla sounds") + void playsVanillaEffects_whenMechanicIsNull() { + Block block = mock(Block.class); + World world = mock(World.class); + Location location = mock(Location.class); + Location clonedLocation = mock(Location.class); + BlockData blockData = mock(BlockData.class); + SoundGroup soundGroup = mock(SoundGroup.class); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(null); + + when(block.getWorld()).thenReturn(world); + when(block.getLocation()).thenReturn(location); + when(location.clone()).thenReturn(clonedLocation); + when(clonedLocation.add(0.5, 0.5, 0.5)).thenReturn(clonedLocation); + when(block.getBlockData()).thenReturn(blockData); + when(block.getBlockSoundGroup()).thenReturn(soundGroup); + when(soundGroup.getBreakSound()).thenReturn(org.bukkit.Sound.BLOCK_STONE_BREAK); + when(soundGroup.getVolume()).thenReturn(1.0f); + when(soundGroup.getPitch()).thenReturn(1.0f); + + hook.playBlockDropEffects(block); + + verify(world).playSound(location, org.bukkit.Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f); + } + + @Test + @DisplayName("Given a custom block with sounds, when playing effects, then plays custom break sound") + void playsCustomSound_whenCustomBlockHasSounds() throws Exception { + Block block = mock(Block.class); + World world = mock(World.class); + Location location = mock(Location.class); + + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock(block)).thenReturn(true); + + CustomBlockMechanic mechanic = createMechanicWithItemId("nexo:ore"); + com.nexomc.nexo.utils.blocksounds.BlockSounds blockSounds = + (com.nexomc.nexo.utils.blocksounds.BlockSounds) unsafe.allocateInstance( + com.nexomc.nexo.utils.blocksounds.BlockSounds.class); + Field breakSoundField = com.nexomc.nexo.utils.blocksounds.BlockSounds.class.getDeclaredField("breakSound"); + breakSoundField.setAccessible(true); + breakSoundField.set(blockSounds, "custom.break.sound"); + Field breakVolumeField = com.nexomc.nexo.utils.blocksounds.BlockSounds.class.getDeclaredField("breakVolume"); + breakVolumeField.setAccessible(true); + breakVolumeField.setFloat(blockSounds, 0.8f); + Field breakPitchField = com.nexomc.nexo.utils.blocksounds.BlockSounds.class.getDeclaredField("breakPitch"); + breakPitchField.setAccessible(true); + breakPitchField.setFloat(blockSounds, 1.2f); + + Field blockSoundsField = CustomBlockMechanic.class.getDeclaredField("blockSounds"); + blockSoundsField.setAccessible(true); + blockSoundsField.set(mechanic, blockSounds); + + nexoBlocksMock.when(() -> NexoBlocks.customBlockMechanic(location)).thenReturn(mechanic); + + when(block.getWorld()).thenReturn(world); + when(block.getLocation()).thenReturn(location); + + hook.playBlockDropEffects(block); + + verify(world).playSound(location, "custom.break.sound", 0.8f, 1.2f); + } + } + + @Nested + @DisplayName("blockName") + class BlockNameTest { + + @Test + @DisplayName("Given an unregistered Nexo block id, when getting blockName, then returns formatted id") + void returnsFormattedId_whenBlockNotRegistered() { + nexoBlocksMock.when(() -> NexoBlocks.isCustomBlock("nexo:missing_block")).thenReturn(false); + + CustomBlockWrapper wrapper = new TestCustomBlockWrapper("nexo:missing_block"); + + assertEquals("Missing Block", hook.blockName(wrapper)); + } + + @Test + @DisplayName("Given a vanilla block wrapper, when getting blockName, then returns formatted material name") + void returnsFormattedMaterialName_whenVanilla() { + CustomBlockWrapper wrapper = new CustomBlockWrapper(Material.STONE); + + assertEquals("Stone", hook.blockName(wrapper)); + } + } + + private static class TestCustomBlockWrapper extends CustomBlockWrapper { + + private final String testCustomBlock; + + TestCustomBlockWrapper(@NotNull String customBlock) { + super(Material.STONE); + this.testCustomBlock = customBlock; + } + + @NotNull + @Override + public Optional customBlock() { + return Optional.of(testCustomBlock); + } + } +}