chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+350
@@ -0,0 +1,350 @@
|
||||
"""Tests for FCPXML validation functions and DTD infrastructure.
|
||||
|
||||
Covers the 6 _check_*() validators, _dtd_insert() ordering,
|
||||
_enforce_standard_timebases() snapping, and validate_fcpxml() orchestration.
|
||||
These are critical output-correctness guards — bugs here mean FCP import failures.
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from fcpxml.models import ValidationIssueType
|
||||
from fcpxml.writer import (
|
||||
_check_asset_sources,
|
||||
_check_child_order,
|
||||
_check_effect_refs,
|
||||
_check_frame_alignment,
|
||||
_check_required_attributes,
|
||||
_check_timebases,
|
||||
_dtd_insert,
|
||||
_enforce_standard_timebases,
|
||||
_sanitize_xml_value,
|
||||
validate_fcpxml,
|
||||
)
|
||||
|
||||
# ── _dtd_insert: DTD-ordered element insertion ──────────────────────
|
||||
|
||||
|
||||
def test_dtd_insert_marker_before_filter():
|
||||
"""Marker must appear before filter-video per DTD ordering."""
|
||||
clip = ET.Element("asset-clip")
|
||||
ET.SubElement(clip, "filter-video")
|
||||
marker = ET.Element("marker", value="test")
|
||||
_dtd_insert(clip, marker)
|
||||
tags = [c.tag for c in clip]
|
||||
assert tags.index("marker") < tags.index("filter-video")
|
||||
|
||||
|
||||
def test_dtd_insert_note_always_first():
|
||||
"""<note> has lowest priority — always inserted at position 0."""
|
||||
clip = ET.Element("clip")
|
||||
ET.SubElement(clip, "marker")
|
||||
ET.SubElement(clip, "filter-audio")
|
||||
note = ET.Element("note")
|
||||
_dtd_insert(clip, note)
|
||||
assert clip[0].tag == "note"
|
||||
|
||||
|
||||
def test_dtd_insert_unknown_tag_appended():
|
||||
"""Unknown tags get appended at the end."""
|
||||
clip = ET.Element("asset-clip")
|
||||
ET.SubElement(clip, "note")
|
||||
ET.SubElement(clip, "marker")
|
||||
custom = ET.Element("custom-extension")
|
||||
_dtd_insert(clip, custom)
|
||||
assert clip[-1].tag == "custom-extension"
|
||||
|
||||
|
||||
def test_dtd_insert_into_empty_parent():
|
||||
"""Inserting into an empty element works without error."""
|
||||
clip = ET.Element("clip")
|
||||
marker = ET.Element("marker", value="first")
|
||||
_dtd_insert(clip, marker)
|
||||
assert len(clip) == 1
|
||||
assert clip[0].tag == "marker"
|
||||
|
||||
|
||||
def test_dtd_insert_preserves_between_existing():
|
||||
"""Insert conform-rate between note and marker — correct middle position."""
|
||||
clip = ET.Element("asset-clip")
|
||||
ET.SubElement(clip, "note")
|
||||
ET.SubElement(clip, "marker")
|
||||
ET.SubElement(clip, "filter-video")
|
||||
conform = ET.Element("conform-rate")
|
||||
_dtd_insert(clip, conform)
|
||||
tags = [c.tag for c in clip]
|
||||
assert tags == ["note", "conform-rate", "marker", "filter-video"]
|
||||
|
||||
|
||||
# ── _check_child_order: DTD ordering violations ─────────────────────
|
||||
|
||||
|
||||
def test_check_child_order_valid():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip name="A">'
|
||||
'<note/><marker/><filter-video/>'
|
||||
'</asset-clip></fcpxml>'
|
||||
)
|
||||
assert _check_child_order(root) == []
|
||||
|
||||
|
||||
def test_check_child_order_detects_violation():
|
||||
"""filter-video before marker violates DTD ordering."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip name="Bad">'
|
||||
'<filter-video ref="r1"/><marker value="x"/>'
|
||||
'</asset-clip></fcpxml>'
|
||||
)
|
||||
issues = _check_child_order(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.ELEMENT_ORDER
|
||||
assert "Bad" in issues[0].message
|
||||
|
||||
|
||||
def test_check_child_order_skips_single_child():
|
||||
"""Single-child elements can't be out of order."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip name="Solo"><marker/></clip></fcpxml>'
|
||||
)
|
||||
assert _check_child_order(root) == []
|
||||
|
||||
|
||||
def test_check_child_order_ignores_non_clip_parents():
|
||||
"""Only clip/asset-clip/video/audio/ref-clip are checked."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><spine><filter-video/><marker/></spine></fcpxml>'
|
||||
)
|
||||
assert _check_child_order(root) == []
|
||||
|
||||
|
||||
# ── _check_required_attributes ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_required_attrs_all_present():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip ref="r1" duration="100/2400s" name="A"/>'
|
||||
'<format id="r1"/></fcpxml>'
|
||||
)
|
||||
assert _check_required_attributes(root) == []
|
||||
|
||||
|
||||
def test_required_attrs_missing_ref_on_asset_clip():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip duration="100/2400s" name="No Ref"/></fcpxml>'
|
||||
)
|
||||
issues = _check_required_attributes(root)
|
||||
types = [i.issue_type for i in issues]
|
||||
assert ValidationIssueType.MISSING_ATTRIBUTE in types
|
||||
assert any("ref" in i.message for i in issues)
|
||||
|
||||
|
||||
def test_required_attrs_missing_id_on_format():
|
||||
root = ET.fromstring('<fcpxml><format name="HD"/></fcpxml>')
|
||||
issues = _check_required_attributes(root)
|
||||
assert len(issues) == 1
|
||||
assert "id" in issues[0].message
|
||||
|
||||
|
||||
def test_required_attrs_transition_missing_all():
|
||||
"""Transition missing name, offset, duration → 3 issues."""
|
||||
root = ET.fromstring('<fcpxml><transition/></fcpxml>')
|
||||
issues = _check_required_attributes(root)
|
||||
assert len(issues) == 3
|
||||
|
||||
|
||||
# ── _check_timebases ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_timebases_standard_ok():
|
||||
"""100/2400s simplifies to 1/24, 2400/2400s to 1/1 — both standard."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip offset="100/2400s" duration="2400/2400s"/></fcpxml>'
|
||||
)
|
||||
assert _check_timebases(root) == []
|
||||
|
||||
|
||||
def test_timebases_flags_nonstandard():
|
||||
"""Denominator 7 is not a standard FCPXML timebase."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip offset="3/7s" name="Weird"/></fcpxml>'
|
||||
)
|
||||
issues = _check_timebases(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.INVALID_TIMEBASE
|
||||
assert "7" in issues[0].message
|
||||
|
||||
|
||||
def test_timebases_deduplicates():
|
||||
"""Same non-standard value on same element+attr only reported once."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml>'
|
||||
'<clip offset="3/7s" name="A"/>'
|
||||
'<clip offset="3/7s" name="B"/>'
|
||||
'</fcpxml>'
|
||||
)
|
||||
issues = _check_timebases(root)
|
||||
# Both have same (clip, offset, 3/7s) key — only 1 issue
|
||||
assert len(issues) == 1
|
||||
|
||||
|
||||
# ── _check_frame_alignment ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_frame_alignment_exact():
|
||||
"""100 frames at 24fps = 100/24s — frame-aligned."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip duration="100/24s" name="Aligned"/></fcpxml>'
|
||||
)
|
||||
assert _check_frame_alignment(root, fps=24.0) == []
|
||||
|
||||
|
||||
def test_frame_alignment_misaligned():
|
||||
"""7/24s = 7 frames — aligned. But 7/48s = 3.5 frames at 24fps — misaligned."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip duration="7/48s" name="Half Frame"/></fcpxml>'
|
||||
)
|
||||
issues = _check_frame_alignment(root, fps=24.0)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.FRAME_MISALIGNMENT
|
||||
|
||||
|
||||
def test_frame_alignment_ignores_non_clip_elements():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><format duration="7/48s"/></fcpxml>'
|
||||
)
|
||||
assert _check_frame_alignment(root, fps=24.0) == []
|
||||
|
||||
|
||||
# ── _check_effect_refs ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_effect_refs_valid():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources><effect id="e1"/></resources>'
|
||||
'<clip><filter-video ref="e1"/></clip></fcpxml>'
|
||||
)
|
||||
assert _check_effect_refs(root) == []
|
||||
|
||||
|
||||
def test_effect_refs_dangling():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip><filter-video ref="missing_effect"/></clip></fcpxml>'
|
||||
)
|
||||
issues = _check_effect_refs(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.MISSING_EFFECT_REF
|
||||
assert "missing_effect" in issues[0].message
|
||||
|
||||
|
||||
# ── _check_asset_sources ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_asset_with_src():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources>'
|
||||
'<asset id="r1" src="file:///video.mov" name="V"/>'
|
||||
'</resources></fcpxml>'
|
||||
)
|
||||
assert _check_asset_sources(root) == []
|
||||
|
||||
|
||||
def test_asset_with_media_rep():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources>'
|
||||
'<asset id="r1" name="V"><media-rep/></asset>'
|
||||
'</resources></fcpxml>'
|
||||
)
|
||||
assert _check_asset_sources(root) == []
|
||||
|
||||
|
||||
def test_asset_missing_both():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources>'
|
||||
'<asset id="r1" name="Orphan"/>'
|
||||
'</resources></fcpxml>'
|
||||
)
|
||||
issues = _check_asset_sources(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.MISSING_MEDIA_REP
|
||||
|
||||
|
||||
# ── _enforce_standard_timebases ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_enforce_timebases_snaps_nonstandard():
|
||||
root = ET.fromstring('<fcpxml><clip offset="3/7s"/></fcpxml>')
|
||||
_enforce_standard_timebases(root)
|
||||
clip = root.find("clip")
|
||||
val = clip.get("offset")
|
||||
assert "/" in val and val.endswith("s")
|
||||
# Should be snapped to a standard timebase denominator
|
||||
denom = int(val.rstrip("s").split("/")[1])
|
||||
assert denom in {1, 24, 25, 30, 48, 50, 60, 90, 96, 100, 120, 240, 600, 2400}
|
||||
|
||||
|
||||
def test_enforce_timebases_leaves_standard_alone():
|
||||
root = ET.fromstring('<fcpxml><clip offset="3600/2400s"/></fcpxml>')
|
||||
_enforce_standard_timebases(root)
|
||||
assert root.find("clip").get("offset") == "3600/2400s"
|
||||
|
||||
|
||||
def test_enforce_timebases_handles_unparseable():
|
||||
"""Malformed time values are silently skipped, not crashed on."""
|
||||
root = ET.fromstring('<fcpxml><clip offset="garbage/0s"/></fcpxml>')
|
||||
_enforce_standard_timebases(root) # Should not raise
|
||||
assert root.find("clip").get("offset") == "garbage/0s"
|
||||
|
||||
|
||||
# ── _sanitize_xml_value edge cases ──────────────────────────────────
|
||||
|
||||
|
||||
def test_sanitize_strips_null_bytes():
|
||||
assert _sanitize_xml_value("he\x00llo") == "hello"
|
||||
|
||||
|
||||
def test_sanitize_preserves_tabs_newlines():
|
||||
assert _sanitize_xml_value("line1\tline2\n") == "line1\tline2\n"
|
||||
|
||||
|
||||
def test_sanitize_enforces_max_length():
|
||||
result = _sanitize_xml_value("x" * 500, max_length=100)
|
||||
assert len(result) == 100
|
||||
|
||||
|
||||
def test_sanitize_coerces_non_string():
|
||||
assert _sanitize_xml_value(42) == "42"
|
||||
|
||||
|
||||
# ── validate_fcpxml orchestration ───────────────────────────────────
|
||||
|
||||
|
||||
def test_validate_fcpxml_aggregates_all_checks():
|
||||
"""Intentionally broken XML triggers multiple validator categories."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml>'
|
||||
'<resources><asset id="r1" name="No Source"/></resources>'
|
||||
'<asset-clip ref="r1" duration="7/48s" name="Clip">'
|
||||
'<filter-video ref="ghost"/><marker value="x"/>'
|
||||
'</asset-clip>'
|
||||
'</fcpxml>'
|
||||
)
|
||||
issues = validate_fcpxml(root, fps=24.0)
|
||||
types = {i.issue_type for i in issues}
|
||||
# Should catch: ordering (filter before marker), missing media rep,
|
||||
# dangling effect ref, frame misalignment
|
||||
assert ValidationIssueType.ELEMENT_ORDER in types
|
||||
assert ValidationIssueType.MISSING_MEDIA_REP in types
|
||||
assert ValidationIssueType.MISSING_EFFECT_REF in types
|
||||
assert ValidationIssueType.FRAME_MISALIGNMENT in types
|
||||
|
||||
|
||||
def test_validate_fcpxml_clean_returns_empty():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml>'
|
||||
'<resources><effect id="e1"/>'
|
||||
'<asset id="r1" src="file:///v.mov"/></resources>'
|
||||
'<asset-clip ref="r1" duration="100/2400s" name="OK">'
|
||||
'<marker value="ch1"/><filter-video ref="e1"/>'
|
||||
'</asset-clip>'
|
||||
'</fcpxml>'
|
||||
)
|
||||
assert validate_fcpxml(root) == []
|
||||
Reference in New Issue
Block a user