Files
gart/code/tests/test_refine_voice_timeline_tool.py
T

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 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