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>
342 lines
14 KiB
Python
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
|