diff --git a/build.gradle.kts b/build.gradle.kts index b22c5bcc..153f3e40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,4 @@ +import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -130,6 +131,16 @@ tasks { targetCompatibility = javaVersionString } + withType().configureEach { + reports { + html.required.set(true) + xml.required.set(true) + txt.required.set(false) + sarif.required.set(false) + md.required.set(false) + } + } + test { useJUnitPlatform() } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c9eb15be..a5952066 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/com/github/rushyverse/api/APIPlugin.kt b/src/main/kotlin/com/github/rushyverse/api/APIPlugin.kt index 2bb5d419..e1b0480e 100644 --- a/src/main/kotlin/com/github/rushyverse/api/APIPlugin.kt +++ b/src/main/kotlin/com/github/rushyverse/api/APIPlugin.kt @@ -2,6 +2,7 @@ package com.github.rushyverse.api import com.github.rushyverse.api.extension.registerListener import com.github.rushyverse.api.game.SharedGameData +import com.github.rushyverse.api.gui.GUIManager import com.github.rushyverse.api.koin.CraftContext import com.github.rushyverse.api.koin.loadModule import com.github.rushyverse.api.listener.api.LanguageListener @@ -37,6 +38,7 @@ public class APIPlugin : JavaPlugin() { single { ScoreboardManager() } single { LanguageManager() } single { SharedGameData() } + single { GUIManager() } } registerListener { LanguageListener() } diff --git a/src/main/kotlin/com/github/rushyverse/api/Plugin.kt b/src/main/kotlin/com/github/rushyverse/api/Plugin.kt index 31d770e5..58315a14 100644 --- a/src/main/kotlin/com/github/rushyverse/api/Plugin.kt +++ b/src/main/kotlin/com/github/rushyverse/api/Plugin.kt @@ -7,6 +7,7 @@ import com.github.rushyverse.api.configuration.reader.IFileReader import com.github.rushyverse.api.configuration.reader.YamlFileReader import com.github.rushyverse.api.extension.asComponent import com.github.rushyverse.api.extension.registerListener +import com.github.rushyverse.api.gui.GUIListener import com.github.rushyverse.api.koin.CraftContext import com.github.rushyverse.api.koin.inject import com.github.rushyverse.api.koin.loadModule @@ -16,11 +17,21 @@ import com.github.rushyverse.api.player.Client import com.github.rushyverse.api.player.ClientManager import com.github.rushyverse.api.player.ClientManagerImpl import com.github.rushyverse.api.player.language.LanguageManager -import com.github.rushyverse.api.serializer.* +import com.github.rushyverse.api.serializer.ComponentSerializer +import com.github.rushyverse.api.serializer.DyeColorSerializer +import com.github.rushyverse.api.serializer.EnchantmentSerializer +import com.github.rushyverse.api.serializer.ItemStackSerializer +import com.github.rushyverse.api.serializer.LocationSerializer +import com.github.rushyverse.api.serializer.MaterialSerializer +import com.github.rushyverse.api.serializer.NamespacedSerializer +import com.github.rushyverse.api.serializer.PatternSerializer +import com.github.rushyverse.api.serializer.PatternTypeSerializer +import com.github.rushyverse.api.serializer.RangeDoubleSerializer import com.github.rushyverse.api.translation.ResourceBundleTranslator import com.github.rushyverse.api.translation.Translator import com.github.rushyverse.api.translation.registerResourceBundleForSupportedLocales import com.github.shynixn.mccoroutine.bukkit.SuspendingJavaPlugin +import java.util.* import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModuleBuilder import kotlinx.serialization.modules.contextual @@ -29,7 +40,6 @@ import org.bukkit.entity.Player import org.jetbrains.annotations.Blocking import org.koin.core.module.Module import org.koin.dsl.bind -import java.util.* /** * Represents the base functionality required to create a plugin. @@ -70,6 +80,7 @@ public abstract class Plugin( registerListener { PlayerListener(this) } registerListener { VillagerListener(this) } + registerListener { GUIListener(this) } } /** diff --git a/src/main/kotlin/com/github/rushyverse/api/extension/_String.kt b/src/main/kotlin/com/github/rushyverse/api/extension/_String.kt index 59523834..3a59a649 100644 --- a/src/main/kotlin/com/github/rushyverse/api/extension/_String.kt +++ b/src/main/kotlin/com/github/rushyverse/api/extension/_String.kt @@ -3,14 +3,14 @@ package com.github.rushyverse.api.extension +import java.math.BigInteger +import java.util.* import net.kyori.adventure.text.Component import net.kyori.adventure.text.TextComponent import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver import net.kyori.adventure.text.minimessage.tag.standard.StandardTags -import java.math.BigInteger -import java.util.* /** * MiniMessage instance to deserialize components without strict mode. diff --git a/src/main/kotlin/com/github/rushyverse/api/extension/event/_Event.kt b/src/main/kotlin/com/github/rushyverse/api/extension/event/_Event.kt index f1f1e25a..444d7b50 100644 --- a/src/main/kotlin/com/github/rushyverse/api/extension/event/_Event.kt +++ b/src/main/kotlin/com/github/rushyverse/api/extension/event/_Event.kt @@ -1,7 +1,16 @@ package com.github.rushyverse.api.extension.event +import com.github.shynixn.mccoroutine.bukkit.callSuspendingEvent +import kotlinx.coroutines.joinAll +import org.bukkit.Bukkit +import org.bukkit.block.BlockFace import org.bukkit.entity.Damageable +import org.bukkit.entity.Player +import org.bukkit.event.block.Action import org.bukkit.event.entity.EntityDamageEvent +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.inventory.ItemStack +import org.bukkit.plugin.Plugin /** * Future life of the damaged entity. @@ -15,3 +24,31 @@ public fun EntityDamageEvent.finalDamagedHealth(): Double? { null } } + +/** + * Create a [PlayerInteractEvent] to simulate a right click with an item for a player and call it. + * @param plugin Plugin to call the event. + * @param player Player who clicked. + * @param item Item that was clicked. + */ +public suspend fun callRightClickOnItemEvent(plugin: Plugin, player: Player, item: ItemStack) { + val rightClickWithItemEvent = createRightClickEventWithItem(player, item) + Bukkit.getPluginManager().callSuspendingEvent(rightClickWithItemEvent, plugin).joinAll() +} + +/** + * Create a PlayerInteractEvent to simulate a right click with an item. + * @param player Player who clicked. + * @param item Item that was clicked. + * @return The new event. + */ +private fun createRightClickEventWithItem( + player: Player, + item: ItemStack +) = PlayerInteractEvent( + player, + Action.RIGHT_CLICK_AIR, + item, + null, + BlockFace.NORTH // random value not null +) diff --git a/src/main/kotlin/com/github/rushyverse/api/game/GameData.kt b/src/main/kotlin/com/github/rushyverse/api/game/GameData.kt index 0266b918..0b229ec4 100644 --- a/src/main/kotlin/com/github/rushyverse/api/game/GameData.kt +++ b/src/main/kotlin/com/github/rushyverse/api/game/GameData.kt @@ -13,4 +13,5 @@ public data class GameData( val id: Int, var players: Int = 0, var state: GameState = GameState.WAITING, + val permanent: Boolean = false ) diff --git a/src/main/kotlin/com/github/rushyverse/api/game/GameState.kt b/src/main/kotlin/com/github/rushyverse/api/game/GameState.kt index 7029f34e..b66df1fe 100644 --- a/src/main/kotlin/com/github/rushyverse/api/game/GameState.kt +++ b/src/main/kotlin/com/github/rushyverse/api/game/GameState.kt @@ -1,33 +1,40 @@ package com.github.rushyverse.api.game +import net.kyori.adventure.text.format.NamedTextColor + /** * Represents the various states a game can be in at any given moment. * Each state corresponds to a different phase in the game's lifecycle. */ -public enum class GameState { +public enum class GameState( + public val color: NamedTextColor, + public val miniColor: String, +) { + + NOT_STARTED(NamedTextColor.BLUE, ""), /** * Represents the state where the game is waiting for necessary conditions to start. * This could be waiting for more players to join, or waiting for some setup process to finish. */ - WAITING, + WAITING(NamedTextColor.GOLD, ""), /** * Represents the state when the game is in the process of starting. * This is a transitional phase, initialization of game resources, * a countdown timer before the game starts, etc. */ - STARTING, + STARTING(NamedTextColor.YELLOW, ""), /** * Represents the state where the game has officially started. * Gameplay is active during this state. */ - STARTED, + STARTED(NamedTextColor.GREEN, ""), /** * Represents the state when the game is in the process of ending. * This is a transitional phase, where final scores might be calculated, game resources might be cleaned up, etc. */ - ENDING; + ENDED(NamedTextColor.RED, ""); } diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt b/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt new file mode 100644 index 00000000..fb354055 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt @@ -0,0 +1,433 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.koin.inject +import com.github.rushyverse.api.player.Client +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import mu.KotlinLogging +import org.bukkit.Material +import org.bukkit.Server +import org.bukkit.entity.HumanEntity +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * Pair of an index and an ItemStack. + */ +public typealias ItemStackIndex = Pair + +/** + * Data class to store the inventory and the loading job. + * Can be used to cancel the loading job if the inventory is closed. + * @property inventory Inventory created. + * @property job Loading job to fill & animate the loading of the inventory. + * @property isLoading If true, the inventory is loading; otherwise it is filled or cancelled. + */ +public data class InventoryData( + val inventory: Inventory, + val job: Job, +) { + + val isLoading: Boolean get() = job.isActive + +} + +private val logger = KotlinLogging.logger {} + +/** + * Exception concerning the GUI. + */ +public open class GUIException(message: String? = null) : CancellationException(message) + +/** + * Exception thrown when the GUI is closed. + */ +public class GUIClosedException(message: String? = null) : GUIException(message) + +/** + * Exception thrown when the GUI is updating. + */ +public class GUIUpdatedException : GUIException() + +/** + * Exception thrown when the GUI is closed for a specific client. + * @property client Client for which the GUI is closed. + */ +public class GUIClosedForClientException(public val client: Client) : + GUIException("GUI closed for client ${client.playerUUID}") + +/** + * GUI that can be shared by multiple players. + * Only one inventory is created for all the viewers. + * @property server Server. + * @property manager Manager to register or unregister the GUI. + */ +public abstract class GUI( + private val loadingAnimation: InventoryLoadingAnimation? = null, + initialNumberInventories: Int = 16, +) { + + protected val server: Server by inject() + + protected val manager: GUIManager by inject() + + protected var inventories: MutableMap = HashMap(initialNumberInventories) + + protected val mutex: Mutex = Mutex() + + /** + * Get the key linked to the client to interact with the GUI. + * @param client Client to get the key for. + * @return The key. + */ + protected abstract suspend fun getKey(client: Client): T + + /** + * Get the coroutine scope to fill the inventory and the loading animation. + * @param key Key to get the coroutine scope for. + * @return The coroutine scope. + */ + protected abstract suspend fun fillScope(key: T): CoroutineScope + + /** + * Open the GUI for the client only if the GUI is not closed. + * If the client has another GUI opened, close it. + * If the client has the same GUI opened, do nothing. + * @param client Client to open the GUI for. + * @return True if the GUI was opened, false otherwise. + */ + public open suspend fun openClient(client: Client): Boolean { + val player = client.player + if (player == null) { + logger.warn { "Cannot open inventory for player ${client.playerUUID}: player is null" } + return false + } + // If the player is dead, do not open the GUI because the interface cannot be shown to the player. + if (player.isDead) return false + + val gui = client.gui() + if (gui === this) return false + + // Here we don't need + // to force to close the GUI because the GUI is closed when the player opens another inventory + // (if not cancelled). + + val key = getKey(client) + val inventory = getOrCreateInventory(key) + + // We open the inventory out of the mutex to avoid blocking operation from registered Listener. + if (player.openInventory(inventory) == null) { + // If the opening was cancelled (null returned), + // We need to unregister the client from the GUI + // and maybe close the inventory if it is individual. + closeClient(client, false) + return false + } + + return true + } + + /** + * Update the opened inventory for the client. + * If the opened inventory is shared with other players, the inventory will be updated for all the viewers. + * + * If the client has the GUI opened, the inventory will be updated. + * If the client has another GUI opened, do nothing. + * + * Call [getItems] to get the new items to fill the inventory. + * @param client Client to update the inventory for. + * @param interruptLoading If true and if the inventory is loading, the loading will be interrupted + * to start a new loading animation. + * @return True if the inventory was updated, false otherwise. + * @see [getItems] + * @see [update] + */ + public open suspend fun updateClient(client: Client, interruptLoading: Boolean = false): Boolean { + val key = getKey(client) + return mutex.withLock { + if (!unsafeContains(client)) return false + unsafeUpdate(key, interruptLoading) + } + } + + /** + * Update the inventory for the key. + * If the inventory is shared with several players, the inventory will be updated for all the viewers. + * + * If the inventory is not loaded, the inventory will be updated. + * If the inventory is loading, the inventory will be updated if [interruptLoading] is true. + * + * Call [getItems] to get the new items to fill the inventory. + * @param key Key to update the inventory for. + * @param interruptLoading If true and if the inventory is loading, the loading will be interrupted + * to start a new loading animation. + * @return True if the inventory was updated, false otherwise. + */ + public open suspend fun update(key: T, interruptLoading: Boolean = false): Boolean { + return mutex.withLock { unsafeUpdate(key, interruptLoading) } + } + + /** + * This function is not thread-safe. + * + * Update the inventory for the key. + * If the inventory is shared with several players, the inventory will be updated for all the viewers. + * + * If the inventory is not loaded, the inventory will be updated. + * If the inventory is loading, the inventory will be updated if [interruptLoading] is true. + * + * Call [getItems] to get the new items to fill the inventory. + * @param key Key to update the inventory for. + * @param interruptLoading If true and if the inventory is loading, the loading will be interrupted + * to start a new loading animation. + * @return True if the inventory was updated, false otherwise. + */ + private suspend fun unsafeUpdate(key: T, interruptLoading: Boolean = false): Boolean { + val inventoryData = inventories[key] ?: return false + + if (inventoryData.isLoading) { + // If we don't want to interrupt the loading and the inventory is loading, do nothing. + if (!interruptLoading) return false + else { + // If we want to interrupt the loading, we cancel the loading job. + // We need to wait for the job to be cancelled to avoid conflicts with the new loading animation. + inventoryData.job.apply { + cancel(GUIUpdatedException()) + join() + } + } + } + + val inventory = inventoryData.inventory + // Begin a new loading job and replace the old one. + val newLoadingJob = startLoadingInventory(key, inventory) + inventories[key] = InventoryData(inventory, newLoadingJob) + return true + } + + /** + * Get the inventory for the key. + * If the inventory does not exist, create it. + * @param key Key to get the inventory for. + * @return The inventory for the key. + */ + private suspend fun getOrCreateInventory(key: T): Inventory { + return mutex.withLock { + val loadedInventory = inventories[key] + if (loadedInventory != null) { + return@withLock loadedInventory.inventory + } + + val inventory = createInventory(key) + // Start the fill asynchronously to avoid blocking the other inventory creation with the mutex. + val loadingJob = startLoadingInventory(key, inventory) + inventories[key] = InventoryData(inventory, loadingJob) + + inventory + } + } + + /** + * Start the asynchronous loading animation and fill the inventory. + * @param key Key to create the inventory for. + * @param inventory Inventory to fill and animate. + * @return The job that can be cancelled to stop the loading animation. + */ + private suspend fun startLoadingInventory(key: T, inventory: Inventory): Job { + val size = inventory.size + // Empty the inventory, there is no effect if the inventory is new + // But avoid conflicts with old items if the inventory is updated. + inventory.contents = arrayOfNulls(size) + + // If no suspend operation is used in the flow, the fill will be done in the same thread & tick. + // That's why we start with unconfined dispatcher. + return fillScope(key).launch(Dispatchers.Unconfined) { + val inventoryFlowItems = getItems(key, size).cancellable() + + if (loadingAnimation == null) { + // Will fill the inventory bit by bit. + inventoryFlowItems.collect { (index, item) -> inventory.setItem(index, item) } + } else { + val loadingAnimationJob = launch { loadingAnimation.loading(key, inventory) } + + // To avoid conflicts with the loading animation, + // we need to store the items in a temporary inventory + val temporaryInventory = arrayOfNulls(size) + + inventoryFlowItems + .onCompletion { exception -> + // When the flow is finished, we cancel the loading animation. + loadingAnimationJob.cancelAndJoin() + + // If the flow was completed successfully, we fill the inventory with the temporary inventory. + if (exception == null) { + inventory.contents = temporaryInventory + } + }.collect { (index, item) -> temporaryInventory[index] = item } + } + } + } + + /** + * Create the inventory for the key. + * @param key Key to create the inventory for. + * @return New created inventory. + */ + protected abstract suspend fun createInventory(key: T): Inventory + + /** + * Create a new flow of [Item][ItemStack] to fill the inventory with. + * ```kotlin + * flow { + * emit(0 to ItemStack(Material.STONE)) + * delay(1.seconds) // simulate a suspend operation + * emit(1 to ItemStack(Material.DIRT)) + * } + * ``` + * If the flow doesn't suspend the coroutine, + * the inventory will be filled in the same tick & thread than during the creation of the inventory. + * @param key Key to fill the inventory for. + * @param size Size of the inventory. + * @return Flow of [Item][ItemStack] with index. + */ + protected abstract fun getItems(key: T, size: Int): Flow + + /** + * Check if the GUI contains the inventory. + * @param inventory Inventory to check. + * @return True if the GUI contains the inventory, false otherwise. + */ + public open suspend fun hasInventory(inventory: Inventory): Boolean { + return mutex.withLock { + inventories.values.any { it.inventory === inventory } + } + } + + /** + * Check if the inventory is loading. + * @param inventory Inventory to check. + * @return True if the inventory is loading (all the items are not loaded), + * false if the inventory is loaded or not present in the GUI. + */ + public open suspend fun isInventoryLoading(inventory: Inventory): Boolean { + return mutex.withLock { + inventories.values.firstOrNull { it.inventory === inventory }?.isLoading == true + } + } + + /** + * Get the viewers of the GUI. + * @return List of viewers. + */ + public open suspend fun viewers(): Sequence { + return mutex.withLock { unsafeViewers() } + } + + /** + * Get the viewers of the inventory. + * This function is not thread-safe. + * @return The viewers of the inventory. + */ + protected open fun unsafeViewers(): Sequence { + return inventories.values.asSequence().map { it.inventory }.flatMap(Inventory::getViewers) + } + + /** + * Check if the GUI contains the player. + * @param client Client to check. + * @return True if the GUI contains the player, false otherwise. + */ + public open suspend fun contains(client: Client): Boolean { + return mutex.withLock { unsafeContains(client) } + } + + /** + * Check if the GUI contains the client. + * This function is not thread-safe. + * @param client Client to check. + * @return True if the GUI contains the client, false otherwise. + */ + protected open fun unsafeContains(client: Client): Boolean { + val player = client.player ?: return false + return unsafeViewers().any { it === player } + } + + /** + * Close the inventory. + * The inventory will be closed for all the viewers. + * The GUI will be removed from the listener and the [onClick] function will not be called anymore. + */ + public open suspend fun close() { + unregister() + + mutex.withLock { + inventories.values.asFlow() + .onCompletion { inventories.clear() } + .onEach { + val job = it.job + job.cancel(GUIClosedException()) + job.join() + } + .map { it.inventory } + .toCollection(ArrayList(inventories.size)) + // Close the inventories out of the mutex + // to avoid slowing down the mutex with the events sent to the listeners. + }.forEach(Inventory::close) + } + + /** + * Remove the client has a viewer of the GUI. + * @param client Client to close the GUI for. + * @param closeInventory If true, the interface will be closed, otherwise it will be kept open. + * @return True if the inventory was closed, false otherwise. + */ + public abstract suspend fun closeClient(client: Client, closeInventory: Boolean = true): Boolean + + /** + * Register the GUI to the listener. + * If the GUI is already registered, do nothing. + * If the GUI is closed, he will be opened again. + * @return True if the GUI was registered, false otherwise. + */ + public open suspend fun register(): Boolean { + return manager.add(this) + } + + /** + * Unregister the GUI from the listener. + * Should be called when the GUI is closed with [closeClient]. + * @return True if the GUI was unregistered, false otherwise. + */ + protected open suspend fun unregister(): Boolean { + return manager.remove(this) + } + + /** + * Action to do when the client clicks on an item in the inventory. + * @param client Client who clicked. + * @param clickedItem Item clicked by the client cannot be null or [AIR][Material.AIR] + * @param clickedInventory Inventory where the click was detected. + * @param event Event of the click. + */ + public abstract suspend fun onClick( + client: Client, + clickedInventory: Inventory, + clickedItem: ItemStack, + event: InventoryClickEvent + ) +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/GUIListener.kt b/src/main/kotlin/com/github/rushyverse/api/gui/GUIListener.kt new file mode 100644 index 00000000..3609bb14 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/GUIListener.kt @@ -0,0 +1,82 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.Plugin +import com.github.rushyverse.api.extension.event.cancel +import com.github.rushyverse.api.koin.inject +import com.github.rushyverse.api.player.ClientManager +import org.bukkit.Material +import org.bukkit.entity.HumanEntity +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * Listener for GUI events. + * @property clients Manager of clients. + */ +public class GUIListener(private val plugin: Plugin) : Listener { + + private val clients: ClientManager by inject(plugin.id) + + /** + * Called when a player clicks on an item in an inventory. + * If the click is detected in a GUI, the event is cancelled and the GUI is notified. + * @param event Event of the click. + */ + @EventHandler + public suspend fun onInventoryClick(event: InventoryClickEvent) { + if (event.isCancelled) return + + val item = event.currentItem + // If the item is null or air, we should ignore the click + if (item == null || item.type == Material.AIR) return + + // If the click is not in an inventory, this is not a GUI click + val clickedInventory = event.clickedInventory ?: return + + val player = event.whoClicked + handleClickOnGUI(player, clickedInventory, item, event) + } + + /** + * Called when a player clicks on an item in an inventory. + * @param player Player who clicked. + * @param clickedInventory Inventory where the click was detected. + * @param item Item that was clicked. + * @param event Event of the click. + */ + private suspend fun handleClickOnGUI( + player: HumanEntity, + clickedInventory: Inventory, + item: ItemStack, + event: InventoryClickEvent + ) { + val client = clients.getClient(player) + val gui = client.gui() ?: return + if (!gui.hasInventory(clickedInventory)) { + return + } + + // The item in a GUI is not supposed to be moved + event.cancel() + gui.onClick(client, clickedInventory, item, event) + } + + /** + * Called when a player closes an inventory. + * If the inventory is a GUI, the GUI is notified that it is closed for this player. + * @param event Event of the close. + */ + @EventHandler + public suspend fun onInventoryClose(event: InventoryCloseEvent) { + val client = clients.getClientOrNull(event.player) ?: return + val gui = client.gui() ?: return + // We don't close the inventory because it is closing due to event. + // That avoids an infinite loop of events and consequently a stack overflow. + gui.closeClient(client, false) + } + +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/GUIManager.kt b/src/main/kotlin/com/github/rushyverse/api/gui/GUIManager.kt new file mode 100644 index 00000000..fc720077 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/GUIManager.kt @@ -0,0 +1,58 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.player.Client +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Manages the GUIs for players within the game. + * This class ensures thread-safe operations on the GUIs by using mutex locks. + */ +public class GUIManager { + + /** + * Mutex used to ensure thread-safe operations. + */ + private val mutex = Mutex() + + /** + * Private mutable set storing GUIs. + */ + private val _guis = mutableSetOf>() + + /** + * Immutable view of the GUIs set. + */ + public val guis: Collection> get() = _guis + + /** + * Retrieves the GUI for the specified player. + * This function is thread-safe and uses mutex locks to ensure atomic operations. + * + * @param client The player for whom the GUI is to be retrieved or created. + * @return The language associated with the player. + */ + public suspend fun get(client: Client): GUI<*>? { + return mutex.withLock { + guis.firstOrNull { it.contains(client) } + } + } + + /** + * Add a GUI to the listener. + * @param gui GUI to add. + * @return True if the GUI was added, false otherwise. + */ + public suspend fun add(gui: GUI<*>): Boolean { + return mutex.withLock { _guis.add(gui) } + } + + /** + * Remove a GUI from the listener. + * @param gui GUI to remove. + * @return True if the GUI was removed, false otherwise. + */ + public suspend fun remove(gui: GUI<*>): Boolean { + return mutex.withLock { _guis.remove(gui) } + } +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/LocaleGUI.kt b/src/main/kotlin/com/github/rushyverse/api/gui/LocaleGUI.kt new file mode 100644 index 00000000..5e3d3f07 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/LocaleGUI.kt @@ -0,0 +1,46 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import com.github.rushyverse.api.translation.SupportedLanguage +import com.github.shynixn.mccoroutine.bukkit.scope +import java.util.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.plus +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.plugin.Plugin + +/** + * GUI where a new inventory is created for each [locale][Locale]. + * This is useful to share the GUI between multiple players with the same language. + * + * For example, if two players have the same language, they will share the same inventory. + * If one of them changes their language, he will have another inventory dedicated to his new language. + */ +public abstract class LocaleGUI( + protected val plugin: Plugin, + loadingAnimation: InventoryLoadingAnimation? = null, + initialNumberInventories: Int = SupportedLanguage.entries.size +) : GUI( + loadingAnimation = loadingAnimation, + initialNumberInventories = initialNumberInventories +) { + + override suspend fun getKey(client: Client): Locale { + return client.lang().locale + } + + override suspend fun fillScope(key: Locale): CoroutineScope { + val scope = plugin.scope + return scope + SupervisorJob(scope.coroutineContext.job) + } + + override suspend fun closeClient(client: Client, closeInventory: Boolean): Boolean { + return if (closeInventory && contains(client)) { + client.player?.closeInventory(InventoryCloseEvent.Reason.PLUGIN) + true + } else false + } +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/PlayerGUI.kt b/src/main/kotlin/com/github/rushyverse/api/gui/PlayerGUI.kt new file mode 100644 index 00000000..d0238193 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/PlayerGUI.kt @@ -0,0 +1,72 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.withLock +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.InventoryHolder + +/** + * GUI where a new inventory is created for each player. + * An inventory is created when the player opens the GUI and he is not sharing the GUI with another player. + */ +public abstract class PlayerGUI( + loadingAnimation: InventoryLoadingAnimation? = null +) : GUI(loadingAnimation = loadingAnimation) { + + override suspend fun getKey(client: Client): Client { + return client + } + + override suspend fun fillScope(key: Client): CoroutineScope { + return key + SupervisorJob(key.coroutineContext.job) + } + + override suspend fun updateClient(client: Client, interruptLoading: Boolean): Boolean { + // Little optimization to avoid checking if the client is contained in map's values. + return super.update(client, interruptLoading) + } + + override fun unsafeContains(client: Client): Boolean { + // Little optimization to avoid checking if the client is contained in map's values. + return inventories.containsKey(client) + } + + /** + * Create the inventory for the client. + * Will translate the title and fill the inventory. + * @param key The client to create the inventory for. + * @return The inventory for the client. + */ + override suspend fun createInventory(key: Client): Inventory { + val player = key.requirePlayer() + return createInventory(player, key) + } + + /** + * Create the inventory for the client. + * This function is called when the [owner] wants to open the inventory. + * @param owner Player who wants to open the inventory. + * @param client The client to create the inventory for. + * @return The inventory for the client. + */ + protected abstract suspend fun createInventory(owner: InventoryHolder, client: Client): Inventory + + override suspend fun closeClient(client: Client, closeInventory: Boolean): Boolean { + val (inventory, job) = mutex.withLock { inventories.remove(client) } ?: return false + + job.cancel(GUIClosedForClientException(client)) + job.join() + + if (closeInventory) { + // Call out of the lock to avoid slowing down the mutex. + inventory.close() + } + + return true + } +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/SingleGUI.kt b/src/main/kotlin/com/github/rushyverse/api/gui/SingleGUI.kt new file mode 100644 index 00000000..47b5ae5e --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/SingleGUI.kt @@ -0,0 +1,86 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import com.github.shynixn.mccoroutine.bukkit.scope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.job +import kotlinx.coroutines.plus +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.plugin.Plugin + +/** + * GUI that can be shared by multiple players. + * Only one inventory is created for all the viewers. + * @property server Server. + * @property viewers List of viewers. + */ +public abstract class SingleGUI( + protected val plugin: Plugin, + loadingAnimation: InventoryLoadingAnimation? = null +) : GUI( + loadingAnimation = loadingAnimation, + initialNumberInventories = 1 +) { + + public companion object { + /** + * Unique key for the GUI. + * This GUI is shared by all the players, so the key is the same for all of them. + * That allows creating a unique inventory. + */ + private val KEY: Unit get() = Unit + } + + override suspend fun getKey(client: Client) { + return KEY + } + + override suspend fun fillScope(key: Unit): CoroutineScope { + val scope = plugin.scope + return scope + SupervisorJob(scope.coroutineContext.job) + } + + override suspend fun createInventory(key: Unit): Inventory { + return createInventory() + } + + /** + * Create the inventory. + * @return New created inventory. + */ + protected abstract suspend fun createInventory(): Inventory + + /** + * Update the inventory. + * If the inventory is not loaded, the inventory will be updated. + * If the inventory is loading, the inventory will be updated if [interruptLoading] is true. + * + * Call [getItems] to get the new items to fill the inventory. + * @param interruptLoading If true and if the inventory is loading, the loading will be interrupted + * to start a new loading animation. + * @return True if the inventory was updated, false otherwise. + */ + public suspend fun update(interruptLoading: Boolean = false): Boolean { + return super.update(KEY, interruptLoading) + } + + override suspend fun closeClient(client: Client, closeInventory: Boolean): Boolean { + return if (closeInventory && contains(client)) { + client.player?.closeInventory(InventoryCloseEvent.Reason.PLUGIN) + true + } else false + } + + override fun getItems(key: Unit, size: Int): Flow { + return getItems(size) + } + + /** + * @see getItems(key, size) + */ + protected abstract fun getItems(size: Int): Flow +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/load/InventoryLoadingAnimation.kt b/src/main/kotlin/com/github/rushyverse/api/gui/load/InventoryLoadingAnimation.kt new file mode 100644 index 00000000..a6179f74 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/load/InventoryLoadingAnimation.kt @@ -0,0 +1,18 @@ +package com.github.rushyverse.api.gui.load + +import org.bukkit.inventory.Inventory + +/** + * Animate an inventory while it is being loaded in the background. + * @param T Type of the key. + */ +public fun interface InventoryLoadingAnimation { + + /** + * Animate the inventory while the real inventory is being loaded in the background. + * @param key Key to animate the inventory for. + * @param inventory Inventory to animate. + * @return A job that can be cancelled to stop the animation. + */ + public suspend fun loading(key: T, inventory: Inventory) +} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimation.kt b/src/main/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimation.kt new file mode 100644 index 00000000..513eda73 --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimation.kt @@ -0,0 +1,57 @@ +package com.github.rushyverse.api.gui.load + +import java.util.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * Animation that shifts the items in the inventory. + * The items are shifted by [shift] slots every [delay]. + * The items are placed in the inventory by calling [initialize]. + * If too few items are returned by [initialize], the remaining slots will be filled with null items. + * If too many items are returned by [initialize], the overflowing items will be ignored. + * @param T Type of the key. + * @property initialize Function that returns the sequence of items to place in the inventory. + * @property shift Number of slots to shift the items by. + * @property delay Delay between each shift. + */ +public class ShiftInventoryLoadingAnimation( + private val initialize: (T) -> Sequence, + public val shift: Int = 1, + public val delay: Duration = 100.milliseconds, +) : InventoryLoadingAnimation { + + init { + require(delay > Duration.ZERO) { "Delay must be positive" } + } + + override suspend fun loading(key: T, inventory: Inventory) { + val size = inventory.size + val contents = arrayOfNulls(size) + // Fill the inventory with the initial items. + // If the sequence is too short, it will be filled with null items. + // If the sequence is too long, the overflowing items will be ignored. + initialize(key).take(size).forEachIndexed { index, item -> + contents[index] = item + } + + if(shift == 0 || shift == inventory.size) { + inventory.contents = contents + return + } + + coroutineScope { + val contentList = contents.toMutableList() + while (isActive) { + inventory.contents = contentList.toTypedArray() + delay(delay) + Collections.rotate(contentList, shift) + } + } + } +} diff --git a/src/main/kotlin/com/github/rushyverse/api/player/Client.kt b/src/main/kotlin/com/github/rushyverse/api/player/Client.kt index 0127a2d3..22c9cefe 100644 --- a/src/main/kotlin/com/github/rushyverse/api/player/Client.kt +++ b/src/main/kotlin/com/github/rushyverse/api/player/Client.kt @@ -2,17 +2,19 @@ package com.github.rushyverse.api.player import com.github.rushyverse.api.delegate.DelegatePlayer import com.github.rushyverse.api.extension.asComponent +import com.github.rushyverse.api.gui.GUI +import com.github.rushyverse.api.gui.GUIManager import com.github.rushyverse.api.koin.inject import com.github.rushyverse.api.player.exception.PlayerNotFoundException import com.github.rushyverse.api.player.language.LanguageManager import com.github.rushyverse.api.player.scoreboard.ScoreboardManager import com.github.rushyverse.api.translation.SupportedLanguage import fr.mrmicky.fastboard.adventure.FastBoard +import java.util.* import kotlinx.coroutines.CoroutineScope import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver import org.bukkit.entity.Player -import java.util.* /** * Client to store and manage data about player. @@ -28,6 +30,8 @@ public open class Client( private val languageManager: LanguageManager by inject() + private val guiManager: GUIManager by inject() + public val player: Player? by DelegatePlayer(playerUUID) /** @@ -60,7 +64,6 @@ public open class Client( send(message.asComponent()) } - /** * Retrieve the scoreboard of the player. * The scoreboard will be created if it doesn't exist. @@ -74,4 +77,10 @@ public open class Client( */ public suspend fun lang(): SupportedLanguage = languageManager.get(requirePlayer()) + /** + * Get the opened GUI of the player. + * @return The opened GUI of the player. + */ + public suspend fun gui(): GUI<*>? = guiManager.get(this) + } diff --git a/src/main/kotlin/com/github/rushyverse/api/player/ClientManager.kt b/src/main/kotlin/com/github/rushyverse/api/player/ClientManager.kt index 0c88e8a6..6835f1d6 100644 --- a/src/main/kotlin/com/github/rushyverse/api/player/ClientManager.kt +++ b/src/main/kotlin/com/github/rushyverse/api/player/ClientManager.kt @@ -1,13 +1,17 @@ package com.github.rushyverse.api.player -import org.bukkit.entity.Player +import com.github.rushyverse.api.player.exception.ClientNotFoundException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.bukkit.entity.HumanEntity /** * Get a client from the player instance. * @param player Player. * @return The client linked to a player. */ -public suspend inline fun ClientManager.getTypedClient(player: Player): T = getClient(player) as T +public suspend inline fun ClientManager.getTypedClient(player: HumanEntity): T = + getClient(player) as T /** * Get a client from the key linked to a player. @@ -21,7 +25,7 @@ public suspend inline fun ClientManager.getTypedClient(key: * @param player Player. * @return The client linked to a player, `null` if not found. */ -public suspend inline fun ClientManager.getTypedClientOrNull(player: Player): T? = +public suspend inline fun ClientManager.getTypedClientOrNull(player: HumanEntity): T? = getClientOrNull(player) as T? /** @@ -45,28 +49,28 @@ public interface ClientManager { * @param client New client added. * @return The previous value associated with a key, or null there is none. */ - public suspend fun put(player: Player, client: Client): Client? + public suspend fun put(player: HumanEntity, client: Client): Client? /** * Put a new client in the server if no client is linked to the player. * @param client New client added. * @return The previous value associated with a key, or null there is none. */ - public suspend fun putIfAbsent(player: Player, client: Client): Client? + public suspend fun putIfAbsent(player: HumanEntity, client: Client): Client? /** * Remove a client from the server by a Player. * @param player Player linked to a Client. * @return The client that was removed null otherwise. */ - public suspend fun removeClient(player: Player): Client? + public suspend fun removeClient(player: HumanEntity): Client? /** * Get a client from the player instance. * @param player Player. * @return The client linked to a player. */ - public suspend fun getClient(player: Player): Client + public suspend fun getClient(player: HumanEntity): Client /** * Get a client from the key linked to a player. @@ -80,7 +84,7 @@ public interface ClientManager { * @param player Player. * @return The client linked to a player, `null` if not found. */ - public suspend fun getClientOrNull(player: Player): Client? + public suspend fun getClientOrNull(player: HumanEntity): Client? /** * Get a client from the key linked to a player. @@ -94,5 +98,63 @@ public interface ClientManager { * @param player Player. * @return `true` if there is a client for the player, `false` otherwise. */ - public suspend fun contains(player: Player): Boolean + public suspend fun contains(player: HumanEntity): Boolean +} + +/** + * Manage the existing client present in the server. + * The clients are stored with the name of the player. + * @property _clients Synchronized mutable map of clients as value and name of player as a key. + */ +public class ClientManagerImpl : ClientManager { + + private val mutex = Mutex() + + /** + * All clients in server linked by the name of player. + */ + private val _clients = mutableMapOf() + + override val clients: Map = _clients + + override suspend fun put( + player: HumanEntity, + client: Client + ): Client? = mutex.withLock { + _clients.put(player.name, client) + } + + override suspend fun putIfAbsent( + player: HumanEntity, + client: Client + ): Client? = mutex.withLock { + _clients.putIfAbsent(getKey(player), client) + } + + override suspend fun removeClient(player: HumanEntity): Client? = mutex.withLock { + _clients.remove(getKey(player)) + } + + override suspend fun getClient(player: HumanEntity): Client = getClient(getKey(player)) + + override suspend fun getClient(key: String): Client = + getClientOrNull(key) ?: throw ClientNotFoundException("No client is linked to the name [$key]") + + override suspend fun getClientOrNull(player: HumanEntity): Client? = getClientOrNull(getKey(player)) + + override suspend fun getClientOrNull(key: String): Client? = mutex.withLock { + _clients[key] + } + + /** + * Key use for the Map + * @param p Player that has the key + * @return The key for the Map + */ + private fun getKey(p: HumanEntity): String = p.name + + override suspend fun contains(player: HumanEntity): Boolean = mutex.withLock { + _clients.containsKey(getKey(player)) + } + } diff --git a/src/main/kotlin/com/github/rushyverse/api/player/ClientManagerImpl.kt b/src/main/kotlin/com/github/rushyverse/api/player/ClientManagerImpl.kt deleted file mode 100644 index 23aa19de..00000000 --- a/src/main/kotlin/com/github/rushyverse/api/player/ClientManagerImpl.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.rushyverse.api.player - -import com.github.rushyverse.api.player.exception.ClientNotFoundException -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.bukkit.entity.Player - -/** - * Manage the existing client present in the server. - * The clients are stored with the name of the player. - * @property _clients Synchronized mutable map of clients as value and name of player as a key. - */ -public class ClientManagerImpl : ClientManager { - - private val mutex = Mutex() - - /** - * All clients in server linked by the name of player. - */ - private val _clients = mutableMapOf() - - override val clients: Map = _clients - - override suspend fun put( - player: Player, - client: Client - ): Client? = mutex.withLock { - _clients.put(player.name, client) - } - - override suspend fun putIfAbsent( - player: Player, - client: Client - ): Client? = mutex.withLock { - _clients.putIfAbsent(getKey(player), client) - } - - override suspend fun removeClient(player: Player): Client? = mutex.withLock { - _clients.remove(getKey(player)) - } - - override suspend fun getClient(player: Player): Client = getClient(getKey(player)) - - override suspend fun getClient(key: String): Client = - getClientOrNull(key) ?: throw ClientNotFoundException("No client is linked to the name [$key]") - - override suspend fun getClientOrNull(player: Player): Client? = getClientOrNull(getKey(player)) - - override suspend fun getClientOrNull(key: String): Client? = mutex.withLock { - _clients[key] - } - - /** - * Key use for the Map - * @param p Player that has the key - * @return The key for the Map - */ - private fun getKey(p: Player): String = p.name - - override suspend fun contains(player: Player): Boolean = mutex.withLock { - _clients.containsKey(getKey(player)) - } - -} diff --git a/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt b/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt index 4a7ea6aa..fef9664c 100644 --- a/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt @@ -5,15 +5,19 @@ import com.github.rushyverse.api.koin.loadModule import com.github.rushyverse.api.utils.randomString import io.mockk.every import io.mockk.mockk -import org.koin.core.module.Module -import org.koin.dsl.ModuleDeclaration +import io.mockk.unmockkAll import kotlin.test.AfterTest import kotlin.test.BeforeTest +import org.bukkit.Server +import org.koin.core.module.Module +import org.koin.dsl.ModuleDeclaration open class AbstractKoinTest { lateinit var plugin: Plugin + lateinit var server: Server + private lateinit var pluginId: String @BeforeTest @@ -22,19 +26,29 @@ open class AbstractKoinTest { CraftContext.startKoin(pluginId) { } CraftContext.startKoin(APIPlugin.ID_API) { } + server = mockk { + every { pluginManager } returns mockk() + } + loadTestModule { plugin = mockk { every { id } returns pluginId every { name } returns randomString() + every { server } returns this@AbstractKoinTest.server } single { plugin } } + + loadApiTestModule { + single { server } + } } @AfterTest open fun onAfter() { CraftContext.stopKoin(pluginId) CraftContext.stopKoin(APIPlugin.ID_API) + unmockkAll() } fun loadTestModule(moduleDeclaration: ModuleDeclaration): Module = diff --git a/src/test/kotlin/com/github/rushyverse/api/extension/event/EventExtTest.kt b/src/test/kotlin/com/github/rushyverse/api/extension/event/EventExtTest.kt new file mode 100644 index 00000000..f54c2817 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/extension/event/EventExtTest.kt @@ -0,0 +1,66 @@ +package com.github.rushyverse.api.extension.event + +import be.seeseemelk.mockbukkit.MockBukkit +import be.seeseemelk.mockbukkit.ServerMock +import com.github.rushyverse.api.extension.ItemStack +import com.github.shynixn.mccoroutine.bukkit.callSuspendingEvent +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.bukkit.Material +import org.bukkit.event.block.Action +import org.bukkit.event.player.PlayerInteractEvent + +class EventExtTest { + + private lateinit var serverMock: ServerMock + + @BeforeTest + fun onBefore() { + serverMock = MockBukkit.mock() + mockkStatic("com.github.shynixn.mccoroutine.bukkit.MCCoroutineKt") + } + + @AfterTest + fun onAfter() { + MockBukkit.unmock() + unmockkAll() + } + + @Test + fun `should trigger right click`() = runTest { + val plugin = MockBukkit.createMockPlugin() + val player = serverMock.addPlayer() + val item = ItemStack { type = Material.DIRT } + val slot = slot() + + lateinit var jobs: List + val pluginManager = serverMock.pluginManager + every { pluginManager.callSuspendingEvent(capture(slot), plugin) } answers { + List(5) { async { delay(1.seconds) } }.apply { jobs = this } + } + + callRightClickOnItemEvent(plugin, player, item) + + verify(exactly = 1) { pluginManager.callSuspendingEvent(any(), plugin) } + jobs.forEach { it.isCompleted shouldBe true } + + val event = slot.captured + event.player shouldBe player + event.action shouldBe Action.RIGHT_CLICK_AIR + event.item shouldBe item + event.clickedBlock shouldBe null + } + +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/AbstractGUITest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/AbstractGUITest.kt new file mode 100644 index 00000000..6d3c672a --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/AbstractGUITest.kt @@ -0,0 +1,605 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.MockBukkit +import be.seeseemelk.mockbukkit.MockPlugin +import be.seeseemelk.mockbukkit.ServerMock +import be.seeseemelk.mockbukkit.entity.PlayerMock +import com.github.rushyverse.api.AbstractKoinTest +import com.github.rushyverse.api.extension.ItemStack +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import com.github.rushyverse.api.player.ClientManager +import com.github.rushyverse.api.player.ClientManagerImpl +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import java.net.InetSocketAddress +import java.util.* +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.bukkit.Material +import org.bukkit.event.inventory.InventoryType +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +abstract class AbstractGUITest : AbstractKoinTest() { + + protected lateinit var guiManager: GUIManager + protected lateinit var clientManager: ClientManager + protected lateinit var serverMock: ServerMock + protected lateinit var pluginMock: MockPlugin + + @BeforeTest + override fun onBefore() { + super.onBefore() + guiManager = GUIManager() + clientManager = ClientManagerImpl() + + loadApiTestModule { + single { guiManager } + single { clientManager } + } + + serverMock = MockBukkit.mock() + pluginMock = MockBukkit.createMockPlugin() + } + + @AfterTest + override fun onAfter() { + super.onAfter() + MockBukkit.unmock() + } + + abstract inner class Register { + + @Test + fun `should register if not already registered`() = runTest { + val gui = createNonFillGUI() + gui.register() shouldBe true + guiManager.guis shouldContainAll listOf(gui) + } + + @Test + fun `should not register if already registered`() = runTest { + val gui = createNonFillGUI() + gui.register() shouldBe true + gui.register() shouldBe false + guiManager.guis shouldContainAll listOf(gui) + } + + @Test + fun `should register GUI if was closed`() = runTest { + val gui = createNonFillGUI() + gui.close() + gui.register() shouldBe true + } + } + + abstract inner class Viewers { + + @Test + fun `should return empty list if no client is viewing the GUI`() = runTest { + val gui = createNonFillGUI() + gui.viewers().toList() shouldBe emptyList() + } + + @Test + fun `should return the list of clients viewing the GUI`() = runTest { + val gui = createNonFillGUI() + val playerClients = List(5) { registerPlayer() } + + playerClients.forEach { (_, client) -> + gui.openClient(client) shouldBe true + } + + gui.viewers().toList() shouldContainExactlyInAnyOrder playerClients.map { it.first } + } + + } + + abstract inner class Contains { + + @Test + fun `should return false if the client is not viewing the GUI`() = runTest { + val gui = createNonFillGUI() + val (_, client) = registerPlayer() + gui.contains(client) shouldBe false + } + + @Test + fun `should return true if the client is viewing the GUI`() = runTest { + val gui = createNonFillGUI() + val (_, client) = registerPlayer() + gui.openClient(client) shouldBe true + gui.contains(client) shouldBe true + } + + } + + abstract inner class OpenClient { + + @Test + fun `should open if GUI was closed`() = runTest { + val type = InventoryType.FURNACE + val gui = createNonFillGUI(type) + gui.close() + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + } + + @Test + fun `should do nothing if the client has the same GUI opened`() = runTest { + val type = InventoryType.HOPPER + val gui = createNonFillGUI(inventoryType = type) + gui.register() + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + gui.openClient(client) shouldBe false + player.assertInventoryView(type) + } + + @Test + fun `should do nothing if the player is dead`() = runTest { + val gui = createNonFillGUI() + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + + player.health = 0.0 + gui.openClient(client) shouldBe false + player.assertInventoryView(initialInventoryViewType) + } + + @Test + fun `should do nothing if the player open inventory is cancelled`() = runTest { + val gui = createNonFillGUI() + + val basePlayer = serverMock.addPlayer() + val uuid = UUID.randomUUID() + val player = spyk(basePlayer) { + every { uniqueId } returns uuid + every { address } returns InetSocketAddress(0) + every { openInventory(any()) } returns null + } + val (_, client) = registerPlayer(player) + + gui.openClient(client) shouldBe false + gui.contains(client) shouldBe false + gui.viewers().toList() shouldBe emptyList() + } + + @Test + fun `should fill the inventory in the same thread if no suspend operation`() { + + val items: Array = arrayOf( + ItemStack { type = Material.DIAMOND_ORE }, + ItemStack { type = Material.STICK }, + ) + + runBlocking { + val currentThread = Thread.currentThread() + + val type = InventoryType.ENDER_CHEST + val gui = createFillGUI(items, delay = null, inventoryType = type) + gui.register() + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + val inventory = player.openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe false + + val content = inventory.contents + items.forEachIndexed { index, item -> + content[index] shouldBe item + } + + for (i in items.size until content.size) { + content[i] shouldBe null + } + + getFillThreadBeforeSuspend(gui) shouldBe currentThread + getFillThreadAfterSuspend(gui) shouldBe currentThread + } + } + + @Test + fun `should fill the inventory in the other thread after suspend operation`() { + + val items: Array = arrayOf( + ItemStack { type = Material.DIAMOND_AXE }, + ItemStack { type = Material.ACACIA_LEAVES }, + ) + + runBlocking { + val currentThread = Thread.currentThread() + + val type = InventoryType.ENDER_CHEST + val delay = 100.milliseconds + val gui = createFillGUI(items = items, delay = delay, inventoryType = type) + gui.register() + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + val inventory = player.openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + val content = inventory.contents + content.forEach { it shouldBe null } + + delay(delay * 2) + gui.isInventoryLoading(inventory) shouldBe false + + items.forEachIndexed { index, item -> + content[index] shouldBe item + } + + for (i in items.size until content.size) { + content[i] shouldBe null + } + + getFillThreadBeforeSuspend(gui) shouldBe currentThread + getFillThreadAfterSuspend(gui) shouldNotBe currentThread + } + } + + @Test + fun `should use loading animation`() { + val loadingItem = ItemStack { type = Material.BARRIER } + val item1 = ItemStack { type = Material.APPLE } + val item2 = ItemStack { type = Material.BEEF } + + val animation = createAnimation(loadingItem) + + val gui = createDelayGUI( + item1, + item2, + delay = 1.seconds, + inventoryType = InventoryType.CHEST, + loadingAnimation = animation + ) + + runBlocking { + val (player, client) = registerPlayer() + gui.openClient(client) shouldBe true + val guiInventory = player.openInventory.topInventory + + // Animation should be called so the real items should not be in the inventory + val firstContents = guiInventory.contents + firstContents.getOrNull(0) shouldBe loadingItem + firstContents.getOrNull(1) shouldBe null + gui.isInventoryLoading(guiInventory) shouldBe true + + delay(100.milliseconds) + + // Until all items are emitted, the inventory should not be filled + val secondContents = guiInventory.contents + secondContents.getOrNull(0) shouldBe loadingItem + secondContents.getOrNull(1) shouldBe null + gui.isInventoryLoading(guiInventory) shouldBe true + + delay(1.seconds) + + // After all items are emitted, the inventory should be filled + val thirdContents = guiInventory.contents + thirdContents.getOrNull(0) shouldBe item1 + thirdContents.getOrNull(1) shouldBe item2 + gui.isInventoryLoading(guiInventory) shouldBe false + } + } + + @Test + fun `should set bit by bit if no loading animation`() { + val item1 = ItemStack { type = Material.APPLE } + val item2 = ItemStack { type = Material.BEEF } + + val gui = createDelayGUI( + item1, + item2, + delay = 1.seconds, + inventoryType = InventoryType.CHEST, + loadingAnimation = null + ) + + runBlocking { + val (player, client) = registerPlayer() + gui.openClient(client) shouldBe true + delay(100.milliseconds) + val guiInventory = player.openInventory.topInventory + + // Animation should be called so the real items should not be in the inventory + val firstContents = guiInventory.contents + firstContents.getOrNull(0) shouldBe item1 + firstContents.getOrNull(1) shouldBe null + gui.isInventoryLoading(guiInventory) shouldBe true + + delay(1.seconds) + + // After all items are emitted, the inventory should be filled + val secondContents = guiInventory.contents + secondContents.getOrNull(0) shouldBe item1 + secondContents.getOrNull(1) shouldBe item2 + gui.isInventoryLoading(guiInventory) shouldBe false + } + } + + private fun createAnimation(loadingItem: ItemStack) = + InventoryLoadingAnimation { _, inventory -> + inventory.setItem(0, loadingItem) + } + + protected abstract fun createDelayGUI( + item1: ItemStack, + item2: ItemStack, + delay: Duration, + inventoryType: InventoryType, + loadingAnimation: InventoryLoadingAnimation? + ): GUI + + } + + abstract inner class UpdateClient { + + @Test + fun `should return false if the client is not viewing the GUI`() = runTest { + val gui = createNonFillGUI() + val (player, client) = registerPlayer() + val initialInventoryViewType = player.openInventory.type + gui.updateClient(client) shouldBe false + + gui.viewers().toList() shouldBe emptyList() + gui.contains(client) shouldBe false + + player.assertInventoryView(initialInventoryViewType) + } + + @Test + fun `should return false if the client is viewing the GUI with a loading inventory`() { + runBlocking { + val type = InventoryType.DISPENSER + val delay = 100.milliseconds + val gui = createFillGUI(emptyArray(), inventoryType = type, delay = delay) + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + + val guiInventory = player.openInventory.topInventory + gui.isInventoryLoading(guiInventory) shouldBe true + + // We're waiting the half of the delay to be sure that the inventory is loading + delay(50.milliseconds) + + gui.updateClient(client) shouldBe false + player.assertInventoryView(type) + gui.viewers().toList() shouldContainExactlyInAnyOrder listOf(player) + gui.contains(client) shouldBe true + + // if we interrupt the loading, the inventory should be loading from 0 to new delay + // but here, we didn't interrupt the loading, so the inventory should be loaded (50 + 80 = 130 > 100) + delay(80.milliseconds) + + gui.isInventoryLoading(guiInventory) shouldBe false + } + } + + @Test + fun `should return true if the client is viewing the GUI with a loaded inventory`() { + runBlocking { + val type = InventoryType.DISPENSER + val delay = 100.milliseconds + val gui = createFillGUI(emptyArray(), inventoryType = type, delay = delay) + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + val guiInventory = player.openInventory.topInventory + gui.isInventoryLoading(guiInventory) shouldBe true + + // We're waiting the half of the delay to be sure that the inventory is loading + delay(70.milliseconds) + + gui.updateClient(client, true) shouldBe true + player.assertInventoryView(type) + gui.viewers().toList() shouldContainExactlyInAnyOrder listOf(player) + gui.contains(client) shouldBe true + + // if we interrupt the loading, the inventory should be loading from 0 to new delay + delay(70.milliseconds) + gui.isInventoryLoading(guiInventory) shouldBe true + + delay(50.milliseconds) + gui.isInventoryLoading(guiInventory) shouldBe false + } + } + } + + abstract inner class HasInventory { + + @Test + fun `should return false if the inventory doesn't come from GUI`() = runTest { + val gui = createNonFillGUI() + val (_, client) = registerPlayer() + gui.openClient(client) shouldBe true + gui.hasInventory(mockk()) shouldBe false + } + + @Test + fun `should return true if the client is viewing the GUI`() = runTest { + val gui = createNonFillGUI() + val (player, client) = registerPlayer() + gui.openClient(client) shouldBe true + + val inventory = player.openInventory.topInventory + gui.hasInventory(inventory) shouldBe true + } + + } + + abstract inner class IsInventoryLoading { + + @Test + fun `should return false if the inventory doesn't come from GUI`() = runTest { + val gui = createNonFillGUI() + val (_, client) = registerPlayer() + gui.openClient(client) shouldBe true + gui.isInventoryLoading(mockk()) shouldBe false + } + + @Test + fun `should return false if the inventory is not loading`() = runTest { + val gui = createNonFillGUI() + val (player, client) = registerPlayer() + gui.openClient(client) shouldBe true + + val inventory = player.openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe false + } + + @Test + fun `should return true if the inventory is loading`() { + runBlocking { + val delay = 50.milliseconds + val gui = createFillGUI(emptyArray(), delay = delay) + val (player, client) = registerPlayer() + gui.openClient(client) shouldBe true + + val inventory = player.openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + delay(delay * 2) + + gui.isInventoryLoading(inventory) shouldBe false + } + } + } + + abstract inner class Close { + + @Test + fun `should close all inventories and remove all viewers`() = runTest { + val type = InventoryType.BREWING + val gui = createNonFillGUI(type) + gui.register() + + val playerClients = List(5) { registerPlayer() } + val initialInventoryViewType = playerClients.first().first.openInventory.type + + val inventories = playerClients.map { (player, client) -> + player.assertInventoryView(initialInventoryViewType) + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + client.gui() shouldBe gui + player.openInventory.topInventory + } + + gui.close() + + inventories.forEach { inventory -> + gui.hasInventory(inventory) shouldBe false + } + + playerClients.forEach { (player, client) -> + player.assertInventoryView(initialInventoryViewType) + client.gui() shouldBe null + } + } + + @Test + fun `should unregister the GUI`() = runTest { + val gui = createNonFillGUI() + gui.register() + guiManager.guis shouldContainAll listOf(gui) + gui.close() + guiManager.guis shouldContainAll listOf() + } + } + + abstract inner class CloseClient { + + @Test + fun `should return false if the client is not viewing the GUI`() = runTest { + val gui = createNonFillGUI() + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + + player.assertInventoryView(initialInventoryViewType) + gui.closeClient(client, true) shouldBe false + player.assertInventoryView(initialInventoryViewType) + } + + @Test + fun `should return true if the client is viewing the GUI`() = runTest { + val type = InventoryType.DISPENSER + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + gui.closeClient(client, true) shouldBe true + player.assertInventoryView(initialInventoryViewType) + } + + @Test + fun `should not close for other clients`() = runTest { + val type = InventoryType.HOPPER + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + val (player2, client2) = registerPlayer() + val initialInventoryViewType = player2.openInventory.type + + gui.openClient(client) shouldBe true + gui.openClient(client2) shouldBe true + + player.assertInventoryView(type) + player2.assertInventoryView(type) + + gui.closeClient(client2, true) shouldBe true + player.assertInventoryView(type) + player2.assertInventoryView(initialInventoryViewType) + } + + + } + + abstract fun createNonFillGUI(inventoryType: InventoryType = InventoryType.HOPPER): GUI<*> + + abstract fun createFillGUI( + items: Array, + inventoryType: InventoryType = InventoryType.HOPPER, + delay: Duration? = null + ): GUI<*> + + abstract fun getFillThreadBeforeSuspend(gui: GUI<*>): Thread? + + abstract fun getFillThreadAfterSuspend(gui: GUI<*>): Thread? + + protected suspend fun registerPlayer(playerMock: PlayerMock? = null): Pair { + val player = playerMock?.also { serverMock.addPlayer(it) } ?: serverMock.addPlayer() + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + clientManager.put(player, client) + return player to client + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/GUIListenerTest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/GUIListenerTest.kt new file mode 100644 index 00000000..4b5d1566 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/GUIListenerTest.kt @@ -0,0 +1,200 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.MockBukkit +import be.seeseemelk.mockbukkit.ServerMock +import com.github.rushyverse.api.AbstractKoinTest +import com.github.rushyverse.api.extension.ItemStack +import com.github.rushyverse.api.extension.event.cancel +import com.github.rushyverse.api.player.Client +import com.github.rushyverse.api.player.ClientManager +import com.github.rushyverse.api.player.ClientManagerImpl +import com.github.shynixn.mccoroutine.bukkit.callSuspendingEvent +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import org.junit.jupiter.api.Nested + +class GUIListenerTest : AbstractKoinTest() { + + private lateinit var guiManager: GUIManager + private lateinit var clientManager: ClientManager + private lateinit var listener: GUIListener + private lateinit var serverMock: ServerMock + + @BeforeTest + override fun onBefore() { + super.onBefore() + guiManager = GUIManager() + clientManager = ClientManagerImpl() + listener = GUIListener(plugin) + + loadApiTestModule { + single { guiManager } + single { clientManager } + } + + serverMock = MockBukkit.mock() + mockkStatic("com.github.shynixn.mccoroutine.bukkit.MCCoroutineKt") + } + + @AfterTest + override fun onAfter() { + super.onAfter() + MockBukkit.unmock() + unmockkAll() + } + + @Nested + inner class OnInventoryClick { + + @Test + fun `should do nothing if event is cancelled`() = runTest { + val (player, client) = registerPlayer() + val gui = registerGUI { + coEvery { contains(client) } returns true + } + + callEvent(true, player, ItemStack { type = Material.DIRT }, player.inventory) + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } + } + + @Test + fun `should do nothing if item is null or air`() = runTest { + val (player, client) = registerPlayer() + val gui = registerGUI { + coEvery { contains(client) } returns true + } + + suspend fun callEvent(item: ItemStack?) { + val event = callEvent(false, player, item, player.inventory) + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } + verify(exactly = 0) { event.cancel() } + } + + callEvent(null) + callEvent(ItemStack { type = Material.AIR }) + } + + @Test + fun `should do nothing if client doesn't have a GUI opened`() = runTest { + val (player, client) = registerPlayer() + + val gui = registerGUI { + coEvery { contains(client) } returns false + coEvery { hasInventory(any()) } returns false + } + + val pluginManager = server.pluginManager + + val item = ItemStack { type = Material.DIRT } + callEvent(false, player, item, mockk()) + + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } + verify(exactly = 0) { pluginManager.callSuspendingEvent(any(), plugin) } + } + + @Test + fun `should call GUI onClick if client has opened one`() = runTest { + val (player, client) = registerPlayer() + val inventory = mockk() + val gui = registerGUI { + coEvery { contains(client) } returns true + coEvery { onClick(client, any(), any(), any()) } returns Unit + coEvery { hasInventory(inventory) } returns true + } + + val item = ItemStack { type = Material.DIRT } + val event = callEvent(false, player, item, inventory) + coVerify(exactly = 1) { gui.onClick(client, inventory, item, event) } + verify(exactly = 1) { event.cancel() } + } + + private suspend fun callEvent( + cancel: Boolean, + player: Player, + item: ItemStack?, + inventory: Inventory? + ): InventoryClickEvent { + val event = mockk { + every { isCancelled } returns cancel + every { whoClicked } returns player + every { currentItem } returns item + every { cancel() } returns Unit + every { clickedInventory } returns inventory + } + + listener.onInventoryClick(event) + return event + } + + } + + @Nested + inner class OnInventoryClose { + + @Test + fun `should do nothing if client doesn't have a GUI opened`() = runTest { + val (player, client) = registerPlayer() + val gui = registerGUI { + coEvery { contains(client) } returns false + } + + callEvent(player) + coVerify(exactly = 0) { gui.closeClient(client, any()) } + } + + @Test + fun `should close the GUI if client has opened one`() = runTest { + val (player, client) = registerPlayer() + val gui = registerGUI { + coEvery { contains(client) } returns true + coEvery { closeClient(client, any()) } returns true + } + + val gui2 = registerGUI { + coEvery { contains(client) } returns false + } + + callEvent(player) + + coVerify(exactly = 1) { gui.closeClient(client, false) } + coVerify(exactly = 0) { gui2.closeClient(client, any()) } + } + + private suspend fun callEvent(player: Player) { + val event = mockk { + every { getPlayer() } returns player + } + listener.onInventoryClose(event) + } + } + + private suspend fun registerPlayer(): Pair { + val player = serverMock.addPlayer() + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + clientManager.put(player, client) + return player to client + } + + private suspend inline fun registerGUI(block: GUI<*>.() -> Unit): GUI<*> { + val gui = mockk>(block = block) + guiManager.add(gui) + return gui + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/GUIManagerTest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/GUIManagerTest.kt new file mode 100644 index 00000000..04c2c6b0 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/GUIManagerTest.kt @@ -0,0 +1,131 @@ +package com.github.rushyverse.api.gui + +import com.github.rushyverse.api.player.Client +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested + +class GUIManagerTest { + + private lateinit var manager: GUIManager + + @BeforeTest + fun onBefore() { + manager = GUIManager() + } + + @Nested + inner class Get { + + @Test + fun `should returns null if no GUI is registered`() = runTest { + val client = mockk() + manager.get(client) shouldBe null + } + + @Test + fun `should returns null if no GUI contains the client`() = runTest { + val client = mockk() + val gui = mockk> { + coEvery { contains(any()) } returns false + } + manager.add(gui) + manager.get(client) shouldBe null + } + + @Test + fun `should returns GUI if contains the client`() = runTest { + val client = mockk() + val gui = mockk> { + coEvery { contains(client) } returns true + } + manager.add(gui) + manager.get(client) shouldBe gui + } + + @Test + fun `should returns GUI if contains the asked client`() = runTest { + val client = mockk() + val client2 = mockk() + val gui = mockk> { + coEvery { contains(client) } returns true + coEvery { contains(client2) } returns false + } + manager.add(gui) + manager.get(client) shouldBe gui + manager.get(client2) shouldBe null + } + + } + + @Nested + inner class Add { + + @Test + fun `should add non registered GUI`() = runTest { + val gui = mockk>() + manager.add(gui) shouldBe true + manager.guis.contains(gui) shouldBe true + manager.guis.size shouldBe 1 + } + + @Test + fun `should not add registered GUI`() = runTest { + val gui = mockk>() + manager.add(gui) shouldBe true + manager.add(gui) shouldBe false + manager.guis.contains(gui) shouldBe true + manager.guis.size shouldBe 1 + } + + @Test + fun `should add multiple GUIs`() = runTest { + val gui1 = mockk>() + val gui2 = mockk>() + manager.add(gui1) shouldBe true + manager.add(gui2) shouldBe true + manager.guis.contains(gui1) shouldBe true + manager.guis.contains(gui2) shouldBe true + manager.guis.size shouldBe 2 + } + + } + + @Nested + inner class Remove { + + @Test + fun `should remove registered GUI`() = runTest { + val gui = mockk>() + manager.add(gui) shouldBe true + manager.remove(gui) shouldBe true + manager.guis.contains(gui) shouldBe false + manager.guis.size shouldBe 0 + } + + @Test + fun `should not remove non registered GUI`() = runTest { + val gui = mockk>() + manager.remove(gui) shouldBe false + manager.guis.contains(gui) shouldBe false + manager.guis.size shouldBe 0 + } + + @Test + fun `should remove one GUI`() = runTest { + val gui1 = mockk>() + val gui2 = mockk>() + manager.add(gui1) shouldBe true + manager.add(gui2) shouldBe true + manager.remove(gui1) shouldBe true + manager.guis.contains(gui1) shouldBe false + manager.guis.contains(gui2) shouldBe true + manager.guis.size shouldBe 1 + } + + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/LocalePlayerGUITest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/LocalePlayerGUITest.kt new file mode 100644 index 00000000..4249e861 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/LocalePlayerGUITest.kt @@ -0,0 +1,256 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.ServerMock +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import com.github.rushyverse.api.player.language.LanguageManager +import com.github.rushyverse.api.translation.SupportedLanguage +import com.github.shynixn.mccoroutine.bukkit.scope +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockkStatic +import java.util.* +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryType +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import org.bukkit.plugin.Plugin +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class LocalePlayerGUITest : AbstractGUITest() { + + private lateinit var languageManager: LanguageManager + + @BeforeTest + override fun onBefore() { + super.onBefore() + languageManager = LanguageManager() + + loadApiTestModule { + single { languageManager } + } + + mockkStatic("com.github.shynixn.mccoroutine.bukkit.MCCoroutineKt") + every { plugin.scope } returns CoroutineScope(EmptyCoroutineContext) + } + + override fun createFillGUI(items: Array, inventoryType: InventoryType, delay: Duration?): GUI<*> { + return LocaleFillGUI(plugin, serverMock, inventoryType, items, delay) + } + + override fun createNonFillGUI(inventoryType: InventoryType): GUI<*> { + return LocaleNonFillGUI(plugin, serverMock, inventoryType) + } + + override fun getFillThreadAfterSuspend(gui: GUI<*>): Thread? { + return (gui as LocaleFillGUI).newThread + } + + override fun getFillThreadBeforeSuspend(gui: GUI<*>): Thread? { + return (gui as LocaleFillGUI).calledThread + } + + @Nested + inner class Register : AbstractGUITest.Register() + + @Nested + inner class Viewers : AbstractGUITest.Viewers() + + @Nested + inner class Contains : AbstractGUITest.Contains() + + @Nested + inner class OpenClient : AbstractGUITest.OpenClient() { + + @Test + fun `should create a new inventory according to the language client`() = runTest { + val type = InventoryType.HOPPER + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + val (player2, client2) = registerPlayer() + languageManager.set(player, SupportedLanguage.ENGLISH) + languageManager.set(player2, SupportedLanguage.FRENCH) + + gui.openClient(client) shouldBe true + gui.openClient(client2) shouldBe true + + player.assertInventoryView(type) + player2.assertInventoryView(type) + + player.openInventory.topInventory shouldNotBe player2.openInventory.topInventory + } + + @Test + fun `should use the same inventory according to the language client`() = runTest { + val type = InventoryType.DISPENSER + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + val (player2, client2) = registerPlayer() + languageManager.set(player, SupportedLanguage.FRENCH) + languageManager.set(player2, SupportedLanguage.FRENCH) + + gui.openClient(client) shouldBe true + gui.openClient(client2) shouldBe true + + player.assertInventoryView(type) + player2.assertInventoryView(type) + + player.openInventory.topInventory shouldBe player2.openInventory.topInventory + } + + @Test + fun `should not create a new inventory for the same client if previously closed`() = runTest { + val type = InventoryType.BREWING + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + val firstInventory = player.openInventory.topInventory + + gui.closeClient(client, true) shouldBe true + + gui.openClient(client) shouldBe true + player.openInventory.topInventory shouldBe firstInventory + + player.assertInventoryView(type) + } + + override fun createDelayGUI( + item1: ItemStack, + item2: ItemStack, + delay: Duration, + inventoryType: InventoryType, + loadingAnimation: InventoryLoadingAnimation? + ): GUI { + return object : AbstractLocaleGUITest(plugin, serverMock, InventoryType.CHEST, loadingAnimation) { + override fun getItems(key: Locale, size: Int): Flow { + return flow { + emit(0 to item1) + delay(1.seconds) + emit(1 to item2) + } + } + } + } + + } + + @Nested + inner class UpdateClient : AbstractGUITest.UpdateClient() + + @Nested + inner class HasInventory : AbstractGUITest.HasInventory() + + @Nested + inner class IsInventoryLoading : AbstractGUITest.IsInventoryLoading() + + @Nested + inner class Close : AbstractGUITest.Close() + + @Nested + inner class CloseClient : AbstractGUITest.CloseClient() { + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `should not stop loading the inventory if the client is viewing the GUI`(closeInventory: Boolean) { + runBlocking { + val type = InventoryType.HOPPER + val gui = createFillGUI(emptyArray(), delay = 10.minutes, inventoryType = type) + gui.register() + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + val openInventory = player.openInventory + val inventory = openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + gui.closeClient(client, closeInventory) shouldBe closeInventory + gui.isInventoryLoading(inventory) shouldBe true + + if (closeInventory) { + player.assertInventoryView(initialInventoryViewType) + gui.contains(client) shouldBe false + } else { + player.assertInventoryView(type) + gui.contains(client) shouldBe true + } + } + } + } +} + +private abstract class AbstractLocaleGUITest( + plugin: Plugin, + val serverMock: ServerMock, + val type: InventoryType = InventoryType.HOPPER, + animation: InventoryLoadingAnimation? = null +) : LocaleGUI(plugin, animation) { + + override suspend fun createInventory(key: Locale): Inventory { + return serverMock.createInventory(null, type) + } + + override suspend fun onClick( + client: Client, + clickedInventory: Inventory, + clickedItem: ItemStack, + event: InventoryClickEvent + ) { + error("Should not be called") + } +} + +private class LocaleNonFillGUI( + plugin: Plugin, + serverMock: ServerMock, + type: InventoryType +) : AbstractLocaleGUITest(plugin, serverMock, type) { + + override fun getItems(key: Locale, size: Int): Flow { + return emptyFlow() + } +} + +private class LocaleFillGUI( + plugin: Plugin, + serverMock: ServerMock, + type: InventoryType, + val items: Array, + val delay: Duration? +) : AbstractLocaleGUITest(plugin, serverMock, type) { + + var calledThread: Thread? = null + + var newThread: Thread? = null + + override fun getItems(key: Locale, size: Int): Flow { + calledThread = Thread.currentThread() + return flow { + delay?.let { delay(it) } + items.forEachIndexed { index, item -> + emit(index to item) + } + newThread = Thread.currentThread() + } + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/PlayerGUITest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/PlayerGUITest.kt new file mode 100644 index 00000000..3672cda5 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/PlayerGUITest.kt @@ -0,0 +1,222 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.ServerMock +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryType +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.InventoryHolder +import org.bukkit.inventory.ItemStack +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class PlayerGUITest : AbstractGUITest() { + + @Nested + inner class Register : AbstractGUITest.Register() + + @Nested + inner class Viewers : AbstractGUITest.Viewers() + + @Nested + inner class Contains : AbstractGUITest.Contains() + + @Nested + inner class OpenClient : AbstractGUITest.OpenClient() { + + @Test + fun `should create a new inventory for the client`() = runTest { + val type = InventoryType.ENDER_CHEST + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + val (player2, client2) = registerPlayer() + + gui.openClient(client) shouldBe true + gui.openClient(client2) shouldBe true + + player.assertInventoryView(type) + player2.assertInventoryView(type) + + player.openInventory.topInventory shouldNotBe player2.openInventory.topInventory + } + + @Test + fun `should create a new inventory for the same client if previous is closed before`() = runTest { + val type = InventoryType.BREWING + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + val firstInventory = player.openInventory.topInventory + + gui.closeClient(client, true) shouldBe true + + gui.openClient(client) shouldBe true + player.openInventory.topInventory shouldNotBe firstInventory + + player.assertInventoryView(type) + } + + override fun createDelayGUI( + item1: ItemStack, + item2: ItemStack, + delay: Duration, + inventoryType: InventoryType, + loadingAnimation: InventoryLoadingAnimation? + ): GUI { + return object : AbstractPlayerGUITest(serverMock, InventoryType.CHEST, loadingAnimation) { + override fun getItems(key: Client, size: Int): Flow { + return flow { + emit(0 to item1) + delay(1.seconds) + emit(1 to item2) + } + } + } + } + } + + @Nested + inner class UpdateClient : AbstractGUITest.UpdateClient() + + @Nested + inner class HasInventory : AbstractGUITest.HasInventory() + + @Nested + inner class IsInventoryLoading : AbstractGUITest.IsInventoryLoading() + + @Nested + inner class Close : AbstractGUITest.Close() + + @Nested + inner class CloseClient : AbstractGUITest.CloseClient() { + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `should stop loading the inventory if the client is viewing the GUI`(closeInventory: Boolean) { + runBlocking { + val type = InventoryType.DROPPER + val gui = createFillGUI(items = emptyArray(), inventoryType = type, delay = 10.minutes) + gui.register() + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + val openInventory = player.openInventory + val inventory = openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + gui.closeClient(client, closeInventory) shouldBe true + gui.isInventoryLoading(inventory) shouldBe false + + if (closeInventory) { + player.assertInventoryView(initialInventoryViewType) + } else { + player.assertInventoryView(type) + } + } + } + + @Test + fun `should remove client inventory without closing it if closeInventory is false`() = + runTest { + val type = InventoryType.ENDER_CHEST + val gui = NonFillGUI(serverMock, type = type) + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + gui.closeClient(client, false) shouldBe true + player.assertInventoryView(type) + + gui.contains(client) shouldBe false + } + } + + override fun createNonFillGUI(inventoryType: InventoryType): GUI<*> { + return NonFillGUI(serverMock, inventoryType) + } + + override fun createFillGUI(items: Array, inventoryType: InventoryType, delay: Duration?): GUI<*> { + return FillGUI(serverMock, inventoryType, items, delay) + } + + override fun getFillThreadBeforeSuspend(gui: GUI<*>): Thread? { + return (gui as FillGUI).calledThread + } + + override fun getFillThreadAfterSuspend(gui: GUI<*>): Thread? { + return (gui as FillGUI).newThread + } +} + +private abstract class AbstractPlayerGUITest( + val serverMock: ServerMock, + val type: InventoryType, + loadingAnimation: InventoryLoadingAnimation? = null +) : PlayerGUI(loadingAnimation) { + + override suspend fun createInventory(owner: InventoryHolder, client: Client): Inventory { + return serverMock.createInventory(owner, type) + } + + override suspend fun onClick( + client: Client, + clickedInventory: Inventory, + clickedItem: ItemStack, + event: InventoryClickEvent + ) { + error("Should not be called") + } +} + +private class NonFillGUI( + serverMock: ServerMock, + type: InventoryType +) : AbstractPlayerGUITest(serverMock, type) { + + override fun getItems(key: Client, size: Int): Flow { + return emptyFlow() + } +} + +private class FillGUI( + serverMock: ServerMock, + type: InventoryType, + val items: Array, + val delay: Duration? +) : AbstractPlayerGUITest(serverMock, type) { + + var calledThread: Thread? = null + + var newThread: Thread? = null + + override fun getItems(key: Client, size: Int): Flow { + calledThread = Thread.currentThread() + return flow { + delay?.let { delay(it) } + items.forEachIndexed { index, item -> + emit(index to item) + } + newThread = Thread.currentThread() + } + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/SingleGUITest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/SingleGUITest.kt new file mode 100644 index 00000000..51601e76 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/SingleGUITest.kt @@ -0,0 +1,244 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.ServerMock +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation +import com.github.rushyverse.api.player.Client +import com.github.shynixn.mccoroutine.bukkit.scope +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.spyk +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryType +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import org.bukkit.plugin.Plugin +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class SingleGUITest : AbstractGUITest() { + + @BeforeTest + override fun onBefore() { + super.onBefore() + mockkStatic("com.github.shynixn.mccoroutine.bukkit.MCCoroutineKt") + every { plugin.scope } returns CoroutineScope(EmptyCoroutineContext) + } + + override fun createNonFillGUI(inventoryType: InventoryType): SingleGUI { + return SingleNonFillGUI(plugin, serverMock, inventoryType) + } + + override fun createFillGUI(items: Array, inventoryType: InventoryType, delay: Duration?): SingleGUI { + return SingleFillGUI(plugin, serverMock, inventoryType, items, delay) + } + + override fun getFillThreadBeforeSuspend(gui: GUI<*>): Thread? { + return (gui as SingleFillGUI).calledThread + } + + override fun getFillThreadAfterSuspend(gui: GUI<*>): Thread? { + return (gui as SingleFillGUI).newThread + } + + @Nested + inner class Register : AbstractGUITest.Register() + + @Nested + inner class Viewers : AbstractGUITest.Viewers() + + @Nested + inner class Contains : AbstractGUITest.Contains() + + @Nested + inner class OpenClient : AbstractGUITest.OpenClient() { + + @Test + fun `should use the same inventory for all clients`() = runTest { + val type = InventoryType.ENDER_CHEST + val gui = createNonFillGUI(type) + val inventories = List(5) { + val (player, client) = registerPlayer() + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + player.openInventory.topInventory + } + + inventories.all { it === inventories.first() } shouldBe true + } + + @Test + fun `should not create a new inventory for the same client if previously closed`() = runTest { + val type = InventoryType.BREWING + val gui = createNonFillGUI(type) + val (player, client) = registerPlayer() + + gui.openClient(client) shouldBe true + val firstInventory = player.openInventory.topInventory + + gui.closeClient(client, true) shouldBe true + + gui.openClient(client) shouldBe true + player.openInventory.topInventory shouldBe firstInventory + + player.assertInventoryView(type) + } + + override fun createDelayGUI( + item1: ItemStack, + item2: ItemStack, + delay: Duration, + inventoryType: InventoryType, + loadingAnimation: InventoryLoadingAnimation? + ): GUI { + return object : AbstractSingleGUITest(plugin, serverMock, InventoryType.CHEST, loadingAnimation) { + override fun getItems(size: Int): Flow { + return flow { + emit(0 to item1) + delay(1.seconds) + emit(1 to item2) + } + } + } + } + } + + @Nested + inner class UpdateClient : AbstractGUITest.UpdateClient() + + @Nested + inner class Update { + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `should call update function with generic key`(boolean: Boolean) = runTest { + val gui = spyk(createNonFillGUI()) { + coEvery { update(Unit, boolean) } returns boolean + } + + gui.update(boolean) shouldBe boolean + coVerify(exactly = 1) { gui.update(Unit, boolean) } + } + + } + + @Nested + inner class HasInventory : AbstractGUITest.HasInventory() + + @Nested + inner class IsInventoryLoading : AbstractGUITest.IsInventoryLoading() + + @Nested + inner class Close : AbstractGUITest.Close() + + @Nested + inner class CloseClient : AbstractGUITest.CloseClient() { + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `should not stop loading the inventory if the client is viewing the GUI`(closeInventory: Boolean) { + runBlocking { + val type = InventoryType.DROPPER + val gui = createFillGUI(emptyArray(), delay = 10.minutes, inventoryType = type) + gui.register() + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + + gui.openClient(client) shouldBe true + player.assertInventoryView(type) + + val openInventory = player.openInventory + val inventory = openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + gui.closeClient(client, closeInventory) shouldBe closeInventory + gui.isInventoryLoading(inventory) shouldBe true + + if (closeInventory) { + player.assertInventoryView(initialInventoryViewType) + gui.contains(client) shouldBe false + } else { + player.assertInventoryView(type) + gui.contains(client) shouldBe true + } + } + } + + } +} + +private abstract class AbstractSingleGUITest( + plugin: Plugin, + val serverMock: ServerMock, + val type: InventoryType, + animation: InventoryLoadingAnimation? = null +) : SingleGUI(plugin, animation) { + + override suspend fun createInventory(): Inventory { + return serverMock.createInventory(null, type) + } + + override suspend fun onClick( + client: Client, + clickedInventory: Inventory, + clickedItem: ItemStack, + event: InventoryClickEvent + ) { + error("Should not be called") + } + +} + +private class SingleNonFillGUI( + plugin: Plugin, + serverMock: ServerMock, + type: InventoryType +) : AbstractSingleGUITest(plugin, serverMock, type) { + + override fun getItems(size: Int): Flow { + return emptyFlow() + } + +} + +private class SingleFillGUI( + plugin: Plugin, + serverMock: ServerMock, + type: InventoryType, + val items: Array, + val delay: Duration? +) : AbstractSingleGUITest(plugin, serverMock, type) { + + var calledThread: Thread? = null + + var newThread: Thread? = null + + override fun getItems(size: Int): Flow { + calledThread = Thread.currentThread() + return flow { + delay?.let { delay(it) } + items.forEachIndexed { index, item -> + emit(index to item) + } + newThread = Thread.currentThread() + } + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimationTest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimationTest.kt new file mode 100644 index 00000000..8c1e8c9c --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimationTest.kt @@ -0,0 +1,140 @@ +package com.github.rushyverse.api.gui.load + +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import java.util.* +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class ShiftInventoryLoadingAnimationTest { + + private lateinit var inventory: Inventory + + @BeforeTest + fun onBefore() { + inventory = mockk { + val contents = arrayOfNulls(9 * 3) + every { size } returns contents.size + every { getContents() } returns contents + every { setContents(any()) } answers { + val items = firstArg>() + items.forEachIndexed { index, itemStack -> + contents[index] = itemStack + } + } + } + } + + @ParameterizedTest + @ValueSource(ints = [0, -1, -2, -3, -4, -5, -6, -7, -8]) + fun `should throw if duration is not positive`(duration: Int) { + shouldThrow { + ShiftInventoryLoadingAnimation( + initialize = { emptySequence() }, + shift = 1, + delay = duration.milliseconds + ) + } + } + + @ParameterizedTest + @ValueSource(ints = [1, 2, 3, 4, 5, 6, 7, 8]) + fun `should not throw if duration is positive`(duration: Int) { + shouldNotThrow { + ShiftInventoryLoadingAnimation( + initialize = { emptySequence() }, + shift = 1, + delay = duration.nanoseconds + ) + } + } + + @Test + fun `should not change inventory if initialize is empty`() { + val delay = 10.milliseconds + val animation = ShiftInventoryLoadingAnimation( + initialize = { emptySequence() }, + shift = 1, + delay = delay + ) + + runBlocking { + val job = launch { animation.loading(Unit, inventory) } + delay(delay * 3) + job.cancelAndJoin() + } + + inventory.contents shouldBe arrayOfNulls(inventory.size) + } + + @Test + fun `should just initialize if shift is zero`() = runBlocking { + shouldJustInitializeWithoutShift(0) + } + + @Test + fun `should just initialize if shift is inventory size`() = runBlocking { + shouldJustInitializeWithoutShift(inventory.size) + } + + private suspend fun shouldJustInitializeWithoutShift(shift: Int) = coroutineScope { + val delay = 10.milliseconds + val items = Array(inventory.size) { mockk() } + + val animation = ShiftInventoryLoadingAnimation( + initialize = { items.asSequence() }, + shift = shift, + delay = delay + ) + + val job = launch { animation.loading(Unit, inventory) } + delay(delay * 3) + job.cancelAndJoin() + + inventory.contents.toList() shouldContainExactly items.toList() + } + + @ParameterizedTest + @ValueSource(ints = [1, 2, 3, 4, 5, 6, 7, 8]) + fun `should shift initialized items`(shift: Int) = runBlocking { + val delay = 100.milliseconds + val items = Array(inventory.size) { mockk() } + val itemList = items.toList() + + val animation = ShiftInventoryLoadingAnimation( + initialize = { items.asSequence() }, + shift = shift, + delay = delay + ) + + val job = launch { animation.loading(Unit, inventory) } + + delay(delay / 2) + repeat(5) { + inventory.contents.toList() shouldContainExactly itemList + delay(delay) + Collections.rotate(itemList, shift) + job.isActive shouldBe true + } + + job.cancelAndJoin() + delay(delay) + // The inventory should not have changed after the animation is finished. + inventory.contents.toList() shouldContainExactly itemList + } +} diff --git a/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt b/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt index 554a6237..205eb4cc 100644 --- a/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt @@ -4,17 +4,29 @@ import be.seeseemelk.mockbukkit.MockBukkit import be.seeseemelk.mockbukkit.ServerMock import be.seeseemelk.mockbukkit.entity.PlayerMock import com.github.rushyverse.api.AbstractKoinTest +import com.github.rushyverse.api.gui.GUI +import com.github.rushyverse.api.gui.GUIManager import com.github.rushyverse.api.player.exception.PlayerNotFoundException -import kotlinx.coroutines.CoroutineScope -import org.junit.jupiter.api.assertThrows +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk import java.util.* import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.* +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows class ClientTest : AbstractKoinTest() { private lateinit var player: PlayerMock private lateinit var serverMock: ServerMock + private lateinit var guiManager: GUIManager @BeforeTest override fun onBefore() { @@ -22,6 +34,11 @@ class ClientTest : AbstractKoinTest() { serverMock = MockBukkit.mock().apply { player = addPlayer() } + guiManager = GUIManager() + + loadApiTestModule { + single { guiManager } + } } @AfterTest @@ -30,29 +47,66 @@ class ClientTest : AbstractKoinTest() { super.onAfter() } - @Test - fun `retrieve player instance not found returns null`() { - val client = Client(UUID.randomUUID(), CoroutineScope(EmptyCoroutineContext)) - assertNull(client.player) - } + @Nested + inner class GetPlayer { - @Test - fun `retrieve player instance found returns the instance`() { - val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) - assertEquals(player, client.player) - } + @Test + fun `retrieve player instance not found returns null`() { + val client = Client(UUID.randomUUID(), CoroutineScope(EmptyCoroutineContext)) + assertNull(client.player) + } + + @Test + fun `retrieve player instance found returns the instance`() { + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + assertEquals(player, client.player) + } - @Test - fun `require player instance not found throws an exception`() { - val client = Client(UUID.randomUUID(), CoroutineScope(EmptyCoroutineContext)) - assertThrows { - client.requirePlayer() + @Test + fun `require player instance not found throws an exception`() { + val client = Client(UUID.randomUUID(), CoroutineScope(EmptyCoroutineContext)) + assertThrows { + client.requirePlayer() + } } + + @Test + fun `require player instance found returns the instance`() { + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + assertEquals(player, client.requirePlayer()) + } + } - @Test - fun `require player instance found returns the instance`() { - val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) - assertEquals(player, client.requirePlayer()) + @Nested + inner class GetGUI { + + @Test + fun `get GUI returns null if no GUI is registered`() = runTest { + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + client.gui() shouldBe null + } + + @Test + fun `get GUI returns null if no GUI contains the client`() = runTest { + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + val gui = mockk> { + coEvery { contains(client) } returns false + } + guiManager.add(gui) + client.gui() shouldBe null + } + + @Test + fun `get GUI returns GUI if contains the client`() = runTest { + val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) + val gui = mockk> { + coEvery { contains(client) } returns true + } + guiManager.add(gui) + client.gui() shouldBe gui + } + } + }