From 5bb1162e06b8dbda3bc95bbd16aae56351ce97de Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 18 Nov 2025 14:15:51 -0500 Subject: [PATCH] easings --- FlexLove.lua | 3 + modules/Animation.lua | 248 ++++++++++-- modules/Easing.lua | 361 ++++++++++++++++++ testing/__tests__/easing_test.lua | 310 +++++++++++++++ testing/__tests__/keyframe_animation_test.lua | 353 +++++++++++++++++ 5 files changed, 1233 insertions(+), 42 deletions(-) create mode 100644 modules/Easing.lua create mode 100644 testing/__tests__/easing_test.lua create mode 100644 testing/__tests__/keyframe_animation_test.lua diff --git a/FlexLove.lua b/FlexLove.lua index 0ad7003..980c4cb 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -33,6 +33,8 @@ local Element = req("Element") local Animation = req("Animation") ---@type AnimationGroup local AnimationGroup = req("AnimationGroup") +---@type Easing +local Easing = req("Easing") ---@type Color local Color = req("Color") ---@type Theme @@ -1120,6 +1122,7 @@ end flexlove.Animation = Animation flexlove.AnimationGroup = AnimationGroup +flexlove.Easing = Easing flexlove.Color = Color flexlove.Theme = Theme flexlove.enums = enums diff --git a/modules/Animation.lua b/modules/Animation.lua index 41495b0..cf7c6bb 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -4,54 +4,19 @@ -- ErrorHandler dependency (injected via initializeErrorHandler) local ErrorHandler = nil ---- Easing functions for animations ----@type table -local Easing = { - linear = function(t) - return t - end, +-- Easing module for easing functions +local Easing = require("modules.Easing") +---@class Keyframe +---@field at number Normalized time position (0-1) +---@field values table Property values at this keyframe +---@field easing string|EasingFunction? Easing to use between this and next keyframe - easeInQuad = function(t) - return t * t - end, - easeOutQuad = function(t) - return t * (2 - t) - end, - easeInOutQuad = function(t) - return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t - end, - - easeInCubic = function(t) - return t * t * t - end, - easeOutCubic = function(t) - local t1 = t - 1 - return t1 * t1 * t1 + 1 - end, - easeInOutCubic = function(t) - return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 - end, - - easeInQuart = function(t) - return t * t * t * t - end, - easeOutQuart = function(t) - local t1 = t - 1 - return 1 - t1 * t1 * t1 * t1 - end, - - easeInExpo = function(t) - return t == 0 and 0 or math.pow(2, 10 * (t - 1)) - end, - easeOutExpo = function(t) - return t == 1 and 1 or 1 - math.pow(2, -10 * t) - end, -} ---@class AnimationProps ---@field duration number Duration in seconds ---@field start table Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, etc.) ---@field final table Final values (same properties as start) ---@field easing string? Easing function name (default: "linear") +---@field keyframes Keyframe[]? Array of keyframes for complex animations ---@field transform table? Additional transform properties ---@field transition table? Transition properties ---@field onStart function? Called when animation starts: (animation, element) @@ -65,6 +30,7 @@ local Easing = { ---@field final table Final values ---@field elapsed number Elapsed time in seconds ---@field easing EasingFunction Easing function +---@field keyframes Keyframe[]? Array of keyframes for complex animations ---@field transform table? Additional transform properties ---@field transition table? Transition properties ---@field _cachedResult table Cached interpolation result @@ -103,6 +69,7 @@ function Animation.new(props) self.duration = props.duration self.start = props.start self.final = props.final + self.keyframes = props.keyframes self.transform = props.transform self.transition = props.transition self.elapsed = 0 @@ -304,6 +271,98 @@ local function lerpTable(startTable, finalTable, easedT) return result end +--- Find the two keyframes surrounding the current progress +---@param progress number Current animation progress (0-1) +---@return Keyframe prevFrame The keyframe before current progress +---@return Keyframe nextFrame The keyframe after current progress +function Animation:findKeyframes(progress) + if not self.keyframes or #self.keyframes < 2 then + return nil, nil + end + + -- Find surrounding keyframes + local prevFrame = self.keyframes[1] + local nextFrame = self.keyframes[#self.keyframes] + + for i = 1, #self.keyframes - 1 do + if progress >= self.keyframes[i].at and progress <= self.keyframes[i + 1].at then + prevFrame = self.keyframes[i] + nextFrame = self.keyframes[i + 1] + break + end + end + + return prevFrame, nextFrame +end + +--- Interpolate between two keyframes +---@param prevFrame Keyframe Starting keyframe +---@param nextFrame Keyframe Ending keyframe +---@param easedT number Eased time (0-1) for interpolation +---@return table result Interpolated values +function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) + local result = {} + + -- Get all unique property keys + local keys = {} + for k in pairs(prevFrame.values) do keys[k] = true end + for k in pairs(nextFrame.values) do keys[k] = true end + + -- Define properties that should be animated as numbers + local numericProperties = { + "width", "height", "opacity", "x", "y", + "gap", "imageOpacity", "scrollbarWidth", + "borderWidth", "fontSize", "lineHeight" + } + + -- Define properties that should be animated as Colors + local colorProperties = { + "backgroundColor", "borderColor", "textColor", + "scrollbarColor", "scrollbarBackgroundColor", "imageTint" + } + + -- Define properties that should be animated as tables + local tableProperties = { + "padding", "margin", "cornerRadius" + } + + -- Create lookup sets for faster property type checking + local numericSet = {} + for _, prop in ipairs(numericProperties) do numericSet[prop] = true end + + local colorSet = {} + for _, prop in ipairs(colorProperties) do colorSet[prop] = true end + + local tableSet = {} + for _, prop in ipairs(tableProperties) do tableSet[prop] = true end + + -- Interpolate each property + for key in pairs(keys) do + local startVal = prevFrame.values[key] + local finalVal = nextFrame.values[key] + + if numericSet[key] and type(startVal) == "number" and type(finalVal) == "number" then + result[key] = lerpNumber(startVal, finalVal, easedT) + elseif colorSet[key] and self._Color then + if startVal ~= nil and finalVal ~= nil then + result[key] = lerpColor(startVal, finalVal, easedT, self._Color) + end + elseif tableSet[key] and type(startVal) == "table" and type(finalVal) == "table" then + result[key] = lerpTable(startVal, finalVal, easedT) + elseif type(startVal) == type(finalVal) then + -- For unknown types, try numeric interpolation if they're numbers + if type(startVal) == "number" then + result[key] = lerpNumber(startVal, finalVal, easedT) + else + -- Otherwise use the final value + result[key] = finalVal + end + end + end + + return result +end + --- Calculate the current animated values between start and end states based on elapsed time --- Use this to get the interpolated properties to apply to your element ---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...} @@ -315,6 +374,50 @@ function Animation:interpolate() local t = math.min(self.elapsed / self.duration, 1) + -- Handle keyframe animations + if self.keyframes and #self.keyframes >= 2 then + local prevFrame, nextFrame = self:findKeyframes(t) + + if prevFrame and nextFrame then + -- Calculate local progress between keyframes + local localProgress = 0 + if nextFrame.at > prevFrame.at then + localProgress = (t - prevFrame.at) / (nextFrame.at - prevFrame.at) + end + + -- Apply per-keyframe easing + local easingFn = Easing.linear + if prevFrame.easing then + if type(prevFrame.easing) == "string" then + easingFn = Easing[prevFrame.easing] or Easing.linear + elseif type(prevFrame.easing) == "function" then + easingFn = prevFrame.easing + end + end + + local success, easedT = pcall(easingFn, localProgress) + if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then + easedT = localProgress + end + + -- Interpolate between keyframes + local keyframeResult = self:lerpKeyframes(prevFrame, nextFrame, easedT) + + -- Copy to cached result + local result = self._cachedResult + for k in pairs(result) do + result[k] = nil + end + for k, v in pairs(keyframeResult) do + result[k] = v + end + + self._resultDirty = false + return result + end + end + + -- Standard interpolation (non-keyframe) -- Apply easing function with protection local success, easedT = pcall(self.easing, t) if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then @@ -651,6 +754,67 @@ function Animation.scale(duration, fromScale, toScale, easing) }) end +--- Create a keyframe-based animation with multiple waypoints and per-keyframe easing +--- Use this for complex multi-step animations like bounce-in effects or CSS-style @keyframes +---@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} Animation properties +---@return Animation animation The keyframe animation +function Animation.keyframes(props) + if not ErrorHandler then + ErrorHandler = require("modules.ErrorHandler") + end + + -- Validate input + if type(props) ~= "table" then + ErrorHandler.warn("Animation", "Animation.keyframes() requires a table argument. Using default values.") + props = {duration = 1, keyframes = {}} + end + + if type(props.duration) ~= "number" or props.duration <= 0 then + ErrorHandler.warn("Animation", "Keyframe animation duration must be a positive number. Using 1 second.") + props.duration = 1 + end + + if type(props.keyframes) ~= "table" or #props.keyframes < 2 then + ErrorHandler.warn("Animation", "Keyframe animation requires at least 2 keyframes. Using empty animation.") + props.keyframes = { + {at = 0, values = {}}, + {at = 1, values = {}} + } + end + + -- Sort keyframes by 'at' position + local sortedKeyframes = {} + for i, kf in ipairs(props.keyframes) do + if type(kf) == "table" and type(kf.at) == "number" and type(kf.values) == "table" then + table.insert(sortedKeyframes, kf) + end + end + + table.sort(sortedKeyframes, function(a, b) return a.at < b.at end) + + -- Ensure keyframes start at 0 and end at 1 + if #sortedKeyframes > 0 then + if sortedKeyframes[1].at > 0 then + table.insert(sortedKeyframes, 1, {at = 0, values = sortedKeyframes[1].values}) + end + if sortedKeyframes[#sortedKeyframes].at < 1 then + table.insert(sortedKeyframes, {at = 1, values = sortedKeyframes[#sortedKeyframes].values}) + end + end + + -- Create animation with keyframes + return Animation.new({ + duration = props.duration, + start = {}, + final = {}, + keyframes = sortedKeyframes, + onStart = props.onStart, + onUpdate = props.onUpdate, + onComplete = props.onComplete, + onCancel = props.onCancel, + }) +end + --- Initialize ErrorHandler dependency ---@param errorHandler table The ErrorHandler module local function initializeErrorHandler(errorHandler) diff --git a/modules/Easing.lua b/modules/Easing.lua new file mode 100644 index 0000000..1d3629a --- /dev/null +++ b/modules/Easing.lua @@ -0,0 +1,361 @@ +--- Easing functions for animations +--- Provides 30+ easing functions for smooth animation transitions +--- +--- Easing function type +---@alias EasingFunction fun(t: number): number +--- +---@class Easing +local Easing = {} + +-- ============================================================================ +-- Linear +-- ============================================================================ + +---@type EasingFunction +function Easing.linear(t) + return t +end + +-- ============================================================================ +-- Quadratic (Quad) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInQuad(t) + return t * t +end + +---@type EasingFunction +function Easing.easeOutQuad(t) + return t * (2 - t) +end + +---@type EasingFunction +function Easing.easeInOutQuad(t) + return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t +end + +-- ============================================================================ +-- Cubic +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInCubic(t) + return t * t * t +end + +---@type EasingFunction +function Easing.easeOutCubic(t) + local t1 = t - 1 + return t1 * t1 * t1 + 1 +end + +---@type EasingFunction +function Easing.easeInOutCubic(t) + return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 +end + +-- ============================================================================ +-- Quartic (Quart) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInQuart(t) + return t * t * t * t +end + +---@type EasingFunction +function Easing.easeOutQuart(t) + local t1 = t - 1 + return 1 - t1 * t1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutQuart(t) + if t < 0.5 then + return 8 * t * t * t * t + else + local t1 = t - 1 + return 1 - 8 * t1 * t1 * t1 * t1 + end +end + +-- ============================================================================ +-- Quintic (Quint) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInQuint(t) + return t * t * t * t * t +end + +---@type EasingFunction +function Easing.easeOutQuint(t) + local t1 = t - 1 + return 1 + t1 * t1 * t1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutQuint(t) + if t < 0.5 then + return 16 * t * t * t * t * t + else + local t1 = t - 1 + return 1 + 16 * t1 * t1 * t1 * t1 * t1 + end +end + +-- ============================================================================ +-- Exponential (Expo) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInExpo(t) + return t == 0 and 0 or math.pow(2, 10 * (t - 1)) +end + +---@type EasingFunction +function Easing.easeOutExpo(t) + return t == 1 and 1 or 1 - math.pow(2, -10 * t) +end + +---@type EasingFunction +function Easing.easeInOutExpo(t) + if t == 0 then return 0 end + if t == 1 then return 1 end + + if t < 0.5 then + return 0.5 * math.pow(2, 20 * t - 10) + else + return 1 - 0.5 * math.pow(2, -20 * t + 10) + end +end + +-- ============================================================================ +-- Sine +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInSine(t) + return 1 - math.cos(t * math.pi / 2) +end + +---@type EasingFunction +function Easing.easeOutSine(t) + return math.sin(t * math.pi / 2) +end + +---@type EasingFunction +function Easing.easeInOutSine(t) + return -(math.cos(math.pi * t) - 1) / 2 +end + +-- ============================================================================ +-- Circular (Circ) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInCirc(t) + return 1 - math.sqrt(1 - t * t) +end + +---@type EasingFunction +function Easing.easeOutCirc(t) + local t1 = t - 1 + return math.sqrt(1 - t1 * t1) +end + +---@type EasingFunction +function Easing.easeInOutCirc(t) + if t < 0.5 then + return (1 - math.sqrt(1 - 4 * t * t)) / 2 + else + local t1 = -2 * t + 2 + return (math.sqrt(1 - t1 * t1) + 1) / 2 + end +end + +-- ============================================================================ +-- Back (Overshoot) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInBack(t) + local c1 = 1.70158 + local c3 = c1 + 1 + return c3 * t * t * t - c1 * t * t +end + +---@type EasingFunction +function Easing.easeOutBack(t) + local c1 = 1.70158 + local c3 = c1 + 1 + local t1 = t - 1 + return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutBack(t) + local c1 = 1.70158 + local c2 = c1 * 1.525 + + if t < 0.5 then + return (2 * t * 2 * t * ((c2 + 1) * 2 * t - c2)) / 2 + else + local t1 = 2 * t - 2 + return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2 + end +end + +-- ============================================================================ +-- Elastic (Spring) +-- ============================================================================ + +---@type EasingFunction +function Easing.easeInElastic(t) + if t == 0 then return 0 end + if t == 1 then return 1 end + + local c4 = (2 * math.pi) / 3 + return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4) +end + +---@type EasingFunction +function Easing.easeOutElastic(t) + if t == 0 then return 0 end + if t == 1 then return 1 end + + local c4 = (2 * math.pi) / 3 + return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1 +end + +---@type EasingFunction +function Easing.easeInOutElastic(t) + if t == 0 then return 0 end + if t == 1 then return 1 end + + local c5 = (2 * math.pi) / 4.5 + + if t < 0.5 then + return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 + else + return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1 + end +end + +-- ============================================================================ +-- Bounce +-- ============================================================================ + +---@type EasingFunction +function Easing.easeOutBounce(t) + local n1 = 7.5625 + local d1 = 2.75 + + if t < 1 / d1 then + return n1 * t * t + elseif t < 2 / d1 then + local t1 = t - 1.5 / d1 + return n1 * t1 * t1 + 0.75 + elseif t < 2.5 / d1 then + local t1 = t - 2.25 / d1 + return n1 * t1 * t1 + 0.9375 + else + local t1 = t - 2.625 / d1 + return n1 * t1 * t1 + 0.984375 + end +end + +---@type EasingFunction +function Easing.easeInBounce(t) + return 1 - Easing.easeOutBounce(1 - t) +end + +---@type EasingFunction +function Easing.easeInOutBounce(t) + if t < 0.5 then + return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 + else + return (1 + Easing.easeOutBounce(2 * t - 1)) / 2 + end +end + +-- ============================================================================ +-- Configurable Easing Factories +-- ============================================================================ + +--- Create a custom back easing function with configurable overshoot +---@param overshoot number? Overshoot amount (default: 1.70158) +---@return EasingFunction +function Easing.back(overshoot) + overshoot = overshoot or 1.70158 + local c3 = overshoot + 1 + + return function(t) + return c3 * t * t * t - overshoot * t * t + end +end + +--- Create a custom elastic easing function +---@param amplitude number? Amplitude (default: 1) +---@param period number? Period (default: 0.3) +---@return EasingFunction +function Easing.elastic(amplitude, period) + amplitude = amplitude or 1 + period = period or 0.3 + + return function(t) + if t == 0 then return 0 end + if t == 1 then return 1 end + + local s = period / 4 + local a = amplitude + + if a < 1 then + a = 1 + s = period / 4 + else + s = period / (2 * math.pi) * math.asin(1 / a) + end + + return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1 + end +end + +--- Get list of all available easing function names +---@return string[] names Array of easing function names +function Easing.list() + return { + -- 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", + } +end + +--- Get an easing function by name +---@param name string Easing function name +---@return EasingFunction? easing The easing function, or nil if not found +function Easing.get(name) + return Easing[name] +end + +return Easing diff --git a/testing/__tests__/easing_test.lua b/testing/__tests__/easing_test.lua new file mode 100644 index 0000000..fe90b84 --- /dev/null +++ b/testing/__tests__/easing_test.lua @@ -0,0 +1,310 @@ +local luaunit = require("testing.luaunit") +require("testing.loveStub") + +local Easing = require("modules.Easing") +local ErrorHandler = require("modules.ErrorHandler") +local ErrorCodes = require("modules.ErrorCodes") + +-- Initialize ErrorHandler +ErrorHandler.init({ ErrorCodes = ErrorCodes }) + +TestEasing = {} + +function TestEasing:setUp() + -- Reset state before each test +end + +-- Test that all easing functions exist +function TestEasing:testAllEasingFunctionsExist() + 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 + +-- Test that all easing functions accept t parameter (0-1) +function TestEasing:testEasingFunctionsAcceptParameter() + local result = Easing.linear(0.5) + luaunit.assertNotNil(result) + luaunit.assertEquals(type(result), "number") +end + +-- Test linear easing +function TestEasing:testLinear() + 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:testEaseInQuad() + 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:testEaseOutQuad() + 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:testEaseInOutQuad() + 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:testEaseInSine() + 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:testEaseOutSine() + 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:testEaseInOutSine() + 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:testEaseInQuint() + 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:testEaseOutQuint() + 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:testEaseInCirc() + 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:testEaseOutCirc() + 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:testEaseInOutCirc() + 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:testEaseInBack() + 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:testEaseOutBack() + 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:testEaseInElastic() + 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:testEaseOutElastic() + 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:testEaseInBounce() + 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:testEaseOutBounce() + 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:testEaseInOutBounce() + 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 + +-- Test configurable back() factory +function TestEasing:testBackFactory() + 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:testElasticFactory() + 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 Easing.list() method +function TestEasing:testList() + local list = Easing.list() + luaunit.assertEquals(type(list), "table") + luaunit.assertEquals(#list, 31, "Should have exactly 31 easing functions") + + -- Check that linear is in the list + local hasLinear = false + for _, name in ipairs(list) do + if name == "linear" then + hasLinear = true + break + end + end + luaunit.assertTrue(hasLinear, "List should contain 'linear'") +end + +-- Test Easing.get() method +function TestEasing:testGet() + local linear = Easing.get("linear") + luaunit.assertNotNil(linear) + luaunit.assertEquals(type(linear), "function") + luaunit.assertEquals(linear(0.5), 0.5) + + -- Test non-existent easing + local nonExistent = Easing.get("nonExistentEasing") + luaunit.assertNil(nonExistent) +end + +-- Test that all InOut easings are symmetric around 0.5 +function TestEasing:testInOutSymmetry() + 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:testBoundaryConditions() + 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 + +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/__tests__/keyframe_animation_test.lua b/testing/__tests__/keyframe_animation_test.lua new file mode 100644 index 0000000..5b23170 --- /dev/null +++ b/testing/__tests__/keyframe_animation_test.lua @@ -0,0 +1,353 @@ +local luaunit = require("testing.luaunit") +require("testing.loveStub") + +local Animation = require("modules.Animation") +local ErrorHandler = require("modules.ErrorHandler") +local ErrorCodes = require("modules.ErrorCodes") + +-- Initialize ErrorHandler for Animation module +ErrorHandler.init({ ErrorCodes = ErrorCodes }) +Animation.initializeErrorHandler(ErrorHandler) + +TestKeyframeAnimation = {} + +function TestKeyframeAnimation:setUp() + -- Reset state before each test +end + +-- Test basic keyframe animation creation +function TestKeyframeAnimation:testCreateKeyframeAnimation() + 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:testMultipleWaypoints() + 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:testKeyframeSorting() + 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:testInterpolationAtStart() + 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:testInterpolationAtEnd() + 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:testInterpolationAtMidpoint() + 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:testPerKeyframeEasing() + 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:testFindKeyframes() + 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:testKeyframeAnimationUpdate() + 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:testKeyframeAnimationCallbacks() + 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:testMissingKeyframes() + -- 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:testSingleKeyframe() + -- 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:testKeyframesWithoutStart() + 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:testKeyframesWithoutEnd() + 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:testInvalidKeyframeProps() + -- 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:testMultiPropertyKeyframes() + 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:testKeyframeWithEasingFunction() + 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:testKeyframeCaching() + 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 + +os.exit(luaunit.LuaUnit.run())