Files
researchowl/tests/test_shortsmith_live.py
T
ChemaVXandClaude Opus 5 7b79e14fdc
Build & Deploy ResearchOwl / build-and-push (push) Successful in 17s
refactor(contrato): el sobre se lee de GET /limits, ya no se copia a mano
Las props se validaban contra `GET /templates` y los presets contra `GET /audio`,
pero el sobre —resolución, fps, duraciones, topes— estaba escrito aquí. El
comentario decía «pequeño, estable»; el 2026-09-01 shortsmith quitó 1920x1080
(358eec9) porque ninguna plantilla lo componía y esta lista siguió aceptándolo.
Un spec apaisado pasaba la validación local y lo rechazaba el servidor: una
generación pagada y tirada.

Ningún test lo veía, y no por falta de tests: los dos lados eran coherentes
consigo mismos. Sólo se ve comparando con lo servido, que es lo que hace ahora
`test_shortsmith_live.py`.

Lo que era el espejo sobrevive como `BASELINE_LIMITS`, y sólo como suelo para
cuando el servicio no conteste. Estrecho a propósito, igual que
`BASELINE_PRESET_NAMES`: un fallback nunca puede aceptar lo que el contrato vivo
rechaza. Con eso el fallo queda tapado aunque shortsmith no llegue a desplegarse,
y contra un shortsmith anterior al endpoint el 404 cae al suelo sin error.

`_limit()` resuelve clave a clave: un sobre a medias, o uno más nuevo con claves
que aquí todavía no se leen, deja las demás en su sitio en vez de tirar la
validación entera al suelo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq
2026-09-02 07:50:43 +00:00

122 lines
4.9 KiB
Python

"""Prueba de fontanería contra un shortsmith VIVO.
Se salta salvo que se le dé una URL alcanzable desde donde corren los tests:
SHORTSMITH_LIVE_URL=http://10.43.86.57:8080 pytest tests/test_shortsmith_live.py -v
(dentro del cluster es `http://shortsmith-svc.shortsmith.svc.cluster.local:8080`;
desde el nodo, la ClusterIP de `kubectl get svc -n shortsmith`).
Renderiza el ejemplo de referencia entero — ~30 s de CPU en el pod — y comprueba
que vuelve un MP4. Es el paso 1 del §12 del spec de fase 2: probar el transporte
antes de generar nada.
"""
import json
import os
from pathlib import Path
import pytest
from src.generator.shortsmith import ShortsmithClient
LIVE_URL = os.environ.get("SHORTSMITH_LIVE_URL")
pytestmark = pytest.mark.skipif(
not LIVE_URL, reason="define SHORTSMITH_LIVE_URL para probar contra el servicio vivo")
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
@pytest.mark.asyncio
async def test_healthz_and_templates():
client = ShortsmithClient(LIVE_URL)
health = await client.health()
assert health["status"] == "ok"
assert health["templates"] >= 1
templates = await client.templates(refresh=True)
# No se comprueban nombres concretos a propósito: el contrato es de
# shortsmith y añadir plantillas allí no debe romper aquí.
assert templates, "GET /templates devolvió vacío"
for name, schema in templates.items():
assert schema.get("type") == "object", f"{name} no publica un esquema de objeto"
assert "properties" in schema
# Los dos presupuestos de texto, en el contrato SERVIDO. `unreadable_notes`
# se calla contra un esquema que no los traiga —no puede inventarse el
# número—, así que un shortsmith anterior a 289d50e apagaría la comprobación
# entera sin un solo error. Esto es lo único que lo nota.
sin_tope = [
f"{name}.{prop}"
for name, schema in templates.items()
for owner in [schema, *(schema.get("$defs") or {}).values()]
for prop, node in (owner.get("properties") or {}).items()
if "x-fits" in node and "x-fits-hard" not in node
]
assert sin_tope == [], f"campos con x-fits pero sin x-fits-hard: {sin_tope}"
@pytest.mark.asyncio
async def test_the_envelope_served_is_the_envelope_validated_against():
"""El sobre, contra el servicio de verdad — que es donde derivó.
La copia local decía «pequeña y estable» y aceptaba 1920x1080 días después
de que shortsmith dejara de renderizarlo. Un test de unidad no podía verlo:
los dos lados eran coherentes consigo mismos. Esto compara con el servido.
Contra un shortsmith anterior a `GET /limits` el cliente devuelve el suelo
sin error, y entonces esto sólo comprueba que el suelo es el estrecho — que
es exactamente lo que se quiere de un fallback.
"""
from src.generator.spec_contract import BASELINE_LIMITS, validate_spec, SpecInvalid
client = ShortsmithClient(LIVE_URL)
limits = await client.limits(refresh=True)
resoluciones = [tuple(pair) for pair in limits["resolutions"]]
assert (1080, 1920) in resoluciones, "el servicio ya no acepta el vertical"
# Lo que el sobre NO trae, el validador local tiene que rechazarlo. Si
# shortsmith deja de aceptar una resolución, esto falla el día que pasa y no
# la generación siguiente.
spec = json.loads(EXAMPLE.read_text())
templates = await client.templates(refresh=True)
for size in [(1920, 1080), (1080, 1080)]:
if size in resoluciones:
continue
doc = json.loads(json.dumps(spec))
doc["meta"]["width"], doc["meta"]["height"] = size
with pytest.raises(SpecInvalid):
validate_spec(doc, templates, limits=limits)
# Y el suelo nunca puede ser más ancho que lo servido: si lo fuera, un
# shortsmith caído dejaría pasar specs que el vivo rechaza.
suelo = {tuple(pair) for pair in BASELINE_LIMITS["resolutions"]}
assert suelo <= set(resoluciones), (
f"el suelo {sorted(suelo)} acepta más que el servicio {sorted(resoluciones)}")
@pytest.mark.asyncio
async def test_render_the_reference_example_end_to_end(tmp_path):
client = ShortsmithClient(LIVE_URL)
spec = json.loads(EXAMPLE.read_text())
job_id = await client.render(spec)
seen = []
async def on_progress(fraction, status):
seen.append(fraction)
result = await client.poll(job_id, on_progress=on_progress)
assert result.ok, f"el job terminó en {result.status}: {result.error}"
assert seen and max(seen) == 1.0
video = await client.fetch_video(job_id)
# ftyp en los primeros bytes: es un MP4 de verdad, no una página de error.
assert b"ftyp" in video[:32]
assert len(video) > 100_000, f"solo {len(video)} bytes — sospechosamente corto"
out = tmp_path / "jal1628.mp4"
out.write_bytes(video)
print(f"\nrenderizado {len(video)/1e6:.2f} MB en {out}")