"""Step definitions for binding_resolution.feature.""" from __future__ import annotations from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.application.services.binding_resolution_service import ( BindingResolutionService, ) from cleveragents.core.exceptions import NotFoundError, ValidationError from cleveragents.domain.models.core.project import ( LinkedResource, NamespacedProject, ) from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ) from cleveragents.domain.models.core.resource_slot import BindingResult from cleveragents.domain.models.core.resource_type import ( ResourceKind, ResourceTypeSpec, SandboxStrategy, ) from cleveragents.domain.models.core.tool import ( BindingMode, ResourceAccessMode, ResourceSlot, Tool, ToolSource, ToolType, ) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _make_resource( resource_id: str, type_name: str, name: str | None = None, ) -> Resource: return Resource( resource_id=resource_id, name=name, resource_type_name=type_name, classification=PhysVirt.PHYSICAL, description=f"Test resource {resource_id}", ) def _make_type_spec( name: str, parent_types: list[str] | None = None, ) -> ResourceTypeSpec: is_builtin = "/" not in name return ResourceTypeSpec( name=name, description=f"Type {name}", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, parent_types=parent_types or [], built_in=is_builtin, ) # ------------------------------------------------------------------ # Background # ------------------------------------------------------------------ @given("a mock resource registry") def step_mock_registry(context: Context) -> None: from cleveragents.resource.inheritance import is_subtype_of as _is_subtype_of registry = MagicMock() registry._resources: dict[str, Resource] = {} registry._types: dict[str, ResourceTypeSpec] = { "git-checkout": _make_type_spec("git-checkout", parent_types=[]), "fs-directory": _make_type_spec( "fs-directory", parent_types=["git-checkout", "fs-directory"], ), "fs-file": _make_type_spec("fs-file", parent_types=[]), } # Inheritance map used by is_subtype_of: maps type name -> inherits value. # Populated by step helpers when multi-level inheritance is needed. registry._inherits: dict[str, str | None] = { "git-checkout": None, "fs-directory": None, "fs-file": None, } def show_resource(name_or_id: str) -> Resource: res = registry._resources.get(name_or_id) if res is None: raise NotFoundError( resource_type="resource", resource_id=name_or_id, ) return res def show_type(name: str) -> ResourceTypeSpec: spec = registry._types.get(name) if spec is None: raise NotFoundError( resource_type="resource_type", resource_id=name, ) return spec def is_subtype_of_mock(type_name: str, ancestor_name: str) -> bool: """Walk the full inheritance chain using the _inherits map.""" type_registry = { t: {"inherits": registry._inherits.get(t)} for t in set(list(registry._types.keys()) + list(registry._inherits.keys())) } return _is_subtype_of(type_name, ancestor_name, type_registry) registry.show_resource = show_resource registry.show_type = show_type registry.is_subtype_of = is_subtype_of_mock context.mock_registry = registry @given("a binding resolution service using the registry") def step_service(context: Context) -> None: context.binding_service = BindingResolutionService( resource_registry=context.mock_registry, ) # ------------------------------------------------------------------ # Given: projects # ------------------------------------------------------------------ @given('a project "{name}" with a linked resource:') def step_project_one_resource(context: Context, name: str) -> None: parts = name.split("/", 1) namespace = parts[0] if len(parts) == 2 else "local" proj_name = parts[1] if len(parts) == 2 else parts[0] links: list[LinkedResource] = [] for row in context.table: rid = row["resource_id"] tname = row["type_name"] alias = row["alias"] if row["alias"] else None resource = _make_resource(rid, tname, name=None) context.mock_registry._resources[rid] = resource links.append( LinkedResource( resource_id=rid, alias=alias, ) ) context.project = NamespacedProject( name=proj_name, namespace=namespace, linked_resources=links, ) @given('a project "{name}" with linked resources:') def step_project_multi_resources(context: Context, name: str) -> None: parts = name.split("/", 1) namespace = parts[0] if len(parts) == 2 else "local" proj_name = parts[1] if len(parts) == 2 else parts[0] links: list[LinkedResource] = [] for row in context.table: rid = row["resource_id"] tname = row["type_name"] alias = row["alias"] if row["alias"] else None resource = _make_resource(rid, tname, name=None) context.mock_registry._resources[rid] = resource links.append( LinkedResource( resource_id=rid, alias=alias, ) ) context.project = NamespacedProject( name=proj_name, namespace=namespace, linked_resources=links, ) @given('a project "{name}" with no linked resources') def step_project_empty(context: Context, name: str) -> None: parts = name.split("/", 1) namespace = parts[0] if len(parts) == 2 else "local" proj_name = parts[1] if len(parts) == 2 else parts[0] context.project = NamespacedProject( name=proj_name, namespace=namespace, linked_resources=[], ) # ------------------------------------------------------------------ # Given: resources in registry # ------------------------------------------------------------------ @given('a resource "{res_name}" of type "{type_name}" in the registry') def step_resource_in_registry( context: Context, res_name: str, type_name: str, ) -> None: rid = "01HGZ6FE0AQDYTR4BX00000020" if res_name == "local/wrong-type": rid = "01HGZ6FE0AQDYTR4BX00000050" resource = _make_resource(rid, type_name, name=res_name) context.mock_registry._resources[res_name] = resource context.mock_registry._resources[rid] = resource # ------------------------------------------------------------------ # Given: resource type overrides # ------------------------------------------------------------------ @given('the resource type "{tname}" has parent_types including "{parent}"') def step_type_parent(context: Context, tname: str, parent: str) -> None: existing = context.mock_registry._types.get(tname) parents = list(existing.parent_types) if existing else [] if parent not in parents: parents.append(parent) context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=parents) # Also update the _inherits map so is_subtype_of() walks the chain correctly. # Use the first parent as the direct ancestor (single-inheritance model). if parents: context.mock_registry._inherits[tname] = parents[0] @given('the resource type "{tname}" has no parent_types') def step_type_no_parents(context: Context, tname: str) -> None: context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=[]) # ------------------------------------------------------------------ # Given: tools # ------------------------------------------------------------------ @given('a tool "{tool_name}" with a contextual slot "{slot}" of type "{rtype}"') def step_tool_contextual( context: Context, tool_name: str, slot: str, rtype: str, ) -> None: context.tool = Tool( name=tool_name, description=f"Tool {tool_name}", source=ToolSource.BUILTIN, tool_type=ToolType.TOOL, resource_slots=[ ResourceSlot( name=slot, resource_type=rtype, access=ResourceAccessMode.READ_WRITE, binding=BindingMode.CONTEXTUAL, ), ], ) @given( 'a tool "{tool_name}" with a static slot "{slot}" bound to' ' "{res}" of type "{rtype}"' ) def step_tool_static( context: Context, tool_name: str, slot: str, res: str, rtype: str, ) -> None: context.tool = Tool( name=tool_name, description=f"Tool {tool_name}", source=ToolSource.BUILTIN, tool_type=ToolType.TOOL, resource_slots=[ ResourceSlot( name=slot, resource_type=rtype, access=ResourceAccessMode.READ_ONLY, binding=BindingMode.STATIC, static_resource=res, ), ], ) @given('a tool "{tool_name}" with a parameter slot "{slot}" of type "{rtype}"') def step_tool_parameter( context: Context, tool_name: str, slot: str, rtype: str, ) -> None: context.tool = Tool( name=tool_name, description=f"Tool {tool_name}", source=ToolSource.BUILTIN, tool_type=ToolType.TOOL, resource_slots=[ ResourceSlot( name=slot, resource_type=rtype, access=ResourceAccessMode.READ_WRITE, binding=BindingMode.PARAMETER, ), ], ) @given('a tool "{tool_name}" with mixed slots:') def step_tool_mixed(context: Context, tool_name: str) -> None: slots: list[ResourceSlot] = [] for row in context.table: binding = BindingMode(row["binding"]) static_res: str | None = ( row["static_resource"] if row["static_resource"] else None ) slots.append( ResourceSlot( name=row["name"], resource_type=row["resource_type"], access=ResourceAccessMode.READ_WRITE, binding=binding, static_resource=static_res, ) ) context.tool = Tool( name=tool_name, description=f"Tool {tool_name}", source=ToolSource.BUILTIN, tool_type=ToolType.TOOL, resource_slots=slots, ) # ------------------------------------------------------------------ # When # ------------------------------------------------------------------ @when("the bindings are resolved") def step_resolve(context: Context) -> None: context.binding_results = context.binding_service.resolve( context.tool, context.project, ) @when("the binding resolution is attempted") def step_resolve_attempt(context: Context) -> None: try: context.binding_results = context.binding_service.resolve( context.tool, context.project, ) context.binding_error = None except ValidationError as exc: context.binding_error = exc context.binding_results = None # ------------------------------------------------------------------ # Then # ------------------------------------------------------------------ @then('the binding for slot "{slot}" should have mode "{mode}"') def step_check_mode(context: Context, slot: str, mode: str) -> None: result = _find_result(context, slot) assert result.binding_mode == mode, ( f"Expected mode '{mode}', got '{result.binding_mode}'" ) @then('the binding for slot "{slot}" should have resource_id "{rid}"') def step_check_rid(context: Context, slot: str, rid: str) -> None: result = _find_result(context, slot) assert result.resource_id == rid, ( f"Expected resource_id '{rid}', got '{result.resource_id}'" ) @then('the binding for slot "{slot}" should not be deferred') def step_not_deferred(context: Context, slot: str) -> None: result = _find_result(context, slot) assert not result.deferred, f"Slot '{slot}' should not be deferred" @then('the binding for slot "{slot}" should be deferred') def step_is_deferred(context: Context, slot: str) -> None: result = _find_result(context, slot) assert result.deferred, f"Slot '{slot}' should be deferred" @then('the binding for slot "{slot}" should have no resource_id') def step_no_rid(context: Context, slot: str) -> None: result = _find_result(context, slot) assert result.resource_id is None, ( f"Expected None resource_id, got '{result.resource_id}'" ) @then('a binding ValidationError should be raised mentioning "{text}"') def step_binding_error(context: Context, text: str) -> None: assert context.binding_error is not None, ( "Expected ValidationError but none was raised" ) assert isinstance(context.binding_error, ValidationError), ( f"Expected ValidationError, got {type(context.binding_error).__name__}" ) assert text in str(context.binding_error), ( f"Expected '{text}' in error, got '{context.binding_error}'" ) @then("there should be {count:d} binding results") def step_count_results(context: Context, count: int) -> None: assert len(context.binding_results) == count, ( f"Expected {count} results, got {len(context.binding_results)}" ) # ------------------------------------------------------------------ # Internal helper # ------------------------------------------------------------------ def _find_result(context: Context, slot_name: str) -> BindingResult: for result in context.binding_results: if result.slot_name == slot_name: return result raise AssertionError(f"No binding result for slot '{slot_name}'") # ------------------------------------------------------------------ # Given: inheritance chain setup for multi-level tests # ------------------------------------------------------------------ @given('the resource type "{tname}" inherits from nothing') def step_type_inherits_nothing(context: Context, tname: str) -> None: """Register a root type with no parent in the inheritance chain.""" if tname not in context.mock_registry._types: context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=[]) context.mock_registry._inherits[tname] = None @given('the resource type "{tname}" inherits from "{parent}"') def step_type_inherits_from(context: Context, tname: str, parent: str) -> None: """Register a type that directly inherits from *parent*.""" if tname not in context.mock_registry._types: context.mock_registry._types[tname] = _make_type_spec( tname, parent_types=[parent] ) # Ensure parent is also in the inherits map (as a root if not already set). if parent not in context.mock_registry._inherits: context.mock_registry._inherits[parent] = None context.mock_registry._inherits[tname] = parent