"""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, 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}, "subline": {"type": "string", "default": ""}, "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}, "value": {"type": "number", "exclusiveMinimum": 0}, "unit": {"type": "string", "default": ""}, "color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"], "type": "string", "default": "ink"}, "value_label": {"type": "string", "default": ""}, }, }}, "properties": { "headline": {"type": "string", "minLength": 1}, "bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"}, "minItems": 1, "maxItems": 3}, "quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2}, "attribution": {"type": "string", "default": ""}, }, }, } 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) 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)))) 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_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)))[0] assert "recorta" in editorial_notes( spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] assert editorial_notes(spec_with(shot(duration=30.0))) == [] 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 "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 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)