Files
gart/code/tests/test_voice_actions.py
T
João HenriqueandClaude Opus 5 1bebee4359 feat: etapa 5 do assistente — revisão de ênfases com timeline
Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da
IA chega carregada e o editor afina frase a frase o que é ênfase e o que
fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase
recebem zoom e legenda dinâmica; as demais ficam com legenda comum.

O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas
não muda e a etapa 6 segue intacta.

Backend (fcpxml/phrase_review.py):
- build_phrase_review funde o _voice_timeline.json com as actions da IA
- trim por frase que anda em fronteira de palavra; corte parcial da IA
  chega como trim em vez de ser arredondado fora
- phrase_review_to_actions volta a cuts/zooms + emphasis_spans
- merge_saved_decisions reaplica só as decisões salvas sobre uma revisão
  remontada da análise atual, para reprocessar a voz não ficar mascarado
- resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo

App (SwiftUI):
- layout de sala de edição: preview em cima, inspector à direita, timeline
  atravessando embaixo com seis trilhas rotuladas
- preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal,
  projeto vertical), com alternância para a mídia original
- reprodução pula os trechos removidos e para no fim do trecho
- zoom manual por trecho marcado, sem guardar escala: a forma vem das
  configurações de Análise de Voz no render
- emoção da fala exposta por frase

Correções encontradas no caminho:
- VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc;
  trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22)
- teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:29:27 -04:00

233 lines
9.0 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_absent_scale_is_left_absent(self):
# The parser no longer stamps a default: an omitted scale must reach the
# applier untouched so it can fall back to the user's configured
# `zoom_scale` (see server_tools/_shared.py). Filling one in here would
# silently override that setting for every action the model sends
# without an explicit scale.
actions, _ = parse_actions([{"kind": "zoom", "start": 1.0, "end": 2.0}])
assert "scale" not in actions[0].params
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