From 998469141a3db000d0145fa508dd4de853c3f428 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 25 Feb 2026 00:46:45 -0500 Subject: [PATCH] feat: animation expansion --- FlexLove.lua | 1 + modules/Animation.lua | 19 + modules/Element.lua | 95 ++ testing/__tests__/animation_chaining_test.lua | 531 +++++++++++ testing/__tests__/animation_group_test.lua | 853 ++++++++++++++++++ testing/__tests__/transition_test.lua | 442 +++++++++ testing/runAll.lua | 3 + 7 files changed, 1944 insertions(+) create mode 100644 testing/__tests__/animation_chaining_test.lua create mode 100644 testing/__tests__/animation_group_test.lua create mode 100644 testing/__tests__/transition_test.lua diff --git a/FlexLove.lua b/FlexLove.lua index a11aa43..0be64b1 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -232,6 +232,7 @@ function flexlove.init(config) ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, Transform = Transform, + Animation = Animation, } -- Initialize Element module with dependencies diff --git a/modules/Animation.lua b/modules/Animation.lua index 21bbbc6..98afa78 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -1232,6 +1232,25 @@ function Animation.keyframes(props) }) end +--- Link an array of animations into a chain (static helper) +--- Each animation's completion triggers the next in sequence +---@param animations Animation[] Array of animations to chain +---@return Animation first The first animation in the chain +function Animation.chainSequence(animations) + if type(animations) ~= "table" or #animations == 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_004") + end + return Animation.new({ duration = 0, start = {}, final = {} }) + end + + for i = 1, #animations - 1 do + animations[i]:chain(animations[i + 1]) + end + + return animations[1] +end + -- ============================================================================ -- ANIMATION GROUP (Utility) -- ============================================================================ diff --git a/modules/Element.lua b/modules/Element.lua index 61d76c7..cc14047 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -191,6 +191,7 @@ function Element.init(deps) Element._StateManager = deps.StateManager Element._GestureRecognizer = deps.GestureRecognizer Element._Performance = deps.Performance + Element._Animation = deps.Animation end ---@param props ElementProps @@ -3716,6 +3717,100 @@ function Element:setTransformOrigin(originX, originY) self.transform.originY = originY end +--- Animate element to new property values with automatic transition +--- Captures current values as start, uses provided values as final, and applies the animation +---@param props table Target property values +---@param duration number? Animation duration in seconds (default: 0.3) +---@param easing string? Easing function name (default: "linear") +---@return Element self For method chaining +function Element:animateTo(props, duration, easing) + if not Element._Animation then + Element._ErrorHandler:warn("Element", "ELEM_003") + return self + end + + if type(props) ~= "table" then + Element._ErrorHandler:warn("Element", "ELEM_003") + return self + end + + duration = duration or 0.3 + easing = easing or "linear" + + -- Collect current values as start + local startValues = {} + for key, _ in pairs(props) do + startValues[key] = self[key] + end + + -- Create and apply animation + local anim = Element._Animation.new({ + duration = duration, + start = startValues, + final = props, + easing = easing, + }) + + anim:apply(self) + return self +end + +--- Fade element to full opacity +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:fadeIn(duration, easing) + return self:animateTo({ opacity = 1 }, duration or 0.3, easing) +end + +--- Fade element to zero opacity +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:fadeOut(duration, easing) + return self:animateTo({ opacity = 0 }, duration or 0.3, easing) +end + +--- Scale element to target scale value using transforms +---@param targetScale number Target scale multiplier +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:scaleTo(targetScale, duration, easing) + if not Element._Animation or not Element._Transform then + Element._ErrorHandler:warn("Element", "ELEM_003") + return self + end + + -- Ensure element has a transform + if not self.transform then + self.transform = Element._Transform.new({}) + end + + local currentScaleX = self.transform.scaleX or 1 + local currentScaleY = self.transform.scaleY or 1 + + local anim = Element._Animation.new({ + duration = duration or 0.3, + start = { scaleX = currentScaleX, scaleY = currentScaleY }, + final = { scaleX = targetScale, scaleY = targetScale }, + easing = easing or "linear", + }) + + anim:apply(self) + return self +end + +--- Move element to target position +---@param x number Target x position +---@param y number Target y position +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:moveTo(x, y, duration, easing) + return self:animateTo({ x = x, y = y }, duration or 0.3, easing) +end + --- Set transition configuration for a property ---@param property string Property name or "all" for all properties ---@param config table Transition config {duration, easing, delay, onComplete} diff --git a/testing/__tests__/animation_chaining_test.lua b/testing/__tests__/animation_chaining_test.lua new file mode 100644 index 0000000..c640553 --- /dev/null +++ b/testing/__tests__/animation_chaining_test.lua @@ -0,0 +1,531 @@ +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 + +-- Helper: create a simple animation +local function makeAnim(duration, startX, finalX) + return Animation.new({ + duration = duration or 1, + start = { x = startX or 0 }, + final = { x = finalX or 100 }, + }) +end + +-- Helper: create a retained-mode test element +local function makeElement(props) + love.window.setMode(1920, 1080) + FlexLove.beginFrame() + local el = FlexLove.new(props or { width = 100, height = 100 }) + FlexLove.endFrame() + return el +end + +-- ============================================================================ +-- Test Suite: Animation Instance Chaining +-- ============================================================================ + +TestAnimationChaining = {} + +function TestAnimationChaining:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationChaining:tearDown() + FlexLove.endFrame() +end + +function TestAnimationChaining:test_chain_links_two_animations() + local anim1 = makeAnim(0.5, 0, 50) + local anim2 = makeAnim(0.5, 50, 100) + + local returned = anim1:chain(anim2) + + luaunit.assertEquals(anim1._next, anim2) + luaunit.assertEquals(returned, anim2) +end + +function TestAnimationChaining:test_chain_with_factory_function() + local factory = function(element) + return makeAnim(0.5, 0, 100) + end + local anim1 = makeAnim(0.5) + + local returned = anim1:chain(factory) + + luaunit.assertEquals(anim1._nextFactory, factory) + luaunit.assertEquals(returned, anim1) -- returns self when factory +end + +function TestAnimationChaining:test_chained_animations_execute_in_order() + local el = makeElement({ width = 100, height = 100, opacity = 1 }) + + local order = {} + local anim1 = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 50 }, + onComplete = function() table.insert(order, 1) end, + }) + local anim2 = Animation.new({ + duration = 0.2, + start = { x = 50 }, + final = { x = 100 }, + onComplete = function() table.insert(order, 2) end, + }) + + anim1:chain(anim2) + anim1:apply(el) + + -- Run anim1 to completion + for i = 1, 20 do + el:update(1 / 60) + end + + -- anim1 should be done, anim2 should now be the active animation + luaunit.assertEquals(order[1], 1) + luaunit.assertEquals(el.animation, anim2) + + -- Run anim2 to completion + for i = 1, 20 do + el:update(1 / 60) + end + + luaunit.assertEquals(order[2], 2) + luaunit.assertNil(el.animation) +end + +function TestAnimationChaining:test_chain_with_factory_creates_dynamic_animation() + local el = makeElement({ width = 100, height = 100 }) + el.x = 0 + + local anim1 = Animation.new({ + duration = 0.1, + start = { x = 0 }, + final = { x = 50 }, + }) + + local factoryCalled = false + anim1:chain(function(element) + factoryCalled = true + return Animation.new({ + duration = 1.0, + start = { x = 50 }, + final = { x = 200 }, + }) + end) + + anim1:apply(el) + + -- Run anim1 to completion (0.1s duration, ~7 frames at 1/60) + for i = 1, 10 do + el:update(1 / 60) + end + + luaunit.assertTrue(factoryCalled) + luaunit.assertNotNil(el.animation) -- Should have the factory-created animation (1s duration) +end + +-- ============================================================================ +-- Test Suite: Animation delay() +-- ============================================================================ + +TestAnimationDelay = {} + +function TestAnimationDelay:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationDelay:tearDown() + FlexLove.endFrame() +end + +function TestAnimationDelay:test_delay_delays_animation_start() + local anim = makeAnim(0.5) + anim:delay(0.3) + + -- During delay period, animation should not progress + local finished = anim:update(0.2) + luaunit.assertFalse(finished) + luaunit.assertEquals(anim.elapsed, 0) + + -- Still in delay (0.2 + 0.15 = 0.35 total delay elapsed, but the second + -- call starts with _delayElapsed=0.2 < 0.3, so it adds 0.15 and returns false) + finished = anim:update(0.15) + luaunit.assertFalse(finished) + luaunit.assertEquals(anim.elapsed, 0) + + -- Now delay is past (0.35 >= 0.3), animation should start progressing + anim:update(0.1) + luaunit.assertTrue(anim.elapsed > 0) +end + +function TestAnimationDelay:test_delay_returns_self() + local anim = makeAnim(1) + local returned = anim:delay(0.5) + luaunit.assertEquals(returned, anim) +end + +function TestAnimationDelay:test_delay_with_invalid_value_defaults_to_zero() + local anim = makeAnim(0.5) + anim:delay(-1) + luaunit.assertEquals(anim._delay, 0) + + local anim2 = makeAnim(0.5) + anim2:delay("bad") + luaunit.assertEquals(anim2._delay, 0) +end + +-- ============================================================================ +-- Test Suite: Animation repeatCount() +-- ============================================================================ + +TestAnimationRepeat = {} + +function TestAnimationRepeat:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationRepeat:tearDown() + FlexLove.endFrame() +end + +function TestAnimationRepeat:test_repeat_n_times() + local anim = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 100 }, + }) + anim:repeatCount(3) + + local completions = 0 + -- Run through multiple cycles + for i = 1, 300 do + local finished = anim:update(1 / 60) + if anim.elapsed == 0 or finished then + completions = completions + 1 + end + if finished then + break + end + end + + luaunit.assertEquals(anim:getState(), "completed") +end + +function TestAnimationRepeat:test_repeat_returns_self() + local anim = makeAnim(1) + local returned = anim:repeatCount(3) + luaunit.assertEquals(returned, anim) +end + +-- ============================================================================ +-- Test Suite: Animation yoyo() +-- ============================================================================ + +TestAnimationYoyo = {} + +function TestAnimationYoyo:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationYoyo:tearDown() + FlexLove.endFrame() +end + +function TestAnimationYoyo:test_yoyo_reverses_on_repeat() + local anim = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 100 }, + }) + anim:repeatCount(2):yoyo(true) + + -- First cycle + for i = 1, 15 do + anim:update(1 / 60) + end + + -- After first cycle completes, it should be reversed + luaunit.assertTrue(anim._reversed) +end + +function TestAnimationYoyo:test_yoyo_returns_self() + local anim = makeAnim(1) + local returned = anim:yoyo(true) + luaunit.assertEquals(returned, anim) +end + +function TestAnimationYoyo:test_yoyo_default_true() + local anim = makeAnim(1) + anim:yoyo() + luaunit.assertTrue(anim._yoyo) +end + +function TestAnimationYoyo:test_yoyo_false_disables() + local anim = makeAnim(1) + anim:yoyo(false) + luaunit.assertFalse(anim._yoyo) +end + +-- ============================================================================ +-- Test Suite: Animation.chainSequence() static helper +-- ============================================================================ + +TestAnimationChainSequence = {} + +function TestAnimationChainSequence:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationChainSequence:tearDown() + FlexLove.endFrame() +end + +function TestAnimationChainSequence:test_chainSequence_links_all_animations() + local a1 = makeAnim(0.2, 0, 50) + local a2 = makeAnim(0.2, 50, 100) + local a3 = makeAnim(0.2, 100, 150) + + local first = Animation.chainSequence({ a1, a2, a3 }) + + luaunit.assertEquals(first, a1) + luaunit.assertEquals(a1._next, a2) + luaunit.assertEquals(a2._next, a3) +end + +function TestAnimationChainSequence:test_chainSequence_single_animation() + local a1 = makeAnim(0.2) + local first = Animation.chainSequence({ a1 }) + + luaunit.assertEquals(first, a1) + luaunit.assertNil(a1._next) +end + +function TestAnimationChainSequence:test_chainSequence_empty_array() + local first = Animation.chainSequence({}) + luaunit.assertNotNil(first) -- should return a fallback animation +end + +-- ============================================================================ +-- Test Suite: Element Fluent API +-- ============================================================================ + +TestElementFluentAPI = {} + +function TestElementFluentAPI:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestElementFluentAPI:tearDown() + FlexLove.endFrame() +end + +function TestElementFluentAPI:test_animateTo_creates_animation() + local el = FlexLove.new({ width = 100, height = 100 }) + el.opacity = 0.5 + + local returned = el:animateTo({ opacity = 1 }, 0.5, "easeOutQuad") + + luaunit.assertEquals(returned, el) -- returns self + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.duration, 0.5) + luaunit.assertEquals(el.animation.start.opacity, 0.5) + luaunit.assertEquals(el.animation.final.opacity, 1) +end + +function TestElementFluentAPI:test_animateTo_with_defaults() + local el = FlexLove.new({ width = 100, height = 100 }) + el.x = 10 + + el:animateTo({ x = 200 }) + + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.duration, 0.3) -- default +end + +function TestElementFluentAPI:test_fadeIn_sets_opacity_target_to_1() + local el = FlexLove.new({ width = 100, height = 100 }) + el.opacity = 0 + + local returned = el:fadeIn(0.5) + + luaunit.assertEquals(returned, el) + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.start.opacity, 0) + luaunit.assertEquals(el.animation.final.opacity, 1) +end + +function TestElementFluentAPI:test_fadeIn_default_duration() + local el = FlexLove.new({ width = 100, height = 100 }) + el.opacity = 0 + + el:fadeIn() + + luaunit.assertEquals(el.animation.duration, 0.3) +end + +function TestElementFluentAPI:test_fadeOut_sets_opacity_target_to_0() + local el = FlexLove.new({ width = 100, height = 100 }) + el.opacity = 1 + + local returned = el:fadeOut(0.5) + + luaunit.assertEquals(returned, el) + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.start.opacity, 1) + luaunit.assertEquals(el.animation.final.opacity, 0) +end + +function TestElementFluentAPI:test_fadeOut_default_duration() + local el = FlexLove.new({ width = 100, height = 100 }) + el:fadeOut() + + luaunit.assertEquals(el.animation.duration, 0.3) +end + +function TestElementFluentAPI:test_scaleTo_creates_scale_animation() + local el = FlexLove.new({ width = 100, height = 100 }) + + local returned = el:scaleTo(2.0, 0.5) + + luaunit.assertEquals(returned, el) + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.final.scaleX, 2.0) + luaunit.assertEquals(el.animation.final.scaleY, 2.0) +end + +function TestElementFluentAPI:test_scaleTo_default_duration() + local el = FlexLove.new({ width = 100, height = 100 }) + el:scaleTo(1.5) + + luaunit.assertEquals(el.animation.duration, 0.3) +end + +function TestElementFluentAPI:test_scaleTo_initializes_transform() + local el = FlexLove.new({ width = 100, height = 100 }) + -- Should not have a transform yet (or it has one from constructor) + + el:scaleTo(2.0) + + luaunit.assertNotNil(el.transform) +end + +function TestElementFluentAPI:test_moveTo_creates_position_animation() + local el = FlexLove.new({ width = 100, height = 100 }) + el.x = 0 + el.y = 0 + + local returned = el:moveTo(200, 300, 0.5, "easeInOutCubic") + + luaunit.assertEquals(returned, el) + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.start.x, 0) + luaunit.assertEquals(el.animation.start.y, 0) + luaunit.assertEquals(el.animation.final.x, 200) + luaunit.assertEquals(el.animation.final.y, 300) +end + +function TestElementFluentAPI:test_moveTo_default_duration() + local el = FlexLove.new({ width = 100, height = 100 }) + el:moveTo(100, 100) + + luaunit.assertEquals(el.animation.duration, 0.3) +end + +function TestElementFluentAPI:test_animateTo_with_invalid_props_returns_self() + local el = FlexLove.new({ width = 100, height = 100 }) + + local returned = el:animateTo("invalid") + + luaunit.assertEquals(returned, el) + luaunit.assertNil(el.animation) +end + +-- ============================================================================ +-- Test Suite: Integration - Chaining with Fluent API +-- ============================================================================ + +TestAnimationChainingIntegration = {} + +function TestAnimationChainingIntegration:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationChainingIntegration:tearDown() + FlexLove.endFrame() +end + +function TestAnimationChainingIntegration:test_chained_delay_and_repeat() + local anim = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 100 }, + }) + local chained = anim:delay(0.1):repeatCount(2):yoyo(true) + + luaunit.assertEquals(chained, anim) + luaunit.assertEquals(anim._delay, 0.1) + luaunit.assertEquals(anim._repeatCount, 2) + luaunit.assertTrue(anim._yoyo) +end + +function TestAnimationChainingIntegration:test_complex_chain_executes_fully() + local el = makeElement({ width = 100, height = 100, opacity = 1 }) + + local a1 = Animation.new({ + duration = 0.1, + start = { opacity = 1 }, + final = { opacity = 0 }, + }) + local a2 = Animation.new({ + duration = 0.1, + start = { opacity = 0 }, + final = { opacity = 1 }, + }) + local a3 = Animation.new({ + duration = 0.1, + start = { opacity = 1 }, + final = { opacity = 0.5 }, + }) + + Animation.chainSequence({ a1, a2, a3 }) + a1:apply(el) + + -- Run all three animations + for i = 1, 100 do + el:update(1 / 60) + if not el.animation then + break + end + end + + -- All should have completed, no animation left + luaunit.assertNil(el.animation) +end + +-- Run all tests +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/animation_group_test.lua b/testing/__tests__/animation_group_test.lua new file mode 100644 index 0000000..3e7863b --- /dev/null +++ b/testing/__tests__/animation_group_test.lua @@ -0,0 +1,853 @@ +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 AnimationGroup = Animation.Group + +-- Helper: create a simple animation with given duration +local function makeAnim(duration, startVal, finalVal) + return Animation.new({ + duration = duration or 1, + start = { x = startVal or 0 }, + final = { x = finalVal or 100 }, + }) +end + +-- Helper: advance an animation group to completion +local function runToCompletion(group, dt) + dt = dt or 1 / 60 + local maxFrames = 10000 + for i = 1, maxFrames do + if group:update(dt) then + return i + end + end + return maxFrames +end + +-- ============================================================================ +-- Test Suite: AnimationGroup Construction +-- ============================================================================ + +TestAnimationGroupConstruction = {} + +function TestAnimationGroupConstruction:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupConstruction:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupConstruction:test_new_creates_group_with_defaults() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ animations = { anim1, anim2 } }) + + luaunit.assertNotNil(group) + luaunit.assertEquals(group.mode, "parallel") + luaunit.assertEquals(group.stagger, 0.1) + luaunit.assertEquals(#group.animations, 2) + luaunit.assertEquals(group:getState(), "ready") +end + +function TestAnimationGroupConstruction:test_new_with_sequence_mode() + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + mode = "sequence", + }) + luaunit.assertEquals(group.mode, "sequence") +end + +function TestAnimationGroupConstruction:test_new_with_stagger_mode() + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + mode = "stagger", + stagger = 0.2, + }) + luaunit.assertEquals(group.mode, "stagger") + luaunit.assertEquals(group.stagger, 0.2) +end + +function TestAnimationGroupConstruction:test_new_with_invalid_mode_defaults_to_parallel() + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + mode = "invalid", + }) + luaunit.assertEquals(group.mode, "parallel") +end + +function TestAnimationGroupConstruction:test_new_with_nil_props_does_not_error() + local group = AnimationGroup.new(nil) + luaunit.assertNotNil(group) + luaunit.assertEquals(#group.animations, 0) +end + +function TestAnimationGroupConstruction:test_new_with_empty_animations() + local group = AnimationGroup.new({ animations = {} }) + luaunit.assertNotNil(group) + luaunit.assertEquals(#group.animations, 0) +end + +function TestAnimationGroupConstruction:test_new_with_callbacks() + local onStart = function() end + local onComplete = function() end + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + onStart = onStart, + onComplete = onComplete, + }) + luaunit.assertEquals(group.onStart, onStart) + luaunit.assertEquals(group.onComplete, onComplete) +end + +-- ============================================================================ +-- Test Suite: Parallel Mode +-- ============================================================================ + +TestAnimationGroupParallel = {} + +function TestAnimationGroupParallel:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupParallel:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupParallel:test_parallel_runs_all_animations_simultaneously() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ + mode = "parallel", + animations = { anim1, anim2 }, + }) + + group:update(0.5) + + -- Both animations should have progressed + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertTrue(anim2.elapsed > 0) +end + +function TestAnimationGroupParallel:test_parallel_completes_when_all_finish() + local anim1 = makeAnim(0.5) + local anim2 = makeAnim(1.0) + local group = AnimationGroup.new({ + mode = "parallel", + animations = { anim1, anim2 }, + }) + + -- After 0.6s: anim1 done, anim2 not done + local finished = group:update(0.6) + luaunit.assertFalse(finished) + luaunit.assertEquals(group:getState(), "playing") + + -- After another 0.5s: both done + finished = group:update(0.5) + luaunit.assertTrue(finished) + luaunit.assertEquals(group:getState(), "completed") +end + +function TestAnimationGroupParallel:test_parallel_uses_max_duration() + local anim1 = makeAnim(0.3) + local anim2 = makeAnim(0.5) + local anim3 = makeAnim(0.8) + local group = AnimationGroup.new({ + mode = "parallel", + animations = { anim1, anim2, anim3 }, + }) + + -- At 0.5s, anim3 is not yet done + local finished = group:update(0.5) + luaunit.assertFalse(finished) + + -- At 0.9s total, all should be done + finished = group:update(0.4) + luaunit.assertTrue(finished) +end + +function TestAnimationGroupParallel:test_parallel_does_not_update_completed_animations() + local anim1 = makeAnim(0.2) + local anim2 = makeAnim(1.0) + local group = AnimationGroup.new({ + mode = "parallel", + animations = { anim1, anim2 }, + }) + + -- Run past anim1's completion + group:update(0.3) + local anim1Elapsed = anim1.elapsed + + -- Update again - anim1 should not be updated further + group:update(0.1) + -- anim1 is completed, so its elapsed might stay clamped + luaunit.assertEquals(anim1:getState(), "completed") +end + +-- ============================================================================ +-- Test Suite: Sequence Mode +-- ============================================================================ + +TestAnimationGroupSequence = {} + +function TestAnimationGroupSequence:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupSequence:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupSequence:test_sequence_runs_one_at_a_time() + local anim1 = makeAnim(0.5) + local anim2 = makeAnim(0.5) + local group = AnimationGroup.new({ + mode = "sequence", + animations = { anim1, anim2 }, + }) + + -- After 0.3s, only anim1 should have progressed + group:update(0.3) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertEquals(anim2.elapsed, 0) -- anim2 hasn't started +end + +function TestAnimationGroupSequence:test_sequence_advances_to_next_on_completion() + local anim1 = makeAnim(0.5) + local anim2 = makeAnim(0.5) + local group = AnimationGroup.new({ + mode = "sequence", + animations = { anim1, anim2 }, + }) + + -- Complete anim1 + group:update(0.6) + luaunit.assertEquals(anim1:getState(), "completed") + + -- Now anim2 should receive updates + group:update(0.3) + luaunit.assertTrue(anim2.elapsed > 0) +end + +function TestAnimationGroupSequence:test_sequence_completes_when_last_finishes() + local anim1 = makeAnim(0.3) + local anim2 = makeAnim(0.3) + local group = AnimationGroup.new({ + mode = "sequence", + animations = { anim1, anim2 }, + }) + + -- Complete anim1 + group:update(0.4) + luaunit.assertFalse(group:getState() == "completed") + + -- Complete anim2 + group:update(0.4) + luaunit.assertEquals(group:getState(), "completed") +end + +function TestAnimationGroupSequence:test_sequence_maintains_order() + local order = {} + local anim1 = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 100 }, + onStart = function() table.insert(order, 1) end, + }) + local anim2 = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 100 }, + onStart = function() table.insert(order, 2) end, + }) + local anim3 = Animation.new({ + duration = 0.2, + start = { x = 0 }, + final = { x = 100 }, + onStart = function() table.insert(order, 3) end, + }) + + local group = AnimationGroup.new({ + mode = "sequence", + animations = { anim1, anim2, anim3 }, + }) + + runToCompletion(group, 0.05) + + luaunit.assertEquals(order, { 1, 2, 3 }) +end + +-- ============================================================================ +-- Test Suite: Stagger Mode +-- ============================================================================ + +TestAnimationGroupStagger = {} + +function TestAnimationGroupStagger:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupStagger:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupStagger:test_stagger_delays_animation_starts() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local anim3 = makeAnim(1) + local group = AnimationGroup.new({ + mode = "stagger", + stagger = 0.5, + animations = { anim1, anim2, anim3 }, + }) + + -- At t=0.3: only anim1 should have started (stagger=0.5 means anim2 starts at t=0.5) + group:update(0.3) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertEquals(anim2.elapsed, 0) + luaunit.assertEquals(anim3.elapsed, 0) +end + +function TestAnimationGroupStagger:test_stagger_timing_is_correct() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local anim3 = makeAnim(1) + local group = AnimationGroup.new({ + mode = "stagger", + stagger = 0.2, + animations = { anim1, anim2, anim3 }, + }) + + -- At t=0.15: only anim1 started (anim2 starts at t=0.2, anim3 at t=0.4) + group:update(0.15) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertEquals(anim2.elapsed, 0) + luaunit.assertEquals(anim3.elapsed, 0) + + -- At t=0.3: anim1 and anim2 started, anim3 not yet + group:update(0.15) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertTrue(anim2.elapsed > 0) + luaunit.assertEquals(anim3.elapsed, 0) + + -- At t=0.5: all started + group:update(0.2) + luaunit.assertTrue(anim3.elapsed > 0) +end + +function TestAnimationGroupStagger:test_stagger_completes_when_all_finish() + -- With stagger, animations get the full dt once their stagger offset is reached. + -- Use a longer stagger so anim2 hasn't started yet at the first check. + local anim1 = makeAnim(0.5) + local anim2 = makeAnim(0.5) + local group = AnimationGroup.new({ + mode = "stagger", + stagger = 0.5, + animations = { anim1, anim2 }, + }) + + -- At t=0.3: anim1 started, anim2 not yet (starts at t=0.5) + local finished = group:update(0.3) + luaunit.assertFalse(finished) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertEquals(anim2.elapsed, 0) + + -- At t=0.6: anim1 completed, anim2 just started and got 0.3s dt + finished = group:update(0.3) + luaunit.assertFalse(finished) + luaunit.assertEquals(anim1:getState(), "completed") + luaunit.assertTrue(anim2.elapsed > 0) + + -- At t=0.9: anim2 should be completed (got 0.3 + 0.3 = 0.6s of updates) + finished = group:update(0.3) + luaunit.assertTrue(finished) + luaunit.assertEquals(group:getState(), "completed") +end + +-- ============================================================================ +-- Test Suite: Callbacks +-- ============================================================================ + +TestAnimationGroupCallbacks = {} + +function TestAnimationGroupCallbacks:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupCallbacks:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupCallbacks:test_onStart_called_once() + local startCount = 0 + local group = AnimationGroup.new({ + animations = { makeAnim(0.5) }, + onStart = function() startCount = startCount + 1 end, + }) + + group:update(0.1) + group:update(0.1) + group:update(0.1) + + luaunit.assertEquals(startCount, 1) +end + +function TestAnimationGroupCallbacks:test_onStart_receives_group_reference() + local receivedGroup = nil + local group = AnimationGroup.new({ + animations = { makeAnim(0.5) }, + onStart = function(g) receivedGroup = g end, + }) + + group:update(0.1) + luaunit.assertEquals(receivedGroup, group) +end + +function TestAnimationGroupCallbacks:test_onComplete_called_when_all_finish() + local completeCount = 0 + local group = AnimationGroup.new({ + animations = { makeAnim(0.3) }, + onComplete = function() completeCount = completeCount + 1 end, + }) + + runToCompletion(group) + luaunit.assertEquals(completeCount, 1) +end + +function TestAnimationGroupCallbacks:test_onComplete_not_called_before_completion() + local completed = false + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + onComplete = function() completed = true end, + }) + + group:update(0.5) + luaunit.assertFalse(completed) +end + +function TestAnimationGroupCallbacks:test_callback_error_does_not_crash() + local group = AnimationGroup.new({ + animations = { makeAnim(0.1) }, + onStart = function() error("onStart error") end, + onComplete = function() error("onComplete error") end, + }) + + -- Should not throw + runToCompletion(group) + luaunit.assertEquals(group:getState(), "completed") +end + +-- ============================================================================ +-- Test Suite: Control Methods +-- ============================================================================ + +TestAnimationGroupControl = {} + +function TestAnimationGroupControl:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupControl:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupControl:test_pause_stops_updates() + local anim1 = makeAnim(1) + local group = AnimationGroup.new({ + animations = { anim1 }, + }) + + group:update(0.2) + local elapsedBefore = anim1.elapsed + + group:pause() + group:update(0.3) + + -- Elapsed should not have increased + luaunit.assertEquals(anim1.elapsed, elapsedBefore) + luaunit.assertTrue(group:isPaused()) +end + +function TestAnimationGroupControl:test_resume_continues_updates() + local anim1 = makeAnim(1) + local group = AnimationGroup.new({ + animations = { anim1 }, + }) + + group:update(0.2) + group:pause() + group:update(0.3) -- Should be ignored + + group:resume() + group:update(0.2) + + -- Should have progressed past the paused value + luaunit.assertTrue(anim1.elapsed > 0.2) + luaunit.assertFalse(group:isPaused()) +end + +function TestAnimationGroupControl:test_reverse_reverses_all_animations() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ + animations = { anim1, anim2 }, + }) + + group:update(0.5) + group:reverse() + + luaunit.assertTrue(anim1._reversed) + luaunit.assertTrue(anim2._reversed) +end + +function TestAnimationGroupControl:test_setSpeed_affects_all_animations() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ + animations = { anim1, anim2 }, + }) + + group:setSpeed(2.0) + + luaunit.assertEquals(anim1._speed, 2.0) + luaunit.assertEquals(anim2._speed, 2.0) +end + +function TestAnimationGroupControl:test_cancel_cancels_all_animations() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ + animations = { anim1, anim2 }, + }) + + group:update(0.3) + group:cancel() + + luaunit.assertEquals(group:getState(), "cancelled") + luaunit.assertEquals(anim1:getState(), "cancelled") + luaunit.assertEquals(anim2:getState(), "cancelled") +end + +function TestAnimationGroupControl:test_cancel_prevents_further_updates() + local anim1 = makeAnim(1) + local group = AnimationGroup.new({ + animations = { anim1 }, + }) + + group:update(0.2) + group:cancel() + local elapsedAfterCancel = anim1.elapsed + + group:update(0.3) + luaunit.assertEquals(anim1.elapsed, elapsedAfterCancel) +end + +function TestAnimationGroupControl:test_reset_restores_initial_state() + local anim1 = makeAnim(0.5) + local group = AnimationGroup.new({ + mode = "sequence", + animations = { anim1 }, + }) + + runToCompletion(group) + luaunit.assertEquals(group:getState(), "completed") + + group:reset() + luaunit.assertEquals(group:getState(), "ready") + luaunit.assertFalse(group._hasStarted) + luaunit.assertEquals(group._currentIndex, 1) + luaunit.assertEquals(group._staggerElapsed, 0) +end + +function TestAnimationGroupControl:test_reset_allows_replaying() + local completeCount = 0 + local group = AnimationGroup.new({ + animations = { makeAnim(0.2) }, + onComplete = function() completeCount = completeCount + 1 end, + }) + + runToCompletion(group) + luaunit.assertEquals(completeCount, 1) + + group:reset() + runToCompletion(group) + luaunit.assertEquals(completeCount, 2) +end + +-- ============================================================================ +-- Test Suite: State and Progress +-- ============================================================================ + +TestAnimationGroupStateProgress = {} + +function TestAnimationGroupStateProgress:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupStateProgress:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupStateProgress:test_state_transitions() + local group = AnimationGroup.new({ + animations = { makeAnim(0.5) }, + }) + + luaunit.assertEquals(group:getState(), "ready") + + group:update(0.1) + luaunit.assertEquals(group:getState(), "playing") + + runToCompletion(group) + luaunit.assertEquals(group:getState(), "completed") +end + +function TestAnimationGroupStateProgress:test_progress_parallel() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ + mode = "parallel", + animations = { anim1, anim2 }, + }) + + luaunit.assertAlmostEquals(group:getProgress(), 0, 0.01) + + group:update(0.5) + local progress = group:getProgress() + luaunit.assertTrue(progress > 0) + luaunit.assertTrue(progress < 1) + + runToCompletion(group) + luaunit.assertAlmostEquals(group:getProgress(), 1, 0.01) +end + +function TestAnimationGroupStateProgress:test_progress_sequence() + local anim1 = makeAnim(1) + local anim2 = makeAnim(1) + local group = AnimationGroup.new({ + mode = "sequence", + animations = { anim1, anim2 }, + }) + + -- Before any update + luaunit.assertAlmostEquals(group:getProgress(), 0, 0.01) + + -- Halfway through first animation (25% total) + group:update(0.5) + local progress = group:getProgress() + luaunit.assertTrue(progress > 0) + luaunit.assertTrue(progress <= 0.5) + + -- Complete first animation (50% total) + group:update(0.6) + progress = group:getProgress() + luaunit.assertTrue(progress >= 0.5) +end + +function TestAnimationGroupStateProgress:test_empty_group_progress_is_1() + local group = AnimationGroup.new({ animations = {} }) + luaunit.assertAlmostEquals(group:getProgress(), 1, 0.01) +end + +-- ============================================================================ +-- Test Suite: Empty and Edge Cases +-- ============================================================================ + +TestAnimationGroupEdgeCases = {} + +function TestAnimationGroupEdgeCases:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupEdgeCases:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupEdgeCases:test_empty_group_completes_immediately() + local completed = false + local group = AnimationGroup.new({ + animations = {}, + onComplete = function() completed = true end, + }) + + local finished = group:update(0.1) + luaunit.assertTrue(finished) + luaunit.assertEquals(group:getState(), "completed") +end + +function TestAnimationGroupEdgeCases:test_single_animation_group() + local anim = makeAnim(0.5) + local group = AnimationGroup.new({ + animations = { anim }, + }) + + runToCompletion(group) + luaunit.assertEquals(group:getState(), "completed") + luaunit.assertEquals(anim:getState(), "completed") +end + +function TestAnimationGroupEdgeCases:test_update_after_completion_returns_true() + local group = AnimationGroup.new({ + animations = { makeAnim(0.1) }, + }) + + runToCompletion(group) + local finished = group:update(0.1) + luaunit.assertTrue(finished) +end + +function TestAnimationGroupEdgeCases:test_invalid_dt_is_handled() + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + }) + + -- Should not throw for invalid dt values + group:update(-1) + group:update(0 / 0) -- NaN + group:update(math.huge) + luaunit.assertNotNil(group) +end + +function TestAnimationGroupEdgeCases:test_apply_assigns_group_to_element() + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + }) + + local mockElement = {} + group:apply(mockElement) + luaunit.assertEquals(mockElement.animationGroup, group) +end + +function TestAnimationGroupEdgeCases:test_apply_with_nil_element_does_not_crash() + local group = AnimationGroup.new({ + animations = { makeAnim(1) }, + }) + -- Should not throw + group:apply(nil) +end + +-- ============================================================================ +-- Test Suite: Nested Groups +-- ============================================================================ + +TestAnimationGroupNested = {} + +function TestAnimationGroupNested:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestAnimationGroupNested:tearDown() + FlexLove.endFrame() +end + +function TestAnimationGroupNested:test_nested_parallel_in_sequence() + local anim1 = makeAnim(0.3) + local anim2 = makeAnim(0.3) + local innerGroup = AnimationGroup.new({ + mode = "parallel", + animations = { anim1, anim2 }, + }) + + local anim3 = makeAnim(0.3) + local outerGroup = AnimationGroup.new({ + mode = "sequence", + animations = { innerGroup, anim3 }, + }) + + -- Inner group should run first + outerGroup:update(0.2) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertTrue(anim2.elapsed > 0) + luaunit.assertEquals(anim3.elapsed, 0) + + -- Complete inner group, anim3 should start + outerGroup:update(0.2) + outerGroup:update(0.2) + luaunit.assertTrue(anim3.elapsed > 0) +end + +function TestAnimationGroupNested:test_nested_sequence_in_parallel() + local anim1 = makeAnim(0.2) + local anim2 = makeAnim(0.2) + local innerSeq = AnimationGroup.new({ + mode = "sequence", + animations = { anim1, anim2 }, + }) + + local anim3 = makeAnim(0.3) + local outerGroup = AnimationGroup.new({ + mode = "parallel", + animations = { innerSeq, anim3 }, + }) + + -- Both innerSeq and anim3 should run in parallel + outerGroup:update(0.1) + luaunit.assertTrue(anim1.elapsed > 0) + luaunit.assertTrue(anim3.elapsed > 0) +end + +function TestAnimationGroupNested:test_nested_group_completes() + local innerGroup = AnimationGroup.new({ + mode = "parallel", + animations = { makeAnim(0.2), makeAnim(0.2) }, + }) + local outerGroup = AnimationGroup.new({ + mode = "sequence", + animations = { innerGroup, makeAnim(0.2) }, + }) + + runToCompletion(outerGroup) + luaunit.assertEquals(outerGroup:getState(), "completed") + luaunit.assertEquals(innerGroup:getState(), "completed") +end + +function TestAnimationGroupNested:test_deeply_nested_groups() + local leaf1 = makeAnim(0.1) + local leaf2 = makeAnim(0.1) + local inner = AnimationGroup.new({ + mode = "parallel", + animations = { leaf1, leaf2 }, + }) + local middle = AnimationGroup.new({ + mode = "sequence", + animations = { inner, makeAnim(0.1) }, + }) + local outer = AnimationGroup.new({ + mode = "parallel", + animations = { middle, makeAnim(0.2) }, + }) + + runToCompletion(outer) + luaunit.assertEquals(outer:getState(), "completed") + luaunit.assertEquals(middle:getState(), "completed") + luaunit.assertEquals(inner:getState(), "completed") +end + +-- Run all tests +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/transition_test.lua b/testing/__tests__/transition_test.lua new file mode 100644 index 0000000..f62577c --- /dev/null +++ b/testing/__tests__/transition_test.lua @@ -0,0 +1,442 @@ +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 + +-- Helper: create a retained-mode element +local function makeElement(props) + props = props or {} + props.width = props.width or 100 + props.height = props.height or 100 + return FlexLove.new(props) +end + +-- ============================================================================ +-- Test Suite: setTransition() +-- ============================================================================ + +TestSetTransition = {} + +function TestSetTransition:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestSetTransition:tearDown() + FlexLove.endFrame() +end + +function TestSetTransition:test_setTransition_creates_transitions_table() + local el = makeElement() + el:setTransition("opacity", { duration = 0.5 }) + + luaunit.assertNotNil(el.transitions) + luaunit.assertNotNil(el.transitions.opacity) +end + +function TestSetTransition:test_setTransition_stores_config() + local el = makeElement() + el:setTransition("opacity", { + duration = 0.5, + easing = "easeInQuad", + delay = 0.1, + }) + + luaunit.assertEquals(el.transitions.opacity.duration, 0.5) + luaunit.assertEquals(el.transitions.opacity.easing, "easeInQuad") + luaunit.assertEquals(el.transitions.opacity.delay, 0.1) +end + +function TestSetTransition:test_setTransition_uses_defaults() + local el = makeElement() + el:setTransition("opacity", {}) + + luaunit.assertEquals(el.transitions.opacity.duration, 0.3) + luaunit.assertEquals(el.transitions.opacity.easing, "easeOutQuad") + luaunit.assertEquals(el.transitions.opacity.delay, 0) +end + +function TestSetTransition:test_setTransition_invalid_duration_uses_default() + local el = makeElement() + el:setTransition("opacity", { duration = -1 }) + + luaunit.assertEquals(el.transitions.opacity.duration, 0.3) +end + +function TestSetTransition:test_setTransition_with_invalid_config_handles_gracefully() + local el = makeElement() + -- Should not throw + el:setTransition("opacity", "invalid") + luaunit.assertNotNil(el.transitions.opacity) +end + +function TestSetTransition:test_setTransition_for_all_properties() + local el = makeElement() + el:setTransition("all", { duration = 0.2, easing = "linear" }) + + luaunit.assertNotNil(el.transitions["all"]) + luaunit.assertEquals(el.transitions["all"].duration, 0.2) +end + +function TestSetTransition:test_setTransition_with_onComplete_callback() + local el = makeElement() + local cb = function() end + el:setTransition("opacity", { + duration = 0.3, + onComplete = cb, + }) + + luaunit.assertEquals(el.transitions.opacity.onComplete, cb) +end + +function TestSetTransition:test_setTransition_overwrites_previous() + local el = makeElement() + el:setTransition("opacity", { duration = 0.5 }) + el:setTransition("opacity", { duration = 1.0 }) + + luaunit.assertEquals(el.transitions.opacity.duration, 1.0) +end + +-- ============================================================================ +-- Test Suite: setTransitionGroup() +-- ============================================================================ + +TestSetTransitionGroup = {} + +function TestSetTransitionGroup:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestSetTransitionGroup:tearDown() + FlexLove.endFrame() +end + +function TestSetTransitionGroup:test_setTransitionGroup_applies_to_all_properties() + local el = makeElement() + el:setTransitionGroup("colors", { duration = 0.3 }, { + "backgroundColor", + "borderColor", + "textColor", + }) + + luaunit.assertNotNil(el.transitions.backgroundColor) + luaunit.assertNotNil(el.transitions.borderColor) + luaunit.assertNotNil(el.transitions.textColor) + luaunit.assertEquals(el.transitions.backgroundColor.duration, 0.3) +end + +function TestSetTransitionGroup:test_setTransitionGroup_with_invalid_properties() + local el = makeElement() + -- Should not throw + el:setTransitionGroup("invalid", { duration = 0.3 }, "not a table") + -- No transitions should be set + luaunit.assertNil(el.transitions) +end + +function TestSetTransitionGroup:test_setTransitionGroup_shared_config() + local el = makeElement() + el:setTransitionGroup("position", { duration = 0.5, easing = "easeInOutCubic" }, { + "x", + "y", + }) + + luaunit.assertEquals(el.transitions.x.duration, 0.5) + luaunit.assertEquals(el.transitions.y.duration, 0.5) + luaunit.assertEquals(el.transitions.x.easing, "easeInOutCubic") +end + +-- ============================================================================ +-- Test Suite: removeTransition() +-- ============================================================================ + +TestRemoveTransition = {} + +function TestRemoveTransition:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestRemoveTransition:tearDown() + FlexLove.endFrame() +end + +function TestRemoveTransition:test_removeTransition_removes_single() + local el = makeElement() + el:setTransition("opacity", { duration = 0.3 }) + el:setTransition("x", { duration = 0.5 }) + + el:removeTransition("opacity") + + luaunit.assertNil(el.transitions.opacity) + luaunit.assertNotNil(el.transitions.x) +end + +function TestRemoveTransition:test_removeTransition_all_clears_all() + local el = makeElement() + el:setTransition("opacity", { duration = 0.3 }) + el:setTransition("x", { duration = 0.5 }) + + el:removeTransition("all") + + luaunit.assertEquals(next(el.transitions), nil) -- empty table +end + +function TestRemoveTransition:test_removeTransition_no_transitions_does_not_error() + local el = makeElement() + -- Should not throw even with no transitions set + el:removeTransition("opacity") +end + +function TestRemoveTransition:test_removeTransition_nonexistent_property() + local el = makeElement() + el:setTransition("opacity", { duration = 0.3 }) + + -- Should not throw + el:removeTransition("nonexistent") + luaunit.assertNotNil(el.transitions.opacity) +end + +-- ============================================================================ +-- Test Suite: setProperty() with Transitions +-- ============================================================================ + +TestSetPropertyTransitions = {} + +function TestSetPropertyTransitions:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestSetPropertyTransitions:tearDown() + FlexLove.endFrame() +end + +function TestSetPropertyTransitions:test_setProperty_without_transition_sets_immediately() + local el = makeElement() + el.opacity = 1 + + el:setProperty("opacity", 0.5) + + luaunit.assertEquals(el.opacity, 0.5) + luaunit.assertNil(el.animation) +end + +function TestSetPropertyTransitions:test_setProperty_with_transition_creates_animation() + local el = makeElement() + el.opacity = 1 + el:setTransition("opacity", { duration = 0.5 }) + + el:setProperty("opacity", 0) + + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.duration, 0.5) + luaunit.assertEquals(el.animation.start.opacity, 1) + luaunit.assertEquals(el.animation.final.opacity, 0) +end + +function TestSetPropertyTransitions:test_setProperty_same_value_does_not_animate() + local el = makeElement() + el.opacity = 1 + el:setTransition("opacity", { duration = 0.5 }) + + el:setProperty("opacity", 1) + + luaunit.assertNil(el.animation) +end + +function TestSetPropertyTransitions:test_setProperty_with_all_transition() + local el = makeElement() + el.opacity = 1 + el:setTransition("all", { duration = 0.3 }) + + el:setProperty("opacity", 0) + + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.duration, 0.3) +end + +function TestSetPropertyTransitions:test_setProperty_specific_overrides_all() + local el = makeElement() + el.opacity = 1 + el:setTransition("all", { duration = 0.3 }) + el:setTransition("opacity", { duration = 0.8 }) + + el:setProperty("opacity", 0) + + -- Should use the specific "opacity" transition, not "all" + luaunit.assertNotNil(el.animation) + luaunit.assertEquals(el.animation.duration, 0.8) +end + +function TestSetPropertyTransitions:test_setProperty_transition_with_delay() + local el = makeElement() + el.opacity = 1 + el:setTransition("opacity", { duration = 0.3, delay = 0.2 }) + + el:setProperty("opacity", 0) + + -- Animation should have the delay set + -- The delay is part of the transition config, which is used to create the animation + -- Note: delay may not be passed to Animation.new automatically by current implementation + luaunit.assertNotNil(el.animation) +end + +function TestSetPropertyTransitions:test_setProperty_transition_onComplete_callback() + local el = makeElement() + el.opacity = 1 + local callbackCalled = false + el:setTransition("opacity", { + duration = 0.3, + onComplete = function() callbackCalled = true end, + }) + + el:setProperty("opacity", 0) + + luaunit.assertNotNil(el.animation) + luaunit.assertNotNil(el.animation.onComplete) +end + +function TestSetPropertyTransitions:test_setProperty_nil_current_value_sets_directly() + local el = makeElement() + el:setTransition("customProp", { duration = 0.3 }) + + -- customProp is nil, should set directly + el:setProperty("customProp", 42) + + luaunit.assertEquals(el.customProp, 42) + luaunit.assertNil(el.animation) +end + +-- ============================================================================ +-- Test Suite: Per-Property Transition Configuration +-- ============================================================================ + +TestPerPropertyTransitionConfig = {} + +function TestPerPropertyTransitionConfig:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestPerPropertyTransitionConfig:tearDown() + FlexLove.endFrame() +end + +function TestPerPropertyTransitionConfig:test_different_durations_per_property() + local el = makeElement() + el:setTransition("opacity", { duration = 0.3 }) + el:setTransition("x", { duration = 1.0 }) + + luaunit.assertEquals(el.transitions.opacity.duration, 0.3) + luaunit.assertEquals(el.transitions.x.duration, 1.0) +end + +function TestPerPropertyTransitionConfig:test_different_easing_per_property() + local el = makeElement() + el:setTransition("opacity", { easing = "easeInQuad" }) + el:setTransition("x", { easing = "easeOutCubic" }) + + luaunit.assertEquals(el.transitions.opacity.easing, "easeInQuad") + luaunit.assertEquals(el.transitions.x.easing, "easeOutCubic") +end + +function TestPerPropertyTransitionConfig:test_transition_disabled_after_removal() + local el = makeElement() + el.opacity = 1 + el:setTransition("opacity", { duration = 0.3 }) + + -- Verify transition is active + el:setProperty("opacity", 0.5) + luaunit.assertNotNil(el.animation) + + -- Remove transition and reset + el.animation = nil + el.opacity = 1 + el:removeTransition("opacity") + + -- Should set immediately now + el:setProperty("opacity", 0.5) + luaunit.assertEquals(el.opacity, 0.5) + luaunit.assertNil(el.animation) +end + +function TestPerPropertyTransitionConfig:test_multiple_properties_configured() + local el = makeElement() + el:setTransition("opacity", { duration = 0.3 }) + el:setTransition("x", { duration = 0.5 }) + el:setTransition("width", { duration = 1.0 }) + + luaunit.assertEquals(el.transitions.opacity.duration, 0.3) + luaunit.assertEquals(el.transitions.x.duration, 0.5) + luaunit.assertEquals(el.transitions.width.duration, 1.0) +end + +-- ============================================================================ +-- Test Suite: Transition Integration +-- ============================================================================ + +TestTransitionIntegration = {} + +function TestTransitionIntegration:setUp() + love.window.setMode(1920, 1080) + FlexLove.beginFrame() +end + +function TestTransitionIntegration:tearDown() + FlexLove.endFrame() +end + +function TestTransitionIntegration:test_transition_animation_runs_to_completion() + local el = makeElement() + el.opacity = 1 + el:setTransition("opacity", { duration = 0.2 }) + el:setProperty("opacity", 0) + + luaunit.assertNotNil(el.animation) + + -- Run animation to completion + for i = 1, 30 do + el:update(1 / 60) + if not el.animation then + break + end + end + + luaunit.assertNil(el.animation) +end + +function TestTransitionIntegration:test_manual_animation_overrides_transition() + local el = makeElement() + el.opacity = 1 + el:setTransition("opacity", { duration = 0.3 }) + + -- Apply manual animation + local manualAnim = Animation.new({ + duration = 1.0, + start = { opacity = 1 }, + final = { opacity = 0 }, + }) + manualAnim:apply(el) + + luaunit.assertEquals(el.animation.duration, 1.0) -- Manual anim +end + +-- Run all tests +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/runAll.lua b/testing/runAll.lua index 6809adc..d71a346 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -37,6 +37,8 @@ local luaunit = require("testing.luaunit") local testFiles = { "testing/__tests__/absolute_positioning_test.lua", + "testing/__tests__/animation_chaining_test.lua", + "testing/__tests__/animation_group_test.lua", "testing/__tests__/animation_test.lua", "testing/__tests__/blur_test.lua", "testing/__tests__/calc_test.lua", @@ -67,6 +69,7 @@ local testFiles = { "testing/__tests__/text_editor_test.lua", "testing/__tests__/theme_test.lua", "testing/__tests__/touch_events_test.lua", + "testing/__tests__/transition_test.lua", "testing/__tests__/units_test.lua", "testing/__tests__/utils_test.lua", }