refactor(contrato): el sobre se lee de GET /limits, ya no se copia a mano
Build & Deploy ResearchOwl / build-and-push (push) Successful in 17s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 17s
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
This commit is contained in:
@@ -56,6 +56,46 @@ async def test_healthz_and_templates():
|
||||
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)
|
||||
|
||||
@@ -80,6 +80,12 @@ def spec_with(*shots, **meta):
|
||||
}
|
||||
|
||||
|
||||
def errors_of_with_limits(spec, limits):
|
||||
with pytest.raises(SpecInvalid) as exc:
|
||||
validate_spec(spec, TEMPLATES, limits=limits)
|
||||
return exc.value.errors
|
||||
|
||||
|
||||
def errors_of(spec, templates=None):
|
||||
with pytest.raises(SpecInvalid) as exc:
|
||||
validate_spec(spec, templates if templates is not None else TEMPLATES)
|
||||
@@ -171,6 +177,54 @@ def test_resolution_must_be_a_shorts_one():
|
||||
for e in errors_of(spec_with(shot(), width=800, height=600)))
|
||||
|
||||
|
||||
def test_landscape_is_rejected_even_without_the_live_envelope():
|
||||
"""El fallo que dio origen a `GET /limits`, clavado.
|
||||
|
||||
shortsmith quitó 1920x1080 en `358eec9` porque ninguna plantilla lo componía.
|
||||
Esta copia siguió aceptándolo, así que un spec apaisado pasaba aquí y moría
|
||||
en el servidor: una generación pagada y tirada. El suelo tiene que ser el
|
||||
estrecho — si alguien lo vuelve a ensanchar «por compatibilidad», esto falla.
|
||||
"""
|
||||
assert any("no es una resolución admitida" in e
|
||||
for e in errors_of(spec_with(shot(), width=1920, height=1080)))
|
||||
|
||||
|
||||
def test_the_live_envelope_wins_over_the_floor():
|
||||
"""El suelo es suelo, no verdad: lo que diga `GET /limits` manda.
|
||||
|
||||
Con un shortsmith que publique el apaisado, el mismo spec pasa sin tocar una
|
||||
línea de este repo — que es exactamente lo que `GET /templates` hace ya con
|
||||
una plantilla nueva.
|
||||
"""
|
||||
apaisado = spec_with(shot(duration=25.0), width=1920, height=1080)
|
||||
with pytest.raises(SpecInvalid):
|
||||
validate_spec(apaisado, TEMPLATES)
|
||||
validate_spec(apaisado, TEMPLATES,
|
||||
limits={"resolutions": [[1080, 1920], [1920, 1080]]})
|
||||
|
||||
|
||||
def test_a_partial_envelope_falls_back_key_by_key():
|
||||
"""Un sobre a medias no tira la validación entera al suelo.
|
||||
|
||||
Un shortsmith que publique menos claves de las que se leen aquí — o una
|
||||
respuesta recortada — deja las demás en su sitio. Se resuelve clave a clave
|
||||
justamente para eso.
|
||||
"""
|
||||
solo_fps = {"fps": [30]}
|
||||
validate_spec(spec_with(shot(duration=25.0)), TEMPLATES, limits=solo_fps)
|
||||
assert any("no es una resolución admitida" in e
|
||||
for e in errors_of_with_limits(
|
||||
spec_with(shot(), width=1920, height=1080), solo_fps))
|
||||
|
||||
|
||||
def test_the_envelope_also_moves_the_caps_that_are_not_resolution():
|
||||
"""Resolución es la que se rompió, pero el sobre entero llega vivo."""
|
||||
corto = spec_with(shot(duration=6.0)) # 6 s: válido con el suelo
|
||||
validate_spec(corto, TEMPLATES)
|
||||
errors = errors_of_with_limits(corto, {"total_duration": {"min": 20.0, "max": 30.0}})
|
||||
assert any("no llega al" in e and "20.0" in e for e in errors)
|
||||
|
||||
|
||||
def test_total_duration_ceiling_is_the_contract_not_the_target():
|
||||
"""45 s es el objetivo editorial; 180 s es el límite duro. Pasarse de 45 no
|
||||
invalida el spec — eso es una nota, no un error."""
|
||||
|
||||
Reference in New Issue
Block a user