"""Step definitions for the multi-project subplan 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.multi_project_service import ( MultiProjectService, ) from cleveragents.domain.models.core.multi_project import ( ChangeSetSummary, CrossProjectDependency, ProjectScopeResolver, ) from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, PlanIdentity, PlanPhase, ProcessingState, ProjectLink, ) # --- helpers --------------------------------------------------------------- _PLAN_ID = "01HXAAAAAAAAAAAAAAAAAAAAAA" def _make_plan(project_links: list[ProjectLink]) -> Plan: """Build a minimal Plan with the given project links.""" return Plan( identity=PlanIdentity(plan_id=_PLAN_ID), namespaced_name=NamespacedName.parse("local/multi-test"), description="Multi-project test plan", action_name="local/test-action", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.QUEUED, project_links=project_links, ) def _mock_decision_service() -> MagicMock: """Create a mock DecisionService.""" mock = MagicMock() mock.list_by_type.return_value = [] return mock # --- Background ------------------------------------------------------------ @given("a multi-project service") def step_create_service(context: Context) -> None: context.decision_service = _mock_decision_service() context.service = MultiProjectService( decision_service=context.decision_service, ) context.resolver = ProjectScopeResolver() context.available_resources: dict[str, list[str]] = {} @given('a plan with projects "{proj1}" and "{proj2}"') def step_plan_two_projects(context: Context, proj1: str, proj2: str) -> None: context.project_links = [ ProjectLink(project_name=proj1), ProjectLink(project_name=proj2), ] context.plan = _make_plan(context.project_links) @given('a plan with only project "{proj}"') def step_plan_single_project(context: Context, proj: str) -> None: context.project_links = [ProjectLink(project_name=proj)] context.plan = _make_plan(context.project_links) @given('a plan with project "{proj}" aliased as "{alias}"') def step_plan_aliased(context: Context, proj: str, alias: str) -> None: link = ProjectLink(project_name=proj, alias=alias) # Keep shared-lib from background other_links = [pl for pl in context.project_links if pl.project_name != proj] context.project_links = [link, *other_links] context.plan = _make_plan(context.project_links) @given('a plan with "{rw}" writable and "{ro}" read-only') def step_plan_mixed_access(context: Context, rw: str, ro: str) -> None: context.project_links = [ ProjectLink(project_name=rw, read_only=False), ProjectLink(project_name=ro, read_only=True), ] context.plan = _make_plan(context.project_links) # --- Resource setup -------------------------------------------------------- @given('available resources for "{proj}" are "{resource_csv}"') def step_set_resources(context: Context, proj: str, resource_csv: str) -> None: context.available_resources[proj] = [ r.strip() for r in resource_csv.split(",") if r.strip() ] @given("no available resources") def step_no_resources(context: Context) -> None: context.available_resources = {} # --- When clauses ---------------------------------------------------------- @when("I initialize multi-project scopes") def step_initialize_scopes(context: Context) -> None: context.plan = context.service.initialize_scopes( context.plan, context.available_resources ) @when("I initialize multi-project scopes with allow_mixed") def step_initialize_scopes_mixed(context: Context) -> None: context.plan = context.service.initialize_scopes( context.plan, context.available_resources ) # Override allow_mixed on the metadata if context.plan.multi_project_metadata is not None: updated_mp = context.plan.multi_project_metadata.model_copy( update={"allow_mixed_access": True} ) context.plan = context.plan.model_copy( update={"multi_project_metadata": updated_mp} ) @when("I validate access modes without allow_mixed") def step_validate_access_no_mixed(context: Context) -> None: context.access_errors = context.resolver.validate_access_modes( context.project_links, allow_mixed=False ) @when("I validate access modes with allow_mixed") def step_validate_access_mixed(context: Context) -> None: context.access_errors = context.resolver.validate_access_modes( context.project_links, allow_mixed=True ) @when('I add a cross-project dependency from "{src}" to "{tgt}" type "{dep_type}"') def step_add_dependency(context: Context, src: str, tgt: str, dep_type: str) -> None: dep = CrossProjectDependency( source_project=src, target_project=tgt, dependency_type=dep_type, ) mp = context.plan.multi_project_metadata assert mp is not None updated_deps = [*list(mp.cross_project_dependencies), dep] updated_mp = mp.model_copy(update={"cross_project_dependencies": updated_deps}) context.plan = context.plan.model_copy( update={"multi_project_metadata": updated_mp} ) @when('I record a changeset for "{proj}" with {n:d} files changed and {lines:d} lines') def step_record_changeset(context: Context, proj: str, n: int, lines: int) -> None: summary = ChangeSetSummary( project_name=proj, files_changed=n, total_lines_changed=lines, ) context.plan = context.service.record_changeset(context.plan, proj, summary) # --- Then clauses ---------------------------------------------------------- @then("the plan should have {count:d} project scopes") def step_check_scope_count(context: Context, count: int) -> None: mp = context.plan.multi_project_metadata assert mp is not None, "multi_project_metadata is None" assert len(mp.project_scopes) == count, ( f"Expected {count} scopes, got {len(mp.project_scopes)}" ) @then('scope "{proj}" should have {count:d} resource IDs') def step_check_scope_resources(context: Context, proj: str, count: int) -> None: scope = context.plan.get_project_scope(proj) assert scope is not None, f"Scope for {proj} not found" assert len(scope.resource_ids) == count, ( f"Expected {count} resources for {proj}, got {len(scope.resource_ids)}" ) @then("the plan should be multi-project") def step_is_multi_project(context: Context) -> None: assert context.plan.is_multi_project is True @then("the plan should not be multi-project") def step_is_not_multi_project(context: Context) -> None: assert context.plan.is_multi_project is False @then('resolving alias "{alias}" should return project "{proj}"') def step_resolve_alias(context: Context, alias: str, proj: str) -> None: link = context.resolver.resolve_alias(context.plan.project_links, alias) assert link is not None, f"Alias '{alias}' not resolved" assert link.project_name == proj @then('resolving alias "{alias}" should return nothing') def step_resolve_alias_none(context: Context, alias: str) -> None: link = context.resolver.resolve_alias(context.plan.project_links, alias) assert link is None, f"Expected None, got {link}" @then('resolving context view for "{proj}" should succeed') def step_context_view_success(context: Context, proj: str) -> None: context.resolved_scope = context.service.resolve_context_view(context.plan, proj) assert context.resolved_scope is not None @then('the context view should have project name "{proj}"') def step_context_view_name(context: Context, proj: str) -> None: assert context.resolved_scope.project_name == proj @then('resolving context view for "{proj}" should raise KeyError') def step_context_view_key_error(context: Context, proj: str) -> None: try: context.service.resolve_context_view(context.plan, proj) raise AssertionError("Expected KeyError") except KeyError: pass @then("resolving context view without metadata should raise ValueError") def step_context_view_no_metadata(context: Context) -> None: # Use a plan without multi_project_metadata bare_plan = _make_plan(context.project_links) try: context.service.resolve_context_view(bare_plan, "api-service") raise AssertionError("Expected ValueError") except ValueError: pass @then("there should be {count:d} validation error about mixed access") def step_check_mixed_error_count(context: Context, count: int) -> None: assert len(context.access_errors) == count, ( f"Expected {count} errors, got {len(context.access_errors)}" ) @then("there should be {count:d} validation errors") def step_check_error_count(context: Context, count: int) -> None: assert len(context.access_errors) == count, ( f"Expected {count} errors, got {len(context.access_errors)}" ) @then("the metadata should have {count:d} cross-project dependency") def step_check_dep_count(context: Context, count: int) -> None: mp = context.plan.multi_project_metadata assert mp is not None assert len(mp.cross_project_dependencies) == count @then('the dependency should be from "{src}" to "{tgt}"') def step_check_dep_endpoints(context: Context, src: str, tgt: str) -> None: mp = context.plan.multi_project_metadata assert mp is not None dep = mp.cross_project_dependencies[0] assert dep.source_project == src assert dep.target_project == tgt @then("cross-project validation should report an error about unknown project") def step_cross_project_unknown(context: Context) -> None: errors = context.service.validate_cross_project(context.plan) assert any("unknown" in e.lower() for e in errors), ( f"Expected unknown project error, got: {errors}" ) @then('scope "{proj}" should have a changeset summary') def step_scope_has_changeset(context: Context, proj: str) -> None: scope = context.plan.get_project_scope(proj) assert scope is not None assert scope.changeset_summary is not None @then("the changeset should show {count:d} files changed") def step_changeset_files(context: Context, count: int) -> None: # Find the scope with a changeset mp = context.plan.multi_project_metadata assert mp is not None for scope in mp.project_scopes: if scope.changeset_summary is not None: assert scope.changeset_summary.files_changed == count return raise AssertionError("No changeset summary found") @then('recording a changeset for "{proj}" should raise KeyError') def step_record_changeset_error(context: Context, proj: str) -> None: summary = ChangeSetSummary( project_name=proj, files_changed=1, total_lines_changed=1, ) try: context.service.record_changeset(context.plan, proj, summary) raise AssertionError("Expected KeyError") except KeyError: pass @then("cross-project validation should report sandbox isolation violation") def step_sandbox_violation(context: Context) -> None: errors = context.service.validate_cross_project(context.plan) assert any("sandbox isolation" in e.lower() for e in errors), ( f"Expected sandbox isolation error, got: {errors}" ) @then("cross-project validation should pass") def step_cross_project_valid(context: Context) -> None: errors = context.service.validate_cross_project(context.plan) assert len(errors) == 0, f"Expected no errors, got: {errors}" @then('the plan cli dict should contain a "{key}" key') def step_cli_dict_key(context: Context, key: str) -> None: cli_dict = context.plan.as_cli_dict() assert key in cli_dict, f"Key '{key}' not in cli_dict: {list(cli_dict.keys())}" @then("the multi_project section should have {count:d} project scopes") def step_cli_dict_scope_count(context: Context, count: int) -> None: cli_dict = context.plan.as_cli_dict() mp = cli_dict["multi_project"] assert len(mp["project_scopes"]) == count