Files
PythonInUnreal/tests/test_skeletal_sequence.py
T
2026-10-31 20:16:32 +01:00

146 lines
7.4 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"))
class Clip:
def __init__(self, skeleton):
self.skeleton = skeleton
def get_editor_property(self, name):
assert name == "skeleton"
return self.skeleton
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.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(_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_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")}):
with self.subTest(options=options), self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk", **options)
self.shot.bind.assert_not_called()
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()