diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index cdadc4e..2f5f9cf 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -171,6 +171,84 @@ def _check_props(props: Any, schema: dict, path: str) -> list[str]: return _check(props, schema, path, schema.get("$defs", {})) +# --- reglas que cruzan campos ----------------------------------------------- +# Espejo a mano de los `@model_validator` de shortsmith/spec.py, porque NO salen +# en el JSON Schema publicado: pydantic no los serializa. Antes se dejaban al 422 +# del servidor, y eso costaba una generación entera — el 422 llega al RENDERIZAR, +# cuando el bucle de reintentos ya ha terminado, así que el spec no se reescribe: +# se devuelve a mano. La sesión 162 (Trans-en-Provence) se perdió justo así el +# 2026-08-13. Comprobadas aquí, son un reintento normal. +# +# El texto del error es el de shortsmith palabra por palabra: al modelo se le +# devuelve verbatim, y dos redacciones distintas del mismo fallo según dónde se +# cace es exactamente el tipo de detalle que hace inútil un mensaje de error. + +def _scale_bars_quote_needs_room(props: dict, path: str) -> list[str]: + bars = props.get("bars") + quote = props.get("quote") + if isinstance(bars, list) and isinstance(quote, list) and len(bars) > 2 and quote: + return [f"{path}: {len(bars)} bars leave no room for a quote — use at " + "most 2 bars with a quote"] + return [] + + +def _track_map_waypoints_inside(props: dict, path: str) -> list[str]: + """Una ventana fijada a mano tiene que contener la ruta que enmarca. + + La proyección de shortsmith es lineal y sin recortar, así que un waypoint + fuera de `bounds` no se dibuja en el borde: se dibuja donde lo ponga la + aritmética, a veces fuera del encuadre. Se rechaza en vez de recortarse + porque un mapa que miente sobre dónde pasó algo es peor que un spec que + falla. + """ + bounds = props.get("bounds") + waypoints = props.get("waypoints") + if not isinstance(bounds, dict) or not isinstance(waypoints, list): + return [] + try: + lat_min, lat_max = float(bounds["lat_min"]), float(bounds["lat_max"]) + lon_min, lon_max = float(bounds["lon_min"]), float(bounds["lon_max"]) + except (KeyError, TypeError, ValueError): + return [] # incompleto o mal tipado: ya lo dijo el esquema + + if lat_max <= lat_min or lon_max <= lon_min: + return [f"{path}.bounds: map bounds must have max greater than min on " + "both axes"] + + outside = [] + for w in waypoints: + if not isinstance(w, dict): + continue + try: + lat, lon = float(w["lat"]), float(w["lon"]) + except (KeyError, TypeError, ValueError): + continue + if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max): + outside.append(str(w.get("label", "?"))) + if outside: + return [f"{path}: waypoints outside the map bounds: " + f"{', '.join(outside)} — widen bounds or omit them to fit the " + "window to the route"] + return [] + + +#: template -> comprobaciones extra. Una plantilla sin entrada no tiene reglas +#: cruzadas, que es el caso de casi todas. +CROSS_FIELD_CHECKS = { + "scale_bars": [_scale_bars_quote_needs_room], + "track_map": [_track_map_waypoints_inside], +} + + +def _check_cross_field(template: str, props: Any, path: str) -> list[str]: + if not isinstance(props, dict): + return [] + errors: list[str] = [] + for check in CROSS_FIELD_CHECKS.get(template, ()): + errors.extend(check(props, path)) + return errors + + # --- el sobre --------------------------------------------------------------- def _check_meta(meta: Any) -> list[str]: @@ -318,8 +396,13 @@ def validate_spec(spec: Any, templates: dict[str, dict], if "props" not in shot: errors.append(f"{path}.props: falta y es obligatorio") continue - errors.extend(_check_props(shot["props"], templates[template], - f"{path}.{template}.props")) + props_path = f"{path}.{template}.props" + props_errors = _check_props(shot["props"], templates[template], props_path) + errors.extend(props_errors) + # Sólo si el esquema pasó: con props mal tipadas, una regla cruzada + # diría algo que no es el fallo real y taparía el que sí lo es. + if not props_errors: + errors.extend(_check_cross_field(template, shot["props"], props_path)) total = _total_duration(spec) if total < MIN_TOTAL_DURATION: diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index 180a272..f6133c1 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -178,6 +178,88 @@ def test_total_duration_floor(): 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_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]]}