bd955696f9
Address all 4 remaining blocking issues from PR #7811 review: - Delete duplicate _validation_pipeline_mock.py in features/steps/ (the inline MockValidationExecutor is kept where it belongs) - Replace # type: ignore[return-value] with cast(dict[str, Any], ...) to satisfy the zero-type-suppressions policy for test additions - Add @tdd_issue_7623 regression tag on concurrency scenario - Remove unreachable dead-code RuntimeError guard in _install_thread_local_streams() (constructors never return None) All CI lint checks now pass. Source files remain within line limits.
426 lines
15 KiB
Python
426 lines
15 KiB
Python
"""Step definitions for validation pipeline scenarios.
|
|
|
|
All step names are prefixed with 'validation pipeline' context to avoid
|
|
AmbiguousStep collisions with existing step definitions.
|
|
Uses the ``re`` step matcher for patterns that have optional trailing parts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from behave import given, then, use_step_matcher, when
|
|
|
|
from cleveragents.application.services.validation_pipeline import (
|
|
ValidationCommand,
|
|
ValidationPipeline,
|
|
ValidationResult,
|
|
ValidationSummary,
|
|
)
|
|
from cleveragents.domain.models.core.tool import ValidationMode
|
|
from features.mocks.validation_pipeline_mock import MockValidationExecutor
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_RESOURCE_ID_COUNTER: int = 0
|
|
|
|
|
|
def _next_resource_id() -> str:
|
|
global _RESOURCE_ID_COUNTER
|
|
_RESOURCE_ID_COUNTER += 1
|
|
return f"R{_RESOURCE_ID_COUNTER:05d}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — use "re" matcher for the required/informational patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
use_step_matcher("re")
|
|
|
|
|
|
@given(r"a validation pipeline test environment")
|
|
def step_given_pipeline_env(context: Any) -> None:
|
|
context.vp_commands: list[ValidationCommand] = []
|
|
context.vp_executor = MockValidationExecutor()
|
|
context.vp_max_workers: int = 4
|
|
context.vp_read_only_resources: set[str] = set()
|
|
context.vp_summary: ValidationSummary | None = None
|
|
context.vp_plan_metadata: dict[str, Any] | None = None
|
|
context.vp_resource_id_map: dict[str, str] = {}
|
|
global _RESOURCE_ID_COUNTER
|
|
_RESOURCE_ID_COUNTER = 0
|
|
|
|
|
|
@given(r"an empty list of validation commands")
|
|
def step_given_empty_commands(context: Any) -> None:
|
|
context.vp_commands = []
|
|
|
|
|
|
@given(
|
|
r'a required vp command "(?P<name>[^"]+)" on resource "(?P<resource>[^"]+)"'
|
|
r" with timeout (?P<timeout>[\d.]+)"
|
|
)
|
|
def step_given_required_command_timeout(
|
|
context: Any, name: str, resource: str, timeout: str
|
|
) -> None:
|
|
rid = context.vp_resource_id_map.get(resource, _next_resource_id())
|
|
context.vp_resource_id_map[resource] = rid
|
|
context.vp_commands.append(
|
|
ValidationCommand(
|
|
validation_name=name,
|
|
resource_id=rid,
|
|
resource_name=resource,
|
|
mode=ValidationMode.REQUIRED,
|
|
arguments={},
|
|
timeout_seconds=float(timeout),
|
|
)
|
|
)
|
|
|
|
|
|
@given(
|
|
r'a required vp command "(?P<name>[^"]+)" on resource "(?P<resource>[^"]+)"'
|
|
r' having resource_id "(?P<rid>[^"]+)"'
|
|
)
|
|
def step_given_required_command_with_rid(
|
|
context: Any, name: str, resource: str, rid: str
|
|
) -> None:
|
|
context.vp_resource_id_map[resource] = rid
|
|
context.vp_commands.append(
|
|
ValidationCommand(
|
|
validation_name=name,
|
|
resource_id=rid,
|
|
resource_name=resource,
|
|
mode=ValidationMode.REQUIRED,
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
|
|
@given(r'a required vp command "(?P<name>[^"]+)" on resource "(?P<resource>[^"]+)"')
|
|
def step_given_required_command(context: Any, name: str, resource: str) -> None:
|
|
rid = context.vp_resource_id_map.get(resource, _next_resource_id())
|
|
context.vp_resource_id_map[resource] = rid
|
|
context.vp_commands.append(
|
|
ValidationCommand(
|
|
validation_name=name,
|
|
resource_id=rid,
|
|
resource_name=resource,
|
|
mode=ValidationMode.REQUIRED,
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
|
|
@given(
|
|
r'an informational vp command "(?P<name>[^"]+)"'
|
|
r' on resource "(?P<resource>[^"]+)"'
|
|
)
|
|
def step_given_informational_command(context: Any, name: str, resource: str) -> None:
|
|
rid = context.vp_resource_id_map.get(resource, _next_resource_id())
|
|
context.vp_resource_id_map[resource] = rid
|
|
context.vp_commands.append(
|
|
ValidationCommand(
|
|
validation_name=name,
|
|
resource_id=rid,
|
|
resource_name=resource,
|
|
mode=ValidationMode.INFORMATIONAL,
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
|
|
@given(r'the vp executor returns passed for "(?P<name>[^"]+)"')
|
|
def step_given_executor_passed(context: Any, name: str) -> None:
|
|
context.vp_executor.set_passed(name)
|
|
|
|
|
|
@given(
|
|
r'the vp executor returns failed for "(?P<name>[^"]+)"'
|
|
r' with message "(?P<message>[^"]+)"'
|
|
)
|
|
def step_given_executor_failed(context: Any, name: str, message: str) -> None:
|
|
context.vp_executor.set_failed(name, message)
|
|
|
|
|
|
@given(r"the vp executor returns passed for all validations")
|
|
def step_given_executor_all_passed(context: Any) -> None:
|
|
context.vp_executor.set_default_passed()
|
|
|
|
|
|
@given(r'the vp executor will timeout for "(?P<name>[^"]+)"')
|
|
def step_given_executor_timeout(context: Any, name: str) -> None:
|
|
context.vp_executor.set_timeout(name)
|
|
|
|
|
|
@given(r'the vp executor returns non-dict output for "(?P<name>[^"]+)"')
|
|
def step_given_executor_non_dict(context: Any, name: str) -> None:
|
|
context.vp_executor.set_non_dict(name)
|
|
|
|
|
|
@given(r'the vp executor returns dict without passed key for "(?P<name>[^"]+)"')
|
|
def step_given_executor_missing_passed(context: Any, name: str) -> None:
|
|
context.vp_executor.set_missing_passed(name)
|
|
|
|
|
|
@given(r'the vp executor raises an exception for "(?P<name>[^"]+)"')
|
|
def step_given_executor_exception(context: Any, name: str) -> None:
|
|
context.vp_executor.set_exception(name, RuntimeError("Test exception"))
|
|
|
|
|
|
@given(r'the vp executor returns passed with data for "(?P<name>[^"]+)"')
|
|
def step_given_executor_passed_with_data(context: Any, name: str) -> None:
|
|
context.vp_executor.set_passed_with_data(name)
|
|
|
|
|
|
@given(r'the vp resource "(?P<rid>[^"]+)" is marked as read-only')
|
|
def step_given_read_only_resource(context: Any, rid: str) -> None:
|
|
context.vp_read_only_resources.add(rid)
|
|
|
|
|
|
@given(r"a validation summary with (?P<count>\d+) required failures")
|
|
def step_given_summary_with_failures(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
context.vp_summary = ValidationSummary(
|
|
total=cnt + 1,
|
|
required_passed=1,
|
|
required_failed=cnt,
|
|
informational_passed=0,
|
|
informational_failed=0,
|
|
results=[],
|
|
)
|
|
|
|
|
|
@given(r"the pipeline max_workers is set to (?P<workers>\d+)")
|
|
def step_given_max_workers(context: Any, workers: str) -> None:
|
|
context.vp_max_workers = int(workers)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when(r"the validation pipeline runs for plan with metadata")
|
|
def step_when_pipeline_runs_for_plan(context: Any) -> None:
|
|
context.vp_plan_metadata = {}
|
|
pipeline = ValidationPipeline(
|
|
commands=context.vp_commands,
|
|
executor=context.vp_executor,
|
|
max_workers=context.vp_max_workers,
|
|
read_only_resources=context.vp_read_only_resources,
|
|
)
|
|
context.vp_summary = pipeline.run_for_plan(plan_metadata=context.vp_plan_metadata)
|
|
|
|
|
|
@when(r"the validation pipeline runs")
|
|
def step_when_pipeline_runs(context: Any) -> None:
|
|
pipeline = ValidationPipeline(
|
|
commands=context.vp_commands,
|
|
executor=context.vp_executor,
|
|
max_workers=context.vp_max_workers,
|
|
read_only_resources=context.vp_read_only_resources,
|
|
)
|
|
context.vp_summary = pipeline.run()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then(r"the validation summary total is (?P<count>\d+)")
|
|
def step_then_summary_total(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None, "No summary available"
|
|
assert context.vp_summary.total == cnt, (
|
|
f"Expected total={cnt}, got {context.vp_summary.total}"
|
|
)
|
|
|
|
|
|
@then(r"the validation summary required_passed is (?P<count>\d+)")
|
|
def step_then_summary_required_passed(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None
|
|
assert context.vp_summary.required_passed == cnt, (
|
|
f"Expected required_passed={cnt}, got {context.vp_summary.required_passed}"
|
|
)
|
|
|
|
|
|
@then(r"the validation summary required_failed is (?P<count>\d+)")
|
|
def step_then_summary_required_failed(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None
|
|
assert context.vp_summary.required_failed == cnt, (
|
|
f"Expected required_failed={cnt}, got {context.vp_summary.required_failed}"
|
|
)
|
|
|
|
|
|
@then(r"the validation summary informational_passed is (?P<count>\d+)")
|
|
def step_then_summary_informational_passed(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None
|
|
assert context.vp_summary.informational_passed == cnt, (
|
|
f"Expected informational_passed={cnt}, "
|
|
f"got {context.vp_summary.informational_passed}"
|
|
)
|
|
|
|
|
|
@then(r"the validation summary informational_failed is (?P<count>\d+)")
|
|
def step_then_summary_informational_failed(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None
|
|
assert context.vp_summary.informational_failed == cnt, (
|
|
f"Expected informational_failed={cnt}, "
|
|
f"got {context.vp_summary.informational_failed}"
|
|
)
|
|
|
|
|
|
@then(r"all required validations passed")
|
|
def step_then_all_required_passed(context: Any) -> None:
|
|
assert context.vp_summary is not None
|
|
assert context.vp_summary.all_required_passed, (
|
|
f"Expected all_required_passed=True, got False "
|
|
f"(required_failed={context.vp_summary.required_failed})"
|
|
)
|
|
|
|
|
|
@then(r"not all required validations passed")
|
|
def step_then_not_all_required_passed(context: Any) -> None:
|
|
assert context.vp_summary is not None
|
|
assert not context.vp_summary.all_required_passed, (
|
|
"Expected all_required_passed=False, got True"
|
|
)
|
|
|
|
|
|
@then(r"the validation results are sorted by resource then mode then name")
|
|
def step_then_results_sorted(context: Any) -> None:
|
|
assert context.vp_summary is not None
|
|
results = context.vp_summary.results
|
|
keys = [(r.resource_name, r.mode.value, r.validation_name) for r in results]
|
|
assert keys == sorted(keys), f"Results not sorted: {keys}"
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has timed_out true')
|
|
def step_then_result_timed_out(context: Any, name: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert result.timed_out, f"Expected timed_out=True for '{name}'"
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has passed false')
|
|
def step_then_result_passed_false(context: Any, name: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert not result.passed, f"Expected passed=False for '{name}'"
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has passed true')
|
|
def step_then_result_passed_true(context: Any, name: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert result.passed, f"Expected passed=True for '{name}'"
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has error containing "(?P<text>[^"]+)"')
|
|
def step_then_result_error_contains(context: Any, name: str, text: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert result.error is not None, f"Expected error for '{name}', got None"
|
|
assert text in result.error, (
|
|
f"Expected error containing '{text}', got: {result.error}"
|
|
)
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has message containing "(?P<text>[^"]+)"')
|
|
def step_then_result_message_contains(context: Any, name: str, text: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert text in result.message, (
|
|
f"Expected message containing '{text}', got: {result.message}"
|
|
)
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has data key "(?P<key>[^"]+)"')
|
|
def step_then_result_has_data_key(context: Any, name: str, key: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert result.data is not None, f"Expected data for '{name}', got None"
|
|
assert key in result.data, (
|
|
f"Expected data key '{key}' in {list(result.data.keys())}"
|
|
)
|
|
|
|
|
|
@then(r"the results grouped by resource have (?P<count>\d+) groups")
|
|
def step_then_groups_count(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None
|
|
groups = ValidationPipeline.group_by_resource(context.vp_summary.results)
|
|
assert len(groups) == cnt, f"Expected {cnt} groups, got {len(groups)}"
|
|
|
|
|
|
@then(r'the vp resource group "(?P<resource>[^"]+)" has (?P<count>\d+) result')
|
|
def step_then_group_result_count(context: Any, resource: str, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_summary is not None
|
|
groups = ValidationPipeline.group_by_resource(context.vp_summary.results)
|
|
assert resource in groups, f"Resource '{resource}' not in groups"
|
|
actual = len(groups[resource])
|
|
assert actual == cnt, f"Expected {cnt} results for '{resource}', got {actual}"
|
|
|
|
|
|
@then(r"the summary all_required_passed property is true")
|
|
def step_then_summary_property_true(context: Any) -> None:
|
|
assert context.vp_summary is not None
|
|
assert context.vp_summary.all_required_passed
|
|
|
|
|
|
@then(r"the summary all_required_passed property is false")
|
|
def step_then_summary_property_false(context: Any) -> None:
|
|
assert context.vp_summary is not None
|
|
assert not context.vp_summary.all_required_passed
|
|
|
|
|
|
@then(r"the plan metadata contains validation_summary")
|
|
def step_then_plan_metadata_has_summary(context: Any) -> None:
|
|
assert context.vp_plan_metadata is not None
|
|
assert "validation_summary" in context.vp_plan_metadata
|
|
|
|
|
|
@then(r"the plan metadata validation_summary total is (?P<count>\d+)")
|
|
def step_then_plan_metadata_total(context: Any, count: str) -> None:
|
|
cnt = int(count)
|
|
assert context.vp_plan_metadata is not None
|
|
vs = context.vp_plan_metadata["validation_summary"]
|
|
assert vs["total"] == cnt, f"Expected total={cnt}, got {vs['total']}"
|
|
|
|
|
|
@then(r'the vp result for "(?P<name>[^"]+)" has positive duration_ms')
|
|
def step_then_result_positive_duration(context: Any, name: str) -> None:
|
|
assert context.vp_summary is not None
|
|
result = _find_result(context.vp_summary, name)
|
|
assert result.duration_ms > 0, (
|
|
f"Expected duration_ms > 0 for '{name}', got {result.duration_ms}"
|
|
)
|
|
|
|
|
|
# Reset to default parse matcher
|
|
use_step_matcher("parse")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _find_result(summary: ValidationSummary, name: str) -> ValidationResult:
|
|
"""Find a validation result by name."""
|
|
for r in summary.results:
|
|
if r.validation_name == name:
|
|
return r
|
|
available = [r.validation_name for r in summary.results]
|
|
msg = f"No result for '{name}', available: {available}"
|
|
raise AssertionError(msg)
|