Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test> {
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
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.+" }
Expand All @@ -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" }
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/xyz/atrius/waystones/Waystones.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ typealias KoinApp =
org.koin.core.KoinApplication

@PluginEntrypoint
class Waystones : KotlinPlugin() {
open class Waystones : KotlinPlugin() {

private lateinit var koin: KoinApp

Expand All @@ -36,4 +36,6 @@ class Waystones : KotlinPlugin() {
override fun onDisable() {
koin.koin.get<WaystonesInitializer>().disable(this)
}

internal fun getKoinApp(): KoinApp = koin
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/test/kotlin/xyz/atrius/waystones/test/ProjectConfig.kt
Original file line number Diff line number Diff line change
@@ -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
}
155 changes: 155 additions & 0 deletions src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpec.kt
Original file line number Diff line number Diff line change
@@ -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 <reified T : Any> get(): T = koin.get()

/** Resolve a bean from the Koin container, or null if not found. */
internal inline fun <reified T : Any> 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,
)
16 changes: 16 additions & 0 deletions src/test/kotlin/xyz/atrius/waystones/test/ServerFunSpecTest.kt
Original file line number Diff line number Diff line change
@@ -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<LocalizationManager>() shouldNotBe null
}
})
Loading