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:
+17
-3
@@ -32,7 +32,9 @@ from src.generator.shortsmith import (
|
||||
ShortsmithUnavailable,
|
||||
)
|
||||
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
|
||||
from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec
|
||||
from src.generator.spec_contract import (
|
||||
BASELINE_LIMITS, SpecInvalid, editorial_notes, validate_spec,
|
||||
)
|
||||
from src.llm import get_anthropic_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -148,6 +150,16 @@ class ShortProducer:
|
||||
logger.warning("GET /audio falló — paleta base", error=str(e))
|
||||
return dict(BASELINE_PRESETS)
|
||||
|
||||
async def _limits(self) -> dict:
|
||||
"""El sobre del contrato, en vivo. Tampoco tumba nada: sin él se valida
|
||||
contra el suelo, que es más estrecho que cualquier shortsmith y por eso
|
||||
no puede dejar pasar un spec que el servidor fuera a rechazar."""
|
||||
try:
|
||||
return await self.client.limits()
|
||||
except Exception as e:
|
||||
logger.warning("GET /limits falló — sobre base", error=str(e))
|
||||
return dict(BASELINE_LIMITS)
|
||||
|
||||
# --- pipeline -----------------------------------------------------------
|
||||
|
||||
async def produce(self, session_id: int,
|
||||
@@ -186,6 +198,7 @@ class ShortProducer:
|
||||
# ofrece la paleta base y el render sale igual.
|
||||
templates = await self.client.templates()
|
||||
presets = await self._presets()
|
||||
limits = await self._limits()
|
||||
|
||||
# 3. El spec.
|
||||
started = time.monotonic()
|
||||
@@ -193,7 +206,7 @@ class ShortProducer:
|
||||
writer = ShortSpecWriter(
|
||||
llm_call, templates,
|
||||
refresh_templates=lambda: self.client.templates(refresh=True),
|
||||
presets=presets)
|
||||
presets=presets, limits=limits)
|
||||
try:
|
||||
written = await writer.write(
|
||||
topic, context, article_url=result.article_url,
|
||||
@@ -269,8 +282,9 @@ class ShortProducer:
|
||||
# de retoque para el que existe este camino.
|
||||
templates = await self.client.templates()
|
||||
presets = await self._presets()
|
||||
limits = await self._limits()
|
||||
try:
|
||||
validate_spec(spec, templates, presets=presets)
|
||||
validate_spec(spec, templates, presets=presets, limits=limits)
|
||||
except SpecInvalid as e:
|
||||
result.failure = ("El spec editado no pasa el contrato: "
|
||||
+ "; ".join(e.errors[:6]))
|
||||
|
||||
@@ -24,6 +24,7 @@ import aiohttp
|
||||
import structlog
|
||||
|
||||
from src.config import settings, SAFE_ACCEPT_ENCODING
|
||||
from src.generator.spec_contract import BASELINE_LIMITS
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -97,6 +98,7 @@ class JobResult:
|
||||
#: mitad de una run.
|
||||
_templates_cache: dict[str, dict[str, Any]] = {}
|
||||
_presets_cache: dict[str, dict[str, str]] = {}
|
||||
_limits_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
class ShortsmithClient:
|
||||
@@ -178,6 +180,39 @@ class ShortsmithClient:
|
||||
logger.info("shortsmith audio presets fetched", presets=sorted(data))
|
||||
return data
|
||||
|
||||
async def limits(self, refresh: bool = False) -> dict[str, Any]:
|
||||
"""El sobre del contrato: resoluciones, fps, duraciones y topes.
|
||||
|
||||
La tercera mitad del contrato vivo, y la que faltaba. Las props se leen
|
||||
de `/templates` desde el principio y los presets de `/audio`; el sobre
|
||||
se copiaba a mano en `spec_contract.BASELINE_LIMITS`, y la copia derivó
|
||||
en cuanto shortsmith quitó 1920x1080. Un 404 es un shortsmith anterior
|
||||
al endpoint y devuelve el suelo, sin error — fallbacks siempre.
|
||||
"""
|
||||
if not refresh and self.base_url in _limits_cache:
|
||||
return _limits_cache[self.base_url]
|
||||
try:
|
||||
async with self._session(30) as sess:
|
||||
async with sess.get(f"{self.base_url}/limits") as resp:
|
||||
if resp.status == 404:
|
||||
data = dict(BASELINE_LIMITS)
|
||||
elif resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise ShortsmithError(
|
||||
f"GET /limits devolvió {resp.status}: {body[:200]}")
|
||||
else:
|
||||
payload = await resp.json()
|
||||
data = payload if isinstance(payload, dict) and payload \
|
||||
else dict(BASELINE_LIMITS)
|
||||
except aiohttp.ClientError as e:
|
||||
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
|
||||
except asyncio.TimeoutError as e:
|
||||
raise ShortsmithUnavailable("shortsmith no respondió a /limits") from e
|
||||
_limits_cache[self.base_url] = data
|
||||
logger.info("shortsmith limits fetched",
|
||||
resolutions=data.get("resolutions"))
|
||||
return data
|
||||
|
||||
async def render(self, spec: dict[str, Any]) -> str:
|
||||
"""`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected."""
|
||||
try:
|
||||
|
||||
@@ -495,9 +495,13 @@ LLMCall = Callable[[str, str], Awaitable[str]]
|
||||
class ShortSpecWriter:
|
||||
def __init__(self, llm_call: LLMCall, templates: dict[str, dict],
|
||||
refresh_templates: Optional[Callable[[], Awaitable[dict]]] = None,
|
||||
presets: Optional[dict[str, str]] = None):
|
||||
presets: Optional[dict[str, str]] = None,
|
||||
limits: Optional[dict] = None):
|
||||
self.llm_call = llm_call
|
||||
self.templates = templates
|
||||
#: El sobre vivo de `GET /limits`. Sin él se valida contra el suelo,
|
||||
#: que nunca acepta nada que un shortsmith viejo fuera a rechazar.
|
||||
self.limits = limits
|
||||
#: Se vuelve a pedir el contrato si una validación falla: el
|
||||
#: renderizador puede haberse actualizado a mitad de la run.
|
||||
self.refresh_templates = refresh_templates
|
||||
@@ -560,7 +564,8 @@ class ShortSpecWriter:
|
||||
|
||||
last_spec = spec
|
||||
try:
|
||||
validate_spec(spec, self.templates, presets=self.presets)
|
||||
validate_spec(spec, self.templates, presets=self.presets,
|
||||
limits=self.limits)
|
||||
except SpecInvalid as e:
|
||||
history.append(e.errors)
|
||||
feedback = _format_errors(e.errors)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""El contrato del spec, leído — no copiado — de shortsmith.
|
||||
|
||||
Dos cosas, las dos guiadas por lo que publica `GET /templates`:
|
||||
Dos cosas, guiadas por lo que shortsmith publica:
|
||||
|
||||
* `describe_templates()` — el contrato en prosa compacta, para meterlo en el
|
||||
prompt. Añadir una plantilla en shortsmith la deja descrita aquí sola.
|
||||
@@ -10,13 +10,18 @@ Dos cosas, las dos guiadas por lo que publica `GET /templates`:
|
||||
comprobador de fundamento va entre la validación y el render: mandar el spec
|
||||
a `POST /render` para validarlo ya encolaría el render.
|
||||
|
||||
La mitad de props del contrato NO vive aquí: se valida contra el esquema
|
||||
recibido. Lo único escrito a mano es el sobre (version/meta/audio/shots), que
|
||||
es pequeño, estable, y está anotado con la regla equivalente de
|
||||
`shortsmith/src/shortsmith/spec.py`. Las reglas de pydantic que cruzan campos
|
||||
(los límites de MapBounds, "3 barras no dejan sitio para una cita") NO se
|
||||
replican: las coge el 422 del servidor al enviar, y ese camino también está
|
||||
cubierto.
|
||||
**Nada del contrato se escribe aquí.** Las props se validan contra el esquema de
|
||||
`GET /templates`, los presets contra `GET /audio` y el sobre — resolución, fps,
|
||||
duraciones, topes — contra `GET /limits`. De cada uno hay una base local, pero
|
||||
sólo como suelo para cuando el servicio no conteste, nunca como fuente.
|
||||
|
||||
El sobre **sí** estuvo escrito a mano, y por eso hay tres endpoints en vez de
|
||||
dos: la copia decía «pequeña y estable» hasta que shortsmith quitó 1920x1080 y
|
||||
esta lista siguió aceptándolo días. Ver `BASELINE_LIMITS`.
|
||||
|
||||
Las reglas de pydantic que cruzan campos (los límites de MapBounds, "3 barras no
|
||||
dejan sitio para una cita") NO se replican: las coge el 422 del servidor al
|
||||
enviar, y ese camino también está cubierto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -40,14 +45,48 @@ __all__ = [
|
||||
"TARGET_MAX_DURATION",
|
||||
]
|
||||
|
||||
# Límites del sobre — espejo de shortsmith/spec.py.
|
||||
RESOLUTIONS = {(1080, 1920), (1920, 1080)}
|
||||
FPS_VALUES = {24, 25, 30, 60}
|
||||
MIN_SHOT_DURATION = 0.5
|
||||
MIN_TOTAL_DURATION = 5.0
|
||||
MAX_TOTAL_DURATION = 180.0 # límite duro de YouTube Shorts
|
||||
MAX_SHOTS = 64
|
||||
META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
#: El sobre que aceptaba shortsmith antes de que `GET /limits` existiera, y el
|
||||
#: suelo cuando el endpoint no contesta.
|
||||
#:
|
||||
#: Esto **era** un espejo a mano, y se rompió como se rompen los espejos: el
|
||||
#: 2026-09-01 shortsmith quitó 1920x1080 (`358eec9`) porque ninguna plantilla lo
|
||||
#: componía, y esta lista siguió aceptándolo. Un spec que aquí pasaba, allí se
|
||||
#: rechazaba: una generación pagada y tirada. Nadie se equivocó al escribirlo —
|
||||
#: ése es el argumento. Ahora el sobre se lee de `GET /limits` igual que las
|
||||
#: props se leen de `GET /templates`, y esto es sólo el suelo.
|
||||
#:
|
||||
#: Conservador a propósito, como `BASELINE_PRESET_NAMES`: nunca acepta nada que
|
||||
#: un shortsmith viejo fuera a rechazar. Por eso la resolución es la estrecha.
|
||||
BASELINE_LIMITS: dict[str, Any] = {
|
||||
"resolutions": [[1080, 1920]],
|
||||
"fps": [24, 25, 30, 60],
|
||||
"meta_id_pattern": r"^[a-z0-9][a-z0-9_-]{0,63}$",
|
||||
"shot_duration": {"min": 0.5, "max": 180.0},
|
||||
"total_duration": {"min": 5.0, "max": 180.0}, # 180 s: límite de Shorts
|
||||
"shots": {"min": 1, "max": 64},
|
||||
"max_narration_chars": 320,
|
||||
"max_silence_ranges": 16,
|
||||
}
|
||||
|
||||
|
||||
def _limit(limits: Optional[dict], *path: str) -> Any:
|
||||
"""Un número del sobre vivo, con el suelo debajo.
|
||||
|
||||
Se resuelve clave a clave y no de golpe: un shortsmith que publique un sobre
|
||||
a medias — o uno más nuevo con una clave que aquí todavía no se lee — deja
|
||||
las demás en su sitio en vez de tirar la validación entera al suelo.
|
||||
"""
|
||||
for source in (limits, BASELINE_LIMITS):
|
||||
node: Any = source
|
||||
for key in path:
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
node = None
|
||||
break
|
||||
node = node[key]
|
||||
if node is not None:
|
||||
return node
|
||||
raise KeyError(f"el sobre no publica {'.'.join(path)}")
|
||||
|
||||
|
||||
#: El objetivo editorial, que NO es el techo del contrato. 180 s es lo que el
|
||||
#: renderizador acepta; 20-45 s es lo que se ve entero.
|
||||
@@ -296,24 +335,27 @@ def _check_cross_field(template: str, props: Any, path: str) -> list[str]:
|
||||
|
||||
# --- el sobre ---------------------------------------------------------------
|
||||
|
||||
def _check_meta(meta: Any) -> list[str]:
|
||||
def _check_meta(meta: Any, limits: Optional[dict] = None) -> list[str]:
|
||||
if not isinstance(meta, dict):
|
||||
return ["meta: se esperaba un objeto"]
|
||||
errors = []
|
||||
spec_id = meta.get("id")
|
||||
if not isinstance(spec_id, str) or not META_ID.match(spec_id):
|
||||
pattern = re.compile(_limit(limits, "meta_id_pattern"))
|
||||
if not isinstance(spec_id, str) or not pattern.match(spec_id):
|
||||
errors.append("meta.id: minúsculas, dígitos, '_' y '-', empezando por "
|
||||
f"letra o dígito, hasta 64 caracteres (llegó {spec_id!r})")
|
||||
if not isinstance(meta.get("title"), str) or not meta.get("title"):
|
||||
errors.append("meta.title: obligatorio y no vacío")
|
||||
width = meta.get("width", 1080)
|
||||
height = meta.get("height", 1920)
|
||||
if (width, height) not in RESOLUTIONS:
|
||||
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(RESOLUTIONS))
|
||||
resolutions = [tuple(pair) for pair in _limit(limits, "resolutions")]
|
||||
if (width, height) not in resolutions:
|
||||
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(resolutions))
|
||||
errors.append(f"meta: {width}x{height} no es una resolución admitida ({allowed})")
|
||||
if meta.get("fps", 30) not in FPS_VALUES:
|
||||
fps_values = _limit(limits, "fps")
|
||||
if meta.get("fps", 30) not in fps_values:
|
||||
errors.append(f"meta.fps: {meta.get('fps')!r} no está entre "
|
||||
f"{sorted(FPS_VALUES)}")
|
||||
f"{sorted(fps_values)}")
|
||||
for key in meta:
|
||||
if key not in ("id", "title", "width", "height", "fps", "theme"):
|
||||
errors.append(f"meta.{key}: campo no permitido")
|
||||
@@ -327,7 +369,8 @@ BASELINE_PRESET_NAMES = ("sonar", "none")
|
||||
|
||||
|
||||
def _check_audio(audio: Any, total: float,
|
||||
presets: Optional[Iterable[str]] = None) -> list[str]:
|
||||
presets: Optional[Iterable[str]] = None,
|
||||
limits: Optional[dict] = None) -> list[str]:
|
||||
if audio is None:
|
||||
return []
|
||||
if not isinstance(audio, dict):
|
||||
@@ -340,8 +383,9 @@ def _check_audio(audio: Any, total: float,
|
||||
silence = audio.get("silence", [])
|
||||
if not isinstance(silence, list):
|
||||
return errors + ["audio.silence: se esperaba una lista de pares [inicio, fin]"]
|
||||
if len(silence) > 16:
|
||||
errors.append(f"audio.silence: {len(silence)} rangos, el máximo es 16")
|
||||
max_ranges = _limit(limits, "max_silence_ranges")
|
||||
if len(silence) > max_ranges:
|
||||
errors.append(f"audio.silence: {len(silence)} rangos, el máximo es {max_ranges}")
|
||||
for i, rango in enumerate(silence):
|
||||
if not (isinstance(rango, (list, tuple)) and len(rango) == 2
|
||||
and all(isinstance(v, (int, float)) for v in rango)):
|
||||
@@ -361,19 +405,17 @@ def _check_audio(audio: Any, total: float,
|
||||
return errors
|
||||
|
||||
|
||||
#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla
|
||||
#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero.
|
||||
MAX_NARRATION_CHARS = 320
|
||||
|
||||
|
||||
def _check_narration(narration: Any, path: str) -> list[str]:
|
||||
def _check_narration(narration: Any, path: str,
|
||||
limits: Optional[dict] = None) -> list[str]:
|
||||
"""El tope de narración de un shot, el mismo que aplica shortsmith.
|
||||
Rechazarla aquí cuesta un reintento del modelo; allí, el render entero."""
|
||||
if narration is None:
|
||||
return []
|
||||
if not isinstance(narration, str):
|
||||
return [f"{path}.narration: se esperaba texto"]
|
||||
if len(narration) > MAX_NARRATION_CHARS:
|
||||
return [f"{path}.narration: {len(narration)} caracteres, el máximo es "
|
||||
f"{MAX_NARRATION_CHARS}"]
|
||||
cap = _limit(limits, "max_narration_chars")
|
||||
if len(narration) > cap:
|
||||
return [f"{path}.narration: {len(narration)} caracteres, el máximo es {cap}"]
|
||||
return []
|
||||
|
||||
|
||||
@@ -386,15 +428,18 @@ def _total_duration(spec: dict) -> float:
|
||||
|
||||
|
||||
def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
presets: Optional[Iterable[str]] = None) -> None:
|
||||
presets: Optional[Iterable[str]] = None,
|
||||
limits: Optional[dict] = None) -> None:
|
||||
"""Lanza `SpecInvalid` con TODAS las rutas que fallan.
|
||||
|
||||
Se devuelven todos los errores de golpe a propósito: el bucle de reintento
|
||||
se los da al modelo verbatim y arreglar cinco de una vez sale más barato
|
||||
que cinco vueltas.
|
||||
|
||||
`presets` es la paleta viva de `GET /audio`; sin ella se valida contra la
|
||||
paleta base, que nunca acepta nada que un shortsmith viejo no renderice.
|
||||
Las tres mitades del contrato llegan vivas y ninguna se escribe aquí:
|
||||
`templates` de `GET /templates`, `presets` de `GET /audio` y `limits` de
|
||||
`GET /limits`. Sin cada una se cae a su base, que nunca acepta nada que un
|
||||
shortsmith viejo no renderice.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if not isinstance(spec, dict):
|
||||
@@ -407,15 +452,18 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
errors.append(f"{key}: campo no permitido en la raíz "
|
||||
"(las válidas son: version, meta, audio, shots)")
|
||||
|
||||
errors.extend(_check_meta(spec.get("meta")))
|
||||
errors.extend(_check_meta(spec.get("meta"), limits))
|
||||
|
||||
shots = spec.get("shots")
|
||||
if not isinstance(shots, list) or not shots:
|
||||
errors.append("shots: hace falta al menos un shot")
|
||||
raise SpecInvalid(errors)
|
||||
if len(shots) > MAX_SHOTS:
|
||||
errors.append(f"shots: {len(shots)} shots, el máximo es {MAX_SHOTS}")
|
||||
max_shots = _limit(limits, "shots", "max")
|
||||
if len(shots) > max_shots:
|
||||
errors.append(f"shots: {len(shots)} shots, el máximo es {max_shots}")
|
||||
|
||||
shot_min = _limit(limits, "shot_duration", "min")
|
||||
shot_max = _limit(limits, "shot_duration", "max")
|
||||
known = ", ".join(sorted(templates))
|
||||
for i, shot in enumerate(shots):
|
||||
path = f"shots.{i}"
|
||||
@@ -431,13 +479,13 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
if key not in ("template", "duration", "props", "narration"):
|
||||
errors.append(f"{path}.{key}: campo no permitido "
|
||||
"(las válidas son: template, duration, props, narration)")
|
||||
errors.extend(_check_narration(shot.get("narration"), path))
|
||||
errors.extend(_check_narration(shot.get("narration"), path, limits))
|
||||
duration = shot.get("duration")
|
||||
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
|
||||
errors.append(f"{path}.duration: obligatoria y numérica")
|
||||
elif not MIN_SHOT_DURATION <= duration <= MAX_TOTAL_DURATION:
|
||||
elif not shot_min <= duration <= shot_max:
|
||||
errors.append(f"{path}.duration: {duration} fuera de "
|
||||
f"[{MIN_SHOT_DURATION}, {MAX_TOTAL_DURATION}]")
|
||||
f"[{shot_min}, {shot_max}]")
|
||||
if "props" not in shot:
|
||||
errors.append(f"{path}.props: falta y es obligatorio")
|
||||
continue
|
||||
@@ -450,13 +498,15 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
errors.extend(_check_cross_field(template, shot["props"], props_path))
|
||||
|
||||
total = _total_duration(spec)
|
||||
if total < MIN_TOTAL_DURATION:
|
||||
total_min = _limit(limits, "total_duration", "min")
|
||||
total_max = _limit(limits, "total_duration", "max")
|
||||
if total < total_min:
|
||||
errors.append(f"shots: la duración total ({total:.2f}s) no llega al "
|
||||
f"mínimo de {MIN_TOTAL_DURATION}s")
|
||||
if total > MAX_TOTAL_DURATION:
|
||||
f"mínimo de {total_min}s")
|
||||
if total > total_max:
|
||||
errors.append(f"shots: la duración total ({total:.2f}s) pasa del límite "
|
||||
f"de {MAX_TOTAL_DURATION}s")
|
||||
errors.extend(_check_audio(spec.get("audio"), total, presets))
|
||||
f"de {total_max}s")
|
||||
errors.extend(_check_audio(spec.get("audio"), total, presets, limits))
|
||||
|
||||
if errors:
|
||||
raise SpecInvalid(errors)
|
||||
|
||||
@@ -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