fix(short): los textos ilegibles se avisaban con el render ya pagado
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s

La nota que teníamos era falsa: los avisos `severe` de shortsmith SÍ llegan a
un humano — el informe de Telegram los saca en rojo. Lo que pasa es que llegan
colgados del render TERMINADO, así que hacerles caso significa editar el spec a
mano y pagar un segundo. Por eso nadie actuó nunca.

`x-fits` tampoco podía ser la comprobación, y a propósito: es guía blanda que el
propio ejemplo de referencia se salta por uno o tres caracteres viéndose bien.
Parar ahí sería gritar con specs buenos, y un aviso que grita se ignora — que es
justo cómo sobrevivieron los graves. Así que shortsmith publica desde 289d50e un
segundo número medido por campo, `x-fits-hard`, y esto lo comprueba antes de
gastar el render: cuesta un reintento del modelo en vez de un render. El mismo
movimiento que hizo `MAX_CUE_CHARS` con los captions en e32c59f.

Auditado contra los 17 short_en de producción: OCHO llevan al menos un texto que
se dibuja ilegible.

- El ya conocido, Cash-Landrum: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH
  RADIATION EXPOSURE`, 36 px pedidos y 20 dibujados. Salta con 63 caracteres
  contra un presupuesto de 61 — así de vertical es la curva pegada al muro.
- El que nadie había visto es peor y más común: el CARTEL DE CIERRE.
  `counter_close.lines` pide 110 px y se dibujó a 28 en el peor caso, y por
  debajo de 64 en cinco de los ocho. Es la llamada a la acción, y en un tercio
  del catálogo es el texto más pequeño del fotograma.
- Y la tolerancia aguanta sobre datos reales: `STILL UNEXPLAINED` encoge de 110
  a 84 px y no se avisa, que es lo correcto.

El desempate entre intentos pasa a contar averías —gancho y texto ilegible—
antes que segundos, por la misma razón que en 6d9b602: una reescritura que
arregla un rótulo pero se pasa un segundo perdía contra la que no lo arreglaba.

Un campo sin presupuesto propio (`Bar.unit`, que se dibuja dentro de la cadena
de `value_label`) se salta a conciencia: reconstruir esa cadena aquí pediría
conocer el formato de la plantilla, que es lo que este repo no sabe ni debe.

Y contra un shortsmith anterior a 289d50e esto se calla — no puede inventarse el
número —, así que de que el contrato traiga el campo se encarga
test_shortsmith_live.py, que es quien habla con el servicio.

Suite: 274 pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-09-01 15:31:05 +00:00
co-authored by Claude Opus 5
parent 6d9b6025ba
commit 198b0e6238
6 changed files with 330 additions and 38 deletions
+28 -2
View File
@@ -280,8 +280,34 @@ second long would lose to the attempt that opened with a date, and the note woul
decoration. decoration.
Still open on the generator side: `document_quote` is also weak as the *second* shot for Still open on the generator side: `document_quote` is also weak as the *second* shot for
the same reason, and nothing yet reads the `severe: true` auto-fit warnings shortsmith the same reason.
already emits (one real caption was drawn at 20 px).
### Text drawn unreadable — **done 2026-09-01**
The `severe: true` auto-fit warnings were never the problem we had written down. They
*do* reach a human: the Telegram report prints them in red with "quedaron ILEGIBLES".
They just arrive attached to the **finished render**, so acting on one means editing the
spec by hand and paying for a second one — which is why nobody ever did.
`x-fits` could not be the check either, and deliberately so: it is soft guidance the
reference example itself exceeds by a character or three while looking right. A check
there would fire on good specs and get ignored, which is how the genuinely bad ones
survived. So shortsmith now publishes a second measured number per field (`289d50e`):
`x-fits-hard`, the length past which auto-fit's shrink turns severe. `unreadable_notes()`
checks it before the render, at the cost of one model retry instead of one render —
the same move `MAX_CUE_CHARS` made for captions in `e32c59f`.
Audited against the seventeen `short_en` in production: **eight carry at least one text
that is drawn unreadable.** The one already known — Cash-Landrum's
`ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE`, 36 px requested and
20 px drawn — is flagged at 63 characters against a budget of 61, which is how steep the
curve is near the wall. The one nobody had noticed is worse and more common: the
**closing card**, `counter_close.lines`, asks for 110 px and was drawn at **28** in the
worst case and under 64 in five of the eight. That is the call to action, and in a third
of the catalogue it is the smallest type on the frame.
The tolerance holds on real data too: `STILL UNEXPLAINED` is shrunk 110 → 84 px and is
correctly left alone.
--- ---
+20 -14
View File
@@ -23,7 +23,7 @@ import structlog
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, estimated_duration, SpecInvalid, describe_templates, editorial_notes, estimated_duration,
max_words_in, opens_badly, validate_spec, NARRATION_ROUNDED_PAD, defect_notes, max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND, NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
TARGET_MAX_DURATION, TARGET_MIN_DURATION, TARGET_MAX_DURATION, TARGET_MIN_DURATION,
) )
@@ -136,12 +136,17 @@ headline. A {max_shot:.0f}-second shot with a short line on it is not a \
generous shot, it is a shot the viewer has already finished reading. generous shot, it is a shot the viewer has already finished reading.
- Every string is drawn as given. Write them the way they should appear: \ - Every string is drawn as given. Write them the way they should appear: \
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words. SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
- **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \ - **"CABE ~N caracteres dibujados" is a width, not a character count you can \
will catch for you.** Nothing rejects a longer string: the renderer shrinks the \ argue with.** Nothing rejects a longer string: the renderer shrinks the type \
type until it fits, so a string at twice its budget is drawn at a fraction of \ until it fits, so a string at twice its budget is drawn at a fraction of its \
its size and ends up the smallest text on a frame it was supposed to dominate. \ size and ends up the smallest text on a frame it was supposed to dominate. \
Stay at or under N. On a quote that means picking a shorter verbatim span, \ Stay at or under N. On a quote that means picking a shorter verbatim span, \
never squeezing the whole sentence in. never squeezing the whole sentence in.
- **"ILEGIBLE por encima de M" is the line that is actually checked**, before \
anything renders. Between N and M the text is drawn a little smaller and looks \
fine that tolerance is deliberate. Past M it is not a smaller headline, it is \
an unreadable one: one real caption asked for 36 px and was drawn at 20 on a \
1080-wide frame. Aim at N; M is the wall.
- Respect every max length and list-length limit above. They are enforced. - Respect every max length and list-length limit above. They are enforced.
- Colours are palette names ({colors}) never hex. - Colours are palette names ({colors}) never hex.
- Quotes carry the typographic quote marks: SPLIT RADAR IMAGE, with U+201C \ - Quotes carry the typographic quote marks: SPLIT RADAR IMAGE, with U+201C \
@@ -461,24 +466,25 @@ def _off_target(spec: dict) -> float:
def _closer_to_target(a: Optional[SpecResult], b: SpecResult, def _closer_to_target(a: Optional[SpecResult], b: SpecResult,
templates: dict[str, dict]) -> SpecResult: templates: dict[str, dict]) -> SpecResult:
"""De dos specs válidos, el mejor: primero el gancho, luego la duración. """De dos specs válidos, el mejor: primero las averías, luego la duración.
Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que
obedecía la nota a medias 53 s en vez de 58 se tiraba entera y salía el obedecía la nota a medias 53 s en vez de 58 se tiraba entera y salía el
largo. El empate se lo lleva el anterior: sin razón para cambiar, no se largo. El empate se lo lleva el anterior: sin razón para cambiar, no se
cambia. cambia.
El gancho va delante de los segundos, y no por gusto: ordenar sólo por Las averías van delante de los segundos, y no por gusto: ordenar sólo por
duración deja pasar el caso que hace inútil el aviso de apertura un duración deja pasar el caso que hace inútiles los avisos un segundo
segundo intento que arregla el titular pero se pasa un segundo perdería intento que arregla el titular, o acorta un rótulo que iba a salir
contra el primero, y el modelo habría obedecido la nota para nada. Los ilegible, pero se pasa un segundo, perdería contra el primero, y el modelo
segundos fuera de objetivo son un gradiente; abrir con una fecha o con un habría obedecido la nota para nada. Los segundos fuera de objetivo son un
plano que aún se está escribiendo es binario, y cuesta más. gradiente; abrir con una fecha, o dibujar un texto que no se lee, es
binario y cuesta más.
""" """
if a is None: if a is None:
return b return b
def rank(r: SpecResult) -> tuple[bool, float]: def rank(r: SpecResult) -> tuple[int, float]:
return (opens_badly(r.spec, templates), _off_target(r.spec)) return (len(defect_notes(r.spec, templates)), _off_target(r.spec))
return a if rank(a) <= rank(b) else b return a if rank(a) <= rank(b) else b
+122 -11
View File
@@ -28,7 +28,8 @@ __all__ = [
"validate_spec", "validate_spec",
"editorial_notes", "editorial_notes",
"opening_notes", "opening_notes",
"opens_badly", "unreadable_notes",
"defect_notes",
"estimated_duration", "estimated_duration",
"spoken_seconds", "spoken_seconds",
"sentence_count", "sentence_count",
@@ -655,9 +656,113 @@ def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
return [] return []
def opens_badly(spec: dict, templates: dict[str, dict]) -> bool: #: Cuántos textos ilegibles se nombran antes de resumir. Cinco caben en un
"""Si el gancho está mal. Lo usa el desempate entre intentos válidos.""" #: mensaje de Telegram y son de sobra para que el modelo entienda el patrón; la
return bool(opening_notes(spec, templates)) #: lista entera sólo entierra el consejo.
MAX_NAMED_UNREADABLE = 5
def _budget_of(node: Any) -> Optional[int]:
"""`x-fits-hard` del nodo, resuelto por si el campo es una lista de textos."""
if not isinstance(node, dict):
return None
hard = node.get("x-fits-hard")
return hard if isinstance(hard, int) else None
def _too_long(value: Any, node: Any, path: str) -> list[tuple[str, int, int]]:
"""(ruta, longitud, presupuesto) de cada cadena que pasa de `x-fits-hard`."""
hard = _budget_of(node)
if hard is None:
return []
if isinstance(value, str):
return [(path, len(value), hard)] if len(value) > hard else []
if isinstance(value, list):
# El presupuesto de una lista de textos es POR LÍNEA: el campo se dibuja
# como varias líneas, no como una cadena unida. Medirlo sobre el total
# avisaría de una cita bien partida en dos.
return [(f"{path}[{i}]", len(v), hard)
for i, v in enumerate(value)
if isinstance(v, str) and len(v) > hard]
return []
def unreadable_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Los textos que shortsmith va a dibujar ilegibles, dichos ANTES del render.
`x-fits` es guía blanda y está bien que lo sea: el ejemplo de referencia se
pasa de varios de sus propios presupuestos por uno o tres caracteres y se ve
bien. Parar ahí sería gritar con specs buenos. `x-fits-hard` es la otra
línea que shortsmith publica desde 289d50e: pasada ella la cadena no es un
titular más pequeño, es uno ilegible, y ahí hay algo que decir.
El caso real: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION
EXPOSURE` pidió 36 px y se dibujó a **20** en un fotograma de 1080 de ancho.
El aviso existía shortsmith lo manda con `severe`, y el informe de Telegram
lo saca en rojo pero salía del render TERMINADO, y hacerle caso significaba
editar el spec a mano y pagar un segundo render. Comprobarlo aquí cuesta un
reintento del modelo. Es exactamente el mismo movimiento que `MAX_CUE_CHARS`
hizo con los captions.
Nota editorial y no error de contrato, y a propósito: los caracteres son un
proxy de los píxeles y el número es una medida sobre inglés en mayúsculas
realista, así que una cadena estrecha puede pasarse de la cuenta y caber.
Rechazarla sería el error que `x-fits` evita a conciencia.
Los campos con `x-fits-part-of` se saltan: no tienen presupuesto propio
porque se dibujan dentro de la cadena de otro (`Bar.unit` va en
`f"{value} {unit}"`), y reconstruir esa cadena aquí pediría conocer el
formato de la plantilla, que es justo lo que este repo no sabe ni debe.
"""
found: list[tuple[str, int, int]] = []
for i, shot in enumerate(spec.get("shots") or []):
if not isinstance(shot, dict):
continue
schema = templates.get(shot.get("template")) if isinstance(templates, dict) else None
if not isinstance(schema, dict):
continue
defs = schema.get("$defs", {})
props = shot.get("props")
if not isinstance(props, dict):
continue
for name, value in props.items():
node = (schema.get("properties") or {}).get(name)
if not isinstance(node, dict) or "x-fits-part-of" in node:
continue
path = f"shots.{i}.{name}"
found.extend(_too_long(value, node, path))
# Listas de objetos: el presupuesto vive en el submodelo.
item = _resolve(node.get("items", {}), defs) if node.get("type") == "array" else {}
if item.get("type") == "object" and isinstance(value, list):
for k, entry in enumerate(value):
if not isinstance(entry, dict):
continue
for sub, sub_value in entry.items():
sub_node = (item.get("properties") or {}).get(sub)
if isinstance(sub_node, dict) and "x-fits-part-of" not in sub_node:
found.extend(_too_long(sub_value, sub_node,
f"{path}[{k}].{sub}"))
if not found:
return []
named = ", ".join(f"{path} ({n} caracteres, caben {hard})"
for path, n, hard in found[:MAX_NAMED_UNREADABLE])
rest = f" y {len(found) - MAX_NAMED_UNREADABLE} más" if len(found) > MAX_NAMED_UNREADABLE else ""
return [f"{len(found)} texto{'s' if len(found) > 1 else ''} se va{'n' if len(found) > 1 else ''} "
"a dibujar ILEGIBLE: el renderizador encoge lo que no cabe y a esta "
"longitud queda por debajo de la mitad del tamaño de diseño. Acorta "
f"{named}{rest}"]
def defect_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Lo que está MAL, frente a lo que está fuera de objetivo.
La distinción decide dos cosas: qué se dice primero y, sobre todo, cómo se
eligen los intentos entre . Un vídeo que se pasa cinco segundos se ve; uno
que abre con una fecha o lleva un rótulo ilegible no se arregla durando
menos.
"""
return opening_notes(spec, templates) + unreadable_notes(spec, templates)
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]: def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
@@ -667,9 +772,9 @@ def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez; 70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez;
si insiste, se renderiza igual antes que tirar la generación a la basura. si insiste, se renderiza igual antes que tirar la generación a la basura.
""" """
# Primero el gancho: si el vídeo se abre mal, es lo que hay que arreglar # Primero los defectos: si el vídeo abre mal o lleva un texto ilegible, eso
# antes que su duración. # va antes que su duración, que es un objetivo y no una avería.
notes = opening_notes(spec, templates) notes = defect_notes(spec, templates)
declared = _total_duration(spec) declared = _total_duration(spec)
total = estimated_duration(spec) total = estimated_duration(spec)
stretched = total > declared + 0.5 stretched = total > declared + 0.5
@@ -747,12 +852,18 @@ def _describe_field(name: str, schema: dict, required: bool, defs: dict,
bits.append("no vacío") bits.append("no vacío")
if "maxLength" in schema: if "maxLength" in schema:
bits.append(f"máx {schema['maxLength']} caracteres") bits.append(f"máx {schema['maxLength']} caracteres")
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por # `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño y `x-fits-hard`
# shortsmith contra sus propias fuentes. No se valida — los caracteres son # dónde deja de leerse, los dos medidos por shortsmith contra sus propias
# un proxy de los píxeles — pero es lo único que evita que el modelo escriba # fuentes. Ninguno se valida — los caracteres son un proxy de los píxeles —
# una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible. # pero el segundo sí se comprueba antes de renderizar (`unreadable_notes`),
# así que se le enseñan los dos: el objetivo y la línea roja. Sin el segundo,
# el modelo lee "~16" como una sugerencia sin consecuencia y escribe 58.
if "x-fits" in schema: if "x-fits" in schema:
bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados") bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados")
if "x-fits-hard" in schema:
bits.append(f"ILEGIBLE por encima de {schema['x-fits-hard']}")
if "x-fits-part-of" in schema:
bits.append(f"se dibuja dentro de {schema['x-fits-part-of']}, comparte su sitio")
for key, text in (("minimum", ""), ("maximum", ""), for key, text in (("minimum", ""), ("maximum", ""),
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")): ("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
if key in schema: if key in schema:
+13
View File
@@ -42,6 +42,19 @@ async def test_healthz_and_templates():
assert schema.get("type") == "object", f"{name} no publica un esquema de objeto" assert schema.get("type") == "object", f"{name} no publica un esquema de objeto"
assert "properties" in schema 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 @pytest.mark.asyncio
async def test_render_the_reference_example_end_to_end(tmp_path): async def test_render_the_reference_example_end_to_end(tmp_path):
+29
View File
@@ -116,6 +116,18 @@ def test_the_prompt_states_what_the_opening_shot_has_to_do():
assert "never the date" in prompt and "subline" in prompt assert "never the date" in prompt and "subline" in prompt
def test_the_prompt_states_both_text_budgets():
"""El primero es el objetivo y el segundo la línea roja, y hacen falta los
dos: con sólo "CABE ~16" el modelo lee una sugerencia sin consecuencia y
escribe 58. Con sólo la línea roja, apunta a ella y todo sale encogido."""
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert "CABE ~13 caracteres dibujados" in prompt # del esquema
assert "ILEGIBLE por encima de 21" in prompt
assert "is the line that is actually checked" in prompt
def test_prompt_includes_the_worked_example_in_full(): def test_prompt_includes_the_worked_example_in_full():
w, _ = writer("{}") w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST") prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
@@ -426,6 +438,23 @@ async def test_a_fixed_hook_beats_a_closer_duration():
assert result.notes and "objetivo" in result.notes[0] assert result.notes and "objetivo" in result.notes[0]
@pytest.mark.asyncio
async def test_a_shortened_label_also_beats_a_closer_duration():
"""El mismo desempate que el gancho, por la otra avería: un rótulo que se iba
a dibujar ilegible no se arregla durando menos."""
ilegible = json.loads(json.dumps(GOOD))
ilegible["shots"][0]["props"]["subline"] = "X" * 60 # 30 s: en objetivo
corto = json.loads(json.dumps(GOOD))
corto["shots"][0]["props"]["subline"] = "SOCORRO, NEW MEXICO"
for shot in corto["shots"]:
shot["duration"] = 24.0 # 48 s: se pasa
w, _ = writer(json.dumps(ilegible), json.dumps(corto))
result = await w.write("Caso X", "material")
assert result.spec["shots"][0]["props"]["subline"] == "SOCORRO, NEW MEXICO"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_note_does_not_eat_the_attempt_the_contract_needs(): async def test_a_note_does_not_eat_the_attempt_the_contract_needs():
"""Si la reescritura sale malformada, aún queda un intento para arreglarla.""" """Si la reescritura sale malformada, aún queda un intento para arreglarla."""
+118 -11
View File
@@ -13,7 +13,7 @@ import pytest
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, opening_notes, SpecInvalid, describe_templates, editorial_notes, opening_notes,
validate_spec, unreadable_notes, validate_spec,
) )
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json" EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
@@ -23,8 +23,10 @@ TEMPLATES = {
"type": "object", "additionalProperties": False, "type": "object", "additionalProperties": False,
"required": ["headline"], "required": ["headline"],
"properties": { "properties": {
"headline": {"type": "string", "minLength": 1}, "headline": {"type": "string", "minLength": 1,
"subline": {"type": "string", "default": ""}, "x-fits": 13, "x-fits-hard": 21},
"subline": {"type": "string", "default": "",
"x-fits": 27, "x-fits-hard": 42},
"contact_bearing_deg": {"type": "number", "minimum": 0, "contact_bearing_deg": {"type": "number", "minimum": 0,
"exclusiveMaximum": 360, "default": 210.0}, "exclusiveMaximum": 360, "default": 210.0},
"sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10, "sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10,
@@ -38,20 +40,26 @@ TEMPLATES = {
"type": "object", "additionalProperties": False, "type": "object", "additionalProperties": False,
"required": ["label", "value"], "required": ["label", "value"],
"properties": { "properties": {
"label": {"type": "string", "minLength": 1}, "label": {"type": "string", "minLength": 1,
"x-fits": 32, "x-fits-hard": 53},
"value": {"type": "number", "exclusiveMinimum": 0}, "value": {"type": "number", "exclusiveMinimum": 0},
"unit": {"type": "string", "default": ""}, "unit": {"type": "string", "default": "",
"x-fits-part-of": "value_label"},
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"], "color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
"type": "string", "default": "ink"}, "type": "string", "default": "ink"},
"value_label": {"type": "string", "default": ""}, "value_label": {"type": "string", "default": "",
"x-fits": 30, "x-fits-hard": 49},
}, },
}}, }},
"properties": { "properties": {
"headline": {"type": "string", "minLength": 1}, "headline": {"type": "string", "minLength": 1,
"x-fits": 16, "x-fits-hard": 26},
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"}, "bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
"minItems": 1, "maxItems": 3}, "minItems": 1, "maxItems": 3},
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2}, "quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2,
"attribution": {"type": "string", "default": ""}, "x-fits": 33, "x-fits-hard": 49},
"attribution": {"type": "string", "default": "",
"x-fits": 46, "x-fits-hard": 73},
}, },
}, },
} }
@@ -353,10 +361,16 @@ def test_a_spec_that_is_not_even_a_dict():
def test_describe_templates_is_driven_by_what_the_service_publishes(): def test_describe_templates_is_driven_by_what_the_service_publishes():
text = describe_templates(TEMPLATES) text = describe_templates(TEMPLATES)
assert "radar_sweep:" in text and "scale_bars:" in text assert "radar_sweep:" in text and "scale_bars:" in text
assert "headline: string, no vacío, OBLIGATORIO" in text
assert "1-3 elementos" in text # los límites llegan al prompt assert "1-3 elementos" in text # los límites llegan al prompt
assert "ink, amber, amber_dark, muted, dim, red" in text assert "ink, amber, amber_dark, muted, dim, red" in text
assert "label: string, no vacío, OBLIGATORIO" in text # despliega los objetos anidados # Los dos presupuestos, y el que no tiene por dibujarse dentro de otro: el
# modelo apunta al primero, y el segundo es el que se le comprueba.
assert "headline: string, no vacío, CABE ~13 caracteres dibujados, " \
"ILEGIBLE por encima de 21, OBLIGATORIO" in text
assert "unit: string, se dibuja dentro de value_label, comparte su sitio" in text
# Y despliega los objetos anidados, con sus presupuestos de `$defs`.
assert "label: string, no vacío, CABE ~32 caracteres dibujados, " \
"ILEGIBLE por encima de 53, OBLIGATORIO" in text
def test_a_template_nobody_wrote_here_still_gets_described(): def test_a_template_nobody_wrote_here_still_gets_described():
@@ -644,6 +658,99 @@ def test_the_hook_note_comes_before_the_duration_one():
assert "fecha" in notes[0] and "objetivo" in notes[1] assert "fecha" in notes[0] and "objetivo" in notes[1]
# --- textos que se van a dibujar ilegibles -----------------------------------
# `x-fits` es guía blanda y tiene que serlo: el ejemplo de referencia se pasa de
# varios de sus propios presupuestos por uno o tres caracteres y se ve bien.
# `x-fits-hard` es la otra línea, y esa sí se comprueba antes de gastar el
# render — que es donde el aviso llegaba antes, con el vídeo ya pagado.
def one_shot(template="radar_sweep", **props):
return spec_with({"template": template, "duration": 25.0, "props": props})
def test_a_text_past_the_hard_budget_is_flagged():
"""El caso real, medido sobre Cash-Landrum: un texto pidió 36 px y se dibujó
a 20 en un fotograma de 1080 de ancho."""
largo = "ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE"
note = unreadable_notes(one_shot(headline=largo), TEMPLATES)[0]
assert "ILEGIBLE" in note
assert "shots.0.headline" in note and f"{len(largo)} caracteres" in note
assert "caben 21" in note
def test_a_text_between_the_two_budgets_is_left_alone():
"""El control, y es la mitad del diseño: entre `x-fits` y `x-fits-hard` el
texto sale un poco más pequeño y se ve bien. Avisar ahí sería gritar con
specs buenos, y un aviso que grita se acaba ignorando que es exactamente
cómo el de verdad grave se pasó meses sin que nadie actuara."""
assert len("3 RADARS TRACKING") > 13 # por encima del x-fits
assert len("3 RADARS TRACKING") < 21 # por debajo del ilegible
assert unreadable_notes(one_shot(headline="3 RADARS TRACKING"), TEMPLATES) == []
def test_the_budget_of_a_list_of_lines_is_per_line():
"""Una cita se dibuja partida en líneas, así que el presupuesto es por línea.
Medirlo sobre el texto unido avisaría de una cita bien partida en dos."""
dos = ["A QUOTE SPLIT WHERE IT HAS TO", "BREAK SO THAT IT FITS ON SCREEN"]
assert sum(len(x) for x in dos) > 49 and all(len(x) < 49 for x in dos)
ok = spec_with(shot("scale_bars", quote=dos))
assert unreadable_notes(ok, TEMPLATES) == []
larga = spec_with(shot("scale_bars", quote=["X" * 60]))
assert "shots.0.quote[0]" in unreadable_notes(larga, TEMPLATES)[0]
def test_the_budget_inside_a_list_of_objects_is_found():
"""Los presupuestos de un submodelo viven en `$defs`, y saltárselos fue justo
el agujero por el que shortsmith se pasó meses sin medir nueve campos."""
doc = spec_with(shot("scale_bars",
bars=[{"label": "BOEING 747", "value": 232},
{"label": "X" * 60, "value": 100}]))
note = unreadable_notes(doc, TEMPLATES)[0]
assert "shots.0.bars[1].label" in note
def test_a_field_drawn_inside_another_is_not_judged_alone():
"""`unit` no tiene presupuesto propio: se dibuja dentro de la cadena de
`value_label`. Juzgarlo solo sería inventarse un límite que el contrato dice
expresamente que no existe."""
doc = spec_with(shot("scale_bars",
bars=[{"label": "BOEING 747", "value": 232,
"unit": "X" * 60}]))
assert unreadable_notes(doc, TEMPLATES) == []
def test_an_old_contract_without_the_hard_budget_says_nothing():
"""shortsmith publicó `x-fits-hard` en 289d50e. Contra uno anterior esto no
puede inventarse el número: se calla, y de que el contrato lo traiga se
encarga `test_shortsmith_live.py`, que es quien habla con el servicio."""
viejo = {"radar_sweep": {"type": "object", "required": ["headline"],
"properties": {"headline": {"type": "string",
"x-fits": 13}}}}
assert unreadable_notes(one_shot(headline="X" * 90), viejo) == []
def test_the_defects_come_before_the_duration():
"""Los tres avisos pueden salir juntos y quien los lee coge `[0]`. Primero lo
que está roto, después lo que está fuera de objetivo."""
doc = spec_with({"template": "radar_sweep", "duration": 90.0,
"props": {"headline": "8 JAN 1981",
"subline": "X" * 60}})
notes = editorial_notes(doc, TEMPLATES)
assert len(notes) == 3
assert "fecha" in notes[0] and "ILEGIBLE" in notes[1] and "objetivo" in notes[2]
def test_the_prompt_carries_how_much_text_actually_fits(): def test_the_prompt_carries_how_much_text_actually_fits():
"""`x-fits` es el único límite que nada rechaza: si no llega al prompt, el """`x-fits` es el único límite que nada rechaza: si no llega al prompt, el
modelo escribe una cita de 58 caracteres para un hueco de 16.""" modelo escribe una cita de 58 caracteres para un hueco de 16."""