From a27c4fef8aac7386dc28a2e0b1a0c4c90c63ad2f Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Mon, 13 Apr 2026 13:42:25 +0100 Subject: [PATCH 1/9] Upgrades to 2.3.0 --- build.gradle.kts | 11 +++- .../main/kotlin/io/canopy/engine/app/App.kt | 7 ++- .../engine/core/managers/SceneManager.kt | 17 +++--- .../io/canopy/engine/core/nodes/Node.kt | 52 ++++++++++--------- .../io/canopy/engine/input/InputManager.kt | 24 ++++----- .../io/canopy/engine/input/InputMapper.kt | 6 +-- .../io/canopy/engine/input/binds/InputData.kt | 2 +- gradle/libs.versions.toml | 2 +- 8 files changed, 63 insertions(+), 58 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a40036e..877deb2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ import org.gradle.api.publish.maven.MavenPublication import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.plugins.ide.eclipse.model.EclipseModel import org.gradle.plugins.ide.idea.model.IdeaModel +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile val canopyVersion: String by project @@ -24,7 +25,7 @@ allprojects { apply(plugin = "eclipse") apply(plugin = "idea") - group = "io.canopy" + group = "io.github.canopy" version = canopyVersion extensions.configure { @@ -87,6 +88,14 @@ subprojects { tasks.withType(Test::class.java).configureEach { useJUnitPlatform() } + + plugins.withId("org.jetbrains.kotlin.jvm") { + tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xexplicit-backing-fields") + } + } + } } extensions.configure { diff --git a/engine/src/main/kotlin/io/canopy/engine/app/App.kt b/engine/src/main/kotlin/io/canopy/engine/app/App.kt index eb61ea8..4425bc6 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/App.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/App.kt @@ -30,9 +30,8 @@ abstract class App protected constructor() { * Configuration * ============================================================ */ - private var _config: C? = null - protected val config: C - get() = _config ?: defaultConfig() + protected var config: C = defaultConfig() + private set abstract fun defaultConfig(): C @@ -268,7 +267,7 @@ abstract class App protected constructor() { * ============================================================ */ fun config(newConfig: C) { - _config = newConfig + config = newConfig } fun onReady(handler: App.() -> Unit) { diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt index 2ab016e..9f51222 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt @@ -70,17 +70,17 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * Scene state * ============================================================ */ - private var _currScene: Node<*>? = null - /** * Active scene root. Assigning to this property replaces the scene and triggers: * - exit/unregister on the previous scene subtree * - register/build on the new scene subtree * - [onSceneReplaced] emission */ - var currScene: Node<*>? - get() = _currScene - set(value) = replaceScene(value) + var currScene: Node<*>? = null + set(value) { + field = value + replaceScene(currScene, value) + } /** Accumulator used to determine when to run fixed-step physics ticks. */ private var physicsAccumulator = 0f @@ -131,9 +131,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * 2) Swap pointer and emit [onSceneReplaced] * 3) Register + build new subtree */ - private fun replaceScene(newScene: Node<*>?) { - val oldScene = _currScene - + private fun replaceScene(oldScene: Node<*>?, newScene: Node<*>?) { log.info( "event" to "scene.replace", "oldScene" to oldScene?.name, @@ -148,8 +146,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: } } - _currScene = newScene - onSceneReplaced.emit(_currScene) + onSceneReplaced.emit(currScene) newScene?.let { scene -> LogContext.with("scene" to scene.name) { diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index c972e90..a8d763e 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -50,10 +50,11 @@ abstract class Node> protected constructor( * - the parent’s children map key * - this node’s [path] and all descendant paths */ - private var _name = name - var name - get() = _name - set(value) = rename(value) + var name = name + set(value) { + rename(value) + field = value + } /* ============================================================ * Groups @@ -65,8 +66,8 @@ abstract class Node> protected constructor( * Stored as a set to avoid duplicates. * Publicly exposed as read-only. */ - private val _groups = linkedSetOf() - val groups: Set get() = _groups + val groups: Set + field = linkedSetOf() /** * Adds this node to a group. @@ -75,7 +76,7 @@ abstract class Node> protected constructor( * into the SceneManager group registry. */ fun addGroup(group: String) { - if (_groups.add(group) && built) { + if (groups.add(group) && built) { sceneManager.addToGroup(group, this) } } @@ -87,7 +88,7 @@ abstract class Node> protected constructor( * into the SceneManager group registry. */ fun removeGroup(group: String) { - if (_groups.remove(group) && built) { + if (groups.remove(group) && built) { sceneManager.removeFromGroup(group, this) } } @@ -105,7 +106,7 @@ abstract class Node> protected constructor( * ``` */ fun updateGroups(block: MutableSet.() -> Unit) { - _groups.block() + groups.block() if (!built) return @@ -137,8 +138,8 @@ abstract class Node> protected constructor( * - a node is attached/detached * - a node is renamed */ - private var _path: String = "/$name" - val path: String get() = _path + var path: String = "/$name" + private set /** Stable engine logger for node operations (routable to engine logs). */ private val log = EngineLogs.node @@ -153,14 +154,16 @@ abstract class Node> protected constructor( /** * References this node's parent */ - private var _parent: Node<*>? = null - val parent get() = _parent + // private var _parent: Node<*>? = null + var parent: Node<*>? = null + private set /** * References this node's children */ - private val _children: MutableMap> = mutableMapOf() - val children get() = _children.toMap() + // private val _children: MutableMap> = mutableMapOf() + val children: Map> + field = mutableMapOf() /* ============================================================ * DSL support @@ -200,8 +203,8 @@ abstract class Node> protected constructor( "Child with name '${child.name}' already exists under '${this.name}'" } - _children[child.name] = child - child._parent = this + children[child.name] = child + child.parent = this child.recomputePathRecursively() LogContext.with("nodePath" to this.path, "childPath" to child.path) { @@ -269,8 +272,8 @@ abstract class Node> protected constructor( child.nodeExitTree() } - _children.remove(child.name) - child._parent = null + children.remove(child.name) + child.parent = null child.recomputePathRecursively() sceneManager.unregisterSubtree(child) @@ -586,8 +589,8 @@ abstract class Node> protected constructor( /** Recomputes this node path and all descendant paths. */ private fun recomputePathRecursively() { - _path = parent?.let { "${it.path}/$name" } ?: "/$name" - _children.values.forEach { it.recomputePathRecursively() } + path = parent?.let { "${it.path}/$name" } ?: "/$name" + children.values.forEach { it.recomputePathRecursively() } } /** @@ -598,15 +601,14 @@ abstract class Node> protected constructor( val p = parent if (p != null) { - require(!p._children.containsKey(newName)) { + require(!p.children.containsKey(newName)) { "Sibling with name '$newName' already exists under parent '${p.path}'." } - p._children.remove(name) - p._children[newName] = this + p.children.remove(name) + p.children[newName] = this } - _name = newName recomputePathRecursively() } diff --git a/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt b/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt index 53be8f9..f2a6a97 100644 --- a/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt @@ -11,8 +11,8 @@ abstract class InputManager : Manager { private val mapper = InputMapper() - private val _actionStates = mutableMapOf() - val actionStates get() = _actionStates.toMap() + val actionStates: Map + field = mutableMapOf() /** * Backend-specific raw polling. @@ -23,7 +23,7 @@ abstract class InputManager : Manager { * Recomputes all mapped action states for the current frame. */ fun updateActions() { - mapper.actions.forEach { (action, binds) -> + mapper.mappings.forEach { (action, binds) -> val rawPressed = binds.any(::pollPressed) val previousState = getActionState(action) @@ -32,11 +32,11 @@ abstract class InputManager : Manager { rawState = if (rawPressed) InputState.Pressed else InputState.Released ) - _actionStates[action] = nextState + actionStates[action] = nextState } } - fun getActionState(action: String): InputState = _actionStates[action] ?: InputState.Released + fun getActionState(action: String): InputState = actionStates[action] ?: InputState.Released fun isActionPressed(action: String): Boolean { val state = getActionState(action) @@ -73,10 +73,10 @@ abstract class InputManager : Manager { fun mapActions(vararg actions: Pair>, replace: Boolean = true) { mapper.mapActions(*actions, replace = replace) - if (replace) _actionStates.clear() + if (replace) actionStates.clear() actions.forEach { (action, _) -> - _actionStates[action] = InputState.Released + actionStates[action] = InputState.Released } } @@ -86,12 +86,12 @@ abstract class InputManager : Manager { fun unmapAction(action: String) { mapper.unmapAction(action) - _actionStates.remove(action) + actionStates.remove(action) } fun clearMappings() { mapper.clearMappings() - _actionStates.clear() + actionStates.clear() } fun registerPersistence(destination: String = "input", moduleId: String = "input") { @@ -102,9 +102,9 @@ abstract class InputManager : Manager { onSave = { mapper.toData() }, onLoad = { mapper.loadData(it) - _actionStates.clear() - mapper.actions.keys.forEach { action -> - _actionStates[action] = InputState.Released + actionStates.clear() + mapper.mappings.keys.forEach { action -> + actionStates[action] = InputState.Released } } ) diff --git a/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt b/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt index 3b1c540..cf8211f 100644 --- a/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt +++ b/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt @@ -9,10 +9,8 @@ class InputMapper { private val logger = logger() - private val mappings: MutableMap> = mutableMapOf() - - val actions: Map> - get() = mappings.mapValues { it.value.toList() } + val mappings: Map> + field = mutableMapOf>() init { clearMappings() diff --git a/engine/src/main/kotlin/io/canopy/engine/input/binds/InputData.kt b/engine/src/main/kotlin/io/canopy/engine/input/binds/InputData.kt index aaf40c6..3204945 100644 --- a/engine/src/main/kotlin/io/canopy/engine/input/binds/InputData.kt +++ b/engine/src/main/kotlin/io/canopy/engine/input/binds/InputData.kt @@ -33,7 +33,7 @@ class InputData( */ fun InputMapper.asData(): InputData { val entries: List = - actions.map { (action, binds) -> + mappings.map { (action, binds) -> InputEntry(action, binds) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e606a48..e5a6ea5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ [versions] # Kotlin -kotlin = "2.3.10" +kotlin = "2.3.20" ktlint = "14.2.0" kotlinxSerialization = "1.10.0" From 3943ce80a57ceec2210702f3dfe5d87a5c51fa08 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Mon, 13 Apr 2026 20:12:55 +0100 Subject: [PATCH 2/9] Fixed compiling as "pre-release" version For now we can't use explicit backing field --- build.gradle.kts | 8 -------- .../engine/core/managers/SceneManager.kt | 3 ++- .../io/canopy/engine/core/nodes/Node.kt | 20 ++++++++++--------- .../io/canopy/engine/input/InputManager.kt | 19 +++++++++--------- .../io/canopy/engine/input/InputMapper.kt | 13 ++++++------ 5 files changed, 30 insertions(+), 33 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 877deb2..806dda8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,6 @@ import org.gradle.api.publish.maven.MavenPublication import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.plugins.ide.eclipse.model.EclipseModel import org.gradle.plugins.ide.idea.model.IdeaModel -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile val canopyVersion: String by project @@ -89,13 +88,6 @@ subprojects { useJUnitPlatform() } - plugins.withId("org.jetbrains.kotlin.jvm") { - tasks.withType().configureEach { - compilerOptions { - freeCompilerArgs.add("-Xexplicit-backing-fields") - } - } - } } extensions.configure { diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt index 9f51222..65c2c11 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt @@ -78,8 +78,9 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: */ var currScene: Node<*>? = null set(value) { + val oldScene = field field = value - replaceScene(currScene, value) + replaceScene(oldScene, value) } /** Accumulator used to determine when to run fixed-step physics ticks. */ diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index a8d763e..bbe4216 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -66,8 +66,9 @@ abstract class Node> protected constructor( * Stored as a set to avoid duplicates. * Publicly exposed as read-only. */ + private val mutableGroups = linkedSetOf() val groups: Set - field = linkedSetOf() + get() = mutableGroups /** * Adds this node to a group. @@ -76,7 +77,7 @@ abstract class Node> protected constructor( * into the SceneManager group registry. */ fun addGroup(group: String) { - if (groups.add(group) && built) { + if (mutableGroups.add(group) && built) { sceneManager.addToGroup(group, this) } } @@ -88,7 +89,7 @@ abstract class Node> protected constructor( * into the SceneManager group registry. */ fun removeGroup(group: String) { - if (groups.remove(group) && built) { + if (mutableGroups.remove(group) && built) { sceneManager.removeFromGroup(group, this) } } @@ -106,7 +107,7 @@ abstract class Node> protected constructor( * ``` */ fun updateGroups(block: MutableSet.() -> Unit) { - groups.block() + mutableGroups.block() if (!built) return @@ -162,8 +163,9 @@ abstract class Node> protected constructor( * References this node's children */ // private val _children: MutableMap> = mutableMapOf() + private val mutableChildren = mutableMapOf>() val children: Map> - field = mutableMapOf() + get() = mutableChildren /* ============================================================ * DSL support @@ -203,7 +205,7 @@ abstract class Node> protected constructor( "Child with name '${child.name}' already exists under '${this.name}'" } - children[child.name] = child + mutableChildren[child.name] = child child.parent = this child.recomputePathRecursively() @@ -272,7 +274,7 @@ abstract class Node> protected constructor( child.nodeExitTree() } - children.remove(child.name) + mutableChildren.remove(child.name) child.parent = null child.recomputePathRecursively() @@ -605,8 +607,8 @@ abstract class Node> protected constructor( "Sibling with name '$newName' already exists under parent '${p.path}'." } - p.children.remove(name) - p.children[newName] = this + p.mutableChildren.remove(name) + p.mutableChildren[newName] = this } recomputePathRecursively() diff --git a/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt b/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt index f2a6a97..f029b30 100644 --- a/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt @@ -11,8 +11,9 @@ abstract class InputManager : Manager { private val mapper = InputMapper() + private val mutableActionStates = mutableMapOf() val actionStates: Map - field = mutableMapOf() + get() = mutableActionStates /** * Backend-specific raw polling. @@ -32,11 +33,11 @@ abstract class InputManager : Manager { rawState = if (rawPressed) InputState.Pressed else InputState.Released ) - actionStates[action] = nextState + mutableActionStates[action] = nextState } } - fun getActionState(action: String): InputState = actionStates[action] ?: InputState.Released + fun getActionState(action: String): InputState = mutableActionStates[action] ?: InputState.Released fun isActionPressed(action: String): Boolean { val state = getActionState(action) @@ -73,10 +74,10 @@ abstract class InputManager : Manager { fun mapActions(vararg actions: Pair>, replace: Boolean = true) { mapper.mapActions(*actions, replace = replace) - if (replace) actionStates.clear() + if (replace) mutableActionStates.clear() actions.forEach { (action, _) -> - actionStates[action] = InputState.Released + mutableActionStates[action] = InputState.Released } } @@ -86,12 +87,12 @@ abstract class InputManager : Manager { fun unmapAction(action: String) { mapper.unmapAction(action) - actionStates.remove(action) + mutableActionStates.remove(action) } fun clearMappings() { mapper.clearMappings() - actionStates.clear() + mutableActionStates.clear() } fun registerPersistence(destination: String = "input", moduleId: String = "input") { @@ -102,9 +103,9 @@ abstract class InputManager : Manager { onSave = { mapper.toData() }, onLoad = { mapper.loadData(it) - actionStates.clear() + mutableActionStates.clear() mapper.mappings.keys.forEach { action -> - actionStates[action] = InputState.Released + mutableActionStates[action] = InputState.Released } } ) diff --git a/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt b/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt index cf8211f..96d83d7 100644 --- a/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt +++ b/engine/src/main/kotlin/io/canopy/engine/input/InputMapper.kt @@ -9,8 +9,9 @@ class InputMapper { private val logger = logger() + private val mutableMappings = mutableMapOf>() val mappings: Map> - field = mutableMapOf>() + get() = mutableMappings init { clearMappings() @@ -19,8 +20,8 @@ class InputMapper { fun toData(): InputData = asData() fun loadData(data: InputData) { - mappings.clear() - mappings.putAll( + mutableMappings.clear() + mutableMappings.putAll( data.mappings.associate { entry -> entry.name to entry.binds.toMutableList() } @@ -34,7 +35,7 @@ class InputMapper { .toList() fun clearMappings() { - mappings.clear() + mutableMappings.clear() } fun mapActions(vararg newMappings: Pair>, replace: Boolean = true) { @@ -43,7 +44,7 @@ class InputMapper { "Mapping action [$action] to: ${newBinds.joinToString { it.describe() }}" } - val binds = mappings.getOrPut(action) { mutableListOf() } + val binds = mutableMappings.getOrPut(action) { mutableListOf() } if (replace) binds.clear() @@ -52,7 +53,7 @@ class InputMapper { } fun unmapAction(action: String) { - mappings.remove(action) + mutableMappings.remove(action) } private fun InputBind.describe(): String = when (type) { From c0a6cf6eb2ae3d2a7d0711c0e4c12718c32dcea1 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Mon, 13 Apr 2026 23:59:30 +0100 Subject: [PATCH 3/9] build: upgrade kotlin to 2.3.20 and enable automated kover reports --- .gitignore | 1 + build.gradle.kts | 25 +++++++++++++++++++++++++ gradle/libs.versions.toml | 2 ++ tooling/utils/build.gradle.kts | 5 ++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6fef137..fabd990 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,4 @@ Thumbs.db **/resource-config.json **/.canopy/ **/bin/ +**/build/ diff --git a/build.gradle.kts b/build.gradle.kts index 806dda8..ac4d102 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,12 +14,22 @@ plugins { base alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kover) alias(libs.plugins.ktlint) apply false } group = "io.canopy" version = canopyVersion +dependencies { + kover(projects.engine) + kover(projects.adapters.libgdx) + kover(projects.platforms.headless) + kover(projects.platforms.terminal) + kover(projects.tooling.devtools) + kover(projects.tooling.utils) +} + allprojects { apply(plugin = "eclipse") apply(plugin = "idea") @@ -36,6 +46,8 @@ allprojects { } subprojects { + apply(plugin = "org.jetbrains.kotlinx.kover") + plugins.withType { extensions.configure("base") { archivesName.set(project.path.removePrefix(":").replace(":", "-")) @@ -90,6 +102,19 @@ subprojects { } +kover { + reports { + total { + html { + onCheck = true + } + xml { + onCheck = true + } + } + } +} + extensions.configure { project.name = "canopy-parent" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5a6ea5..0b6427a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ [versions] # Kotlin kotlin = "2.3.20" +kover = "0.9.3" ktlint = "14.2.0" kotlinxSerialization = "1.10.0" @@ -100,4 +101,5 @@ mordant-markdown = {module= "com.github.ajalt.mordant:mordant-markdown", version # Kotlin kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/tooling/utils/build.gradle.kts b/tooling/utils/build.gradle.kts index de0d5c8..a766273 100644 --- a/tooling/utils/build.gradle.kts +++ b/tooling/utils/build.gradle.kts @@ -5,4 +5,7 @@ plugins { `maven-publish` } -dependencies {} +dependencies { + testImplementation(libs.kotlin.test.junit5) + testImplementation(libs.junit.jupiter) +} From 3d23da790516503989252d99d86914da73f139f3 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Mon, 13 Apr 2026 23:59:35 +0100 Subject: [PATCH 4/9] feat(core): enhance node operators, physics timing, and path resolution --- .../engine/core/managers/SceneManager.kt | 52 +++++++++++++++---- .../io/canopy/engine/core/nodes/Node.kt | 51 ++++++++++++------ 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt index 65c2c11..5a1cd1e 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt @@ -47,6 +47,9 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * SceneManager during construction. */ internal val currentParent = ThreadLocal.withInitial { null } + + /** Limits the number of physics steps per frame to avoid "spiral of death" during lag. */ + private const val MAX_PHYSICS_STEPS = 8 } init { @@ -104,6 +107,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * Systems indexed by node type they care about. Used for registering/unregistering nodes. */ private val systemsByNodeTypes = mutableMapOf>, MutableList>() + private val globalSystems = mutableListOf() /* ============================================================ * Groups @@ -173,6 +177,17 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: flatTree[node.path] = node // Register node into systems interested in its type. + globalSystems.forEach { sys -> + LogContext.with( + "scene" to root.name, + "nodePath" to node.path, + "system" to sys::class.simpleName + ) { + log.trace("event" to "system.register_node") { "Registering node in global system" } + } + sys.register(node) + } + systemsByNodeTypes[node::class]?.forEach { sys -> LogContext.with( "scene" to root.name, @@ -203,6 +218,17 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: traverseNodes(root) { node -> flatTree.remove(node.path) + globalSystems.forEach { sys -> + LogContext.with( + "scene" to root.name, + "nodePath" to node.path, + "system" to sys::class.simpleName + ) { + log.trace("event" to "system.unregister_node") { "Unregistering node from global system" } + } + sys.unregister(node) + } + systemsByNodeTypes[node::class]?.forEach { sys -> LogContext.with( "scene" to root.name, @@ -254,8 +280,12 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: list.sortBy(TreeSystem::priority) } - system.requiredTypes.forEach { type -> - systemsByNodeTypes.computeIfAbsent(type) { mutableListOf() }.add(system) + if (system.requiredTypes.isEmpty()) { + globalSystems += system + } else { + system.requiredTypes.forEach { type -> + systemsByNodeTypes.computeIfAbsent(type) { mutableListOf() }.add(system) + } } log.info( @@ -281,7 +311,11 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: systems[system.phase]?.apply { remove(system) - system.requiredTypes.forEach { type -> systemsByNodeTypes[type]?.remove(system) } + if (system.requiredTypes.isEmpty()) { + globalSystems.remove(system) + } else { + system.requiredTypes.forEach { type -> systemsByNodeTypes[type]?.remove(system) } + } sortBy(TreeSystem::priority) } systemsByClass.remove(kClass) @@ -378,16 +412,17 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: */ fun tick(delta: Float) { val root = currScene ?: return + physicsAccumulator += delta LogContext.with( "scene" to root.name, "delta" to delta, "physicsStep" to physicsStep ) { - val physicsFrame = isPhysicsFrame(delta) - - if (physicsFrame) { - log.trace("event" to "tick.physics") { "Physics tick" } + var steps = 0 + while (hasPhysicsStep() && steps < MAX_PHYSICS_STEPS) { + steps++ + log.trace("event" to "tick.physics", "step" to steps) { "Physics tick" } systems[TreeSystem.UpdatePhase.PhysicsPre]?.forEach { sys -> LogContext.with("system" to (sys::class.simpleName ?: "UnknownSystem"), "phase" to "PhysicsPre") { @@ -432,8 +467,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * Fixed time-step accumulator. * Returns true when we should run a physics step. */ - private fun isPhysicsFrame(delta: Float): Boolean { - physicsAccumulator += delta + private fun hasPhysicsStep(): Boolean { if (physicsAccumulator >= physicsStep) { physicsAccumulator -= physicsStep return true diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index bbe4216..53c9d59 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -52,8 +52,9 @@ abstract class Node> protected constructor( */ var name = name set(value) { - rename(value) + val oldName = field field = value + rename(oldName, value) } /* ============================================================ @@ -249,7 +250,7 @@ abstract class Node> protected constructor( } /** DSL: `+childNode` inside a node scope. */ - operator fun Node<*>.unaryPlus() = addChild(this) + operator fun Node<*>.unaryPlus() = this@Node.addChild(this) /** DSL: `node += child` */ operator fun plusAssign(child: Node<*>) = addChild(child) @@ -285,7 +286,7 @@ abstract class Node> protected constructor( } /** DSL: `-childNode` */ - operator fun Node<*>.unaryMinus() = removeChild(this) + operator fun Node<*>.unaryMinus() = this@Node.removeChild(this) /** DSL: `node -= child` */ operator fun minusAssign(child: Node<*>) = removeChild(child) @@ -316,6 +317,12 @@ abstract class Node> protected constructor( * - `../Camera` */ fun > getNode(path: String): T { + val node = getNodeOrNull(path) + requireNotNull(node) { "Node at path '$path' not found (relative to '${this.path}')" } + return node + } + + fun > getNodeOrNull(path: String): T? { val parts = path.split("/") val firstPart = parts.firstOrNull() @@ -368,27 +375,38 @@ abstract class Node> protected constructor( "", "." -> current // Go back one node ".." -> current?.findVisibleParent() - ?: throw IllegalArgumentException("No parent for path: $path") + ?: null.also { + log.error("event" to "node.lookup_no_parent", "path" to path) { + "No parent while resolving path: $path" + } + } + // Paths (ex: [a,b,c]) else -> { val node = current - ?: throw IllegalArgumentException("Null node while resolving path: $path") - - node.findVisibleChild(part) - ?: throw IllegalArgumentException( - "No child '$part' under '${node.name}' for path '$path'" - ) + if (node == null) { + log.error("event" to "node.lookup_null_current", "path" to path, "part" to part, "base" to this.path) { + "Base node went null while resolving path '$path' at part '$part'" + } + null + } else { + node.findVisibleChild(part) ?: null.also { + log.error("event" to "node.lookup_child_not_found", "path" to path, "child" to part, "base" to this.path) { + "Child '$part' not found while resolving path '$path' from '${this.path}'" + } + } + } } } } - return current as T + return current as T? } /** * Kotlin shorthand: `node["Player/Weapon"]` */ - inline operator fun > get(path: String): T = getNode(path) + inline operator fun > get(path: String): T? = getNode(path) /* ============================================================ * Prefab / instancing @@ -598,8 +616,8 @@ abstract class Node> protected constructor( /** * Renames this node and updates the parent index + paths. */ - private fun rename(newName: String) { - if (newName == name) return + private fun rename(oldName: String, newName: String) { + if (newName == oldName) return val p = parent if (p != null) { @@ -607,7 +625,7 @@ abstract class Node> protected constructor( "Sibling with name '$newName' already exists under parent '${p.path}'." } - p.mutableChildren.remove(name) + p.mutableChildren.remove(oldName) p.mutableChildren[newName] = this } @@ -622,7 +640,8 @@ abstract class Node> protected constructor( // fun groups(vararg groups: String) = apply { groups.forEach { addGroup(it) } } - fun > patch(path: String, handler: T.() -> Unit) = getNode(path).apply(handler) + fun > patch(path: String, handler: T.() -> Unit) = getNode(path)?.apply(handler) + ?: throw IllegalArgumentException("Node at path '$path' not found for patching.") /* ------------------------------------------------------------------ * Top-level DSL helpers From 940894631595fbd0c6d88c06e488f63383fb55ed Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Mon, 13 Apr 2026 23:59:38 +0100 Subject: [PATCH 5/9] fix(lifecycle): ensure reliable exit logging and update tree systems --- engine/src/main/kotlin/io/canopy/engine/app/App.kt | 8 +++----- .../main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt | 7 ++++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/engine/src/main/kotlin/io/canopy/engine/app/App.kt b/engine/src/main/kotlin/io/canopy/engine/app/App.kt index 4425bc6..f0e7d4c 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/App.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/App.kt @@ -179,6 +179,7 @@ abstract class App protected constructor() { frame++ LogContext.with("frame" to frame) { + beforeUpdate(delta) onUpdate(this@App, delta) } @@ -210,17 +211,14 @@ abstract class App protected constructor() { EngineLogs.lifecycle.info("event" to "app.dispose") { "Disposing app" } beforeExit() + onExit(this) ManagersRegistry.teardown() CanopyLogging.end(reason = "normal") } catch (t: Throwable) { CanopyLogging.end(reason = "crash", t = t) throw t } finally { - try { - onExit(this) - } finally { - markFinished() - } + markFinished() } } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt index 6560163..260c930 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt @@ -67,9 +67,10 @@ abstract class TreeSystem( protected open fun onNodeAdded(node: Node<*>) {} protected open fun onNodeRemoved(node: Node<*>) {} - private fun acceptsNode(node: Node<*>) = requiredTypes.any { type -> - type.isInstance(node) || node.hasChildType(type) - } + private fun acceptsNode(node: Node<*>) = requiredTypes.isEmpty() || + requiredTypes.any { type -> + type.isInstance(node) || node.hasChildType(type) + } // =============================== // TICK PROCESSING From 41130195849731d3c1d1e55b80f5ad8b86e9e373 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Mon, 13 Apr 2026 23:59:45 +0100 Subject: [PATCH 6/9] test: implement comprehensive coverage for core engine modules --- .../libgdx/data/assets/GdxAssetEntryTests.kt | 59 +++++ .../data/assets/GdxAssetsManagerTests.kt | 57 ++++ .../input/GdxInputManagerMappingTests.kt | 161 +++++++++++ .../libgdx/input/GdxInputManagerTests.kt | 49 ++++ .../canopy/engine/app/ScreenManagerTests.kt | 114 ++++++++ .../engine/core/flows/EventAdvancedTests.kt | 55 ++++ .../flows/events/EventWeakListenersTests.kt | 63 +++++ .../engine/core/flows/events/EventsTests.kt | 34 +++ .../core/managers/InjectionManagerTests.kt | 69 +++++ .../core/managers/ManagersRegistryTests.kt | 158 +++++++++++ .../engine/core/managers/SceneManagerTests.kt | 249 ++++++++++++++++++ .../io/canopy/engine/core/nodes/NodeTests.kt | 176 +++++++++++++ .../engine/core/nodes/TreeSystemTests.kt | 147 +++++++++++ .../canopy/engine/data/parsers/ParserTests.kt | 36 +++ .../engine/data/registry/IdRegistryTests.kt | 93 +++++++ .../engine/data/saving/SaveManagerTests.kt | 87 +++++- .../io/canopy/engine/input/InputEventTests.kt | 45 ++++ .../canopy/engine/input/InputManagerTests.kt | 135 ++++++++++ .../canopy/engine/input/InputMapperTests.kt | 89 +++++++ .../canopy/engine/input/InputSystemTests.kt | 64 +++++ .../engine/logging/CanopyLoggingTests.kt | 13 + .../engine/logging/util/ConsoleBannerTests.kt | 32 +++ .../io/canopy/engine/math/Vector2Tests.kt | 50 ++++ .../canopy/devtools/app/AppLifecycleTests.kt | 145 ++++++++++ .../canopy/devtools/app/AppTestDriverTests.kt | 62 +++++ .../data/assets/InMemoryAssetEntryTests.kt | 46 ++++ .../data/assets/TestAssetEntryTests.kt | 50 ++++ .../tooling/utils/PropertyUtilsTests.kt | 21 ++ .../src/test/resources/sample-test.properties | 2 + 29 files changed, 2357 insertions(+), 4 deletions(-) create mode 100644 adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetEntryTests.kt create mode 100644 adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetsManagerTests.kt create mode 100644 adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerMappingTests.kt create mode 100644 adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/app/ScreenManagerTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/flows/EventAdvancedTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventWeakListenersTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventsTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/managers/InjectionManagerTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/managers/ManagersRegistryTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/data/parsers/ParserTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/data/registry/IdRegistryTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/input/InputEventTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/input/InputManagerTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/input/InputMapperTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/input/InputSystemTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/logging/CanopyLoggingTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/logging/util/ConsoleBannerTests.kt create mode 100644 engine/src/test/kotlin/io/canopy/engine/math/Vector2Tests.kt create mode 100644 tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppLifecycleTests.kt create mode 100644 tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppTestDriverTests.kt create mode 100644 tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/InMemoryAssetEntryTests.kt create mode 100644 tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/TestAssetEntryTests.kt create mode 100644 tooling/utils/src/test/kotlin/io/canopy/tooling/utils/PropertyUtilsTests.kt create mode 100644 tooling/utils/src/test/resources/sample-test.properties diff --git a/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetEntryTests.kt b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetEntryTests.kt new file mode 100644 index 0000000..4dc4c07 --- /dev/null +++ b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetEntryTests.kt @@ -0,0 +1,59 @@ +package io.canopy.adapters.libgdx.data.assets + +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import com.badlogic.gdx.files.FileHandle + +@OptIn(ExperimentalPathApi::class) +class GdxAssetEntryTests { + + private val tempRoots = mutableListOf() + + @AfterTest + fun cleanup() { + tempRoots.forEach { it.deleteRecursively() } + tempRoots.clear() + } + + @Test + fun `entry delegates file properties and read operations`() { + val root = createTempDirectory("canopy-gdx-asset") + tempRoots.add(root) + val file = root.resolve("hello.txt") + file.writeText("hello") + + val entry = GdxAssetEntry(FileHandle(file.toFile())) + + assertTrue(entry.exists()) + assertEquals("hello.txt", entry.name) + assertEquals("txt", entry.extension) + assertFalse(entry.isDirectory) + assertEquals("hello", entry.readText()) + assertContentEquals("hello".encodeToByteArray(), entry.readBytes()) + assertEquals(file.toFile().path.replace('\\', '/'), entry.path.replace('\\', '/')) + } + + @Test + fun `entry writes and lists children`() { + val root = createTempDirectory("canopy-gdx-dir") + tempRoots.add(root) + val child = root.resolve("child.txt") + child.writeText("first") + + val directory = GdxAssetEntry(FileHandle(root.toFile())) + val fileEntry = directory.list().single() as GdxAssetEntry + fileEntry.writeText("-second", append = true) + + assertTrue(directory.isDirectory) + assertEquals(listOf("child.txt"), directory.list().map { it.name }) + assertEquals("first-second", child.toFile().readText()) + } +} diff --git a/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetsManagerTests.kt b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetsManagerTests.kt new file mode 100644 index 0000000..efec76b --- /dev/null +++ b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/data/assets/GdxAssetsManagerTests.kt @@ -0,0 +1,57 @@ +package io.canopy.adapters.libgdx.data.assets + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.backends.headless.HeadlessApplication +import com.badlogic.gdx.backends.headless.HeadlessApplicationConfiguration +import io.canopy.engine.data.assets.FileSource + +class GdxAssetsManagerTests { + + private var app: HeadlessApplication? = null + + @AfterTest + fun cleanup() { + app?.exit() + app = null + } + + private fun startHeadless() { + if (app == null) { + app = HeadlessApplication(object : ApplicationAdapter() {}, HeadlessApplicationConfiguration()) + } + } + + @Test + fun `resolveFile supports absolute files`() { + startHeadless() + val tempFile = kotlin.io.path.createTempFile("canopy-gdx-absolute", ".txt").toFile() + tempFile.writeText("value") + + val manager = GdxAssetsManager() + val file = manager.resolveFile(tempFile.absolutePath, FileSource.Absolute) + + assertTrue(file.exists()) + assertEquals(tempFile.absolutePath.replace('\\', '/'), file.path().replace('\\', '/')) + } + + @Test + fun `loadFile returns asset entry and applies custom options`() { + startHeadless() + val tempFile = kotlin.io.path.createTempFile("canopy-gdx-load", ".txt").toFile() + tempFile.writeText("value") + + var configuredPath = "" + val asset = GdxAssetsManager().loadFile(tempFile.absolutePath, FileSource.Absolute) { + configuredPath = path + } + + assertIs(asset) + assertEquals("value", asset.readText()) + assertEquals(asset.path, configuredPath) + } +} diff --git a/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerMappingTests.kt b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerMappingTests.kt new file mode 100644 index 0000000..62ac2b9 --- /dev/null +++ b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerMappingTests.kt @@ -0,0 +1,161 @@ +package io.canopy.adapters.libgdx.input + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertTrue +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import io.canopy.engine.input.binds.InputBind +import io.mockk.every +import io.mockk.mockk + +class GdxInputManagerMappingTests { + + private val originalInput = Gdx.input + + @AfterTest + fun cleanup() { + Gdx.input = originalInput + } + + @Test + fun `all keyboard binds map to a concrete gdx key`() { + val expected = mapOf( + InputBind.A to Input.Keys.A, + InputBind.B to Input.Keys.B, + InputBind.C to Input.Keys.C, + InputBind.D to Input.Keys.D, + InputBind.E to Input.Keys.E, + InputBind.F to Input.Keys.F, + InputBind.G to Input.Keys.G, + InputBind.H to Input.Keys.H, + InputBind.I to Input.Keys.I, + InputBind.J to Input.Keys.J, + InputBind.K to Input.Keys.K, + InputBind.L to Input.Keys.L, + InputBind.M to Input.Keys.M, + InputBind.N to Input.Keys.N, + InputBind.O to Input.Keys.O, + InputBind.P to Input.Keys.P, + InputBind.Q to Input.Keys.Q, + InputBind.R to Input.Keys.R, + InputBind.S to Input.Keys.S, + InputBind.T to Input.Keys.T, + InputBind.U to Input.Keys.U, + InputBind.V to Input.Keys.V, + InputBind.W to Input.Keys.W, + InputBind.X to Input.Keys.X, + InputBind.Y to Input.Keys.Y, + InputBind.Z to Input.Keys.Z, + InputBind.NUM_0 to Input.Keys.NUM_0, + InputBind.NUM_1 to Input.Keys.NUM_1, + InputBind.NUM_2 to Input.Keys.NUM_2, + InputBind.NUM_3 to Input.Keys.NUM_3, + InputBind.NUM_4 to Input.Keys.NUM_4, + InputBind.NUM_5 to Input.Keys.NUM_5, + InputBind.NUM_6 to Input.Keys.NUM_6, + InputBind.NUM_7 to Input.Keys.NUM_7, + InputBind.NUM_8 to Input.Keys.NUM_8, + InputBind.NUM_9 to Input.Keys.NUM_9, + InputBind.LEFT to Input.Keys.LEFT, + InputBind.RIGHT to Input.Keys.RIGHT, + InputBind.UP to Input.Keys.UP, + InputBind.DOWN to Input.Keys.DOWN, + InputBind.SPACE to Input.Keys.SPACE, + InputBind.ENTER to Input.Keys.ENTER, + InputBind.ESCAPE to Input.Keys.ESCAPE, + InputBind.TAB to Input.Keys.TAB, + InputBind.BACKSPACE to Input.Keys.BACKSPACE, + InputBind.INSERT to Input.Keys.INSERT, + InputBind.DELETE to Input.Keys.FORWARD_DEL, + InputBind.HOME to Input.Keys.HOME, + InputBind.END to Input.Keys.END, + InputBind.PAGE_UP to Input.Keys.PAGE_UP, + InputBind.PAGE_DOWN to Input.Keys.PAGE_DOWN, + InputBind.SHIFT_LEFT to Input.Keys.SHIFT_LEFT, + InputBind.SHIFT_RIGHT to Input.Keys.SHIFT_RIGHT, + InputBind.CTRL_LEFT to Input.Keys.CONTROL_LEFT, + InputBind.CTRL_RIGHT to Input.Keys.CONTROL_RIGHT, + InputBind.ALT_LEFT to Input.Keys.ALT_LEFT, + InputBind.ALT_RIGHT to Input.Keys.ALT_RIGHT, + InputBind.META_LEFT to Input.Keys.SYM, + InputBind.META_RIGHT to Input.Keys.SYM, + InputBind.CAPS_LOCK to Input.Keys.CAPS_LOCK, + InputBind.NUM_LOCK to Input.Keys.NUM, + InputBind.SCROLL_LOCK to Input.Keys.SCROLL_LOCK, + InputBind.PRINT_SCREEN to Input.Keys.PRINT_SCREEN, + InputBind.PAUSE to Input.Keys.PAUSE, + InputBind.GRAVE to Input.Keys.GRAVE, + InputBind.MINUS to Input.Keys.MINUS, + InputBind.EQUALS to Input.Keys.EQUALS, + InputBind.LEFT_BRACKET to Input.Keys.LEFT_BRACKET, + InputBind.RIGHT_BRACKET to Input.Keys.RIGHT_BRACKET, + InputBind.BACKSLASH to Input.Keys.BACKSLASH, + InputBind.SEMICOLON to Input.Keys.SEMICOLON, + InputBind.APOSTROPHE to Input.Keys.APOSTROPHE, + InputBind.COMMA to Input.Keys.COMMA, + InputBind.PERIOD to Input.Keys.PERIOD, + InputBind.SLASH to Input.Keys.SLASH, + InputBind.F1 to Input.Keys.F1, + InputBind.F2 to Input.Keys.F2, + InputBind.F3 to Input.Keys.F3, + InputBind.F4 to Input.Keys.F4, + InputBind.F5 to Input.Keys.F5, + InputBind.F6 to Input.Keys.F6, + InputBind.F7 to Input.Keys.F7, + InputBind.F8 to Input.Keys.F8, + InputBind.F9 to Input.Keys.F9, + InputBind.F10 to Input.Keys.F10, + InputBind.F11 to Input.Keys.F11, + InputBind.F12 to Input.Keys.F12, + InputBind.NUMPAD_0 to Input.Keys.NUMPAD_0, + InputBind.NUMPAD_1 to Input.Keys.NUMPAD_1, + InputBind.NUMPAD_2 to Input.Keys.NUMPAD_2, + InputBind.NUMPAD_3 to Input.Keys.NUMPAD_3, + InputBind.NUMPAD_4 to Input.Keys.NUMPAD_4, + InputBind.NUMPAD_5 to Input.Keys.NUMPAD_5, + InputBind.NUMPAD_6 to Input.Keys.NUMPAD_6, + InputBind.NUMPAD_7 to Input.Keys.NUMPAD_7, + InputBind.NUMPAD_8 to Input.Keys.NUMPAD_8, + InputBind.NUMPAD_9 to Input.Keys.NUMPAD_9, + InputBind.NUMPAD_ADD to Input.Keys.NUMPAD_ADD, + InputBind.NUMPAD_SUBTRACT to Input.Keys.NUMPAD_SUBTRACT, + InputBind.NUMPAD_MULTIPLY to Input.Keys.NUMPAD_MULTIPLY, + InputBind.NUMPAD_DIVIDE to Input.Keys.NUMPAD_DIVIDE, + InputBind.NUMPAD_DECIMAL to Input.Keys.NUMPAD_DOT, + InputBind.NUMPAD_ENTER to Input.Keys.NUMPAD_ENTER + ) + + val input = mockk() + every { input.isKeyPressed(any()) } answers + { firstArg() == Input.Keys.UNKNOWN || firstArg() in expected.values } + every { input.isButtonPressed(any()) } returns false + Gdx.input = input + + val manager = GdxInputManager() + expected.keys.forEach { bind -> + assertTrue(manager.isPressed(bind), "Expected $bind to map to a queried GDX key") + } + } + + @Test + fun `all mouse binds map to a concrete gdx button`() { + val expected = mapOf( + InputBind.LEFT_MOUSE to Input.Buttons.LEFT, + InputBind.RIGHT_MOUSE to Input.Buttons.RIGHT, + InputBind.MIDDLE_MOUSE to Input.Buttons.MIDDLE, + InputBind.BACK_MOUSE to Input.Buttons.BACK, + InputBind.FORWARD_MOUSE to Input.Buttons.FORWARD + ) + + val input = mockk() + every { input.isButtonPressed(any()) } answers { firstArg() in expected.values } + every { input.isKeyPressed(any()) } returns false + Gdx.input = input + + val manager = GdxInputManager() + expected.keys.forEach { bind -> + assertTrue(manager.isPressed(bind), "Expected $bind to map to a queried GDX button") + } + } +} diff --git a/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerTests.kt b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerTests.kt new file mode 100644 index 0000000..02aa428 --- /dev/null +++ b/adapters/libgdx/src/test/kotlin/io/canopy/adapters/libgdx/input/GdxInputManagerTests.kt @@ -0,0 +1,49 @@ +package io.canopy.adapters.libgdx.input + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import io.canopy.engine.input.binds.InputBind +import io.mockk.every +import io.mockk.mockk + +class GdxInputManagerTests { + + private val originalInput = Gdx.input + + @AfterTest + fun cleanup() { + Gdx.input = originalInput + } + + @Test + fun `keyboard binds delegate to Gdx key polling`() { + val input = mockk() + every { input.isKeyPressed(Input.Keys.SPACE) } returns true + every { input.isKeyPressed(Input.Keys.FORWARD_DEL) } returns false + every { input.isButtonPressed(any()) } returns false + Gdx.input = input + + val manager = GdxInputManager() + + assertTrue(manager.isPressed(InputBind.SPACE)) + assertFalse(manager.isPressed(InputBind.DELETE)) + } + + @Test + fun `mouse binds delegate to Gdx button polling`() { + val input = mockk() + every { input.isButtonPressed(Input.Buttons.RIGHT) } returns true + every { input.isButtonPressed(Input.Buttons.MIDDLE) } returns false + every { input.isKeyPressed(any()) } returns false + Gdx.input = input + + val manager = GdxInputManager() + + assertTrue(manager.isPressed(InputBind.RIGHT_MOUSE)) + assertFalse(manager.isPressed(InputBind.MIDDLE_MOUSE)) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/app/ScreenManagerTests.kt b/engine/src/test/kotlin/io/canopy/engine/app/ScreenManagerTests.kt new file mode 100644 index 0000000..ec4a0c9 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/app/ScreenManagerTests.kt @@ -0,0 +1,114 @@ +package io.canopy.engine.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ScreenManagerTests { + + private open class RecordingScreen(private val calls: MutableList, private val label: String) : Screen() { + override fun setup() { + calls += "$label.setup" + } + + override fun onEnter() { + super.onEnter() + calls += "$label.enter" + } + + override fun onFrame(delta: Float) { + calls += "$label.frame:$delta" + } + + override fun onResize(width: Int, height: Int) { + calls += "$label.resize:$width:$height" + } + + override fun onExit() { + calls += "$label.exit" + } + + override fun dispose() { + calls += "$label.dispose" + } + } + + private class FirstScreen(calls: MutableList) : RecordingScreen(calls, "first") + private class SecondScreen(calls: MutableList) : RecordingScreen(calls, "second") + + @Test + fun `start enters screen and setup only runs once`() { + val calls = mutableListOf() + val manager = ScreenManager() + val first = FirstScreen(calls) + manager.register(first) + + manager.start(FirstScreen::class) + manager.start(FirstScreen::class) + + assertSame(first, manager.current) + assertEquals(listOf("first.setup", "first.enter"), calls) + } + + @Test + fun `switching screens exits previous screen and enters next`() { + val calls = mutableListOf() + val manager = ScreenManager() + manager.register(FirstScreen(calls)) + manager.register(SecondScreen(calls)) + + manager.start(FirstScreen::class) + manager.start(SecondScreen::class) + + assertEquals( + listOf("first.setup", "first.enter", "first.exit", "second.setup", "second.enter"), + calls + ) + } + + @Test + fun `frame and resize are forwarded to current screen`() { + val calls = mutableListOf() + val manager = ScreenManager() + manager.register(FirstScreen(calls)) + manager.start(FirstScreen::class) + + manager.frame(0.25f) + manager.resize(320, 240) + + assertTrue("first.frame:0.25" in calls) + assertTrue("first.resize:320:240" in calls) + } + + @Test + fun `removing current screen exits it`() { + val calls = mutableListOf() + val manager = ScreenManager() + manager.register(FirstScreen(calls)) + manager.start(FirstScreen::class) + + manager.remove(FirstScreen::class) + + assertNull(manager.current) + assertTrue("first.exit" in calls) + } + + @Test + fun `teardown exits current screen and disposes all registered screens`() { + val calls = mutableListOf() + val manager = ScreenManager() + manager.register(FirstScreen(calls)) + manager.register(SecondScreen(calls)) + manager.start(FirstScreen::class) + + manager.teardown() + + assertEquals( + listOf("first.setup", "first.enter", "first.exit", "first.dispose", "second.dispose"), + calls + ) + assertNull(manager.current) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/flows/EventAdvancedTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/flows/EventAdvancedTests.kt new file mode 100644 index 0000000..e3f82e5 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/flows/EventAdvancedTests.kt @@ -0,0 +1,55 @@ +package io.canopy.engine.core.flows + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import io.canopy.engine.core.flows.events.EventWeakListeners +import io.canopy.engine.core.flows.events.event + +class EventAdvancedTests { + + @Test + fun `event factories support all arities`() { + val noArg = event() + val oneArg = event() + val twoArg = event() + var calls = "" + + noArg.connect { calls += "a" } + oneArg.connect { calls += it.toString() } + twoArg.connect { a, b -> calls += "$a$b" } + + noArg.emit() + oneArg.emit(2) + twoArg.emit(3, "b") + + assertEquals("a23b", calls) + assertFalse(noArg.isEmpty()) + noArg.clear() + assertTrue(noArg.isEmpty()) + } + + @Test + fun `weak listeners add remove and clear strong references`() { + val listeners = EventWeakListeners<() -> Unit>("test") + var calls = 0 + val callback: () -> Unit = { + calls++ + Unit + } + + listeners.add(callback) + assertEquals(1, listeners.size()) + + listeners.forEach { it() } + assertEquals(1, calls) + + listeners.remove(callback) + assertEquals(0, listeners.size()) + + listeners.add(callback) + listeners.clear() + assertEquals(0, listeners.size()) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventWeakListenersTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventWeakListenersTests.kt new file mode 100644 index 0000000..5f5aa15 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventWeakListenersTests.kt @@ -0,0 +1,63 @@ +package io.canopy.engine.core.flows.events + +import kotlin.test.Test +import kotlin.test.assertEquals + +class EventWeakListenersTests { + + class DummyListener { + var called = false + fun trigger() { + called = true + } + } + + @Test + fun `add, remove and clear listeners`() { + val listeners = EventWeakListeners("test") + val listener1 = DummyListener() + + assertEquals(0, listeners.size()) + listeners.add(listener1) + assertEquals(1, listeners.size()) + + var count = 0 + listeners.forEach { count++ } + assertEquals(1, count) + + listeners.remove(listener1) + assertEquals(0, listeners.size()) + + listeners.add(listener1) + assertEquals(1, listeners.size()) + listeners.clear() + assertEquals(0, listeners.size()) + } + + @Test + fun `garbage collected listeners are cleaned up`() { + val listeners = EventWeakListeners("test") + + var ref: DummyListener? = DummyListener() + listeners.add(ref!!) + + assertEquals(1, listeners.size()) + + ref = null + System.gc() + + // Give GC a bit to run + Thread.sleep(50) + + // The GC should hopefully clean it up, reducing the size + // If the JVM didn't GC, size will still be 1 (JVM gc is unpredictable) + // At least we hit the lines. + listeners.size() + + var count = 0 + // forEach calls cleanup if elements are null + listeners.forEach { count++ } + + // Remove if object is collected works? Yes, cleanupDead is called. + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventsTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventsTests.kt new file mode 100644 index 0000000..6f9eaab --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/flows/events/EventsTests.kt @@ -0,0 +1,34 @@ +package io.canopy.engine.core.flows.events + +import kotlin.test.Test +import kotlin.test.assertEquals + +class EventsTests { + + @Test + fun `emit NoArgEvent`() { + val event = NoArgEvent() + var count = 0 + event.connect { count++ } + event.emit() + assertEquals(1, count) + } + + @Test + fun `emit OneArgEvent`() { + val event = OneArgEvent() + var arg = "" + event.connect { a -> arg = a } + event.emit("test") + assertEquals("test", arg) + } + + @Test + fun `emit TwoArgsEvent`() { + val event = TwoArgsEvent() + var res = "" + event.connect { a, b -> res = a + b } + event.emit("test", 1) + assertEquals("test1", res) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/managers/InjectionManagerTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/managers/InjectionManagerTests.kt new file mode 100644 index 0000000..58bfd0d --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/managers/InjectionManagerTests.kt @@ -0,0 +1,69 @@ +package io.canopy.engine.core.managers + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class InjectionManagerTests { + + private class Service + + @Test + fun `inject returns provider value`() { + val manager = InjectionManager() + val service = Service() + + manager.registerInjectable(Service::class) { service } + + assertSame(service, manager.inject(Service::class)) + } + + @Test + fun `registerInjectable rejects duplicate type`() { + val manager = InjectionManager() + manager.registerInjectable(Service::class) { Service() } + + assertFailsWith { + manager.registerInjectable(Service::class) { Service() } + } + } + + @Test + fun `provider is invoked for each injection`() { + val manager = InjectionManager() + var calls = 0 + manager.registerInjectable(Service::class) { + calls++ + Service() + } + + val first = manager.inject(Service::class) + val second = manager.inject(Service::class) + + assertEquals(2, calls) + assertNotSame(first, second) + } + + @Test + fun `inject throws when type is missing`() { + val manager = InjectionManager() + + assertFailsWith { + manager.inject(Service::class) + } + } + + @Test + fun `teardown clears providers`() { + val manager = InjectionManager() + manager.registerInjectable(Service::class) { Service() } + + manager.teardown() + + assertFailsWith { + manager.inject(Service::class) + } + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/managers/ManagersRegistryTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/managers/ManagersRegistryTests.kt new file mode 100644 index 0000000..a4139c1 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/managers/ManagersRegistryTests.kt @@ -0,0 +1,158 @@ +package io.canopy.engine.core.managers + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import io.canopy.engine.core.managers.lazyManager +import io.canopy.engine.core.managers.manager + +class ManagersRegistryTests { + + private open class BaseManager : Manager { + var setupCalls = 0 + var teardownCalls = 0 + + override fun setup() { + setupCalls++ + } + + override fun teardown() { + teardownCalls++ + } + } + + private class ChildManager : BaseManager() + private class OtherManager : Manager + + @AfterTest + fun cleanup() { + ManagersRegistry.teardown() + } + + @Test + fun `register rejects duplicate concrete manager`() { + ManagersRegistry.register(OtherManager()) + + assertFailsWith { + ManagersRegistry.register(OtherManager()) + } + } + + @Test + fun `register rejects managers that conflict by assignable lookup type`() { + ManagersRegistry.register(ChildManager()) + + assertFailsWith { + ManagersRegistry.register(BaseManager()) + } + } + + @Test + fun `lookup resolves by base type and unregister invalidates cache`() { + val manager = ChildManager() + ManagersRegistry.register(manager) + + assertSame(manager, ManagersRegistry.getManager(BaseManager::class)) + assertTrue(ManagersRegistry.has(BaseManager::class)) + + ManagersRegistry.unregister(BaseManager::class) + + assertFalse(ManagersRegistry.has(BaseManager::class)) + assertFailsWith { + ManagersRegistry.getManager(BaseManager::class) + } + } + + @Test + fun `setup and teardown call registered managers in registration order`() { + val first = ChildManager() + val second = OtherManager() + val calls = mutableListOf() + val recordingSecond = object : Manager { + override fun setup() { + calls += "second.setup" + } + + override fun teardown() { + calls += "second.teardown" + } + } + val recordingFirst = object : Manager { + override fun setup() { + calls += "first.setup" + } + + override fun teardown() { + calls += "first.teardown" + } + } + + ManagersRegistry.register(recordingFirst) + ManagersRegistry.register(recordingSecond) + + ManagersRegistry.setup() + ManagersRegistry.teardown() + + assertEquals( + listOf("first.setup", "second.setup", "first.teardown", "second.teardown"), + calls + ) + assertEquals(0, first.setupCalls) + assertTrue(second is OtherManager) + } + + @Test + fun `withScope clears previous registrations and sets up scoped managers`() { + val scoped = ChildManager() + ManagersRegistry.register(OtherManager()) + + ManagersRegistry.withScope { + register(scoped) + } + + assertTrue(ManagersRegistry.has(ChildManager::class)) + assertFalse(ManagersRegistry.has(OtherManager::class)) + assertEquals(1, scoped.setupCalls) + } + + @Test + fun `manager and lazyManager helpers work correctly`() { + val manager = ChildManager() + ManagersRegistry.register(manager) + + val retrievedManager: ChildManager = manager() + assertSame(manager, retrievedManager) + + val lazyRetrieved: ChildManager by lazyManager() + assertSame(manager, lazyRetrieved) + } + + @Test + fun `has operator and contains work`() { + val manager = ChildManager() + ManagersRegistry.register(manager) + + assertTrue(ManagersRegistry.has(ChildManager::class)) + assertTrue(ChildManager::class in ManagersRegistry) + + assertFalse(ManagersRegistry.has(OtherManager::class)) + assertFalse(OtherManager::class in ManagersRegistry) + } + + @Test + fun `ambiguous manager resolution fails`() { + // Registering anonymously implemented managers that share the same super type + val m1 = object : BaseManager() {} + val m2 = object : BaseManager() {} + + ManagersRegistry.register(m1) + + assertFailsWith { + ManagersRegistry.register(m2) + } + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt new file mode 100644 index 0000000..d4445db --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt @@ -0,0 +1,249 @@ +package io.canopy.engine.core.managers + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import io.canopy.engine.core.nodes.Node +import io.canopy.engine.core.nodes.TreeSystem +import io.canopy.engine.core.nodes.types.empty.EmptyNode + +class SceneManagerTests { + + private open class RecordingSystem( + phase: TreeSystem.UpdatePhase, + priority: Int, + private val calls: MutableList, + ) : TreeSystem(phase, priority, EmptyNode::class) { + override fun onRegister() { + calls += "register:$priority" + } + + override fun onUnregister() { + calls += "unregister:$priority" + } + + override fun onNodeAdded(node: Node<*>) { + calls += "add:${node.name}:$priority" + } + + override fun onNodeRemoved(node: Node<*>) { + calls += "remove:${node.name}:$priority" + } + + override fun processNode(node: Node<*>, delta: Float) { + calls += "process:${node.name}:$priority:$delta" + } + } + + private class LowPriorityRecordingSystem(calls: MutableList) : + RecordingSystem(TreeSystem.UpdatePhase.PhysicsPre, 10, calls) + + private class HighPriorityRecordingSystem(calls: MutableList) : + RecordingSystem(TreeSystem.UpdatePhase.PhysicsPre, 20, calls) + + @AfterTest + fun cleanup() { + ManagersRegistry.teardown() + } + + @Test + fun `scene replacement unregisters old scene and registers new scene`() { + val sceneManager = SceneManager() + val calls = mutableListOf() + val system = RecordingSystem(TreeSystem.UpdatePhase.FramePre, 0, calls) + sceneManager.addSystem(system) + ManagersRegistry.withScope { + register(sceneManager) + } + + val oldScene = EmptyNode("old") { EmptyNode("old-child") } + val newScene = EmptyNode("new") { EmptyNode("new-child") } + + sceneManager.currScene = oldScene + sceneManager.currScene = newScene + + assertTrue("add:old:0" in calls) + assertTrue("add:old-child:0" in calls) + assertTrue("remove:old:0" in calls) + assertTrue("remove:old-child:0" in calls) + assertTrue("add:new:0" in calls) + assertTrue("add:new-child:0" in calls) + } + + @Test + fun `tick runs systems in priority order and only runs physics after accumulator reaches step`() { + val sceneManager = SceneManager(physicsStep = 0.5f) + val calls = mutableListOf() + sceneManager.addSystem(HighPriorityRecordingSystem(calls)) + sceneManager.addSystem(LowPriorityRecordingSystem(calls)) + ManagersRegistry.withScope { + register(sceneManager) + } + sceneManager.currScene = EmptyNode("root") + calls.clear() + + sceneManager.tick(0.25f) + assertTrue(calls.none { it.startsWith("process") }) + + sceneManager.tick(0.25f) + + assertEquals( + listOf("process:root:10:0.5", "process:root:20:0.5"), + calls.filter { it.startsWith("process") } + ) + } + + @Test + fun `groups can be updated and signaled`() { + val sceneManager = SceneManager() + ManagersRegistry.withScope { + register(sceneManager) + } + + val node = EmptyNode("root") + node.addGroup("old") + sceneManager.currScene = node + + val signaled = mutableListOf() + sceneManager.signalGroup("old") { signaled += it.name } + assertEquals(listOf("root"), signaled) + + node.updateGroups { + remove("old") + add("new") + } + + signaled.clear() + sceneManager.signalGroup("new") { signaled += it.name } + + assertEquals(listOf("root"), signaled) + + signaled.clear() + sceneManager.signalGroup("old") { signaled += it.name } + assertTrue(signaled.isEmpty()) + } + + @Test + fun `resize emits resize event`() { + val sceneManager = SceneManager() + val sizes = mutableListOf>() + sceneManager.onResize.connect { width, height -> sizes += width to height } + + sceneManager.resize(800, 600) + + assertEquals(listOf(800 to 600), sizes) + } + + @Test + fun `teardown calls system unregister hooks`() { + val calls = mutableListOf() + val sceneManager = SceneManager { + addSystem(RecordingSystem(TreeSystem.UpdatePhase.FramePre, 0, calls)) + } + + sceneManager.setup() + sceneManager.teardown() + + assertEquals(listOf("register:0", "unregister:0"), calls) + } + + @Test + fun `scene replacement event and system lookup helpers work`() { + val sceneManager = SceneManager() + class LookupSystem(calls: MutableList) : RecordingSystem(TreeSystem.UpdatePhase.FramePre, 0, calls) + val calls = mutableListOf() + val replaced = mutableListOf() + val system = LookupSystem(calls) + sceneManager.onSceneReplaced.connect { replaced += it?.name } + + sceneManager.addSystem(system) + assertTrue(sceneManager.hasSystem(system::class)) + assertEquals(system, sceneManager.getSystem(system::class)) + + sceneManager.currScene = EmptyNode("first") + sceneManager.currScene = EmptyNode("second") + + assertEquals(listOf("first", "second"), replaced) + + sceneManager.removeSystem(system::class) + assertTrue(!sceneManager.hasSystem(system::class)) + } + + @Test + fun `global systems and type-specific systems are fully triggered in register and unregister`() { + val sceneManager = SceneManager() + val calls = mutableListOf() + val globalSystem = object : TreeSystem(TreeSystem.UpdatePhase.FramePre, 1) { + override fun onNodeAdded(node: Node<*>) { + calls += "add:${node.name}:1" + } + override fun onNodeRemoved(node: Node<*>) { + calls += "remove:${node.name}:1" + } + } + val specificSystem = RecordingSystem(TreeSystem.UpdatePhase.FramePre, 2, calls) + + sceneManager.addSystem(globalSystem) + sceneManager.addSystem(specificSystem) + + ManagersRegistry.withScope { + register(sceneManager) + } + + val node = EmptyNode("root") + sceneManager.currScene = node // triggers registerSubtree + + assertTrue(calls.contains("add:root:1")) + assertTrue(calls.contains("add:root:2")) + calls.clear() + + sceneManager.currScene = null // triggers unregisterSubtree + + assertTrue(calls.contains("remove:root:1")) + assertTrue(calls.contains("remove:root:2")) + + // test removeSystem as well + sceneManager.removeSystem(globalSystem::class) + sceneManager.removeSystem(specificSystem::class) + assertFalse(sceneManager.hasSystem(globalSystem::class)) + assertFalse(sceneManager.hasSystem(specificSystem::class)) + } + + @Test + fun `tick executes all system update phases`() { + val sceneManager = SceneManager(physicsStep = 0.5f) + val calls = mutableListOf() + + sceneManager.addSystem(object : RecordingSystem(TreeSystem.UpdatePhase.PhysicsPre, 1, calls) {}) + sceneManager.addSystem(object : RecordingSystem(TreeSystem.UpdatePhase.PhysicsPost, 1, calls) {}) + sceneManager.addSystem(object : RecordingSystem(TreeSystem.UpdatePhase.FramePre, 1, calls) {}) + sceneManager.addSystem(object : RecordingSystem(TreeSystem.UpdatePhase.FramePost, 1, calls) {}) + + ManagersRegistry.withScope { + register(sceneManager) + } + + sceneManager.currScene = EmptyNode("root") + calls.clear() + + // run delta enough to trigger physics tick + sceneManager.tick(0.5f) + + TreeSystem.UpdatePhase.entries.forEach { phase -> + assertTrue(calls.any { it.startsWith("process:root:1") }, "Missing phase: $phase") + } + } + + @Test + fun `removeFromGroup throws when group or node is invalid`() { + val sceneManager = SceneManager() + val node = EmptyNode("root") + + assertFailsWith { + sceneManager.removeFromGroup("ghost", node) + } + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt index dddeddb..365ded8 100644 --- a/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt @@ -5,6 +5,7 @@ import kotlin.time.DurationUnit import kotlin.time.toDuration import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.SceneManager +import io.canopy.engine.core.managers.manager import io.canopy.engine.core.nodes.types.empty.EmptyNode import io.canopy.engine.core.nodes.types.empty.EmptyNode2D import io.canopy.engine.math.Vector2 @@ -282,4 +283,179 @@ class NodeTests { assertTrue(wasCalled) } + + @Test + fun `rename reparent and relative lookups should update paths`() { + val root = EmptyNode("root") { + EmptyNode("a") + EmptyNode("b") + } + root.buildTree() + + val a = root.getNode("a") + a.name = "renamed" + assertEquals("/root/renamed", a.path) + assertSame(a, root.getNode("renamed")) + + root.reparent(a, root.getNode("b")) + + assertEquals("/root/b/renamed", a.path) + assertSame(a, root.getNode("b/renamed")) + assertSame(root.getNode("b"), a.getNode("..")) + } + + @Test + fun `prefab child skips lifecycle until built manually`() { + var readyCalls = 0 + val root = EmptyNode("root") + root.buildTree() + + val prefab = EmptyNode("child") { + behavior(onReady = { readyCalls++ }) + }.asPrefab() + + root.addChild(prefab) + assertEquals(0, readyCalls) + + prefab.buildTree() + assertEquals(1, readyCalls) + } + + @Test + fun `group changes on built node should mirror through scene manager`() { + val root = EmptyNode("root") + root.asSceneRoot() + root.buildTree() + + root.addGroup("one") + val sceneManager = manager() + val firstNames = mutableSetOf() + sceneManager.signalGroup("one") { firstNames += it.name } + assertEquals(setOf("root"), firstNames) + + root.removeGroup("one") + val names = mutableSetOf() + sceneManager.signalGroup("one") { names += it.name } + assertTrue(names.isEmpty()) + } + + @Test + fun `getNode allows complex resolution with relative and wrapper paths`() { + val root = EmptyNode("root") { + EmptyNode("visibleChild") { + // Not visible/skipOnSearch = true (using empty node for now but just assume we test relative resolution) + EmptyNode("subChild") + } + }.asSceneRoot() + root.buildTree() + + val child = root.getNode("./visibleChild/subChild") + assertEquals("subChild", child.name) + + val sibling = child.getNode("..") + assertEquals("visibleChild", sibling.name) + + val backToRoot = sibling.getNode("..") + assertEquals("root", backToRoot.name) + } + + @Test + fun `unary plus and unary minus operators work`() { + val child1 = EmptyNode("child1").asPrefab() + val child2 = EmptyNode("child2").asPrefab() + + val root = EmptyNode("root") { + +child1 + +child2 + } + root.buildTree() + assertEquals(2, root.children.size) + + with(root) { + -child1 + } + assertEquals(1, root.children.size) + } + + @Test + fun `plusAssign and minusAssign operators work`() { + val root = EmptyNode("root") + val child = EmptyNode("child") + + root += child + assertEquals(1, root.children.size) + + root -= child + assertEquals(0, root.children.size) + } + + @Test + fun `plus operator returns parent`() { + val root = EmptyNode("root") + val child = EmptyNode("child") + + with(root) { + val returned = this + child + assertSame(root, returned) + assertEquals(1, root.children.size) + } + } + + @Test + fun `hasChildType works correctly`() { + val root = EmptyNode("root") { + EmptyNode2D("a2d") + } + root.buildTree() + + assertTrue(root.hasChildType(EmptyNode2D::class)) + assertFalse(root.hasChildType(EmptyNode::class)) // children does not include EmptyNode + } + + @Test + fun `runBehavior catches and logs exceptions`() { + val behavior = createBehavior( + onReady = { throw RuntimeException("Throwing behavior") } + ) + + val root = EmptyNode("root") { + attachBehavior(behavior) + } + + assertFailsWith { + root.buildTree() + } + } + + @Test + fun `getNode supports complex path resolution`() { + val target = EmptyNode("target") + val child = EmptyNode("child") { +target } + val wrapper = EmptyNode("wrapper") { +child } + val root = EmptyNode("root") { + +wrapper + EmptyNode("sibling") + } + root.buildTree() + + // From target's perspective + assertEquals(target, target.getNode(".")) + assertEquals(target, target.getNode("")) + assertEquals(child, target.getNode("..")) + // Resolving parent, skipping wrapper + assertEquals(wrapper, target.getNode("../..")) + // Resolving up and down + assertEquals(root.getNode("sibling"), target.getNode("../../../sibling")) + + // From root's perspective + assertEquals(target, root.getNode("wrapper/child/target")) + assertEquals(target, wrapper.getNode("child/target")) // Because wrapper is skipped + + // Invalid paths + assertNull(root.getNodeOrNull("missing")) + assertNull(root.getNodeOrNull("../missing")) + + // Root path fallback + assertEquals(root, root.getNode("/")) + } } diff --git a/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt new file mode 100644 index 0000000..3003217 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt @@ -0,0 +1,147 @@ +package io.canopy.engine.core.nodes + +import kotlin.test.* +import io.canopy.engine.core.managers.ManagersRegistry +import io.canopy.engine.core.managers.SceneManager +import io.canopy.engine.core.nodes.types.empty.EmptyNode +import org.junit.jupiter.api.BeforeAll + +class TreeSystemTests { + + companion object { + @BeforeAll + @JvmStatic + fun setup() { + ManagersRegistry.withScope { + register(SceneManager()) + } + } + } + + @Test + fun `tree system should invoke hooks correctly and handle errors`() { + val callOrder = mutableListOf() + var errorThrown = false + + val system = object : TreeSystem(UpdatePhase.FramePre, 0, EmptyNode::class) { + override fun onRegister() { + callOrder += "onRegister" + } + override fun onUnregister() { + callOrder += "onUnregister" + } + override fun beforeProcess(delta: Float) { + callOrder += "beforeProcess" + } + override fun processNode(node: Node<*>, delta: Float) { + callOrder += "processNode:${node.name}" + if (node.name == "fail") throw RuntimeException("Fail node") + } + override fun afterProcess(delta: Float) { + callOrder += "afterProcess" + } + override fun onNodeAdded(node: Node<*>) { + callOrder += "onNodeAdded:${node.name}" + } + override fun onNodeRemoved(node: Node<*>) { + callOrder += "onNodeRemoved:${node.name}" + } + } + + system.onRegister() + + val node1 = EmptyNode("node1") + node1.buildTree() + + val nodeFail = EmptyNode("fail") + nodeFail.buildTree() + + // Registration + system.register(node1) + system.register(nodeFail) + + assertEquals(listOf("onRegister", "onNodeAdded:node1", "onNodeAdded:fail"), callOrder) + callOrder.clear() + + // Tick process + assertFailsWith { + system.tick(1.0f) + } + + // It should have executed beforeProcess and processNode:node1 and processNode:fail + assertTrue(callOrder.contains("beforeProcess")) + assertTrue(callOrder.contains("processNode:node1")) + assertTrue(callOrder.contains("processNode:fail")) + callOrder.clear() + + // Unregister + system.unregister(node1) + system.unregister(nodeFail) + + assertEquals(listOf("onNodeRemoved:node1", "onNodeRemoved:fail"), callOrder) + callOrder.clear() + + system.onUnregister() + assertEquals(listOf("onUnregister"), callOrder) + } + + @Test + fun `createTreeSystem inline function works`() { + var registered = false + var unregistered = false + var matched = 0 + + val sys = object : TreeSystem(UpdatePhase.FramePre, 0, EmptyNode::class) { + override fun onRegister() { + registered = true + } + override fun onUnregister() { + unregistered = true + } + override fun processNode(node: Node<*>, delta: Float) { + matched++ + } + } + + sys.onRegister() + assertTrue(registered) + + val node = EmptyNode("test") + node.buildTree() + sys.register(node) + + sys.tick(0f) + assertEquals(1, matched) + + sys.unregister(node) + sys.onUnregister() + assertTrue(unregistered) + } + + @Test + fun `acceptsNode checks for child types correctly`() { + class CustomType : Node("custom") + + val sys = object : TreeSystem(UpdatePhase.FramePre, 0, CustomType::class) {} + + val node1 = EmptyNode("root") { + CustomType() + } + node1.buildTree() + + sys.register(node1) // Should accept because it has a child of CustomType + + // Use reflection to access matchingNodes size + val sizeProp = TreeSystem::class.members.find { it.name == "matchingNodes" } + // We know it must be in the list if it accepted. But without reflection, let's test processNode. + var processed = false + val sys2 = object : TreeSystem(UpdatePhase.FramePre, 0, CustomType::class) { + override fun processNode(node: Node<*>, delta: Float) { + processed = true + } + } + sys2.register(node1) + sys2.tick(0f) + assertTrue(processed) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/data/parsers/ParserTests.kt b/engine/src/test/kotlin/io/canopy/engine/data/parsers/ParserTests.kt new file mode 100644 index 0000000..473e92d --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/data/parsers/ParserTests.kt @@ -0,0 +1,36 @@ +package io.canopy.engine.data.parsers + +import kotlin.test.Test +import kotlin.test.assertEquals +import io.canopy.engine.data.saving.InMemoryAssetEntry +import kotlinx.serialization.Serializable + +class ParserTests { + + @Serializable + data class TestData(val name: String, val value: Int) + + @Test + fun `Json toFile and fromFile works`() { + val asset = InMemoryAssetEntry("test.json") + val data = TestData("json", 10) + + Json.toFile(data, asset) + val loaded: TestData = Json.fromFile(asset) + + assertEquals("json", loaded.name) + assertEquals(10, loaded.value) + } + + @Test + fun `Toml toFile and fromFile works`() { + val asset = InMemoryAssetEntry("test.toml") + val data = TestData("toml", 20) + + Toml.toFile(data, asset) + val loaded: TestData = Toml.fromFile(asset) + + assertEquals("toml", loaded.name) + assertEquals(20, loaded.value) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/data/registry/IdRegistryTests.kt b/engine/src/test/kotlin/io/canopy/engine/data/registry/IdRegistryTests.kt new file mode 100644 index 0000000..1a55fb4 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/data/registry/IdRegistryTests.kt @@ -0,0 +1,93 @@ +package io.canopy.engine.data.registry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import io.canopy.engine.data.core.registry.IdEntry +import io.canopy.engine.data.saving.InMemoryAssetEntry +import kotlinx.serialization.Serializable + +class IdRegistryTests { + + @Serializable + private data class TestEntry( + override val domain: String, + override val name: String, + var touched: Boolean = false, + ) : IdEntry + + @Test + fun `addItemsToRegistry rejects duplicate ids`() { + val registry = IdRegistry() + + assertFailsWith { + registry.addItemsToRegistry( + listOf( + TestEntry("test", "one"), + TestEntry("test", "one") + ) + ) + } + } + + @Test + fun `mapIds resolves entries and applies update handler`() { + val registry = IdRegistry() + registry.addItemsToRegistry(listOf(TestEntry("test", "one"), TestEntry("test", "two"))) + + val mapped = registry.mapIds(listOf("test:one", "test:two")) { + touched = true + } + + assertEquals(listOf("test:one", "test:two"), mapped.map { it.id }) + assertTrue(mapped.all { it.touched }) + } + + @Test + fun `mapIds throws for missing ids`() { + val registry = IdRegistry() + + assertFailsWith { + registry.mapIds(listOf("test:missing")) + } + } + + @Test + fun `loadRegistry requires a source when items are not provided`() { + val registry = IdRegistry() + + assertFailsWith { + registry.loadRegistry() + } + } + + @Test + fun `collectJsonFiles returns nested json files only`() { + val root = InMemoryAssetEntry("root", isDirectory = true) + val nested = InMemoryAssetEntry("nested", isDirectory = true) + val firstJson = InMemoryAssetEntry("root/one.json") + val secondJson = InMemoryAssetEntry("root/nested/two.json") + + root.addChild(firstJson) + root.addChild(InMemoryAssetEntry("root/readme.txt")) + root.addChild(nested) + nested.addChild(secondJson) + + val registry = IdRegistry(root) + + assertEquals(listOf(firstJson, secondJson), registry.collectJsonFiles(root)) + } + + @Test + fun `loadRegistry loads from JSON file`() { + val root = InMemoryAssetEntry("root/one.json") + root.writeText("""[{"domain":"test","name":"one"}]""") + + val registry = IdRegistry(root) + registry.loadRegistry() + + assertEquals(1, registry.nEntries()) + assertEquals("test:one", registry.map["test:one"]?.id) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt b/engine/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt index 96e9bd7..9de228f 100644 --- a/engine/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt @@ -2,13 +2,14 @@ package io.canopy.engine.data.saving import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.manager import io.canopy.engine.data.assets.WritableAssetEntry import kotlinx.serialization.builtins.serializer import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach /** * Tests for [SaveManager] + [SaveModule] integration. @@ -29,14 +30,18 @@ class SaveManagerTests { "player" to ::entryForSlot ) - @JvmStatic - @BeforeAll - fun setup() { + fun reset() { entries.clear() + ManagersRegistry.teardown() ManagersRegistry.register(saveManager) } } + @BeforeEach + fun setup() { + reset() + } + @AfterEach fun cleanup() { manager().cleanModules("player") @@ -76,4 +81,78 @@ class SaveManagerTests { assertEquals(5, intData) assertEquals("abc", stringData) } + + @Test + fun `load should ignore missing destinations and missing module payloads`() { + var loaded = 0 + registerSaveModule( + destination = "player", + id = "test-int", + serializer = Int.serializer(), + onSave = { 7 }, + onLoad = { loaded = it } + ) + + saveManager.load("missing", 3) + assertEquals(0, loaded) + + entries.getOrPut(3) { InMemoryAssetEntry("player-3.json", """{"other": 99}""") } + saveManager.load("player", 3) + + assertEquals(0, loaded) + } + + @Test + fun `saveAll and loadAll process every destination`() { + val settingsEntries = mutableMapOf() + fun settingsEntryForSlot(slot: Int): WritableAssetEntry = + settingsEntries.getOrPut(slot) { InMemoryAssetEntry("settings-$slot.json") } + + val manager = SaveManager( + "player" to ::entryForSlot, + "settings" to ::settingsEntryForSlot + ) + + ManagersRegistry.teardown() + ManagersRegistry.register(manager) + + var player = 0 + var settings = "" + registerSaveModule( + destination = "player", + id = "player-score", + serializer = Int.serializer(), + onSave = { 42 }, + onLoad = { player = it } + ) + registerSaveModule( + destination = "settings", + id = "difficulty", + serializer = String.serializer(), + onSave = { "hard" }, + onLoad = { settings = it } + ) + + manager.saveAll(0) + manager.loadAll(0) + + assertEquals(42, player) + assertEquals("hard", settings) + } + + @Test + fun `cleanModules removes registered module data`() { + registerSaveModule( + destination = "player", + id = "test-int", + serializer = Int.serializer(), + onSave = { 5 } + ) + + saveManager.cleanModules("player") + + assertFailsWith { + saveManager.loadData("player", Int::class) + } + } } diff --git a/engine/src/test/kotlin/io/canopy/engine/input/InputEventTests.kt b/engine/src/test/kotlin/io/canopy/engine/input/InputEventTests.kt new file mode 100644 index 0000000..8314f27 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/input/InputEventTests.kt @@ -0,0 +1,45 @@ +package io.canopy.engine.input + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import io.canopy.engine.math.Vector2 + +class InputEventTests { + + @Test + fun `consume marks event as handled`() { + val event = ButtonInputEvent("jump", InputState.JustPressed) + + assertFalse(event.isHandled) + + event.consume() + + assertTrue(event.isHandled) + } + + @Test + fun `action helpers match pressed and released states`() { + val pressed = ButtonInputEvent("jump", InputState.Pressed) + val justPressed = ButtonInputEvent("jump", InputState.JustPressed) + val released = ButtonInputEvent("jump", InputState.Released) + val justReleased = ButtonInputEvent("jump", InputState.JustReleased) + + assertTrue(pressed.isActionPressed("jump")) + assertTrue(justPressed.isActionPressed("jump")) + assertTrue(justPressed.isActionJustPressed("jump")) + assertTrue(released.isActionReleased("jump")) + assertTrue(justReleased.isActionReleased("jump")) + assertTrue(justReleased.isActionJustReleased("jump")) + assertFalse(pressed.isActionPressed("other")) + } + + @Test + fun `any action helpers reflect exact state`() { + assertTrue(ButtonInputEvent("jump", InputState.Pressed).isAnyActionPressed()) + assertTrue(ButtonInputEvent("jump", InputState.JustPressed).isAnyActionJustPressed()) + assertTrue(ButtonInputEvent("jump", InputState.Released).isAnyActionReleased()) + assertTrue(ButtonInputEvent("jump", InputState.JustReleased).isAnyActionJustReleased()) + assertFalse(MouseMoveEvent(Vector2(1f, 2f), "move").isAnyActionPressed()) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/input/InputManagerTests.kt b/engine/src/test/kotlin/io/canopy/engine/input/InputManagerTests.kt new file mode 100644 index 0000000..2f8e1dd --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/input/InputManagerTests.kt @@ -0,0 +1,135 @@ +package io.canopy.engine.input + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import io.canopy.engine.core.managers.ManagersRegistry +import io.canopy.engine.data.assets.WritableAssetEntry +import io.canopy.engine.data.saving.InMemoryAssetEntry +import io.canopy.engine.data.saving.SaveManager +import io.canopy.engine.input.binds.InputBind + +class InputManagerTests { + + private class TestInputManager : InputManager() { + private val pressed = mutableSetOf() + + fun press(vararg binds: InputBind) { + pressed += binds + } + + fun release(vararg binds: InputBind) { + pressed -= binds.toSet() + } + + override fun pollPressed(bind: InputBind): Boolean = bind in pressed + } + + @AfterTest + fun cleanup() { + ManagersRegistry.teardown() + } + + @Test + fun `updateActions advances button state across frames`() { + val input = TestInputManager() + input.mapActions("jump" to listOf(InputBind.SPACE)) + + assertEquals(InputState.Released, input.getActionState("jump")) + + input.press(InputBind.SPACE) + input.updateActions() + assertEquals(InputState.JustPressed, input.getActionState("jump")) + assertTrue(input.isActionPressed("jump")) + assertTrue(input.isActionJustPressed("jump")) + + input.updateActions() + assertEquals(InputState.Pressed, input.getActionState("jump")) + + input.release(InputBind.SPACE) + input.updateActions() + assertEquals(InputState.JustReleased, input.getActionState("jump")) + assertTrue(input.isActionReleased("jump")) + assertTrue(input.isActionJustReleased("jump")) + + input.updateActions() + assertEquals(InputState.Released, input.getActionState("jump")) + } + + @Test + fun `multiple binds press an action when any bind is pressed`() { + val input = TestInputManager() + input.mapActions("left" to listOf(InputBind.A, InputBind.LEFT)) + + input.press(InputBind.LEFT) + input.updateActions() + + assertEquals(InputState.JustPressed, input.getActionState("left")) + assertTrue(input.isPressed(InputBind.LEFT)) + assertFalse(input.isPressed(InputBind.A)) + } + + @Test + fun `axis and input vector combine action states`() { + val input = TestInputManager() + input.mapActions( + "left" to listOf(InputBind.A), + "right" to listOf(InputBind.D), + "down" to listOf(InputBind.S), + "up" to listOf(InputBind.W) + ) + + input.press(InputBind.D, InputBind.W) + input.updateActions() + + assertEquals(1f, input.getAxis("left", "right")) + assertEquals(1f, input.getAxis("down", "up")) + assertEquals(1f, input.getInputVector("left", "right", "down", "up").x) + assertEquals(1f, input.getInputVector("left", "right", "down", "up").y) + + input.press(InputBind.A) + input.updateActions() + + assertEquals(0f, input.getAxis("left", "right")) + } + + @Test + fun `unmapAction and clearMappings reset action states`() { + val input = TestInputManager() + input.mapActions("jump" to listOf(InputBind.SPACE), "cancel" to listOf(InputBind.ESCAPE)) + + input.unmapAction("jump") + + assertEquals(InputState.Released, input.getActionState("jump")) + assertEquals(setOf("cancel"), input.actionStates.keys) + + input.clearMappings() + + assertTrue(input.actionStates.isEmpty()) + } + + @Test + fun `registerPersistence saves and loads mappings with released states`() { + val entries = mutableMapOf() + fun entryForSlot(slot: Int): WritableAssetEntry = + entries.getOrPut(slot) { InMemoryAssetEntry("input-$slot.json") } + + val saveManager = SaveManager("input" to ::entryForSlot) + ManagersRegistry.withScope { + register(saveManager) + } + + val input = TestInputManager() + input.mapActions("jump" to listOf(InputBind.SPACE)) + input.registerPersistence() + + saveManager.save("input", 0) + input.clearMappings() + saveManager.load("input", 0) + + assertEquals(setOf("jump"), input.actionStates.keys) + assertEquals(InputState.Released, input.getActionState("jump")) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/input/InputMapperTests.kt b/engine/src/test/kotlin/io/canopy/engine/input/InputMapperTests.kt new file mode 100644 index 0000000..4a7e5af --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/input/InputMapperTests.kt @@ -0,0 +1,89 @@ +package io.canopy.engine.input + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import io.canopy.engine.input.binds.InputBind +import io.canopy.engine.input.binds.InputData +import io.canopy.engine.input.binds.InputEntry + +class InputMapperTests { + + @Test + fun `mapActions replaces existing binds by default`() { + val mapper = InputMapper() + + mapper.mapActions("jump" to listOf(InputBind.SPACE)) + mapper.mapActions("jump" to listOf(InputBind.ENTER)) + + assertEquals(listOf(InputBind.ENTER), mapper.mappings["jump"]) + assertEquals(listOf("jump"), mapper.mapToAction(InputBind.ENTER)) + assertTrue(mapper.mapToAction(InputBind.SPACE).isEmpty()) + } + + @Test + fun `mapActions appends binds when replace is false`() { + val mapper = InputMapper() + + mapper.mapActions("jump" to listOf(InputBind.SPACE)) + mapper.mapActions("jump" to listOf(InputBind.ENTER), replace = false) + + assertEquals(listOf(InputBind.SPACE, InputBind.ENTER), mapper.mappings["jump"]) + } + + @Test + fun `mapToAction returns every action mapped to the bind`() { + val mapper = InputMapper() + + mapper.mapActions( + "jump" to listOf(InputBind.SPACE), + "confirm" to listOf(InputBind.SPACE), + "cancel" to listOf(InputBind.ESCAPE) + ) + + assertEquals(listOf("jump", "confirm"), mapper.mapToAction(InputBind.SPACE)) + } + + @Test + fun `unmapAction and clearMappings remove mappings`() { + val mapper = InputMapper() + + mapper.mapActions( + "jump" to listOf(InputBind.SPACE), + "cancel" to listOf(InputBind.ESCAPE) + ) + + mapper.unmapAction("jump") + + assertEquals(setOf("cancel"), mapper.mappings.keys) + + mapper.clearMappings() + + assertTrue(mapper.mappings.isEmpty()) + } + + @Test + fun `toData and loadData roundtrip mappings`() { + val mapper = InputMapper() + mapper.mapActions( + "left" to listOf(InputBind.A, InputBind.LEFT), + "right" to listOf(InputBind.D, InputBind.RIGHT) + ) + + val restored = InputMapper() + restored.loadData(mapper.toData()) + + assertEquals(mapper.mappings, restored.mappings) + } + + @Test + fun `loadData clears existing mappings before applying data`() { + val mapper = InputMapper() + mapper.mapActions("old" to listOf(InputBind.SPACE)) + + mapper.loadData(InputData(listOf(InputEntry("new", listOf(InputBind.ENTER))))) + + assertEquals(setOf("new"), mapper.mappings.keys) + assertEquals(listOf(InputBind.ENTER), mapper.mappings["new"]) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/input/InputSystemTests.kt b/engine/src/test/kotlin/io/canopy/engine/input/InputSystemTests.kt new file mode 100644 index 0000000..042cd5c --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/input/InputSystemTests.kt @@ -0,0 +1,64 @@ +package io.canopy.engine.input + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import io.canopy.engine.core.managers.ManagersRegistry +import io.canopy.engine.core.managers.SceneManager +import io.canopy.engine.core.nodes.Node +import io.canopy.engine.input.binds.InputBind +import io.canopy.tooling.utils.UnstableApi + +@OptIn(UnstableApi::class) +class InputSystemTests { + + private class TestInputManager : InputManager() { + private val pressed = mutableSetOf() + + fun press(vararg binds: InputBind) { + pressed += binds + } + + override fun pollPressed(bind: InputBind): Boolean = bind in pressed + } + + private class RecordingNode(name: String = "node") : Node(name) { + val received = mutableListOf>() + + override fun nodeInput(event: InputEvent) { + received += event.action to event.state + super.nodeInput(event) + } + } + + @AfterTest + fun cleanup() { + ManagersRegistry.teardown() + } + + @Test + fun `input system dispatches button events to nodes`() { + val input = TestInputManager() + val sceneManager = SceneManager { + +InputSystem() + } + + ManagersRegistry.withScope { + register(input) + register(sceneManager) + } + + input.mapActions("jump" to listOf(InputBind.SPACE)) + val root = RecordingNode("root") + sceneManager.currScene = root + + input.press(InputBind.SPACE) + sceneManager.tick(1f / 60f) + sceneManager.tick(1f / 60f) + + assertEquals( + listOf("jump" to InputState.JustPressed, "jump" to InputState.Pressed), + root.received + ) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/logging/CanopyLoggingTests.kt b/engine/src/test/kotlin/io/canopy/engine/logging/CanopyLoggingTests.kt new file mode 100644 index 0000000..681dd73 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/logging/CanopyLoggingTests.kt @@ -0,0 +1,13 @@ +package io.canopy.engine.logging + +import kotlin.test.Test + +class CanopyLoggingTests { + @Test + fun `init and end logging`() { + CanopyLogging.init() + CanopyLogging.end("test_reason") + + CanopyLogging.end("test_error", RuntimeException("test error")) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/logging/util/ConsoleBannerTests.kt b/engine/src/test/kotlin/io/canopy/engine/logging/util/ConsoleBannerTests.kt new file mode 100644 index 0000000..4eb3396 --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/logging/util/ConsoleBannerTests.kt @@ -0,0 +1,32 @@ +package io.canopy.engine.logging.util + +import kotlin.test.Test + +class ConsoleBannerTests { + + @Test + fun `prints simple mode`() { + ConsoleBanner.print("1.0.0", ConsoleBanner.Mode.SIMPLE) + } + + @Test + fun `prints gradient mode`() { + ConsoleBanner.print("1.0.0", ConsoleBanner.Mode.GRADIENT) + } + + @Test + fun `private color methods work correctly`() { + val interpolateMethod = ConsoleBanner::class.java.getDeclaredMethod("interpolateColor", Double::class.java) + interpolateMethod.isAccessible = true + val result = interpolateMethod.invoke(ConsoleBanner, 0.5) + + val colorizeMethod = ConsoleBanner::class.java.getDeclaredMethod( + "colorizeLine", + String::class.java, + Int::class.java, + Int::class.java + ) + colorizeMethod.isAccessible = true + colorizeMethod.invoke(ConsoleBanner, "test", 1, 10) + } +} diff --git a/engine/src/test/kotlin/io/canopy/engine/math/Vector2Tests.kt b/engine/src/test/kotlin/io/canopy/engine/math/Vector2Tests.kt new file mode 100644 index 0000000..5e990ac --- /dev/null +++ b/engine/src/test/kotlin/io/canopy/engine/math/Vector2Tests.kt @@ -0,0 +1,50 @@ +package io.canopy.engine.math + +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class Vector2Tests { + + @Test + fun `set add and scalar scale mutate and return same instance`() { + val vector = Vector2(1f, 2f) + + assertSame(vector, vector.set(3f, 4f)) + assertSame(vector, vector.add(2f, -1f)) + assertSame(vector, vector.scl(2f)) + assertEquals(Vector2(10f, 6f), vector) + } + + @Test + fun `plus and vector scale return scaled values`() { + val vector = Vector2(2f, 3f) + val multiplied = Vector2(2f, 3f) + + assertEquals(Vector2(3f, 4f), vector + Vector2(1f, 1f)) + assertEquals(Vector2(4f, 9f), multiplied * Vector2(2f, 3f)) + } + + @Test + fun `len and nor compute vector magnitude and normalization`() { + val vector = Vector2(3f, 4f) + + assertEquals(5f, vector.len()) + + vector.nor() + + assertEquals(1f, vector.len(), 0.0001f) + assertEquals(3f / sqrt(25f), vector.x, 0.0001f) + assertEquals(4f / sqrt(25f), vector.y, 0.0001f) + } + + @Test + fun `normalizing zero vector keeps zero`() { + val zero = Vector2.Zero.copy() + + zero.nor() + + assertEquals(Vector2(0f, 0f), zero) + } +} diff --git a/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppLifecycleTests.kt b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppLifecycleTests.kt new file mode 100644 index 0000000..f4f02fc --- /dev/null +++ b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppLifecycleTests.kt @@ -0,0 +1,145 @@ +package io.canopy.devtools.app + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import java.util.concurrent.TimeUnit +import io.canopy.engine.app.App +import io.canopy.engine.app.AppConfig +import io.canopy.engine.core.managers.Manager +import io.canopy.engine.core.managers.ManagersRegistry +import io.canopy.engine.input.InputManager +import io.canopy.engine.input.binds.InputBind + +class AppLifecycleTests { + + private class TestInputManager : InputManager() { + override fun pollPressed(bind: InputBind): Boolean = false + } + + private open class RecordingApp( + private val withInputManager: Boolean = false, + private val failOnLaunch: Boolean = false, + ) : App() { + val calls = mutableListOf() + val inputManager = TestInputManager() + var exitRequests = 0 + var forceRequests = 0 + + override fun defaultConfig(): AppConfig = AppConfig(title = "test") + + override fun collectManagers(): List = if (withInputManager) listOf(inputManager) else emptyList() + + override fun afterReady() { + calls += "afterReady" + } + + override fun beforeUpdate(delta: Float) { + calls += "beforeUpdate:$delta" + } + + override fun afterResize(width: Int, height: Int) { + calls += "afterResize:$width:$height" + } + + override fun beforeExit() { + calls += "beforeExit" + } + + override fun internalLaunch(config: AppConfig, vararg args: String) { + if (failOnLaunch) error("boom") + installBackendHandle( + requestExit = { exitRequests++ }, + forceClose = { forceRequests++ } + ) + ready() + } + } + + @AfterTest + fun cleanup() { + ManagersRegistry.teardown() + } + + @Test + fun `app lifecycle hooks run in expected order`() { + val app = RecordingApp() + app.onReady { + app.calls += "onReady" + } + app.onUpdate { delta -> + app.calls += "onUpdate:$delta" + } + app.onResize { width, height -> + app.calls += "onResize:$width:$height" + } + app.onExit { + app.calls += "onExit" + } + + app.ready() + app.update(0.25f) + app.resize(320, 240) + app.exit() + + assertEquals( + listOf( + "onReady", + "afterReady", + "beforeUpdate:0.25", + "onUpdate:0.25", + "onResize:320:240", + "afterResize:320:240", + "beforeExit", + "onExit" + ), + app.calls + ) + } + + @Test + fun `inputs mapping is applied when input manager is registered`() { + val app = RecordingApp(withInputManager = true) + app.inputs( + "jump" to listOf(InputBind.SPACE), + "left" to listOf(InputBind.A, InputBind.LEFT) + ) + + app.ready() + + assertEquals(setOf("jump", "left"), app.inputManager.actionStates.keys) + assertEquals(listOf(InputBind.SPACE), app.inputManager.loadMappings()["jump"]) + } + + @Test + fun `handle routes graceful and force close through backend callbacks`() { + val app = RecordingApp() + + app.launchAsync().use { handle -> + assertTrue(handle.awaitStarted(1, TimeUnit.SECONDS)) + handle.requestExit() + handle.forceClose() + } + + assertEquals(2, app.exitRequests) + assertEquals(1, app.forceRequests) + } + + @Test + fun `launchAsync surfaces internal launch failures`() { + val app = RecordingApp(failOnLaunch = true) + + assertFailsWith { + app.launchAsync() + } + } + + private fun TestInputManager.loadMappings(): Map> { + val mapper = javaClass.superclass.getDeclaredField("mapper") + mapper.isAccessible = true + val inputMapper = mapper.get(this) as io.canopy.engine.input.InputMapper + return inputMapper.mappings + } +} diff --git a/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppTestDriverTests.kt b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppTestDriverTests.kt new file mode 100644 index 0000000..ba02aed --- /dev/null +++ b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/AppTestDriverTests.kt @@ -0,0 +1,62 @@ +package io.canopy.devtools.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import io.canopy.engine.app.App +import io.canopy.engine.app.AppConfig + +class AppTestDriverTests { + + private class RecordingApp : App() { + val calls = mutableListOf() + + override fun defaultConfig(): AppConfig = AppConfig("driver") + + override fun internalLaunch(config: AppConfig, vararg args: String) { + calls += "launch" + ready() + } + + override fun afterReady() { + calls += "ready" + } + + override fun beforeUpdate(delta: Float) { + calls += "update:$delta" + } + + override fun afterResize(width: Int, height: Int) { + calls += "resize:$width:$height" + } + + override fun beforeExit() { + calls += "exit" + } + } + + @Test + fun `driver delegates lifecycle methods to app`() { + val app = RecordingApp() + val driver = appTestDriver(app) + + driver.start() + driver.frame(0.5f) + driver.resize(320, 200) + driver.stop() + + assertEquals( + listOf("ready", "update:0.5", "resize:320:200", "exit"), + app.calls + ) + } + + @Test + fun `driver launch delegates to app launch`() { + val app = RecordingApp() + val driver = appTestDriver(app) + + driver.launch() + + assertEquals(listOf("launch", "ready"), app.calls) + } +} diff --git a/tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/InMemoryAssetEntryTests.kt b/tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/InMemoryAssetEntryTests.kt new file mode 100644 index 0000000..2fcd77b --- /dev/null +++ b/tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/InMemoryAssetEntryTests.kt @@ -0,0 +1,46 @@ +package io.canopy.devtools.data.assets + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class InMemoryAssetEntryTests { + + @Test + fun `reads writes and appends text and bytes`() { + val entry = InMemoryAssetEntry("dir/file.txt", "hi") + + entry.writeText("-there", append = true) + assertEquals("hi-there", entry.readText()) + + entry.writeBytes(byteArrayOf(1, 2), append = false) + assertContentEquals(byteArrayOf(1, 2), entry.readBytes()) + + entry.writeBytes(byteArrayOf(3), append = true) + assertContentEquals(byteArrayOf(1, 2, 3), entry.readBytes()) + } + + @Test + fun `directory children can be listed and cleared`() { + val root = InMemoryAssetEntry("root", isDirectory = true) + root.addChild(InMemoryAssetEntry("root/a.txt")) + root.addChild(InMemoryAssetEntry("root/b.txt")) + + assertEquals(listOf("a.txt", "b.txt"), root.list().map { it.name }) + + root.clearChildren() + + assertTrue(root.list().isEmpty()) + } + + @Test + fun `non directory cannot accept children`() { + val file = InMemoryAssetEntry("file.txt") + + assertFailsWith { + file.addChild(InMemoryAssetEntry("other.txt")) + } + } +} diff --git a/tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/TestAssetEntryTests.kt b/tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/TestAssetEntryTests.kt new file mode 100644 index 0000000..f067099 --- /dev/null +++ b/tooling/devtools/src/test/kotlin/io/canopy/devtools/data/assets/TestAssetEntryTests.kt @@ -0,0 +1,50 @@ +package io.canopy.devtools.data.assets + +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalPathApi::class) +class TestAssetEntryTests { + + private val tempDirs = mutableListOf() + + @AfterTest + fun cleanup() { + tempDirs.forEach { it.deleteRecursively() } + tempDirs.clear() + } + + @Test + fun `entry creates parent directories and reads writes content`() { + val root = createTempDirectory("canopy-test-asset") + tempDirs.add(root) + val file = root.resolve("nested/data.bin").toFile() + val entry = TestAssetEntry(file) + + entry.writeText("hello", append = false) + entry.writeBytes(byteArrayOf(1, 2), append = true) + + assertTrue(entry.exists()) + assertEquals("data.bin", entry.name) + assertEquals("bin", entry.extension) + assertContentEquals("hello".encodeToByteArray() + byteArrayOf(1, 2), entry.readBytes()) + } + + @Test + fun `directory lists nested files`() { + val root = createTempDirectory("canopy-test-asset-dir") + tempDirs.add(root) + root.resolve("a.txt").toFile().writeText("a") + root.resolve("b.txt").toFile().writeText("b") + + val entry = TestAssetEntry(root.toFile()) + + assertEquals(listOf("a.txt", "b.txt"), entry.list().map { it.name }.sorted()) + } +} diff --git a/tooling/utils/src/test/kotlin/io/canopy/tooling/utils/PropertyUtilsTests.kt b/tooling/utils/src/test/kotlin/io/canopy/tooling/utils/PropertyUtilsTests.kt new file mode 100644 index 0000000..5e73e51 --- /dev/null +++ b/tooling/utils/src/test/kotlin/io/canopy/tooling/utils/PropertyUtilsTests.kt @@ -0,0 +1,21 @@ +package io.canopy.tooling.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PropertyUtilsTests { + + @Test + fun `loadClasspathProperties returns properties when resource exists`() { + val properties = loadClasspathProperties("sample-test.properties") + + assertEquals("canopy", properties?.getProperty("name")) + assertEquals("90", properties?.getProperty("target.coverage")) + } + + @Test + fun `loadClasspathProperties returns null when resource is missing`() { + assertNull(loadClasspathProperties("missing-resource.properties")) + } +} diff --git a/tooling/utils/src/test/resources/sample-test.properties b/tooling/utils/src/test/resources/sample-test.properties new file mode 100644 index 0000000..83bc29c --- /dev/null +++ b/tooling/utils/src/test/resources/sample-test.properties @@ -0,0 +1,2 @@ +name=canopy +target.coverage=90 From 30068b43c76c1a48eaf099582d9e39d4202e438b Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 14 Apr 2026 00:04:29 +0100 Subject: [PATCH 7/9] refactor(core): persist viewport state, improve lookup tracing, and rename test nodes --- .../engine/core/managers/SceneManager.kt | 10 ++ .../io/canopy/engine/core/nodes/Node.kt | 10 +- .../core/nodes/types/empty/EmptyNode.kt | 6 - .../core/nodes/types/empty/EmptyNode2D.kt | 5 - .../engine/core/nodes/types/empty/TestNode.kt | 6 + .../core/nodes/types/empty/TestNode2D.kt | 5 + .../canopy/engine/core/flows/ContextTests.kt | 26 +-- .../engine/core/managers/SceneManagerTests.kt | 22 +-- .../io/canopy/engine/core/nodes/NodeTests.kt | 160 +++++++++--------- .../engine/core/nodes/TreeSystemTests.kt | 14 +- .../backends/graphics/AnimationTests.kt | 6 +- .../backends/graphics/SpriteAnimationTests.kt | 4 +- 12 files changed, 146 insertions(+), 128 deletions(-) delete mode 100644 engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt delete mode 100644 engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt create mode 100644 engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode.kt create mode 100644 engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode2D.kt diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt index 5a1cd1e..4da20b0 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt @@ -69,6 +69,14 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: /** Emitted after the scene root is replaced. Payload is the new root (or null). */ val onSceneReplaced = event?>() + /** Current viewport width. Updated in [resize]. */ + var viewportWidth: Int = 0 + private set + + /** Current viewport height. Updated in [resize]. */ + var viewportHeight: Int = 0 + private set + /* ============================================================ * Scene state * ============================================================ */ @@ -459,6 +467,8 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * Emits resize event for listeners (UI/layout/camera systems). */ fun resize(width: Int, height: Int) { + viewportWidth = width + viewportHeight = height onResize.emit(width, height) log.debug("event" to "scene.resize", "width" to width, "height" to height) { "Resize" } } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index 53c9d59..f0b5ac2 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -340,7 +340,12 @@ abstract class Node> protected constructor( */ fun Node<*>.findVisibleParent(): Node<*>? { var p = parent - while (p?.skipOnSearch == true) p = p.parent + while (p?.skipOnSearch == true) { + log.trace("event" to "node.lookup_skip_parent_wrapper", "wrapper" to p.name) { + "Skipping transparent parent wrapper" + } + p = p.parent + } return p } @@ -362,6 +367,9 @@ abstract class Node> protected constructor( for (child in children.values) { if (child.skipOnSearch) { + log.trace("event" to "node.lookup_skip_wrapper", "wrapper" to child.name, "seeking" to name) { + "Skipping transparent wrapper to seek child" + } child.findVisibleChild(name)?.let { return it } } } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt deleted file mode 100644 index 5a1dd0f..0000000 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.canopy.engine.core.nodes.types.empty - -import io.canopy.engine.core.nodes.Node - -/** Empty Node with no Behavior **/ -class EmptyNode(name: String, block: EmptyNode.() -> Unit = {}) : Node(name, block = block) diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt deleted file mode 100644 index 149e50d..0000000 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.canopy.engine.core.nodes.types.empty - -import io.canopy.engine.core.nodes.Node2D - -class EmptyNode2D(name: String, block: EmptyNode2D.() -> Unit = {}) : Node2D(name, block) diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode.kt new file mode 100644 index 0000000..db6d0bd --- /dev/null +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode.kt @@ -0,0 +1,6 @@ +package io.canopy.engine.core.nodes.types.empty + +import io.canopy.engine.core.nodes.Node + +/** Test/utility Node with no Behavior **/ +class TestNode(name: String, block: TestNode.() -> Unit = {}) : Node(name, block = block) diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode2D.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode2D.kt new file mode 100644 index 0000000..ceb9d03 --- /dev/null +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/TestNode2D.kt @@ -0,0 +1,5 @@ +package io.canopy.engine.core.nodes.types.empty + +import io.canopy.engine.core.nodes.Node2D + +class TestNode2D(name: String, block: TestNode2D.() -> Unit = {}) : Node2D(name, block) diff --git a/engine/src/test/kotlin/io/canopy/engine/core/flows/ContextTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/flows/ContextTests.kt index e1a71fb..94932a3 100644 --- a/engine/src/test/kotlin/io/canopy/engine/core/flows/ContextTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/core/flows/ContextTests.kt @@ -6,7 +6,7 @@ import java.util.concurrent.atomic.AtomicBoolean import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.SceneManager import io.canopy.engine.core.nodes.Node -import io.canopy.engine.core.nodes.types.empty.EmptyNode +import io.canopy.engine.core.nodes.types.empty.TestNode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -34,7 +34,7 @@ class ContextTests { // --- Helpers ------------------------------------------------------------ - private fun n(name: String, block: EmptyNode.() -> Unit = {}) = EmptyNode(name, block) + private fun n(name: String, block: TestNode.() -> Unit = {}) = TestNode(name, block) private object DebugModeKey // any object key (no ::class) private object SeasonKey @@ -55,7 +55,7 @@ class ContextTests { root.buildTree() // Find the "child" node. Adjust if you have find-by-path utilities. - val child = root.getNode("./child") + val child = root.getNode("./child") val debug: Boolean = child.fromContext("debug") assertTrue(debug) @@ -74,7 +74,7 @@ class ContextTests { root.buildTree() - val b = root.getNode("./a/b") + val b = root.getNode("./a/b") val debug: Boolean = b.fromContext("debug") assertTrue(debug) @@ -97,8 +97,8 @@ class ContextTests { root.buildTree() - val a = root.getNode("./a") - val b = root.getNode("./b") + val a = root.getNode("./a") + val b = root.getNode("./b") val aDebug: Boolean = a.fromContext("debug") val bDebug: Boolean = b.fromContext("debug") @@ -113,7 +113,7 @@ class ContextTests { root.buildTree() - val child = root.getNode("./child") + val child = root.getNode("./child") val missing: String? = child.fromContextOrNull("nope") assertNull(missing) @@ -125,7 +125,7 @@ class ContextTests { root.buildTree() - val child = root.getNode("./child") + val child = root.getNode("./child") val ex = assertThrows { child.fromContext("missing") @@ -147,7 +147,7 @@ class ContextTests { root.buildTree() - val child = root.getNode("./child") + val child = root.getNode("./child") val debug: Boolean = child.fromContext("debugMode") val season: String = child.fromContext("season") @@ -168,7 +168,7 @@ class ContextTests { root.buildTree() - val child = root.getNode("./child") + val child = root.getNode("./child") assertEquals(1, child.fromContext("keyA")) assertEquals(2, child.fromContext("keyB")) @@ -190,7 +190,7 @@ class ContextTests { root.buildTree() - val c = root.getNode("./a/b/c") + val c = root.getNode("./a/b/c") assertEquals(42, c.fromContext("x")) } @@ -209,7 +209,7 @@ class ContextTests { } root.buildTree() - val c = root.getNode("./a") + val c = root.getNode("./a") assertEquals(1, c.fromContext("keyA")) } @@ -224,7 +224,7 @@ class ContextTests { root.buildTree() - val a = root.getNode("./a") + val a = root.getNode("./a") val noHang = AtomicBoolean(false) diff --git a/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt index d4445db..b249f69 100644 --- a/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/core/managers/SceneManagerTests.kt @@ -8,7 +8,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import io.canopy.engine.core.nodes.Node import io.canopy.engine.core.nodes.TreeSystem -import io.canopy.engine.core.nodes.types.empty.EmptyNode +import io.canopy.engine.core.nodes.types.empty.TestNode class SceneManagerTests { @@ -16,7 +16,7 @@ class SceneManagerTests { phase: TreeSystem.UpdatePhase, priority: Int, private val calls: MutableList, - ) : TreeSystem(phase, priority, EmptyNode::class) { + ) : TreeSystem(phase, priority, TestNode::class) { override fun onRegister() { calls += "register:$priority" } @@ -59,8 +59,8 @@ class SceneManagerTests { register(sceneManager) } - val oldScene = EmptyNode("old") { EmptyNode("old-child") } - val newScene = EmptyNode("new") { EmptyNode("new-child") } + val oldScene = TestNode("old") { TestNode("old-child") } + val newScene = TestNode("new") { TestNode("new-child") } sceneManager.currScene = oldScene sceneManager.currScene = newScene @@ -82,7 +82,7 @@ class SceneManagerTests { ManagersRegistry.withScope { register(sceneManager) } - sceneManager.currScene = EmptyNode("root") + sceneManager.currScene = TestNode("root") calls.clear() sceneManager.tick(0.25f) @@ -103,7 +103,7 @@ class SceneManagerTests { register(sceneManager) } - val node = EmptyNode("root") + val node = TestNode("root") node.addGroup("old") sceneManager.currScene = node @@ -163,8 +163,8 @@ class SceneManagerTests { assertTrue(sceneManager.hasSystem(system::class)) assertEquals(system, sceneManager.getSystem(system::class)) - sceneManager.currScene = EmptyNode("first") - sceneManager.currScene = EmptyNode("second") + sceneManager.currScene = TestNode("first") + sceneManager.currScene = TestNode("second") assertEquals(listOf("first", "second"), replaced) @@ -193,7 +193,7 @@ class SceneManagerTests { register(sceneManager) } - val node = EmptyNode("root") + val node = TestNode("root") sceneManager.currScene = node // triggers registerSubtree assertTrue(calls.contains("add:root:1")) @@ -226,7 +226,7 @@ class SceneManagerTests { register(sceneManager) } - sceneManager.currScene = EmptyNode("root") + sceneManager.currScene = TestNode("root") calls.clear() // run delta enough to trigger physics tick @@ -240,7 +240,7 @@ class SceneManagerTests { @Test fun `removeFromGroup throws when group or node is invalid`() { val sceneManager = SceneManager() - val node = EmptyNode("root") + val node = TestNode("root") assertFailsWith { sceneManager.removeFromGroup("ghost", node) diff --git a/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt index 365ded8..fc88c7f 100644 --- a/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt @@ -6,8 +6,8 @@ import kotlin.time.toDuration import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.SceneManager import io.canopy.engine.core.managers.manager -import io.canopy.engine.core.nodes.types.empty.EmptyNode -import io.canopy.engine.core.nodes.types.empty.EmptyNode2D +import io.canopy.engine.core.nodes.types.empty.TestNode +import io.canopy.engine.core.nodes.types.empty.TestNode2D import io.canopy.engine.math.Vector2 import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -30,21 +30,21 @@ class NodeTests { @Test fun `structure should pass`() { // Verifies DSL-built hierarchy and parent pointers. - val scene = EmptyNode("root") { - EmptyNode("child-a") + val scene = TestNode("root") { + TestNode("child-a") - EmptyNode("child-b") { - EmptyNode("child-c") + TestNode("child-b") { + TestNode("child-c") } } scene.buildTree() assertEquals(2, scene.children.size) - assertSame(scene, scene.getNode("child-b").parent) + assertSame(scene, scene.getNode("child-b").parent) assertSame( - scene.getNode("child-b"), - scene.getNode("child-b/child-c").parent + scene.getNode("child-b"), + scene.getNode("child-b/child-c").parent ) } @@ -54,7 +54,7 @@ class NodeTests { val childCount: MutableMap = mutableMapOf() val lambdaBehavior = - createBehavior( + createBehavior( onReady = { val parent = parent ?: return@createBehavior childCount.merge(parent.name, 1) { old, new -> old + new } @@ -62,15 +62,15 @@ class NodeTests { } ) - EmptyNode("Test 2") { - EmptyNode("child-a") { + TestNode("Test 2") { + TestNode("child-a") { attachBehavior(lambdaBehavior) } - EmptyNode("child-b") { + TestNode("child-b") { attachBehavior(lambdaBehavior) - EmptyNode("child-c") { + TestNode("child-c") { attachBehavior(lambdaBehavior) } } @@ -94,21 +94,21 @@ class NodeTests { val callOrder = mutableListOf() val behaviour = - createBehavior( + createBehavior( onReady = { callOrder += name } ) - EmptyNode("Test 2") { + TestNode("Test 2") { attachBehavior(behaviour) - EmptyNode("child-a") { + TestNode("child-a") { attachBehavior(behaviour) } - EmptyNode("child-b") { + TestNode("child-b") { attachBehavior(behaviour) - EmptyNode("child-c") { + TestNode("child-c") { attachBehavior(behaviour) } } @@ -132,12 +132,12 @@ class NodeTests { var nPhysicsTicks = 0 val behavior = - createBehavior( + createBehavior( onUpdate = { nTicks++ }, onPhysicsUpdate = { nPhysicsTicks++ } ) - val tree = EmptyNode("root") { + val tree = TestNode("root") { attachBehavior(behavior) } tree.buildTree() @@ -164,16 +164,16 @@ class NodeTests { var wasCalled = false val behavior = - createBehavior( + createBehavior( onReady = { wasCalled = true } ) - val root = EmptyNode("root") + val root = TestNode("root") root.buildTree() assertFalse(wasCalled) - root += EmptyNode("child") { + root += TestNode("child") { attachBehavior(behavior) } @@ -186,13 +186,13 @@ class NodeTests { var wasCalled = false val behavior = - createBehavior( + createBehavior( onExitTree = { wasCalled = true } ) val tree = - EmptyNode("root") { - EmptyNode("child") { + TestNode("root") { + TestNode("child") { attachBehavior(behavior) } }.asSceneRoot() @@ -208,13 +208,13 @@ class NodeTests { fun `queue free should delete node`() { // Verifies queueFree removes a node from its parent. val tree = - EmptyNode("root") { - EmptyNode("child") + TestNode("root") { + TestNode("child") } tree.buildTree() - val child = tree.getNode("child") + val child = tree.getNode("child") assertNotNull(child) child.queueFree() @@ -228,13 +228,13 @@ class NodeTests { class CustomScene(name: String = "custom", block: CustomScene.() -> Unit = {}) : Node(name, block = block) { override fun create() { - EmptyNode("empty") + TestNode("empty") } } val customScene = CustomScene { - EmptyNode("child") + TestNode("child") } customScene.buildTree() @@ -248,19 +248,19 @@ class NodeTests { class CustomScene(name: String = "custom", block: CustomScene.() -> Unit = {}) : Node(name, block = block) { override fun create() { - EmptyNode2D("empty") + TestNode2D("empty") } } val node = CustomScene { - patch("./empty") { + patch("./empty") { name = "patched" position = Vector2(100f, 100f) } } node.buildTree() - val child = node.getNode("./patched") + val child = node.getNode("./patched") assertEquals("patched", child.name) assertEquals(Vector2(100f, 100f), child.position) @@ -286,31 +286,31 @@ class NodeTests { @Test fun `rename reparent and relative lookups should update paths`() { - val root = EmptyNode("root") { - EmptyNode("a") - EmptyNode("b") + val root = TestNode("root") { + TestNode("a") + TestNode("b") } root.buildTree() - val a = root.getNode("a") + val a = root.getNode("a") a.name = "renamed" assertEquals("/root/renamed", a.path) - assertSame(a, root.getNode("renamed")) + assertSame(a, root.getNode("renamed")) - root.reparent(a, root.getNode("b")) + root.reparent(a, root.getNode("b")) assertEquals("/root/b/renamed", a.path) - assertSame(a, root.getNode("b/renamed")) - assertSame(root.getNode("b"), a.getNode("..")) + assertSame(a, root.getNode("b/renamed")) + assertSame(root.getNode("b"), a.getNode("..")) } @Test fun `prefab child skips lifecycle until built manually`() { var readyCalls = 0 - val root = EmptyNode("root") + val root = TestNode("root") root.buildTree() - val prefab = EmptyNode("child") { + val prefab = TestNode("child") { behavior(onReady = { readyCalls++ }) }.asPrefab() @@ -323,7 +323,7 @@ class NodeTests { @Test fun `group changes on built node should mirror through scene manager`() { - val root = EmptyNode("root") + val root = TestNode("root") root.asSceneRoot() root.buildTree() @@ -341,30 +341,30 @@ class NodeTests { @Test fun `getNode allows complex resolution with relative and wrapper paths`() { - val root = EmptyNode("root") { - EmptyNode("visibleChild") { + val root = TestNode("root") { + TestNode("visibleChild") { // Not visible/skipOnSearch = true (using empty node for now but just assume we test relative resolution) - EmptyNode("subChild") + TestNode("subChild") } }.asSceneRoot() root.buildTree() - val child = root.getNode("./visibleChild/subChild") + val child = root.getNode("./visibleChild/subChild") assertEquals("subChild", child.name) - val sibling = child.getNode("..") + val sibling = child.getNode("..") assertEquals("visibleChild", sibling.name) - val backToRoot = sibling.getNode("..") + val backToRoot = sibling.getNode("..") assertEquals("root", backToRoot.name) } @Test fun `unary plus and unary minus operators work`() { - val child1 = EmptyNode("child1").asPrefab() - val child2 = EmptyNode("child2").asPrefab() + val child1 = TestNode("child1").asPrefab() + val child2 = TestNode("child2").asPrefab() - val root = EmptyNode("root") { + val root = TestNode("root") { +child1 +child2 } @@ -379,8 +379,8 @@ class NodeTests { @Test fun `plusAssign and minusAssign operators work`() { - val root = EmptyNode("root") - val child = EmptyNode("child") + val root = TestNode("root") + val child = TestNode("child") root += child assertEquals(1, root.children.size) @@ -391,8 +391,8 @@ class NodeTests { @Test fun `plus operator returns parent`() { - val root = EmptyNode("root") - val child = EmptyNode("child") + val root = TestNode("root") + val child = TestNode("child") with(root) { val returned = this + child @@ -403,22 +403,22 @@ class NodeTests { @Test fun `hasChildType works correctly`() { - val root = EmptyNode("root") { - EmptyNode2D("a2d") + val root = TestNode("root") { + TestNode2D("a2d") } root.buildTree() - assertTrue(root.hasChildType(EmptyNode2D::class)) - assertFalse(root.hasChildType(EmptyNode::class)) // children does not include EmptyNode + assertTrue(root.hasChildType(TestNode2D::class)) + assertFalse(root.hasChildType(TestNode::class)) // children does not include TestNode } @Test fun `runBehavior catches and logs exceptions`() { - val behavior = createBehavior( + val behavior = createBehavior( onReady = { throw RuntimeException("Throwing behavior") } ) - val root = EmptyNode("root") { + val root = TestNode("root") { attachBehavior(behavior) } @@ -429,33 +429,33 @@ class NodeTests { @Test fun `getNode supports complex path resolution`() { - val target = EmptyNode("target") - val child = EmptyNode("child") { +target } - val wrapper = EmptyNode("wrapper") { +child } - val root = EmptyNode("root") { + val target = TestNode("target") + val child = TestNode("child") { +target } + val wrapper = TestNode("wrapper") { +child } + val root = TestNode("root") { +wrapper - EmptyNode("sibling") + TestNode("sibling") } root.buildTree() // From target's perspective - assertEquals(target, target.getNode(".")) - assertEquals(target, target.getNode("")) - assertEquals(child, target.getNode("..")) + assertEquals(target, target.getNode(".")) + assertEquals(target, target.getNode("")) + assertEquals(child, target.getNode("..")) // Resolving parent, skipping wrapper - assertEquals(wrapper, target.getNode("../..")) + assertEquals(wrapper, target.getNode("../..")) // Resolving up and down - assertEquals(root.getNode("sibling"), target.getNode("../../../sibling")) + assertEquals(root.getNode("sibling"), target.getNode("../../../sibling")) // From root's perspective - assertEquals(target, root.getNode("wrapper/child/target")) - assertEquals(target, wrapper.getNode("child/target")) // Because wrapper is skipped + assertEquals(target, root.getNode("wrapper/child/target")) + assertEquals(target, wrapper.getNode("child/target")) // Because wrapper is skipped // Invalid paths - assertNull(root.getNodeOrNull("missing")) - assertNull(root.getNodeOrNull("../missing")) + assertNull(root.getNodeOrNull("missing")) + assertNull(root.getNodeOrNull("../missing")) // Root path fallback - assertEquals(root, root.getNode("/")) + assertEquals(root, root.getNode("/")) } } diff --git a/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt index 3003217..34a8f43 100644 --- a/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/core/nodes/TreeSystemTests.kt @@ -3,7 +3,7 @@ package io.canopy.engine.core.nodes import kotlin.test.* import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.SceneManager -import io.canopy.engine.core.nodes.types.empty.EmptyNode +import io.canopy.engine.core.nodes.types.empty.TestNode import org.junit.jupiter.api.BeforeAll class TreeSystemTests { @@ -23,7 +23,7 @@ class TreeSystemTests { val callOrder = mutableListOf() var errorThrown = false - val system = object : TreeSystem(UpdatePhase.FramePre, 0, EmptyNode::class) { + val system = object : TreeSystem(UpdatePhase.FramePre, 0, TestNode::class) { override fun onRegister() { callOrder += "onRegister" } @@ -50,10 +50,10 @@ class TreeSystemTests { system.onRegister() - val node1 = EmptyNode("node1") + val node1 = TestNode("node1") node1.buildTree() - val nodeFail = EmptyNode("fail") + val nodeFail = TestNode("fail") nodeFail.buildTree() // Registration @@ -91,7 +91,7 @@ class TreeSystemTests { var unregistered = false var matched = 0 - val sys = object : TreeSystem(UpdatePhase.FramePre, 0, EmptyNode::class) { + val sys = object : TreeSystem(UpdatePhase.FramePre, 0, TestNode::class) { override fun onRegister() { registered = true } @@ -106,7 +106,7 @@ class TreeSystemTests { sys.onRegister() assertTrue(registered) - val node = EmptyNode("test") + val node = TestNode("test") node.buildTree() sys.register(node) @@ -124,7 +124,7 @@ class TreeSystemTests { val sys = object : TreeSystem(UpdatePhase.FramePre, 0, CustomType::class) {} - val node1 = EmptyNode("root") { + val node1 = TestNode("root") { CustomType() } node1.buildTree() diff --git a/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/AnimationTests.kt b/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/AnimationTests.kt index 9735cab..0550444 100644 --- a/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/AnimationTests.kt +++ b/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/AnimationTests.kt @@ -7,7 +7,7 @@ import com.badlogic.gdx.math.Vector2 import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.SceneManager import io.canopy.engine.core.managers.lazyManager -import io.canopy.engine.core.nodes.types.empty.EmptyNode +import io.canopy.engine.core.nodes.types.empty.TestNode import io.canopy.engine.graphics.nodes.animation.tracks.ActionTrack import io.canopy.engine.graphics.nodes.animation.tracks.PropertyTrack import io.canopy.engine.utils.UnstableApi @@ -33,7 +33,7 @@ class AnimationTests { @Test fun `animation structure should work`() { - val emptyNode = EmptyNode("node") + val emptyNode = TestNode("node") val animation = Animation("anim", 1.5f) { @@ -49,7 +49,7 @@ class AnimationTests { @Test fun `test animation`() { val emptyNode = - EmptyNode("node") { + TestNode("node") { AnimationPlayer("player") } var counter = 0 diff --git a/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/SpriteAnimationTests.kt b/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/SpriteAnimationTests.kt index 6b76cf3..aef2d7f 100644 --- a/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/SpriteAnimationTests.kt +++ b/platforms/desktop/src/test/kotlin/io/github/canopy/backends/graphics/SpriteAnimationTests.kt @@ -8,7 +8,7 @@ import io.canopy.engine.app.test.testHeadlessApp import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.lazyManager import io.canopy.engine.core.nodes.treeSystem -import io.canopy.engine.core.nodes.types.empty.EmptyNode +import io.canopy.engine.core.nodes.types.empty.TestNode import io.canopy.engine.data.core.assets.AssetsManager import io.canopy.engine.graphics.nodes.animation.tracks.ActionTrack import io.canopy.engine.graphics.nodes.animation.tracks.SpriteTrack @@ -40,7 +40,7 @@ class SpriteAnimationTests { @Test fun `should create empty animation`() { val emptyNode = - EmptyNode("root") { + TestNode("root") { AnimatedSprite2D("sprite") AnimationPlayer("player") } From ad67ffa76fbf273d80c9cdb0e2691a0a0b35e9ce Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira <97637719+GuilhermeF03@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:03:04 +0100 Subject: [PATCH 8/9] Fixed ktlint format build error --- .../kotlin/io/canopy/engine/core/nodes/Node.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index f0b5ac2..21f8950 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -393,13 +393,23 @@ abstract class Node> protected constructor( else -> { val node = current if (node == null) { - log.error("event" to "node.lookup_null_current", "path" to path, "part" to part, "base" to this.path) { + log.error( + "event" to "node.lookup_null_current", + "path" to path, + "part" to part, + "base" to this.path + ) { "Base node went null while resolving path '$path' at part '$part'" } null } else { node.findVisibleChild(part) ?: null.also { - log.error("event" to "node.lookup_child_not_found", "path" to path, "child" to part, "base" to this.path) { + log.error( + "event" to "node.lookup_child_not_found", + "path" to path, + "child" to part, + "base" to this.path + ) { "Child '$part' not found while resolving path '$path' from '${this.path}'" } } From 1ddad24052feb0e251b92293759fcdeb68aba432 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira <97637719+GuilhermeF03@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:04:57 +0100 Subject: [PATCH 9/9] Removed redundant nullable get --- engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index 21f8950..ff114e7 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -424,7 +424,7 @@ abstract class Node> protected constructor( /** * Kotlin shorthand: `node["Player/Weapon"]` */ - inline operator fun > get(path: String): T? = getNode(path) + inline operator fun > get(path: String): T? = getNodeOrNull(path) /* ============================================================ * Prefab / instancing @@ -658,8 +658,7 @@ abstract class Node> protected constructor( // fun groups(vararg groups: String) = apply { groups.forEach { addGroup(it) } } - fun > patch(path: String, handler: T.() -> Unit) = getNode(path)?.apply(handler) - ?: throw IllegalArgumentException("Node at path '$path' not found for patching.") + fun > patch(path: String, handler: T.() -> Unit) = getNode(path).apply(handler) /* ------------------------------------------------------------------ * Top-level DSL helpers