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