Files
cleveragents-core/features/steps/structural_validation_steps.py
T
HAL9000 4db5a06e0a
CI / helm (pull_request) Successful in 52s
CI / build (pull_request) Successful in 1m18s
CI / push-validation (pull_request) Successful in 1m15s
CI / lint (pull_request) Failing after 1m33s
CI / quality (pull_request) Successful in 1m53s
CI / typecheck (pull_request) Failing after 2m11s
CI / security (pull_request) Successful in 2m12s
CI / integration_tests (pull_request) Successful in 5m34s
CI / unit_tests (pull_request) Failing after 6m37s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s
feat: implement structural component output validation
Replace exact character matching with structural component checking
for output validation. Implements three validators covering plan tree
output, decision CLI dicts, and structured session snapshots.

- validate_plan_tree: validates node dicts for required keys (decision_id, type, sequence, question, children), ULID format, correct types, and sibling ordering
- validate_decision_dict: validates decision CLI output against Decision.as_cli_dict() schema with field presence, type, ULID, confidence range [0..1], bool fields
- validate_structured_output: validates StructuredOutput envelope for command, session_id (ULID), status membership, exit_code, elements integrity
- validate_structured_component_output: unified dispatcher by target_type

BDD tests in features/structural_validation.feature.

ISSUES CLOSED: #11147
2026-05-12 18:30:40 +00:00

458 lines
18 KiB
Python

