Files
PythonInUnreal/tests/test_skeletal_sequence.py
T
2026-10-31 23:11:57 +01:00

260 lines
13 KiB
Python

"""Validate clip scheduling without an Unreal installation."""
import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
from ue_workshop.animation_clip import AnimationClip
class KnownClip(AnimationClip):
WALK = ("walk", 2.0)
class Clip:
def __init__(self, skeleton):
self.skeleton = skeleton
def get_editor_property(self, name):
assert name == "skeleton"
return self.skeleton
def get_path_name(self):
return "OriginalClip"
def get_play_length(self):
return 2.0
class AnimationTests(unittest.TestCase):
def setUp(self):
unreal = MagicMock()
unreal.AnimSequence = Clip
unreal.FrameNumber.side_effect = lambda frame: frame
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/sequence.py"
spec = importlib.util.spec_from_file_location("ue_workshop._sequence_test", path)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"unreal": unreal}):
spec.loader.exec_module(module)
self.unreal = unreal
self.shot = module.Sequence.__new__(module.Sequence)
self.shot.frames = 120
self.shot.fps = 24
self.shot.asset = MagicMock()
self.shot.asset.get_tick_resolution.return_value = SimpleNamespace(numerator=24000, denominator=1)
self.shot._animations = {}
self.shot._transforms = {}
self.shot._root_motion = {}
self.shot._root_motion_copies = {}
self.shot._pose_holds = {}
self.shot.bind = MagicMock()
self.actor = MagicMock()
self.actor.get_path_name.return_value = "Parent"
self.child = MagicMock()
self.child.get_path_name.return_value = "SkeletalChild"
self.skeleton = object()
self.clip = Clip(self.skeleton)
self.imported = SimpleNamespace(mesh=MagicMock(), animations={"walk": self.clip})
self.imported.mesh.get_editor_property.return_value = self.skeleton
self.shot.scene = SimpleNamespace(name="TestScene", _skeletal_models={"Parent": (self.child, self.imported)})
def test_adjacent_clips_share_child_track_and_set_speed(self):
self.shot.animation(self.actor, "walk", start=0, end=48)
self.shot.animation(self.actor, "walk", start=48, end=120, speed=0.8)
self.shot.bind.assert_called_once_with(self.child)
track = self.shot.bind.return_value.add_track.return_value
self.assertEqual(track.add_section.call_count, 2)
track.add_section.return_value.set_range.assert_called_with(48, 120)
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.8)
def test_enum_plays_once_when_end_is_omitted(self):
section = self.shot.animation(self.actor, KnownClip.WALK, start=10)
section.set_range.assert_called_with(10, 58)
section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", self.clip)
def test_enum_automatic_end_respects_speed(self):
section = self.shot.animation(self.actor, KnownClip.WALK, start=10, speed=2)
section.set_range.assert_called_with(10, 34)
def test_explicit_end_overrides_enum_duration(self):
section = self.shot.animation(self.actor, KnownClip.WALK, start=0, end=120)
section.set_range.assert_called_with(0, 120)
def test_enum_cannot_exceed_sequence_length(self):
with self.assertRaises(ValueError):
self.shot.animation(self.actor, KnownClip.WALK, start=100)
self.shot.bind.assert_not_called()
def test_nested_clip_is_rejected_without_creating_section(self):
self.shot.animation(self.actor, "walk", start=0, end=48)
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk", start=12, end=36)
self.assertEqual(self.shot.bind.return_value.add_track.return_value.add_section.call_count, 1)
def test_invalid_ranges_and_speeds_do_not_create_track(self):
for options in ({"start": -1}, {"end": 121}, {"start": 48, "end": 48},
{"start": True}, {"speed": 0}, {"speed": float("nan")},
{"speed": float("inf")}, {"keep_end_position": 1}, {"reverse": 1},
{"keep_end_pose": 1}):
with self.subTest(options=options), self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk", **options)
self.shot.bind.assert_not_called()
def test_reverse_is_set_on_native_animation_parameters(self):
section = self.shot.animation(self.actor, "walk", start=0, end=60, reverse=True)
section.get_editor_property.return_value.set_editor_property.assert_any_call("reverse", True)
def test_keep_end_pose_uses_native_keep_state(self):
main, hold = MagicMock(), MagicMock()
self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold]
section = self.shot.animation(self.actor, KnownClip.WALK, start=10, keep_end_pose=True)
section.set_completion_mode.assert_called_once_with(self.unreal.MovieSceneCompletionMode.KEEP_STATE)
section.set_range.assert_called_with(10, 58)
hold.set_range.assert_called_with(58, 120)
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.0)
def test_later_clip_truncates_frozen_pose(self):
main, hold, next_section = MagicMock(), MagicMock(), MagicMock()
self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold, next_section]
self.shot.animation(self.actor, KnownClip.WALK, start=0, keep_end_pose=True)
self.shot.animation(self.actor, KnownClip.WALK, start=60)
hold.set_range.assert_called_with(48, 60)
def test_relative_move_replaces_baked_keys_and_offsets_future_motion(self):
axes = [MagicMock() for _ in range(3)]
future_keys = []
for axis in axes:
interior, future = MagicMock(), MagicMock()
interior.get_time.return_value.frame_number.value = 90
future.get_time.return_value.frame_number.value = 150
future.get_value.return_value = 100
axis.get_keys.return_value = [interior, future]
future_keys.append(future)
self.shot._transforms["Parent"] = axes
self.shot.position = MagicMock(side_effect=[(0, 300, 0), (0, 350, 0)])
self.shot.transform_key = MagicMock()
self.assertEqual(self.shot.move(self.actor, 80, 110, offset=(0, -200, 0)), (0, 100, 0))
for axis in axes:
axis.remove_key.assert_called_once_with(axis.get_keys.return_value[0])
future_keys[1].set_value.assert_called_once_with(-150)
def test_keep_end_position_uses_copy_and_adds_transform_motion(self):
copy = Clip(self.skeleton)
prepare = MagicMock(return_value=(copy, ["poses"], 2.0))
self.shot._keep_end_position = MagicMock()
with patch.dict(sys.modules, {"ue_workshop.root_motion": SimpleNamespace(prepare=prepare)}):
section = self.shot.animation(self.actor, "walk", start=0, end=48, keep_end_position=True)
self.shot.animation(self.actor, "walk", start=60, end=108, keep_end_position=True)
prepare.assert_called_once()
self.assertIs(self.imported.animations["walk"], self.clip)
section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", copy)
self.assertEqual(self.shot._keep_end_position.call_count, 2)
def test_crossfade_sets_complementary_weight_curves(self):
track = self.shot.bind.return_value.add_track.return_value
first, second = MagicMock(), MagicMock()
track.add_section.side_effect = [first, second]
self.shot.animation(self.actor, "walk", start=0, end=60)
self.shot.animation(self.actor, "walk", start=48, end=120)
outgoing = first.get_all_channels.return_value[0].add_key
incoming = second.get_all_channels.return_value[0].add_key
outgoing.assert_any_call(48, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
outgoing.assert_any_call(60, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
incoming.assert_any_call(48, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
incoming.assert_any_call(60, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
self.shot.bind.assert_called_once_with(self.child)
def test_ambiguous_overlaps_are_rejected_before_creating_section(self):
self.shot.animation(self.actor, start=0, end=60)
for start, end in ((0, 60), (0, 120), (12, 36), (30, 60)):
with self.subTest(start=start, end=end), self.assertRaises(ValueError):
self.shot.animation(self.actor, start=start, end=end)
self.assertEqual(self.shot.bind.return_value.add_track.return_value.add_section.call_count, 1)
def test_three_clip_chain_preserves_middle_fade_in_and_adds_fade_out(self):
track = self.shot.bind.return_value.add_track.return_value
first, middle, last = MagicMock(), MagicMock(), MagicMock()
track.add_section.side_effect = [first, middle, last]
self.shot.animation(self.actor, start=0, end=48)
self.shot.animation(self.actor, start=36, end=84)
self.shot.animation(self.actor, start=72, end=120)
weight = middle.get_all_channels.return_value[0].add_key
weight.assert_any_call(36, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
weight.assert_any_call(48, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
weight.assert_any_call(72, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
weight.assert_any_call(84, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
def test_triple_overlap_is_rejected(self):
self.shot.animation(self.actor, start=0, end=60)
self.shot.animation(self.actor, start=48, end=100)
with self.assertRaises(ValueError):
self.shot.animation(self.actor, start=54, end=120)
def test_single_frame_overlap_automatically_blends(self):
self.shot.animation(self.actor, start=0, end=60)
self.shot.animation(self.actor, start=59, end=120)
self.assertEqual(self.shot._animations["SkeletalChild"][1][4], 1)
def test_adjacent_clips_do_not_fade(self):
self.shot.animation(self.actor, start=0, end=60)
self.shot.animation(self.actor, start=60, end=120)
weight = self.shot.bind.return_value.add_track.return_value.add_section.return_value.get_all_channels.return_value[0]
weight.add_key.assert_not_called()
def test_out_of_order_overlap_is_rejected(self):
self.shot.animation(self.actor, start=30, end=90)
with self.assertRaises(ValueError):
self.shot.animation(self.actor, start=0, end=60)
def test_unknown_clip_and_foreign_skeleton_are_rejected(self):
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "missing")
with self.assertRaises(ValueError):
self.shot.animation(self.actor, Clip(object()))
self.shot.bind.assert_not_called()
def test_default_requires_exactly_one_clip(self):
self.shot.animation(self.actor)
self.imported.animations["idle"] = Clip(self.skeleton)
with self.assertRaises(ValueError):
self.shot.animation(self.actor)
def test_static_actor_cannot_receive_skeletal_animation(self):
self.shot.scene._skeletal_models.clear()
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk")
self.shot.bind.assert_not_called()
def test_position_uses_existing_sequencer_curves(self):
axes = [MagicMock(), MagicMock(), MagicMock()]
for channel, value in zip(axes, (10, 250, 3)):
channel.evaluate_keys.return_value = [value]
self.shot._transforms["Parent"] = axes
self.assertEqual(self.shot.position(self.actor, 80), (10, 250, 3))
self.shot.asset.make_range.assert_called_once_with(80, 1)
def test_position_without_track_uses_spawn_position(self):
self.actor.get_actor_location.return_value.to_tuple.return_value = (30, -850, 0)
self.assertEqual(self.shot.position(self.actor, 80), (30, -850, 0))
def test_relative_move_anchors_existing_position_and_adds_offset(self):
self.shot.position = MagicMock(return_value=(10, 250, 3))
self.shot.transform_key = MagicMock()
target = self.shot.move(self.actor, start=80, end=110, offset=(0, 50, 0))
self.assertEqual(target, (10, 300, 3))
self.shot.position.assert_called_once_with(self.actor, 80)
self.shot.transform_key.assert_any_call(self.actor, 80, location=(10, 250, 3), interpolation="auto")
self.shot.transform_key.assert_any_call(self.actor, 110, location=(10, 300, 3), interpolation="auto")
def test_invalid_relative_move_does_not_write_keys(self):
self.shot.transform_key = MagicMock()
for options in ({"start": 80, "end": 80}, {"start": 80, "end": 120},
{"start": 80, "end": 110, "offset": (0, float("nan"), 0)}):
options.setdefault("offset", (0, 50, 0))
with self.subTest(options=options), self.assertRaises(ValueError):
self.shot.move(self.actor, **options)
self.shot.transform_key.assert_not_called()