"""Tests for FCPXML data models — TimeValue, Timecode, Timeline, and helpers. Covers the core time math and model properties that underpin all 34 tools. """ import pytest from fcpxml.models import ( Clip, FlashFrame, FlashFrameSeverity, GapInfo, Keyword, Marker, MarkerType, PacingConfig, Project, Timecode, Timeline, TimeValue, ValidationIssue, ValidationIssueType, ValidationResult, ) class TestTimeValueCreation: """Test TimeValue construction from various input formats.""" def test_from_rational_string(self): tv = TimeValue.from_timecode("90/30s") assert tv.numerator == 90 and tv.denominator == 30 def test_from_seconds_string(self): assert TimeValue.from_timecode("5s", fps=24.0).to_seconds() == pytest.approx(5.0, abs=0.01) def test_from_seconds_float(self): assert TimeValue.from_seconds(2.5, fps=24.0).to_seconds() == pytest.approx(2.5, abs=0.05) def test_from_timecode_hhmmssff(self): assert TimeValue.from_timecode("00:01:30:15", fps=30.0).to_seconds() == pytest.approx(90.5, abs=0.05) def test_from_timecode_hhmmss(self): assert TimeValue.from_timecode("00:02:00", fps=24.0).to_seconds() == pytest.approx(120.0, abs=0.05) def test_from_frames_string(self): assert TimeValue.from_timecode("48f", fps=24.0).to_seconds() == pytest.approx(2.0, abs=0.05) def test_from_plain_number(self): assert TimeValue.from_timecode("3.5", fps=24.0).to_seconds() == pytest.approx(3.5, abs=0.05) def test_from_empty_string(self): assert TimeValue.from_timecode("") == TimeValue.zero() def test_zero(self): tv = TimeValue.zero() assert tv.numerator == 0 and tv.to_seconds() == 0.0 def test_invalid_format_raises(self): with pytest.raises(ValueError, match="Invalid timecode format"): TimeValue.from_timecode("not_a_timecode") def test_drop_frame_separator(self): assert TimeValue.from_timecode("00:00:10;15", fps=30.0).to_seconds() == pytest.approx(10.5, abs=0.05) def test_from_seconds_string_ntsc_rate_exact(self): """23.976fps (24000/1001): int(fps) truncation used to corrupt the value by ~1.04x (317.99s -> 331.48s) because the numerator was computed with the real fps but the denominator was hardcoded to int(fps)=23. The reconstructed value must round-trip exactly.""" fps = 24000 / 1001 tv = TimeValue.from_timecode("317.9857083333334s", fps=fps) # Half a frame (~0.0417s) of quantization slack is expected/correct; # the bug inflated the value by ~1.04x (to ~331.48s), which this # tolerance would never mask. assert tv.to_seconds() == pytest.approx(317.9857083333334, abs=0.03) assert tv.denominator == 24000 class TestTimeValueConversions: def test_to_fcpxml(self): assert TimeValue(3, 1).to_fcpxml() == "3s" assert TimeValue(90, 30).to_fcpxml() == "3s" # simplifies assert TimeValue(75, 30).to_fcpxml() == "75/30s" # keeps standard timebase denom def test_to_seconds(self): assert TimeValue(72, 24).to_seconds() == pytest.approx(3.0) with pytest.raises(ValueError, match="denominator cannot be zero"): TimeValue(5, 0) # zero denominator rejected at construction def test_to_timecode(self): assert TimeValue(2700, 30).to_timecode(fps=30.0) == "00:01:30:00" assert TimeValue(2715, 30).to_timecode(fps=30.0) == "00:01:30:15" def test_to_frames(self): assert TimeValue(3, 1).to_frames(fps=24.0) == 72 class TestTimeValueArithmetic: def test_add(self): assert (TimeValue(72, 24) + TimeValue(48, 24)).to_seconds() == pytest.approx(5.0) def test_sub(self): assert (TimeValue(120, 24) - TimeValue(72, 24)).to_seconds() == pytest.approx(2.0) def test_add_different_denominators(self): assert (TimeValue(72, 24) + TimeValue(150, 30)).to_seconds() == pytest.approx(8.0) def test_mul(self): assert (TimeValue(72, 24) * 2).to_seconds() == pytest.approx(6.0) def test_div(self): assert (TimeValue(72, 24) / 3).to_seconds() == pytest.approx(1.0) def test_simplify(self): s = TimeValue(120, 48).simplify() assert s.numerator == 5 and s.denominator == 2 def test_simplify_zero(self): s = TimeValue(0, 48).simplify() assert s.numerator == 0 and s.denominator == 1 class TestTimeValueComparisons: def test_ordering(self): small, big = TimeValue(48, 24), TimeValue(72, 24) assert small < big and small <= big assert big > small and big >= small def test_equality(self): assert TimeValue(72, 24) == TimeValue(72, 24) assert TimeValue(3, 1) == TimeValue(72, 24) # cross-denominator assert TimeValue(72, 24) != "not a timevalue" def test_repr(self): r = repr(TimeValue(72, 24)) assert "72" in r and "24" in r and "3.000s" in r class TestTimeline: @staticmethod def _make(durations, fps=24.0): clips, offset = [], 0.0 for i, dur in enumerate(durations): clips.append(Clip( name=f"clip_{i}", start=Timecode(frames=int(offset * fps), frame_rate=fps), duration=Timecode(frames=int(dur * fps), frame_rate=fps), )) offset += dur return Timeline( name="test", duration=Timecode(frames=int(offset * fps), frame_rate=fps), frame_rate=fps, clips=clips, ) def test_total_clips(self): assert self._make([2, 3, 1]).total_clips == 3 def test_total_cuts(self): assert self._make([2, 3, 1]).total_cuts == 2 assert self._make([]).total_cuts == 0 def test_avg_duration(self): assert self._make([2, 4, 6]).average_clip_duration == pytest.approx(4.0, abs=0.1) assert self._make([]).average_clip_duration == 0.0 def test_cuts_per_minute(self): assert self._make([2, 2, 2]).cuts_per_minute == pytest.approx(20.0, abs=0.5) def test_shorter_than(self): assert len(self._make([0.2, 5, 0.3, 2]).get_clips_shorter_than(0.5)) == 2 def test_longer_than(self): assert len(self._make([1, 10, 2, 15]).get_clips_longer_than(5)) == 2 def test_clip_at(self): clip = self._make([2, 3, 1]).get_clip_at(3.5) assert clip is not None and clip.name == "clip_1" assert self._make([2, 3]).get_clip_at(999) is None def test_by_keyword(self): tl = self._make([2, 3]) tl.clips[0].keywords = [Keyword(value="interview")] assert len(tl.get_clips_by_keyword("interview")) == 1 class TestProject: def test_primary_timeline(self): tl = Timeline(name="main", duration=Timecode(frames=0, frame_rate=24.0)) assert Project(name="test", timelines=[tl]).primary_timeline is tl def test_primary_timeline_empty(self): assert Project(name="empty").primary_timeline is None class TestMarker: def test_youtube_timestamp_minutes(self): m = Marker(name="Ch", start=Timecode(frames=2160, frame_rate=24.0)) assert m.to_youtube_timestamp() == "1:30" def test_youtube_timestamp_hours(self): m = Marker(name="Act", start=Timecode(frames=24 * 3700, frame_rate=24.0)) assert m.to_youtube_timestamp().count(":") == 2 class TestTimecode: """Test Timecode — the frame-based time representation used by the parser.""" def test_from_rational_fraction(self): tc = Timecode.from_rational("3600/24s", frame_rate=24.0) assert tc.frames == 3600 assert tc.seconds == pytest.approx(150.0) def test_from_rational_whole_seconds(self): tc = Timecode.from_rational("5s", frame_rate=30.0) assert tc.frames == 150 assert tc.seconds == pytest.approx(5.0) def test_from_rational_plain_number(self): tc = Timecode.from_rational("2.5", frame_rate=24.0) assert tc.frames == 60 assert tc.seconds == pytest.approx(2.5) def test_from_rational_empty_string(self): tc = Timecode.from_rational("", frame_rate=24.0) assert tc.frames == 0 def test_to_smpte_basic(self): tc = Timecode(frames=2700, frame_rate=30.0) assert tc.to_smpte() == "00:01:30:00" def test_to_smpte_with_frames(self): tc = Timecode(frames=2715, frame_rate=30.0) assert tc.to_smpte() == "00:01:30:15" def test_to_smpte_drop_frame_separator(self): tc = Timecode(frames=48, frame_rate=24.0, drop_frame=True) assert ";" in tc.to_smpte() def test_to_smpte_hours(self): tc = Timecode(frames=24 * 3661, frame_rate=24.0) smpte = tc.to_smpte() assert smpte.startswith("01:01:01") def test_to_rational(self): tc = Timecode(frames=72, frame_rate=24.0) assert tc.to_rational() == "72/24s" def test_to_time_value(self): tc = Timecode(frames=72, frame_rate=24.0) tv = tc.to_time_value() assert tv.numerator == 72 assert tv.denominator == 24 assert tv.to_seconds() == pytest.approx(3.0) def test_total_frames_alias(self): tc = Timecode(frames=100, frame_rate=30.0) assert tc.total_frames == 100 class TestClipProperties: """Test Clip computed properties.""" def test_end_timecode(self): clip = Clip( name="test", start=Timecode(frames=48, frame_rate=24.0), duration=Timecode(frames=72, frame_rate=24.0), ) assert clip.end.frames == 120 assert clip.end.frame_rate == 24.0 def test_duration_seconds(self): clip = Clip( name="test", start=Timecode(frames=0, frame_rate=24.0), duration=Timecode(frames=72, frame_rate=24.0), ) assert clip.duration_seconds == pytest.approx(3.0) def test_keyword_values(self): clip = Clip( name="test", start=Timecode(frames=0, frame_rate=24.0), duration=Timecode(frames=24, frame_rate=24.0), keywords=[Keyword(value="interview"), Keyword(value="broll")], ) assert clip.keyword_values == ["interview", "broll"] def test_keyword_values_empty(self): clip = Clip( name="test", start=Timecode(frames=0, frame_rate=24.0), duration=Timecode(frames=24, frame_rate=24.0), ) assert clip.keyword_values == [] class TestFlashFrame: def test_is_critical(self): ff = FlashFrame( clip_name="bad_clip", clip_id="c1", start=Timecode(frames=0, frame_rate=24.0), duration_frames=1, duration_seconds=0.04, severity=FlashFrameSeverity.CRITICAL, ) assert ff.is_critical is True def test_is_not_critical(self): ff = FlashFrame( clip_name="short_clip", clip_id="c2", start=Timecode(frames=0, frame_rate=24.0), duration_frames=4, duration_seconds=0.17, severity=FlashFrameSeverity.WARNING, ) assert ff.is_critical is False class TestGapInfo: def test_timecode_property(self): gap = GapInfo( start=Timecode(frames=2700, frame_rate=30.0), duration_frames=30, duration_seconds=1.0, previous_clip="Clip_A", next_clip="Clip_B", ) assert gap.timecode == "00:01:30:00" class TestValidationResult: def test_summary_format(self): result = ValidationResult( is_valid=False, health_score=72, issues=[ ValidationIssue(issue_type=ValidationIssueType.FLASH_FRAME, severity="error", message="e"), ValidationIssue(issue_type=ValidationIssueType.GAP, severity="warning", message="w"), ], flash_frames=[ FlashFrame("c", "c1", Timecode(0, 24.0), 1, 0.04, FlashFrameSeverity.CRITICAL), ], gaps=[ GapInfo(Timecode(0, 24.0), 10, 0.4), ], ) s = result.summary() assert "72%" in s assert "Errors: 1" in s assert "Warnings: 1" in s assert "Flash frames: 1" in s assert "Gaps: 1" in s class TestMarkerType: @pytest.mark.parametrize("value,expected", [ ("todo", MarkerType.INCOMPLETE), # enum value, not an action item ("TODO", MarkerType.INCOMPLETE), # enum value, not an action item # noqa ("completed", MarkerType.COMPLETED), ("COMPLETED", MarkerType.COMPLETED), ("standard", MarkerType.STANDARD), ("chapter", MarkerType.CHAPTER), ]) def test_from_string_current_values(self, value, expected): assert MarkerType.from_string(value) == expected @pytest.mark.parametrize("alias,expected", [ ("todo-marker", MarkerType.INCOMPLETE), ("completed-marker", MarkerType.COMPLETED), ("chapter-marker", MarkerType.CHAPTER), ]) def test_from_string_legacy_aliases(self, alias, expected): """Legacy spec values (e.g. 'todo-marker') resolve to current enum.""" assert MarkerType.from_string(alias) == expected @pytest.mark.parametrize("value,expected", [ ("Todo", MarkerType.INCOMPLETE), ("tOdO", MarkerType.INCOMPLETE), ("Completed", MarkerType.COMPLETED), ("cOMPLETED", MarkerType.COMPLETED), ("Standard", MarkerType.STANDARD), ("CHAPTER", MarkerType.CHAPTER), ]) def test_from_string_mixed_case(self, value, expected): """Case should never matter — FCP specs are inconsistent about casing.""" assert MarkerType.from_string(value) == expected @pytest.mark.parametrize("alias,expected", [ (" todo-marker ", MarkerType.INCOMPLETE), (" completed-marker ", MarkerType.COMPLETED), ("\tchapter-marker\t", MarkerType.CHAPTER), ]) def test_from_string_legacy_aliases_with_whitespace(self, alias, expected): """Whitespace around legacy aliases must be stripped before matching.""" assert MarkerType.from_string(alias) == expected def test_enum_values_are_lowercase(self): """Enum .value must be lowercase strings — they're used as dict keys.""" assert MarkerType.INCOMPLETE.value == "todo" assert MarkerType.COMPLETED.value == "completed" assert MarkerType.STANDARD.value == "standard" assert MarkerType.CHAPTER.value == "chapter" def test_from_string_invalid_raises(self): with pytest.raises(ValueError, match="Invalid marker type"): MarkerType.from_string("nonexistent") def test_xml_tag_chapter_vs_marker(self): assert MarkerType.CHAPTER.xml_tag == "chapter-marker" assert MarkerType.INCOMPLETE.xml_tag == "marker" assert MarkerType.COMPLETED.xml_tag == "marker" assert MarkerType.STANDARD.xml_tag == "marker" class TestMarkerTypeXmlContract: """Tests for MarkerType.from_xml_element and xml_attrs — the unified serialization contract that both parser and writer depend on.""" def test_from_xml_element_chapter(self): import xml.etree.ElementTree as ET elem = ET.Element('chapter-marker') assert MarkerType.from_xml_element(elem) == MarkerType.CHAPTER def test_from_xml_element_todo(self): import xml.etree.ElementTree as ET elem = ET.Element('marker') elem.set('completed', '0') assert MarkerType.from_xml_element(elem) == MarkerType.INCOMPLETE def test_from_xml_element_completed(self): import xml.etree.ElementTree as ET elem = ET.Element('marker') elem.set('completed', '1') assert MarkerType.from_xml_element(elem) == MarkerType.COMPLETED def test_from_xml_element_standard(self): import xml.etree.ElementTree as ET elem = ET.Element('marker') assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD def test_from_xml_element_rejects_malformed_completed(self): """Non-standard completed values (e.g. 'true') must fall to STANDARD.""" import xml.etree.ElementTree as ET elem = ET.Element('marker') elem.set('completed', 'true') assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD def test_from_xml_element_whitespace_padded_zero_is_standard(self): """Whitespace around '0' must NOT match INCOMPLETE — strict exact matching.""" import xml.etree.ElementTree as ET elem = ET.Element('marker') elem.set('completed', ' 0 ') assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD def test_from_xml_element_whitespace_padded_one_is_standard(self): """Whitespace around '1' must NOT match COMPLETED — strict exact matching.""" import xml.etree.ElementTree as ET elem = ET.Element('marker') elem.set('completed', ' 1 ') assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD def test_from_xml_element_empty_completed_is_standard(self): """An empty completed='' attribute must fall to STANDARD, not INCOMPLETE.""" import xml.etree.ElementTree as ET elem = ET.Element('marker') elem.set('completed', '') assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD def test_from_xml_element_chapter_ignores_completed(self): """A chapter-marker tag takes priority over any completed attribute.""" import xml.etree.ElementTree as ET elem = ET.Element('chapter-marker') elem.set('completed', '0') assert MarkerType.from_xml_element(elem) == MarkerType.CHAPTER def test_xml_attrs_todo(self): assert MarkerType.INCOMPLETE.xml_attrs == {'completed': '0'} def test_xml_attrs_completed(self): assert MarkerType.COMPLETED.xml_attrs == {'completed': '1'} def test_xml_attrs_chapter(self): assert MarkerType.CHAPTER.xml_attrs == {'posterOffset': '0s'} def test_xml_attrs_standard_empty(self): assert MarkerType.STANDARD.xml_attrs == {} def test_roundtrip_symmetry(self): """from_xml_element(write(type)) == type for all marker types.""" import xml.etree.ElementTree as ET for mt in MarkerType: elem = ET.Element(mt.xml_tag) for attr, val in mt.xml_attrs.items(): elem.set(attr, val) assert MarkerType.from_xml_element(elem) == mt class TestTimeValueSnapToFrame: """Tests for TimeValue.snap_to_frame — 2400-tick frame boundary snapping.""" def test_snap_exact_frame_is_noop(self): """A value already on a frame boundary stays unchanged.""" tv = TimeValue(100, 2400) # exactly 1 frame at 24fps (2400/24=100 ticks) snapped = tv.snap_to_frame(24) assert snapped.to_seconds() == pytest.approx(tv.to_seconds(), abs=1e-6) def test_snap_between_frames_rounds_nearest(self): """A value between frames snaps to the nearest one.""" # 1.5 frames at 24fps = 150 ticks. Should snap to 100 or 200. tv = TimeValue(150, 2400) snapped = tv.snap_to_frame(24) assert snapped.denominator == 2400 assert snapped.numerator in (100, 200) def test_snap_30fps_frame_boundaries(self): """30fps: each frame = 80 ticks. 85 ticks should snap to 80.""" tv = TimeValue(85, 2400) snapped = tv.snap_to_frame(30) assert snapped.numerator == 80 assert snapped.denominator == 2400 def test_snap_preserves_zero(self): tv = TimeValue(0, 24).snap_to_frame(24) assert tv.to_seconds() == 0.0 def test_snap_large_value_stays_aligned(self): """10 seconds at 24fps should remain exactly 10 seconds.""" tv = TimeValue(240, 24) # 10s snapped = tv.snap_to_frame(24) assert snapped.to_seconds() == pytest.approx(10.0, abs=1e-6) class TestTimeValueStandardTimebase: """Tests for TimeValue.is_standard_timebase — FCP DTD denominator checks.""" @pytest.mark.parametrize("denom", [1, 24, 30, 60, 2400]) def test_standard_denominators_accepted(self, denom): assert TimeValue(denom, denom).is_standard_timebase() is True def test_non_standard_denominator_rejected(self): """Denominators like 7 are not in any FCP timebase.""" assert TimeValue(7, 7).is_standard_timebase() is True # simplifies to 1/1 assert TimeValue(3, 7).is_standard_timebase() is False def test_simplification_reveals_standard(self): """72/24 simplifies to 3/1 — denominator 1 is standard.""" assert TimeValue(72, 24).is_standard_timebase() is True def test_simplification_reveals_non_standard(self): """15/7 doesn't simplify to a standard timebase.""" assert TimeValue(15, 7).is_standard_timebase() is False class TestTimeValueToFcpxmlEdgeCases: """Tests for to_fcpxml fallback paths that prevent FCP DTD rejection.""" def test_whole_seconds_simplify(self): """72/24 = 3 whole seconds → '3s'.""" assert TimeValue(72, 24).to_fcpxml() == "3s" def test_standard_timebase_simplifies(self): """75/30 simplifies to 5/2 — but 2 is not standard, so stays '75/30s'.""" result = TimeValue(75, 30).to_fcpxml() # Should NOT produce "5/2s" since 2 isn't a standard timebase assert "/2s" not in result or "75/30s" == result def test_non_standard_denom_falls_back_to_original(self): """If simplification yields a non-standard denom, keep the original.""" # 7 ticks at denom 21 → simplifies to 1/3. 3 is non-standard. # Should fall back to "7/21s" tv = TimeValue(7, 21) result = tv.to_fcpxml() assert result == "7/21s" def test_zero_is_always_zero_seconds(self): assert TimeValue(0, 2400).to_fcpxml() == "0s" class TestTimeValueArithmeticEdgeCases: """Edge cases in TimeValue arithmetic that could produce incorrect frame values.""" def test_sub_negative_result(self): """Subtraction producing negative time — used for offset calculations.""" result = TimeValue(24, 24) - TimeValue(72, 24) assert result.to_seconds() == pytest.approx(-2.0) def test_mul_by_zero(self): assert (TimeValue(72, 24) * 0).to_seconds() == 0.0 def test_div_preserves_value(self): """Division should maintain the time value, not produce rounding drift.""" tv = TimeValue(72, 24) # 3 seconds halved = tv / 2 assert halved.to_seconds() == pytest.approx(1.5, abs=1e-6) def test_div_fractional_scalar_rounds_not_truncates(self): """Division by fractional scalar must round denominator, not truncate. int(2400 * 0.3333...) = 799 (truncation bug), round() = 800 (correct). This was the root cause of silent time drift in speed-change operations. """ tv = TimeValue(100, 2400) result = tv / (1 / 3) # divide by 0.333... → multiply duration by 3 assert result.denominator == 800 # round(2400 * 0.333...) = 800 assert result.to_seconds() == pytest.approx(0.125, abs=1e-6) def test_div_by_zero_raises_not_corrupts(self): """Division by zero must raise, not create zombie TimeValue(n, 0).""" tv = TimeValue(100, 2400) with pytest.raises(ZeroDivisionError): tv / 0 def test_div_rounding_to_zero_raises(self): """Small scalars that round denominator to zero must raise, not corrupt.""" tv = TimeValue(1, 1) with pytest.raises(ZeroDivisionError, match="rounds denominator"): tv / 0.3 # round(1 * 0.3) = 0 def test_div_mul_roundtrip(self): """Multiply then divide should approximately restore original value.""" tv = TimeValue(72, 24) # 3 seconds scaled = tv * 2.5 restored = scaled / 2.5 assert restored.to_seconds() == pytest.approx(tv.to_seconds(), abs=0.01) def test_add_mismatched_timebases_lcm(self): """24fps + 30fps values should use LCM denominator, not multiply.""" a = TimeValue(24, 24) # 1 second b = TimeValue(30, 30) # 1 second result = a + b assert result.to_seconds() == pytest.approx(2.0) # LCM(24,30)=120, not 24*30=720 assert result.denominator <= 720 # at minimum, shouldn't explode def test_equality_across_timebases(self): """1 second expressed in different timebases must be equal.""" assert TimeValue(24, 24) == TimeValue(30, 30) == TimeValue(2400, 2400) def test_inequality_near_boundary(self): """Integer-exact comparison: different rational values are never equal.""" a = TimeValue(24000, 24000) # exactly 1s b = TimeValue(24001, 24000) # 1.0000417s # Cross-multiplication gives exact comparison — no epsilon assert a != b # genuinely different values def test_mul_fractional_scalar_rounds_correctly(self): """Multiplying by 0.5 must round, not truncate — avoids half-tick loss.""" # round(101 * 0.5) = 50 vs int(101 * 0.5) = 50 — same here, but # round is correct for odd numerators like 5 * 1.5: tv = TimeValue(5, 24) result = tv * 1.5 # round(7.5) = 8, int(7.5) = 7 — round gives closer-to-correct result assert result.numerator == 8 assert result.to_seconds() == pytest.approx(8 / 24, abs=1e-6) def test_hash_equal_values_same_hash(self): """Equal TimeValues must produce the same hash (dict/set contract).""" a = TimeValue(24, 24) b = TimeValue(30, 30) assert a == b assert hash(a) == hash(b) def test_hash_usable_in_set(self): """TimeValues must be usable as set elements.""" s = {TimeValue(24, 24), TimeValue(30, 30), TimeValue(72, 24)} # 24/24 == 30/30 (both 1s), so set should have 2 elements assert len(s) == 2 def test_hash_usable_as_dict_key(self): """TimeValues must be usable as dict keys.""" d = {TimeValue(24, 24): "one_second"} assert d[TimeValue(30, 30)] == "one_second" class TestMarkerTypeAliasSemantics: """MarkerType enum alias: TODO → INCOMPLETE (Python enum identity, not action items).""" def test_todo_alias_resolves_to_incomplete(self): """All alias properties match — identity, value, xml_tag, xml_attrs.""" assert MarkerType.INCOMPLETE is MarkerType.TODO # enum alias check # noqa assert MarkerType.INCOMPLETE.value == "todo" assert MarkerType.INCOMPLETE.xml_tag == "marker" assert MarkerType.INCOMPLETE.xml_attrs == {'completed': '0'} def test_from_string_returns_canonical(self): """from_string('todo') returns the canonical INCOMPLETE member.""" assert MarkerType.from_string("todo") is MarkerType.INCOMPLETE def test_from_xml_element_numeric_completed_values(self): """Only exact '0' and '1' are recognised — '2', '-1', '00' are STANDARD.""" import xml.etree.ElementTree as ET for bad_val in ('2', '-1', '00', '01', '10'): elem = ET.Element('marker') elem.set('completed', bad_val) assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD, ( f"completed='{bad_val}' should be STANDARD, not a task marker" ) class TestTimecodeEdgeCases: """Edge cases in Timecode that could cause silent bugs in the parser.""" def test_zero_frames_smpte(self): assert Timecode(frames=0, frame_rate=24.0).to_smpte() == "00:00:00:00" def test_one_frame_smpte(self): assert Timecode(frames=1, frame_rate=24.0).to_smpte() == "00:00:00:01" def test_exactly_one_hour(self): tc = Timecode(frames=24 * 3600, frame_rate=24.0) assert tc.to_smpte() == "01:00:00:00" assert tc.seconds == pytest.approx(3600.0) def test_to_time_value_roundtrip(self): """Timecode → TimeValue → seconds should be lossless.""" tc = Timecode(frames=2715, frame_rate=30.0) tv = tc.to_time_value() assert tv.to_seconds() == pytest.approx(tc.seconds, abs=1e-6) class TestTimeValueNegativeDenominator: """Negative denominators must be normalized to preserve hash/eq/ordering.""" def test_negative_denom_normalized_at_construction(self): tv = TimeValue(1, -2) assert tv.numerator == -1 assert tv.denominator == 2 def test_negative_denom_both_negative(self): tv = TimeValue(-3, -4) assert tv.numerator == 3 assert tv.denominator == 4 def test_hash_contract_negative_denom(self): """Equal values must produce identical hashes (Python data model).""" a = TimeValue(-1, 1) b = TimeValue(1, -1) assert a == b assert hash(a) == hash(b) def test_set_dedup_negative_denom(self): s = {TimeValue(-1, 1), TimeValue(1, -1)} assert len(s) == 1 def test_lt_negative_denom(self): assert TimeValue(1, -2) < TimeValue(1, 2) # -0.5 < 0.5 assert not (TimeValue(1, 2) < TimeValue(1, -2)) def test_division_by_negative_normalizes(self): tv = TimeValue(100, 2400) / -2.0 assert tv.denominator > 0 assert tv.to_seconds() == pytest.approx(-100 / 4800, abs=1e-9) def test_to_fcpxml_negative_denom(self): """Normalized negative should serialize cleanly.""" tv = TimeValue(1, -2400) assert tv.to_fcpxml() == "-1/2400s" class TestTimeValueCrossMultiplicationEdgeCases: """Verify integer-exact comparison survives edge cases that broke float-based comparison.""" def test_eq_non_timevalue_returns_false(self): """__eq__ with non-TimeValue must return False, not raise.""" assert TimeValue(1, 1) != 1 assert TimeValue(1, 1) != 1.0 assert TimeValue(1, 1) != "1/1s" assert TimeValue(0, 1) != None # noqa: E711 def test_total_ordering_le_gt_ge(self): """@total_ordering derives __le__/__gt__/__ge__ from __lt__+__eq__. If the decorator were removed, these would raise TypeError.""" a, b = TimeValue(1, 3), TimeValue(2, 3) assert a <= b assert a <= TimeValue(1, 3) # equal case assert b > a assert b >= a assert b >= TimeValue(2, 3) # equal case def test_large_numerator_cross_multiply(self): """Python arbitrary precision means cross-multiplication never overflows, but verify the comparison is actually correct with big numbers.""" big = TimeValue(2**60, 1) small = TimeValue(1, 2**60) assert small < big assert big > small assert big != small def test_hash_contract_different_denominators(self): """Equivalent fractions must hash identically for dict/set correctness.""" a = TimeValue(100, 2400) b = TimeValue(50, 1200) c = TimeValue(1, 24) assert a == b == c assert hash(a) == hash(b) == hash(c) assert len({a, b, c}) == 1 def test_zero_numerator_negative_denom(self): """Zero with negative denominator normalizes cleanly.""" tv = TimeValue(0, -1) assert tv.numerator == 0 assert tv.denominator == 1 assert tv == TimeValue(0, 1) assert hash(tv) == hash(TimeValue(0, 1)) def test_comparison_transitivity(self): """If a < b and b < c then a < c — cross-multiplication must be transitive.""" a = TimeValue(1, 7) b = TimeValue(1, 5) c = TimeValue(1, 3) assert a < b < c assert a < c # transitive def test_sorted_rational_sequence(self): """Sort a sequence of TimeValues and verify order matches float values.""" vals = [TimeValue(n, 24) for n in [72, 12, 48, 24, 36]] sorted_vals = sorted(vals) sorted_seconds = [v.to_seconds() for v in sorted_vals] assert sorted_seconds == sorted(sorted_seconds) def test_simplify_preserves_positive_denom(self): """After simplify(), denominator must remain positive (regression guard).""" tv = TimeValue(-600, 2400).simplify() assert tv.denominator > 0 assert tv.to_seconds() == pytest.approx(-0.25, abs=1e-9) class TestPacingConfig: def test_pacing_ranges(self): assert PacingConfig(pacing="slow").get_duration_range() == (5.0, 10.0) assert PacingConfig(pacing="fast").get_duration_range() == (0.5, 2.0) assert PacingConfig(pacing="unknown").get_duration_range() == (2.0, 5.0)