package coffee.liz.ecs.animation import coffee.liz.ecs.DAGWorld import kotlin.test.* class AnimationSystemTest { private fun createTestSpriteSheet(): SpriteSheet = 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 = Animator( clips = mapOf( AnimationName("idle") to AnimationClip( frameNames = listOf(FrameName("idle_0"), FrameName("idle_1")), frameTicks = 0.1f, loopMode = LoopMode.LOOP, ), AnimationName("walk") to AnimationClip( frameNames = listOf( FrameName("walk_0"), FrameName("walk_1"), FrameName("walk_2"), ), frameTicks = 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.elapsedTicks) // Update with less than frame duration - should not advance world.update(0.05f) assertEquals(0, animator.frameIndex) assertEquals(0.05f, animator.elapsedTicks) // Update to exceed frame duration - should advance to frame 1 world.update(0.06f) assertEquals(1, animator.frameIndex) assertTrue(animator.elapsedTicks < 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")), frameTicks = 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"), ), frameTicks = 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.elapsedTicks = 0.05f // Switch to walk animation animator.play(AnimationName("walk")) assertEquals(AnimationName("walk"), animator.currentClip) assertEquals(0, animator.frameIndex) assertEquals(0f, animator.elapsedTicks) 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.elapsedTicks >= 0.04f && animator.elapsedTicks <= 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) } }