From 58efffd2fd8cbd97ae077b3bdd66d8f078835309 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 28 Dec 2025 13:21:50 -0500 Subject: [PATCH] feat: Animation property locking prevents conflicting animations (closes #120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AnimationConflictMode enum with three modes: - REPLACE (default): Complete existing animation and start new one - QUEUE: Wait for existing animation to complete before starting - ERROR: Raise RuntimeError if property is already being animated Changes: - AnimationManager now tracks property locks per (target, property) pair - Animation.start() accepts optional conflict_mode parameter - Queued animations start automatically when property becomes free - Updated type stubs with ConflictMode type alias 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/Animation.cpp | 150 ++++++++++- src/Animation.h | 71 ++++- src/PyAnimation.cpp | 70 +++-- src/PyAnimation.h | 2 +- stubs/mcrfpy.pyi | 18 +- tests/unit/test_animation_property_locking.py | 249 ++++++++++++++++++ 6 files changed, 522 insertions(+), 38 deletions(-) create mode 100644 tests/unit/test_animation_property_locking.py diff --git a/src/Animation.cpp b/src/Animation.cpp index 03f5b6f..93dc394 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -631,21 +631,122 @@ AnimationManager& AnimationManager::getInstance() { return instance; } -void AnimationManager::addAnimation(std::shared_ptr animation) { - if (animation && animation->hasValidTarget()) { - if (isUpdating) { - // Defer adding during update to avoid iterator invalidation - pendingAnimations.push_back(animation); +void* AnimationManager::getAnimationTarget(const std::shared_ptr& anim) const { + return anim ? anim->getTargetPtr() : nullptr; +} + +bool AnimationManager::isPropertyAnimating(void* target, const std::string& property) const { + if (!target) return false; + PropertyKey key{target, property}; + auto it = propertyLocks.find(key); + if (it == propertyLocks.end()) return false; + // Check if the animation is still valid + return !it->second.expired(); +} + +void AnimationManager::cleanupPropertyLocks() { + // Remove expired locks + for (auto it = propertyLocks.begin(); it != propertyLocks.end(); ) { + if (it->second.expired()) { + it = propertyLocks.erase(it); } else { - activeAnimations.push_back(animation); + ++it; } } } +void AnimationManager::processQueue() { + // Try to start queued animations whose properties are now free + for (auto it = animationQueue.begin(); it != animationQueue.end(); ) { + const auto& key = it->first; + auto& anim = it->second; + + // Check if property is now free + auto lockIt = propertyLocks.find(key); + bool propertyFree = (lockIt == propertyLocks.end()) || lockIt->second.expired(); + + if (propertyFree && anim && anim->hasValidTarget()) { + // Property is free, start the animation + propertyLocks[key] = anim; + activeAnimations.push_back(anim); + it = animationQueue.erase(it); + } else if (!anim || !anim->hasValidTarget()) { + // Animation target was destroyed, remove from queue + it = animationQueue.erase(it); + } else { + ++it; + } + } +} + +void AnimationManager::addAnimation(std::shared_ptr animation, + AnimationConflictMode conflict_mode) { + if (!animation || !animation->hasValidTarget()) { + return; + } + + void* target = getAnimationTarget(animation); + std::string property = animation->getTargetProperty(); + PropertyKey key{target, property}; + + // Check for existing animation on this property (#120) + auto existingIt = propertyLocks.find(key); + bool hasExisting = (existingIt != propertyLocks.end()) && !existingIt->second.expired(); + + if (hasExisting) { + auto existingAnim = existingIt->second.lock(); + + switch (conflict_mode) { + case AnimationConflictMode::REPLACE: + // Complete the existing animation and replace it + if (existingAnim) { + existingAnim->complete(); + // Remove from active animations + activeAnimations.erase( + std::remove(activeAnimations.begin(), activeAnimations.end(), existingAnim), + activeAnimations.end() + ); + } + // Fall through to add the new animation + break; + + case AnimationConflictMode::QUEUE: + // Add to queue - will start when existing completes + if (isUpdating) { + // Also defer queue additions during update + pendingAnimations.push_back(animation); + } else { + animationQueue.emplace_back(key, animation); + } + return; // Don't add to active animations yet + + case AnimationConflictMode::ERROR: + // Raise Python exception + PyGILState_STATE gstate = PyGILState_Ensure(); + PyErr_Format(PyExc_RuntimeError, + "Animation conflict: property '%s' is already being animated on this target. " + "Use conflict_mode='replace' to override or 'queue' to wait.", + property.c_str()); + PyGILState_Release(gstate); + return; + } + } + + // Register property lock and add animation + propertyLocks[key] = animation; + + if (isUpdating) { + // Defer adding during update to avoid iterator invalidation + pendingAnimations.push_back(animation); + } else { + activeAnimations.push_back(animation); + } +} + void AnimationManager::update(float deltaTime) { // Set flag to defer new animations isUpdating = true; - + // Remove completed or invalid animations activeAnimations.erase( std::remove_if(activeAnimations.begin(), activeAnimations.end(), @@ -654,15 +755,37 @@ void AnimationManager::update(float deltaTime) { }), activeAnimations.end() ); - + // Clear update flag isUpdating = false; - + + // Clean up expired property locks (#120) + cleanupPropertyLocks(); + + // Process queued animations - start any that are now unblocked (#120) + processQueue(); + // Add any animations that were created during update if (!pendingAnimations.empty()) { - activeAnimations.insert(activeAnimations.end(), - pendingAnimations.begin(), - pendingAnimations.end()); + // Re-add pending animations through addAnimation to handle conflicts properly + for (auto& anim : pendingAnimations) { + if (anim && anim->hasValidTarget()) { + // Check if this was a queued animation or a new one + void* target = getAnimationTarget(anim); + std::string property = anim->getTargetProperty(); + PropertyKey key{target, property}; + + // If not already locked, add it + auto lockIt = propertyLocks.find(key); + if (lockIt == propertyLocks.end() || lockIt->second.expired()) { + propertyLocks[key] = anim; + activeAnimations.push_back(anim); + } else { + // Property still locked, re-queue + animationQueue.emplace_back(key, anim); + } + } + } pendingAnimations.clear(); } } @@ -678,4 +801,7 @@ void AnimationManager::clear(bool completeAnimations) { } } activeAnimations.clear(); + pendingAnimations.clear(); + animationQueue.clear(); + propertyLocks.clear(); } \ No newline at end of file diff --git a/src/Animation.h b/src/Animation.h index d64f0ab..4d546ab 100644 --- a/src/Animation.h +++ b/src/Animation.h @@ -12,6 +12,15 @@ class UIDrawable; class UIEntity; +/** + * ConflictMode - How to handle multiple animations on the same property (#120) + */ +enum class AnimationConflictMode { + REPLACE, // Stop/complete existing animation, start new one (default) + QUEUE, // Queue new animation to run after existing one completes + ERROR // Raise an error if property is already being animated +}; + // Forward declare namespace namespace EasingFunctions { float linear(float t); @@ -71,6 +80,13 @@ public: float getElapsed() const { return elapsed; } bool isComplete() const { return elapsed >= duration; } bool isDelta() const { return delta; } + + // Get raw target pointer for property locking (#120) + void* getTargetPtr() const { + if (auto sp = targetWeak.lock()) return sp.get(); + if (auto sp = entityTargetWeak.lock()) return sp.get(); + return nullptr; + } private: std::string targetProperty; // Property name to animate (e.g., "x", "color.r", "sprite_number") @@ -157,19 +173,64 @@ namespace EasingFunctions { class AnimationManager { public: static AnimationManager& getInstance(); - + // Add an animation to be managed - void addAnimation(std::shared_ptr animation); - + // conflict_mode determines behavior when property is already animated (#120) + void addAnimation(std::shared_ptr animation, + AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE); + // Update all animations void update(float deltaTime); - + // Clear all animations (optionally completing them first) void clear(bool completeAnimations = false); - + + // Get/set default conflict mode + AnimationConflictMode getDefaultConflictMode() const { return defaultConflictMode; } + void setDefaultConflictMode(AnimationConflictMode mode) { defaultConflictMode = mode; } + + // Check if a property is currently being animated on a target + bool isPropertyAnimating(void* target, const std::string& property) const; + + // Get active animation count (for debugging/testing) + size_t getActiveAnimationCount() const { return activeAnimations.size(); } + private: AnimationManager() = default; std::vector> activeAnimations; std::vector> pendingAnimations; // Animations to add after update bool isUpdating = false; // Flag to track if we're in update loop + AnimationConflictMode defaultConflictMode = AnimationConflictMode::REPLACE; + + // Property lock tracking for conflict detection (#120) + // Key: (target_ptr, property_name) -> weak reference to active animation + struct PropertyKey { + void* target; + std::string property; + + bool operator==(const PropertyKey& other) const { + return target == other.target && property == other.property; + } + }; + + struct PropertyKeyHash { + size_t operator()(const PropertyKey& key) const { + return std::hash()(key.target) ^ + (std::hash()(key.property) << 1); + } + }; + + std::unordered_map, PropertyKeyHash> propertyLocks; + + // Queued animations waiting for property to become available + std::vector>> animationQueue; + + // Helper to get target pointer from animation + void* getAnimationTarget(const std::shared_ptr& anim) const; + + // Clean up expired property locks + void cleanupPropertyLocks(); + + // Process queued animations + void processQueue(); }; \ No newline at end of file diff --git a/src/PyAnimation.cpp b/src/PyAnimation.cpp index 265646d..952aefc 100644 --- a/src/PyAnimation.cpp +++ b/src/PyAnimation.cpp @@ -8,7 +8,7 @@ #include "UIGrid.h" #include "UIEntity.h" #include "UI.h" // For the PyTypeObject definitions -#include +#include // For strcmp in parseConflictMode PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds) { PyAnimationObject* self = (PyAnimationObject*)type->tp_alloc(type, 0); @@ -133,27 +133,53 @@ PyObject* PyAnimation::get_is_delta(PyAnimationObject* self, void* closure) { return PyBool_FromLong(self->data->isDelta()); } -PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) { +// Helper to convert Python string to AnimationConflictMode +static bool parseConflictMode(const char* mode_str, AnimationConflictMode& mode) { + if (!mode_str || strcmp(mode_str, "replace") == 0) { + mode = AnimationConflictMode::REPLACE; + } else if (strcmp(mode_str, "queue") == 0) { + mode = AnimationConflictMode::QUEUE; + } else if (strcmp(mode_str, "error") == 0) { + mode = AnimationConflictMode::ERROR; + } else { + PyErr_Format(PyExc_ValueError, + "Invalid conflict_mode '%s'. Must be 'replace', 'queue', or 'error'.", mode_str); + return false; + } + return true; +} + +PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"target", "conflict_mode", nullptr}; PyObject* target_obj; - if (!PyArg_ParseTuple(args, "O", &target_obj)) { + const char* conflict_mode_str = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|s", const_cast(kwlist), + &target_obj, &conflict_mode_str)) { return NULL; } - + + // Parse conflict mode + AnimationConflictMode conflict_mode; + if (!parseConflictMode(conflict_mode_str, conflict_mode)) { + return NULL; // Error already set + } + // Get type objects from the module to ensure they're initialized PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"); PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"); PyObject* sprite_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"); PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"); PyObject* entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - + bool handled = false; - + // Use PyObject_IsInstance to support inheritance if (frame_type && PyObject_IsInstance(target_obj, frame_type)) { PyUIFrameObject* frame = (PyUIFrameObject*)target_obj; if (frame->data) { self->data->start(frame->data); - AnimationManager::getInstance().addAnimation(self->data); + AnimationManager::getInstance().addAnimation(self->data, conflict_mode); handled = true; } } @@ -161,7 +187,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) { PyUICaptionObject* caption = (PyUICaptionObject*)target_obj; if (caption->data) { self->data->start(caption->data); - AnimationManager::getInstance().addAnimation(self->data); + AnimationManager::getInstance().addAnimation(self->data, conflict_mode); handled = true; } } @@ -169,7 +195,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) { PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj; if (sprite->data) { self->data->start(sprite->data); - AnimationManager::getInstance().addAnimation(self->data); + AnimationManager::getInstance().addAnimation(self->data, conflict_mode); handled = true; } } @@ -177,7 +203,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) { PyUIGridObject* grid = (PyUIGridObject*)target_obj; if (grid->data) { self->data->start(grid->data); - AnimationManager::getInstance().addAnimation(self->data); + AnimationManager::getInstance().addAnimation(self->data, conflict_mode); handled = true; } } @@ -186,23 +212,28 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) { PyUIEntityObject* entity = (PyUIEntityObject*)target_obj; if (entity->data) { self->data->startEntity(entity->data); - AnimationManager::getInstance().addAnimation(self->data); + AnimationManager::getInstance().addAnimation(self->data, conflict_mode); handled = true; } } - + // Clean up references Py_XDECREF(frame_type); Py_XDECREF(caption_type); Py_XDECREF(sprite_type); Py_XDECREF(grid_type); Py_XDECREF(entity_type); - + if (!handled) { PyErr_SetString(PyExc_TypeError, "Target must be a Frame, Caption, Sprite, Grid, or Entity (or a subclass of these)"); return NULL; } - + + // Check if an error was set (e.g., from ERROR conflict mode) + if (PyErr_Occurred()) { + return NULL; + } + Py_RETURN_NONE; } @@ -276,14 +307,19 @@ PyGetSetDef PyAnimation::getsetters[] = { }; PyMethodDef PyAnimation::methods[] = { - {"start", (PyCFunction)start, METH_VARARGS, + {"start", (PyCFunction)start, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Animation, start, - MCRF_SIG("(target: UIDrawable)", "None"), + MCRF_SIG("(target: UIDrawable, conflict_mode: str = 'replace')", "None"), MCRF_DESC("Start the animation on a target UI element."), MCRF_ARGS_START MCRF_ARG("target", "The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)") + MCRF_ARG("conflict_mode", "How to handle conflicts if property is already animating: " + "'replace' (default) - complete existing animation and start new one; " + "'queue' - wait for existing animation to complete; " + "'error' - raise RuntimeError if property is busy") MCRF_RETURNS("None") - MCRF_NOTE("The animation will automatically stop if the target is destroyed. Call AnimationManager.update(delta_time) each frame to progress animations.") + MCRF_RAISES("RuntimeError", "When conflict_mode='error' and property is already animating") + MCRF_NOTE("The animation will automatically stop if the target is destroyed.") )}, {"update", (PyCFunction)update, METH_VARARGS, MCRF_METHOD(Animation, update, diff --git a/src/PyAnimation.h b/src/PyAnimation.h index ccb4f36..964844e 100644 --- a/src/PyAnimation.h +++ b/src/PyAnimation.h @@ -25,7 +25,7 @@ public: static PyObject* get_is_delta(PyAnimationObject* self, void* closure); // Methods - static PyObject* start(PyAnimationObject* self, PyObject* args); + static PyObject* start(PyAnimationObject* self, PyObject* args, PyObject* kwds); static PyObject* update(PyAnimationObject* self, PyObject* args); static PyObject* get_current_value(PyAnimationObject* self, PyObject* args); static PyObject* complete(PyAnimationObject* self, PyObject* args); diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index b6654fa..be3f6a2 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -3,11 +3,12 @@ Core game engine interface for creating roguelike games with Python. """ -from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload +from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload, Literal # Type aliases UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid'] Transition = Union[str, None] +ConflictMode = Literal['replace', 'queue', 'error'] # Classes @@ -461,8 +462,19 @@ class Animation: duration: float, easing: str = 'linear', loop: bool = False, on_complete: Optional[Callable] = None) -> None: ... - def start(self) -> None: - """Start the animation.""" + def start(self, target: Any, conflict_mode: ConflictMode = 'replace') -> None: + """Start the animation on a target UI element. + + Args: + target: The UI element to animate (Frame, Caption, Sprite, Grid, or Entity) + conflict_mode: How to handle conflicts if property is already animating: + - 'replace' (default): Complete existing animation and start new one + - 'queue': Wait for existing animation to complete + - 'error': Raise RuntimeError if property is busy + + Raises: + RuntimeError: When conflict_mode='error' and property is already animating + """ ... def update(self, dt: float) -> bool: diff --git a/tests/unit/test_animation_property_locking.py b/tests/unit/test_animation_property_locking.py new file mode 100644 index 0000000..3b25892 --- /dev/null +++ b/tests/unit/test_animation_property_locking.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +""" +Test Animation Property Locking (#120) +Verifies that multiple animations on the same property are handled correctly. +""" + +import mcrfpy +import sys + +print("Animation Property Locking Test Suite (#120)") +print("=" * 50) + +# Test state +tests_passed = 0 +tests_failed = 0 +test_results = [] + +def test_result(name, passed, details=""): + global tests_passed, tests_failed + if passed: + tests_passed += 1 + result = f"PASS: {name}" + else: + tests_failed += 1 + result = f"FAIL: {name}: {details}" + print(result) + test_results.append((name, passed, details)) + + +def test_1_replace_mode_default(): + """Test that REPLACE mode is the default and works correctly""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + # Start first animation + anim1 = mcrfpy.Animation("x", 500.0, 2.0, "linear") + anim1.start(frame) # Default is replace mode + + # Immediately start second animation on same property + anim2 = mcrfpy.Animation("x", 200.0, 1.0, "linear") + anim2.start(frame) # Should replace anim1 + + # anim1 should have been completed (jumped to final value) + # and anim2 should now be active + # The frame should be at x=500 (anim1's final value) then animating to 200 + + # If we got here without error, replace worked + test_result("Replace mode (default)", True) + except Exception as e: + test_result("Replace mode (default)", False, str(e)) + + +def test_2_replace_mode_explicit(): + """Test explicit REPLACE mode""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + anim1 = mcrfpy.Animation("x", 500.0, 2.0, "linear") + anim1.start(frame, conflict_mode="replace") + + anim2 = mcrfpy.Animation("x", 200.0, 1.0, "linear") + anim2.start(frame, conflict_mode="replace") + + test_result("Replace mode (explicit)", True) + except Exception as e: + test_result("Replace mode (explicit)", False, str(e)) + + +def test_3_queue_mode(): + """Test QUEUE mode - animation should be queued""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + # Start first animation (short duration for test) + anim1 = mcrfpy.Animation("y", 300.0, 0.5, "linear") + anim1.start(frame) + + # Queue second animation + anim2 = mcrfpy.Animation("y", 100.0, 0.5, "linear") + anim2.start(frame, conflict_mode="queue") + + # Both should be accepted without error + # anim2 will start after anim1 completes + test_result("Queue mode", True) + except Exception as e: + test_result("Queue mode", False, str(e)) + + +def test_4_error_mode(): + """Test ERROR mode - should raise RuntimeError""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + anim1 = mcrfpy.Animation("w", 200.0, 2.0, "linear") + anim1.start(frame) + + # Try to start second animation with error mode + anim2 = mcrfpy.Animation("w", 300.0, 1.0, "linear") + try: + anim2.start(frame, conflict_mode="error") + test_result("Error mode", False, "Expected RuntimeError but none was raised") + except RuntimeError as e: + # This is expected! + if "conflict" in str(e).lower() or "already" in str(e).lower(): + test_result("Error mode", True) + else: + test_result("Error mode", False, f"Wrong error message: {e}") + except Exception as e: + test_result("Error mode", False, str(e)) + + +def test_5_invalid_conflict_mode(): + """Test that invalid conflict_mode raises ValueError""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + anim = mcrfpy.Animation("h", 200.0, 1.0, "linear") + try: + anim.start(frame, conflict_mode="invalid_mode") + test_result("Invalid conflict_mode", False, "Expected ValueError but none raised") + except ValueError as e: + if "invalid" in str(e).lower(): + test_result("Invalid conflict_mode", True) + else: + test_result("Invalid conflict_mode", False, f"Wrong error: {e}") + except Exception as e: + test_result("Invalid conflict_mode", False, str(e)) + + +def test_6_different_properties_no_conflict(): + """Test that different properties can animate simultaneously""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + # Animate different properties - should not conflict + anim_x = mcrfpy.Animation("x", 500.0, 1.0, "linear") + anim_y = mcrfpy.Animation("y", 500.0, 1.0, "linear") + anim_w = mcrfpy.Animation("w", 200.0, 1.0, "linear") + + anim_x.start(frame, conflict_mode="error") + anim_y.start(frame, conflict_mode="error") + anim_w.start(frame, conflict_mode="error") + + # All should succeed without error since they're different properties + test_result("Different properties no conflict", True) + except RuntimeError as e: + test_result("Different properties no conflict", False, f"Unexpected conflict: {e}") + except Exception as e: + test_result("Different properties no conflict", False, str(e)) + + +def test_7_different_targets_no_conflict(): + """Test that same property on different targets doesn't conflict""" + try: + ui = mcrfpy.sceneUI("test") + frame1 = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + frame2 = mcrfpy.Frame(pos=(200, 200), size=(100, 100)) + ui.append(frame1) + ui.append(frame2) + + # Same property, different targets - should not conflict + anim1 = mcrfpy.Animation("x", 500.0, 1.0, "linear") + anim2 = mcrfpy.Animation("x", 600.0, 1.0, "linear") + + anim1.start(frame1, conflict_mode="error") + anim2.start(frame2, conflict_mode="error") + + test_result("Different targets no conflict", True) + except RuntimeError as e: + test_result("Different targets no conflict", False, f"Unexpected conflict: {e}") + except Exception as e: + test_result("Different targets no conflict", False, str(e)) + + +def test_8_replace_completes_old(): + """Test that REPLACE mode completes the old animation's value""" + try: + ui = mcrfpy.sceneUI("test") + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + ui.append(frame) + + # Start animation to move x to 500 + anim1 = mcrfpy.Animation("x", 500.0, 10.0, "linear") # Long duration + anim1.start(frame) + + # Immediately replace - should complete anim1 (jump to 500) + anim2 = mcrfpy.Animation("x", 200.0, 1.0, "linear") + anim2.start(frame, conflict_mode="replace") + + # Frame should now be at x=500 (anim1's final) and animating to 200 + # Due to immediate completion, x should equal 500 right now + if frame.x == 500.0: + test_result("Replace completes old animation", True) + else: + test_result("Replace completes old animation", False, + f"Expected x=500, got x={frame.x}") + except Exception as e: + test_result("Replace completes old animation", False, str(e)) + + +def run_all_tests(runtime): + """Run all property locking tests""" + print("\nRunning Animation Property Locking Tests...") + print("-" * 50) + + test_1_replace_mode_default() + test_2_replace_mode_explicit() + test_3_queue_mode() + test_4_error_mode() + test_5_invalid_conflict_mode() + test_6_different_properties_no_conflict() + test_7_different_targets_no_conflict() + test_8_replace_completes_old() + + # Print results + print("\n" + "=" * 50) + print(f"Tests passed: {tests_passed}") + print(f"Tests failed: {tests_failed}") + + if tests_failed == 0: + print("\nAll tests passed!") + else: + print(f"\n{tests_failed} tests failed:") + for name, passed, details in test_results: + if not passed: + print(f" - {name}: {details}") + + # Exit with appropriate code + sys.exit(0 if tests_failed == 0 else 1) + + +# Setup and run +mcrfpy.createScene("test") +mcrfpy.setScene("test") + +# Start tests after a brief delay to allow scene to initialize +mcrfpy.setTimer("start", run_all_tests, 100)