Files
researchowl/tests/test_spec_contract.py
T
ChemaVXandClaude Opus 5 198b0e6238
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
fix(short): los textos ilegibles se avisaban con el render ya pagado
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>
2026-09-01 15:31:05 +00:00

776 lines
34 KiB
Python

"""Validación local del spec contra el esquema publicado por shortsmith.
Los esquemas de abajo son una COPIA REDUCIDA de lo que devuelve
`GET /templates`, sólo para los tests: en producción se piden en vivo. Si
shortsmith cambia el contrato, quien lo nota es `test_shortsmith_live.py`, no
esto.
"""
import copy
import json
from pathlib import Path
import pytest
from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, opening_notes,
unreadable_notes, validate_spec,
)
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
TEMPLATES = {
"radar_sweep": {
"type": "object", "additionalProperties": False,
"required": ["headline"],
"properties": {
"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,
"default": 2.0},
},
},
"scale_bars": {
"type": "object", "additionalProperties": False,
"required": ["headline", "bars"],
"$defs": {"Bar": {
"type": "object", "additionalProperties": False,
"required": ["label", "value"],
"properties": {
"label": {"type": "string", "minLength": 1,
"x-fits": 32, "x-fits-hard": 53},
"value": {"type": "number", "exclusiveMinimum": 0},
"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": "",
"x-fits": 30, "x-fits-hard": 49},
},
}},
"properties": {
"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,
"x-fits": 33, "x-fits-hard": 49},
"attribution": {"type": "string", "default": "",
"x-fits": 46, "x-fits-hard": 73},
},
},
}
def shot(template="radar_sweep", duration=6.0, **props):
base = {"radar_sweep": {"headline": "3 RADARS"},
"scale_bars": {"headline": "ESCALA",
"bars": [{"label": "BOEING 747", "value": 232}]}}[template]
return {"template": template, "duration": duration, "props": {**base, **props}}
def spec_with(*shots, **meta):
return {
"version": 1,
"meta": {"id": "caso", "title": "Un caso", **meta},
"shots": list(shots) or [shot()],
}
def errors_of(spec, templates=None):
with pytest.raises(SpecInvalid) as exc:
validate_spec(spec, templates if templates is not None else TEMPLATES)
return exc.value.errors
# --- lo que pasa ------------------------------------------------------------
def test_a_minimal_valid_spec_passes():
validate_spec(spec_with(shot(duration=25.0)), TEMPLATES)
def test_the_reference_example_passes_against_its_own_templates():
"""El ejemplo de referencia es válido; se comprueba con esquemas laxos para
las plantillas que este fichero no copia (lo estricto lo cubre el test vivo)."""
spec = json.loads(EXAMPLE.read_text())
permissive = {name: {"type": "object"} for name in
{s["template"] for s in spec["shots"]}}
permissive.update(TEMPLATES)
validate_spec(spec, permissive)
# --- rutas de error ---------------------------------------------------------
def test_unknown_prop_is_reported_with_its_full_path():
"""El typo en un nombre de prop es el bug más probable de un spec escrito
por un LLM, y la ruta exacta es lo que se le devuelve para arreglarlo."""
errors = errors_of(spec_with(shot(sweeeps=2)))
assert any(e.startswith("shots.0.radar_sweep.props.sweeeps: campo no permitido")
for e in errors), errors
assert "sweeps" in errors[0], "hay que decirle cuáles SÍ valen"
def test_unknown_template_lists_the_valid_names():
errors = errors_of(spec_with({"template": "radar_swep", "duration": 6.0,
"props": {"headline": "X"}}))
assert errors[0].startswith("shots.0.template:")
assert "radar_sweep" in errors[0] and "scale_bars" in errors[0]
def test_missing_required_prop():
bad = spec_with(shot()); del bad["shots"][0]["props"]["headline"]
assert "shots.0.radar_sweep.props.headline: falta y es obligatorio" in errors_of(bad)
def test_empty_string_where_a_non_empty_one_is_required():
assert any("shots.0.radar_sweep.props.headline" in e
for e in errors_of(spec_with(shot(headline=""))))
def test_numeric_bounds():
errors = errors_of(spec_with(shot(contact_bearing_deg=400)))
assert "shots.0.radar_sweep.props.contact_bearing_deg: 400 debe ser < 360" in errors
def test_list_length_limits_are_enforced():
bars = [{"label": f"B{i}", "value": i + 1} for i in range(4)]
errors = errors_of(spec_with(shot("scale_bars", bars=bars)))
assert "shots.0.scale_bars.props.bars: 4 elementos, el máximo es 3" in errors
def test_colour_must_be_a_palette_name_never_hex():
errors = errors_of(spec_with(shot("scale_bars", bars=[
{"label": "OBJETO", "value": 2000, "color": "#ffbf00"}])))
assert any("color" in e and "amber" in e for e in errors)
def test_nested_paths_survive_lists():
errors = errors_of(spec_with(shot("scale_bars", bars=[
{"label": "BOEING 747", "value": 232},
{"label": "OBJETO", "value": -5}])))
assert "shots.0.scale_bars.props.bars.1.value: -5 debe ser > 0" in errors
def test_every_error_comes_back_at_once():
"""Se devuelven todos: arreglar cinco de una vez sale más barato que cinco vueltas."""
errors = errors_of(spec_with(shot(headline="", sweeeps=1, contact_bearing_deg=999)))
assert len(errors) >= 3
# --- el sobre ---------------------------------------------------------------
def test_meta_id_pattern():
assert any(e.startswith("meta.id:") for e in errors_of(spec_with(id="Caso Roswell")))
def test_resolution_must_be_a_shorts_one():
assert any("no es una resolución admitida" in e
for e in errors_of(spec_with(shot(), width=800, height=600)))
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."""
long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s
validate_spec(long_spec, TEMPLATES)
assert editorial_notes(long_spec, TEMPLATES)
too_long = spec_with(*[shot(duration=30.0) for _ in range(7)]) # 210 s
assert any("pasa del límite" in e for e in errors_of(too_long))
def test_total_duration_floor():
assert any("no llega al mínimo" in e
for e in errors_of(spec_with(shot(duration=2.0))))
class TestCrossFieldRules:
"""Las reglas de pydantic que cruzan campos, replicadas a mano.
No salen en el JSON Schema publicado, así que antes se dejaban al 422 del
servidor — y ese 422 llega al RENDERIZAR, cuando el bucle de reintentos ya
ha terminado. O sea que no costaban un reintento: costaban la generación
entera y no daban vídeo. Pasó de verdad con la sesión 162 el 2026-08-13.
"""
def test_three_bars_and_a_quote_do_not_fit(self):
# El fallo exacto de la 162, con el texto exacto de shortsmith.
errors = errors_of(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
{"label": "C", "value": 3}],
quote=["“UNA CITA”"])))
assert any("3 bars leave no room for a quote" in e for e in errors)
assert any(e.startswith("shots.0.scale_bars.props") for e in errors)
def test_three_bars_without_a_quote_are_fine(self):
"""La regla es sobre el hueco, no sobre el número de barras."""
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
{"label": "C", "value": 3}])), TEMPLATES)
def test_two_bars_with_a_quote_are_fine(self):
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2}],
quote=["“UNA CITA”"])), TEMPLATES)
def test_a_waypoint_outside_a_pinned_window_is_rejected(self):
"""La proyección no recorta: un waypoint fuera se dibuja donde diga la
aritmética, a veces fuera del encuadre."""
templates = {"track_map": {"type": "object"}}
spec = spec_with({
"template": "track_map", "duration": 25.0,
"props": {
"headline": "RUTA",
"waypoints": [{"label": "DENTRO", "lat": 62.0, "lon": -148.0},
{"label": "FUERA", "lat": 20.0, "lon": -148.0}],
"bounds": {"lat_min": 60.0, "lat_max": 67.0,
"lon_min": -152.0, "lon_max": -143.0}}})
errors = errors_of(spec, templates)
assert any("waypoints outside the map bounds: FUERA" in e for e in errors)
def test_bounds_with_max_below_min_are_rejected(self):
templates = {"track_map": {"type": "object"}}
spec = spec_with({
"template": "track_map", "duration": 25.0,
"props": {
"headline": "RUTA",
"waypoints": [{"label": "A", "lat": 62.0, "lon": -148.0}],
"bounds": {"lat_min": 67.0, "lat_max": 60.0,
"lon_min": -152.0, "lon_max": -143.0}}})
assert any("max greater than min" in e for e in errors_of(spec, templates))
def test_a_fitted_window_needs_no_check(self):
"""Sin `bounds`, shortsmith ajusta la ventana a la ruta: están dentro
por construcción y no hay nada que comprobar."""
templates = {"track_map": {"type": "object"}}
validate_spec(spec_with({
"template": "track_map", "duration": 25.0,
"props": {"headline": "RUTA",
"waypoints": [{"label": "A", "lat": 2.0, "lon": -1.0}]}}),
templates)
def test_two_quotes_welded_into_one_field_are_rejected(self):
"""El peor fallo del sistema: una frase que nadie dijo, hecha con
material auténtico y firmada por alguien con nombre y apellidos.
El comprobador de fundamento une las líneas antes de buscarlas, y eso
caza la forma con la que falló Socorro. Pero la unión se derrota
poniéndole a cada línea su propio par de comillas: entonces son dos
citas, cada una fundamentada por su lado, y pasa en silencio. Caso real
de la sesión 162.
"""
errors = errors_of(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}],
quote=["“GRAY, LIKE ZINC”", "“TWO SAUCERS GLUED AT THE RIM”"])))
assert any("es UNA cita partida en líneas" in e for e in errors)
def test_a_span_broken_across_lines_is_the_normal_case(self):
"""La forma buena: abre en la primera línea y cierra en la última. Es
como está escrito el ejemplo de referencia, así que rechazarla rompería
el propio prompt."""
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}],
quote=["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"])), TEMPLATES)
def test_a_quote_without_marks_is_left_alone(self):
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}],
quote=["LANDING TRACE", "CONFIRMED BY LAB"])), TEMPLATES)
def test_the_reference_example_survives_the_quote_rule(self):
"""Si el ejemplo no pasara su propia regla, volveríamos a enseñar el
fallo que la regla intenta evitar."""
spec = json.loads(EXAMPLE.read_text())
permissive = {name: {"type": "object"} for name in
{s["template"] for s in spec["shots"]}}
validate_spec(spec, permissive)
def test_a_bad_schema_hides_the_cross_field_noise(self):
"""Con props mal tipadas, la regla cruzada diría algo que no es el fallo
real y taparía el que sí lo es."""
# Tres barras (la regla cruzada dispararía) pero a las que les falta el
# campo obligatorio: el fallo que hay que arreglar es ese, no el hueco
# de la cita, que puede desaparecer al arreglarlo.
errors = errors_of(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A"}, {"label": "B"}, {"label": "C"}],
quote=["“X”"])))
assert any("value: falta y es obligatorio" in e for e in errors)
assert not any("leave no room" in e for e in errors)
def test_silence_window_cannot_run_past_the_end():
bad = spec_with(shot(duration=25.0))
bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]}
assert any("se sale de la duración total" in e for e in errors_of(bad))
def test_the_live_palette_widens_what_a_preset_may_be():
"""Con la paleta de GET /audio, un preset nuevo en shortsmith llega aquí
sin tocar este repo — el mismo pacto que las plantillas."""
doc = spec_with(shot(duration=25.0))
doc["audio"] = {"preset": "pulse"}
validate_spec(doc, TEMPLATES, presets=("sonar", "pulse", "static", "none"))
def test_without_the_palette_only_the_baseline_presets_pass():
"""El default es conservador a propósito: nunca acepta lo que un shortsmith
viejo no renderice."""
doc = spec_with(shot(duration=25.0))
doc["audio"] = {"preset": "pulse"}
assert any("audio.preset" in e for e in errors_of(doc))
def test_an_unknown_preset_error_names_the_palette():
doc = spec_with(shot(duration=25.0))
doc["audio"] = {"preset": "vaporwave"}
with pytest.raises(SpecInvalid) as exc:
validate_spec(doc, TEMPLATES, presets=("sonar", "pulse", "none"))
line = next(e for e in exc.value.errors if "audio.preset" in e)
assert "pulse" in line and "vaporwave" in line
def test_extra_root_key_is_rejected():
bad = spec_with(shot(duration=25.0)); bad["narrative_shape"] = "case_file"
assert any(e.startswith("narrative_shape:") for e in errors_of(bad))
def test_editorial_notes_flag_both_ends():
assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)), TEMPLATES)[0]
assert "recorta" in editorial_notes(
spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert editorial_notes(spec_with(shot(duration=30.0)), TEMPLATES) == []
def test_a_spec_that_is_not_even_a_dict():
with pytest.raises(SpecInvalid):
validate_spec([1, 2, 3], TEMPLATES)
# --- descripción para el prompt ---------------------------------------------
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 "1-3 elementos" in text # los límites llegan al prompt
assert "ink, amber, amber_dark, muted, dim, red" in text
# 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():
"""La prueba de que el contrato no está copiado: una plantilla inventada,
que este repo no conoce, se describe igual."""
text = describe_templates({**TEMPLATES, "holo_scan": {
"type": "object", "required": ["title"],
"properties": {"title": {"type": "string", "minLength": 1},
"depth_m": {"type": "number", "maximum": 999}}}})
assert "holo_scan:" in text
assert "depth_m: number, ≤ 999" in text
def test_validation_accepts_a_template_nobody_wrote_here():
templates = {**TEMPLATES, "holo_scan": {
"type": "object", "additionalProperties": False, "required": ["title"],
"properties": {"title": {"type": "string", "minLength": 1}}}}
validate_spec(spec_with({"template": "holo_scan", "duration": 30.0,
"props": {"title": "X"}}), templates)
# --- narración (fase 4b) ----------------------------------------------------
def test_a_shot_may_carry_narration():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = "Three radars tracked it that night."
validate_spec(doc, TEMPLATES)
def test_an_overlong_narration_is_rejected_with_its_path():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = "x" * 400
assert any("shots.0.narration" in e and "320" in e for e in errors_of(doc))
def test_narration_that_is_not_text_is_rejected():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = ["a", "b"]
assert any("shots.0.narration" in e for e in errors_of(doc))
def test_an_unknown_shot_key_still_names_the_valid_ones():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["voiceover"] = "nope"
assert any("narration" in e for e in errors_of(doc))
#: Líneas de narración de specs que se renderizaron de verdad, con lo que tarda
#: Piper en decirlas. Medido el 2026-08-12 con el binario, el modelo y las
#: banderas de shortsmith (`en_US-lessac-medium`, length_scale 1.0,
#: --noise_scale 0 --noise_w 0), que son deterministas: estos segundos se
#: reproducen. Se eligieron los extremos del muestreo de 28 líneas — la más
#: rápida, la más lenta y las dos más largas — porque son las que rompen un
#: modelo mal calibrado; la media la aguanta cualquiera.
MEASURED = [
("Eight FBI witness interviews. Five digital renderings. All describe the "
"same shape flying across America for twenty-four years.", 7.809),
("The files are public now, but sections remain blacked out. Witness "
"identities. Sensor details. Locations redacted.", 8.140),
("Three hundred seventy-eight files released. Hundreds of incidents "
"documented. And the government still cannot explain what those shapes "
"were.", 7.681),
("The files came out. The numbers stayed classified.", 3.310),
("Nothing should have been able to hold station beside them up there.", 3.396),
("The Air Force's own investigators called it unexplained.", 2.990),
]
@pytest.mark.parametrize("line,real", MEASURED)
def test_the_estimate_lands_within_a_second_of_the_voice(line, real):
"""La estimación es lo único que separa un aviso útil de una reescritura
inventada, así que se contrasta contra audio medido, no contra sí misma.
El margen es un segundo. Más apretado sería falso — esto estima, no
sintetiza — y más ancho deja de decir nada: el error del modelo anterior
sobre un Short entero era de cuatro a seis segundos, y de ahí salían los
tres intentos que se gastaban en cada generación.
"""
from src.generator.spec_contract import spoken_seconds
assert spoken_seconds(line) == pytest.approx(real, abs=1.0)
def test_a_line_of_short_sentences_is_not_taken_for_fast_prose():
"""Piper calla un cuarto de segundo en cada punto. Cuatro frases cortas son
un segundo de silencio, y contarlas como texto corrido las da por rápidas:
es el caso donde más se equivocaba el modelo de sólo caracteres."""
from src.generator.spec_contract import spoken_seconds
chopped = "The files are public now, but sections remain blacked out. " \
"Witness identities. Sensor details. Locations redacted."
flowing = "The files are public now but sections remain blacked out with " \
"witness identities sensor details and locations redacted"
assert len(chopped) < len(flowing)
assert spoken_seconds(chopped) > spoken_seconds(flowing)
def test_a_decimal_point_is_not_the_end_of_a_sentence():
from src.generator.spec_contract import spoken_seconds
assert spoken_seconds("It climbed to 1.5 miles") == \
pytest.approx(spoken_seconds("It climbed to 155 miles"))
def test_the_estimate_counts_the_voice_not_just_the_declared_seconds():
"""La duración declarada es un suelo: shortsmith estira el shot si la frase
no cabe, y el modelo tiene que enterarse ANTES de pagar el render."""
from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=3.0))
doc["shots"][0]["narration"] = MEASURED[0][0] # 7,81 s de voz medidos
assert estimated_duration(doc) > 8.0
def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=30.0))
doc["shots"][0]["narration"] = "Short line."
assert estimated_duration(doc) == pytest.approx(30.0)
def test_narration_that_overshoots_the_target_is_flagged_as_narration():
"""El consejo tiene que decir QUÉ recortar: con la voz mandando, acortar
duraciones no arregla nada."""
# 3 shots de 8 s = 24 s declarados, dentro del objetivo y sin avisos. Con
# ~21 s de voz cada uno se van a 65 s: sin la estimación, silencio absoluto.
quiet = spec_with(*[shot(duration=8.0) for _ in range(3)])
assert editorial_notes(quiet, TEMPLATES) == []
doc = copy.deepcopy(quiet)
for s in doc["shots"]:
s["narration"] = "A" * 300
note = editorial_notes(doc, TEMPLATES)[0]
assert "narración" in note and "estimada" in note
def test_a_second_over_the_target_is_not_worth_a_rewrite():
"""El objetivo sigue siendo 45 s, pero la estimación tiene un segundo de
error por línea: avisar por medio segundo es avisar del estimador. Caso
real — la sesión 168 salió a 45,4 s y se pagó una generación por ello."""
from src.generator.spec_contract import TARGET_GRACE, TARGET_MAX_DURATION
justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1))
pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1))
assert editorial_notes(justo, TEMPLATES) == []
assert editorial_notes(pasado, TEMPLATES)
# Y el consejo se mide contra el objetivo, no contra el margen: se pide
# bajar hasta 45, no hasta 46,5.
assert "sobran 1.6s" in editorial_notes(pasado, TEMPLATES)[0]
def test_the_grace_works_at_both_ends():
from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE + 0.1)), TEMPLATES) == []
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE - 0.1)), TEMPLATES)
def test_the_advice_says_how_much_to_cut_and_from_where():
""""Recorta narración" no dice cuánta, y las tres veces que saltó este aviso
el modelo devolvió un spec que seguía pasándose. El exceso va en palabras
porque es lo que el modelo escribe, y señalando el plano que más habla."""
doc = spec_with(shot(duration=4.0), shot(duration=4.0))
doc["shots"][0]["narration"] = "Short line."
doc["shots"][1]["narration"] = " ".join(["word"] * 200)
note = editorial_notes(doc, TEMPLATES)[0]
assert "palabras de narración" in note
assert "shots.1" in note and "shots.0" not in note
def test_the_advice_for_a_silent_spec_never_mentions_narration():
"""Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no
existe: lo que sobra son duraciones declaradas."""
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert "narración" not in note and "duraciones declaradas" in note
def test_a_spec_without_narration_keeps_the_old_wording():
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert "duración total" in note and "estimada" not in note
# --- el gancho: lo que se ve en el primer plano ------------------------------
# Los titulares de abajo son los reales de los once casos distintos que el bot ha
# escrito. Se copian aquí en vez de generarlos porque el detector no se juzga
# contra ejemplos cómodos: se juzga contra lo que el modelo escribe de verdad.
#: Plantilla sin `headline`: su contenido se escribe a máquina más abajo y la
#: banda superior del fotograma se queda en el fondo todo el plano.
SIN_TITULAR = {"document_quote": {
"type": "object", "required": ["quote_a"],
"properties": {"source": {"type": "string"},
"quote_a": {"type": "string", "minLength": 1}}}}
FECHAS = ["APRIL 24 1964", "APRIL 24, 1964", "APRIL 24", "8 JAN 1981",
"OCT 16 1957", "NOVEMBER 12", "1947"]
NO_FECHAS = ["62 CHILDREN", "62 WITNESSES", "23 HELICOPTERS", "TRIANGLES",
"RELEASE 05", "LANDING TRACE", "62"]
def opening(template="radar_sweep", templates=None, **props):
spec = spec_with({"template": template, "duration": 6.0, "props": props},
)
return opening_notes(spec, templates if templates is not None else TEMPLATES)
def test_the_opening_shot_has_to_draw_a_headline():
"""Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con
`headline` lo ponen a tinta plena en 0,33-0,40 s; en las tres que no lo
tienen la banda superior no pasa del fondo en todo el plano y su texto no
está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio eso es un tercio
de la ventana. Ocurrió de verdad — el output 131 abrió con `document_quote`.
"""
note = opening(template="document_quote", templates=SIN_TITULAR,
quote_a="“NO CONTACT”")[0]
assert "document_quote" in note and "titular" in note
# Y la misma plantilla más adelante en el vídeo no molesta a nadie: lo que
# se juzga es la apertura, no el catálogo.
permisivo = {**TEMPLATES, **SIN_TITULAR}
tarde = spec_with(shot(), {"template": "document_quote", "duration": 6.0,
"props": {"quote_a": "“NO CONTACT”"}})
assert opening_notes(tarde, permisivo) == []
def test_the_rule_is_asked_of_the_schema_not_of_a_list_of_names():
"""El corte no puede ser una lista de plantillas escrita a mano: shortsmith
añade plantillas sin avisar a este repo. Una inventada CON titular abre sin
tocar nada, y una inventada SIN él queda cubierta igual."""
nuevas = {
"plantilla_nueva_con_titular": {
"type": "object", "required": ["headline"],
"properties": {"headline": {"type": "string"}}},
"plantilla_nueva_sin_titular": {
"type": "object", "properties": {"body": {"type": "string"}}},
}
assert opening(template="plantilla_nueva_con_titular", templates=nuevas,
headline="62 CHILDREN") == []
assert opening(template="plantilla_nueva_sin_titular", templates=nuevas,
body="lo que sea")
@pytest.mark.parametrize("headline", FECHAS)
def test_a_headline_that_is_only_a_date_is_flagged(headline):
"""Cinco de los once casos abrieron así, con el sitio ya puesto en el
`subline` de debajo: el texto más grande del vídeo gastado en metadatos."""
note = opening(headline=headline)[0]
assert "fecha" in note and headline in note
@pytest.mark.parametrize("headline", NO_FECHAS)
def test_a_figure_is_not_mistaken_for_a_date(headline):
"""El control, y no es un adorno: sin él, un detector que marcara cualquier
titular con un número dentro pasaría todos los casos de arriba y estaría
rechazando exactamente los titulares que se quieren. "62" a secas es el que
lo decide — es una cifra desnuda, que es el gancho ideal, no una fecha."""
assert opening(headline=headline) == []
def test_the_hook_note_comes_before_the_duration_one():
"""Los dos avisos pueden salir a la vez y quien los lee coge `[0]`. Primero
el gancho: un Short que se pasa cinco segundos se ve; uno cuya apertura no
dice nada no se ve entero de todas formas."""
largo = spec_with({"template": "radar_sweep", "duration": 90.0,
"props": {"headline": "8 JAN 1981"}})
notes = editorial_notes(largo, TEMPLATES)
assert len(notes) == 2
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."""
templates = {"document_quote": {
"type": "object", "required": ["quote_a"],
"properties": {"quote_a": {"type": "string", "minLength": 1, "x-fits": 16}}}}
described = describe_templates(templates)
assert "CABE ~16 caracteres" in described
def test_a_string_longer_than_it_fits_is_still_valid():
"""Los caracteres son un proxy de los píxeles: rechazar por ancho estimado
tiraría specs que se dibujan perfectamente."""
templates = {"radar_sweep": {
"type": "object", "additionalProperties": False, "required": ["headline"],
"properties": {"headline": {"type": "string", "minLength": 1, "x-fits": 13}}}}
doc = spec_with({"template": "radar_sweep", "duration": 25.0,
"props": {"headline": "UN TITULAR BASTANTE MAS LARGO QUE ESO"}})
validate_spec(doc, templates)