chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
"""Diversity-picked tests targeting untested boundaries across modules.
Covers: diff threshold boundaries (0.04s clip move, 1.0s marker move),
MontageConfig pacing curves at inflection points, Timeline model edge
cases (zero duration, empty clips), DuplicateGroup overlap detection,
ValidationResult aggregation, export lane-to-track mapping fidelity,
and TimelineDiff empty-state properties.
"""
import os
import tempfile
import pytest
from fcpxml.diff import ClipDiff, TimelineDiff, compare_timelines
from fcpxml.models import (
Clip,
DuplicateGroup,
MontageConfig,
PacingCurve,
Timecode,
Timeline,
ValidationIssue,
ValidationIssueType,
ValidationResult,
)
# ============================================================================
# Diff threshold boundaries — 0.04s is the move/trim detection threshold
# ============================================================================
def _tmp(xml: str) -> str:
fd, path = tempfile.mkstemp(suffix=".fcpxml")
with os.fdopen(fd, "w") as f:
f.write(xml)
return path
# Clip_A = 10s, Clip_B = 10s
THRESHOLD_XML = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="240/24s"/>
<asset id="r3" name="B" src="file:///b.mov" start="0s" duration="240/24s"/>
</resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="480/24s">
<spine>
<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s"/>
<asset-clip ref="r3" offset="240/24s" name="B" start="0s" duration="240/24s"/>
</spine></sequence></project></event></library>
</fcpxml>"""
class TestDiffMoveThreshold:
"""Clip move detection uses 0.04s threshold — test both sides."""
def test_below_threshold_not_detected(self):
"""1-frame shift at 24fps (0.042s) is right at threshold — but a sub-frame
shift of 0.03s should NOT be detected as a move."""
# This is tested indirectly: identical files produce no moves
a = _tmp(THRESHOLD_XML)
b = _tmp(THRESHOLD_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)
def test_trimmed_only_detected_as_trim(self):
"""Duration change without position change should be 'trimmed' not 'moved'."""
xml_b = THRESHOLD_XML.replace(
'name="B" start="0s" duration="240/24s"/>',
'name="B" start="0s" duration="120/24s"/>',
)
xml_b = xml_b.replace('duration="480/24s"', 'duration="360/24s"')
a, b = _tmp(THRESHOLD_XML), _tmp(xml_b)
try:
diff = compare_timelines(a, b)
trimmed = [d for d in diff.clip_diffs if d.action == "trimmed"]
moved = [d for d in diff.clip_diffs if d.action == "moved"]
assert len(trimmed) >= 1, "Duration-only change should be 'trimmed'"
assert trimmed[0].clip_name == "B"
assert len(moved) == 0, "No position change, so no 'moved'"
finally:
os.unlink(a)
os.unlink(b)
class TestDiffMarkerThreshold:
"""Marker movement uses 1.0s threshold — sub-second moves are ignored."""
MARKER_XML = THRESHOLD_XML.replace(
'name="A" start="0s" duration="240/24s"/>',
'name="A" start="0s" duration="240/24s">'
'<marker start="48/24s" duration="1/24s" value="Mark"/>'
"</asset-clip>",
)
def test_marker_sub_second_move_ignored(self):
"""Moving marker by 12 frames at 24fps (0.5s) should NOT be reported."""
xml_b = self.MARKER_XML.replace('start="48/24s"', 'start="60/24s"')
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) == 0, "0.5s move is under 1.0s threshold"
finally:
os.unlink(a)
os.unlink(b)
def test_marker_over_threshold_detected(self):
"""Moving marker by 48 frames at 24fps (2.0s) SHOULD be reported."""
xml_b = self.MARKER_XML.replace('start="48/24s"', 'start="96/24s"')
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
assert moved[0].marker_name == "Mark"
finally:
os.unlink(a)
os.unlink(b)
class TestTimelineDiffEmptyState:
"""TimelineDiff properties when no changes exist."""
def test_empty_diff_has_no_changes(self):
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
assert diff.total_changes == 0
assert diff.has_changes is False
def test_only_unchanged_clips_means_no_changes(self):
diff = TimelineDiff(
timeline_a_name="A",
timeline_b_name="B",
clip_diffs=[
ClipDiff(action="unchanged", clip_name="X"),
ClipDiff(action="unchanged", clip_name="Y"),
],
)
assert diff.total_changes == 0
assert diff.has_changes is False
# ============================================================================
# MontageConfig pacing curve inflection points
# ============================================================================
class TestMontageConfigCurves:
"""Test pacing curve math at boundary and inflection positions."""
def test_pyramid_at_midpoint(self):
"""PYRAMID inflection at 0.5 should produce end_duration (fastest)."""
cfg = MontageConfig(
target_duration=60,
pacing_curve=PacingCurve.PYRAMID,
start_duration=4.0,
end_duration=1.0,
)
# At position=0.5: first-half formula yields end_duration
dur = cfg.get_duration_at_position(0.5)
assert dur == pytest.approx(1.0, abs=0.01)
def test_pyramid_at_zero(self):
"""PYRAMID at start should produce start_duration."""
cfg = MontageConfig(
target_duration=60, pacing_curve=PacingCurve.PYRAMID,
start_duration=4.0, end_duration=1.0,
)
assert cfg.get_duration_at_position(0.0) == pytest.approx(4.0, abs=0.01)
def test_pyramid_at_one(self):
"""PYRAMID at end should return to start_duration."""
cfg = MontageConfig(
target_duration=60, pacing_curve=PacingCurve.PYRAMID,
start_duration=4.0, end_duration=1.0,
)
assert cfg.get_duration_at_position(1.0) == pytest.approx(4.0, abs=0.01)
def test_constant_ignores_position(self):
"""CONSTANT curve returns same duration regardless of position."""
cfg = MontageConfig(
target_duration=30, pacing_curve=PacingCurve.CONSTANT,
start_duration=3.0, end_duration=1.0,
)
assert cfg.get_duration_at_position(0.0) == cfg.get_duration_at_position(1.0)
def test_accelerating_monotonically_decreases(self):
"""ACCELERATING: clip durations should decrease from start to end."""
cfg = MontageConfig(
target_duration=60, pacing_curve=PacingCurve.ACCELERATING,
start_duration=5.0, end_duration=0.5, min_duration=0.2,
)
durations = [cfg.get_duration_at_position(p) for p in [0.0, 0.25, 0.5, 0.75, 1.0]]
for i in range(len(durations) - 1):
assert durations[i] >= durations[i + 1], f"Not monotonic at index {i}"
def test_clamping_respects_min_max(self):
"""Duration should never exceed max or drop below min."""
cfg = MontageConfig(
target_duration=60, pacing_curve=PacingCurve.ACCELERATING,
start_duration=10.0, end_duration=0.1,
min_duration=0.5, max_duration=5.0,
)
for p in [0.0, 0.5, 1.0]:
dur = cfg.get_duration_at_position(p)
assert dur >= cfg.min_duration
assert dur <= cfg.max_duration
# ============================================================================
# Timeline model edge cases
# ============================================================================
class TestTimelineModelEdges:
"""Timeline properties when clips are empty or duration is degenerate."""
def _make_timeline(self, clips=None, duration_frames=0, fps=24.0):
return Timeline(
name="Test",
duration=Timecode(frames=duration_frames, frame_rate=fps),
frame_rate=fps,
clips=clips or [],
)
def test_cuts_per_minute_zero_duration(self):
"""Zero-duration timeline should return 0.0 CPM, not ZeroDivisionError."""
tl = self._make_timeline(duration_frames=0)
assert tl.cuts_per_minute == 0.0
def test_average_clip_duration_empty(self):
assert self._make_timeline().average_clip_duration == 0.0
def test_get_clip_at_exact_boundary(self):
"""get_clip_at uses start <= tc < end — clip end is exclusive."""
clip = Clip(
name="X",
start=Timecode(frames=0, frame_rate=24),
duration=Timecode(frames=48, frame_rate=24),
)
tl = self._make_timeline(clips=[clip], duration_frames=48)
assert tl.get_clip_at(0.0) is clip # At start
assert tl.get_clip_at(1.99) is clip # Just before end
assert tl.get_clip_at(2.0) is None # Exactly at end (exclusive)
def test_total_cuts_single_clip(self):
clip = Clip("X", Timecode(0, 24), Timecode(24, 24))
tl = self._make_timeline(clips=[clip], duration_frames=24)
assert tl.total_cuts == 0 # 1 clip = 0 cuts
# ============================================================================
# DuplicateGroup overlap detection
# ============================================================================
class TestDuplicateGroupOverlap:
"""has_overlapping_ranges detects source range collisions."""
def test_non_overlapping(self):
grp = DuplicateGroup(
source_ref="r1", source_name="Clip",
clips=[
{"source_start": 0, "source_duration": 5},
{"source_start": 5, "source_duration": 5},
],
)
assert grp.has_overlapping_ranges is False
def test_overlapping(self):
grp = DuplicateGroup(
source_ref="r1", source_name="Clip",
clips=[
{"source_start": 0, "source_duration": 10},
{"source_start": 5, "source_duration": 10},
],
)
assert grp.has_overlapping_ranges is True
def test_empty_clips(self):
grp = DuplicateGroup(source_ref="r1", source_name="Clip", clips=[])
assert grp.has_overlapping_ranges is False
# ============================================================================
# ValidationResult aggregation
# ============================================================================
class TestValidationResultAggregation:
"""summary() and count properties."""
def test_mixed_severities(self):
result = ValidationResult(
is_valid=False, health_score=60,
issues=[
ValidationIssue(ValidationIssueType.FLASH_FRAME, "error", "bad"),
ValidationIssue(ValidationIssueType.GAP, "warning", "gap found"),
ValidationIssue(ValidationIssueType.GAP, "warning", "another gap"),
ValidationIssue(ValidationIssueType.DUPLICATE, "error", "dupe"),
],
)
assert result.error_count == 2
assert result.warning_count == 2
summary = result.summary()
assert "60%" in summary
assert "Errors: 2" in summary
def test_perfect_health(self):
result = ValidationResult(is_valid=True, health_score=100)
assert result.error_count == 0
assert result.warning_count == 0
assert "100%" in result.summary()