|
| 1 | +"""Base-path coverage for the JSON-Schema subset validator. |
| 2 | +
|
| 3 | +`validate()` gates every tool invocation: when an LLM emits arguments that |
| 4 | +don't match a connected capability's declared schema, the hub short-circuits |
| 5 | +the call (server.py `_resolve_one`). The union-typed structural path is pinned |
| 6 | +in test_validate_union_types; everything else — primitive type gating, the |
| 7 | +bool/number/integer interplay, array `items`, nested objects, the `_kind` |
| 8 | +message reporter, multi-error accumulation, and the untyped passthrough — |
| 9 | +had no direct test. These pin the contract the resolver depends on. |
| 10 | +""" |
| 11 | + |
| 12 | +from zhub.validate import validate, _kind |
| 13 | + |
| 14 | + |
| 15 | +# --- primitive type gate + message shape ---------------------------------- |
| 16 | + |
| 17 | +def test_type_mismatch_reports_root_and_kinds(): |
| 18 | + assert validate(5, {"type": "string"}) == [ |
| 19 | + "<root>: expected string, got integer" |
| 20 | + ] |
| 21 | + |
| 22 | + |
| 23 | +def test_int_validates_as_number_but_float_not_as_integer(): |
| 24 | + # JSON has one numeric type; the validator treats any int as a valid |
| 25 | + # `number`, but an `integer` must be a real int (a float like 5.0 fails). |
| 26 | + assert validate(5, {"type": "number"}) == [] |
| 27 | + assert validate(5.0, {"type": "integer"}) == [ |
| 28 | + "<root>: expected integer, got number" |
| 29 | + ] |
| 30 | + |
| 31 | + |
| 32 | +def test_bool_is_not_integer_or_number(): |
| 33 | + # bool is an int subclass in Python; the validator must reject it for |
| 34 | + # numeric types or a schema expecting a count would accept True. |
| 35 | + assert validate(True, {"type": "integer"}) == [ |
| 36 | + "<root>: expected integer, got boolean" |
| 37 | + ] |
| 38 | + assert validate(True, {"type": "number"}) == [ |
| 39 | + "<root>: expected number, got boolean" |
| 40 | + ] |
| 41 | + |
| 42 | + |
| 43 | +def test_null_member_of_union_passes(): |
| 44 | + assert validate(None, {"type": ["string", "null"]}) == [] |
| 45 | + |
| 46 | + |
| 47 | +def test_unknown_type_string_is_lenient(): |
| 48 | + # Unsupported keywords/types are ignored rather than crashing the gate. |
| 49 | + assert validate(5, {"type": "weird"}) == [] |
| 50 | + |
| 51 | + |
| 52 | +def test_empty_or_non_dict_schema_passes(): |
| 53 | + assert validate({"anything": 1}, {}) == [] |
| 54 | + assert validate(5, "not-a-schema") == [] # type: ignore[arg-type] |
| 55 | + |
| 56 | + |
| 57 | +# --- structural: objects ---------------------------------------------------- |
| 58 | + |
| 59 | +def test_missing_required_accumulates_each_field(): |
| 60 | + errs = validate({}, {"type": "object", "required": ["a", "b"]}) |
| 61 | + assert errs == [ |
| 62 | + "<root>: missing required field 'a'", |
| 63 | + "<root>: missing required field 'b'", |
| 64 | + ] |
| 65 | + |
| 66 | + |
| 67 | +def test_property_type_error_carries_field_name(): |
| 68 | + schema = { |
| 69 | + "type": "object", |
| 70 | + "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, |
| 71 | + } |
| 72 | + assert validate({"a": "x", "b": 3}, schema) == [ |
| 73 | + "b: expected string, got integer" |
| 74 | + ] |
| 75 | + |
| 76 | + |
| 77 | +def test_nested_object_path_is_dotted(): |
| 78 | + schema = { |
| 79 | + "type": "object", |
| 80 | + "properties": { |
| 81 | + "x": {"type": "object", "properties": {"y": {"type": "string"}}} |
| 82 | + }, |
| 83 | + } |
| 84 | + assert validate({"x": {"y": 1}}, schema) == [ |
| 85 | + "x.y: expected string, got integer" |
| 86 | + ] |
| 87 | + |
| 88 | + |
| 89 | +def test_required_ignored_when_value_is_not_an_object(): |
| 90 | + # `required` is only meaningful for objects; a string value must not |
| 91 | + # trip a missing-field error. |
| 92 | + assert validate("hi", {"required": ["a"]}) == [] |
| 93 | + |
| 94 | + |
| 95 | +# --- structural: arrays ----------------------------------------------------- |
| 96 | + |
| 97 | +def test_array_item_error_carries_index(): |
| 98 | + schema = {"type": "array", "items": {"type": "integer"}} |
| 99 | + assert validate([1, "x", 3], schema) == [ |
| 100 | + "[1]: expected integer, got string" |
| 101 | + ] |
| 102 | + |
| 103 | + |
| 104 | +# --- type-mismatch short-circuit ------------------------------------------- |
| 105 | + |
| 106 | +def test_type_mismatch_short_circuits_structural_checks(): |
| 107 | + # When the top-level type is wrong, only the type error is reported — |
| 108 | + # downstream required/properties checks assume the type matched. |
| 109 | + assert validate(5, {"type": "object", "required": ["a"]}) == [ |
| 110 | + "<root>: expected object, got integer" |
| 111 | + ] |
| 112 | + |
| 113 | + |
| 114 | +# --- untyped schemas still run structural checks --------------------------- |
| 115 | + |
| 116 | +def test_untyped_schema_still_checks_required_and_properties(): |
| 117 | + errs = validate( |
| 118 | + {}, {"required": ["a"], "properties": {"a": {"type": "string"}}} |
| 119 | + ) |
| 120 | + assert errs == ["<root>: missing required field 'a'"] |
| 121 | + |
| 122 | + |
| 123 | +def test_untyped_schema_still_checks_array_items(): |
| 124 | + assert validate([1, "x"], {"items": {"type": "integer"}}) == [ |
| 125 | + "[1]: expected integer, got string" |
| 126 | + ] |
| 127 | + |
| 128 | + |
| 129 | +# --- _kind reporter --------------------------------------------------------- |
| 130 | + |
| 131 | +def test_kind_distinguishes_every_json_type(): |
| 132 | + assert _kind(True) == "boolean" |
| 133 | + assert _kind(3) == "integer" |
| 134 | + assert _kind(1.5) == "number" |
| 135 | + assert _kind("s") == "string" |
| 136 | + assert _kind([]) == "array" |
| 137 | + assert _kind({}) == "object" |
| 138 | + assert _kind(None) == "null" |
| 139 | + |
| 140 | + |
| 141 | +def test_kind_falls_back_to_python_type_name(): |
| 142 | + assert _kind((1, 2)) == "tuple" |
0 commit comments