352 lines
13 KiB
Python
Executable File
352 lines
13 KiB
Python
Executable File
"""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 = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE fcpxml>
|
|
<fcpxml version="1.11">
|
|
<resources>
|
|
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
|
<asset id="r2" name="Clip_A" src="file:///a.mov" start="0s" duration="240/24s"/>
|
|
<asset id="r3" name="Clip_B" src="file:///b.mov" start="0s" duration="240/24s"/>
|
|
</resources>
|
|
<library>
|
|
<event name="Test">
|
|
<project name="Gap Test">
|
|
<sequence format="r1" duration="480/24s">
|
|
<spine>
|
|
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s"/>
|
|
<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s"/>
|
|
</spine>
|
|
</sequence>
|
|
</project>
|
|
</event>
|
|
</library>
|
|
</fcpxml>"""
|
|
|
|
|
|
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">'
|
|
'<marker start="48/24s" duration="1/24s" value="New Mark"/>'
|
|
"</asset-clip>",
|
|
)
|
|
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])
|