feat: Animation property locking prevents conflicting animations (closes #120)

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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-12-28 13:21:50 -05:00
commit 58efffd2fd
6 changed files with 522 additions and 38 deletions

View file

@ -631,8 +631,110 @@ AnimationManager& AnimationManager::getInstance() {
return instance; return instance;
} }
void AnimationManager::addAnimation(std::shared_ptr<Animation> animation) { void* AnimationManager::getAnimationTarget(const std::shared_ptr<Animation>& anim) const {
if (animation && animation->hasValidTarget()) { 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 {
++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> 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) { if (isUpdating) {
// Defer adding during update to avoid iterator invalidation // Defer adding during update to avoid iterator invalidation
pendingAnimations.push_back(animation); pendingAnimations.push_back(animation);
@ -640,7 +742,6 @@ void AnimationManager::addAnimation(std::shared_ptr<Animation> animation) {
activeAnimations.push_back(animation); activeAnimations.push_back(animation);
} }
} }
}
void AnimationManager::update(float deltaTime) { void AnimationManager::update(float deltaTime) {
// Set flag to defer new animations // Set flag to defer new animations
@ -658,11 +759,33 @@ void AnimationManager::update(float deltaTime) {
// Clear update flag // Clear update flag
isUpdating = false; 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 // Add any animations that were created during update
if (!pendingAnimations.empty()) { if (!pendingAnimations.empty()) {
activeAnimations.insert(activeAnimations.end(), // Re-add pending animations through addAnimation to handle conflicts properly
pendingAnimations.begin(), for (auto& anim : pendingAnimations) {
pendingAnimations.end()); 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(); pendingAnimations.clear();
} }
} }
@ -678,4 +801,7 @@ void AnimationManager::clear(bool completeAnimations) {
} }
} }
activeAnimations.clear(); activeAnimations.clear();
pendingAnimations.clear();
animationQueue.clear();
propertyLocks.clear();
} }

View file

@ -12,6 +12,15 @@
class UIDrawable; class UIDrawable;
class UIEntity; 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 // Forward declare namespace
namespace EasingFunctions { namespace EasingFunctions {
float linear(float t); float linear(float t);
@ -72,6 +81,13 @@ public:
bool isComplete() const { return elapsed >= duration; } bool isComplete() const { return elapsed >= duration; }
bool isDelta() const { return delta; } 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: private:
std::string targetProperty; // Property name to animate (e.g., "x", "color.r", "sprite_number") std::string targetProperty; // Property name to animate (e.g., "x", "color.r", "sprite_number")
AnimationValue startValue; // Starting value (captured when animation starts) AnimationValue startValue; // Starting value (captured when animation starts)
@ -159,7 +175,9 @@ public:
static AnimationManager& getInstance(); static AnimationManager& getInstance();
// Add an animation to be managed // Add an animation to be managed
void addAnimation(std::shared_ptr<Animation> animation); // conflict_mode determines behavior when property is already animated (#120)
void addAnimation(std::shared_ptr<Animation> animation,
AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE);
// Update all animations // Update all animations
void update(float deltaTime); void update(float deltaTime);
@ -167,9 +185,52 @@ public:
// Clear all animations (optionally completing them first) // Clear all animations (optionally completing them first)
void clear(bool completeAnimations = false); 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: private:
AnimationManager() = default; AnimationManager() = default;
std::vector<std::shared_ptr<Animation>> activeAnimations; std::vector<std::shared_ptr<Animation>> activeAnimations;
std::vector<std::shared_ptr<Animation>> pendingAnimations; // Animations to add after update std::vector<std::shared_ptr<Animation>> pendingAnimations; // Animations to add after update
bool isUpdating = false; // Flag to track if we're in update loop 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<void*>()(key.target) ^
(std::hash<std::string>()(key.property) << 1);
}
};
std::unordered_map<PropertyKey, std::weak_ptr<Animation>, PropertyKeyHash> propertyLocks;
// Queued animations waiting for property to become available
std::vector<std::pair<PropertyKey, std::shared_ptr<Animation>>> animationQueue;
// Helper to get target pointer from animation
void* getAnimationTarget(const std::shared_ptr<Animation>& anim) const;
// Clean up expired property locks
void cleanupPropertyLocks();
// Process queued animations
void processQueue();
}; };

View file

