406 lines
12 KiB
Python
406 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from band_aider.context.validation_utils import (
|
|
ITEMS,
|
|
ValidationError,
|
|
ValidationLocation,
|
|
ValidationResult,
|
|
validate_shape,
|
|
)
|
|
|
|
|
|
RESPONSE_LOCATION = ValidationLocation(["response"])
|
|
|
|
|
|
def test_validate_shape_accepts_complete_recursive_shape() -> None:
|
|
"""The reusable validator admits complete nested values and empty typed arrays."""
|
|
|
|
reference = {
|
|
"name": "string value",
|
|
"details": {"locations": ["string item"]},
|
|
}
|
|
|
|
assert validate_shape(
|
|
{"name": "example", "details": {"locations": ["one.py", "two.py"]}},
|
|
[reference],
|
|
location=RESPONSE_LOCATION,
|
|
) == ValidationResult()
|
|
assert validate_shape(
|
|
{"name": "example", "details": {"locations": []}},
|
|
[reference],
|
|
location=RESPONSE_LOCATION,
|
|
) == ValidationResult()
|
|
|
|
|
|
def test_validate_shape_requires_every_recursive_member() -> None:
|
|
"""A missing member is owned by the object that should contain it."""
|
|
|
|
assert validate_shape(
|
|
{},
|
|
[{"b": 1}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(
|
|
errors=(ValidationError(
|
|
location=ValidationLocation(["A"]),
|
|
error="missing member b.",
|
|
),),
|
|
)
|
|
|
|
|
|
def test_validate_shape_requires_every_recursive_type() -> None:
|
|
"""The reusable validator rejects a mismatched leaf at its exact array index."""
|
|
|
|
reference = {
|
|
"name": "string value",
|
|
"details": {"locations": ["string item"]},
|
|
}
|
|
|
|
assert validate_shape(
|
|
{"name": "example", "details": {"locations": ["one.py", 2]}},
|
|
[reference],
|
|
location=RESPONSE_LOCATION,
|
|
) == ValidationResult(
|
|
errors=(ValidationError(
|
|
location=ValidationLocation([
|
|
"response", "details", "locations", ITEMS, "1",
|
|
]),
|
|
error="expected a string but found an int.",
|
|
),),
|
|
)
|
|
|
|
|
|
def test_validate_shape_does_not_compare_list_cardinality() -> None:
|
|
"""Zero, one, or many members satisfy one declared reusable list-member shape."""
|
|
|
|
reference: list[object] = [{"b": ["a_number"]}]
|
|
|
|
assert validate_shape({"b": []}, reference, location=RESPONSE_LOCATION).valid
|
|
assert validate_shape(
|
|
{"b": ["one"]}, reference, location=RESPONSE_LOCATION,
|
|
).valid
|
|
assert validate_shape(
|
|
{"b": ["one", "two"]}, reference, location=RESPONSE_LOCATION,
|
|
).valid
|
|
|
|
|
|
def test_validate_shape_reports_list_member_type_with_brackets() -> None:
|
|
"""List-member type belongs to its indexed leaf and ignores list cardinality."""
|
|
|
|
result = validate_shape(
|
|
{"b": ["one", "two"]},
|
|
[{"b": [1, 2]}],
|
|
location=ValidationLocation(["A"]),
|
|
)
|
|
|
|
assert result == ValidationResult(errors=(
|
|
ValidationError(
|
|
location=ValidationLocation(["A", "b", ITEMS, "0"]),
|
|
error="expected an int but found a string.",
|
|
),
|
|
ValidationError(
|
|
location=ValidationLocation(["A", "b", ITEMS, "1"]),
|
|
error="expected an int but found a string.",
|
|
),
|
|
))
|
|
assert result.errors[0].location.parts == ["A", "b", "__items__", "0"]
|
|
assert result.errors[0].stringify() == (
|
|
"A.b[0]: expected an int but found a string."
|
|
)
|
|
|
|
|
|
def test_validate_shape_reports_null_list_member_at_list_container() -> None:
|
|
"""A null list member is owned by its list rather than its indexed leaf."""
|
|
|
|
assert validate_shape(
|
|
{"b": [None]},
|
|
[{"b": [1]}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A", "b"]),
|
|
error="member [0] expected an int but found null.",
|
|
),))
|
|
|
|
|
|
def test_validate_shape_returns_invalid_for_unexpected_key_type() -> None:
|
|
"""An unexpected non-string object key remains invalid data rather than an exception."""
|
|
|
|
assert validate_shape(
|
|
{1: "example"},
|
|
[{"name": "example"}],
|
|
location=RESPONSE_LOCATION,
|
|
) == ValidationResult(
|
|
errors=(ValidationError(
|
|
location=RESPONSE_LOCATION,
|
|
error="missing member name.",
|
|
), ValidationError(
|
|
location=RESPONSE_LOCATION,
|
|
error="unexpected member 1.",
|
|
),),
|
|
)
|
|
|
|
|
|
def test_validate_shape_reports_unexpected_member_at_container() -> None:
|
|
"""An unexpected member is owned by the object that contains it."""
|
|
|
|
assert validate_shape(
|
|
{"b": 1, "c": 2},
|
|
[{"b": 1}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A"]),
|
|
error="unexpected member c.",
|
|
),))
|
|
|
|
|
|
def test_validate_shape_reports_member_failure_at_member_location() -> None:
|
|
"""An existing member's invalid type is owned by that member's location."""
|
|
|
|
result = validate_shape(
|
|
{"b": "not a number"},
|
|
[{"b": 1}],
|
|
location=ValidationLocation(["A"]),
|
|
)
|
|
|
|
assert result == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A", "b"]),
|
|
error="expected an int but found a string.",
|
|
),))
|
|
assert result.errors[0].location.parts == ["A", "b"]
|
|
assert result.errors[0].location.stringify() == "A.b"
|
|
assert result.errors[0].stringify() == (
|
|
"A.b: expected an int but found a string."
|
|
)
|
|
|
|
|
|
def test_validate_shape_reports_null_member_at_container() -> None:
|
|
"""An unexpected null is owned by the object containing that member."""
|
|
|
|
assert validate_shape(
|
|
{"b": None},
|
|
[{"b": 1}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A"]),
|
|
error="member b expected an int but found null.",
|
|
),))
|
|
|
|
|
|
def test_validate_shape_reports_member_shape_at_member_location() -> None:
|
|
"""An existing member's invalid nested shape is owned by that member."""
|
|
|
|
assert validate_shape(
|
|
{"b": {}},
|
|
[{"b": {"c": 1}}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A", "b"]),
|
|
error="missing member c.",
|
|
),))
|
|
|
|
|
|
def test_validate_shape_accepts_any_complete_reference() -> None:
|
|
"""Plural references form alternatives, including a nullable member shape."""
|
|
|
|
assert validate_shape(
|
|
{"b": None},
|
|
[{"b": "string value"}, {"b": None}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult()
|
|
|
|
|
|
def test_validate_shape_distinguishes_disjunctive_and_conjunctive_references() -> None:
|
|
"""Separate closed shapes reject their union until conjunctive overlay is selected."""
|
|
|
|
references: list[object] = [{"b": 1}, {"c": 1}]
|
|
actual = {"b": 1, "c": 1}
|
|
|
|
assert not validate_shape(
|
|
actual,
|
|
references,
|
|
location=ValidationLocation(["A"]),
|
|
).valid
|
|
assert validate_shape(
|
|
actual,
|
|
references,
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
).valid
|
|
|
|
|
|
def test_validate_shape_conjunctive_overlay_recurses_only_through_objects() -> None:
|
|
"""Nested objects merge while later primitive and list schemas replace earlier ones."""
|
|
|
|
references: list[object] = [
|
|
{"a": {"b": 1, "value": 1}, "values": [1]},
|
|
{"a": {"c": 1, "value": "text"}, "values": ["text"]},
|
|
]
|
|
|
|
assert validate_shape(
|
|
{
|
|
"a": {"b": 1, "c": 1, "value": "later"},
|
|
"values": ["one", "two"],
|
|
},
|
|
references,
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
).valid
|
|
assert validate_shape(
|
|
{
|
|
"a": {"b": 1, "c": 1, "value": 1},
|
|
"values": [1],
|
|
},
|
|
references,
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
) == ValidationResult(errors=(
|
|
ValidationError(
|
|
location=ValidationLocation(["A", "a", "value"]),
|
|
error="expected a string but found an int.",
|
|
),
|
|
ValidationError(
|
|
location=ValidationLocation(["A", "values", ITEMS, "0"]),
|
|
error="expected a string but found an int.",
|
|
),
|
|
))
|
|
|
|
|
|
def test_validate_shape_conjunctive_overlay_replaces_cross_kind_from_right() -> None:
|
|
"""A later primitive reference replaces an earlier object at the same member."""
|
|
|
|
references: list[object] = [{"a": {"b": 1}}, {"a": "text"}]
|
|
|
|
assert validate_shape(
|
|
{"a": "later"},
|
|
references,
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
).valid
|
|
|
|
|
|
def test_validate_shape_conjunctive_overlay_prefers_non_null_collisions() -> None:
|
|
"""A null collision yields its specified side without erasing null-only members."""
|
|
|
|
references: list[object] = [
|
|
{
|
|
"left": 1,
|
|
"right": None,
|
|
"left_null": None,
|
|
"both_null": None,
|
|
"nested": {
|
|
"left": 1,
|
|
"right": None,
|
|
"left_null": None,
|
|
"both_null": None,
|
|
},
|
|
},
|
|
{
|
|
"left": None,
|
|
"right": 2,
|
|
"right_null": None,
|
|
"both_null": None,
|
|
"nested": {
|
|
"left": None,
|
|
"right": 2,
|
|
"right_null": None,
|
|
"both_null": None,
|
|
},
|
|
},
|
|
]
|
|
actual = {
|
|
"left": 1,
|
|
"right": 2,
|
|
"left_null": None,
|
|
"right_null": None,
|
|
"both_null": None,
|
|
"nested": {
|
|
"left": 1,
|
|
"right": 2,
|
|
"left_null": None,
|
|
"right_null": None,
|
|
"both_null": None,
|
|
},
|
|
}
|
|
|
|
assert validate_shape(
|
|
actual,
|
|
references,
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
).valid
|
|
assert validate_shape(
|
|
{"b": 1},
|
|
[None, {"b": 1}, None],
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
).valid
|
|
assert validate_shape(
|
|
{"b": None},
|
|
[{"b": None}],
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
).valid
|
|
assert validate_shape(
|
|
{},
|
|
[{"b": None}],
|
|
location=ValidationLocation(["A"]),
|
|
conjunctive=True,
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A"]),
|
|
error="missing member b.",
|
|
),))
|
|
|
|
|
|
def test_validate_shape_accepts_int_for_float_but_not_float_for_int() -> None:
|
|
"""A declared float widens an int, while a declared int rejects a float."""
|
|
|
|
assert validate_shape(
|
|
{"b": 1},
|
|
[{"b": 1.0}],
|
|
location=ValidationLocation(["A"]),
|
|
).valid
|
|
assert validate_shape(
|
|
{"b": 1.5},
|
|
[{"b": 1}],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A", "b"]),
|
|
error="expected an int but found a float.",
|
|
),))
|
|
|
|
|
|
def test_validate_shape_rejects_only_after_every_reference_fails() -> None:
|
|
"""An invalid union reports the structured errors from every attempted shape."""
|
|
|
|
result = validate_shape(
|
|
{"b": 7},
|
|
[{"b": "string value"}, {"b": None}],
|
|
location=ValidationLocation(["A"]),
|
|
)
|
|
|
|
assert result == ValidationResult(errors=(
|
|
ValidationError(
|
|
location=ValidationLocation(["A", "b"]),
|
|
error="expected a string but found an int.",
|
|
),
|
|
ValidationError(
|
|
location=ValidationLocation(["A"]),
|
|
error="member b expected null but found an int.",
|
|
),
|
|
))
|
|
assert result.stringify() == (
|
|
"A.b: expected a string but found an int.\n"
|
|
"A: member b expected null but found an int."
|
|
)
|
|
|
|
|
|
def test_validate_shape_rejects_absent_references() -> None:
|
|
"""No alternative reference can admit an actual value."""
|
|
|
|
assert validate_shape(
|
|
{"b": None},
|
|
[],
|
|
location=ValidationLocation(["A"]),
|
|
) == ValidationResult(errors=(ValidationError(
|
|
location=ValidationLocation(["A"]),
|
|
error="at least one reference shape is required.",
|
|
),))
|