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