"""Targeted tests for untested branches in diff, export, and models. Covers: clip-only-trim detection, marker addition/threshold, duplicate identity imbalance, has_changes property, XMEML frame math precision, video-only clip audio exclusion, TimeValue division edge cases, ValidationResult.summary format, and MontageConfig boundary clamping. """ import os import tempfile import xml.etree.ElementTree as ET import pytest from fcpxml.diff import ( TimelineDiff, _compare_clips, _compare_markers, compare_timelines, ) from fcpxml.export import DaVinciExporter from fcpxml.models import ( Clip, FlashFrame, FlashFrameSeverity, GapInfo, MontageConfig, PacingCurve, Timecode, Timeline, TimeValue, ValidationIssue, ValidationIssueType, ValidationResult, ) # ── Fixtures ────────────────────────────────────────────────────────── 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 def _out(suffix=".fcpxml") -> str: fd, path = tempfile.mkstemp(suffix=suffix) os.close(fd) return path # ── Diff: trimmed-only (no move) ───────────────────────────────────── class TestTrimmedOnly: """Clip duration changes without position change → 'trimmed' action.""" def test_clip_trimmed_without_move(self): """Shorten Clip_A in-place — position unchanged, duration changed.""" xml_b = BASE_XML.replace( 'name="Clip_A" start="0s" duration="240/24s"', 'name="Clip_A" start="0s" duration="120/24s"', 1, # only first occurrence (spine clip, not asset) ) a, b = _tmp(BASE_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) trimmed = [d for d in diff.clip_diffs if d.action == "trimmed"] assert len(trimmed) >= 1 assert trimmed[0].clip_name == "Clip_A" assert trimmed[0].old_duration != trimmed[0].new_duration # Position should be unchanged assert abs(trimmed[0].old_start - trimmed[0].new_start) <= 0.04 finally: os.unlink(a) os.unlink(b) # ── Diff: marker addition ──────────────────────────────────────────── class TestMarkerAddition: """Markers present in B but not A should report 'added'.""" def test_marker_added(self): xml_b = BASE_XML.replace( 'name="Clip_A" start="0s" duration="240/24s"/>', 'name="Clip_A" start="0s" duration="240/24s">' '' "", ) a, b = _tmp(BASE_XML), _tmp(xml_b) try: diff = compare_timelines(a, b) added = [d for d in diff.marker_diffs if d.action == "added"] assert len(added) >= 1 assert any(m.marker_name == "New Mark" for m in added) finally: os.unlink(a) os.unlink(b) # ── Diff: marker threshold boundary ────────────────────────────────── class TestMarkerThreshold: """Markers moved ≤1.0s are NOT reported; >1.0s are.""" def test_marker_at_exact_threshold_not_reported(self): """1.0s movement is boundary — should NOT be reported as moved.""" diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B") # Simulate: marker at 0s in A, 1.0s in B (exactly at threshold) tl_a = _make_timeline_with_marker("Mk", 0.0) tl_b = _make_timeline_with_marker("Mk", 1.0) _compare_markers(tl_a, tl_b, diff) moved = [d for d in diff.marker_diffs if d.action == "moved"] assert len(moved) == 0, "Exactly 1.0s should NOT trigger 'moved'" def test_marker_above_threshold_reported(self): """~1.08s movement (26 frames at 24fps) exceeds threshold — reported.""" diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B") tl_a = _make_timeline_with_marker("Mk", 0.0) # 26 frames / 24fps = 1.083s, safely above 1.0s threshold tl_b = _make_timeline_with_marker("Mk", 26 / 24) _compare_markers(tl_a, tl_b, diff) moved = [d for d in diff.marker_diffs if d.action == "moved"] assert len(moved) == 1 # ── Diff: duplicate identity imbalance ──────────────────────────────── class TestDuplicateIdentityImbalance: """When clip identity maps have unequal counts, extras are added/removed.""" def test_extra_clip_in_b_is_added(self): """3 clips with same identity in B vs 2 in A → 1 added.""" diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B") tc = Timecode(frames=0, frame_rate=24) dur = Timecode(frames=48, frame_rate=24) clips_a = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 2 clips_b = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 3 _compare_clips(clips_a, clips_b, diff) added = [d for d in diff.clip_diffs if d.action == "added"] assert len(added) == 1 def test_extra_clip_in_a_is_removed(self): tc = Timecode(frames=0, frame_rate=24) dur = Timecode(frames=48, frame_rate=24) diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B") clips_a = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 3 clips_b = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 1 _compare_clips(clips_a, clips_b, diff) removed = [d for d in diff.clip_diffs if d.action == "removed"] assert len(removed) == 2 # ── Diff: has_changes property ──────────────────────────────────────── class TestHasChanges: def test_no_changes(self): diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B") assert diff.has_changes is False def test_with_format_change(self): diff = TimelineDiff( timeline_a_name="A", timeline_b_name="B", format_changes=["Resolution: 1920x1080 -> 3840x2160"], ) assert diff.has_changes is True # ── Export: XMEML frame math ────────────────────────────────────────── class TestXmemlFrameMath: """Verify clipitem start/end/in/out frame calculations.""" def test_clipitem_frame_values(self): src = _tmp(BASE_XML) out = _out(".xml") try: exp = DaVinciExporter(src) exp.export_xmeml(out) tree = ET.parse(out) ci = tree.findall(".//clipitem") # First clip: start=0, duration=10s at 24fps = 240 frames first = ci[0] assert first.find("start").text == "0" assert first.find("end").text == "240" assert first.find("in").text == "0" assert first.find("out").text == "240" finally: os.unlink(src) os.unlink(out) # ── TimeValue: division edge cases ──────────────────────────────────── class TestTimeValueDivision: def test_divide_by_zero_raises(self): """Division by zero must raise ZeroDivisionError, not silently corrupt.""" tv = TimeValue(100, 1) with pytest.raises(ZeroDivisionError, match="Cannot divide TimeValue by zero"): tv / 0.0 def test_negative_timevalue_comparison(self): """Negative TimeValue should compare correctly.""" neg = TimeValue(-10, 1) pos = TimeValue(10, 1) assert neg < pos assert not (pos < neg) def test_multiply_preserves_denominator(self): tv = TimeValue(100, 2400) result = tv * 2 assert result.denominator == 2400 assert result.numerator == 200 # ── ValidationResult.summary format ─────────────────────────────────── class TestValidationSummary: def test_summary_format(self): result = ValidationResult( is_valid=False, health_score=72, issues=[ ValidationIssue(ValidationIssueType.FLASH_FRAME, "error", "flash"), ValidationIssue(ValidationIssueType.GAP, "warning", "gap"), ValidationIssue(ValidationIssueType.GAP, "warning", "gap2"), ], flash_frames=[ FlashFrame("clip1", "r2", Timecode(0, 24), 1, 0.04, FlashFrameSeverity.CRITICAL), ], gaps=[ GapInfo(Timecode(100, 24), 5, 0.21), GapInfo(Timecode(200, 24), 3, 0.13), ], ) s = result.summary() assert "72%" in s assert "Errors: 1" in s assert "Warnings: 2" in s assert "Flash frames: 1" in s assert "Gaps: 2" in s # ── MontageConfig: clamping at boundaries ───────────────────────────── class TestMontageConfigClamping: def test_decelerating_at_end_clamps_to_max(self): """Decelerating at position=1.0 returns start_duration, clamped to max.""" cfg = MontageConfig( target_duration=60, pacing_curve=PacingCurve.DECELERATING, start_duration=10.0, end_duration=0.5, max_duration=5.0, ) # At position 1.0, decelerating = end + (start-end)*1.0 = start = 10.0 # Clamped to max_duration = 5.0 assert cfg.get_duration_at_position(1.0) == 5.0 def test_accelerating_at_start_clamps_to_max(self): cfg = MontageConfig( target_duration=60, pacing_curve=PacingCurve.ACCELERATING, start_duration=8.0, end_duration=0.5, max_duration=5.0, ) # At position 0.0, accelerating = start = 8.0, clamped to 5.0 assert cfg.get_duration_at_position(0.0) == 5.0 def test_constant_clamps_to_min_duration(self): """CONSTANT pacing must respect min_duration (was bypassing clamp).""" cfg = MontageConfig( target_duration=60, pacing_curve=PacingCurve.CONSTANT, start_duration=0.1, end_duration=0.1, min_duration=0.5, max_duration=5.0, ) # Average = 0.1, but must be clamped up to min_duration = 0.5 for p in [0.0, 0.5, 1.0]: assert cfg.get_duration_at_position(p) == 0.5 def test_constant_clamps_to_max_duration(self): """CONSTANT pacing must respect max_duration (was bypassing clamp).""" cfg = MontageConfig( target_duration=60, pacing_curve=PacingCurve.CONSTANT, start_duration=10.0, end_duration=10.0, min_duration=0.5, max_duration=5.0, ) # Average = 10.0, but must be clamped down to max_duration = 5.0 for p in [0.0, 0.5, 1.0]: assert cfg.get_duration_at_position(p) == 5.0 def test_constant_within_bounds_unchanged(self): """CONSTANT pacing returns unclamped average when within bounds.""" cfg = MontageConfig( target_duration=60, pacing_curve=PacingCurve.CONSTANT, start_duration=3.0, end_duration=1.0, min_duration=0.5, max_duration=5.0, ) # Average = 2.0, within [0.5, 5.0] — no clamp needed assert cfg.get_duration_at_position(0.5) == 2.0 # ── Helpers ─────────────────────────────────────────────────────────── def _make_timeline_with_marker(name: str, position_seconds: float) -> Timeline: """Build a minimal Timeline with one clip-level marker at a given position.""" from fcpxml.models import Marker, MarkerType tc = Timecode(frames=0, frame_rate=24) dur = Timecode(frames=2400, frame_rate=24) marker_tc = Timecode(frames=int(position_seconds * 24), frame_rate=24) clip = Clip( name="Host", start=tc, duration=dur, markers=[Marker(name=name, start=marker_tc, marker_type=MarkerType.STANDARD)], ) return Timeline(name="Test", duration=dur, clips=[clip])