From 198b0e62386452ce0be20ef8fac066af868878e4 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Tue, 1 Sep 2026 15:31:05 +0000 Subject: [PATCH] fix(short): los textos ilegibles se avisaban con el render ya pagado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/shortsmith-phases-4-6.md | 30 +++++++- src/generator/shortspec.py | 34 +++++---- src/generator/spec_contract.py | 133 ++++++++++++++++++++++++++++++--- tests/test_shortsmith_live.py | 13 ++++ tests/test_shortspec.py | 29 +++++++ tests/test_spec_contract.py | 129 +++++++++++++++++++++++++++++--- 6 files changed, 330 insertions(+), 38 deletions(-) diff --git a/docs/shortsmith-phases-4-6.md b/docs/shortsmith-phases-4-6.md index 461bd7c..5b0cbd3 100644 --- a/docs/shortsmith-phases-4-6.md +++ b/docs/shortsmith-phases-4-6.md @@ -280,8 +280,34 @@ second long would lose to the attempt that opened with a date, and the note woul decoration. 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 -already emits (one real caption was drawn at 20 px). +the same reason. + +### 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. --- diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index cb55524..1e92305 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -23,7 +23,7 @@ import structlog from src.generator.spec_contract import ( 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, 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. - Every string is drawn as given. Write them the way they should appear: \ 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 \ -will catch for you.** Nothing rejects a longer string: the renderer shrinks the \ -type until it fits, so a string at twice its budget is drawn at a fraction of \ -its size and ends up the smallest text on a frame it was supposed to dominate. \ +- **"CABE ~N caracteres dibujados" is a width, not a character count you can \ +argue with.** Nothing rejects a longer string: the renderer shrinks the type \ +until it fits, so a string at twice its budget is drawn at a fraction of its \ +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, \ 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. - Colours are palette names ({colors}) — never hex. - 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, 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 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 cambia. - El gancho va 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 - segundo intento que arregla el titular pero se pasa un segundo perdería - contra el primero, y el modelo habría obedecido la nota para nada. Los - segundos fuera de objetivo son un gradiente; abrir con una fecha o con un - plano que aún se está escribiendo es binario, y cuesta más. + 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útiles los avisos — un segundo + intento que arregla el titular, o acorta un rótulo que iba a salir + ilegible, pero se pasa un segundo, perdería contra el primero, y el modelo + habría obedecido la nota para nada. Los segundos fuera de objetivo son un + gradiente; abrir con una fecha, o dibujar un texto que no se lee, es + binario y cuesta más. """ if a is None: return b - def rank(r: SpecResult) -> tuple[bool, float]: - return (opens_badly(r.spec, templates), _off_target(r.spec)) + def rank(r: SpecResult) -> tuple[int, float]: + return (len(defect_notes(r.spec, templates)), _off_target(r.spec)) return a if rank(a) <= rank(b) else b diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index 386c1cb..2d5074c 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -28,7 +28,8 @@ __all__ = [ "validate_spec", "editorial_notes", "opening_notes", - "opens_badly", + "unreadable_notes", + "defect_notes", "estimated_duration", "spoken_seconds", "sentence_count", @@ -655,9 +656,113 @@ def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]: return [] -def opens_badly(spec: dict, templates: dict[str, dict]) -> bool: - """Si el gancho está mal. Lo usa el desempate entre intentos válidos.""" - return bool(opening_notes(spec, templates)) +#: Cuántos textos ilegibles se nombran antes de resumir. Cinco caben en un +#: mensaje de Telegram y son de sobra para que el modelo entienda el patrón; la +#: 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í sí 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 sí. 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]: @@ -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; 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 - # antes que su duración. - notes = opening_notes(spec, templates) + # Primero los defectos: si el vídeo abre mal o lleva un texto ilegible, eso + # va antes que su duración, que es un objetivo y no una avería. + notes = defect_notes(spec, templates) declared = _total_duration(spec) total = estimated_duration(spec) 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") if "maxLength" in schema: bits.append(f"máx {schema['maxLength']} caracteres") - # `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por - # shortsmith contra sus propias fuentes. No se valida — los caracteres son - # un proxy de los píxeles — pero es lo único que evita que el modelo escriba - # una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible. + # `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño y `x-fits-hard` + # dónde deja de leerse, los dos medidos por shortsmith contra sus propias + # fuentes. Ninguno se valida — los caracteres son un proxy de los píxeles — + # 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: 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", "≤"), ("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")): if key in schema: diff --git a/tests/test_shortsmith_live.py b/tests/test_shortsmith_live.py index 6a7ec6b..2ea7762 100644 --- a/tests/test_shortsmith_live.py +++ b/tests/test_shortsmith_live.py @@ -42,6 +42,19 @@ async def test_healthz_and_templates(): 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_render_the_reference_example_end_to_end(tmp_path): diff --git a/tests/test_shortspec.py b/tests/test_shortspec.py index f1393a4..2ccb4d8 100644 --- a/tests/test_shortspec.py +++ b/tests/test_shortspec.py @@ -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 +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(): w, _ = writer("{}") 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] +@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 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.""" diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index 1048d7e..ac8b301 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -13,7 +13,7 @@ import pytest from src.generator.spec_contract import ( SpecInvalid, describe_templates, editorial_notes, opening_notes, - validate_spec, + unreadable_notes, validate_spec, ) EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json" @@ -23,8 +23,10 @@ TEMPLATES = { "type": "object", "additionalProperties": False, "required": ["headline"], "properties": { - "headline": {"type": "string", "minLength": 1}, - "subline": {"type": "string", "default": ""}, + "headline": {"type": "string", "minLength": 1, + "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, "exclusiveMaximum": 360, "default": 210.0}, "sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10, @@ -38,20 +40,26 @@ TEMPLATES = { "type": "object", "additionalProperties": False, "required": ["label", "value"], "properties": { - "label": {"type": "string", "minLength": 1}, + "label": {"type": "string", "minLength": 1, + "x-fits": 32, "x-fits-hard": 53}, "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"], "type": "string", "default": "ink"}, - "value_label": {"type": "string", "default": ""}, + "value_label": {"type": "string", "default": "", + "x-fits": 30, "x-fits-hard": 49}, }, }}, "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"}, "minItems": 1, "maxItems": 3}, - "quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2}, - "attribution": {"type": "string", "default": ""}, + "quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2, + "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(): text = describe_templates(TEMPLATES) 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 "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(): @@ -644,6 +658,99 @@ def test_the_hook_note_comes_before_the_duration_one(): 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(): """`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."""