Files
gart/code/tests/test_voice_actions.py

228 lines
8.6 KiB
Python

"""Tests for fcpxml/voice_actions.py — the decision contract.
Pure functions over untrusted input (a model's decision list), so these
cover the rejection paths as carefully as the happy path.
"""
import pytest
from fcpxml.voice_actions import (
MAX_TEXT_LENGTH,
VoiceAction,
merge_cut_ranges,
parse_actions,
resolve_actions,
shift_after_cuts,
)
class TestParseActions:
def test_accepts_bare_list(self):
actions, errors = parse_actions([{"kind": "cut", "start": 1.0, "end": 2.0}])
assert len(actions) == 1 and errors == []
def test_accepts_actions_envelope(self):
actions, errors = parse_actions({"actions": [{"kind": "cut", "start": 1.0, "end": 2.0}]})
assert len(actions) == 1 and errors == []
def test_rejects_non_list(self):
actions, errors = parse_actions("cortar tudo")
assert actions == [] and len(errors) == 1
def test_one_bad_row_does_not_discard_the_good_ones(self):
actions, errors = parse_actions([
{"kind": "cut", "start": 1.0, "end": 2.0},
{"kind": "teleport", "start": 3.0, "end": 4.0},
{"kind": "zoom", "start": 5.0, "end": 6.0},
])
assert len(actions) == 2
assert len(errors) == 1 and "teleport" in errors[0]
def test_rejects_unknown_kind(self):
_, errors = parse_actions([{"kind": "explode", "start": 0.0, "end": 1.0}])
assert "explode" in errors[0]
def test_rejects_non_numeric_times(self):
_, errors = parse_actions([{"kind": "cut", "start": "início", "end": 2.0}])
assert "numbers" in errors[0]
def test_rejects_negative_start(self):
_, errors = parse_actions([{"kind": "cut", "start": -1.0, "end": 2.0}])
assert "negative" in errors[0]
def test_rejects_end_before_start(self):
_, errors = parse_actions([{"kind": "cut", "start": 5.0, "end": 2.0}])
assert "must be after" in errors[0]
def test_rejects_zero_length(self):
_, errors = parse_actions([{"kind": "cut", "start": 2.0, "end": 2.0}])
assert errors
def test_rejects_row_that_is_not_an_object(self):
_, errors = parse_actions(["cortar aos 5s"])
assert "expected an object" in errors[0]
def test_kind_is_case_insensitive(self):
actions, _ = parse_actions([{"kind": "ZOOM", "start": 1.0, "end": 2.0}])
assert actions[0].kind == "zoom"
def test_preserves_reason_and_speaker(self):
actions, _ = parse_actions([
{"kind": "zoom", "start": 1.0, "end": 2.0,
"reason": "argumento central", "speaker": "SPEAKER_01"}
])
assert actions[0].reason == "argumento central"
assert actions[0].speaker == "SPEAKER_01"
class TestZoomValidation:
def test_default_scale_when_absent(self):
actions, _ = parse_actions([{"kind": "zoom", "start": 1.0, "end": 2.0}])
assert actions[0].params["scale"] == 1.3
def test_rejects_scale_below_one(self):
_, errors = parse_actions([
{"kind": "zoom", "start": 1.0, "end": 2.0, "params": {"scale": 0.5}}
])
assert "outside" in errors[0]
def test_rejects_absurd_scale(self):
_, errors = parse_actions([
{"kind": "zoom", "start": 1.0, "end": 2.0, "params": {"scale": 50}}
])
assert "outside" in errors[0]
def test_rejects_non_numeric_scale(self):
_, errors = parse_actions([
{"kind": "zoom", "start": 1.0, "end": 2.0, "params": {"scale": "muito"}}
])
assert "must be a number" in errors[0]
class TestTextValidation:
def test_requires_content(self):
_, errors = parse_actions([{"kind": "text", "start": 1.0, "end": 2.0}])
assert "params.content" in errors[0]
def test_rejects_blank_content(self):
_, errors = parse_actions([
{"kind": "text", "start": 1.0, "end": 2.0, "params": {"content": " "}}
])
assert "params.content" in errors[0]
def test_truncates_overlong_content(self):
actions, _ = parse_actions([
{"kind": "text", "start": 1.0, "end": 2.0, "params": {"content": "A" * 500}}
])
assert len(actions[0].params["content"]) == MAX_TEXT_LENGTH
class TestMergeCutRanges:
def test_sorts_and_merges_overlaps(self):
actions = [
VoiceAction("cut", 5.0, 7.0),
VoiceAction("cut", 1.0, 3.0),
VoiceAction("cut", 2.0, 4.0),
]
assert merge_cut_ranges(actions) == [(1.0, 4.0), (5.0, 7.0)]
def test_merges_touching_ranges(self):
actions = [VoiceAction("cut", 1.0, 2.0), VoiceAction("cut", 2.0, 3.0)]
assert merge_cut_ranges(actions) == [(1.0, 3.0)]
def test_ignores_non_cut_actions(self):
assert merge_cut_ranges([VoiceAction("zoom", 1.0, 2.0)]) == []
class TestShiftAfterCuts:
def test_time_before_any_cut_is_unchanged(self):
assert shift_after_cuts(0.5, [(2.0, 4.0)]) == 0.5
def test_time_after_a_cut_moves_earlier(self):
assert shift_after_cuts(6.0, [(2.0, 4.0)]) == pytest.approx(4.0)
def test_time_inside_a_cut_is_dropped(self):
assert shift_after_cuts(3.0, [(2.0, 4.0)]) is None
def test_multiple_cuts_accumulate(self):
cuts = [(1.0, 2.0), (5.0, 7.0)]
assert shift_after_cuts(10.0, cuts) == pytest.approx(7.0)
def test_no_cuts_is_identity(self):
assert shift_after_cuts(3.0, []) == 3.0
def test_boundary_start_of_cut_is_inside(self):
assert shift_after_cuts(2.0, [(2.0, 4.0)]) is None
def test_boundary_end_of_cut_survives(self):
assert shift_after_cuts(4.0, [(2.0, 4.0)]) == pytest.approx(2.0)
class TestResolveActions:
def test_zoom_after_a_cut_is_moved_earlier(self):
actions = [VoiceAction("cut", 2.0, 4.0), VoiceAction("zoom", 6.0, 7.0)]
cuts, placed, dropped = resolve_actions(actions)
assert cuts == [(2.0, 4.0)]
assert dropped == []
assert placed[0].start == pytest.approx(4.0)
assert placed[0].end == pytest.approx(5.0)
def test_zoom_inside_a_cut_is_dropped_not_slid(self):
actions = [VoiceAction("cut", 2.0, 8.0), VoiceAction("zoom", 3.0, 4.0)]
_, placed, dropped = resolve_actions(actions)
assert placed == []
assert len(dropped) == 1
def test_zoom_straddling_a_cut_edge_is_dropped(self):
actions = [VoiceAction("cut", 4.0, 8.0), VoiceAction("zoom", 3.0, 5.0)]
_, placed, dropped = resolve_actions(actions)
assert placed == [] and len(dropped) == 1
def test_cuts_are_not_returned_as_placed(self):
_, placed, _ = resolve_actions([VoiceAction("cut", 1.0, 2.0)])
assert placed == []
def test_without_cuts_everything_keeps_its_time(self):
actions = [VoiceAction("zoom", 3.0, 4.0), VoiceAction("text", 5.0, 6.0)]
cuts, placed, dropped = resolve_actions(actions)
assert cuts == [] and dropped == []
assert [(a.start, a.end) for a in placed] == [(3.0, 4.0), (5.0, 6.0)]
def test_params_survive_the_shift(self):
actions = [
VoiceAction("cut", 1.0, 2.0),
VoiceAction("text", 5.0, 6.0, params={"content": "SEGURANÇA"}),
]
_, placed, _ = resolve_actions(actions)
assert placed[0].params["content"] == "SEGURANÇA"
class TestMarkersSurviveCutEdges:
"""A marker is a point in time, not a span. The markers worth keeping are
precisely the ones flagging a join, which sit against a cut edge — so
requiring their nominal end to survive would drop exactly those."""
def test_marker_at_a_cut_edge_survives(self):
actions = [VoiceAction("cut", 21.9, 127.6), VoiceAction("marker", 21.85, 22.0)]
_, placed, dropped = resolve_actions(actions)
assert dropped == []
assert placed[0].kind == "marker"
assert placed[0].start == pytest.approx(21.85)
def test_marker_inside_removed_material_is_still_dropped(self):
actions = [VoiceAction("cut", 20.0, 100.0), VoiceAction("marker", 50.0, 50.2)]
_, placed, dropped = resolve_actions(actions)
assert placed == [] and len(dropped) == 1
def test_marker_keeps_its_length_after_shifting(self):
actions = [VoiceAction("cut", 0.0, 10.0), VoiceAction("marker", 20.0, 20.5)]
_, placed, _ = resolve_actions(actions)
assert placed[0].start == pytest.approx(10.0)
assert placed[0].duration == pytest.approx(0.5)
def test_zoom_straddling_an_edge_is_still_dropped(self):
"""Only markers get the point-action treatment — a span must fit."""
actions = [VoiceAction("cut", 21.9, 127.6), VoiceAction("zoom", 21.0, 22.5)]
_, placed, dropped = resolve_actions(actions)
assert placed == [] and len(dropped) == 1