diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-01-27 22:09:22 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-01-27 22:33:43 +0900 |
| commit | 5171dc41dc633e51470ea2e9f92b3434547a465c (patch) | |
| tree | 046ac63c25077673bec55697c0cfb46857f77cb0 /platform-paper/src | |
| parent | 21d17ca948193a9f4b7ef080d2b949240e64e538 (diff) | |
| download | LunaticChat-5171dc41dc633e51470ea2e9f92b3434547a465c.tar.gz LunaticChat-5171dc41dc633e51470ea2e9f92b3434547a465c.tar.bz2 LunaticChat-5171dc41dc633e51470ea2e9f92b3434547a465c.zip | |
test: Add more test-case
Diffstat (limited to 'platform-paper/src')
13 files changed, 980 insertions, 47 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt index ba2eca6..31a6886 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt @@ -22,7 +22,7 @@ import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.listener.EventListenerRegistry import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import org.bukkit.event.Listener import org.bukkit.plugin.java.JavaPlugin import java.util.concurrent.atomic.AtomicBoolean @@ -43,13 +43,15 @@ class LunaticChat : private lateinit var services: ServiceContainer private lateinit var configuration: LunaticChatConfiguration private lateinit var serviceInitializer: ServiceInitializer + private lateinit var pluginScope: PluginCoroutineScope private var updateChecker: UpdateChecker? = null private val updateAvailable = AtomicBoolean(false) override fun onEnable() { saveDefaultConfig() - configuration = ConfigManager.loadConfiguration(config) + val configManager = ConfigManager() + configuration = configManager.loadConfiguration(config) if (configuration.debug) { logger.warning("LunaticChat is running in debug mode.") @@ -58,6 +60,9 @@ class LunaticChat : val httpClient = HttpClient(CIO) + // Initialize plugin coroutine scope + pluginScope = PluginCoroutineScope(this, logger) + // Initialize all services serviceInitializer = ServiceInitializer( @@ -93,6 +98,7 @@ class LunaticChat : } override fun onDisable() { + pluginScope.cancel() serviceInitializer.shutdown(services) logger.info("LunaticChat disabled.") } @@ -157,6 +163,7 @@ class LunaticChat : /** * Initializes the update checker. + * Uses plugin coroutine scope instead of runBlocking for non-blocking async execution. */ private fun initializeUpdateChecker(httpClient: HttpClient) { updateChecker = @@ -165,14 +172,9 @@ class LunaticChat : logger = logger, httpClient = httpClient, ) - server.scheduler.runTaskAsynchronously( - this, - Runnable { - runBlocking { - checkUpdates() - } - }, - ) + pluginScope.scope.launch { + checkUpdates() + } } private suspend fun checkUpdates() { diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt new file mode 100644 index 0000000..34ae0fc --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt @@ -0,0 +1,49 @@ +package dev.m1sk9.lunaticChat.paper + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.bukkit.plugin.java.JavaPlugin +import java.util.logging.Logger + +/** + * Provides a coroutine scope tied to the plugin lifecycle. + * + * This scope: + * - Uses Dispatchers.Default for background CPU-bound work + * - Uses SupervisorJob to prevent child failures from canceling the entire scope + * - Is properly cancelled when the plugin disables + * + * Usage: + * ```kotlin + * pluginScope.launch { + * val result = withTimeout(5000) { + * someAsyncOperation() + * } + * // Handle result... + * } + * ``` + */ +class PluginCoroutineScope( + private val plugin: JavaPlugin, + private val logger: Logger, +) { + private val job = SupervisorJob() + val scope = CoroutineScope(Dispatchers.Default + job) + + /** + * Cancels all coroutines in this scope. + * Should be called during plugin disable. + */ + fun cancel() { + logger.info("Cancelling plugin coroutine scope...") + scope.cancel() + logger.info("Plugin coroutine scope cancelled.") + } + + /** + * Returns true if the scope is still active. + */ + fun isActive(): Boolean = job.isActive +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt index cea20bf..daba3dd 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt @@ -100,8 +100,10 @@ class ServiceInitializer( // 5. Initialize handlers val directMessageHandler = DirectMessageHandler( + configuration = configuration, settingsManager = playerSettingsManager, romanjiConverter = romajiConverter, + languageManager = languageManager, ) return ServiceContainer( @@ -227,9 +229,11 @@ class ServiceInitializer( val messageHandler = ChannelMessageHandler( + configuration = configuration, settingsManager = settingsManager, channelManager = manager, romanjiConverter = romajiConverter, + languageManager = languageManager, logger = io.ktor.util.logging .KtorSimpleLogger("ChannelMessageHandler"), diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt index 89adb6f..12426cf 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt @@ -4,22 +4,24 @@ import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager import dev.m1sk9.lunaticChat.paper.common.playChannelReceiveNotification import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification -import dev.m1sk9.lunaticChat.paper.config.ConfigManager +import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter +import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager import io.ktor.util.logging.Logger import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.HoverEvent import org.bukkit.Bukkit import org.bukkit.entity.Player class ChannelMessageHandler( + private val configuration: LunaticChatConfiguration, private val settingsManager: PlayerSettingsManager?, private val channelManager: ChannelManager, private val romanjiConverter: RomanjiConverter?, + private val languageManager: LanguageManager, private val logger: Logger, ) { - private var lunaticChatConfiguration = ConfigManager.getConfiguration() - fun sendChannelMessage( player: Player, message: String, @@ -31,17 +33,19 @@ class ChannelMessageHandler( val senderSettings = settingsManager?.getSettings(playerId) - // Handle romaji conversion if enabled (requires blocking for HTTP call) + // Handle romaji conversion if enabled + // Uses explicit timeout to prevent long blocking (1s max instead of 3s) val displayMessage = if (senderSettings?.japaneseConversionEnabled == true && romanjiConverter != null) { runCatching { kotlinx.coroutines.runBlocking { - romanjiConverter - ?.convert(message) - ?.let { "$message §e($it)" } - ?: message + kotlinx.coroutines + .withTimeoutOrNull(1000) { + romanjiConverter!! + .convert(message) + }?.let { "$message §e($it)" } ?: message } - }.getOrNull() ?: message + }.getOrElse { message } } else { message } @@ -60,7 +64,15 @@ class ChannelMessageHandler( .getDirectMessageSpyPlayers() .values .filter { it.isOnline && it.uniqueId != playerId && it.uniqueId !in memberIds } - .forEach { it.sendMessage(spyMessage) } + .forEach { + it.sendMessage( + spyMessage.hoverEvent( + HoverEvent.showText( + Component.text(languageManager.getMessage("general.spyMessage")), + ), + ), + ) + } context.members.forEach { member -> Bukkit.getPlayer(member.playerId)?.let { memberPlayer -> if (memberPlayer.isOnline) { @@ -88,7 +100,7 @@ class ChannelMessageHandler( channelName: String, message: String, ): Component { - val format = lunaticChatConfiguration.messageFormat.channelMessageFormat + val format = configuration.messageFormat.channelMessageFormat val text = format .replace("{sender}", senderName) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt index 6155381..3f7950a 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt @@ -3,11 +3,13 @@ package dev.m1sk9.lunaticChat.paper.chat.handler import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager import dev.m1sk9.lunaticChat.paper.common.playDirectMessageNotification import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification -import dev.m1sk9.lunaticChat.paper.config.ConfigManager +import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter +import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager import net.kyori.adventure.text.Component import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent import org.bukkit.Bukkit import org.bukkit.entity.Player import java.util.UUID @@ -18,11 +20,11 @@ import java.util.concurrent.ConcurrentHashMap * Tracks the last player who messaged each player for /reply functionality. */ class DirectMessageHandler( + private val configuration: LunaticChatConfiguration, private val settingsManager: PlayerSettingsManager?, private val romanjiConverter: RomanjiConverter?, + private val languageManager: LanguageManager, ) { - private var lunaticChatConfiguration = ConfigManager.getConfiguration() - private val lastMessager: ConcurrentHashMap<UUID, UUID> = ConcurrentHashMap() private val lastRecipient: ConcurrentHashMap<UUID, UUID> = ConcurrentHashMap() @@ -90,29 +92,39 @@ class DirectMessageHandler( val senderSettings = settingsManager?.getSettings(sender.uniqueId) val recipientSettings = settingsManager?.getSettings(recipient.uniqueId) - // Handle romaji conversion if enabled (requires blocking for HTTP call) + // Handle romaji conversion if enabled + // Uses explicit timeout to prevent long blocking (1s max instead of 3s) val displayMessage = if (senderSettings?.japaneseConversionEnabled == true && romanjiConverter != null) { runCatching { kotlinx.coroutines.runBlocking { - romanjiConverter - ?.convert(message) - ?.let { "$message §e($it)" } - ?: message + kotlinx.coroutines + .withTimeoutOrNull(1000) { + romanjiConverter!! + .convert(message) + }?.let { "$message §e($it)" } ?: message } - }.getOrNull() ?: message + }.getOrElse { message } } else { message } - val format = lunaticChatConfiguration.messageFormat.directMessageFormat + val format = configuration.messageFormat.directMessageFormat val spyMessage = formatMessage(format, sender.name, recipient.name, message) SpyPermissionManager .getDirectMessageSpyPlayers() .values .filter { it.isOnline && it.uniqueId !in setOf(sender.uniqueId, recipient.uniqueId) } - .forEach { it.sendMessage(spyMessage) } + .forEach { + it.sendMessage( + spyMessage.hoverEvent( + HoverEvent.showText( + Component.text(languageManager.getMessage("general.spyMessage")), + ), + ), + ) + } val userMessage = formatMessage(format, sender.name, recipient.name, displayMessage) sender.apply { diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt index c53d154..1bd969d 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt @@ -8,14 +8,11 @@ import dev.m1sk9.lunaticChat.paper.config.key.QuickRepliesFeatureConfig import dev.m1sk9.lunaticChat.paper.i18n.Language import org.bukkit.configuration.file.FileConfiguration -// FIXME: ConfigManager uses mutable static state which makes testing difficult -// and creates hidden global dependencies. Consider refactoring to dependency injection. -object ConfigManager { - private var lunaticChatConfiguration: LunaticChatConfiguration? = null - - fun getConfiguration(): LunaticChatConfiguration = - lunaticChatConfiguration ?: IllegalStateException("LunaticChat Config not loaded").let { throw it } - +/** + * Manages loading and parsing of plugin configuration. + * Converted from singleton to dependency injection pattern for better testability. + */ +class ConfigManager { fun loadConfiguration(configFile: FileConfiguration): LunaticChatConfiguration { val loadedConfig = LunaticChatConfiguration( @@ -81,7 +78,6 @@ object ConfigManager { ), ) - lunaticChatConfiguration = loadedConfig return loadedConfig } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt index 0fa4fa1..ec8804d 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt @@ -54,17 +54,20 @@ class PlayerChatListener( chatModeManager.getChatMode(player.uniqueId) } - // Handle romaji conversion if enabled (requires blocking for HTTP call) + // Handle romaji conversion if enabled + // Uses explicit timeout to prevent long blocking (1s max instead of 3s) + // Note: AsyncChatEvent runs on async thread, so runBlocking here doesn't block main thread val displayMessage = if (settings.japaneseConversionEnabled) { runCatching { runBlocking { - romajiConverter - .convert(messageWithoutPrefix) - ?.let { "$messageWithoutPrefix §e($it)" } - ?: messageWithoutPrefix + kotlinx.coroutines + .withTimeoutOrNull(1000) { + romajiConverter + .convert(messageWithoutPrefix) + }?.let { "$messageWithoutPrefix §e($it)" } ?: messageWithoutPrefix } - }.getOrNull() ?: messageWithoutPrefix + }.getOrElse { messageWithoutPrefix } } else { messageWithoutPrefix } diff --git a/platform-paper/src/main/resources/languages/en.yml b/platform-paper/src/main/resources/languages/en.yml index 2cbff48..767a9b3 100644 --- a/platform-paper/src/main/resources/languages/en.yml +++ b/platform-paper/src/main/resources/languages/en.yml @@ -191,6 +191,7 @@ general: playerOnlyCommand: "This command can only be executed by players." newUpdateAvailable: "The new version of LunaticChat is now available! You can download it from GitHub or Modrinth." noPermission: "You do not have permission to execute this command." + spyMessage: "You have been granted permission, so this message is displayed in spy mode." toggle: off: "Disabled" diff --git a/platform-paper/src/main/resources/languages/ja.yml b/platform-paper/src/main/resources/languages/ja.yml index 766e7e2..a6f2526 100644 --- a/platform-paper/src/main/resources/languages/ja.yml +++ b/platform-paper/src/main/resources/languages/ja.yml @@ -191,6 +191,7 @@ general: playerOnlyCommand: "このコマンドはプレイヤーのみが実行できます" newUpdateAvailable: "LunaticChat の新しいバージョンが利用可能です。GitHubまたはModrinthからダウンロードできます" noPermission: "このコマンドを実行する権限がありません" + spyMessage: "あなたに権限が付与されているため、このメッセージはスパイ状態で表示されています" toggle: on: "有効" diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt new file mode 100644 index 0000000..66fdbd4 --- /dev/null +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt @@ -0,0 +1,208 @@ +package dev.m1sk9.lunaticChat.paper + +import dev.m1sk9.lunaticChat.engine.chat.channel.Channel +import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMember +import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole +import dev.m1sk9.lunaticChat.engine.settings.PlayerChatSettings +import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration +import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig +import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig +import dev.m1sk9.lunaticChat.paper.config.key.JapaneseConversionFeatureConfig +import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig +import dev.m1sk9.lunaticChat.paper.config.key.QuickRepliesFeatureConfig +import dev.m1sk9.lunaticChat.paper.i18n.Language +import io.mockk.mockk +import org.bukkit.entity.Player +import org.bukkit.plugin.java.JavaPlugin +import java.util.UUID +import java.util.logging.Logger + +/** + * Common test utilities for LunaticChat tests. + * Provides mock factories, test data builders, and assertion helpers. + */ +object TestUtils { + /** + * Creates a test logger that collects log messages for verification. + */ + class TestLogger : Logger("test", null) { + val infoMessages = mutableListOf<String>() + val warningMessages = mutableListOf<String>() + val severeMessages = mutableListOf<String>() + + override fun info(msg: String) { + infoMessages.add(msg) + } + + override fun warning(msg: String) { + warningMessages.add(msg) + } + + override fun severe(msg: String) { + severeMessages.add(msg) + } + + fun clear() { + infoMessages.clear() + warningMessages.clear() + severeMessages.clear() + } + } + + /** + * Creates a default test configuration with sensible defaults. + */ + fun createTestConfiguration( + quickRepliesEnabled: Boolean = true, + japaneseConversionEnabled: Boolean = false, + channelChatEnabled: Boolean = false, + maxChannelsPerServer: Int = 10, + maxMembersPerChannel: Int = 50, + maxMembershipPerPlayer: Int = 5, + debug: Boolean = false, + checkForUpdates: Boolean = false, + language: Language = Language.EN, + ): LunaticChatConfiguration = + LunaticChatConfiguration( + features = + FeaturesConfig( + quickReplies = QuickRepliesFeatureConfig(enabled = quickRepliesEnabled), + japaneseConversion = + JapaneseConversionFeatureConfig( + enabled = japaneseConversionEnabled, + cacheMaxEntries = 500, + cacheSaveIntervalSeconds = 300, + cacheFilePath = "test-conversion-cache.json", + apiTimeout = 3000, + apiRetryAttempts = 2, + ), + channelChat = + ChannelChatFeatureConfig( + enabled = channelChatEnabled, + maxChannelsPerServer = maxChannelsPerServer, + maxMembersPerChannel = maxMembersPerChannel, + maxMembershipPerPlayer = maxMembershipPerPlayer, + ), + ), + messageFormat = + MessageFormatConfig( + directMessageFormat = "§7[§e{sender} §7>> §e{recipient}§7] §f{message}", + channelMessageFormat = "§7[§b#{channel}§7] §e{sender}: §f{message}", + ), + debug = debug, + checkForUpdates = checkForUpdates, + userSettingsFilePath = "test-player-settings.yaml", + language = language, + ) + + /** + * Creates a default player settings for testing. + */ + fun createTestPlayerSettings( + uuid: UUID = UUID.randomUUID(), + japaneseConversionEnabled: Boolean = true, + directMessageNotificationEnabled: Boolean = true, + channelMessageNotificationEnabled: Boolean = true, + ): PlayerChatSettings = + PlayerChatSettings( + uuid = uuid, + japaneseConversionEnabled = japaneseConversionEnabled, + directMessageNotificationEnabled = directMessageNotificationEnabled, + channelMessageNotificationEnabled = channelMessageNotificationEnabled, + ) + + /** + * Creates a test channel with default values. + */ + fun createTestChannel( + id: String = "test-channel-1", + name: String = "Test Channel", + description: String? = null, + ownerId: UUID = UUID.randomUUID(), + isPrivate: Boolean = false, + createdAt: Long = System.currentTimeMillis(), + bannedPlayers: Set<UUID> = emptySet(), + ): Channel = + Channel( + id = id, + name = name, + description = description, + ownerId = ownerId, + createdAt = createdAt, + isPrivate = isPrivate, + bannedPlayers = bannedPlayers, + ) + + /** + * Creates a test channel member. + */ + fun createTestChannelMember( + channelId: String = "test-channel-1", + playerId: UUID = UUID.randomUUID(), + role: ChannelRole = ChannelRole.MEMBER, + joinedAt: Long = System.currentTimeMillis(), + ): ChannelMember = + ChannelMember( + channelId = channelId, + playerId = playerId, + role = role, + joinedAt = joinedAt, + ) + + /** + * Creates a mock Player with the given UUID and name. + */ + fun createMockPlayer( + uuid: UUID = UUID.randomUUID(), + name: String = "TestPlayer", + isOnline: Boolean = true, + ): Player { + val player = mockk<Player>(relaxed = true) + io.mockk.every { player.uniqueId } returns uuid + io.mockk.every { player.name } returns name + io.mockk.every { player.isOnline } returns isOnline + return player + } + + /** + * Creates a mock JavaPlugin for testing. + */ + fun createMockPlugin(): JavaPlugin = mockk<JavaPlugin>(relaxed = true) + + /** + * Creates a test UUID from an integer for deterministic testing. + */ + fun createTestUUID(value: Int): UUID = + UUID.fromString( + String.format( + "%08x-0000-0000-0000-000000000000", + value, + ), + ) + + /** + * Assertion helper to check if a string contains all given substrings. + */ + fun assertContainsAll( + actual: String, + vararg expected: String, + ) { + expected.forEach { substring -> + if (!actual.contains(substring)) { + throw AssertionError("Expected '$actual' to contain '$substring'") + } + } + } + + /** + * Assertion helper to check if a list contains items matching a predicate. + */ + fun <T> assertAny( + list: List<T>, + predicate: (T) -> Boolean, + ) { + if (!list.any(predicate)) { + throw AssertionError("Expected list to contain at least one matching item") + } + } +} diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/ChatModeManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/ChatModeManagerTest.kt new file mode 100644 index 0000000..d548f60 --- /dev/null +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/ChatModeManagerTest.kt @@ -0,0 +1,223 @@ +package dev.m1sk9.lunaticChat.paper.chat + +import dev.m1sk9.lunaticChat.engine.chat.ChatMode +import dev.m1sk9.lunaticChat.engine.chat.ChatModeData +import dev.m1sk9.lunaticChat.paper.TestUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests for ChatModeManager. + * Verifies chat mode management, toggling, and persistence. + */ +class ChatModeManagerTest { + private fun createChatModeManager( + initialData: ChatModeData = ChatModeData(), + ): Triple<ChatModeManager, ChatModeStorage, TestUtils.TestLogger> { + val logger = TestUtils.TestLogger() + val storage = mockk<ChatModeStorage>(relaxed = true) + + every { storage.loadFromDisk() } returns initialData + + val manager = ChatModeManager(storage, logger) + return Triple(manager, storage, logger) + } + + @Test + fun `initialize should load data from storage`() { + val playerId = UUID.randomUUID() + val initialData = ChatModeData(modes = mapOf(playerId to ChatMode.CHANNEL)) + val (manager, _, logger) = createChatModeManager(initialData) + + manager.initialize() + + assertEquals(ChatMode.CHANNEL, manager.getChatMode(playerId)) + assert(logger.infoMessages.any { it.contains("ChatModeManager initialized with 1 saved modes") }) + } + + @Test + fun `getChatMode should return DEFAULT for unknown player`() { + val (manager, _, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + val mode = manager.getChatMode(playerId) + + assertEquals(ChatMode.DEFAULT, mode) + assertEquals(ChatMode.GLOBAL, mode) // DEFAULT is GLOBAL + } + + @Test + fun `getChatMode should return set mode`() { + val (manager, _, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.setChatMode(playerId, ChatMode.CHANNEL) + + assertEquals(ChatMode.CHANNEL, manager.getChatMode(playerId)) + } + + @Test + fun `setChatMode should save to storage`() { + val (manager, storage, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.setChatMode(playerId, ChatMode.CHANNEL) + + verify(exactly = 1) { storage.queueAsyncSave(any()) } + } + + @Test + fun `toggleChatMode should switch from GLOBAL to CHANNEL`() { + val (manager, _, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + // Initial mode is GLOBAL (default) + assertEquals(ChatMode.GLOBAL, manager.getChatMode(playerId)) + + val newMode = manager.toggleChatMode(playerId) + + assertEquals(ChatMode.CHANNEL, newMode) + assertEquals(ChatMode.CHANNEL, manager.getChatMode(playerId)) + } + + @Test + fun `toggleChatMode should switch from CHANNEL to GLOBAL`() { + val (manager, _, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.setChatMode(playerId, ChatMode.CHANNEL) + + val newMode = manager.toggleChatMode(playerId) + + assertEquals(ChatMode.GLOBAL, newMode) + assertEquals(ChatMode.GLOBAL, manager.getChatMode(playerId)) + } + + @Test + fun `toggleChatMode should persist changes`() { + val (manager, storage, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.toggleChatMode(playerId) + + verify(atLeast = 1) { storage.queueAsyncSave(any()) } + } + + @Test + fun `removeChatMode should revert to default`() { + val (manager, _, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.setChatMode(playerId, ChatMode.CHANNEL) + assertEquals(ChatMode.CHANNEL, manager.getChatMode(playerId)) + + manager.removeChatMode(playerId) + + assertEquals(ChatMode.GLOBAL, manager.getChatMode(playerId)) + } + + @Test + fun `removeChatMode should save to storage`() { + val (manager, storage, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.setChatMode(playerId, ChatMode.CHANNEL) + manager.removeChatMode(playerId) + + verify(atLeast = 2) { storage.queueAsyncSave(any()) } // Once for set, once for remove + } + + @Test + fun `saveToDisk should synchronously save`() { + val (manager, storage, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + manager.setChatMode(playerId, ChatMode.CHANNEL) + manager.saveToDisk() + + verify { storage.saveToDisk(any()) } + } + + @Test + fun `shutdown should save and shutdown storage`() { + val (manager, storage, _) = createChatModeManager() + manager.initialize() + + manager.shutdown() + + verify { storage.saveToDisk(any()) } + verify { storage.shutdown() } + } + + @Test + fun `manager should handle multiple players independently`() { + val (manager, _, _) = createChatModeManager() + manager.initialize() + + val player1 = UUID.randomUUID() + val player2 = UUID.randomUUID() + + manager.setChatMode(player1, ChatMode.CHANNEL) + manager.setChatMode(player2, ChatMode.GLOBAL) + + assertEquals(ChatMode.CHANNEL, manager.getChatMode(player1)) + assertEquals(ChatMode.GLOBAL, manager.getChatMode(player2)) + } + + @Test + fun `manager should support rapid mode changes`() { + val (manager, storage, _) = createChatModeManager() + manager.initialize() + + val playerId = UUID.randomUUID() + + // Toggle multiple times + manager.toggleChatMode(playerId) // GLOBAL -> CHANNEL + manager.toggleChatMode(playerId) // CHANNEL -> GLOBAL + manager.toggleChatMode(playerId) // GLOBAL -> CHANNEL + + assertEquals(ChatMode.CHANNEL, manager.getChatMode(playerId)) + verify(atLeast = 3) { storage.queueAsyncSave(any()) } + } + + @Test + fun `manager should handle empty initial data`() { + val (manager, _, logger) = createChatModeManager(ChatModeData()) + manager.initialize() + + assert(logger.infoMessages.any { it.contains("ChatModeManager initialized with 0 saved modes") }) + } + + @Test + fun `manager should restore modes after initialization`() { + val player1 = TestUtils.createTestUUID(1) + val player2 = TestUtils.createTestUUID(2) + val initialData = + ChatModeData( + modes = + mapOf( + player1 to ChatMode.CHANNEL, + player2 to ChatMode.GLOBAL, + ), + ) + + val (manager, _, _) = createChatModeManager(initialData) + manager.initialize() + + assertEquals(ChatMode.CHANNEL, manager.getChatMode(player1)) + assertEquals(ChatMode.GLOBAL, manager.getChatMode(player2)) + } +} diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt new file mode 100644 index 0000000..b07636b --- /dev/null +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt @@ -0,0 +1,134 @@ +package dev.m1sk9.lunaticChat.paper.chat.handler + +import dev.m1sk9.lunaticChat.paper.TestUtils +import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter +import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Tests for DirectMessageHandler. + * Validates message handling with dependency injection and conversion features. + */ +class DirectMessageHandlerTest { + private fun createHandler( + configuration: dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration? = null, + settingsManager: PlayerSettingsManager? = null, + romanjiConverter: RomanjiConverter? = null, + ): DirectMessageHandler { + val config = configuration ?: TestUtils.createTestConfiguration() + return DirectMessageHandler(config, settingsManager, romanjiConverter) + } + + @Test + fun `sendDirectMessage should return true on success`() { + val handler = createHandler() + val sender = TestUtils.createMockPlayer() + val recipient = TestUtils.createMockPlayer() + + val result = handler.sendDirectMessage(sender, recipient, "Test message") + + assertTrue(result) + } + + @Test + fun `sendDirectMessage should use custom message format from configuration`() { + val customConfig = + TestUtils.createTestConfiguration().copy( + messageFormat = + TestUtils + .createTestConfiguration() + .messageFormat + .copy( + directMessageFormat = "[DM] {sender} -> {recipient}: {message}", + ), + ) + + val handler = createHandler(configuration = customConfig) + val sender = TestUtils.createMockPlayer(name = "Alice") + val recipient = TestUtils.createMockPlayer(name = "Bob") + + val result = handler.sendDirectMessage(sender, recipient, "Test") + + assertTrue(result) + } + + @Test + fun `sendDirectMessage without conversion should send original message`() { + val settingsManager = mockk<PlayerSettingsManager>() + val senderSettings = + TestUtils.createTestPlayerSettings( + japaneseConversionEnabled = false, + ) + + every { settingsManager.getSettings(any()) } returns senderSettings + + val handler = createHandler(settingsManager = settingsManager) + val sender = TestUtils.createMockPlayer() + val recipient = TestUtils.createMockPlayer() + + val result = handler.sendDirectMessage(sender, recipient, "konnichiwa") + + assertTrue(result) + } + + @Test + fun `sendDirectMessage with conversion should handle conversion timeout gracefully`() { + val settingsManager = mockk<PlayerSettingsManager>() + val senderSettings = + TestUtils.createTestPlayerSettings( + japaneseConversionEnabled = true, + ) + val romanjiConverter = mockk<RomanjiConverter>() + + every { settingsManager.getSettings(any()) } returns senderSettings + // Simulate a slow conversion that would timeout + coEvery { romanjiConverter.convert(any()) } coAnswers { + kotlinx.coroutines.delay(2000) // Exceeds 1s timeout + "こんにちは" + } + + val handler = createHandler(settingsManager = settingsManager, romanjiConverter = romanjiConverter) + val sender = TestUtils.createMockPlayer() + val recipient = TestUtils.createMockPlayer() + + // Should not throw exception and should complete quickly (within timeout) + val result = handler.sendDirectMessage(sender, recipient, "konnichiwa") + + assertTrue(result) + } + + @Test + fun `handler can be created with injected configuration`() { + val config = TestUtils.createTestConfiguration(debug = true) + val handler = DirectMessageHandler(config, null, null) + + // Handler should accept configuration via constructor (DI pattern) + // This validates Issue #1 refactoring - ConfigManager DI + val sender = TestUtils.createMockPlayer() + val recipient = TestUtils.createMockPlayer() + val result = handler.sendDirectMessage(sender, recipient, "Test") + + assertTrue(result) + } + + @Test + fun `handler can be created with all dependencies`() { + val config = TestUtils.createTestConfiguration() + val settingsManager = mockk<PlayerSettingsManager>(relaxed = true) + val romanjiConverter = mockk<RomanjiConverter>(relaxed = true) + + every { settingsManager.getSettings(any()) } returns TestUtils.createTestPlayerSettings() + + val handler = DirectMessageHandler(config, settingsManager, romanjiConverter) + + val sender = TestUtils.createMockPlayer() + val recipient = TestUtils.createMockPlayer() + val result = handler.sendDirectMessage(sender, recipient, "Test") + + assertTrue(result) + } +} diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt new file mode 100644 index 0000000..3f936f6 --- /dev/null +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt @@ -0,0 +1,288 @@ +package dev.m1sk9.lunaticChat.paper.config + +import dev.m1sk9.lunaticChat.paper.i18n.Language +import io.mockk.every +import io.mockk.mockk +import org.bukkit.configuration.file.FileConfiguration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for ConfigManager to ensure proper configuration loading and parsing. + */ +class ConfigManagerTest { + private fun createMockConfig(values: Map<String, Any> = emptyMap()): FileConfiguration { + val config = mockk<FileConfiguration>(relaxed = true) + + // Set up default values + every { config.getBoolean(any(), any()) } answers { + val key = firstArg<String>() + val default = secondArg<Boolean>() + (values[key] as? Boolean) ?: default + } + + every { config.getInt(any(), any()) } answers { + val key = firstArg<String>() + val default = secondArg<Int>() + (values[key] as? Int) ?: default + } + + every { config.getLong(any(), any()) } answers { + val key = firstArg<String>() + val default = secondArg<Long>() + (values[key] as? Long) ?: default + } + + every { config.getString(any(), any()) } answers { + val key = firstArg<String>() + val default = secondArg<String>() + (values[key] as? String) ?: default + } + + every { config.getString(any()) } answers { + val key = firstArg<String>() + values[key] as? String + } + + return config + } + + @Test + fun `loadConfiguration should load all default values`() { + val configManager = ConfigManager() + val mockConfig = createMockConfig() + + val configuration = configManager.loadConfiguration(mockConfig) + + assertNotNull(configuration) + assertTrue(configuration.features.quickReplies.enabled) + assertFalse(configuration.features.japaneseConversion.enabled) + assertFalse(configuration.features.channelChat.enabled) + assertFalse(configuration.debug) + assertFalse(configuration.checkForUpdates) + assertEquals(Language.EN, configuration.language) + } + + @Test + fun `loadConfiguration should load quick replies configuration`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "features.quickReplies.enabled" to false, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertFalse(configuration.features.quickReplies.enabled) + } + + @Test + fun `loadConfiguration should load Japanese conversion configuration`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "features.japaneseConversion.enabled" to true, + "features.japaneseConversion.cache.maxEntries" to 1000, + "features.japaneseConversion.cache.saveIntervalSeconds" to 600, + "features.japaneseConversion.cache.filePath" to "custom_cache.json", + "features.japaneseConversion.api.timeout" to 5000L, + "features.japaneseConversion.api.retryAttempts" to 3, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertTrue(configuration.features.japaneseConversion.enabled) + assertEquals(1000, configuration.features.japaneseConversion.cacheMaxEntries) + assertEquals(600, configuration.features.japaneseConversion.cacheSaveIntervalSeconds) + assertEquals("custom_cache.json", configuration.features.japaneseConversion.cacheFilePath) + assertEquals(5000L, configuration.features.japaneseConversion.apiTimeout) + assertEquals(3, configuration.features.japaneseConversion.apiRetryAttempts) + } + + @Test + fun `loadConfiguration should load channel chat configuration`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "features.channelChat.enabled" to true, + "features.channelChat.maxChannelsPerServer" to 20, + "features.channelChat.maxMembersPerChannel" to 100, + "features.channelChat.maxMembershipPerPlayer" to 10, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertTrue(configuration.features.channelChat.enabled) + assertEquals(20, configuration.features.channelChat.maxChannelsPerServer) + assertEquals(100, configuration.features.channelChat.maxMembersPerChannel) + assertEquals(10, configuration.features.channelChat.maxMembershipPerPlayer) + } + + @Test + fun `loadConfiguration should load message format configuration`() { + val configManager = ConfigManager() + val customDMFormat = "DM: {sender} -> {recipient}: {message}" + val customChannelFormat = "[{channel}] {sender}: {message}" + val mockConfig = + createMockConfig( + mapOf( + "messageFormat.directMessageFormat" to customDMFormat, + "messageFormat.channelMessageFormat" to customChannelFormat, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertEquals(customDMFormat, configuration.messageFormat.directMessageFormat) + assertEquals(customChannelFormat, configuration.messageFormat.channelMessageFormat) + } + + @Test + fun `loadConfiguration should load debug and update check settings`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "debug" to true, + "checkForUpdates" to true, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertTrue(configuration.debug) + assertTrue(configuration.checkForUpdates) + } + + @Test + fun `loadConfiguration should load custom settings file path`() { + val configManager = ConfigManager() + val customPath = "custom-player-settings.yaml" + val mockConfig = + createMockConfig( + mapOf( + "userSettingsFilePath" to customPath, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertEquals(customPath, configuration.userSettingsFilePath) + } + + @Test + fun `loadConfiguration should load Japanese language`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "language" to "ja", + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertEquals(Language.JA, configuration.language) + } + + @Test + fun `loadConfiguration should handle unknown language code`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "language" to "fr", // French not supported + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + // Should fall back to English + assertEquals(Language.EN, configuration.language) + } + + @Test + fun `loadConfiguration should use default values when keys are missing`() { + val configManager = ConfigManager() + val mockConfig = createMockConfig(emptyMap()) + + val configuration = configManager.loadConfiguration(mockConfig) + + // All features should have their defaults + assertTrue(configuration.features.quickReplies.enabled) + assertEquals(500, configuration.features.japaneseConversion.cacheMaxEntries) + assertEquals(300, configuration.features.japaneseConversion.cacheSaveIntervalSeconds) + assertEquals("conversion_cache.json", configuration.features.japaneseConversion.cacheFilePath) + assertEquals(3000L, configuration.features.japaneseConversion.apiTimeout) + assertEquals(2, configuration.features.japaneseConversion.apiRetryAttempts) + assertEquals(0, configuration.features.channelChat.maxChannelsPerServer) + assertEquals(0, configuration.features.channelChat.maxMembersPerChannel) + assertEquals(0, configuration.features.channelChat.maxMembershipPerPlayer) + } + + @Test + fun `loadConfiguration can be called multiple times`() { + val configManager = ConfigManager() + val mockConfig1 = + createMockConfig( + mapOf("debug" to true), + ) + val mockConfig2 = + createMockConfig( + mapOf("debug" to false), + ) + + val config1 = configManager.loadConfiguration(mockConfig1) + val config2 = configManager.loadConfiguration(mockConfig2) + + assertTrue(config1.debug) + assertFalse(config2.debug) + } + + @Test + fun `loadConfiguration should handle all features enabled`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "features.quickReplies.enabled" to true, + "features.japaneseConversion.enabled" to true, + "features.channelChat.enabled" to true, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertTrue(configuration.features.quickReplies.enabled) + assertTrue(configuration.features.japaneseConversion.enabled) + assertTrue(configuration.features.channelChat.enabled) + } + + @Test + fun `loadConfiguration should handle all features disabled`() { + val configManager = ConfigManager() + val mockConfig = + createMockConfig( + mapOf( + "features.quickReplies.enabled" to false, + "features.japaneseConversion.enabled" to false, + "features.channelChat.enabled" to false, + ), + ) + + val configuration = configManager.loadConfiguration(mockConfig) + + assertFalse(configuration.features.quickReplies.enabled) + assertFalse(configuration.features.japaneseConversion.enabled) + assertFalse(configuration.features.channelChat.enabled) + } +} |
