package.path = package.path .. ";./?.lua;./modules/?.lua" local originalSearchers = package.searchers or package.loaders table.insert(originalSearchers, 2, function(modname) if modname:match("^FlexLove%.modules%.") then local moduleName = modname:gsub("^FlexLove%.modules%.", "") return function() return require("modules." .. moduleName) end end end) require("testing.loveStub") local luaunit = require("testing.luaunit") local FlexLove = require("FlexLove") FlexLove.init() local Animation = FlexLove.Animation local Easing = Animation.Easing local Color = FlexLove.Color -- ============================================================================ -- Test Suite: Animation Validation and Error Handling -- ============================================================================ TestAnimationValidation = {} function TestAnimationValidation:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestAnimationValidation:tearDown() FlexLove.endFrame() end function TestAnimationValidation:test_new_with_invalid_props() -- Should handle non-table props gracefully local anim = Animation.new(nil) luaunit.assertNotNil(anim) luaunit.assertEquals(anim.duration, 1) local anim2 = Animation.new("invalid") luaunit.assertNotNil(anim2) luaunit.assertEquals(anim2.duration, 1) end function TestAnimationValidation:test_new_with_negative_duration() -- Should warn and use default duration (1 second) for invalid duration local anim = Animation.new({ duration = -1, start = { opacity = 0 }, final = { opacity = 1 }, }) luaunit.assertNotNil(anim) luaunit.assertEquals(anim.duration, 1) -- Default value end function TestAnimationValidation:test_new_with_zero_duration() -- Should warn and use default duration (1 second) for invalid duration local anim = Animation.new({ duration = 0, start = { opacity = 0 }, final = { opacity = 1 }, }) luaunit.assertNotNil(anim) luaunit.assertEquals(anim.duration, 1) -- Default value end function TestAnimationValidation:test_new_with_string_duration() -- Non-number duration local anim = Animation.new({ duration = "invalid", start = { x = 0 }, final = { x = 100 }, }) luaunit.assertEquals(anim.duration, 1) end function TestAnimationValidation:test_new_with_nil_duration() -- Duration is nil, should use default local anim = Animation.new({ duration = 0.0001, -- Very small instead of nil to avoid nil errors start = { opacity = 0 }, final = { opacity = 1 }, }) luaunit.assertNotNil(anim) end function TestAnimationValidation:test_new_with_invalid_start_final() -- Invalid start table local anim = Animation.new({ duration = 1, start = "invalid", final = { x = 100 }, }) luaunit.assertEquals(type(anim.start), "table") -- Invalid final table local anim2 = Animation.new({ duration = 1, start = { x = 0 }, final = "invalid", }) luaunit.assertEquals(type(anim2.final), "table") end function TestAnimationValidation:test_new_with_invalid_easing() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, easing = "invalidEasing", }) -- Should fallback to linear luaunit.assertNotNil(anim) anim:update(0.5) local result = anim:interpolate() -- Linear at 0.5 should be 0.5 luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) end function TestAnimationValidation:test_new_with_nil_easing() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, easing = nil, }) -- Should use linear as default luaunit.assertNotNil(anim) end function TestAnimationValidation:test_easing_string_and_function() -- Valid easing string local anim = Animation.new({ duration = 1, easing = "easeInQuad", start = { x = 0 }, final = { x = 100 }, }) luaunit.assertEquals(type(anim.easing), "function") -- Invalid easing string (should default to linear) local anim2 = Animation.new({ duration = 1, easing = "invalidEasing", start = { x = 0 }, final = { x = 100 }, }) luaunit.assertEquals(type(anim2.easing), "function") -- Custom easing function local customEasing = function(t) return t * t end local anim3 = Animation.new({ duration = 1, easing = customEasing, start = { x = 0 }, final = { x = 100 }, }) luaunit.assertEquals(anim3.easing, customEasing) end function TestAnimationValidation:test_new_with_missing_start_values() local anim = Animation.new({ duration = 1, start = {}, final = { opacity = 1 }, }) anim:update(0.5) local result = anim:interpolate() -- Should not have opacity since start.opacity is missing luaunit.assertNil(result.opacity) end function TestAnimationValidation:test_new_with_missing_final_values() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = {}, }) anim:update(0.5) local result = anim:interpolate() -- Should not have opacity since final.opacity is missing luaunit.assertNil(result.opacity) end function TestAnimationValidation:test_new_with_mismatched_properties() local anim = Animation.new({ duration = 1, start = { opacity = 0, width = 100 }, final = { opacity = 1 }, -- width missing }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.opacity) luaunit.assertNil(result.width) end -- ============================================================================ -- Test Suite: Animation Update and State Control -- ============================================================================ TestAnimationUpdate = {} function TestAnimationUpdate:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestAnimationUpdate:tearDown() FlexLove.endFrame() end function TestAnimationUpdate:test_update_with_negative_dt() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, }) anim:update(-0.5) -- Elapsed should be negative, but shouldn't crash luaunit.assertNotNil(anim.elapsed) end function TestAnimationUpdate:test_update_with_huge_dt() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, }) local done = anim:update(999999) luaunit.assertTrue(done) -- Should clamp to 1.0 local result = anim:interpolate() luaunit.assertAlmostEquals(result.opacity, 1.0, 0.01) end function TestAnimationUpdate:test_update_with_invalid_dt() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) -- Negative dt anim:update(-1) luaunit.assertEquals(anim.elapsed, 0) -- NaN dt anim:update(0 / 0) luaunit.assertEquals(anim.elapsed, 0) -- Infinite dt anim:update(math.huge) luaunit.assertEquals(anim.elapsed, 0) -- String dt (non-number) anim:update("invalid") luaunit.assertEquals(anim.elapsed, 0) end function TestAnimationUpdate:test_update_while_paused() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:pause() local complete = anim:update(0.5) luaunit.assertFalse(complete) luaunit.assertEquals(anim.elapsed, 0) end function TestAnimationUpdate:test_callbacks() local onStartCalled = false local onUpdateCalled = false local onCompleteCalled = false local anim = Animation.new({ duration = 0.1, start = { x = 0 }, final = { x = 100 }, onStart = function() onStartCalled = true end, onUpdate = function() onUpdateCalled = true end, onComplete = function() onCompleteCalled = true end, }) -- First update should trigger onStart anim:update(0.05) luaunit.assertTrue(onStartCalled) luaunit.assertTrue(onUpdateCalled) luaunit.assertFalse(onCompleteCalled) -- Complete the animation anim:update(0.1) luaunit.assertTrue(onCompleteCalled) end function TestAnimationUpdate:test_onCancel_callback() local onCancelCalled = false local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, onCancel = function() onCancelCalled = true end, }) anim:update(0.5) anim:cancel() luaunit.assertTrue(onCancelCalled) end function TestAnimationUpdate:test_pause_resume() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:update(0.5) local elapsed1 = anim.elapsed anim:pause() anim:update(0.5) luaunit.assertEquals(anim.elapsed, elapsed1) -- Should not advance anim:resume() anim:update(0.1) luaunit.assertTrue(anim.elapsed > elapsed1) -- Should advance end function TestAnimationUpdate:test_reverse() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:update(0.5) anim:reverse() luaunit.assertTrue(anim._reversed) -- Continue updating - it should go backwards anim:update(0.3) luaunit.assertTrue(anim.elapsed < 0.5) end function TestAnimationUpdate:test_setSpeed() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:setSpeed(2.0) luaunit.assertEquals(anim._speed, 2.0) -- Update with 0.1 seconds at 2x speed should advance 0.2 seconds anim:update(0.1) luaunit.assertAlmostEquals(anim.elapsed, 0.2, 0.01) end function TestAnimationUpdate:test_reset() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:update(0.7) luaunit.assertTrue(anim.elapsed > 0) anim:reset() luaunit.assertEquals(anim.elapsed, 0) luaunit.assertFalse(anim._hasStarted) end function TestAnimationUpdate:test_isPaused_isComplete() local anim = Animation.new({ duration = 0.5, start = { x = 0 }, final = { x = 100 }, }) luaunit.assertFalse(anim:isPaused()) anim:pause() luaunit.assertTrue(anim:isPaused()) anim:resume() luaunit.assertFalse(anim:isPaused()) local complete = anim:update(1.0) -- Complete it luaunit.assertTrue(complete) luaunit.assertEquals(anim:getState(), "completed") end function TestAnimationUpdate:test_delay() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:delay(0.5) -- Update during delay - animation should not start yet local result = anim:update(0.3) luaunit.assertFalse(result) luaunit.assertEquals(anim:getState(), "pending") -- Update past delay - animation should be ready to start anim:update(0.3) -- Now delay elapsed is > 0.5 luaunit.assertEquals(anim:getState(), "pending") -- Still pending until next update -- One more update to actually start anim:update(0.01) luaunit.assertEquals(anim:getState(), "playing") end -- ============================================================================ -- Test Suite: Animation Interpolation -- ============================================================================ TestAnimationInterpolation = {} function TestAnimationInterpolation:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestAnimationInterpolation:tearDown() FlexLove.endFrame() end function TestAnimationInterpolation:test_interpolate_before_update() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, }) -- Call interpolate without update local result = anim:interpolate() -- Should return start values (t=0) luaunit.assertAlmostEquals(result.opacity, 0, 0.01) end function TestAnimationInterpolation:test_interpolate_multiple_times_without_update() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, }) anim:update(0.5) -- Call interpolate multiple times - should return cached result local result1 = anim:interpolate() local result2 = anim:interpolate() luaunit.assertEquals(result1, result2) -- Should be same table luaunit.assertAlmostEquals(result1.opacity, 0.5, 0.01) end function TestAnimationInterpolation:test_apply_with_empty_table() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, }) -- Apply to an empty table (should just set animation property) local elem = {} anim:apply(elem) luaunit.assertNotNil(elem.animation) luaunit.assertEquals(elem.animation, anim) end function TestAnimationInterpolation:test_cached_result() -- Test that cached results work correctly local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:update(0.5) local result1 = anim:interpolate() local result2 = anim:interpolate() -- Should use cached result luaunit.assertEquals(result1, result2) -- Same table reference luaunit.assertAlmostEquals(result1.x, 50, 0.01) end function TestAnimationInterpolation:test_result_invalidated_on_update() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:update(0.5) local result1 = anim:interpolate() local x1 = result1.x -- Store value, not reference anim:update(0.25) -- Update again local result2 = anim:interpolate() local x2 = result2.x -- Should recalculate -- Note: result1 and result2 are the same cached table, but values should be updated luaunit.assertAlmostEquals(x1, 50, 0.01) luaunit.assertAlmostEquals(x2, 75, 0.01) -- result1.x will actually be 75 now since it's the same table reference luaunit.assertAlmostEquals(result1.x, 75, 0.01) end -- ============================================================================ -- Test Suite: Animation Helper Functions -- ============================================================================ TestAnimationHelpers = {} function TestAnimationHelpers:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestAnimationHelpers:tearDown() FlexLove.endFrame() end function TestAnimationHelpers:test_fade_with_negative_opacity() local anim = Animation.fade(1, -1, 2) anim:update(0.5) local result = anim:interpolate() -- Should interpolate even with negative values luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) end function TestAnimationHelpers:test_fade_with_same_opacity() local anim = Animation.fade(1, 0.5, 0.5) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) end function TestAnimationHelpers:test_fade_helper_backwards_compatibility() local anim = Animation.fade(1, 0, 1) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) end function TestAnimationHelpers:test_scale_with_negative_dimensions() local anim = Animation.scale(1, { width = -100, height = -50 }, { width = 100, height = 50 }) anim:update(0.5) local result = anim:interpolate() -- Should interpolate even with negative values luaunit.assertAlmostEquals(result.width, 0, 0.1) luaunit.assertAlmostEquals(result.height, 0, 0.1) end function TestAnimationHelpers:test_scale_with_zero_dimensions() local anim = Animation.scale(1, { width = 0, height = 0 }, { width = 100, height = 100 }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.width, 50, 0.1) luaunit.assertAlmostEquals(result.height, 50, 0.1) end function TestAnimationHelpers:test_scale_helper_backwards_compatibility() local anim = Animation.scale(1, { width = 100, height = 100 }, { width = 200, height = 200 }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.width, 150, 0.01) luaunit.assertAlmostEquals(result.height, 150, 0.01) end -- ============================================================================ -- Test Suite: Animation Transform Property -- ============================================================================ TestAnimationTransform = {} function TestAnimationTransform:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestAnimationTransform:tearDown() FlexLove.endFrame() end function TestAnimationTransform:test_transform_property() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, transform = { rotation = 45 }, }) anim:update(0.5) local result = anim:interpolate() -- Transform should be applied luaunit.assertEquals(result.rotation, 45) end function TestAnimationTransform:test_transform_with_multiple_properties() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, transform = { rotation = 45, scale = 2, custom = "value" }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertEquals(result.rotation, 45) luaunit.assertEquals(result.scale, 2) luaunit.assertEquals(result.custom, "value") end -- ============================================================================ -- Test Suite: Easing Functions -- ============================================================================ TestEasing = {} function TestEasing:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestEasing:tearDown() FlexLove.endFrame() end -- Test that all easing functions exist function TestEasing:test_all_easing_functions_exist() local easings = { -- Linear "linear", -- Quad "easeInQuad", "easeOutQuad", "easeInOutQuad", -- Cubic "easeInCubic", "easeOutCubic", "easeInOutCubic", -- Quart "easeInQuart", "easeOutQuart", "easeInOutQuart", -- Quint "easeInQuint", "easeOutQuint", "easeInOutQuint", -- Expo "easeInExpo", "easeOutExpo", "easeInOutExpo", -- Sine "easeInSine", "easeOutSine", "easeInOutSine", -- Circ "easeInCirc", "easeOutCirc", "easeInOutCirc", -- Back "easeInBack", "easeOutBack", "easeInOutBack", -- Elastic "easeInElastic", "easeOutElastic", "easeInOutElastic", -- Bounce "easeInBounce", "easeOutBounce", "easeInOutBounce", } for _, name in ipairs(easings) do luaunit.assertNotNil(Easing[name], "Easing function " .. name .. " should exist") luaunit.assertEquals(type(Easing[name]), "function", name .. " should be a function") end end function TestEasing:test_all_easing_functions_with_animation() local easings = { "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo", } for _, easingName in ipairs(easings) do local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, easing = easingName, }) anim:update(0.5) local result = anim:interpolate() -- All should produce valid values luaunit.assertNotNil(result.opacity) luaunit.assertTrue(result.opacity >= 0 and result.opacity <= 1) end end -- Test that all easing functions accept t parameter (0-1) function TestEasing:test_easing_functions_accept_parameter() local result = Easing.linear(0.5) luaunit.assertNotNil(result) luaunit.assertEquals(type(result), "number") end -- Test linear easing function TestEasing:test_linear() luaunit.assertEquals(Easing.linear(0), 0) luaunit.assertEquals(Easing.linear(0.5), 0.5) luaunit.assertEquals(Easing.linear(1), 1) end -- Test easeInQuad function TestEasing:test_easeInQuad() luaunit.assertEquals(Easing.easeInQuad(0), 0) luaunit.assertAlmostEquals(Easing.easeInQuad(0.5), 0.25, 0.01) luaunit.assertEquals(Easing.easeInQuad(1), 1) end -- Test easeOutQuad function TestEasing:test_easeOutQuad() luaunit.assertEquals(Easing.easeOutQuad(0), 0) luaunit.assertAlmostEquals(Easing.easeOutQuad(0.5), 0.75, 0.01) luaunit.assertEquals(Easing.easeOutQuad(1), 1) end -- Test easeInOutQuad function TestEasing:test_easeInOutQuad() luaunit.assertEquals(Easing.easeInOutQuad(0), 0) luaunit.assertAlmostEquals(Easing.easeInOutQuad(0.5), 0.5, 0.01) luaunit.assertEquals(Easing.easeInOutQuad(1), 1) end -- Test easeInSine function TestEasing:test_easeInSine() luaunit.assertEquals(Easing.easeInSine(0), 0) local mid = Easing.easeInSine(0.5) luaunit.assertTrue(mid > 0 and mid < 1, "easeInSine(0.5) should be between 0 and 1") luaunit.assertAlmostEquals(Easing.easeInSine(1), 1, 0.01) end -- Test easeOutSine function TestEasing:test_easeOutSine() luaunit.assertEquals(Easing.easeOutSine(0), 0) local mid = Easing.easeOutSine(0.5) luaunit.assertTrue(mid > 0 and mid < 1, "easeOutSine(0.5) should be between 0 and 1") luaunit.assertAlmostEquals(Easing.easeOutSine(1), 1, 0.01) end -- Test easeInOutSine function TestEasing:test_easeInOutSine() luaunit.assertEquals(Easing.easeInOutSine(0), 0) luaunit.assertAlmostEquals(Easing.easeInOutSine(0.5), 0.5, 0.01) luaunit.assertAlmostEquals(Easing.easeInOutSine(1), 1, 0.01) end -- Test easeInQuint function TestEasing:test_easeInQuint() luaunit.assertEquals(Easing.easeInQuint(0), 0) luaunit.assertAlmostEquals(Easing.easeInQuint(0.5), 0.03125, 0.01) luaunit.assertEquals(Easing.easeInQuint(1), 1) end -- Test easeOutQuint function TestEasing:test_easeOutQuint() luaunit.assertEquals(Easing.easeOutQuint(0), 0) luaunit.assertAlmostEquals(Easing.easeOutQuint(0.5), 0.96875, 0.01) luaunit.assertEquals(Easing.easeOutQuint(1), 1) end -- Test easeInCirc function TestEasing:test_easeInCirc() luaunit.assertEquals(Easing.easeInCirc(0), 0) local mid = Easing.easeInCirc(0.5) luaunit.assertTrue(mid > 0 and mid < 1, "easeInCirc(0.5) should be between 0 and 1") luaunit.assertAlmostEquals(Easing.easeInCirc(1), 1, 0.01) end -- Test easeOutCirc function TestEasing:test_easeOutCirc() luaunit.assertEquals(Easing.easeOutCirc(0), 0) local mid = Easing.easeOutCirc(0.5) luaunit.assertTrue(mid > 0 and mid < 1, "easeOutCirc(0.5) should be between 0 and 1") luaunit.assertAlmostEquals(Easing.easeOutCirc(1), 1, 0.01) end -- Test easeInOutCirc function TestEasing:test_easeInOutCirc() luaunit.assertEquals(Easing.easeInOutCirc(0), 0) luaunit.assertAlmostEquals(Easing.easeInOutCirc(0.5), 0.5, 0.01) luaunit.assertAlmostEquals(Easing.easeInOutCirc(1), 1, 0.01) end -- Test easeInBack (should overshoot at start) function TestEasing:test_easeInBack() luaunit.assertEquals(Easing.easeInBack(0), 0) local early = Easing.easeInBack(0.3) luaunit.assertTrue(early < 0, "easeInBack should go negative (overshoot) early on") luaunit.assertAlmostEquals(Easing.easeInBack(1), 1, 0.001) end -- Test easeOutBack (should overshoot at end) function TestEasing:test_easeOutBack() luaunit.assertAlmostEquals(Easing.easeOutBack(0), 0, 0.001) local late = Easing.easeOutBack(0.7) luaunit.assertTrue(late > 0.7, "easeOutBack should overshoot at the end") luaunit.assertAlmostEquals(Easing.easeOutBack(1), 1, 0.01) end -- Test easeInElastic (should oscillate) function TestEasing:test_easeInElastic() luaunit.assertEquals(Easing.easeInElastic(0), 0) luaunit.assertAlmostEquals(Easing.easeInElastic(1), 1, 0.01) -- Elastic should go negative at some point local hasNegative = false for i = 1, 9 do local t = i / 10 if Easing.easeInElastic(t) < 0 then hasNegative = true break end end luaunit.assertTrue(hasNegative, "easeInElastic should have negative values (oscillation)") end -- Test easeOutElastic (should oscillate) function TestEasing:test_easeOutElastic() luaunit.assertEquals(Easing.easeOutElastic(0), 0) luaunit.assertAlmostEquals(Easing.easeOutElastic(1), 1, 0.01) -- Elastic should go above 1 at some point local hasOvershoot = false for i = 1, 9 do local t = i / 10 if Easing.easeOutElastic(t) > 1 then hasOvershoot = true break end end luaunit.assertTrue(hasOvershoot, "easeOutElastic should overshoot 1 (oscillation)") end -- Test easeInBounce function TestEasing:test_easeInBounce() luaunit.assertEquals(Easing.easeInBounce(0), 0) luaunit.assertAlmostEquals(Easing.easeInBounce(1), 1, 0.01) -- Bounce should have multiple "bounces" (local minima) local result = Easing.easeInBounce(0.5) luaunit.assertTrue(result >= 0 and result <= 1, "easeInBounce should stay within 0-1 range") end -- Test easeOutBounce function TestEasing:test_easeOutBounce() luaunit.assertEquals(Easing.easeOutBounce(0), 0) luaunit.assertAlmostEquals(Easing.easeOutBounce(1), 1, 0.01) -- Bounce should have bounces local result = Easing.easeOutBounce(0.8) luaunit.assertTrue(result >= 0 and result <= 1, "easeOutBounce should stay within 0-1 range") end -- Test easeInOutBounce function TestEasing:test_easeInOutBounce() luaunit.assertEquals(Easing.easeInOutBounce(0), 0) luaunit.assertAlmostEquals(Easing.easeInOutBounce(0.5), 0.5, 0.01) luaunit.assertAlmostEquals(Easing.easeInOutBounce(1), 1, 0.01) end function TestEasing:test_easeInExpo_at_zero() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, easing = "easeInExpo", }) -- t=0 should return 0 local result = anim:interpolate() luaunit.assertAlmostEquals(result.opacity, 0, 0.01) end function TestEasing:test_easeOutExpo_at_one() local anim = Animation.new({ duration = 1, start = { opacity = 0 }, final = { opacity = 1 }, easing = "easeOutExpo", }) anim:update(1) local result = anim:interpolate() luaunit.assertAlmostEquals(result.opacity, 1.0, 0.01) end -- Test configurable back() factory function TestEasing:test_back_factory() local customBack = Easing.back(2.5) luaunit.assertEquals(type(customBack), "function") luaunit.assertEquals(customBack(0), 0) luaunit.assertEquals(customBack(1), 1) -- Should overshoot with custom amount local mid = customBack(0.3) luaunit.assertTrue(mid < 0, "Custom back easing should overshoot") end -- Test configurable elastic() factory function TestEasing:test_elastic_factory() local customElastic = Easing.elastic(1.5, 0.4) luaunit.assertEquals(type(customElastic), "function") luaunit.assertEquals(customElastic(0), 0) luaunit.assertAlmostEquals(customElastic(1), 1, 0.01) end -- Test that all InOut easings are symmetric around 0.5 function TestEasing:test_inOut_symmetry() local inOutEasings = { "easeInOutQuad", "easeInOutCubic", "easeInOutQuart", "easeInOutQuint", "easeInOutExpo", "easeInOutSine", "easeInOutCirc", "easeInOutBack", "easeInOutElastic", "easeInOutBounce", } for _, name in ipairs(inOutEasings) do local easing = Easing[name] -- At t=0.5, all InOut easings should be close to 0.5 local mid = easing(0.5) luaunit.assertAlmostEquals(mid, 0.5, 0.1, name .. " should be close to 0.5 at t=0.5") end end -- Test boundary conditions for all easings function TestEasing:test_boundary_conditions() local easings = { "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInOutQuart", "easeInQuint", "easeOutQuint", "easeInOutQuint", "easeInExpo", "easeOutExpo", "easeInOutExpo", "easeInSine", "easeOutSine", "easeInOutSine", "easeInCirc", "easeOutCirc", "easeInOutCirc", "easeInBack", "easeOutBack", "easeInOutBack", "easeInElastic", "easeOutElastic", "easeInOutElastic", "easeInBounce", "easeOutBounce", "easeInOutBounce", } for _, name in ipairs(easings) do local easing = Easing[name] -- All easings should start at 0 local start = easing(0) luaunit.assertAlmostEquals(start, 0, 0.01, name .. " should start at 0") -- All easings should end at 1 local finish = easing(1) luaunit.assertAlmostEquals(finish, 1, 0.01, name .. " should end at 1") end end -- ============================================================================ -- Test Suite: Animation Properties (Color, Position, Numeric, Tables) -- ============================================================================ TestAnimationProperties = {} function TestAnimationProperties:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestAnimationProperties:tearDown() FlexLove.endFrame() end -- Test Color.lerp() method function TestAnimationProperties:test_color_lerp_midpoint() local colorA = Color.new(0, 0, 0, 1) -- Black local colorB = Color.new(1, 1, 1, 1) -- White local result = Color.lerp(colorA, colorB, 0.5) luaunit.assertAlmostEquals(result.r, 0.5, 0.01) luaunit.assertAlmostEquals(result.g, 0.5, 0.01) luaunit.assertAlmostEquals(result.b, 0.5, 0.01) luaunit.assertAlmostEquals(result.a, 1, 0.01) end function TestAnimationProperties:test_color_lerp_start_point() local colorA = Color.new(1, 0, 0, 1) -- Red local colorB = Color.new(0, 0, 1, 1) -- Blue local result = Color.lerp(colorA, colorB, 0) luaunit.assertAlmostEquals(result.r, 1, 0.01) luaunit.assertAlmostEquals(result.g, 0, 0.01) luaunit.assertAlmostEquals(result.b, 0, 0.01) end function TestAnimationProperties:test_color_lerp_end_point() local colorA = Color.new(1, 0, 0, 1) -- Red local colorB = Color.new(0, 0, 1, 1) -- Blue local result = Color.lerp(colorA, colorB, 1) luaunit.assertAlmostEquals(result.r, 0, 0.01) luaunit.assertAlmostEquals(result.g, 0, 0.01) luaunit.assertAlmostEquals(result.b, 1, 0.01) end function TestAnimationProperties:test_color_lerp_alpha() local colorA = Color.new(1, 1, 1, 0) -- Transparent white local colorB = Color.new(1, 1, 1, 1) -- Opaque white local result = Color.lerp(colorA, colorB, 0.5) luaunit.assertAlmostEquals(result.a, 0.5, 0.01) end function TestAnimationProperties:test_color_lerp_clamp_t() local colorA = Color.new(0, 0, 0, 1) local colorB = Color.new(1, 1, 1, 1) -- Test t > 1 local result1 = Color.lerp(colorA, colorB, 1.5) luaunit.assertAlmostEquals(result1.r, 1, 0.01) -- Test t < 0 local result2 = Color.lerp(colorA, colorB, -0.5) luaunit.assertAlmostEquals(result2.r, 0, 0.01) end -- Test Position Animation (x, y) function TestAnimationProperties:test_position_animation_x() local anim = Animation.new({ duration = 1, start = { x = 0 }, final = { x = 100 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.x, 50, 0.01) end function TestAnimationProperties:test_position_animation_y() local anim = Animation.new({ duration = 1, start = { y = 0 }, final = { y = 200 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.y, 100, 0.01) end function TestAnimationProperties:test_position_animation_xy() local anim = Animation.new({ duration = 1, start = { x = 10, y = 20 }, final = { x = 110, y = 220 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.x, 60, 0.01) luaunit.assertAlmostEquals(result.y, 120, 0.01) end -- Test Color Property Animation function TestAnimationProperties:test_color_animation_backgroundColor() local anim = Animation.new({ duration = 1, start = { backgroundColor = Color.new(1, 0, 0, 1) }, -- Red final = { backgroundColor = Color.new(0, 0, 1, 1) }, -- Blue }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.backgroundColor) luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) luaunit.assertAlmostEquals(result.backgroundColor.b, 0.5, 0.01) end function TestAnimationProperties:test_color_animation_multiple_colors() local anim = Animation.new({ duration = 1, start = { backgroundColor = Color.new(1, 0, 0, 1), borderColor = Color.new(0, 1, 0, 1), textColor = Color.new(0, 0, 1, 1), }, final = { backgroundColor = Color.new(0, 1, 0, 1), borderColor = Color.new(0, 0, 1, 1), textColor = Color.new(1, 0, 0, 1), }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.backgroundColor) luaunit.assertNotNil(result.borderColor) luaunit.assertNotNil(result.textColor) -- Mid-point should be (0.5, 0.5, 0.5) for backgroundColor luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) luaunit.assertAlmostEquals(result.backgroundColor.g, 0.5, 0.01) end function TestAnimationProperties:test_color_animation_hex_colors() local anim = Animation.new({ duration = 1, start = { backgroundColor = "#FF0000" }, -- Red final = { backgroundColor = "#0000FF" }, -- Blue }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.backgroundColor) luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) end -- Test Numeric Property Animation function TestAnimationProperties:test_numeric_animation_gap() local anim = Animation.new({ duration = 1, start = { gap = 0 }, final = { gap = 20 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.gap, 10, 0.01) end function TestAnimationProperties:test_numeric_animation_image_opacity() local anim = Animation.new({ duration = 1, start = { imageOpacity = 0 }, final = { imageOpacity = 1 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.imageOpacity, 0.5, 0.01) end function TestAnimationProperties:test_numeric_animation_border_width() local anim = Animation.new({ duration = 1, start = { borderWidth = 1 }, final = { borderWidth = 10 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.borderWidth, 5.5, 0.01) end function TestAnimationProperties:test_numeric_animation_font_size() local anim = Animation.new({ duration = 1, start = { fontSize = 12 }, final = { fontSize = 24 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.fontSize, 18, 0.01) end function TestAnimationProperties:test_numeric_animation_multiple_properties() local anim = Animation.new({ duration = 1, start = { gap = 0, imageOpacity = 0, borderWidth = 1 }, final = { gap = 20, imageOpacity = 1, borderWidth = 5 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.gap, 10, 0.01) luaunit.assertAlmostEquals(result.imageOpacity, 0.5, 0.01) luaunit.assertAlmostEquals(result.borderWidth, 3, 0.01) end -- Test Table Property Animation (padding, margin, cornerRadius) function TestAnimationProperties:test_table_animation_padding() local anim = Animation.new({ duration = 1, start = { padding = { top = 0, right = 0, bottom = 0, left = 0 } }, final = { padding = { top = 10, right = 20, bottom = 10, left = 20 } }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.padding) luaunit.assertAlmostEquals(result.padding.top, 5, 0.01) luaunit.assertAlmostEquals(result.padding.right, 10, 0.01) luaunit.assertAlmostEquals(result.padding.bottom, 5, 0.01) luaunit.assertAlmostEquals(result.padding.left, 10, 0.01) end function TestAnimationProperties:test_table_animation_margin() local anim = Animation.new({ duration = 1, start = { margin = { top = 0, right = 0, bottom = 0, left = 0 } }, final = { margin = { top = 20, right = 20, bottom = 20, left = 20 } }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.margin) luaunit.assertAlmostEquals(result.margin.top, 10, 0.01) luaunit.assertAlmostEquals(result.margin.right, 10, 0.01) end function TestAnimationProperties:test_table_animation_corner_radius() local anim = Animation.new({ duration = 1, start = { cornerRadius = { topLeft = 0, topRight = 0, bottomLeft = 0, bottomRight = 0 } }, final = { cornerRadius = { topLeft = 10, topRight = 10, bottomLeft = 10, bottomRight = 10 } }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.cornerRadius) luaunit.assertAlmostEquals(result.cornerRadius.topLeft, 5, 0.01) luaunit.assertAlmostEquals(result.cornerRadius.topRight, 5, 0.01) end function TestAnimationProperties:test_table_animation_partial_keys() -- Test when start and final have different keys local anim = Animation.new({ duration = 1, start = { padding = { top = 0, left = 0 } }, final = { padding = { top = 10, right = 20, left = 10 } }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.padding) luaunit.assertAlmostEquals(result.padding.top, 5, 0.01) luaunit.assertAlmostEquals(result.padding.left, 5, 0.01) luaunit.assertNotNil(result.padding.right) end function TestAnimationProperties:test_table_animation_non_numeric_values() -- Should skip non-numeric values in tables local anim = Animation.new({ duration = 1, start = { padding = { top = 0, special = "value" } }, final = { padding = { top = 10, special = "value" } }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertNotNil(result.padding) luaunit.assertAlmostEquals(result.padding.top, 5, 0.01) end -- Test Combined Animations function TestAnimationProperties:test_combined_animation_all_types() local anim = Animation.new({ duration = 1, start = { width = 100, height = 100, x = 0, y = 0, opacity = 0, backgroundColor = Color.new(1, 0, 0, 1), gap = 0, padding = { top = 0, left = 0 }, }, final = { width = 200, height = 200, x = 100, y = 100, opacity = 1, backgroundColor = Color.new(0, 0, 1, 1), gap = 20, padding = { top = 10, left = 10 }, }, }) anim:update(0.5) local result = anim:interpolate() -- Check all properties interpolated correctly luaunit.assertAlmostEquals(result.width, 150, 0.01) luaunit.assertAlmostEquals(result.height, 150, 0.01) luaunit.assertAlmostEquals(result.x, 50, 0.01) luaunit.assertAlmostEquals(result.y, 50, 0.01) luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) luaunit.assertAlmostEquals(result.gap, 10, 0.01) luaunit.assertNotNil(result.backgroundColor) luaunit.assertNotNil(result.padding) end function TestAnimationProperties:test_combined_animation_with_easing() local anim = Animation.new({ duration = 1, start = { x = 0, backgroundColor = Color.new(0, 0, 0, 1) }, final = { x = 100, backgroundColor = Color.new(1, 1, 1, 1) }, easing = "easeInQuad", }) anim:update(0.5) local result = anim:interpolate() -- With easeInQuad, at t=0.5, eased value should be 0.25 luaunit.assertAlmostEquals(result.x, 25, 0.01) luaunit.assertAlmostEquals(result.backgroundColor.r, 0.25, 0.01) end function TestAnimationProperties:test_backwards_compatibility_width_height_opacity() -- Ensure old animations still work local anim = Animation.new({ duration = 1, start = { width = 100, height = 100, opacity = 0 }, final = { width = 200, height = 200, opacity = 1 }, }) anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.width, 150, 0.01) luaunit.assertAlmostEquals(result.height, 150, 0.01) luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) end -- ============================================================================ -- Test Suite: Keyframe Animation -- ============================================================================ TestKeyframeAnimation = {} function TestKeyframeAnimation:setUp() love.window.setMode(1920, 1080) FlexLove.beginFrame() end function TestKeyframeAnimation:tearDown() FlexLove.endFrame() end -- Test basic keyframe animation creation function TestKeyframeAnimation:test_create_keyframe_animation() local anim = Animation.keyframes({ duration = 2, keyframes = { { at = 0, values = { x = 0, opacity = 0 } }, { at = 1, values = { x = 100, opacity = 1 } }, }, }) luaunit.assertNotNil(anim) luaunit.assertEquals(type(anim), "table") luaunit.assertEquals(anim.duration, 2) luaunit.assertNotNil(anim.keyframes) luaunit.assertEquals(#anim.keyframes, 2) end -- Test keyframe animation with multiple waypoints function TestKeyframeAnimation:test_multiple_waypoints() local anim = Animation.keyframes({ duration = 3, keyframes = { { at = 0, values = { x = 0, opacity = 0 } }, { at = 0.25, values = { x = 50, opacity = 1 } }, { at = 0.75, values = { x = 150, opacity = 1 } }, { at = 1, values = { x = 200, opacity = 0 } }, }, }) luaunit.assertEquals(#anim.keyframes, 4) luaunit.assertEquals(anim.keyframes[1].at, 0) luaunit.assertEquals(anim.keyframes[2].at, 0.25) luaunit.assertEquals(anim.keyframes[3].at, 0.75) luaunit.assertEquals(anim.keyframes[4].at, 1) end -- Test keyframe sorting function TestKeyframeAnimation:test_keyframe_sorting() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 1, values = { x = 100 } }, { at = 0, values = { x = 0 } }, { at = 0.5, values = { x = 50 } }, }, }) -- Should be sorted by 'at' position luaunit.assertEquals(anim.keyframes[1].at, 0) luaunit.assertEquals(anim.keyframes[2].at, 0.5) luaunit.assertEquals(anim.keyframes[3].at, 1) end -- Test keyframe interpolation at start function TestKeyframeAnimation:test_interpolation_at_start() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0, opacity = 0 } }, { at = 1, values = { x = 100, opacity = 1 } }, }, }) anim.elapsed = 0 local result = anim:interpolate() luaunit.assertNotNil(result.x) luaunit.assertNotNil(result.opacity) luaunit.assertAlmostEquals(result.x, 0, 0.01) luaunit.assertAlmostEquals(result.opacity, 0, 0.01) end -- Test keyframe interpolation at end function TestKeyframeAnimation:test_interpolation_at_end() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0, opacity = 0 } }, { at = 1, values = { x = 100, opacity = 1 } }, }, }) anim.elapsed = 1 local result = anim:interpolate() luaunit.assertAlmostEquals(result.x, 100, 0.01) luaunit.assertAlmostEquals(result.opacity, 1, 0.01) end -- Test keyframe interpolation at midpoint function TestKeyframeAnimation:test_interpolation_at_midpoint() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 } }, { at = 1, values = { x = 100 } }, }, }) anim.elapsed = 0.5 local result = anim:interpolate() luaunit.assertAlmostEquals(result.x, 50, 0.01) end -- Test per-keyframe easing function TestKeyframeAnimation:test_per_keyframe_easing() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 }, easing = "easeInQuad" }, { at = 0.5, values = { x = 50 }, easing = "linear" }, { at = 1, values = { x = 100 } }, }, }) -- At t=0.25 (middle of first segment with easeInQuad) anim.elapsed = 0.25 anim._resultDirty = true -- Mark dirty to force recalculation local result1 = anim:interpolate() -- easeInQuad at 0.5 should give 0.25, so x = 0 + (50-0) * 0.25 = 12.5 luaunit.assertTrue(result1.x < 25, "easeInQuad should slow start") -- At t=0.75 (middle of second segment with linear) anim.elapsed = 0.75 anim._resultDirty = true -- Mark dirty to force recalculation local result2 = anim:interpolate() -- linear at 0.5 should give 0.5, so x = 50 + (100-50) * 0.5 = 75 luaunit.assertAlmostEquals(result2.x, 75, 1) end -- Test findKeyframes method function TestKeyframeAnimation:test_find_keyframes() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 } }, { at = 0.25, values = { x = 25 } }, { at = 0.75, values = { x = 75 } }, { at = 1, values = { x = 100 } }, }, }) -- Test finding keyframes at different progress values local prev1, next1 = anim:findKeyframes(0.1) luaunit.assertEquals(prev1.at, 0) luaunit.assertEquals(next1.at, 0.25) local prev2, next2 = anim:findKeyframes(0.5) luaunit.assertEquals(prev2.at, 0.25) luaunit.assertEquals(next2.at, 0.75) local prev3, next3 = anim:findKeyframes(0.9) luaunit.assertEquals(prev3.at, 0.75) luaunit.assertEquals(next3.at, 1) end -- Test keyframe animation with update function TestKeyframeAnimation:test_keyframe_animation_update() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { opacity = 0 } }, { at = 1, values = { opacity = 1 } }, }, }) -- Update halfway through anim:update(0.5) local result = anim:interpolate() luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) luaunit.assertFalse(anim:update(0)) -- Not complete yet -- Update to completion luaunit.assertTrue(anim:update(0.6)) -- Should complete luaunit.assertEquals(anim:getState(), "completed") end -- Test keyframe animation with callbacks function TestKeyframeAnimation:test_keyframe_animation_callbacks() local startCalled = false local updateCalled = false local completeCalled = false local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 } }, { at = 1, values = { x = 100 } }, }, onStart = function() startCalled = true end, onUpdate = function() updateCalled = true end, onComplete = function() completeCalled = true end, }) anim:update(0.5) luaunit.assertTrue(startCalled) luaunit.assertTrue(updateCalled) luaunit.assertFalse(completeCalled) anim:update(0.6) luaunit.assertTrue(completeCalled) end -- Test missing keyframes (error handling) function TestKeyframeAnimation:test_missing_keyframes() -- Should create default keyframes with warning local anim = Animation.keyframes({ duration = 1, keyframes = {}, }) luaunit.assertNotNil(anim) luaunit.assertEquals(#anim.keyframes, 2) -- Should have default start and end end -- Test single keyframe (error handling) function TestKeyframeAnimation:test_single_keyframe() -- Should create default keyframes with warning local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0.5, values = { x = 50 } }, }, }) luaunit.assertNotNil(anim) luaunit.assertTrue(#anim.keyframes >= 2) -- Should have at least 2 keyframes end -- Test keyframes without start (at=0) function TestKeyframeAnimation:test_keyframes_without_start() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0.5, values = { x = 50 } }, { at = 1, values = { x = 100 } }, }, }) -- Should auto-add keyframe at 0 luaunit.assertEquals(anim.keyframes[1].at, 0) luaunit.assertEquals(anim.keyframes[1].values.x, 50) -- Should copy first keyframe values end -- Test keyframes without end (at=1) function TestKeyframeAnimation:test_keyframes_without_end() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 } }, { at = 0.5, values = { x = 50 } }, }, }) -- Should auto-add keyframe at 1 luaunit.assertEquals(anim.keyframes[#anim.keyframes].at, 1) luaunit.assertEquals(anim.keyframes[#anim.keyframes].values.x, 50) -- Should copy last keyframe values end -- Test keyframe with invalid props function TestKeyframeAnimation:test_invalid_keyframe_props() -- Should handle gracefully with warnings local anim = Animation.keyframes({ duration = 0, -- Invalid keyframes = "not a table", -- Invalid }) luaunit.assertNotNil(anim) luaunit.assertEquals(anim.duration, 1) -- Should use default end -- Test complex multi-property keyframes function TestKeyframeAnimation:test_multi_property_keyframes() local anim = Animation.keyframes({ duration = 2, keyframes = { { at = 0, values = { x = 0, y = 0, opacity = 0, width = 50 } }, { at = 0.33, values = { x = 100, y = 50, opacity = 1, width = 100 } }, { at = 0.66, values = { x = 200, y = 100, opacity = 1, width = 150 } }, { at = 1, values = { x = 300, y = 150, opacity = 0, width = 200 } }, }, }) -- Test interpolation at 0.5 (middle of second segment) anim.elapsed = 1.0 -- t = 0.5 local result = anim:interpolate() luaunit.assertNotNil(result.x) luaunit.assertNotNil(result.y) luaunit.assertNotNil(result.opacity) luaunit.assertNotNil(result.width) -- Should be interpolating between keyframes at 0.33 and 0.66 luaunit.assertTrue(result.x > 100 and result.x < 200) luaunit.assertTrue(result.y > 50 and result.y < 100) end -- Test keyframe with easing function (not string) function TestKeyframeAnimation:test_keyframe_with_easing_function() local customEasing = function(t) return t * t end local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 }, easing = customEasing }, { at = 1, values = { x = 100 } }, }, }) anim.elapsed = 0.5 local result = anim:interpolate() -- At t=0.5, easing(0.5) = 0.25, so x = 0 + 100 * 0.25 = 25 luaunit.assertAlmostEquals(result.x, 25, 1) end -- Test caching behavior with keyframes function TestKeyframeAnimation:test_keyframe_caching() local anim = Animation.keyframes({ duration = 1, keyframes = { { at = 0, values = { x = 0 } }, { at = 1, values = { x = 100 } }, }, }) anim.elapsed = 0.5 local result1 = anim:interpolate() local result2 = anim:interpolate() -- Should return cached result luaunit.assertEquals(result1, result2) -- Should be same table end -- Run all tests if not _G.RUNNING_ALL_TESTS then os.exit(luaunit.LuaUnit.run()) end