@ -8,7 +8,7 @@
#include "UIGrid.h" #include "UIGrid.h"
#include "UIEntity.h" #include "UIEntity.h"
#include "UI.h" // For the PyTypeObject definitions #include "UI.h" // For the PyTypeObject definitions
#include <cstring> #include <cstring> // For strcmp in parseConflictMode
PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds) { PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyAnimationObject* self = (PyAnimationObject*)type->tp_alloc(type, 0); PyAnimationObject* self = (PyAnimationObject*)type->tp_alloc(type, 0);
@ -133,12 +133,38 @@ PyObject* PyAnimation::get_is_delta(PyAnimationObject* self, void* closure) {
return PyBool_FromLong(self->data->isDelta()); 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; 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<char**>(kwlist),
&target_obj, &conflict_mode_str)) {
return NULL; 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 // Get type objects from the module to ensure they're initialized
PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"); PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"); PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
@ -153,7 +179,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
PyUIFrameObject* frame = (PyUIFrameObject*)target_obj; PyUIFrameObject* frame = (PyUIFrameObject*)target_obj;
if (frame->data) { if (frame->data) {
self->data->start(frame->data); self->data->start(frame->data);
AnimationManager::getInstance().addAnimation(self->data); AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
handled = true; handled = true;
} }
} }
@ -161,7 +187,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
PyUICaptionObject* caption = (PyUICaptionObject*)target_obj; PyUICaptionObject* caption = (PyUICaptionObject*)target_obj;
if (caption->data) { if (caption->data) {
self->data->start(caption->data); self->data->start(caption->data);
AnimationManager::getInstance().addAnimation(self->data); AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
handled = true; handled = true;
} }
} }
@ -169,7 +195,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj; PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj;
if (sprite->data) { if (sprite->data) {
self->data->start(sprite->data); self->data->start(sprite->data);
AnimationManager::getInstance().addAnimation(self->data); AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
handled = true; handled = true;
} }
} }
@ -177,7 +203,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
PyUIGridObject* grid = (PyUIGridObject*)target_obj; PyUIGridObject* grid = (PyUIGridObject*)target_obj;
if (grid->data) { if (grid->data) {
self->data->start(grid->data); self->data->start(grid->data);
AnimationManager::getInstance().addAnimation(self->data); AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
handled = true; handled = true;
} }
} }
@ -186,7 +212,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj; PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;
if (entity->data) { if (entity->data) {
self->data->startEntity(entity->data); self->data->startEntity(entity->data);
AnimationManager::getInstance().addAnimation(self->data); AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
handled = true; handled = true;
} }
} }
@ -203,6 +229,11 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
return NULL; return NULL;
} }
// Check if an error was set (e.g., from ERROR conflict mode)
if (PyErr_Occurred()) {
return NULL;
}
Py_RETURN_NONE; Py_RETURN_NONE;
} }
@ -276,14 +307,19 @@ PyGetSetDef PyAnimation::getsetters[] = {
}; };
PyMethodDef PyAnimation::methods[] = { PyMethodDef PyAnimation::methods[] = {
{"start", (PyCFunction)start, METH_VARARGS, {"start", (PyCFunction)start, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Animation, start, 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_DESC("Start the animation on a target UI element."),
MCRF_ARGS_START MCRF_ARGS_START
MCRF_ARG("target", "The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)") 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_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, {"update", (PyCFunction)update, METH_VARARGS,
MCRF_METHOD(Animation, update, MCRF_METHOD(Animation, update,

View file

@ -25,7 +25,7 @@ public:
static PyObject* get_is_delta(PyAnimationObject* self, void* closure); static PyObject* get_is_delta(PyAnimationObject* self, void* closure);
// Methods // 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* update(PyAnimationObject* self, PyObject* args);
static PyObject* get_current_value(PyAnimationObject* self, PyObject* args); static PyObject* get_current_value(PyAnimationObject* self, PyObject* args);
static PyObject* complete(PyAnimationObject* self, PyObject* args); static PyObject* complete(PyAnimationObject* self, PyObject* args);

View file

@ -3,11 +3,12 @@
Core game engine interface for creating roguelike games with Python. 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 # Type aliases
UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid'] UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid']
Transition = Union[str, None] Transition = Union[str, None]
ConflictMode = Literal['replace', 'queue', 'error']
# Classes # Classes
@ -461,8 +462,19 @@ class Animation:
duration: float, easing: str = 'linear', loop: bool = False, duration: float, easing: str = 'linear', loop: bool = False,
on_complete: Optional[Callable] = None) -> None: ... on_complete: Optional[Callable] = None) -> None: ...
def start(self) -> None: def start(self, target: Any, conflict_mode: ConflictMode = 'replace') -> None:
"""Start the animation.""" """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: def update(self, dt: float) -> bool:

View file

@ -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)