"""Tests for fcpxml/diff.py — timeline comparison engine. Covers: moved clips, transition diffs, marker removal/movement, duplicate clip identity, simultaneous move+trim, empty timelines. """ import os import tempfile from fcpxml.diff import ( ClipDiff, MarkerDiff, TimelineDiff, _clip_identity, compare_timelines, ) BASE_XML = """ """ def _tmp(xml: str) -> str: fd, path = tempfile.mkstemp(suffix='.fcpxml') with os.fdopen(fd, 'w') as f: f.write(xml) return path class TestMovedClips: """Clips that change timeline position without duration change. Parser computes clip.start from cumulative spine position, not XML offset. Insert a gap to shift downstream clips' computed timeline positions. """ def test_moved_clip_detected(self): """Gap insertion shifts Clip_B's position — detected as 'moved'.""" # Insert a 5-second gap before Clip_B, pushing it from 10s to 15s xml_b = BASE_XML.replace( '', '\n' ' ', ).replace('duration="480/24s"', 'duration="600/24s"') a, b = _tmp(BASE_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) moved = [d for d in diff.clip_diffs if d.action == "moved"] assert len(moved) >= 1 assert moved[0].clip_name == "Clip_B" assert moved[0].old_start is not None assert moved[0].new_start is not None assert abs(moved[0].new_start - moved[0].old_start) > 0.04 finally: os.unlink(a) os.unlink(b) def test_identical_position_is_unchanged(self): """Same spine structure should produce no moved clips.""" a, b = _tmp(BASE_XML), _tmp(BASE_XML) try: diff = compare_timelines(a, b) moved = [d for d in diff.clip_diffs if d.action == "moved"] assert len(moved) == 0 finally: os.unlink(a) os.unlink(b) class TestMovedAndTrimmed: """Clip simultaneously moved and trimmed — reports as 'moved' with trim detail.""" def test_simultaneous_move_and_trim(self): """Gap insertion + duration change = moved AND trimmed.""" # Insert gap (moves Clip_B) AND shorten it (trims Clip_B) xml_b = BASE_XML.replace( '', '\n' ' ', ).replace('duration="480/24s"', 'duration="600/24s"') a, b = _tmp(BASE_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) moved = [d for d in diff.clip_diffs if d.action == "moved"] assert len(moved) >= 1 m = moved[0] assert "trimmed" in m.details.lower() assert m.old_duration != m.new_duration finally: os.unlink(a) os.unlink(b) class TestTransitionDiffs: """Transition comparison — count changes, name/duration changes.""" TRANS_XML = """ """ def test_transition_added(self): """Timeline with no transitions vs one transition.""" a, b = _tmp(BASE_XML), _tmp(self.TRANS_XML) try: diff = compare_timelines(a, b) assert len(diff.transition_diffs) >= 1 assert "count" in diff.transition_diffs[0].lower() finally: os.unlink(a) os.unlink(b) def test_transition_removed(self): a, b = _tmp(self.TRANS_XML), _tmp(BASE_XML) try: diff = compare_timelines(a, b) assert len(diff.transition_diffs) >= 1 finally: os.unlink(a) os.unlink(b) def test_transition_name_change(self): xml_b = self.TRANS_XML.replace('Cross Dissolve', 'Fade to Black') a, b = _tmp(self.TRANS_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) name_changes = [d for d in diff.transition_diffs if 'Cross Dissolve' in d] assert len(name_changes) >= 1 finally: os.unlink(a) os.unlink(b) class TestMarkerDiffs: """Marker removal and position movement detection.""" MARKER_XML = BASE_XML.replace( 'name="Clip_A" start="0s" duration="240/24s"/>', 'name="Clip_A" start="0s" duration="240/24s">' '' '' '' ).replace( '', '', ) def test_marker_removed(self): xml_b = self.MARKER_XML.replace( '', '' ) a, b = _tmp(self.MARKER_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) removed = [d for d in diff.marker_diffs if d.action == "removed"] assert len(removed) >= 1 assert any(d.marker_name == "Take 1" for d in removed) finally: os.unlink(a) os.unlink(b) def test_marker_moved(self): """Marker position change >1.0s should report 'moved'.""" xml_b = self.MARKER_XML.replace( '', '', ) a, b = _tmp(self.MARKER_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) moved = [d for d in diff.marker_diffs if d.action == "moved"] assert len(moved) >= 1 m = moved[0] assert m.marker_name == "Take 1" assert m.old_position is not None assert m.new_position is not None finally: os.unlink(a) os.unlink(b) class TestFrameRateDiff: """Frame rate changes between timelines.""" def test_frame_rate_change_detected(self): xml_b = BASE_XML.replace('frameDuration="1/24s"', 'frameDuration="1/30s"') a, b = _tmp(BASE_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) fps_changes = [c for c in diff.format_changes if "Frame rate" in c] assert len(fps_changes) >= 1 finally: os.unlink(a) os.unlink(b) class TestClipIdentity: """_clip_identity uses (name, source_start) for matching. Parser produces Timecode objects (which have .seconds), so tests must use Timecode — not raw TimeValue — to match real behavior. """ def test_identity_with_source_start(self): from fcpxml.models import Clip, Timecode clip = Clip( name="Interview", start=Timecode(frames=0, frame_rate=24), duration=Timecode(frames=240, frame_rate=24), source_start=Timecode(frames=240, frame_rate=24), # 10 seconds ) key = _clip_identity(clip) assert key == ("Interview", 10.0) def test_identity_no_source_start(self): from fcpxml.models import Clip, Timecode clip = Clip( name="Test", start=Timecode(frames=0, frame_rate=24), duration=Timecode(frames=100, frame_rate=24), source_start=None, ) key = _clip_identity(clip) assert key == ("Test", 0.0) class TestTotalChangesProperty: """TimelineDiff.total_changes excludes 'unchanged' clips.""" def test_unchanged_clips_not_counted(self): diff = TimelineDiff( timeline_a_name="A", timeline_b_name="B", clip_diffs=[ ClipDiff(action="unchanged", clip_name="X"), ClipDiff(action="moved", clip_name="Y"), ], ) assert diff.total_changes == 1 def test_all_change_types_summed(self): diff = TimelineDiff( timeline_a_name="A", timeline_b_name="B", clip_diffs=[ClipDiff(action="added", clip_name="X")], marker_diffs=[MarkerDiff(action="removed", marker_name="M")], transition_diffs=["count changed"], format_changes=["resolution changed"], ) assert diff.total_changes == 4