mirror of
https://github.com/home-assistant/core.git
synced 2026-09-30 11:33:51 +01:00
291 lines
9.7 KiB
Python
291 lines
9.7 KiB
Python
"""Test conversion of structured output schemas."""
|
|
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
|
|
import probatio
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
|
|
from homeassistant.components.open_router.entity import _format_structured_output
|
|
from homeassistant.components.open_router.schema import adjust_schema
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
pytest.param({"type": "string"}, id="string"),
|
|
pytest.param({"type": ["string", "integer"]}, id="multiple-types"),
|
|
pytest.param({"type": ["string", "null"]}, id="nullable"),
|
|
pytest.param({"type": "string", "enum": ["a", "b"]}, id="enum"),
|
|
pytest.param(
|
|
{"type": ["string", "null"], "enum": ["a", None]},
|
|
id="nullable-enum",
|
|
),
|
|
pytest.param({"enum": ["a", 1]}, id="untyped-enum"),
|
|
pytest.param({"type": "string", "const": "a"}, id="constant"),
|
|
pytest.param({"anyOf": [{"type": "string"}, {"type": "null"}]}, id="any-of"),
|
|
pytest.param({"oneOf": [{"type": "string"}, {"type": "null"}]}, id="one-of"),
|
|
pytest.param({"type": "string", "allOf": [{"enum": ["a", "b"]}]}, id="all-of"),
|
|
pytest.param({"type": "string", "not": {"enum": [None, "b"]}}, id="negation"),
|
|
pytest.param({"$ref": "#/$defs/value"}, id="reference"),
|
|
],
|
|
)
|
|
def test_optional_fields(field: dict[str, Any], snapshot: SnapshotAssertion) -> None:
|
|
"""Optional fields must accept null without weakening non-null constraints."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"value": deepcopy(field)},
|
|
"$defs": {"value": {"type": "string"}},
|
|
}
|
|
|
|
adjust_schema(schema)
|
|
|
|
validator = probatio.from_json_schema(schema)
|
|
validator({"value": None})
|
|
validator({"value": "a"})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({"value": []})
|
|
assert schema == snapshot
|
|
|
|
adjusted = deepcopy(schema)
|
|
adjust_schema(schema)
|
|
assert schema == adjusted
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
pytest.param(True, id="true"),
|
|
pytest.param(False, id="false"),
|
|
pytest.param({}, id="empty"),
|
|
pytest.param({"type": "null"}, id="null"),
|
|
],
|
|
)
|
|
def test_optional_untyped_fields(
|
|
field: dict[str, Any] | bool, snapshot: SnapshotAssertion
|
|
) -> None:
|
|
"""Boolean, empty, and null schemas do not need a string type."""
|
|
schema = {"type": "object", "properties": {"value": deepcopy(field)}}
|
|
|
|
adjust_schema(schema)
|
|
|
|
validator = probatio.from_json_schema(schema)
|
|
validator({"value": None})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({})
|
|
assert schema == snapshot
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"schema_type",
|
|
[
|
|
pytest.param("object", id="object"),
|
|
pytest.param(["object", "null"], id="nullable"),
|
|
],
|
|
)
|
|
def test_nested_schemas(
|
|
schema_type: str | list[str], snapshot: SnapshotAssertion
|
|
) -> None:
|
|
"""Visit nullable containers, union branches, and referenced definitions."""
|
|
schema = {
|
|
"type": schema_type,
|
|
"properties": {
|
|
"value": {"type": "string"},
|
|
"children": {
|
|
"type": ["array", "null"],
|
|
"items": {"anyOf": [{"$ref": "#/$defs/child"}, {"type": "null"}]},
|
|
},
|
|
},
|
|
"required": ["value"],
|
|
"$defs": {
|
|
"child": {"type": "object", "properties": {"name": {"type": "string"}}}
|
|
},
|
|
}
|
|
|
|
adjust_schema(schema)
|
|
|
|
validator = probatio.from_json_schema(schema)
|
|
validator({"value": "a", "children": [{"name": None}, None]})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({"value": None, "children": None})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({"value": "a", "children": [{}]})
|
|
assert schema == snapshot
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field_order",
|
|
[
|
|
pytest.param(["value", "alias"], id="target-first"),
|
|
pytest.param(["alias", "value"], id="alias-first"),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("target", "reference"),
|
|
[
|
|
pytest.param(
|
|
{"anyOf": [{"type": "string"}, {"type": "integer"}]},
|
|
"#/properties/value/anyOf/0",
|
|
id="union-branch",
|
|
),
|
|
pytest.param(
|
|
{"type": "string"},
|
|
"#/properties/value",
|
|
id="optional-target",
|
|
),
|
|
pytest.param(
|
|
{"type": "object", "properties": {"a/b~c d": {"type": "string"}}},
|
|
"#/properties/value/properties/a~1b~0c%20d",
|
|
id="escaped-pointer",
|
|
),
|
|
],
|
|
)
|
|
def test_reference_targets(
|
|
target: dict[str, Any], reference: str, field_order: list[str]
|
|
) -> None:
|
|
"""References retain their original constraints when targets become nullable."""
|
|
fields = {"value": deepcopy(target), "alias": {"$ref": reference}}
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {name: fields[name] for name in field_order},
|
|
"required": ["alias"],
|
|
}
|
|
|
|
adjust_schema(schema)
|
|
|
|
validator = probatio.from_json_schema(schema)
|
|
validator({"value": None, "alias": "a"})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({"value": None, "alias": 1})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({"value": None, "alias": None})
|
|
|
|
|
|
def test_recursive_reference_targets(snapshot: SnapshotAssertion) -> None:
|
|
"""Copied targets retain chained and recursive references without name collisions."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"node": {
|
|
"anyOf": [
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"$ref": "#/definitions/value"},
|
|
"child": {"$ref": "#/properties/node/anyOf/0"},
|
|
},
|
|
"required": ["value"],
|
|
},
|
|
{"type": "null"},
|
|
]
|
|
},
|
|
"alias": {"$ref": "#/properties/node/anyOf/0"},
|
|
},
|
|
"required": ["alias"],
|
|
"definitions": {"value": {"type": "string"}},
|
|
"$defs": {"_ha_ref_0": {"type": "integer"}},
|
|
"examples": [{"$ref": "This is literal data"}],
|
|
}
|
|
|
|
adjust_schema(schema)
|
|
|
|
validator = probatio.from_json_schema(schema)
|
|
validator(
|
|
{"node": None, "alias": {"value": "a", "child": {"value": "b", "child": None}}}
|
|
)
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({"node": None, "alias": {"value": None, "child": None}})
|
|
assert schema == snapshot
|
|
adjusted = deepcopy(schema)
|
|
adjust_schema(schema)
|
|
assert schema == adjusted
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"reference",
|
|
[
|
|
pytest.param("#/$defs/missing", id="missing"),
|
|
pytest.param("https://example.com/schema", id="remote"),
|
|
pytest.param("#/properties/value/anyOf/01", id="leading-zero"),
|
|
pytest.param("#/properties/value/anyOf/-1", id="negative-index"),
|
|
pytest.param("#/properties/value/anyOf/9", id="out-of-range"),
|
|
pytest.param("#/properties/value/anyOf/0/type", id="non-schema-target"),
|
|
],
|
|
)
|
|
def test_invalid_reference(reference: str) -> None:
|
|
"""Invalid references fail before schema adjustments can obscure the cause."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"anyOf": [{"type": "string"}]},
|
|
"alias": {"$ref": reference},
|
|
},
|
|
}
|
|
original = deepcopy(schema)
|
|
|
|
with pytest.raises(HomeAssistantError, match="OpenRouter output schema reference"):
|
|
adjust_schema(schema)
|
|
|
|
assert schema == original
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[pytest.param(True, id="true"), pytest.param(False, id="false")],
|
|
)
|
|
def test_boolean_reference_target(target: bool) -> None:
|
|
"""Boolean schema targets remain valid after moving to definitions."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"value": target, "alias": {"$ref": "#/properties/value"}},
|
|
}
|
|
|
|
adjust_schema(schema)
|
|
|
|
assert schema["$defs"] == {"_ha_ref_0": target}
|
|
validator = probatio.from_json_schema(schema)
|
|
validator({"value": None, "alias": None})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
pytest.param(probatio.Any(str, None), id="nullable-string"),
|
|
pytest.param(probatio.Any({"name": str}, None), id="nullable-object"),
|
|
pytest.param([probatio.Any({"name": str}, None)], id="array-of-unions"),
|
|
pytest.param(probatio.In(["a", None]), id="nullable-enum"),
|
|
pytest.param(probatio.ExactSequence([{"name": str}, int]), id="tuple"),
|
|
pytest.param(probatio.Self, id="recursive-reference"),
|
|
],
|
|
)
|
|
def test_format_structured_output(field: object, snapshot: SnapshotAssertion) -> None:
|
|
"""Convert probatio schemas using OpenAPI 3.1, including nested schemas."""
|
|
result = _format_structured_output("test", probatio.Schema({"value": field}), None)
|
|
|
|
validator = probatio.from_json_schema(result["schema"])
|
|
validator({"value": None})
|
|
with pytest.raises(probatio.Invalid):
|
|
validator({})
|
|
assert result == snapshot
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected_name"),
|
|
[
|
|
pytest.param("Test Task", "test_task", id="spaces"),
|
|
pytest.param("Прогноз погоды", "prognoz_pogody", id="unicode"),
|
|
pytest.param("x" * 65, "x" * 64, id="length-limit"),
|
|
pytest.param("!!!", "unknown", id="punctuation-only"),
|
|
pytest.param("", "response", id="empty"),
|
|
],
|
|
)
|
|
def test_structured_output_name(name: str, expected_name: str) -> None:
|
|
"""User-provided task names satisfy the API's schema name restrictions."""
|
|
result = _format_structured_output(name, probatio.Schema({"value": str}), None)
|
|
|
|
assert result["name"] == expected_name
|