diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/DedicatedGUI.kt b/src/main/kotlin/com/github/rushyverse/api/gui/DedicatedGUI.kt deleted file mode 100644 index 164341ba..00000000 --- a/src/main/kotlin/com/github/rushyverse/api/gui/DedicatedGUI.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.github.rushyverse.api.gui - -import com.github.rushyverse.api.player.Client -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.bukkit.entity.HumanEntity -import org.bukkit.inventory.Inventory - -/** - * GUI where a new inventory is created for each key used. - * @param T Type of the key. - * @property inventories Map of inventories for each key. - * @property mutex Mutex to process thread-safe operations. - */ -public abstract class DedicatedGUI : GUI() { - - protected var inventories: MutableMap = mutableMapOf() - - protected val mutex: Mutex = Mutex() - - override suspend fun openGUI(client: Client): Boolean { - val key = getKey(client) - val inventory = getOrCreateInventory(key) - - val player = client.requirePlayer() - player.openInventory(inventory) - - 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 { - inventories[key] ?: createInventory(key).also { - inventories[key] = it - fill(key, it) - } - } - } - - /** - * 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 - - /** - * 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 - - /** - * Fill the inventory for the key. - * @param key Key to fill the inventory for. - * @param inventory Inventory to fill. - */ - protected abstract suspend fun fill(key: T, inventory: Inventory) - - override suspend fun hasInventory(inventory: Inventory): Boolean { - return mutex.withLock { - inventories.values.contains(inventory) - } - } - - override suspend fun viewers(): List { - 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(): List { - return inventories.values.flatMap(Inventory::getViewers) - } - - override 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 inventories.values.any { it.viewers.contains(player) } - } - - override suspend fun close() { - super.close() - mutex.withLock { - inventories.values.forEach(Inventory::close) - inventories.clear() - } - } -} diff --git a/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt b/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt index ced3136c..9f037538 100644 --- a/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt +++ b/src/main/kotlin/com/github/rushyverse/api/gui/GUI.kt @@ -1,26 +1,67 @@ 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.cancellable +import kotlinx.coroutines.flow.onCompletion +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) : RuntimeException(message) +public open class GUIException(message: String) : CancellationException(message) /** * Exception thrown when the GUI is closed. */ public class GUIClosedException(message: String) : GUIException(message) +/** + * 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. @@ -28,18 +69,35 @@ public class GUIClosedException(message: String) : GUIException(message) * @property manager Manager to register or unregister the GUI. * @property isClosed If true, the GUI is closed; otherwise it is open. */ -public abstract class GUI { +public abstract class GUI( + private val loadingAnimation: InventoryLoadingAnimation? = null, + initialNumberInventories: Int = 16, +) { protected val server: Server by inject() - private val manager: GUIManager by inject() + protected val manager: GUIManager by inject() public var isClosed: Boolean = false protected set - init { - register() - } + 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. @@ -51,11 +109,6 @@ public abstract class GUI { public suspend fun open(client: Client): Boolean { requireOpen() - val gui = client.gui() - if (gui === this) return false - // If the client has another GUI opened, close it. - gui?.close(client, true) - val player = client.player if (player === null) { logger.warn { "Cannot open inventory for player ${client.playerUUID}: player is null" } @@ -64,39 +117,174 @@ public abstract class GUI { // If the player is dead, do not open the GUI because the interface cannot be shown to the player. if (player.isDead) return false - return openGUI(client) + 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. + close(client, false) + return false + } + + return true } /** - * Open the GUI for the client. - * Called by [open] after all the checks. - * @param client Client to open the GUI for. - * @return True if the GUI was opened, false otherwise. + * 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. */ - protected abstract suspend fun openGUI(client: Client): Boolean + 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 + } + } /** - * 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. - * @param event Event of the click. + * 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 { + // 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 size = inventory.size + 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. */ - public abstract suspend fun onClick(client: Client, clickedItem: ItemStack, event: InventoryClickEvent) + protected abstract fun createInventory(key: T): Inventory /** - * 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. + * 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. */ - public abstract suspend fun close(client: Client, closeInventory: Boolean = true): Boolean + 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 abstract suspend fun hasInventory(inventory: Inventory): Boolean + 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. @@ -106,8 +294,27 @@ public abstract class GUI { public open suspend fun close() { isClosed = true unregister() + + mutex.withLock { + inventories.values.forEach { + it.job.apply { + cancel(GUIClosedException("The GUI is closing")) + join() + } + it.inventory.close() + } + inventories.clear() + } } + /** + * 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 close(client: Client, closeInventory: Boolean = true): Boolean + /** * Verify that the GUI is open. * If the GUI is closed, throw an exception. @@ -116,33 +323,35 @@ public abstract class GUI { if (isClosed) throw GUIClosedException("Cannot use a closed GUI") } - /** - * Get the viewers of the GUI. - * @return List of viewers. - */ - public abstract suspend fun viewers(): List - - /** - * Check if the GUI contains the player. - * @param client Client to check. - * @return True if the GUI contains the player, false otherwise. - */ - public abstract suspend fun contains(client: Client): Boolean - /** * Register the GUI to the listener. * @return True if the GUI was registered, false otherwise. */ - protected fun register(): Boolean { + public open suspend fun register(): Boolean { + requireOpen() return manager.add(this) } /** * Unregister the GUI from the listener. + * Should be called when the GUI is closed with [close]. * @return True if the GUI was unregistered, false otherwise. */ - protected fun unregister(): Boolean { + 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 index af3cebc9..8053552f 100644 --- a/src/main/kotlin/com/github/rushyverse/api/gui/GUIListener.kt +++ b/src/main/kotlin/com/github/rushyverse/api/gui/GUIListener.kt @@ -17,7 +17,6 @@ import org.bukkit.event.block.Action import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.event.player.PlayerInteractEvent -import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack import org.bukkit.inventory.PlayerInventory @@ -42,8 +41,10 @@ public class GUIListener(private val plugin: Plugin) : Listener { 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 @@ -75,7 +76,7 @@ public class GUIListener(private val plugin: Plugin) : Listener { // The item in a GUI is not supposed to be moved event.cancel() - gui.onClick(client, item, event) + gui.onClick(client, clickedInventory, item, event) } /** @@ -116,27 +117,10 @@ public class GUIListener(private val plugin: Plugin) : Listener { */ @EventHandler public suspend fun onInventoryClose(event: InventoryCloseEvent) { - quitOpenedGUI(event.player) - } - - /** - * Called when a player quits the server. - * If the player has a GUI opened, the GUI is notified that it is closed for this player. - * @param event Event of the quit. - */ - @EventHandler - public suspend fun onPlayerQuit(event: PlayerQuitEvent) { - quitOpenedGUI(event.player) - } - - /** - * Quit the opened GUI for the player. - * @param player Player to quit the GUI for. - */ - private suspend fun quitOpenedGUI(player: HumanEntity) { - val client = clients.getClientOrNull(player) + val client = clients.getClientOrNull(event.player) 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.close(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 index f110d828..fc720077 100644 --- a/src/main/kotlin/com/github/rushyverse/api/gui/GUIManager.kt +++ b/src/main/kotlin/com/github/rushyverse/api/gui/GUIManager.kt @@ -1,6 +1,8 @@ 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. @@ -8,15 +10,20 @@ import com.github.rushyverse.api.player.Client */ public class GUIManager { + /** + * Mutex used to ensure thread-safe operations. + */ + private val mutex = Mutex() + /** * Private mutable set storing GUIs. */ - private val _guis = mutableSetOf() + private val _guis = mutableSetOf>() /** * Immutable view of the GUIs set. */ - public val guis: Collection get() = _guis + public val guis: Collection> get() = _guis /** * Retrieves the GUI for the specified player. @@ -25,8 +32,10 @@ public class GUIManager { * @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 _guis.firstOrNull { it.contains(client) } + public suspend fun get(client: Client): GUI<*>? { + return mutex.withLock { + guis.firstOrNull { it.contains(client) } + } } /** @@ -34,8 +43,8 @@ public class GUIManager { * @param gui GUI to add. * @return True if the GUI was added, false otherwise. */ - public fun add(gui: GUI): Boolean { - return _guis.add(gui) + public suspend fun add(gui: GUI<*>): Boolean { + return mutex.withLock { _guis.add(gui) } } /** @@ -43,7 +52,7 @@ public class GUIManager { * @param gui GUI to remove. * @return True if the GUI was removed, false otherwise. */ - public fun remove(gui: GUI): Boolean { - return _guis.remove(gui) + 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 index 9c004504..4eaf1977 100644 --- a/src/main/kotlin/com/github/rushyverse/api/gui/LocaleGUI.kt +++ b/src/main/kotlin/com/github/rushyverse/api/gui/LocaleGUI.kt @@ -1,9 +1,16 @@ 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.sync.withLock +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]. @@ -12,22 +19,28 @@ import org.bukkit.event.inventory.InventoryCloseEvent * 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 : DedicatedGUI() { +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 close(client: Client, closeInventory: Boolean): Boolean { - if (!closeInventory) { - return false - } + override suspend fun fillScope(key: Locale): CoroutineScope { + val scope = plugin.scope + return scope + SupervisorJob(scope.coroutineContext.job) + } - return mutex.withLock { - if (unsafeContains(client)) { - client.player?.closeInventory(InventoryCloseEvent.Reason.PLUGIN) - true - } else false - } + override suspend fun close(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 index cd3524b9..d5a096fc 100644 --- a/src/main/kotlin/com/github/rushyverse/api/gui/PlayerGUI.kt +++ b/src/main/kotlin/com/github/rushyverse/api/gui/PlayerGUI.kt @@ -1,8 +1,12 @@ 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.event.inventory.InventoryCloseEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.InventoryHolder @@ -10,19 +14,25 @@ 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 : DedicatedGUI() { +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) + } + /** * 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 { + override fun createInventory(key: Client): Inventory { val player = key.requirePlayer() return createInventory(player, key) } @@ -42,11 +52,16 @@ public abstract class PlayerGUI : DedicatedGUI() { } override suspend fun close(client: Client, closeInventory: Boolean): Boolean { - return mutex.withLock { inventories.remove(client) }?.run { - if (closeInventory) { - client.player?.closeInventory(InventoryCloseEvent.Reason.PLUGIN) - } - true - } == true + 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 index 167c74d5..3ec24c71 100644 --- a/src/main/kotlin/com/github/rushyverse/api/gui/SingleGUI.kt +++ b/src/main/kotlin/com/github/rushyverse/api/gui/SingleGUI.kt @@ -1,86 +1,71 @@ package com.github.rushyverse.api.gui +import com.github.rushyverse.api.gui.load.InventoryLoadingAnimation import com.github.rushyverse.api.player.Client -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.bukkit.entity.HumanEntity +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. - * @property inventory Inventory shared by all the viewers. */ -public abstract class SingleGUI : GUI() { +public abstract class SingleGUI( + protected val plugin: Plugin, + loadingAnimation: InventoryLoadingAnimation? = null +) : GUI( + loadingAnimation = loadingAnimation, + initialNumberInventories = 1 +) { - private var inventory: Inventory? = null + 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 = Any() + } - private val mutex = Mutex() + override suspend fun getKey(client: Client): Any { + return KEY + } - override suspend fun openGUI(client: Client): Boolean { - val player = client.requirePlayer() - val inventory = getOrCreateInventory() - player.openInventory(inventory) - return true + override suspend fun fillScope(key: Any): CoroutineScope { + val scope = plugin.scope + return scope + SupervisorJob(scope.coroutineContext.job) } - /** - * Get the inventory of the GUI. - * If the inventory is not created, create it. - * @return The inventory of the GUI. - */ - private suspend fun getOrCreateInventory(): Inventory { - return mutex.withLock { - inventory ?: createInventory().also { - inventory = it - fill(it) - } - } + override fun createInventory(key: Any): Inventory { + return createInventory() } /** - * Create the inventory of the GUI. - * This function is called only once when the inventory is created. - * @return A new inventory. + * @see createInventory(key) */ protected abstract fun createInventory(): Inventory override suspend fun close(client: Client, closeInventory: Boolean): Boolean { return if (closeInventory && contains(client)) { - client.player?.closeInventory() + client.player?.closeInventory(InventoryCloseEvent.Reason.PLUGIN) true } else false } - override suspend fun viewers(): List { - return mutex.withLock { inventory?.viewers } ?: emptyList() - } - - override suspend fun contains(client: Client): Boolean { - return client.player?.let { it in viewers() } == true - } - - override suspend fun hasInventory(inventory: Inventory): Boolean { - return mutex.withLock { this.inventory } == inventory - } - - override suspend fun close() { - super.close() - mutex.withLock { - val inventory = inventory - if (inventory != null) { - inventory.close() - this.inventory = null - } - } + override fun getItems(key: Any, size: Int): Flow { + return getItems(size) } /** - * Fill the inventory with items for the client. - * This function is called when the inventory is created. - * @param inventory The inventory to fill. + * @see getItems(key, size) */ - protected abstract suspend fun fill(inventory: Inventory) + 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..14f95f3e --- /dev/null +++ b/src/main/kotlin/com/github/rushyverse/api/gui/load/ShiftInventoryLoadingAnimation.kt @@ -0,0 +1,48 @@ +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, + private val shift: Int = 1, + private val delay: Duration = 100.milliseconds, +) : InventoryLoadingAnimation { + + override suspend fun loading(key: T, inventory: Inventory) { + coroutineScope { + 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 + } + + 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 bb6ff06f..22c9cefe 100644 --- a/src/main/kotlin/com/github/rushyverse/api/player/Client.kt +++ b/src/main/kotlin/com/github/rushyverse/api/player/Client.kt @@ -81,6 +81,6 @@ public open class Client( * Get the opened GUI of the player. * @return The opened GUI of the player. */ - public suspend fun gui(): GUI? = guiManager.get(this) + public suspend fun gui(): GUI<*>? = guiManager.get(this) } diff --git a/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt b/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt index df562752..fef9664c 100644 --- a/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/AbstractKoinTest.kt @@ -5,6 +5,7 @@ import com.github.rushyverse.api.koin.loadModule import com.github.rushyverse.api.utils.randomString import io.mockk.every import io.mockk.mockk +import io.mockk.unmockkAll import kotlin.test.AfterTest import kotlin.test.BeforeTest import org.bukkit.Server @@ -47,6 +48,7 @@ open class AbstractKoinTest { 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/gui/AbstractGUITest.kt b/src/test/kotlin/com/github/rushyverse/api/gui/AbstractGUITest.kt new file mode 100644 index 00000000..abf9ff48 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/AbstractGUITest.kt @@ -0,0 +1,373 @@ +package com.github.rushyverse.api.gui + +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.extension.ItemStack +import com.github.rushyverse.api.player.Client +import com.github.rushyverse.api.player.ClientManager +import com.github.rushyverse.api.player.ClientManagerImpl +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +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 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.ItemStack + +abstract class AbstractGUITest : AbstractKoinTest() { + + protected lateinit var guiManager: GUIManager + protected lateinit var clientManager: ClientManager + protected lateinit var serverMock: ServerMock + + @BeforeTest + override fun onBefore() { + super.onBefore() + guiManager = GUIManager() + clientManager = ClientManagerImpl() + + loadApiTestModule { + single { guiManager } + single { clientManager } + } + + serverMock = MockBukkit.mock() + } + + @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 throw exception if GUI is closed`() = runTest { + val gui = createNonFillGUI() + gui.close() + shouldThrow { gui.register() } + } + } + + 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.open(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.open(client) shouldBe true + gui.contains(client) shouldBe true + } + + } + + abstract inner class Open { + + @Test + fun `should throw exception if GUI is closed`() = runTest { + val gui = createNonFillGUI() + gui.close() + val (player, client) = registerPlayer() + + val initialInventoryViewType = player.openInventory.type + shouldThrow { gui.open(client) } + player.assertInventoryView(initialInventoryViewType) + } + + @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.open(client) shouldBe true + player.assertInventoryView(type) + + gui.open(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.open(client) shouldBe false + player.assertInventoryView(initialInventoryViewType) + } + + @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.open(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.open(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 + } + } + + } + + 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 + + playerClients.forEach { (player, client) -> + player.assertInventoryView(initialInventoryViewType) + gui.open(client) shouldBe true + player.assertInventoryView(type) + client.gui() shouldBe gui + } + + gui.close() + playerClients.forEach { (player, client) -> + player.assertInventoryView(initialInventoryViewType) + client.gui() shouldBe null + } + } + + @Test + fun `should set isClosed to true`() = runTest { + val gui = createNonFillGUI() + gui.isClosed shouldBe false + gui.close() + gui.isClosed shouldBe true + } + + @Test + fun `should unregister the GUI`() = runTest { + val gui = createNonFillGUI() + gui.register() + guiManager.guis shouldContainAll listOf(gui) + gui.close() + guiManager.guis shouldContainAll listOf() + } + + @Test + fun `should not be able to open the GUI after closing it`() = runTest { + val gui = createNonFillGUI() + gui.register() + val (_, client) = registerPlayer() + gui.close() + + shouldThrow { + gui.open(client) + } + } + + @Test + fun `should not be able to register the GUI after closing it`() = runTest { + val gui = createNonFillGUI() + gui.close() + shouldThrow { + gui.register() + } + } + } + + abstract inner class CloseForClient { + + @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.close(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.open(client) shouldBe true + player.assertInventoryView(type) + gui.close(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.open(client) shouldBe true + gui.open(client2) shouldBe true + + player.assertInventoryView(type) + player2.assertInventoryView(type) + + gui.close(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(): Pair { + val player = 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 index 54900bb5..6d9f6e27 100644 --- a/src/test/kotlin/com/github/rushyverse/api/gui/GUIListenerTest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/gui/GUIListenerTest.kt @@ -27,14 +27,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest -import net.kyori.adventure.text.Component import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.event.block.Action import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.event.player.PlayerInteractEvent -import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack import org.junit.jupiter.api.Nested @@ -80,7 +78,7 @@ class GUIListenerTest : AbstractKoinTest() { } callEvent(true, player, ItemStack { type = Material.DIRT }, player.inventory) - coVerify(exactly = 0) { gui.onClick(any(), any(), any()) } + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } } @Test @@ -92,7 +90,7 @@ class GUIListenerTest : AbstractKoinTest() { suspend fun callEvent(item: ItemStack?) { val event = callEvent(false, player, item, player.inventory) - coVerify(exactly = 0) { gui.onClick(any(), any(), any()) } + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } verify(exactly = 0) { event.cancel() } } @@ -114,7 +112,7 @@ class GUIListenerTest : AbstractKoinTest() { val item = ItemStack { type = Material.DIRT } callEvent(false, player, item, mockk()) - coVerify(exactly = 0) { gui.onClick(any(), any(), any()) } + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } verify(exactly = 0) { pluginManager.callSuspendingEvent(any(), plugin) } } @@ -136,7 +134,7 @@ class GUIListenerTest : AbstractKoinTest() { val item = ItemStack { type = Material.DIRT } callEvent(false, player, item, player.inventory) - coVerify(exactly = 0) { gui.onClick(any(), any(), any()) } + coVerify(exactly = 0) { gui.onClick(any(), any(), any(), any()) } verify(exactly = 1) { pluginManager.callSuspendingEvent(any(), plugin) } jobs.forEach { it.isCompleted shouldBe true } @@ -153,13 +151,13 @@ class GUIListenerTest : AbstractKoinTest() { val inventory = mockk() val gui = registerGUI { coEvery { contains(client) } returns true - coEvery { onClick(client, any(), any()) } returns Unit + 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, item, event) } + coVerify(exactly = 1) { gui.onClick(client, inventory, item, event) } verify(exactly = 1) { event.cancel() } } @@ -183,7 +181,8 @@ class GUIListenerTest : AbstractKoinTest() { } - abstract inner class CloseGUIDuringEvent { + @Nested + inner class OnInventoryClose { @Test fun `should do nothing if client doesn't have a GUI opened`() = runTest { @@ -192,9 +191,7 @@ class GUIListenerTest : AbstractKoinTest() { coEvery { contains(client) } returns false } - val event = PlayerQuitEvent(player, Component.empty(), PlayerQuitEvent.QuitReason.DISCONNECTED) - listener.onPlayerQuit(event) - + callEvent(player) coVerify(exactly = 0) { gui.close(client, any()) } } @@ -216,14 +213,7 @@ class GUIListenerTest : AbstractKoinTest() { coVerify(exactly = 0) { gui2.close(client, any()) } } - protected abstract suspend fun callEvent(player: Player) - - } - - @Nested - inner class OnInventoryClose : CloseGUIDuringEvent() { - - override suspend fun callEvent(player: Player) { + private suspend fun callEvent(player: Player) { val event = mockk { every { getPlayer() } returns player } @@ -231,17 +221,6 @@ class GUIListenerTest : AbstractKoinTest() { } } - @Nested - inner class OnPlayerQuit : CloseGUIDuringEvent() { - - override suspend fun callEvent(player: Player) { - val event = mockk { - every { getPlayer() } returns player - } - listener.onPlayerQuit(event) - } - } - private suspend fun registerPlayer(): Pair { val player = serverMock.addPlayer() val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) @@ -249,8 +228,8 @@ class GUIListenerTest : AbstractKoinTest() { return player to client } - private inline fun registerGUI(block: GUI.() -> Unit): GUI { - val gui = mockk(block = block) + 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 index e861e595..04c2c6b0 100644 --- a/src/test/kotlin/com/github/rushyverse/api/gui/GUIManagerTest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/gui/GUIManagerTest.kt @@ -30,7 +30,7 @@ class GUIManagerTest { @Test fun `should returns null if no GUI contains the client`() = runTest { val client = mockk() - val gui = mockk { + val gui = mockk> { coEvery { contains(any()) } returns false } manager.add(gui) @@ -40,7 +40,7 @@ class GUIManagerTest { @Test fun `should returns GUI if contains the client`() = runTest { val client = mockk() - val gui = mockk { + val gui = mockk> { coEvery { contains(client) } returns true } manager.add(gui) @@ -51,7 +51,7 @@ class GUIManagerTest { fun `should returns GUI if contains the asked client`() = runTest { val client = mockk() val client2 = mockk() - val gui = mockk { + val gui = mockk> { coEvery { contains(client) } returns true coEvery { contains(client2) } returns false } @@ -66,16 +66,16 @@ class GUIManagerTest { inner class Add { @Test - fun `should add non registered GUI`() { - val gui = mockk() + 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`() { - val gui = mockk() + 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 @@ -83,9 +83,9 @@ class GUIManagerTest { } @Test - fun `should add multiple GUIs`() { - val gui1 = mockk() - val gui2 = mockk() + 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 @@ -99,8 +99,8 @@ class GUIManagerTest { inner class Remove { @Test - fun `should remove registered GUI`() { - val gui = mockk() + 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 @@ -108,17 +108,17 @@ class GUIManagerTest { } @Test - fun `should not remove non registered GUI`() { - val gui = mockk() + 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`() { - val gui1 = mockk() - val gui2 = mockk() + 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 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..3e0fdce6 --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/LocalePlayerGUITest.kt @@ -0,0 +1,226 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.ServerMock +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 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 Open : AbstractGUITest.Open() { + + @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.open(client) shouldBe true + gui.open(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.open(client) shouldBe true + gui.open(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.open(client) shouldBe true + val firstInventory = player.openInventory.topInventory + + gui.close(client, true) shouldBe true + + gui.open(client) shouldBe true + player.openInventory.topInventory shouldBe firstInventory + + player.assertInventoryView(type) + } + + } + + @Nested + inner class Close : AbstractGUITest.Close() + + @Nested + inner class CloseForClient : AbstractGUITest.CloseForClient() { + + @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.open(client) shouldBe true + player.assertInventoryView(type) + + val openInventory = player.openInventory + val inventory = openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + gui.close(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 +) : LocaleGUI(plugin) { + + override 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 index 949d4d15..6f5877d4 100644 --- a/src/test/kotlin/com/github/rushyverse/api/gui/PlayerGUITest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/gui/PlayerGUITest.kt @@ -1,309 +1,192 @@ package com.github.rushyverse.api.gui -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.extension.ItemStack import com.github.rushyverse.api.player.Client -import com.github.rushyverse.api.player.ClientManager -import com.github.rushyverse.api.player.ClientManagerImpl -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.collections.shouldContainAll -import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -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.minutes -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.Material 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 : AbstractKoinTest() { - - private lateinit var guiManager: GUIManager - private lateinit var clientManager: ClientManager - private lateinit var serverMock: ServerMock - - @BeforeTest - override fun onBefore() { - super.onBefore() - guiManager = GUIManager() - clientManager = ClientManagerImpl() - - loadApiTestModule { - single { guiManager } - single { clientManager } - } - - serverMock = MockBukkit.mock() - } - - @AfterTest - override fun onAfter() { - super.onAfter() - MockBukkit.unmock() - } +class PlayerGUITest : AbstractGUITest() { @Nested - inner class Open { - - @Test - fun `should throw exception if GUI is closed`() = runTest { - val gui = TestGUI(serverMock) - gui.close() - val (player, client) = registerPlayer() - - val initialInventoryViewType = player.openInventory.type - shouldThrow { gui.open(client) } - player.assertInventoryView(initialInventoryViewType) - } + inner class Register : AbstractGUITest.Register() - @Test - fun `should do nothing if the client has the same GUI opened`() = runTest { - val gui = TestGUI(serverMock) - val (player, client) = registerPlayer() - - gui.open(client) shouldBe true - player.assertInventoryView(gui.type) - - gui.open(client) shouldBe false - player.assertInventoryView(gui.type) - } - - @Test - fun `should close the previous GUI if the client has one opened`() = runTest { - val gui = TestGUI(serverMock, InventoryType.ENDER_CHEST) - val (player, client) = registerPlayer() - - gui.open(client) shouldBe true - player.assertInventoryView(gui.type) - - val gui2 = TestGUI(serverMock, InventoryType.CHEST) - gui2.open(client) shouldBe true - player.assertInventoryView(gui2.type) - gui.contains(client) shouldBe false - gui2.contains(client) shouldBe true - } - - @Test - fun `should do nothing if the player is dead`() = runTest { - val gui = TestGUI(serverMock) - val (player, client) = registerPlayer() + @Nested + inner class Viewers : AbstractGUITest.Viewers() - val initialInventoryViewType = player.openInventory.type + @Nested + inner class Contains : AbstractGUITest.Contains() - player.damage(Double.MAX_VALUE) - gui.open(client) shouldBe false - player.assertInventoryView(initialInventoryViewType) - } + @Nested + inner class Open : AbstractGUITest.Open() { @Test fun `should create a new inventory for the client`() = runTest { - val gui = TestGUI(serverMock) + val type = InventoryType.ENDER_CHEST + val gui = createNonFillGUI(type) val (player, client) = registerPlayer() val (player2, client2) = registerPlayer() gui.open(client) shouldBe true gui.open(client2) shouldBe true - player.assertInventoryView(gui.type) - player2.assertInventoryView(gui.type) + player.assertInventoryView(type) + player2.assertInventoryView(type) player.openInventory.topInventory shouldNotBe player2.openInventory.topInventory } @Test - fun `should fill the inventory`() = runTest { - val gui = TestFilledGUI(serverMock) + 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.open(client) shouldBe true - player.assertInventoryView(InventoryType.CHEST) - - val inventory = player.openInventory.topInventory - val content = inventory.contents - println(content.contentToString()) - content[0]!!.type shouldBe Material.DIAMOND_ORE - content[1]!!.type shouldBe Material.STICK - - for (i in 2 until content.size) { - content[i] shouldBe null - } - } - - } + val firstInventory = player.openInventory.topInventory - @Nested - inner class Viewers { - - @Test - fun `should return empty list if no client is viewing the GUI`() = runTest { - val gui = TestGUI(serverMock) - gui.viewers() shouldBe emptyList() - } - - @Test - fun `should return the list of clients viewing the GUI`() = runTest { - val gui = TestGUI(serverMock) - val playerClients = List(5) { registerPlayer() } + gui.close(client, true) shouldBe true - playerClients.forEach { (_, client) -> - gui.open(client) shouldBe true - } + gui.open(client) shouldBe true + player.openInventory.topInventory shouldNotBe firstInventory - gui.viewers() shouldContainExactlyInAnyOrder playerClients.map { it.first } + player.assertInventoryView(type) } - } @Nested - inner class Contains { - - @Test - fun `should return false if the client is not viewing the GUI`() = runTest { - val gui = TestGUI(serverMock) - val (_, client) = registerPlayer() - gui.contains(client) shouldBe false - } - - @Test - fun `should return true if the client is viewing the GUI`() = runTest { - val gui = TestGUI(serverMock) - val (_, client) = registerPlayer() - gui.open(client) shouldBe true - gui.contains(client) shouldBe true - } - - } + inner class Close : AbstractGUITest.Close() @Nested - inner class CloseForClient { - - @Test - fun `should return false if the client is not viewing the GUI`() = runTest(timeout = 1.minutes) { - val gui = TestGUI(serverMock) - val (player, client) = registerPlayer() + inner class CloseForClient : AbstractGUITest.CloseForClient() { + + @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 + val initialInventoryViewType = player.openInventory.type - player.assertInventoryView(initialInventoryViewType) - gui.close(client, true) shouldBe false - player.assertInventoryView(initialInventoryViewType) - } + gui.open(client) shouldBe true + player.assertInventoryView(type) - @Test - fun `should close the inventory if the client is viewing the GUI`() = runTest(timeout = 1.minutes) { - val gui = TestGUI(serverMock) - val (player, client) = registerPlayer() + val openInventory = player.openInventory + val inventory = openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true - val initialInventoryViewType = player.openInventory.type + gui.close(client, closeInventory) shouldBe true + gui.isInventoryLoading(inventory) shouldBe false - gui.open(client) shouldBe true - player.assertInventoryView(gui.type) - gui.close(client, true) shouldBe true - player.assertInventoryView(initialInventoryViewType) + if (closeInventory) { + player.assertInventoryView(initialInventoryViewType) + } else { + player.assertInventoryView(type) + } + } } @Test fun `should remove client inventory without closing it if closeInventory is false`() = - runTest(timeout = 1.minutes) { - val gui = TestGUI(serverMock) + runTest { + val type = InventoryType.ENDER_CHEST + val gui = NonFillGUI(serverMock, type = type) val (player, client) = registerPlayer() gui.open(client) shouldBe true - player.assertInventoryView(gui.type) + player.assertInventoryView(type) + gui.close(client, false) shouldBe true - player.assertInventoryView(gui.type) + player.assertInventoryView(type) + gui.contains(client) shouldBe false } - } - @Nested - inner class Close { - - @Test - fun `should close all inventories and remove all viewers`() = runTest(timeout = 1.minutes) { - val gui = TestGUI(serverMock, InventoryType.BREWING) - - val playerClients = List(5) { registerPlayer() } - val initialInventoryViewType = playerClients.first().first.openInventory.type - - playerClients.forEach { (player, client) -> - player.assertInventoryView(initialInventoryViewType) - gui.open(client) shouldBe true - player.assertInventoryView(gui.type) - client.gui() shouldBe gui - } - - gui.close() - playerClients.forEach { (player, client) -> - player.assertInventoryView(initialInventoryViewType) - client.gui() shouldBe null - } - } - - @Test - fun `should set isClosed to true`() = runTest { - val gui = TestGUI(serverMock) - gui.isClosed shouldBe false - gui.close() - gui.isClosed shouldBe true - } + override fun createNonFillGUI(inventoryType: InventoryType): GUI<*> { + return NonFillGUI(serverMock, inventoryType) + } - @Test - fun `should unregister the GUI`() = runTest { - val gui = TestGUI(serverMock) - guiManager.guis shouldContainAll listOf(gui) - gui.close() - guiManager.guis shouldContainAll listOf() - } + 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 } - private suspend fun registerPlayer(): Pair { - val player = serverMock.addPlayer() - val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) - clientManager.put(player, client) - return player to client + override fun getFillThreadAfterSuspend(gui: GUI<*>): Thread? { + return (gui as FillGUI).newThread } } -private class TestGUI(val serverMock: ServerMock, val type: InventoryType = InventoryType.HOPPER) : PlayerGUI() { +private abstract class AbstractPlayerGUITest( + val serverMock: ServerMock, + val type: InventoryType +) : PlayerGUI() { + override fun createInventory(owner: InventoryHolder, client: Client): Inventory { return serverMock.createInventory(owner, type) } - override suspend fun fill(client: Client, inventory: Inventory) { - // Do nothing - } - - override suspend fun onClick(client: Client, clickedItem: ItemStack, event: InventoryClickEvent) { + override suspend fun onClick( + client: Client, + clickedInventory: Inventory, + clickedItem: ItemStack, + event: InventoryClickEvent + ) { error("Should not be called") } } -private class TestFilledGUI(val serverMock: ServerMock) : PlayerGUI() { - override fun createInventory(owner: InventoryHolder, client: Client): Inventory { - return serverMock.createInventory(owner, InventoryType.CHEST) - } +private class NonFillGUI( + serverMock: ServerMock, + type: InventoryType +) : AbstractPlayerGUITest(serverMock, type) { - override suspend fun fill(client: Client, inventory: Inventory) { - inventory.setItem(0, ItemStack { type = Material.DIAMOND_ORE }) - inventory.setItem(1, ItemStack { type = Material.STICK }) + override fun getItems(key: Client, size: Int): Flow { + return emptyFlow() } +} - override suspend fun onClick(client: Client, clickedItem: ItemStack, event: InventoryClickEvent) { - error("Should not be called") +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..0cd3c26f --- /dev/null +++ b/src/test/kotlin/com/github/rushyverse/api/gui/SingleGUITest.kt @@ -0,0 +1,195 @@ +package com.github.rushyverse.api.gui + +import be.seeseemelk.mockbukkit.ServerMock +import com.github.rushyverse.api.player.Client +import com.github.shynixn.mccoroutine.bukkit.scope +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockkStatic +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +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): GUI<*> { + return SingleNonFillGUI(plugin, serverMock, inventoryType) + } + + override fun createFillGUI(items: Array, inventoryType: InventoryType, delay: Duration?): GUI<*> { + 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 Open : AbstractGUITest.Open() { + + @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.open(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.open(client) shouldBe true + val firstInventory = player.openInventory.topInventory + + gui.close(client, true) shouldBe true + + gui.open(client) shouldBe true + player.openInventory.topInventory shouldBe firstInventory + + player.assertInventoryView(type) + } + } + + @Nested + inner class Close : AbstractGUITest.Close() + + @Nested + inner class CloseForClient : AbstractGUITest.CloseForClient() { + + @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.open(client) shouldBe true + player.assertInventoryView(type) + + val openInventory = player.openInventory + val inventory = openInventory.topInventory + gui.isInventoryLoading(inventory) shouldBe true + + gui.close(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 +) : SingleGUI(plugin) { + + override 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/player/ClientTest.kt b/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt index 7d356af7..205eb4cc 100644 --- a/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt +++ b/src/test/kotlin/com/github/rushyverse/api/player/ClientTest.kt @@ -10,13 +10,17 @@ import com.github.rushyverse.api.player.exception.PlayerNotFoundException import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.CoroutineScope -import org.junit.jupiter.api.assertThrows 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() { @@ -75,7 +79,7 @@ class ClientTest : AbstractKoinTest() { } @Nested - inner class GetGUI { + inner class GetGUI { @Test fun `get GUI returns null if no GUI is registered`() = runTest { @@ -86,7 +90,7 @@ class ClientTest : AbstractKoinTest() { @Test fun `get GUI returns null if no GUI contains the client`() = runTest { val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) - val gui = mockk { + val gui = mockk> { coEvery { contains(client) } returns false } guiManager.add(gui) @@ -96,7 +100,7 @@ class ClientTest : AbstractKoinTest() { @Test fun `get GUI returns GUI if contains the client`() = runTest { val client = Client(player.uniqueId, CoroutineScope(EmptyCoroutineContext)) - val gui = mockk { + val gui = mockk> { coEvery { contains(client) } returns true } guiManager.add(gui)