"""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()