Files
gart/code/tests/test_models_api.py
T
João HenriqueandClaude Opus 5 711c397dfe fix: admin/api apontava para admin/code (inexistente) — crash no app
Ao dividir _shared.py em admin/api/*.py ontem, o cálculo
`Path(__file__).resolve().parent.parent / "code"` foi copiado sem ajustar
para o nível de diretório novo. No arquivo original (admin/models_api.py,
direto em admin/) dois `.parent` chegavam na raiz do repo. Em
admin/api/shared.py, um nível mais fundo, dois `.parent` param em admin/ —
e admin/code nunca existiu. sys.path nunca recebia code/, então toda ação
que passa por `server` (analisar voz, aplicar decisões) crashava o app com
ModuleNotFoundError: server_tools.

O bug sobreviveu a duas rodadas de validação da sessão anterior — lint
zero, 1454 testes verdes, comando testado manualmente pela ponte — porque
todos rodam num venv com install editável (__editable__.fcp_mcp_server.pth)
que já deixa fcpxml/server_tools importáveis por conta própria, mascarando
qualquer erro no cálculo manual de sys.path. Só o app real, no fallback sem
uv, expõe o bug.

Correção: o cálculo de sys.path sai de cada módulo de comando (estava
duplicado em nove arquivos) e passa a existir uma única vez em
admin/api/__init__.py, que roda antes de qualquer submódulo — nenhum
precisa mais da própria cópia.

O teste de regressão precisou de duas tentativas pelo mesmo motivo do bug:
a primeira versão também passava com o bug presente, por rodar no mesmo
venv "de sorte". Só ficou confiável isolando um subprocess que remove
site-packages do sys.path antes de importar — confirmado nos dois sentidos,
falha com o bug reintroduzido e passa com a correção
(TestCodeDirResolution).

Detalhe completo, incluindo por que o comando manual não pegou:
Engine/docs/05_EXPERIENCIAS.md #25.

Lint zerado, 1457 testes passando (3 novos), app compilado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:50:04 -04:00

342 lines
14 KiB
Python

"""Tests for the SwiftUI JSON bridge commands (admin/api/).
Focused on the transcription flow: atomic save, speaker renaming, and the
"use the selected model" default plus the model-availability guard.
Patch targets follow one rule: replace a name **in the module that uses it**.
`shared.emit` is the exception that proves it — the command modules call it as
`shared.emit(...)` rather than binding the name locally, precisely so that one
patch keeps capturing the output of all of them.
"""
import json
import subprocess
import sys
from pathlib import Path
# `admin/` lives outside `code/`, which is pytest's rootdir — without the repo
# root on the path this module is invisible and the whole file silently stops
# being collected. It spent its life outside `testpaths` for exactly that
# reason, so keep the insert next to the import that needs it.
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from admin.api import models, shared, subtitles, transcription # noqa: E402
_MODELS_API = _REPO_ROOT / "admin" / "models_api.py"
class TestCodeDirResolution:
"""Pins the sys.path computation itself, independent of the environment.
`code/` has an editable install (`__editable__.fcp_mcp_server*.pth`) that
makes `fcpxml`/`server_tools` importable regardless of this calculation —
which is exactly why TestSubprocessEntryPoint below can't be trusted alone
to catch a regression here: it passes in an environment with the editable
install even when the path math is wrong. The app's real subprocess call
can fall back to a plain `python3` with no such install (see
PythonBridge.swift), where this calculation is the only thing that puts
`code/` on sys.path — so pin the math directly, not just its usual
workaround.
"""
def test_importing_the_package_puts_a_real_code_dir_on_sys_path(self):
"""Checks the observable effect (what `admin.api`'s own `__init__.py`
puts on sys.path), with the editable install that normally masks this
stripped out of `sys.path` first — otherwise `server_tools` imports
fine regardless of whether the path math is right, and the assertion
proves nothing. A fresh subprocess is required too: importing
`admin.api` in-process reuses whatever `sys.modules` already cached
from an earlier test in this file, silently skipping `__init__.py`
the second time around.
"""
script = (
"import sys\n"
"sys.path = [p for p in sys.path if 'site-packages' not in p]\n"
"import admin.api\n"
"import server_tools\n" # only resolvable if __init__.py did its job
"print('OK')\n"
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=15, cwd=str(_REPO_ROOT),
)
assert result.returncode == 0 and "OK" in result.stdout, result.stderr
class TestSubprocessEntryPoint:
"""Runs models_api.py as the real subprocess the app launches.
Every other test in this file imports admin.api directly, which resolves
`code/` onto sys.path a completely different way than a fresh Python
process does. That gap let a real bug through: `admin/api/shared.py` sits
one directory deeper than the old single-file `admin/models_api.py`, so
its `Path(__file__).resolve().parent.parent / "code"` pointed at
`admin/code` (nonexistent) instead of `code/` — `server_tools` (which
lives in `code/`) was then unimportable the moment a handler tried
`from server import ...`. Every in-process test still passed, because
none of them launch a real subprocess with a real `__file__`.
"""
def _run(self, command: str, args: dict | None = None) -> dict:
argv = [sys.executable, str(_MODELS_API), command]
if args is not None:
argv.append(json.dumps(args))
result = subprocess.run(
argv, capture_output=True, text=True, timeout=30, cwd=str(_REPO_ROOT / "code")
)
assert result.returncode in (0, 1), (
f"crashed (exit {result.returncode}):\n{result.stderr}"
)
return json.loads(result.stdout.strip().splitlines()[-1])
def test_simple_command_runs_as_a_real_subprocess(self):
assert self._run("acoustics_capability")["ok"] is True
def test_command_that_imports_server_tools_runs_as_a_real_subprocess(self):
"""The exact failure mode: a handler reaching into `server` (and
transitively `server_tools`) from a process where `code/` was never
actually on sys.path."""
result = self._run("analyze_voice", {"path": "/nonexistent/project.fcpxml"})
# The path is bogus on purpose — the point is that it FAILS on a
# missing project, not on `ModuleNotFoundError: server_tools`.
assert result["ok"] is False
assert "server_tools" not in result.get("error", "")
assert "ModuleNotFoundError" not in result.get("error", "")
def _capture(monkeypatch):
captured: list[dict] = []
def _emit(obj):
captured.append(obj)
monkeypatch.setattr(shared, "emit", _emit)
return captured
def test_save_json_atomic(tmp_path):
p = tmp_path / "t.json"
shared._save_json_atomic(p, {"a": [1, 2], "text": "olá"})
assert p.exists()
assert not (tmp_path / "t.json.tmp").exists()
assert json.loads(p.read_text(encoding="utf-8"))["text"] == "olá"
def test_rename_speakers(tmp_path, monkeypatch):
captured = _capture(monkeypatch)
p = tmp_path / "t.json"
p.write_text(
json.dumps(
{
"speakers": [
{"id": "SPEAKER_00", "name": "Speaker 1"},
{"id": "SPEAKER_01", "name": "Speaker 2"},
]
}
),
encoding="utf-8",
)
assert transcription.cmd_rename_speakers({"path": str(p), "speakers": {"SPEAKER_01": "Erika"}}) == 0
assert captured[0]["ok"] is True
saved = json.loads(p.read_text(encoding="utf-8"))
assert saved["speakers"][0]["name"] == "Speaker 1"
assert saved["speakers"][1]["name"] == "Erika"
def test_rename_speakers_missing_file(monkeypatch):
captured = _capture(monkeypatch)
assert transcription.cmd_rename_speakers({"path": "/nonexistent/x.json"}) == 1
assert captured[0]["type"] == "error"
def test_transcribe_requires_output_dir(monkeypatch):
captured = _capture(monkeypatch)
monkeypatch.setattr(transcription, "load_selected_model", lambda: "small")
monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: True)
assert transcription.cmd_transcribe({"path": "/some/project.fcpxml"}) == 1
assert captured[0]["type"] == "error"
assert "pasta do projeto" in captured[0]["message"]
def test_transcribe_requires_installed_model(monkeypatch, tmp_path):
captured = _capture(monkeypatch)
monkeypatch.setattr(transcription, "load_selected_model", lambda: "")
monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: False)
assert transcription.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1
assert captured[0]["type"] == "error"
assert "instalado" in captured[0]["message"]
def test_transcribe_defaults_to_selected_model(monkeypatch, tmp_path):
captured = _capture(monkeypatch)
monkeypatch.setattr(transcription, "load_selected_model", lambda: "small")
monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: m == "small")
class FakeTL:
clips = []
class FakeProject:
primary_timeline = None
timelines = [FakeTL()]
monkeypatch.setattr(transcription, "parse_fcpxml", lambda p: FakeProject())
# No media accessible -> reaches the media-path check (past model validation).
assert transcription.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1
assert captured[0]["type"] == "error"
assert "mídia" in captured[0]["message"]
def test_set_language_persists(monkeypatch):
captured = _capture(monkeypatch)
assert models.cmd_set_language({"language": "pt"}) == 0
assert captured[0]["ok"] is True
assert captured[0]["language"] == "pt"
assert models.load_transcript_language() == "pt"
def test_set_language_rejects_unknown(monkeypatch):
captured = _capture(monkeypatch)
assert models.cmd_set_language({"language": "xx"}) == 1
assert captured[0]["ok"] is False
assert "language" in captured[0]["error"]
def test_transcribe_defaults_language_to_persisted(monkeypatch, tmp_path):
monkeypatch.setattr(transcription, "load_selected_model", lambda: "small")
monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: m == "small")
monkeypatch.setattr(models, "load_transcript_language", lambda: "pt")
media = tmp_path / "clip.mov"
media.write_bytes(b"fake")
class FakeClip:
media_path = ""
class FakeTL:
clips = [FakeClip()]
class FakeProject:
primary_timeline = None
timelines = [FakeTL()]
monkeypatch.setattr(transcription, "parse_fcpxml", lambda p: FakeProject())
monkeypatch.setattr(transcription, "media_src_to_path", lambda mp: str(media))
called = {}
monkeypatch.setattr(
transcription, "transcribe",
lambda mp, model_size, language, **kw: called.update(lang=language),
)
assert transcription.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path / "out")}) == 1
assert called["lang"] == "pt"
def test_srt_stamp_format():
assert subtitles.srt_stamp(0.0) == "00:00:00,000"
assert subtitles.srt_stamp(1.5) == "00:00:01,500"
assert subtitles.srt_stamp(3661.234) == "01:01:01,234"
_FCPXML_SAMPLE = """<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<asset id="r1" name="clip" uid="u1" start="0s" duration="100s"
hasVideo="1" format="f1" hasAudio="1">
<media-rep kind="original-media" src="file:///tmp/clip.mp4"/>
</asset>
<format id="f1" name="FFVideoFormat1080p25" frameDuration="1/25s" width="1920" height="1080"/>
</resources>
<library>
<event name="Event">
<project name="P">
<sequence format="f1">
<spine>
<asset-clip ref="r1" offset="0s" start="10s" duration="10s" name="clip"/>
<gap name="Espaço" offset="10s" duration="90s" start="10s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>
"""
def test_cmd_export_srt_maps_to_edited_timeline(tmp_path, monkeypatch):
"""Captions must reflect the EDITED timeline, not the whole source file."""
captured = _capture(monkeypatch)
project = tmp_path / "proj.fcpxml"
project.write_text(_FCPXML_SAMPLE, encoding="utf-8")
media = tmp_path / "clip.mp4"
media.write_bytes(b"fake")
# Transcript covers 0..100s; the clip only USES source 10..20s -> timeline 0..10s.
transcript = {
"words": [],
"segments": [
{"start": 5.0, "end": 6.0, "text": "antes do corte"},
{"start": 12.0, "end": 14.0, "text": "dentro do corte"},
{"start": 50.0, "end": 51.0, "text": "depois do corte"},
]
}
tj = shared._transcript_json_path(media)
tj.parent.mkdir(parents=True, exist_ok=True)
shared._save_json_atomic(tj, transcript)
monkeypatch.setattr(subtitles, "media_src_to_path", lambda src: str(media))
assert subtitles.cmd_export_srt({"path": str(project)}) == 0
assert captured[0]["ok"] is True
srt = tmp_path / "clip_captions.srt"
assert srt.exists()
text = srt.read_text(encoding="utf-8")
# Only the segment inside the used source window (12s) survives.
assert "dentro do corte" in text
assert "antes do corte" not in text
assert "depois do corte" not in text
# Mapped to timeline 0..10s -> the 12s source segment lands at 2s.
assert "00:00:02,000 --> 00:00:04,000" in text
def test_cmd_export_srt_no_transcript(tmp_path, monkeypatch):
captured = _capture(monkeypatch)
project = tmp_path / "proj.fcpxml"
project.write_text(_FCPXML_SAMPLE, encoding="utf-8")
media = tmp_path / "clip.mp4"
media.write_bytes(b"fake")
monkeypatch.setattr(subtitles, "media_src_to_path", lambda src: str(media))
assert subtitles.cmd_export_srt({"path": str(project)}) == 1
assert captured[0]["ok"] is False
def test_cmd_export_srt_clamps_past_project_duration(tmp_path, monkeypatch):
"""A segment ending after the last clip must be clamped to the project end.
Final Cut rejects an SRT whose final cue overruns the timeline
("subtitle extends beyond project duration").
"""
captured = _capture(monkeypatch)
project = tmp_path / "proj.fcpxml"
project.write_text(_FCPXML_SAMPLE, encoding="utf-8")
media = tmp_path / "clip.mp4"
media.write_bytes(b"fake")
# Clip uses source 10..20s -> timeline 0..10s. A segment 12..30s maps to
# timeline 2..20s, but the project only lasts 10s: must clamp end to 10s.
transcript = {
"words": [],
"segments": [
{"start": 12.0, "end": 30.0, "text": "longa fala"},
]
}
tj = shared._transcript_json_path(media)
tj.parent.mkdir(parents=True, exist_ok=True)
shared._save_json_atomic(tj, transcript)
monkeypatch.setattr(subtitles, "media_src_to_path", lambda src: str(media))
assert subtitles.cmd_export_srt({"path": str(project)}) == 0
assert captured[0]["ok"] is True
srt = tmp_path / "clip_captions.srt"
text = srt.read_text(encoding="utf-8")
# Timeline is 10s; the cue must not end past it.
assert "00:00:02,000 --> 00:00:10,000" in text
assert "00:00:20,000" not in text