"""Step definitions for structural component output validation.
Tests for features/structural_validation.feature - validates all four
validators covering plan tree nodes, decision CLI dicts, structured
output envelopes, and the unified dispatcher.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.core.validation import (
ValidationError,
validate_decision_dict,
validate_plan_tree,
validate_structured_component_output,
validate_structured_output,
)
# Helper: valid ULID strings for test data
VALID_ULID_A = "01ARZ3NDEKTSV4XXFFJFRC889A"
VALID_ULID_B = "01ARZ3NDEKTSV4XXFFJFRC889B"
VALID_ULID_C = "01ARZ3NDEKTSV4XXFFJFRC889C"
# ────────────────────────────────────────────────────────────
# Plan tree scenarios
# ────────────────────────────────────────────────────────────
@given('the plan tree contains {node_count:d} node(s)')
def step_plan_tree_node_count(ctx: Context, node_count: int) -> None:
"""Create a list of valid plan tree nodes."""
ctx.tree_nodes = [
{
"decision_id": f"01ARZ3NDEKTSV4XXFFJFRC{str(i).zfill(4)}",
"type": "strategy_choice",
"sequence": i,
"question": f"Decision question #{i}",
"children": [],
**(
{"parent_decision_id": VALID_ULID_A}
if i > 0 else {}
),
}
for i in range(node_count)
]
@given('the plan tree contains a node missing "decision_id"')
def step_node_missing_decision_id(ctx: Context) -> None:
"""Create a node missing the required decision_id field."""
ctx.tree_nodes = [
{"type": "strategy_choice", "sequence": 0, "question": "Missing ID", "children": []}
]
@given('the plan tree contains a node with invalid ULID "not-a-ulid-12345"')
def step_node_invalid_ulid(ctx: Context) -> None:
"""Create a node with an invalid decision_id."""
ctx.tree_nodes = [
{
"decision_id": "not-a-ulid-12345",
"type": "strategy_choice",
"sequence": 0,
"question": "Invalid ULID test",
"children": [],
}
]
@given('the plan tree contains a node with empty "children" list')
def step_node_empty_children(ctx: Context) -> None:
"""Create a node with an empty children list - should be valid."""
ctx.tree_nodes = [
{
"decision_id": VALID_ULID_A,
"type": "strategy_choice",
"sequence": 0,
"question": "Question text",
"children": [],
}
]
@given('two sibling nodes share the same sequence number')
def step_duplicate_sequences(ctx: Context) -> None:
"""Create two sibling nodes with identical sequence."""
ctx.tree_nodes = [
{
"decision_id": VALID_ULID_A,
"type": "strategy_choice",
"sequence": 5,
"question": "First",
"children": [],
"parent_decision_id": VALID_ULID_B,
},
{
"decision_id": VALID_ULID_C,
"type": "implementation_choice",
"sequence": 5,
"question": "Second",
"children": [],
"parent_decision_id": VALID_ULID_B,
},
]
@given('a plan tree node with sequence "{seq}"')
def step_node_sequence(ctx: Context, seq: str) -> None:
"""Create a node with the given sequence value (as string to parse)."""
int_seq = int(seq)
ctx.tree_nodes = [
{
"decision_id": VALID_ULID_A,
"type": "strategy_choice",
"sequence": int_seq,
"question": "Q",
"children": [],
}
]
# ────────────────────────────────────────────────────────────
# Decision dict scenarios
# ────────────────────────────────────────────────────────────
def _parse_value(raw: str) -> Any:
"""Parse a behave value string into Python type."""
stripped = raw.strip().strip('"').strip("'")
if stripped in ("null", "none", "None"):
return None
if stripped == "true":
return True
if stripped == "false":
return False
try:
return int(stripped)
except ValueError:
pass
try:
return float(stripped)
except ValueError:
pass
return stripped
def _ensure_decision_dict(ctx: Context) -> dict:
"""Ensure ctx.decision_dict exists with defaults."""
if not hasattr(ctx, "decision_dict"):
ctx.decision_dict = {
"decision_id": VALID_ULID_A,
"plan_id": VALID_ULID_B,
"type": "strategy_choice",
"sequence": 1,
"question": "Which approach?",
"chosen": "Option A",
"confidence": 0.75,
"parent": "(root)",
"is_correction": False,
"superseded": False,
}
return ctx.decision_dict # type: ignore[return-value]
@given('I have a decision dict with "{field}" set to {value}')
def step_decision_dict_field(ctx: Context, field: str, value) -> Any: # type: ignore[no-untyped-def]
"""Set a single field on the decision dict."""
d = _ensure_decision_dict(ctx)
d[field] = _parse_value(str(value))
@given('I have a valid decision dict')
@when('I validate the decision dict')
def step_validate_decision(ctx: Context) -> None:
"""Validate and store results."""
d = _ensure_decision_dict(ctx)
ctx.decision_result = validate_decision_dict(d)
# ────────────────────────────────────────────────────────────
# Structured output scenarios
# ────────────────────────────────────────────────────────────
def _ensure_struct_output(ctx: Context) -> dict:
"""Ensure ctx.struct_output exists with defaults."""
if not hasattr(ctx, "struct_output"):
ctx.struct_output = {
"command": "agents plan list",
"session_id": VALID_ULID_A,
"status": "ok",
"exit_code": 0,
"elements": [],
}
return ctx.struct_output # type: ignore[return-value]
@given('I have a valid structured output with all required fields')
@when('I validate the structured output')
def step_validate_structured(ctx: Context) -> None:
"""Validate and store results."""
o = _ensure_struct_output(ctx)
ctx.struct_result = validate_structured_output(o)
@given('I have a structured output with "{field}" set to {value} (and all other required fields present)')
def step_structured_output_field(ctx: Context, field: str, value) -> Any: # type: ignore[no-untyped-def]
"""Set a single field on the structured output."""
o = _ensure_struct_output(ctx)
o[field] = _parse_value(str(value))
@given('I have a structured output with empty "elements" list (and all other fields valid)')
def step_empty_elements(ctx: Context) -> None:
"""Empty elements is valid."""
o = _ensure_struct_output(ctx)
o["elements"] = []
@given('I have a structured output with an element missing "kind" field')
def step_invalid_element(ctx: Context) -> None:
"""Create an invalid element."""
o = _ensure_struct_output(ctx)
o["elements"] = [{"not_kind": "panel"}]
# ────────────────────────────────────────────────────────────
# Dispatcher scenarios
# ────────────────────────────────────────────────────────────
@given('I target validation for "{target}" with valid data')
def step_dispatcher_target(ctx: Context, target: str) -> None:
"""Set up a dispatcher test."""
ctx.target_type = target
if target in ("plan_tree", "plan-tree", "decisions", "decision-tree", "tree"):
ctx.validator_data = [
{"decision_id": VALID_ULID_A, "type": "strategy_choice", "sequence": 0, "question": "Q", "children": []}
]
elif target in ("decision", "decision_cli", "decision-cli", "cli_dict", "as_cli_dict"):
ctx.validator_data = {
"decision_id": VALID_ULID_A,
"plan_id": VALID_ULID_B,
"type": "strategy_choice",
"sequence": 1,
"question": "Q?",
"chosen": "A",
"confidence": 0.5,
"parent": "(root)",
"is_correction": False,
"superseded": False,
}
elif target in ("structured_output", "structured-output", "session_output", "output_session"):
ctx.validator_data = {
"command": "test",
"session_id": VALID_ULID_A,
"status": "ok",
"exit_code": 0,
"elements": [],
}
else:
ctx.validator_data = {}
@when('I call validate_structured_component_output')
def step_call_dispatcher(ctx: Context) -> None:
"""Call the dispatcher."""
target = ctx.target_type if hasattr(ctx, "target_type") else "plan_tree" # type: ignore[attr-defined]
data = ctx.validator_data if hasattr(ctx, "validator_data") else {} # type: ignore[attr-defined]
try:
ctx.dispatcher_result = validate_structured_component_output(target, data) # type: ignore[arg-type]
ctx.dispatcher_error = None
except ValidationError as exc:
ctx.dispatcher_result = None
ctx.dispatcher_error = exc
# ────────────────────────────────────────────────────────────
# Shared scenarios
# ────────────────────────────────────────────────────────────
@given('the plan tree has {node_count:d} valid nodes')
def step_valid_tree(ctx: Context, node_count: int = 3) -> None:
"""Set up a default valid tree."""
if not hasattr(ctx, "tree_nodes"):
ctx.tree_nodes = [
{"decision_id": f"01ARZ3NDEKTSV4XXFFJFRC{str(i).zfill(4)}",
"type": "strategy_choice", "sequence": i, "question": f"Q{i}", "children": []}
for i in range(node_count)
]
@given('I have a plan tree with valid nodes')
def step_valid_nodes_default(ctx: Context) -> None:
"""Set default valid tree if no tree defined."""
_valid_tree = False # handled by node_count scenario; nothing extra
@when('I validate the plan tree')
def step_validate_plan_tree(ctx: Context) -> None:
"""Validate and store results."""
nodes = ctx.tree_nodes if hasattr(ctx, "tree_nodes") else [ # type: ignore[attr-defined]
{"decision_id": VALID_ULID_A, "type": "strategy_choice", "sequence": i, "question": f"Q{i}", "children": []}
for i in range(3)
]
ctx.validation_result = validate_plan_tree(nodes)
@when('I validate the plan tree structure')
def step_validate_structure(ctx: Context) -> None:
"""Run plan_tree validation."""
nodes = ctx.tree_nodes if hasattr(ctx, "tree_nodes") else [] # type: ignore[attr-defined]
ctx.validation_result = validate_plan_tree(nodes)
@given('I have a decision dict where whitespace may vary between fields')
def step_whitespace_dict(ctx: Context) -> None:
"""Whitespace in string values - structure still valid."""
ctx.decision_dict = {
"decision_id": VALID_ULID_A,
"plan_id": VALID_ULID_B,
"type": " strategy_choice ",
"sequence": 1,
"question": "Which approach? ",
"chosen": " Option A ",
"confidence": 0.75,
"parent": "(root)",
"is_correction": False,
"superseded": False,
}
@given('a plan tree with parent and child node references connected by ULID')
def step_parent_child_tree(ctx: Context) -> None:
"""Parent-decoy pattern - root plus children."""
ctx.tree_nodes = [
{
"decision_id": VALID_ULID_A,
"type": "prompt_definition",
"sequence": 0,
"question": "Root question",
"children": [{"decision_id": VALID_ULID_B}, {"decision_id": VALID_ULID_C}],
"parent_decision_id": None,
},
{
"decision_id": VALID_ULID_B,
"type": "strategy_choice",
"sequence": 1,
"question": "Child A",
"children": [],
"parent_decision_id": VALID_ULID_A,
},
{
"decision_id": VALID_ULID_C,
"type": "strategy_choice",
"sequence": 2,
"question": "Child B",
"children": [],
"parent_decision_id": VALID_ULID_A,
},
]
@given('I have invalid plan tree data')
def step_invalid_tree_data(ctx: Context) -> None:
"""Create invalid plan tree node."""
ctx.tree_nodes = [
{"not_a_key": "value"}, # missing required fields entirely
]
# ────────────────────────────────────────────────────────────
# Assertions (shared / cross-scenario)
# ────────────────────────────────────────────────────────────
@then('it should be structurally valid')
def step_should_be_valid(ctx: Context) -> None:
"""Assert validation passed."""
result = getattr(ctx, "validation_result", {}) or {"valid": True}
assert result["valid"], f"Expected valid but got errors: {result.get('errors', [])}"
@then('it should be structurally invalid')
def step_should_be_invalid(ctx: Context) -> None:
"""Assert validation failed."""
result = getattr(ctx, "validation_result", {}) or {"valid": True}
assert not result["valid"], f"Expected invalid but got valid"
@then('it should report no errors')
def step_no_errors(ctx: Context) -> None:
"""Assert no error messages."""
result = getattr(ctx, "validation_result", {}) or {}
errs = result.get("errors", [])
assert len(errs) == 0, f"Expected no errors but got: {errs}"
@then('it should report errors including "{text}"')
def step_errors_include(ctx: Context, text: str) -> None:
"""Assert at least one error contains the given substring."""
result = getattr(ctx, "validation_result", {}) or {}
errs = result.get("errors", [])
assert any(text in e for e in errs), f"'{text}' not found in errors: {errs}"
@then('error message must include "{text}"')
@then('error must include "{text}"')
def step_error_contains(ctx: Context, text: str) -> None:
"""Assert an error contains substring."""
result = getattr(ctx, "validation_result", {}) or {}
errs = result.get("errors", [])
assert any(text in e for e in errs), f"'{text}' not found in errors: {errs}"
@then('the first error message should identify "{prefix}" and the specific field issue')
def step_first_error_identifies(ctx: Context, prefix: str) -> None:
"""Assert first error contains prefix."""
result = getattr(ctx, "validation_result", {}) or {}
errs = result.get("errors", [])
assert len(errs) > 0, "Expected at least one error"
assert prefix in errs[0], f"First error '{errs[0]}' should contain '{prefix}'"
@then('it should raise a ValidationError with message containing "{text}"')
def step_raise_error_with_text(ctx: Context, text: str) -> None:
"""Assert dispatcher raised specific error."""
err = getattr(ctx, "dispatcher_error", None)
assert err is not None, "Expected a ValidationError"
assert isinstance(err, ValidationError), f"Expected ValidationError, got {type(err)}"
assert text in str(err), f"'{text}' not in error message: {err}"
@then('it should dispatch to the matching validator')
@then('return a valid result')
def step_dispatcher_valid(ctx: Context) -> None:
"""Assert dispatcher succeeded and returned valid."""
err = getattr(ctx, "dispatcher_error", None)
res = getattr(ctx, "dispatcher_result", None)
assert err is None, f"Dispatcher raised: {err}"
assert res is not None, "Expected a result dict"
assert res.get("valid"), f"Result not valid: {res}"
@then('validation should pass (structure matched, not exact characters)')
def step_structure_matched(ctx: Context) -> None:
"""Whitespace-tolerant structural validation passes."""
d = getattr(ctx, "decision_dict", {})
ctx.validation_result = validate_decision_dict(d) # type: ignore[attr-defined]
assert ctx.validation_result["valid"], f"Structural validation failed: {ctx.validation_result.get('errors', [])}" # type: ignore[attr-defined]
@then('parent-child relationships should be validly connected')
def step_relationships_valid(ctx: Context) -> None:
"""Parent-child nodes validate without errors."""
result = ctx.validation_result if hasattr(ctx, "validation_result") else {} # type: ignore[attr-defined]
assert result.get("valid", False), f"Relation validation failed: {result.get('errors', [])}"