diff options
Diffstat (limited to 'composeApp/src/commonTest/kotlin/coffee/liz/ecs')
3 files changed, 596 insertions, 0 deletions
diff --git a/composeApp/src/commonTest/kotlin/coffee/liz/ecs/DAGWorldTest.kt b/composeApp/src/commonTest/kotlin/coffee/liz/ecs/DAGWorldTest.kt new file mode 100644 index 0000000..737e386 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/coffee/liz/ecs/DAGWorldTest.kt @@ -0,0 +1,242 @@ +package coffee.liz.ecs + +import kotlin.reflect.KClass +import kotlin.test.* + +// Test systems for circular dependency test +class CircularSystemA : System { + override val dependencies: Set<KClass<out System>> + get() = setOf(CircularSystemB::class) + override fun update(world: World, deltaTime: Float) {} +} + +class CircularSystemB : System { + override val dependencies: Set<KClass<out System>> + get() = setOf(CircularSystemA::class) + override fun update(world: World, deltaTime: Float) {} +} + +class DAGWorldTest { + + @Test + fun `can create entities with unique ids`() { + val world = DAGWorld(emptyList()) + + val entity1 = world.createEntity() + val entity2 = world.createEntity() + + assertNotEquals(entity1.id, entity2.id) + } + + @Test + fun `can destroy entity`() { + val world = DAGWorld(emptyList()) + val entity = world.createEntity() + entity.add(Position(10f, 20f)) + + world.destroyEntity(entity) + + val results = world.query<Position>() + assertFalse(results.contains(entity)) + } + + @Test + fun `query returns entities with matching components`() { + val world = DAGWorld(emptyList()) + val entity1 = world.createEntity().add(Position(10f, 20f)) + val entity2 = world.createEntity().add(Position(30f, 40f)) + val entity3 = world.createEntity().add(Velocity(1f, 2f)) + + world.update(0f) // Trigger cache update + + val results = world.query<Position>() + + assertEquals(2, results.size) + assertTrue(results.contains(entity1)) + assertTrue(results.contains(entity2)) + assertFalse(results.contains(entity3)) + } + + @Test + fun `query with multiple components returns intersection`() { + val world = DAGWorld(emptyList()) + val entity1 = world.createEntity() + .add(Position(10f, 20f)) + .add(Velocity(1f, 2f)) + val entity2 = world.createEntity() + .add(Position(30f, 40f)) + val entity3 = world.createEntity() + .add(Velocity(3f, 4f)) + + world.update(0f) // Trigger cache update + + val results = world.query<Position, Velocity>() + + assertEquals(1, results.size) + assertTrue(results.contains(entity1)) + } + + @Test + fun `systems execute in dependency order`() { + val executionOrder = mutableListOf<String>() + + val systemA = object : System { + override fun update(world: World, deltaTime: Float) { + executionOrder.add("A") + } + } + + val systemB = object : System { + override val dependencies = setOf(systemA::class) + override fun update(world: World, deltaTime: Float) { + executionOrder.add("B") + } + } + + val systemC = object : System { + override val dependencies = setOf(systemB::class) + override fun update(world: World, deltaTime: Float) { + executionOrder.add("C") + } + } + + val world = DAGWorld(listOf(systemC, systemA, systemB)) + world.update(0f) + + assertEquals(listOf("A", "B", "C"), executionOrder) + } + + @Test + fun `systems with no dependencies can execute in any order`() { + val executionOrder = mutableListOf<String>() + + val systemA = object : System { + override fun update(world: World, deltaTime: Float) { + executionOrder.add("A") + } + } + + val systemB = object : System { + override fun update(world: World, deltaTime: Float) { + executionOrder.add("B") + } + } + + val world = DAGWorld(listOf(systemA, systemB)) + world.update(0f) + + assertEquals(2, executionOrder.size) + assertTrue(executionOrder.contains("A")) + assertTrue(executionOrder.contains("B")) + } + + @Test + fun `circular dependency throws exception`() { + val systemA = CircularSystemA() + val systemB = CircularSystemB() + + assertFailsWith<IllegalArgumentException> { + DAGWorld(listOf(systemA, systemB)) + } + } + + @Test + fun `system receives correct deltaTime`() { + var receivedDelta = 0f + + val system = object : System { + override fun update(world: World, deltaTime: Float) { + receivedDelta = deltaTime + } + } + + val world = DAGWorld(listOf(system)) + world.update(0.016f) + + assertEquals(0.016f, receivedDelta) + } + + @Test + fun `systems can query entities during update`() { + var queriedEntities: Set<Entity>? = null + + val system = object : System { + override fun update(world: World, deltaTime: Float) { + queriedEntities = world.query<Position>() + } + } + + val world = DAGWorld(listOf(system)) + val entity = world.createEntity().add(Position(10f, 20f)) + + world.update(0f) + + assertNotNull(queriedEntities) + assertEquals(1, queriedEntities!!.size) + assertTrue(queriedEntities!!.contains(entity)) + } + + @Test + fun `component cache updates after entity changes`() { + val world = DAGWorld(emptyList()) + val entity = world.createEntity() + + world.update(0f) + assertEquals(0, world.query<Position>().size) + + entity.add(Position(10f, 20f)) + world.update(0f) + assertEquals(1, world.query<Position>().size) + + entity.remove<Position>() + world.update(0f) + assertEquals(0, world.query<Position>().size) + } + + @Test + fun `diamond dependency resolves correctly`() { + val executionOrder = mutableListOf<String>() + + // A + // / \ + // B C + // \ / + // D + + val systemA = object : System { + override fun update(world: World, deltaTime: Float) { + executionOrder.add("A") + } + } + + val systemB = object : System { + override val dependencies = setOf(systemA::class) + override fun update(world: World, deltaTime: Float) { + executionOrder.add("B") + } + } + + val systemC = object : System { + override val dependencies = setOf(systemA::class) + override fun update(world: World, deltaTime: Float) { + executionOrder.add("C") + } + } + + val systemD = object : System { + override val dependencies = setOf(systemB::class, systemC::class) + override fun update(world: World, deltaTime: Float) { + executionOrder.add("D") + } + } + + val world = DAGWorld(listOf(systemD, systemC, systemB, systemA)) + world.update(0f) + + // A must come first, D must come last, B and C in between + assertEquals("A", executionOrder.first()) + assertEquals("D", executionOrder.last()) + assertTrue(executionOrder.indexOf("B") < executionOrder.indexOf("D")) + assertTrue(executionOrder.indexOf("C") < executionOrder.indexOf("D")) + } +} diff --git a/composeApp/src/commonTest/kotlin/coffee/liz/ecs/EntityTest.kt b/composeApp/src/commonTest/kotlin/coffee/liz/ecs/EntityTest.kt new file mode 100644 index 0000000..e807bd2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/coffee/liz/ecs/EntityTest.kt @@ -0,0 +1,116 @@ +package coffee.liz.ecs + +import kotlin.test.* + +// Test components +data class Position(val x: Float, val y: Float) : Component +data class Velocity(val dx: Float, val dy: Float) : Component +data class Health(val value: Int) : Component + +class EntityTest { + + @Test + fun `entity has unique id`() { + val entity1 = Entity(1) + val entity2 = Entity(2) + + assertNotEquals(entity1.id, entity2.id) + } + + @Test + fun `can add component to entity`() { + val entity = Entity(0) + val position = Position(10f, 20f) + + entity.add(position) + + assertTrue(entity.has<Position>()) + assertEquals(position, entity.get<Position>()) + } + + @Test + fun `can remove component from entity`() { + val entity = Entity(0) + entity.add(Position(10f, 20f)) + + entity.remove<Position>() + + assertFalse(entity.has<Position>()) + assertNull(entity.get<Position>()) + } + + @Test + fun `add returns entity for chaining`() { + val entity = Entity(0) + + val result = entity + .add(Position(10f, 20f)) + .add(Velocity(1f, 2f)) + + assertSame(entity, result) + assertTrue(entity.has<Position>()) + assertTrue(entity.has<Velocity>()) + } + + @Test + fun `can replace component by adding same type`() { + val entity = Entity(0) + entity.add(Position(10f, 20f)) + entity.add(Position(30f, 40f)) + + val position = entity.get<Position>() + + assertNotNull(position) + assertEquals(30f, position.x) + assertEquals(40f, position.y) + } + + @Test + fun `hasAll returns true when entity has all components`() { + val entity = Entity(0) + entity.add(Position(10f, 20f)) + entity.add(Velocity(1f, 2f)) + + assertTrue(entity.hasAll(Position::class, Velocity::class)) + } + + @Test + fun `hasAll returns false when entity missing component`() { + val entity = Entity(0) + entity.add(Position(10f, 20f)) + + assertFalse(entity.hasAll(Position::class, Velocity::class)) + } + + @Test + fun `componentTypes returns all component types`() { + val entity = Entity(0) + entity.add(Position(10f, 20f)) + entity.add(Velocity(1f, 2f)) + entity.add(Health(100)) + + val types = entity.componentTypes() + + assertEquals(3, types.size) + assertTrue(types.contains(Position::class)) + assertTrue(types.contains(Velocity::class)) + assertTrue(types.contains(Health::class)) + } + + @Test + fun `entities with same id are equal`() { + val entity1 = Entity(1) + val entity2 = Entity(1) + + assertEquals(entity1, entity2) + assertEquals(entity1.hashCode(), entity2.hashCode()) + } + + @Test + fun `entities with different id are not equal`() { + val entity1 = Entity(1) + val entity2 = Entity(2) + + assertNotEquals(entity1, entity2) + } +} diff --git a/composeApp/src/commonTest/kotlin/coffee/liz/ecs/animation/AnimationSystemTest.kt b/composeApp/src/commonTest/kotlin/coffee/liz/ecs/animation/AnimationSystemTest.kt new file mode 100644 index 0000000..bb7298e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/coffee/liz/ecs/animation/AnimationSystemTest.kt @@ -0,0 +1,238 @@ +package coffee.liz.ecs.animation + +import coffee.liz.ecs.DAGWorld +import kotlin.test.* + +class AnimationSystemTest { + + private fun createTestSpriteSheet(): SpriteSheet { + return SpriteSheet( + imagePath = "test.png", + frames = mapOf( + FrameName("idle_0") to Rect(0, 0, 32, 32), + FrameName("idle_1") to Rect(32, 0, 32, 32), + FrameName("walk_0") to Rect(0, 32, 32, 32), + FrameName("walk_1") to Rect(32, 32, 32, 32), + FrameName("walk_2") to Rect(64, 32, 32, 32) + ) + ) + } + + private fun createTestAnimator(): Animator { + return Animator( + clips = mapOf( + AnimationName("idle") to AnimationClip( + frameNames = listOf(FrameName("idle_0"), FrameName("idle_1")), + frameDuration = 0.1f, + loopMode = LoopMode.LOOP + ), + AnimationName("walk") to AnimationClip( + frameNames = listOf( + FrameName("walk_0"), + FrameName("walk_1"), + FrameName("walk_2") + ), + frameDuration = 0.15f, + loopMode = LoopMode.LOOP + ) + ), + currentClip = AnimationName("idle") + ) + } + + @Test + fun `animation advances frame after frame duration`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = createTestAnimator() + entity.add(createTestSpriteSheet()) + entity.add(animator) + + assertEquals(0, animator.frameIndex) + assertEquals(0f, animator.elapsed) + + // Update with less than frame duration - should not advance + world.update(0.05f) + assertEquals(0, animator.frameIndex) + assertEquals(0.05f, animator.elapsed) + + // Update to exceed frame duration - should advance to frame 1 + world.update(0.06f) + assertEquals(1, animator.frameIndex) + assertTrue(animator.elapsed < 0.1f) + } + + @Test + fun `LOOP mode wraps to beginning`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = createTestAnimator() + entity.add(createTestSpriteSheet()) + entity.add(animator) + + // Idle animation has 2 frames at 0.1s each + world.update(0.1f) // Frame 1 + assertEquals(1, animator.frameIndex) + + world.update(0.1f) // Should wrap to Frame 0 + assertEquals(0, animator.frameIndex) + assertTrue(animator.playing) // Still playing + } + + @Test + fun `ONCE mode stops at last frame`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = Animator( + clips = mapOf( + AnimationName("once") to AnimationClip( + frameNames = listOf(FrameName("idle_0"), FrameName("idle_1")), + frameDuration = 0.1f, + loopMode = LoopMode.ONCE + ) + ), + currentClip = AnimationName("once") + ) + entity.add(createTestSpriteSheet()) + entity.add(animator) + + world.update(0.1f) // Frame 1 + assertEquals(1, animator.frameIndex) + assertTrue(animator.playing) + + world.update(0.1f) // Should stop at frame 1 + assertEquals(1, animator.frameIndex) + assertFalse(animator.playing) + + // Further updates should not change anything + world.update(0.1f) + assertEquals(1, animator.frameIndex) + assertFalse(animator.playing) + } + + @Test + fun `PING_PONG mode bounces back and forth`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = Animator( + clips = mapOf( + AnimationName("pingpong") to AnimationClip( + frameNames = listOf( + FrameName("walk_0"), + FrameName("walk_1"), + FrameName("walk_2") + ), + frameDuration = 0.1f, + loopMode = LoopMode.PING_PONG + ) + ), + currentClip = AnimationName("pingpong") + ) + entity.add(createTestSpriteSheet()) + entity.add(animator) + + // Forward: 0 -> 1 -> 2 + world.update(0.1f) + assertEquals(1, animator.frameIndex) + assertEquals(1, animator.direction) + + world.update(0.1f) + assertEquals(2, animator.frameIndex) + assertEquals(1, animator.direction) + + // Hit end, should reverse: 2 -> 1 + world.update(0.1f) + assertEquals(1, animator.frameIndex) + assertEquals(-1, animator.direction) + + // Continue backward: 1 -> 0 + world.update(0.1f) + assertEquals(0, animator.frameIndex) + assertEquals(-1, animator.direction) + + // Hit beginning, should reverse: 0 -> 1 + world.update(0.1f) + assertEquals(1, animator.frameIndex) + assertEquals(1, animator.direction) + } + + @Test + fun `play() switches animation and resets state`() { + val animator = createTestAnimator() + assertEquals(AnimationName("idle"), animator.currentClip) + assertEquals(0, animator.frameIndex) + + // Manually advance + animator.frameIndex = 1 + animator.elapsed = 0.05f + + // Switch to walk animation + animator.play(AnimationName("walk")) + assertEquals(AnimationName("walk"), animator.currentClip) + assertEquals(0, animator.frameIndex) + assertEquals(0f, animator.elapsed) + assertEquals(1, animator.direction) + assertTrue(animator.playing) + } + + @Test + fun `pause() and resume() control playback`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = createTestAnimator() + entity.add(createTestSpriteSheet()) + entity.add(animator) + + animator.pause() + assertFalse(animator.playing) + + // Update should not advance animation + world.update(0.1f) + assertEquals(0, animator.frameIndex) + + animator.resume() + assertTrue(animator.playing) + + // Now it should advance + world.update(0.1f) + assertEquals(1, animator.frameIndex) + } + + @Test + fun `getCurrentFrameName returns correct frame`() { + val animator = createTestAnimator() + + assertEquals(FrameName("idle_0"), animator.getCurrentFrameName()) + + animator.frameIndex = 1 + assertEquals(FrameName("idle_1"), animator.getCurrentFrameName()) + + animator.play(AnimationName("walk")) + assertEquals(FrameName("walk_0"), animator.getCurrentFrameName()) + } + + @Test + fun `multiple updates in single frame work correctly`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = createTestAnimator() + entity.add(createTestSpriteSheet()) + entity.add(animator) + + // Large delta time should advance multiple frames + world.update(0.25f) // Should advance 2 frames and have 0.05s remainder + assertEquals(0, animator.frameIndex) // Wrapped back to 0 (LOOP mode) + assertTrue(animator.elapsed >= 0.04f && animator.elapsed <= 0.06f) + } + + @Test + fun `entities without SpriteSheet are skipped`() { + val world = DAGWorld(listOf(AnimationSystem())) + val entity = world.createEntity() + val animator = createTestAnimator() + entity.add(animator) // No SpriteSheet + + // Should not crash + world.update(0.1f) + } +} |
