Files
gart/code/tests/test_refine_voice_timeline_tool.py
T
João HenriqueandClaude Opus 5 ffaebb3f72 refactor: _shared.py vira subpacote, um módulo por papel
Eram 882 linhas de seis papéis sem relação, sob um nome que só dizia
"compartilhado" — o depósito onde tudo que servia a mais de um handler
acabava caindo.

    media       316   transcrição em cache, corte por fala, relatório
    paths       206   sandbox, limites, caminho de saída
    project     116   abrir projeto, preparar modifier/generator
    captions    112   SRT, VTT, listas com timestamp
    detection    99   flash frames, buracos, duplicados
    formatting   86   tabelas e relatórios dos handlers

O __init__ reexporta os 46 nomes, então os treze pontos que importam daqui
não mudaram.

_transcript_cut_report saiu de formatting para media: ele precisa do hint de
instalação e do _text_result, ou seja, é relatório de transcrição e não
formatação genérica — mover foi mais honesto que cruzar imports entre os
dois módulos.

Quatro testes patchavam `server_tools._shared.transcribe`; o nome agora é
ligado por _shared/media.py, então o patch passou a apontar para lá — mesmo
padrão da experiência #23.

Lint zerado, 1454 testes passando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:35:02 -04:00

168 lines
6.5 KiB
Python

"""Tests for the refine_voice_timeline MCP tool.
The tool exists because emphasis is *relative*: cut the loudest moment of a
recording and every surviving score is still measured against something the
viewer never sees. These tests pin the re-normalization actually happening,
and the times staying in original source seconds so the result can be fed
straight back to apply_voice_actions.
"""
import json
import pytest
from tests.test_voice_features_tool import _write_silent_wav
from tests.test_voice_timeline_tool import _TRANSCRIPT, patched, wav # noqa: F401
_ = _write_silent_wav, _TRANSCRIPT # re-exported fixtures need the imports
async def _build(wav_path):
from server import handle_build_voice_timeline
await handle_build_voice_timeline({"media_path": str(wav_path)})
@pytest.fixture
def two_candidates(monkeypatch):
"""A transcript where BOTH lines carry a content word.
The shared fixture's first line is "isso e" — two function words, which
``suggest_zoom_windows`` skips by design, so it can never produce more
than one candidate to cap.
"""
import fcpxml.voice_timeline as vt
import server_tools._shared.media as _shared_mod
transcript = {
"language": "pt",
"duration": 4.0,
"text": "cirurgia rapida seguranca total",
"segments": [
{"text": "cirurgia rapida", "start": 0.0, "end": 1.0},
{"text": "seguranca total", "start": 2.0, "end": 4.0},
],
"words": [
{"word": "cirurgia", "start": 0.0, "end": 0.4, "confidence": 0.9},
{"word": "rapida", "start": 0.5, "end": 0.7, "confidence": 0.9},
{"word": "seguranca", "start": 2.0, "end": 2.9, "confidence": 0.9},
{"word": "total", "start": 3.0, "end": 3.5, "confidence": 0.9},
],
}
monkeypatch.setattr(_shared_mod, "transcribe", lambda *a, **k: transcript)
monkeypatch.setattr(vt, "extract_pitch", lambda *a, **k: [(2.4, 260.0), (0.2, 120.0)])
monkeypatch.setattr(vt, "extract_energy", lambda *a, **k: [(2.4, 0.95), (0.2, 0.10)])
class TestRefineVoiceTimelineHandler:
async def test_requires_an_existing_timeline(self, wav): # noqa: F811
from server import handle_refine_voice_timeline
result = await handle_refine_voice_timeline({"media_path": str(wav), "cuts": []})
assert "build_voice_timeline" in result[0].text
async def test_rejects_disallowed_extension(self, tmp_path):
from server import handle_refine_voice_timeline
bad = tmp_path / "clip.txt"
bad.write_text("not audio")
with pytest.raises(ValueError):
await handle_refine_voice_timeline({"media_path": str(bad), "cuts": []})
async def test_compares_raw_against_survivors(self, wav, patched): # noqa: F811
from server import handle_refine_voice_timeline
await _build(wav)
result = await handle_refine_voice_timeline(
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}]}
)
text = result[0].text
assert "Survivors only" in text
assert "Average emphasis" in text
async def test_cut_words_are_excluded(self, wav, patched): # noqa: F811
from server import handle_refine_voice_timeline
await _build(wav)
result = await handle_refine_voice_timeline(
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}], "save": True}
)
assert "_voice_timeline_refined.json" in result[0].text
data = json.loads(
(wav.parent / "clip_voice_timeline_refined.json").read_text(encoding="utf-8")
)
words = [w["text"] for s in data["segments"] for w in s["words"]]
assert "isso" not in words
assert "seguranca" in words
async def test_times_stay_in_original_source_seconds(self, wav, patched): # noqa: F811
"""A cut at the head must NOT slide the survivors back to zero."""
from server import handle_refine_voice_timeline
await _build(wav)
await handle_refine_voice_timeline(
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}], "save": True}
)
data = json.loads(
(wav.parent / "clip_voice_timeline_refined.json").read_text(encoding="utf-8")
)
first = data["segments"][0]["words"][0]
assert first["start"] == pytest.approx(2.0)
async def test_proposes_zoom_candidates(self, wav, patched): # noqa: F811
from server import handle_refine_voice_timeline
await _build(wav)
result = await handle_refine_voice_timeline({"media_path": str(wav), "cuts": []})
assert "Zoom Candidates" in result[0].text
async def test_max_zooms_caps_the_list(self, wav, two_candidates): # noqa: F811
from server import handle_refine_voice_timeline
await _build(wav)
async def zoom_rows(**extra):
result = await handle_refine_voice_timeline(
{"media_path": str(wav), "cuts": [], "min_gap": 0.0, **extra}
)
section = result[0].text.split("## Zoom Candidates", 1)[1]
return [
ln for ln in section.splitlines()
if ln.startswith("| ") and ln.rstrip().endswith("|") and "Start" not in ln
]
assert len(await zoom_rows()) == 2
assert len(await zoom_rows(max_zooms=1)) == 1
async def test_malformed_cut_is_reported_not_raised(self, wav, patched): # noqa: F811
from server import handle_refine_voice_timeline
await _build(wav)
result = await handle_refine_voice_timeline(
{"media_path": str(wav), "cuts": [{"start": 3.0, "end": 1.0}]}
)
text = result[0].text
assert "Rejected cuts" in text
assert "must be after start" in text
async def test_cutting_everything_says_so(self, wav, patched): # noqa: F811
from server import handle_refine_voice_timeline
await _build(wav)
result = await handle_refine_voice_timeline(
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 60.0}]}
)
assert "removed every word" in result[0].text
class TestRefineVoiceTimelineRegistration:
async def test_tool_is_listed(self):
from server import list_tools
assert "refine_voice_timeline" in {t.name for t in await list_tools()}
async def test_tool_is_dispatched(self):
from server import TOOL_HANDLERS, handle_refine_voice_timeline
assert TOOL_HANDLERS["refine_voice_timeline"] is handle_refine_voice_timeline