Files
gart/code/tests/test_models_api.py
T
João HenriqueandClaude Opus 5 6090e229e9 refactor: models_api.py vira ponto de entrada sobre admin/api/
A ponte JSON do app tinha 1.395 linhas e 37 comandos de oito assuntos
diferentes num arquivo só. Agora models_api.py guarda apenas a referência
dos comandos, a tabela de despacho e o main(); cada assunto virou um módulo
em admin/api/ (models, project, editing, zoom, subtitles, transcription,
voice, review), com a base comum em shared.py.

Nada muda para o app: ele continua chamando admin/models_api.py por caminho,
e os 37 comandos respondem igual — verificado rodando a ponte de verdade.

Duas coisas que a divisão obrigou a arrumar:

- A saída passa por `shared.emit` chamada pelo módulo, não pelo nome
  importado. Isso preserva a propriedade de que trocar `emit` num lugar só
  captura a saída de todos os comandos — que era acidental quando tudo
  morava no mesmo arquivo, e vira intencional agora.
- `_CANCEL` e o lock eram globais compartilhados. O registro de downloads
  foi para models.py, junto de quem o usa, com lock próprio: o antigo
  protegia ao mesmo tempo o dicionário e a escrita em stdout, duas coisas
  sem relação.

Também: admin/test_models_api.py estava fora de `testpaths` e nunca rodava.
Movido para code/tests/ e ligado ao gate — 1441 → 1454 testes
(ver Engine/docs/05_EXPERIENCIAS.md #24).

Lint zerado, 1454 testes passando.

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

260 lines
9.6 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 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
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