| """Regression tests for scripts/validate_workflow.py. |
| |
| Anchored by PR #170 review: the validator must report problems rather than |
| raise on malformed-but-valid JSON. These tests pin that behavior plus a |
| sampling of the existing happy-path and consistency checks so future edits |
| cannot silently regress the safety guarantees. |
| """ |
| from __future__ import annotations |
| import importlib.util |
| import json |
| from pathlib import Path |
|
|
| import pytest |
|
|
| |
| _SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "validate_workflow.py" |
| _spec = importlib.util.spec_from_file_location("validate_workflow", _SCRIPT) |
| assert _spec is not None and _spec.loader is not None |
| _module = importlib.util.module_from_spec(_spec) |
| _spec.loader.exec_module(_module) |
| validate = _module.validate |
| main = _module.main |
|
|
|
|
| def _minimal_clean() -> dict: |
| """Smallest workflow that should validate cleanly: two nodes, one link.""" |
| return { |
| "last_node_id": 2, |
| "last_link_id": 1, |
| "nodes": [ |
| { |
| "id": 1, |
| "type": "Src", |
| "inputs": [], |
| "outputs": [ |
| {"name": "out", "type": "FOO", "links": [1]}, |
| ], |
| }, |
| { |
| "id": 2, |
| "type": "Dst", |
| "inputs": [ |
| {"name": "in", "type": "FOO", "link": 1}, |
| ], |
| "outputs": [], |
| }, |
| ], |
| "links": [[1, 1, 0, 2, 0, "FOO"]], |
| } |
|
|
|
|
| |
|
|
| def test_minimal_clean_workflow_has_no_problems() -> None: |
| assert validate(_minimal_clean()) == [] |
|
|
|
|
| |
|
|
| @pytest.mark.parametrize("data", [None, 42, 3.14, "string", [1, 2], (1, 2)]) |
| def test_top_level_not_object_reports_does_not_crash(data) -> None: |
| problems = validate(data) |
| assert problems, "expected at least one problem" |
| assert "top-level JSON must be an object" in problems[0] |
|
|
|
|
| def test_node_entry_not_dict_reports_does_not_crash() -> None: |
| |
| data = {"nodes": [1], "links": [], "last_node_id": 0, "last_link_id": 0} |
| problems = validate(data) |
| assert any("nodes[0]" in p and "object" in p for p in problems), problems |
|
|
|
|
| def test_nodes_field_not_list_reports() -> None: |
| data = {"nodes": {"oops": "dict not list"}, "links": []} |
| problems = validate(data) |
| assert any("'nodes' must be a list" in p for p in problems), problems |
|
|
|
|
| def test_links_field_not_list_reports() -> None: |
| data = {"nodes": [], "links": "not-a-list"} |
| problems = validate(data) |
| assert any("'links' must be a list" in p for p in problems), problems |
|
|
|
|
| def test_inputs_field_not_list_reports() -> None: |
| data = _minimal_clean() |
| data["nodes"][1]["inputs"] = {"oops": "dict not list"} |
| problems = validate(data) |
| assert any("'inputs' must be a list" in p for p in problems), problems |
|
|
|
|
| def test_input_entry_not_dict_reports() -> None: |
| data = _minimal_clean() |
| data["nodes"][1]["inputs"] = ["string-not-dict"] |
| problems = validate(data) |
| assert any("inputs[0]" in p and "object" in p for p in problems), problems |
|
|
|
|
| def test_output_entry_not_dict_reports() -> None: |
| data = _minimal_clean() |
| data["nodes"][0]["outputs"] = [99] |
| problems = validate(data) |
| assert any("outputs[0]" in p and "object" in p for p in problems), problems |
|
|
|
|
| def test_malformed_link_not_list_reports() -> None: |
| data = _minimal_clean() |
| data["links"].append("not-a-list") |
| problems = validate(data) |
| assert any("links[1]" in p for p in problems), problems |
|
|
|
|
| def test_malformed_link_wrong_length_reports() -> None: |
| data = _minimal_clean() |
| data["links"].append([2, 1, 0]) |
| problems = validate(data) |
| assert any("links[1]" in p and "6 elements" in p for p in problems), problems |
|
|
|
|
| |
|
|
| def test_link_to_missing_src_node_reports() -> None: |
| data = _minimal_clean() |
| data["links"] = [[1, 99, 0, 2, 0, "FOO"]] |
| data["nodes"][0]["outputs"][0]["links"] = [] |
| problems = validate(data) |
| assert any("src node 99 missing" in p for p in problems), problems |
|
|
|
|
| def test_type_mismatch_on_slot_reports() -> None: |
| data = _minimal_clean() |
| data["links"] = [[1, 1, 0, 2, 0, "BAR"]] |
| problems = validate(data) |
| assert any("type mismatch" in p for p in problems), problems |
|
|
|
|
| def test_wildcard_destination_accepts_concrete_link_type() -> None: |
| data = _minimal_clean() |
| data["nodes"][1]["inputs"][0]["type"] = "*" |
| assert validate(data) == [] |
|
|
|
|
| def test_duplicate_node_ids_report() -> None: |
| data = _minimal_clean() |
| data["nodes"][1]["id"] = 1 |
| problems = validate(data) |
| assert any("Duplicate node IDs" in p for p in problems), problems |
|
|
|
|
| def test_endpoint_ref_desync_reports() -> None: |
| data = _minimal_clean() |
| data["nodes"][0]["outputs"][0]["links"] = [] |
| problems = validate(data) |
| assert any("missing from node 1 output[0]" in p for p in problems), problems |
|
|
|
|
| def test_last_node_id_too_low_reports() -> None: |
| data = _minimal_clean() |
| data["last_node_id"] = 0 |
| problems = validate(data) |
| assert any("last_node_id" in p for p in problems), problems |
|
|
|
|
| |
|
|
| def test_cli_returns_zero_on_clean_workflow(tmp_path: Path) -> None: |
| p = tmp_path / "clean.json" |
| p.write_text(json.dumps(_minimal_clean()), encoding="utf-8") |
| assert main([str(p), "--quiet"]) == 0 |
|
|
|
|
| def test_cli_returns_one_on_problems(tmp_path: Path) -> None: |
| p = tmp_path / "broken.json" |
| p.write_text(json.dumps({"nodes": [1], "links": []}), encoding="utf-8") |
| assert main([str(p), "--quiet"]) == 1 |
|
|
|
|
| def test_cli_returns_two_on_missing_file(tmp_path: Path) -> None: |
| assert main([str(tmp_path / "does-not-exist.json"), "--quiet"]) == 2 |
|
|
|
|
| def test_cli_returns_two_on_invalid_json(tmp_path: Path) -> None: |
| p = tmp_path / "bad.json" |
| p.write_text("{not valid", encoding="utf-8") |
| assert main([str(p), "--quiet"]) == 2 |
|
|