"""Edge-case tests targeting real production failure modes. Covers boundary conditions in TimeValue arithmetic, clip index collisions from duplicate names, split_clip boundary behavior, diff identity rounding, and to_fcpxml round-trip fidelity for non-standard timebases. """ import os import tempfile import xml.etree.ElementTree as ET import pytest from fcpxml.diff import ClipDiff, TimelineDiff, _clip_identity from fcpxml.models import Clip, Timecode, TimeValue from fcpxml.writer import FCPXMLModifier # ============================================================================ # TimeValue boundary arithmetic # ============================================================================ class TestTimeValueBoundaries: """Catch real bugs in rational time math at the edges.""" def test_subtraction_produces_negative_time(self): """Negative results from subtraction must not silently corrupt offsets.""" a = TimeValue(10, 30) b = TimeValue(20, 30) result = a - b assert result.numerator == -10 assert result.to_seconds() < 0, "Negative time should report negative seconds" def test_zero_denominator_rejected_at_construction(self): """Zero denominator must be caught at construction, not silently propagated.""" with pytest.raises(ValueError, match="denominator cannot be zero"): TimeValue(100, 0) def test_zero_denominator_rejected_even_with_zero_numerator(self): """TimeValue(0, 0) is still invalid — use TimeValue(0, 1) for zero time.""" with pytest.raises(ValueError, match="denominator cannot be zero"): TimeValue(0, 0) def test_snap_to_frame_rejects_zero_fps(self): """snap_to_frame(0) would cause ZeroDivisionError without the guard.""" tv = TimeValue(100, 2400) with pytest.raises(ValueError, match="fps must be positive"): tv.snap_to_frame(0) def test_snap_to_frame_rejects_negative_fps(self): tv = TimeValue(100, 2400) with pytest.raises(ValueError, match="fps must be positive"): tv.snap_to_frame(-24) def test_multiply_by_zero_yields_zero_time(self): tv = TimeValue(90, 30) result = tv * 0 assert result.to_seconds() == 0.0 def test_divide_by_zero_raises(self): """Division by zero scalar must raise ZeroDivisionError, not corrupt silently.""" tv = TimeValue(90, 30) with pytest.raises(ZeroDivisionError, match="Cannot divide TimeValue by zero"): tv / 0 def test_from_timecode_zero_denominator_rational(self): """Parsing '10/0s' must raise, not silently produce bad TimeValue.""" with pytest.raises(ValueError, match="Zero denominator"): TimeValue.from_timecode("10/0s") def test_to_fcpxml_preserves_non_standard_denominator(self): """Non-standard denominators (e.g., 7) fall back to unsimplified form.""" tv = TimeValue(14, 7) # 2 seconds, but 7 isn't a standard timebase result = tv.to_fcpxml() assert result == "2s" # Simplifies to 2/1, denominator=1 → "2s" def test_to_fcpxml_non_standard_no_simplify_to_whole(self): """Non-standard denominator that doesn't simplify to 1 keeps original.""" tv = TimeValue(10, 7) # 10/7 ≈ 1.43s, can't simplify to standard result = tv.to_fcpxml() assert result == "10/7s" # Falls back to unsimplified def test_simplify_negative_numerator(self): """Simplify handles negative numerators correctly via abs().""" tv = TimeValue(-60, 30) simplified = tv.simplify() assert simplified.numerator == -2 assert simplified.denominator == 1 def test_equality_exact_rational(self): """Equivalent fractions are equal via cross-multiplication.""" a = TimeValue(600, 2400) # 0.25s b = TimeValue(300, 1200) # 0.25s — same value, different representation assert a == b def test_equality_different_values(self): """Genuinely different rational values are NOT equal.""" a = TimeValue(30000, 30000) # Exactly 1.0s b = TimeValue(10000, 10001) # 0.99990001s — different value assert a != b def test_equality_beyond_tolerance(self): """TimeValues differing by any amount are NOT equal.""" a = TimeValue(1000, 1000) # 1.0s b = TimeValue(999, 1000) # 0.999s assert a != b # ============================================================================ # Timecode edge cases # ============================================================================ class TestTimecodeEdges: """Edge cases in legacy Timecode wrapper.""" def test_zero_frame_rate_seconds(self): """Timecode with frame_rate=0 would crash on .seconds — document behavior.""" tc = Timecode(frames=100, frame_rate=0) with pytest.raises(ZeroDivisionError): _ = tc.seconds def test_negative_frames_smpte(self): """Negative frame count should not crash to_smpte — just produce odd output.""" tc = Timecode(frames=-1, frame_rate=24.0) # Should not raise; output may be unusual but shouldn't crash result = tc.to_smpte() assert isinstance(result, str) # ============================================================================ # Clip index collision (duplicate names) # ============================================================================ DUPLICATE_NAMES_FCPXML = """\ """ class TestClipIndexCollision: """When multiple clips share a name, only the LAST one is indexed. This is a known limitation documented in MEMORY.md — these tests pin the behavior so regressions are caught if the indexing strategy changes. """ def test_duplicate_name_last_wins(self): """_build_clip_index stores last occurrence for duplicate names.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(DUPLICATE_NAMES_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) clip = mod.clips.get("Interview") assert clip is not None # Last clip starts at source offset 60s assert clip.get("start") == "60s" finally: os.unlink(f.name) def test_add_marker_hits_last_duplicate(self): """add_marker on duplicate name affects last indexed clip, not first.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(DUPLICATE_NAMES_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) result = mod.add_marker("Interview", "1/24s", "Test Marker") assert result is not None # Marker should be on the LAST Interview clip (start=60s) clip = mod.clips["Interview"] markers = [c for c in clip if c.tag == "marker"] assert len(markers) == 1 assert markers[0].get("value") == "Test Marker" finally: os.unlink(f.name) # ============================================================================ # split_clip boundary behavior # ============================================================================ SPLIT_FCPXML = """\ """ class TestSplitClipBoundaries: """split_clip at edge positions — verify zero-duration segments are skipped.""" def test_split_at_start_skips_zero_duration_segment(self): """Splitting at offset=0 should skip the zero-duration first segment.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(SPLIT_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) # Split at the very beginning (0 frames into clip) result = mod.split_clip("A", ["0/24s"]) # Zero-duration segment should be skipped → only 1 clip remains assert len(result) == 1 # Duration preserved (may be simplified by to_fcpxml) dur_seconds = TimeValue.from_timecode(result[0].get("duration")).to_seconds() assert dur_seconds == pytest.approx(5.0, abs=0.05) finally: os.unlink(f.name) def test_split_at_end_skips_zero_duration_tail(self): """Splitting at the clip's full duration produces no zero-duration tail.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(SPLIT_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) result = mod.split_clip("A", ["120/24s"]) # Full duration split → first segment = full clip, tail = 0 → skipped assert len(result) == 1 finally: os.unlink(f.name) def test_split_midpoint_produces_two_clips(self): """Normal midpoint split produces exactly two clips with correct offsets.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(SPLIT_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) result = mod.split_clip("A", ["60/24s"]) assert len(result) == 2 assert result[0].get("duration") == "60/24s" assert result[1].get("duration") == "60/24s" # Offsets must be contiguous assert result[0].get("offset") == "0s" assert result[1].get("offset") == "60/24s" finally: os.unlink(f.name) # ============================================================================ # Split clip child-element filtering (markers, keywords) # ============================================================================ SPLIT_MARKER_FCPXML = """\ """ class TestSplitClipChildFiltering: """split_clip must filter markers/keywords so they only appear on the segment whose source range actually contains them.""" def test_markers_land_on_correct_segment(self): """Markers must appear only on the segment whose source range covers them.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(SPLIT_MARKER_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) # Clip starts at source 10s, duration 120/24s = 5s → source range [10s, 15s) # Split at 60/24s = 2.5s into clip → source split at 12.5s # Segment 0: source [10s, 12.5s) — "Early mark" at 11s ✓, "Late mark" at 13s ✗ # Segment 1: source [12.5s, 15s) — "Early mark" at 11s ✗, "Late mark" at 13s ✓ result = mod.split_clip("A", ["60/24s"]) assert len(result) == 2 seg0_markers = [c for c in result[0] if c.tag == 'marker'] seg1_markers = [c for c in result[1] if c.tag == 'marker'] assert len(seg0_markers) == 1 assert seg0_markers[0].get('value') == 'Early mark' assert len(seg1_markers) == 1 assert seg1_markers[0].get('value') == 'Late mark' finally: os.unlink(f.name) def test_keyword_clamped_to_segment_boundaries(self): """Keywords spanning the full clip get clamped to each segment's range.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(SPLIT_MARKER_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) result = mod.split_clip("A", ["60/24s"]) assert len(result) == 2 seg0_kw = [c for c in result[0] if c.tag == 'keyword'] seg1_kw = [c for c in result[1] if c.tag == 'keyword'] # Both segments should have the keyword (it spans the whole clip) assert len(seg0_kw) == 1 assert len(seg1_kw) == 1 # Segment 0: keyword clamped to [10s, 12.5s) → duration = 60/24s seg0_dur = TimeValue.from_timecode(seg0_kw[0].get('duration')) assert seg0_dur.to_seconds() == pytest.approx(2.5, abs=0.05) # Segment 1: keyword clamped to [12.5s, 15s) → duration = 60/24s seg1_dur = TimeValue.from_timecode(seg1_kw[0].get('duration')) assert seg1_dur.to_seconds() == pytest.approx(2.5, abs=0.05) finally: os.unlink(f.name) def test_marker_outside_all_segments_removed(self): """A marker exactly at the segment boundary end is excluded.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: # Marker at source 15s, clip source range [10s, 15s) — at boundary = excluded xml = SPLIT_MARKER_FCPXML.replace( ' Clip: return Clip( name=name, start=Timecode(frames=0, frame_rate=24), duration=Timecode(frames=120, frame_rate=24), source_start=Timecode( frames=int(round(source_start_seconds * 24)), frame_rate=24, ), ) def test_same_name_different_source_start_distinct(self): """Clips one full frame apart (0.042s at 24fps) are distinct identities.""" a = self._make_clip("Shot", 1.00) b = self._make_clip("Shot", 1.05) # 1.05*24=25.2→25 frames→1.04s assert _clip_identity(a) != _clip_identity(b) def test_rounding_collision_within_half_cent(self): """Clips 0.004s apart may collide after rounding to 0.01s.""" a = self._make_clip("Shot", 1.004) b = self._make_clip("Shot", 1.006) # Both round to 1.00 or 1.01 depending on frame quantization at 24fps id_a = _clip_identity(a) id_b = _clip_identity(b) # At 24fps: 1.004*24=24.096→24 frames→1.0s, 1.006*24=24.144→24 frames→1.0s # Both map to source_start=1.0 → collision assert id_a == id_b, "Rounding collision is expected at sub-frame precision" def test_timeline_diff_total_changes_excludes_unchanged(self): """total_changes property skips 'unchanged' diffs.""" diff = TimelineDiff( timeline_a_name="A", timeline_b_name="B", clip_diffs=[ ClipDiff(action="unchanged", clip_name="C1"), ClipDiff(action="moved", clip_name="C2"), ClipDiff(action="added", clip_name="C3"), ], ) assert diff.total_changes == 2 assert diff.has_changes is True # ============================================================================ # _filter_children_for_segment (direct unit tests) # ============================================================================ class TestFilterChildrenForSegment: """Direct unit tests for _filter_children_for_segment in isolation.""" @staticmethod def _make_clip_with_children(*children_specs): """Build a minimal . Each spec is (tag, attrs_dict).""" clip = ET.Element('asset-clip') clip.set('start', '0s') clip.set('duration', '240/24s') # 10s for tag, attrs in children_specs: child = ET.SubElement(clip, tag) for k, v in attrs.items(): child.set(k, v) return clip def test_chapter_marker_filtered_same_as_regular_marker(self): """chapter-marker elements must be filtered identically to markers.""" clip = self._make_clip_with_children( ('chapter-marker', {'start': '1s', 'duration': '1/24s', 'value': 'Ch1'}), ('chapter-marker', {'start': '8s', 'duration': '1/24s', 'value': 'Ch2'}), ) seg_start = TimeValue(0, 1) # 0s seg_dur = TimeValue(120, 24) # 5s → segment [0s, 5s) FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur) remaining = [c for c in clip if c.tag == 'chapter-marker'] assert len(remaining) == 1 assert remaining[0].get('value') == 'Ch1' def test_keyword_completely_before_segment_removed(self): """Keyword ending before segment start is removed entirely.""" clip = self._make_clip_with_children( ('keyword', {'start': '0s', 'duration': '48/24s', 'value': 'Early'}), # 0-2s ) seg_start = TimeValue(120, 24) # 5s seg_dur = TimeValue(120, 24) # 5s → segment [5s, 10s) FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur) assert len([c for c in clip if c.tag == 'keyword']) == 0 def test_keyword_completely_after_segment_removed(self): """Keyword starting at or after segment end is removed.""" clip = self._make_clip_with_children( ('keyword', {'start': '6s', 'duration': '48/24s', 'value': 'Late'}), # 6-8s ) seg_start = TimeValue(0, 1) # 0s seg_dur = TimeValue(120, 24) # 5s → segment [0s, 5s) FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur) assert len([c for c in clip if c.tag == 'keyword']) == 0 def test_keyword_zero_duration_inside_segment_kept(self): """Zero-duration keyword at a point inside segment survives filter.""" clip = self._make_clip_with_children( ('keyword', {'start': '3s', 'duration': '0s', 'value': 'Point'}), ) seg_start = TimeValue(0, 1) seg_dur = TimeValue(120, 24) # 5s FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur) remaining = [c for c in clip if c.tag == 'keyword'] assert len(remaining) == 1 def test_keyword_zero_duration_at_segment_end_removed(self): """Zero-duration keyword exactly at segment end boundary is removed.""" clip = self._make_clip_with_children( ('keyword', {'start': '5s', 'duration': '0s', 'value': 'Boundary'}), ) seg_start = TimeValue(0, 1) seg_dur = TimeValue(120, 24) # 5s → segment [0s, 5s) FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur) # kw_start(5s) >= seg_end(5s) → removed assert len([c for c in clip if c.tag == 'keyword']) == 0 def test_non_marker_children_preserved(self): """Elements other than marker/keyword/chapter-marker are untouched.""" clip = self._make_clip_with_children( ('conform-rate', {'scaleEnabled': '1'}), ('marker', {'start': '99s', 'duration': '1/24s', 'value': 'Outside'}), ) seg_start = TimeValue(0, 1) seg_dur = TimeValue(24, 24) # 1s FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur) # Marker at 99s removed, but conform-rate must survive assert len([c for c in clip if c.tag == 'conform-rate']) == 1 assert len([c for c in clip if c.tag == 'marker']) == 0 def test_keyword_partial_overlap_clamped_to_segment(self): """Keyword [2s,8s) in segment [5s,10s) → clamped to [5s,8s).""" clip = self._make_clip_with_children( ('keyword', {'start': '2s', 'duration': '6s', 'value': 'Wide'}), ) FCPXMLModifier._filter_children_for_segment( clip, TimeValue(120, 24), TimeValue(120, 24)) # seg [5s, 10s) kw = [c for c in clip if c.tag == 'keyword'][0] assert TimeValue.from_timecode(kw.get('start')).to_seconds() == pytest.approx(5.0, abs=0.01) assert TimeValue.from_timecode(kw.get('duration')).to_seconds() == pytest.approx(3.0, abs=0.01) # ============================================================================ # Multi-point split with distributed children # ============================================================================ MULTI_SPLIT_FCPXML = """\ """ class TestMultiPointSplit: """Splitting at 3+ points: marker distribution + keyword clamping.""" def test_three_way_split_markers_and_keywords(self): """Each marker lands on exactly one segment; keyword spans all three.""" with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f: f.write(MULTI_SPLIT_FCPXML) f.flush() try: mod = FCPXMLModifier(f.name) # Split at 3s and 6s into a 10s clip → [0-3), [3-6), [6-10) result = mod.split_clip("A", ["72/24s", "144/24s"]) assert len(result) == 3 # Markers: M1@1s→seg0, M2@4s→seg1, M3@8s→seg2 markers = [[c.get('value') for c in s if c.tag == 'marker'] for s in result] assert markers[0] == ['M1'] assert markers[1] == ['M2'] assert markers[2] == ['M3'] # Full-span keyword appears on all segments, durations sum to 10s total_kw_dur = sum( TimeValue.from_timecode( [c for c in seg if c.tag == 'keyword'][0].get('duration') ).to_seconds() for seg in result ) assert total_kw_dur == pytest.approx(10.0, abs=0.05) finally: os.unlink(f.name) # ============================================================================ # TimeValue division edge cases # ============================================================================ class TestTimeValueDivision: """Division edge cases for speed-change and time arithmetic.""" def test_negative_scalar_flips_playback_direction(self): """Dividing by negative scalar normalizes sign onto numerator.""" tv = TimeValue(100, 30) result = tv / -2 # Denominator normalized to positive; sign moves to numerator assert result.numerator == -100 assert result.denominator == 60 # Seconds should be negative (reverse playback) assert result.to_seconds() < 0 def test_very_small_scalar_rounds_denom_to_zero_raises(self): """When scalar is so small that denom * scalar rounds to 0, raise.""" # 24 * 0.01 = 0.24 → rounds to 0 → ZeroDivisionError with pytest.raises(ZeroDivisionError, match="rounds denominator.*to zero"): TimeValue(100, 24) / 0.01 def test_division_mul_roundtrip(self): """(tv / k) * k ≈ tv for non-pathological scalars.""" tv = TimeValue(120, 24) k = 3.0 result = (tv / k) * k assert result.to_seconds() == pytest.approx(tv.to_seconds(), abs=0.01)