diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e7db9db..daaf90225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ structured error types for configuration, provider, and file I/O failures, error code mapping, bare-except prohibition, and secret redaction in error details. (#320) - Added 32 BDD scenarios to boost unit test coverage from 97.0% to 97.2%. +- Added Behave BDD scenarios for six under-tested modules (container, correction + service, plan lifecycle service, plan CLI, skill CLI, database models) to exercise + uncovered lines, exception-handling paths, and partial branches. (#446) - Fixed failing Robot Framework integration tests related to security secrets handling. - Fixed style check violations across the codebase. - Fixed failing unit tests. diff --git a/benchmarks/cli_extension_tests_bench.py b/benchmarks/cli_extension_tests_bench.py index bb3c0db5f..7fefaad16 100644 --- a/benchmarks/cli_extension_tests_bench.py +++ b/benchmarks/cli_extension_tests_bench.py @@ -9,6 +9,7 @@ Benchmarks the extended test scenarios added in #326: from __future__ import annotations +import contextlib import importlib import sys from datetime import datetime @@ -254,10 +255,8 @@ class ActorValidationErrorSuite: def time_invalid_actor_rejection(self) -> None: """Benchmark rejecting an invalid actor name.""" - try: + with contextlib.suppress(ValidationError): validate_namespaced_actor("bad-format", "--strategy-actor") - except ValidationError: - pass def time_plan_use_invalid_actor_cli(self) -> None: """Benchmark CLI rejection of invalid actor flag.""" diff --git a/benchmarks/m2_actor_tool_smoke_bench.py b/benchmarks/m2_actor_tool_smoke_bench.py index f2f2f266e..0c4200c46 100644 --- a/benchmarks/m2_actor_tool_smoke_bench.py +++ b/benchmarks/m2_actor_tool_smoke_bench.py @@ -11,7 +11,6 @@ from __future__ import annotations import importlib import sys -import tempfile from pathlib import Path from typing import Any @@ -32,6 +31,8 @@ import cleveragents # noqa: E402 importlib.reload(cleveragents) +from mcp_stub_server import McpStubServer # noqa: E402 + from cleveragents.actor.loader import ActorLoader # noqa: E402 from cleveragents.actor.schema import ActorConfigSchema # noqa: E402 from cleveragents.domain.models.core.skill import Skill, SkillInlineTool # noqa: E402 @@ -49,7 +50,6 @@ from cleveragents.tool.lifecycle import ( # noqa: E402 ToolResult, ToolRuntime, ) -from mcp_stub_server import McpStubServer # noqa: E402 _FIXTURES_DIR = Path(__file__).resolve().parents[1] / "features" / "fixtures" / "m2" diff --git a/features/container_coverage_r2.feature b/features/container_coverage_r2.feature new file mode 100644 index 000000000..5be4c77f9 --- /dev/null +++ b/features/container_coverage_r2.feature @@ -0,0 +1,91 @@ +Feature: Container module coverage round 2 + As a developer maintaining container.py + I want every uncovered line and branch exercised + So that container.py achieves full coverage + + # ------------------------------------------------------------------- + # get_ai_provider – non-mock paths (lines 51 False, 66-67, 69) + # ------------------------------------------------------------------- + + Scenario: AI provider returns None when mock disabled and no providers configured + Given r2cont- the mock AI environment flag is disabled + And r2cont- a provider registry with no configured providers + When r2cont- I call get_ai_provider with the mocked registry + Then r2cont- the ai provider result should be None + + Scenario: AI provider returns real provider when mock disabled and providers exist + Given r2cont- the mock AI environment flag is disabled + And r2cont- a provider registry with configured providers + When r2cont- I call get_ai_provider with the mocked registry + Then r2cont- the ai provider result should not be None + + # ------------------------------------------------------------------- + # get_ai_provider – features path already on sys.path (line 57 partial) + # ------------------------------------------------------------------- + + Scenario: AI provider with features path already on sys.path does not duplicate it + Given r2cont- the mock AI environment flag is enabled + And r2cont- the features path is already on sys.path + When r2cont- I call get_ai_provider without registry args + Then r2cont- the features path should appear exactly once on sys.path + + # ------------------------------------------------------------------- + # _build_project_resource_link_repo (lines 125-130) + # ------------------------------------------------------------------- + + Scenario: Build project resource link repo creates valid repository + When r2cont- I build a project resource link repo with an in-memory database + Then r2cont- the result should be a ProjectResourceLinkRepository instance + + # ------------------------------------------------------------------- + # get_database_url – env var branches (lines 82, 87) + # ------------------------------------------------------------------- + + Scenario: get_database_url returns fallback when no env vars are set + Given r2cont- no database URL environment variables are set + When r2cont- I call get_database_url + Then r2cont- the database URL should start with "sqlite:///" + + Scenario: get_database_url returns CLEVERAGENTS_DATABASE_URL when set + Given r2cont- the env var "CLEVERAGENTS_DATABASE_URL" is set to "sqlite:///explicit.db" + When r2cont- I call get_database_url + Then r2cont- the database URL should be "sqlite:///explicit.db" + + Scenario: get_database_url returns CLEVERAGENTS_TEST_DATABASE_URL when primary is unset + Given r2cont- the env var "CLEVERAGENTS_DATABASE_URL" is cleared + And r2cont- the env var "CLEVERAGENTS_TEST_DATABASE_URL" is set to "sqlite:///test.db" + When r2cont- I call get_database_url + Then r2cont- the database URL should be "sqlite:///test.db" + + Scenario: get_database_url skips empty env var values + Given r2cont- the env var "CLEVERAGENTS_DATABASE_URL" is set to empty + And r2cont- the env var "CLEVERAGENTS_TEST_DATABASE_URL" is set to empty + When r2cont- I call get_database_url + Then r2cont- the database URL should start with "sqlite:///" + + # ------------------------------------------------------------------- + # get_container – cached instance (line 256 False branch) + # ------------------------------------------------------------------- + + Scenario: get_container returns the cached instance on subsequent calls + Given r2cont- the global container has been reset + When r2cont- I call get_container twice + Then r2cont- both calls should return the same container instance + + # ------------------------------------------------------------------- + # override_providers – non-existent attribute (line 285 False) + # ------------------------------------------------------------------- + + Scenario: override_providers ignores non-existent provider names + Given r2cont- the global container has been reset + When r2cont- I override a provider with a non-existent name + Then r2cont- no error should have been raised + + # ------------------------------------------------------------------- + # override_providers – non-database_url provider (line 288 else) + # ------------------------------------------------------------------- + + Scenario: override_providers handles non-database_url providers via Object + Given r2cont- the global container has been reset + When r2cont- I override the settings provider with a mock value + Then r2cont- the settings provider should return the mock value diff --git a/features/correction_service_coverage_r2.feature b/features/correction_service_coverage_r2.feature new file mode 100644 index 000000000..dc8cd41b1 --- /dev/null +++ b/features/correction_service_coverage_r2.feature @@ -0,0 +1,89 @@ +Feature: CorrectionService exception-handling coverage (round 2) + As a developer maintaining CorrectionService + I want the exception-handling paths in execute_revert and execute_append fully tested + So that lines 254-262 and 320-328 of correction_service.py achieve coverage + + Background: + Given r2corr-a fresh correction service + + # ------------------------------------------------------------------- + # execute_revert – exception path (lines 254-262) + # ------------------------------------------------------------------- + + Scenario: execute_revert catches internal error and returns FAILED result + Given r2corr-a stored revert correction for plan "P1" targeting "D1" + And r2corr-analyze_impact is patched to raise RuntimeError "boom in analyze" + When r2corr-I execute revert on the stored correction + Then r2corr-the revert result status should be "failed" + And r2corr-the revert result error_message should contain "boom in analyze" + And r2corr-the stored correction status should be "failed" + And r2corr-the first attempt should have success false + And r2corr-the first attempt details should contain error key + And r2corr-the first attempt details error should contain "boom in analyze" + And r2corr-the first attempt completed_at should be set + And r2corr-the result should be stored in the service results dict + + Scenario: execute_revert exception path preserves attempt count + Given r2corr-a stored revert correction for plan "P1" targeting "D2" + And r2corr-analyze_impact is patched to raise ValueError "bad value" + When r2corr-I execute revert on the stored correction + Then r2corr-the attempts for the stored correction should have 1 entry + And r2corr-the first attempt should have success false + + Scenario: execute_revert exception with TypeError message + Given r2corr-a stored revert correction for plan "P1" targeting "D3" + And r2corr-analyze_impact is patched to raise TypeError "wrong type" + When r2corr-I execute revert on the stored correction + Then r2corr-the revert result status should be "failed" + And r2corr-the revert result error_message should contain "wrong type" + And r2corr-the stored correction status should be "failed" + + # ------------------------------------------------------------------- + # execute_append – exception path (lines 320-328) + # ------------------------------------------------------------------- + + Scenario: execute_append catches internal error and returns FAILED result + Given r2corr-a stored append correction for plan "P1" targeting "A1" + And r2corr-ULID is patched to raise RuntimeError "ulid broken" + When r2corr-I execute append on the stored correction + Then r2corr-the append result status should be "failed" + And r2corr-the append result error_message should contain "ulid broken" + And r2corr-the stored correction status should be "failed" + And r2corr-the first attempt should have success false + And r2corr-the first attempt details should contain error key + And r2corr-the first attempt details error should contain "ulid broken" + And r2corr-the first attempt completed_at should be set + And r2corr-the append result should be stored in the service results dict + + Scenario: execute_append exception path preserves attempt count + Given r2corr-a stored append correction for plan "P1" targeting "A2" + And r2corr-ULID is patched to raise ValueError "ulid value error" + When r2corr-I execute append on the stored correction + Then r2corr-the attempts for the stored correction should have 1 entry + And r2corr-the first attempt should have success false + + Scenario: execute_append exception with OSError message + Given r2corr-a stored append correction for plan "P1" targeting "A3" + And r2corr-ULID is patched to raise OSError "system failure" + When r2corr-I execute append on the stored correction + Then r2corr-the append result status should be "failed" + And r2corr-the append result error_message should contain "system failure" + And r2corr-the stored correction status should be "failed" + + # ------------------------------------------------------------------- + # execute_correction dispatch into exception paths + # ------------------------------------------------------------------- + + Scenario: dispatch to revert hits exception path when analyze_impact fails + Given r2corr-a stored revert correction for plan "P1" targeting "DR1" + And r2corr-analyze_impact is patched to raise RuntimeError "dispatch revert boom" + When r2corr-I dispatch execute correction on the stored correction + Then r2corr-the dispatch result status should be "failed" + And r2corr-the dispatch result error_message should contain "dispatch revert boom" + + Scenario: dispatch to append hits exception path when ULID fails + Given r2corr-a stored append correction for plan "P1" targeting "DA1" + And r2corr-ULID is patched to raise RuntimeError "dispatch append boom" + When r2corr-I dispatch execute correction on the stored correction + Then r2corr-the dispatch result status should be "failed" + And r2corr-the dispatch result error_message should contain "dispatch append boom" diff --git a/features/models_lifecycle_coverage_r2.feature b/features/models_lifecycle_coverage_r2.feature new file mode 100644 index 000000000..0542f9627 --- /dev/null +++ b/features/models_lifecycle_coverage_r2.feature @@ -0,0 +1,87 @@ +Feature: LifecycleAction and LifecyclePlan model branch coverage (round 2) + As a developer maintaining the ORM layer + I want every branch in the domain-conversion helpers tested + So that models.py achieves near-100 % branch coverage + + # =================================================================== + # LifecycleActionModel.to_domain() — lines 307-388 + # Targets: None default_value_json, None min_value, None max_value, + # None tags_json, None inputs_schema_json, empty rels + # =================================================================== + + Scenario: r2mod- ActionModel.to_domain args with all None optional fields + Given a r2mod-ActionModel with an argument having None defaults + When I r2mod-convert the ActionModel to domain + Then the r2mod-action first argument default_value should be None + And the r2mod-action first argument min_value should be None + And the r2mod-action first argument max_value should be None + + Scenario: r2mod- ActionModel.to_domain with None tags_json and no inputs_schema + Given a r2mod-ActionModel with None tags_json and None inputs_schema_json + When I r2mod-convert the ActionModel to domain + Then the r2mod-action tags should be empty + And the r2mod-action inputs_schema should be None + + Scenario: r2mod- ActionModel.to_domain with None arguments_rel and None invariants_rel + Given a r2mod-ActionModel with None rels + When I r2mod-convert the ActionModel to domain + Then the r2mod-action arguments should be empty + And the r2mod-action invariants should be empty + + # =================================================================== + # LifecyclePlanModel.to_domain() — lines 700-840 + # Targets: None automation_profile, None validation_summary_json, + # None sandbox_refs_json, None tags_json, empty action_name, + # None error_details_json, None argument value_json, + # None project_links_rel/invariants_rel/arguments_rel + # =================================================================== + + Scenario: r2mod- PlanModel.to_domain with all optional fields None + Given a r2mod-PlanModel with all optional fields set to None + When I r2mod-convert the PlanModel to domain + Then the r2mod-plan automation_profile should be None + And the r2mod-plan validation_summary should be None + And the r2mod-plan sandbox_refs should be empty + And the r2mod-plan tags should be empty + And the r2mod-plan error_details should be None + + Scenario: r2mod- PlanModel.to_domain with empty action_name falls back to empty string + Given a r2mod-PlanModel with None action_name + When I r2mod-attempt to convert the PlanModel to domain + Then a r2mod-ValidationError should have been raised with "action_name" + + Scenario: r2mod- PlanModel.to_domain with argument having None value_json + Given a r2mod-PlanModel with an argument having None value_json + When I r2mod-convert the PlanModel to domain + Then the r2mod-plan argument "test_arg" should be None + + # =================================================================== + # LifecycleActionModel.from_domain() — lines 391-468 + # Targets: None inputs_schema, state with value attr, arg with None + # default_value + # =================================================================== + + Scenario: r2mod- ActionModel.from_domain with None inputs_schema + When I r2mod-create ActionModel from domain with None inputs_schema + Then the r2mod-created ActionModel inputs_schema_json should be None + + Scenario: r2mod- ActionModel.from_domain with argument having None default + When I r2mod-create ActionModel from domain with argument having None default + Then the r2mod-created ActionModel first argument default_value_json should be None + + # =================================================================== + # LifecyclePlanModel helpers — _parse_iso / _to_iso + # Targets: None branches + # =================================================================== + + Scenario: r2mod- PlanModel._parse_iso with None returns None + Then r2mod-_parse_iso with None should return None + + Scenario: r2mod- PlanModel._to_iso with None returns None + Then r2mod-_to_iso with None should return None + + Scenario: r2mod- PlanModel._parse_iso with valid ISO returns datetime + Then r2mod-_parse_iso with "2024-01-15T10:30:00" should return a datetime + + Scenario: r2mod- PlanModel._to_iso with valid datetime returns string + Then r2mod-_to_iso with a datetime should return an ISO string diff --git a/features/models_record_coverage_r2.feature b/features/models_record_coverage_r2.feature new file mode 100644 index 000000000..75b87d5b4 --- /dev/null +++ b/features/models_record_coverage_r2.feature @@ -0,0 +1,91 @@ +Feature: Session, Tool, and Project model branch coverage (round 2) + As a developer maintaining the ORM layer + I want every branch in the domain-conversion helpers tested + So that models.py achieves near-100 % branch coverage + + # =================================================================== + # SessionModel.to_domain() — lines 1858-1896 + # Targets: None branches on linked_plan_ids_json, token_usage_json, + # metadata_json, messages_rel + # =================================================================== + + Scenario: r2mod- SessionModel.to_domain with all None JSON fields + Given a r2mod-SessionModel with all JSON fields as None + When I r2mod-convert the SessionModel to domain + Then the r2mod-session linked_plan_ids should be empty + And the r2mod-session token_usage input_tokens should be 0 + And the r2mod-session metadata should be empty + And the r2mod-session messages should be empty + + # =================================================================== + # SessionMessageModel.to_domain() — line 1983 + # Targets: None metadata_json branch + # =================================================================== + + Scenario: r2mod- SessionMessageModel.to_domain with None metadata_json + Given a r2mod-SessionMessageModel with None metadata_json + When I r2mod-convert the SessionMessageModel to domain + Then the r2mod-message metadata should be empty + + # =================================================================== + # ToolModel.to_domain() / from_domain() — lines 1612-1718 + # Targets: None resource_bindings_rel branch, dict-vs-object input, + # no-slash name branch + # =================================================================== + + Scenario: r2mod- ToolModel.to_domain with None resource_bindings_rel + Given a r2mod-ToolModel with no resource bindings + When I r2mod-convert the ToolModel to domain + Then the r2mod-tool resource_bindings should be empty + + Scenario: r2mod- ToolModel.from_domain with dict input and no slash in name + When I r2mod-create ToolModel from dict with name "nonamespace" + Then the r2mod-created ToolModel namespace should be empty string + And the r2mod-created ToolModel short_name should be "nonamespace" + + Scenario: r2mod- ToolModel.from_domain with dict input and slash in name + When I r2mod-create ToolModel from dict with name "ns/tool-name" + Then the r2mod-created ToolModel namespace should be "ns" + And the r2mod-created ToolModel short_name should be "tool-name" + + Scenario: r2mod- ToolModel.from_domain with object input + When I r2mod-create ToolModel from object with name "ns/objtool" + Then the r2mod-created ToolModel namespace should be "ns" + + Scenario: r2mod- ToolModel.from_domain with resource_bindings as None + When I r2mod-create ToolModel from dict with no resource_bindings + Then the r2mod-created ToolModel resource_bindings_rel should be empty + + # =================================================================== + # NamespacedProjectModel.to_domain() — lines 1109-1153 + # Targets: no context_policy_json, no resource_links, no-slash name + # =================================================================== + + Scenario: r2mod- ProjectModel.to_domain with no context policy and no-slash name + Given a r2mod-ProjectModel with no context_policy_json and name "simplename" + When I r2mod-convert the ProjectModel to domain + Then the r2mod-project name should be "simplename" + And the r2mod-project linked_resources should be empty + + Scenario: r2mod- ProjectModel.to_domain with None resource_links + Given a r2mod-ProjectModel with None resource_links + When I r2mod-convert the ProjectModel to domain + Then the r2mod-project linked_resources should be empty + + # =================================================================== + # NamespacedProjectModel.from_domain() — lines 1156-1187 + # Targets: None context_config branch + # =================================================================== + + Scenario: r2mod- ProjectModel.from_domain with None context_config + When I r2mod-create ProjectModel from domain with None context_config + Then the r2mod-created ProjectModel context_policy_json should be None + + # =================================================================== + # SessionMessageModel.from_domain() — lines 1996-2014 + # Targets: role with value attr vs plain string + # =================================================================== + + Scenario: r2mod- SessionMessageModel.from_domain with string role + When I r2mod-create SessionMessageModel from domain with string role + Then the r2mod-created SessionMessageModel role should be "user" diff --git a/features/models_skill_coverage_r2.feature b/features/models_skill_coverage_r2.feature new file mode 100644 index 000000000..9d8f443a7 --- /dev/null +++ b/features/models_skill_coverage_r2.feature @@ -0,0 +1,108 @@ +Feature: SkillModel to_domain/from_domain branch coverage (round 2) + As a developer maintaining the ORM layer + I want every branch in the domain-conversion helpers tested + So that models.py achieves near-100 % branch coverage + + # =================================================================== + # SkillModel.to_domain() — lines 2185-2224 + # Targets: uncovered lines 2188-2189, branch partials on item_type + # checks, item_config_str checks, metadata_str check + # =================================================================== + + Scenario: r2mod- SkillModel.to_domain with None items_rel returns empty lists + Given a r2mod-SkillModel with no items and no metadata + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill tool_refs should be empty + And the r2mod-skill includes should be empty + And the r2mod-skill anonymous_tools should be empty + And the r2mod-skill mcp_servers should be empty + And the r2mod-skill agent_skills should be empty + And the r2mod-skill overrides should be empty + + Scenario: r2mod- SkillModel.to_domain with tool_ref items + Given a r2mod-SkillModel with a tool_ref item named "local/my-tool" + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill tool_refs should contain "local/my-tool" + + Scenario: r2mod- SkillModel.to_domain with include item with config overrides + Given a r2mod-SkillModel with an include item "local/inc" with config overrides + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill includes should have 1 entry + And the r2mod-skill first include name should be "local/inc" + And the r2mod-skill first include overrides should not be None + + Scenario: r2mod- SkillModel.to_domain with include item without config + Given a r2mod-SkillModel with an include item "local/inc" without config + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill includes should have 1 entry + And the r2mod-skill first include overrides should be None + + Scenario: r2mod- SkillModel.to_domain with inline_tool item without config (skipped) + Given a r2mod-SkillModel with an inline_tool item without config + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill anonymous_tools should be empty + + Scenario: r2mod- SkillModel.to_domain with mcp_source item without config (skipped) + Given a r2mod-SkillModel with an mcp_source item without config + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill mcp_servers should be empty + + Scenario: r2mod- SkillModel.to_domain with agent_source item + Given a r2mod-SkillModel with an agent_source item "path/to/agent" + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill agent_skills should have 1 entry + And the r2mod-skill first agent_skill path should be "path/to/agent" + + Scenario: r2mod- SkillModel.to_domain with metadata_json containing overrides + Given a r2mod-SkillModel with metadata_json containing overrides + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill overrides should not be empty + + Scenario: r2mod- SkillModel.to_domain with inline_tool and mcp_source with config + Given a r2mod-SkillModel with inline_tool and mcp_source items with config + When I r2mod-convert the SkillModel to domain + Then the r2mod-skill anonymous_tools should have 1 entry + And the r2mod-skill mcp_servers should have 1 entry + + # =================================================================== + # SkillModel.from_domain() — lines 2227-2366 + # Targets: ValueError branch, overrides branch, include-as-string, + # anonymous_tools-as-dict, mcp_servers-as-dict, + # agent_skills-as-string + # =================================================================== + + Scenario: r2mod- SkillModel.from_domain raises ValueError for invalid name + When I r2mod-attempt from_domain on SkillModel with name "badname" + Then a r2mod-ValueError should have been raised + + Scenario: r2mod- SkillModel.from_domain with no overrides + When I r2mod-create SkillModel from domain with name "ns/sk" and no overrides + Then the r2mod-created SkillModel metadata_json should be None + + Scenario: r2mod- SkillModel.from_domain with overrides + When I r2mod-create SkillModel from domain with name "ns/sk" and overrides + Then the r2mod-created SkillModel metadata_json should contain "overrides" + + Scenario: r2mod- SkillModel.from_domain with include as string + When I r2mod-create SkillModel from domain with includes as strings + Then the r2mod-created SkillModel should have include items + + Scenario: r2mod- SkillModel.from_domain with anonymous_tools as dict + When I r2mod-create SkillModel from domain with anonymous_tools as dicts + Then the r2mod-created SkillModel should have inline_tool items + + Scenario: r2mod- SkillModel.from_domain with mcp_servers as dict + When I r2mod-create SkillModel from domain with mcp_servers as dicts + Then the r2mod-created SkillModel should have mcp_source items + + Scenario: r2mod- SkillModel.from_domain with agent_skills as string + When I r2mod-create SkillModel from domain with agent_skills as strings + Then the r2mod-created SkillModel should have agent_source items + + Scenario: r2mod- SkillModel.from_domain with anonymous_tools fallback branch + When I r2mod-create SkillModel from domain with anonymous_tools as plain objects + Then the r2mod-created SkillModel should have inline_tool items + + Scenario: r2mod- SkillModel.from_domain with mcp_servers fallback branch + When I r2mod-create SkillModel from domain with mcp_servers as plain objects + Then the r2mod-created SkillModel should have mcp_source items diff --git a/features/plan_cli_commands_r2.feature b/features/plan_cli_commands_r2.feature new file mode 100644 index 000000000..a6900d540 --- /dev/null +++ b/features/plan_cli_commands_r2.feature @@ -0,0 +1,228 @@ +Feature: Plan CLI commands branch coverage (round 2) + As a developer + I want to exercise uncovered branches in CLI command functions + So that branch coverage for use_action, execute, apply, list, revert, status, correct, and diff rises + + # =================================================================== + # use_action argument parsing – int / float / bool / string + # =================================================================== + + Scenario: r2plan use_action parses integer argument + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and arg "count=42" + Then r2plan-the parsed arguments should have "count" as int 42 + + Scenario: r2plan use_action parses float argument + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and arg "ratio=3.14" + Then r2plan-the parsed arguments should have "ratio" as float 3.14 + + Scenario: r2plan use_action parses bool true argument + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and arg "verbose=true" + Then r2plan-the parsed arguments should have "verbose" as bool true + + Scenario: r2plan use_action parses bool false argument + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and arg "verbose=false" + Then r2plan-the parsed arguments should have "verbose" as bool false + + Scenario: r2plan use_action parses string argument + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and arg "name=hello" + Then r2plan-the parsed arguments should have "name" as string "hello" + + Scenario: r2plan use_action rejects argument missing equals sign + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and arg "badarg" + Then r2plan-the command should abort + And r2plan-the output should contain "Invalid argument format" + + # =================================================================== + # use_action – invalid automation profile + # =================================================================== + + Scenario: r2plan use_action rejects invalid automation profile + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and automation profile "bogus" + Then r2plan-the command should abort + And r2plan-the output should contain "Invalid automation profile" + + # =================================================================== + # use_action – actor overrides with invalid names + # =================================================================== + + Scenario: r2plan use_action rejects invalid strategy actor + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and strategy actor "INVALID" + Then r2plan-the command should abort + And r2plan-the output should contain "Validation Error" + + Scenario: r2plan use_action rejects invalid execution actor + Given r2plan-a mocked lifecycle service + When r2plan-I invoke use with action "local/test" and execution actor "bad-name" + Then r2plan-the command should abort + And r2plan-the output should contain "Validation Error" + + # =================================================================== + # execute_plan – auto-resolve: no plans / multiple plans + # =================================================================== + + Scenario: r2plan execute_plan aborts when no plans ready + Given r2plan-a mocked lifecycle service + And r2plan-the service lists no complete strategize plans + When r2plan-I invoke execute without plan_id + Then r2plan-the command should abort + And r2plan-the output should contain "No plans ready for execution" + + Scenario: r2plan execute_plan aborts when multiple plans ready + Given r2plan-a mocked lifecycle service + And r2plan-the service lists multiple complete strategize plans + When r2plan-I invoke execute without plan_id + Then r2plan-the command should abort + And r2plan-the output should contain "Multiple plans ready" + + # =================================================================== + # lifecycle_apply_plan – auto-resolve: no plans / multiple plans + # =================================================================== + + Scenario: r2plan lifecycle_apply aborts when no plans ready + Given r2plan-a mocked lifecycle service + And r2plan-the service lists no complete execute plans + When r2plan-I invoke lifecycle-apply without plan_id + Then r2plan-the command should abort + And r2plan-the output should contain "No plans ready for apply" + + Scenario: r2plan lifecycle_apply aborts when multiple plans ready + Given r2plan-a mocked lifecycle service + And r2plan-the service lists multiple complete execute plans + When r2plan-I invoke lifecycle-apply without plan_id + Then r2plan-the command should abort + And r2plan-the output should contain "Multiple plans ready" + + # =================================================================== + # lifecycle_list_plans – invalid phase / invalid state + # =================================================================== + + Scenario: r2plan lifecycle_list rejects invalid phase + Given r2plan-a mocked lifecycle service + When r2plan-I invoke lifecycle-list with phase "nonexistent" + Then r2plan-the command should abort + And r2plan-the output should contain "Invalid phase" + + Scenario: r2plan lifecycle_list rejects invalid state + Given r2plan-a mocked lifecycle service + When r2plan-I invoke lifecycle-list with state "nonexistent" + Then r2plan-the command should abort + And r2plan-the output should contain "Invalid state" + + # =================================================================== + # lifecycle_list_plans – empty result + # =================================================================== + + Scenario: r2plan lifecycle_list shows no-plans message when empty + Given r2plan-a mocked lifecycle service + And r2plan-the service lists no plans + When r2plan-I invoke lifecycle-list + Then r2plan-the command should succeed + And r2plan-the output should contain "No plans found" + + # =================================================================== + # revert_plan – invalid --to-phase + # =================================================================== + + Scenario: r2plan revert_plan rejects invalid to-phase + Given r2plan-a mocked lifecycle service + When r2plan-I invoke revert with plan "PLAN123" and invalid phase "badphase" + Then r2plan-the command should abort + And r2plan-the output should contain "Invalid phase" + + Scenario: r2plan revert_plan non-rich format + Given r2plan-a mocked lifecycle service + And r2plan-the service can revert a plan + When r2plan-I invoke revert with plan "PLAN123" and format "json" + Then r2plan-the command should succeed + And r2plan-the output should contain "plan_id" + + # =================================================================== + # plan_status – without plan_id: no plans, plans in non-rich, rich + # =================================================================== + + Scenario: r2plan status shows no-plans message + Given r2plan-a mocked lifecycle service + And r2plan-the service lists no plans + When r2plan-I invoke status without plan_id + Then r2plan-the command should succeed + And r2plan-the output should contain "No v3 lifecycle plans found" + + Scenario: r2plan status without plan_id lists plans in non-rich + Given r2plan-a mocked lifecycle service + And r2plan-the service lists some plans + When r2plan-I invoke status without plan_id and format "json" + Then r2plan-the command should succeed + And r2plan-the output should contain "plan_id" + + Scenario: r2plan status with plan_id in non-rich format + Given r2plan-a mocked lifecycle service + And r2plan-the service can get a plan by id + When r2plan-I invoke status with plan_id and format "json" + Then r2plan-the command should succeed + And r2plan-the output should contain "plan_id" + + # =================================================================== + # correct_decision – invalid mode / empty guidance + # =================================================================== + + Scenario: r2plan correct rejects invalid mode + Given r2plan-a mocked lifecycle service + When r2plan-I invoke correct with mode "badmode" + Then r2plan-the command should abort + And r2plan-the output should contain "Invalid mode" + + Scenario: r2plan correct rejects empty guidance + Given r2plan-a mocked lifecycle service + When r2plan-I invoke correct with empty guidance + Then r2plan-the command should abort + And r2plan-the output should contain "guidance" + + # =================================================================== + # plan_diff – correction flag present + # =================================================================== + + Scenario: r2plan diff shows correction stub when correction flag set + Given r2plan-a mocked lifecycle service + When r2plan-I invoke diff with plan "P1" and correction "CORR1" + Then r2plan-the command should succeed + And r2plan-the output should contain "Correction Attempt" + And r2plan-the output should contain "CORR1" + + # =================================================================== + # execute_plan – auto-resolve with exactly one plan + # =================================================================== + + Scenario: r2plan execute_plan auto-resolves single plan + Given r2plan-a mocked lifecycle service + And r2plan-the service lists exactly one complete strategize plan + When r2plan-I invoke execute without plan_id + Then r2plan-the command should succeed + + # =================================================================== + # lifecycle_apply – auto-resolve with exactly one plan + # =================================================================== + + Scenario: r2plan lifecycle_apply auto-resolves single plan + Given r2plan-a mocked lifecycle service + And r2plan-the service lists exactly one complete execute plan + When r2plan-I invoke lifecycle-apply without plan_id + Then r2plan-the command should succeed + + # =================================================================== + # lifecycle_list_plans – projects > 2 truncation + # =================================================================== + + Scenario: r2plan lifecycle_list truncates projects over 2 + Given r2plan-a mocked lifecycle service + And r2plan-the service lists a plan with 4 project links + When r2plan-I invoke lifecycle-list + Then r2plan-the command should succeed + And r2plan-the output should contain "+2 more" diff --git a/features/plan_cli_legacy_r2.feature b/features/plan_cli_legacy_r2.feature new file mode 100644 index 000000000..d82816978 --- /dev/null +++ b/features/plan_cli_legacy_r2.feature @@ -0,0 +1,80 @@ +Feature: Plan CLI legacy wrappers and resolve branch coverage (round 2) + As a developer + I want to exercise uncovered branches in legacy wrappers and _resolve_active_plan_id + So that branch coverage for legacy and resolve logic rises above the current level + + # =================================================================== + # Legacy programmatic wrappers – no project branch + # =================================================================== + + Scenario: r2plan tell_command raises when no project + When r2plan-I call tell_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan build_command raises when no project + When r2plan-I call build_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan apply_command raises when no project + When r2plan-I call apply_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan new_command raises when no project + When r2plan-I call new_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan current_command raises when no project + When r2plan-I call current_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan list_command raises when no project + When r2plan-I call list_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan cd_command raises when no project + When r2plan-I call cd_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + Scenario: r2plan continue_command raises when no project + When r2plan-I call continue_command with no project + Then r2plan-a CleverAgentsError should be raised with message "No project found" + + # =================================================================== + # continue_command – with prompt vs no-prompt + no current plan + # =================================================================== + + Scenario: r2plan continue_command with prompt calls continue_plan + When r2plan-I call continue_command with prompt "add tests" + Then r2plan-the continue_plan service method should be called + + Scenario: r2plan continue_command no prompt and no current plan raises + When r2plan-I call continue_command with no prompt and no current plan + Then r2plan-a CleverAgentsError should be raised with message "No current plan" + + # =================================================================== + # build_command – returns empty list when changes is None + # =================================================================== + + Scenario: r2plan build_command returns empty list when None + When r2plan-I call build_command with build returning None + Then r2plan-the build result should be an empty list + + # =================================================================== + # list_command – returns empty list when plans is None + # =================================================================== + + Scenario: r2plan list_command returns empty list when None + When r2plan-I call list_command with list returning None + Then r2plan-the list result should be an empty list + + # =================================================================== + # _resolve_active_plan_id – no active plans / service error + # =================================================================== + + Scenario: r2plan resolve_active_plan_id aborts when no active plans + When r2plan-I call _resolve_active_plan_id with no active plans + Then r2plan-a typer Abort should be raised + + Scenario: r2plan resolve_active_plan_id aborts on service error + When r2plan-I call _resolve_active_plan_id with service error + Then r2plan-a typer Abort should be raised diff --git a/features/plan_cli_spec_print_r2.feature b/features/plan_cli_spec_print_r2.feature new file mode 100644 index 000000000..73db51bf6 --- /dev/null +++ b/features/plan_cli_spec_print_r2.feature @@ -0,0 +1,190 @@ +Feature: Plan CLI spec dict and print branch coverage (round 2) + As a developer + I want to exercise uncovered branches in _plan_spec_dict and _print_lifecycle_plan + So that branch coverage for spec-dict and print logic rises above the current level + + # =================================================================== + # _plan_spec_dict – project link alias / read_only branches + # =================================================================== + + Scenario: r2plan spec dict includes alias in project link + Given r2plan-a v3 Plan with a project link that has alias "backend" + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict project_links should include alias "backend" + + Scenario: r2plan spec dict includes read_only in project link + Given r2plan-a v3 Plan with a project link that is read_only + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict project_links should include read_only true + + Scenario: r2plan spec dict omits alias and read_only when unset + Given r2plan-a v3 Plan with a plain project link + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict project_links should not include alias + And r2plan-the spec dict project_links should not include read_only + + # =================================================================== + # _plan_spec_dict – automation_profile truthy / falsy + # =================================================================== + + Scenario: r2plan spec dict includes automation_profile when set + Given r2plan-a v3 Plan with automation_profile "review" + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict automation_profile should be "review" + + Scenario: r2plan spec dict has null automation_profile when unset + Given r2plan-a v3 Plan without automation_profile + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict automation_profile should be null + + # =================================================================== + # _plan_spec_dict – invariants, validation_summary/dod + # =================================================================== + + Scenario: r2plan spec dict includes invariants when present + Given r2plan-a v3 Plan with invariants + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should contain key "invariants" + And r2plan-the spec dict invariants count should be 2 + + Scenario: r2plan spec dict omits invariants when empty + Given r2plan-a v3 Plan without invariants + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should not contain key "invariants" + + Scenario: r2plan spec dict includes dod_evaluation when validated + Given r2plan-a v3 Plan with dod validation summary + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should contain key "dod_evaluation" + And r2plan-the spec dict dod_evaluation all_passed should be true + + Scenario: r2plan spec dict omits dod_evaluation when no validation + Given r2plan-a v3 Plan without validation_summary + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should not contain key "dod_evaluation" + + # =================================================================== + # _plan_spec_dict – last_completed_step / last_checkpoint_id + # =================================================================== + + Scenario: r2plan spec dict includes last_completed_step when >= 0 + Given r2plan-a v3 Plan with last_completed_step 3 + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should contain key "last_completed_step" + And r2plan-the spec dict last_completed_step should be 3 + + Scenario: r2plan spec dict omits last_completed_step when -1 + Given r2plan-a v3 Plan with last_completed_step default + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should not contain key "last_completed_step" + + Scenario: r2plan spec dict includes last_checkpoint_id when set + Given r2plan-a v3 Plan with last_checkpoint_id "01CHKPT001" + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should contain key "last_checkpoint_id" + + Scenario: r2plan spec dict omits last_checkpoint_id when None + Given r2plan-a v3 Plan without last_checkpoint_id + When r2plan-I call _plan_spec_dict + Then r2plan-the spec dict should not contain key "last_checkpoint_id" + + # =================================================================== + # _print_lifecycle_plan – definition_of_done branches + # =================================================================== + + Scenario: r2plan print shows definition_of_done when short + Given r2plan-a v3 Plan with definition_of_done "All tests pass" + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Definition of Done" + And r2plan-the printed output should contain "All tests pass" + + Scenario: r2plan print truncates long definition_of_done + Given r2plan-a v3 Plan with definition_of_done longer than 200 chars + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Definition of Done" + And r2plan-the printed output should contain "..." + + Scenario: r2plan print omits definition_of_done when None + Given r2plan-a v3 Plan without definition_of_done + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should not contain "Definition of Done" + + # =================================================================== + # _print_lifecycle_plan – validation_summary dod pass / fail + # =================================================================== + + Scenario: r2plan print shows DoD PASSED evaluation + Given r2plan-a v3 Plan with dod evaluated as passed + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "DoD Evaluation" + And r2plan-the printed output should contain "PASSED" + + Scenario: r2plan print shows DoD FAILED evaluation with failures + Given r2plan-a v3 Plan with dod evaluated as failed + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "DoD Evaluation" + And r2plan-the printed output should contain "FAILED" + And r2plan-the printed output should contain "failed" + + # =================================================================== + # _print_lifecycle_plan – arguments with/without order + # =================================================================== + + Scenario: r2plan print shows arguments using arguments_order + Given r2plan-a v3 Plan with arguments and arguments_order + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Arguments" + And r2plan-the printed output should contain "target_coverage = 80" + + Scenario: r2plan print shows arguments sorted when no order + Given r2plan-a v3 Plan with arguments but no arguments_order + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Arguments" + + # =================================================================== + # _print_lifecycle_plan – long description > 200 chars + # =================================================================== + + Scenario: r2plan print truncates long description + Given r2plan-a v3 Plan with description longer than 200 chars + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "..." + + # =================================================================== + # _print_lifecycle_plan – automation_profile + # =================================================================== + + Scenario: r2plan print shows automation profile + Given r2plan-a v3 Plan with automation_profile "review" + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Automation Profile" + And r2plan-the printed output should contain "review" + + # =================================================================== + # _print_lifecycle_plan – invariants display + # =================================================================== + + Scenario: r2plan print shows invariants + Given r2plan-a v3 Plan with invariants + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Invariants" + + # =================================================================== + # _print_lifecycle_plan – resume metadata + # =================================================================== + + Scenario: r2plan print shows resume metadata + Given r2plan-a v3 Plan with resume metadata + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "Last Completed Step" + And r2plan-the printed output should contain "Last Checkpoint" + + # =================================================================== + # _print_lifecycle_plan – project link alias and read_only + # =================================================================== + + Scenario: r2plan print shows project link with alias and read_only + Given r2plan-a v3 Plan with project link alias and read_only + When r2plan-I call _print_lifecycle_plan + Then r2plan-the printed output should contain "alias:" + And r2plan-the printed output should contain "local/ref-data" diff --git a/features/plan_lifecycle_error_r2.feature b/features/plan_lifecycle_error_r2.feature new file mode 100644 index 000000000..6d8efc997 --- /dev/null +++ b/features/plan_lifecycle_error_r2.feature @@ -0,0 +1,118 @@ +Feature: PlanLifecycleService error and persistence branch coverage (round 2) + As a developer maintaining PlanLifecycleService + I want every partial branch fully exercised + So that plan_lifecycle_service.py branch coverage reaches 100% + + # Targets the following partial branches: + # Line 100 – InvalidPhaseTransitionError.__init__ `if not message:` False branch + # Line 216 – _commit_plan `if self._persisted …` True branch (via mock UoW) + # Line 327 – create_action `if self._persisted …` True branch + # Line 364 – get_action `if self._persisted …` True branch (not-found fallback) + # Line 461 – archive_action `if self._persisted …` True branch + # Line 570 – use_action `if self._persisted …` True branch + # Line 607 – get_plan `if self._persisted …` True branch (not-found fallback) + + # ------------------------------------------------------------------- + # In-memory mode (no UoW) – validates False side of _persisted checks + # ------------------------------------------------------------------- + + Background: + Given r2plc-a fresh plan lifecycle service with mock UoW + + # ------------------------------------------------------------------- + # InvalidPhaseTransitionError with custom message (line 100 False) + # ------------------------------------------------------------------- + + Scenario: InvalidPhaseTransitionError raised with explicit custom message + When r2plc-I construct InvalidPhaseTransitionError with a custom message + Then r2plc-the error message should be the custom message + And r2plc-the from_phase should be STRATEGIZE + And r2plc-the to_phase should be APPLY + + Scenario: InvalidPhaseTransitionError raised with default message + When r2plc-I construct InvalidPhaseTransitionError without a message + Then r2plc-the error message should contain "Invalid phase transition" + And r2plc-the from_phase should be STRATEGIZE + And r2plc-the to_phase should be EXECUTE + + Scenario: revert_plan raises InvalidPhaseTransitionError with custom message + Given r2plc-a plan in STRATEGIZE phase + When r2plc-I attempt to revert the plan to APPLY phase + Then r2plc-an InvalidPhaseTransitionError should have been raised + And r2plc-the caught error message should contain "Cannot revert" + + # ------------------------------------------------------------------- + # _commit_plan in persisted mode (line 216 True) + # ------------------------------------------------------------------- + + Scenario: _commit_plan persists via UoW when persisted mode is active + Given r2plc-a plan in STRATEGIZE phase + When r2plc-I start strategize on the plan + Then r2plc-the mock UoW should have received a plan update call + + Scenario: fail_strategize commits plan via persisted _commit_plan + Given r2plc-a plan in STRATEGIZE PROCESSING state + When r2plc-I fail the strategize with error "something broke" + Then r2plc-the mock UoW should have received a plan update call + And r2plc-the plan processing state should be "errored" + + # ------------------------------------------------------------------- + # create_action persisted mode (line 327 True) + # ------------------------------------------------------------------- + + Scenario: create_action persists via UoW when persisted + When r2plc-I create an action "local/r2-persist-test" in persisted mode + Then r2plc-the mock UoW should have received an action create call + And r2plc-the action should also be in the in-memory cache + + # ------------------------------------------------------------------- + # use_action persisted mode (line 570 True) + # ------------------------------------------------------------------- + + Scenario: use_action persists the new plan via UoW + Given r2plc-an action "local/r2-use-persist" exists + When r2plc-I use the action to create a plan in persisted mode + Then r2plc-the mock UoW should have received a plan create call + And r2plc-the plan should also be in the in-memory plan cache + + # ------------------------------------------------------------------- + # archive_action persisted mode (line 461 True) + # ------------------------------------------------------------------- + + Scenario: archive_action persists via UoW when persisted + Given r2plc-an action "local/r2-archive-persist" exists + When r2plc-I archive the action "local/r2-archive-persist" in persisted mode + Then r2plc-the mock UoW should have received an action update call + And r2plc-the action state should be archived + + # ------------------------------------------------------------------- + # get_action persistence fallback returns None (line 364-370) + # ------------------------------------------------------------------- + + Scenario: get_action raises NotFoundError when persistence also returns None + When r2plc-I attempt to get action "local/nonexistent-r2" in persisted mode + Then r2plc-a NotFoundError should have been raised for action + + # ------------------------------------------------------------------- + # get_plan persistence fallback returns None (line 607-613) + # ------------------------------------------------------------------- + + Scenario: get_plan raises NotFoundError when persistence also returns None + When r2plc-I attempt to get plan "01ZZZZZZZZZZZZZZZZZZZZZZZZ" in persisted mode + Then r2plc-a NotFoundError should have been raised for plan + + # ------------------------------------------------------------------- + # update_error_details in persisted mode (hits _commit_plan True) + # ------------------------------------------------------------------- + + Scenario: update_error_details merges details and persists + Given r2plc-a plan in STRATEGIZE phase + When r2plc-I update error details with key "reason" value "timeout" + Then r2plc-the plan error_details should contain key "reason" + And r2plc-the mock UoW should have received a plan update call + + Scenario: update_error_details merges into existing error_details + Given r2plc-a plan in STRATEGIZE phase with existing error_details + When r2plc-I update error details with key "extra" value "info" + Then r2plc-the plan error_details should contain key "extra" + And r2plc-the plan error_details should contain key "original" diff --git a/features/plan_lifecycle_transitions_r2.feature b/features/plan_lifecycle_transitions_r2.feature new file mode 100644 index 000000000..e467edac8 --- /dev/null +++ b/features/plan_lifecycle_transitions_r2.feature @@ -0,0 +1,67 @@ +Feature: PlanLifecycleService lifecycle transition branch coverage (round 2) + As a developer maintaining PlanLifecycleService + I want every partial branch fully exercised + So that plan_lifecycle_service.py branch coverage reaches 100% + + # Targets the following partial branches: + # Line 576 – use_action with non-reusable action archives it + # Line 216 – execute_plan / apply_plan / cancel_plan / pause_plan / resume_plan + # persisted mode (line 216 True via _commit_plan) + + Background: + Given r2plc-a fresh plan lifecycle service with mock UoW + + # ------------------------------------------------------------------- + # use_action with non-reusable action archives it (line 576-577) + # ------------------------------------------------------------------- + + Scenario: use_action on non-reusable action archives it after plan creation + Given r2plc-a non-reusable action "local/r2-oneshot" exists + When r2plc-I use the non-reusable action to create a plan + Then r2plc-the action "local/r2-oneshot" should be archived + And r2plc-a plan should have been created from the action + + # ------------------------------------------------------------------- + # execute_plan persisted mode (line 216 True via _commit_plan) + # ------------------------------------------------------------------- + + Scenario: execute_plan transitions and persists via _commit_plan + Given r2plc-a plan in STRATEGIZE COMPLETE state + When r2plc-I call execute_plan in persisted mode + Then r2plc-the plan should be in EXECUTE phase + And r2plc-the mock UoW should have received a plan update call + + # ------------------------------------------------------------------- + # apply_plan persisted mode (line 216 True via _commit_plan) + # ------------------------------------------------------------------- + + Scenario: apply_plan transitions and persists via _commit_plan + Given r2plc-a plan in EXECUTE COMPLETE state + When r2plc-I call apply_plan in persisted mode + Then r2plc-the plan should be in APPLY phase + And r2plc-the mock UoW should have received a plan update call + + # ------------------------------------------------------------------- + # cancel_plan persisted mode (line 216 True via _commit_plan) + # ------------------------------------------------------------------- + + Scenario: cancel_plan persists cancellation via _commit_plan + Given r2plc-a plan in STRATEGIZE phase + When r2plc-I cancel the plan with reason "user requested" + Then r2plc-the plan processing state should be "cancelled" + And r2plc-the mock UoW should have received a plan update call + + # ------------------------------------------------------------------- + # pause_plan and resume_plan persisted mode + # ------------------------------------------------------------------- + + Scenario: pause_plan persists via _commit_plan + Given r2plc-a plan in STRATEGIZE phase + When r2plc-I pause the plan + Then r2plc-the plan automation profile should be manual + And r2plc-the mock UoW should have received a plan update call + + Scenario: resume_plan persists and auto-progresses via _commit_plan + Given r2plc-a plan in STRATEGIZE phase + When r2plc-I resume the plan with profile "auto" + Then r2plc-the plan automation profile should be "auto" diff --git a/features/skill_cli_coverage_r2.feature b/features/skill_cli_coverage_r2.feature new file mode 100644 index 000000000..b40a41e33 --- /dev/null +++ b/features/skill_cli_coverage_r2.feature @@ -0,0 +1,272 @@ +Feature: Skill CLI branch coverage round 2 + Cover partial branches in src/cleveragents/cli/commands/skill.py + Lines 74, 109, 112, 121, 155, 164, 169, and related conditionals. + All lines are hit but many conditional branches are only tested in one direction. + + Background: + Given r2skill- a reset skill CLI service + + # ------------------------------------------------------------------- + # _get_skill_service singleton (line 74 false branch) + # ------------------------------------------------------------------- + + Scenario: Service singleton returns existing instance on second call + When r2skill- I call _get_skill_service twice without resetting + Then r2skill- both calls return the same SkillService object + + # ------------------------------------------------------------------- + # _skill_spec_dict — missing timestamps (lines 109, 112 false) + # ------------------------------------------------------------------- + + Scenario: Show skill with JSON format when timestamps are absent + Given r2skill- a registered skill "local/no-timestamps" with tool_refs + And r2skill- the timestamps for "local/no-timestamps" are removed + When r2skill- I invoke show "local/no-timestamps" with format "json" + Then r2skill- the output should be valid JSON + And r2skill- the JSON output should not contain key "created" + And r2skill- the JSON output should not contain key "updated" + + # ------------------------------------------------------------------- + # _tool_count — MCP with no tools (line 121-122 false branch) + # ------------------------------------------------------------------- + + Scenario: Tool count for skill with MCP server having no tools + Given r2skill- a registered skill "local/mcp-no-tools" with MCP server but no tool list + When r2skill- I invoke show "local/mcp-no-tools" with format "rich" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "OK" + + # ------------------------------------------------------------------- + # _print_skill_registered — no includes (line 155 false) + # ------------------------------------------------------------------- + + Scenario: Add skill with no includes shows no Includes panel + Given r2skill- a temp YAML config for "local/no-includes" with only tool_refs + When r2skill- I invoke add with the temp config in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "Skill Registered" + And r2skill- the output should not contain "Includes" + + # ------------------------------------------------------------------- + # _print_skill_registered — empty sources (line 164 false) + # ------------------------------------------------------------------- + + Scenario: Add skill with no tool sources shows no Tool Sources panel + Given r2skill- a temp YAML config for "local/empty-skill" with no tools + When r2skill- I invoke add with the temp config in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "Skill Registered" + And r2skill- the output should not contain "Tool Sources" + + # ------------------------------------------------------------------- + # _print_skill_registered — source type branches (lines 169, 173, 177, 181) + # ------------------------------------------------------------------- + + Scenario: Add skill with only MCP sources excludes builtin from sources + Given r2skill- a temp YAML config for "local/mcp-only" with MCP and tools + When r2skill- I invoke add with the temp config in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "mcp" + + Scenario: Add skill with only custom inline tools + Given r2skill- a temp YAML config for "local/custom-only" with inline tools + When r2skill- I invoke add with the temp config in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "custom" + + # ------------------------------------------------------------------- + # add command — non-rich format (line 483 true branch) + # ------------------------------------------------------------------- + + Scenario: Add skill with JSON format returns JSON output + Given r2skill- a temp YAML config for "local/json-add" with only tool_refs + When r2skill- I invoke add with the temp config in format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + And r2skill- the JSON output should contain key "name" + + # ------------------------------------------------------------------- + # add command — update existing skill (lines 474, 491) + # ------------------------------------------------------------------- + + Scenario: Update existing skill via add --update shows Changes panel + Given r2skill- a temp YAML config for "local/updatable" with only tool_refs + And r2skill- the skill "local/updatable" is already registered via add + And r2skill- a second temp YAML config for "local/updatable" with different tools + When r2skill- I invoke add with the second config and --update in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "Skill Updated" + And r2skill- the output should contain "Changes" + + # ------------------------------------------------------------------- + # add command — duplicate without --update (line 500-510) + # ------------------------------------------------------------------- + + Scenario: Add duplicate skill without update raises already-registered error + Given r2skill- a temp YAML config for "local/duplicate" with only tool_refs + And r2skill- the skill "local/duplicate" is already registered via add + And r2skill- a duplicate temp YAML config for "local/duplicate" + When r2skill- I invoke add with the duplicate config without update + Then r2skill- the CLI exit code should not be 0 + And r2skill- the output should contain "already registered" + And r2skill- the output should contain "--update" + + # ------------------------------------------------------------------- + # list — empty registry (line 646 true) + # ------------------------------------------------------------------- + + Scenario: List skills when registry is empty shows message + When r2skill- I invoke list in rich format + Then r2skill- the output should contain "No skills found" + + # ------------------------------------------------------------------- + # list — non-rich format (line 651 true) + # ------------------------------------------------------------------- + + Scenario: List skills with JSON format + Given r2skill- a registered skill "local/for-json-list" with tool_refs + When r2skill- I invoke list in format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + + # ------------------------------------------------------------------- + # list — non-local namespace (line 671 else branch) + # ------------------------------------------------------------------- + + Scenario: List skills with non-local namespace counts server skills + Given r2skill- a registered skill "server/remote-skill" with tool_refs + When r2skill- I invoke list in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "server/remote-skill" + + # ------------------------------------------------------------------- + # show — non-rich format (line 724 true) + # ------------------------------------------------------------------- + + Scenario: Show skill with JSON format returns structured data + Given r2skill- a registered skill "local/for-json-show" with tool_refs + When r2skill- I invoke show "local/for-json-show" with format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + And r2skill- the JSON output should contain key "name" + + # ------------------------------------------------------------------- + # show — KeyError path (line 731-733) + # ------------------------------------------------------------------- + + Scenario: Show nonexistent skill raises not-found error + When r2skill- I invoke show "local/nonexistent" with format "rich" + Then r2skill- the CLI exit code should not be 0 + And r2skill- the output should contain "Skill not found" + + # ------------------------------------------------------------------- + # remove — rich format output (line 564 false, full rich path) + # ------------------------------------------------------------------- + + Scenario: Remove skill with rich format shows removal panel + Given r2skill- a registered skill "local/to-remove" with tool_refs + When r2skill- I invoke remove "local/to-remove" with --yes in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "Skill Removed" + And r2skill- the output should contain "OK" + + # ------------------------------------------------------------------- + # remove — non-rich format (line 564 true) + # ------------------------------------------------------------------- + + Scenario: Remove skill with JSON format returns structured output + Given r2skill- a registered skill "local/to-remove-json" with tool_refs + When r2skill- I invoke remove "local/to-remove-json" with --yes in format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + + # ------------------------------------------------------------------- + # remove — dependent skills (lines 577-587) + # ------------------------------------------------------------------- + + Scenario: Remove skill that is included by another skill shows dependency warning + Given r2skill- a registered skill "local/base-dep" with tool_refs + And r2skill- a registered skill "local/parent-dep" that includes "local/base-dep" + When r2skill- I invoke remove "local/base-dep" with --yes in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "skill(s) include this skill" + + # ------------------------------------------------------------------- + # remove — KeyError path (line 601-603) + # ------------------------------------------------------------------- + + Scenario: Remove nonexistent skill raises not-found error + When r2skill- I invoke remove "local/nonexistent" with --yes in rich format + Then r2skill- the CLI exit code should not be 0 + And r2skill- the output should contain "Skill not found" + + # ------------------------------------------------------------------- + # tools — non-rich format (line 801 true) + # ------------------------------------------------------------------- + + Scenario: Tools command with JSON format returns structured tool data + Given r2skill- a registered skill "local/for-json-tools" with tool_refs + When r2skill- I invoke tools "local/for-json-tools" with format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + + # ------------------------------------------------------------------- + # tools — inline entries in non-rich output (lines 806-811) + # ------------------------------------------------------------------- + + Scenario: Tools command with inline tools in JSON format shows custom source + Given r2skill- a registered skill "local/inline-tools" with anonymous inline tools + When r2skill- I invoke tools "local/inline-tools" with format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + And r2skill- the JSON tool list should contain source "custom" + + # ------------------------------------------------------------------- + # tools — KeyError path (line 827-829) + # ------------------------------------------------------------------- + + Scenario: Tools for nonexistent skill raises not-found error + When r2skill- I invoke tools "local/nonexistent" with format "rich" + Then r2skill- the CLI exit code should not be 0 + And r2skill- the output should contain "Skill not found" + + # ------------------------------------------------------------------- + # _print_skill_registered — MCP servers panel (line 189) + # ------------------------------------------------------------------- + + Scenario: Add skill with MCP servers shows MCP Servers panel + Given r2skill- a temp YAML config for "local/with-mcp-panel" with MCP servers + When r2skill- I invoke add with the temp config in rich format + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "MCP Servers" + + # ------------------------------------------------------------------- + # _print_skill_detail — no direct tools (line 302 false) + # ------------------------------------------------------------------- + + Scenario: Show skill with no direct tools omits direct tools table + Given r2skill- a registered skill "local/no-direct-tools" with no tools at all + When r2skill- I invoke show "local/no-direct-tools" with format "rich" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should not contain "Direct Tools" + + # ------------------------------------------------------------------- + # _print_skill_detail — MCP tools in show (line 287 true/false) + # ------------------------------------------------------------------- + + Scenario: Show skill with MCP servers displays MCP panel + Given r2skill- a registered skill "local/show-mcp" with MCP server and explicit tools + When r2skill- I invoke show "local/show-mcp" with format "rich" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should contain "MCP Servers" + + # ------------------------------------------------------------------- + # tools — MCP entry in non-rich format (line 806 true) + # ------------------------------------------------------------------- + + Scenario: Tools command with MCP entries in JSON format + Given r2skill- a registered skill "local/mcp-tools-json" with MCP server and explicit tools + When r2skill- I invoke tools "local/mcp-tools-json" with format "json" + Then r2skill- the CLI exit code should be 0 + And r2skill- the output should be valid JSON + And r2skill- the JSON tool list should contain source "mcp" diff --git a/features/steps/container_coverage_r2_steps.py b/features/steps/container_coverage_r2_steps.py new file mode 100644 index 000000000..531ee2929 --- /dev/null +++ b/features/steps/container_coverage_r2_steps.py @@ -0,0 +1,388 @@ +"""Step definitions for container_coverage_r2.feature. + +Covers remaining uncovered lines and partial branches in +``cleveragents.application.container``: + + - Lines 66-67, 69: get_ai_provider non-mock paths + - Lines 125-130: _build_project_resource_link_repo + - Partial branches at lines 51, 57, 82, 87, 256, 284-285, 288 + +All step text uses the ``r2cont-`` prefix to avoid collisions with +existing step definitions in container_and_repository_coverage_steps.py. +""" + +from __future__ import annotations + +import os +import sys +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when +from behave.runner import Context + +# ------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------- + + +def _save_env(context: Context, key: str) -> None: + """Save an environment variable for later restoration.""" + if not hasattr(context, "_r2cont_saved_env"): + context._r2cont_saved_env = {} + if key not in context._r2cont_saved_env: + context._r2cont_saved_env[key] = os.environ.get(key) + + +def _restore_env(context: Context) -> None: + """Restore all saved environment variables.""" + saved: dict[str, str | None] = getattr(context, "_r2cont_saved_env", {}) + for key, original in saved.items(): + if original is None: + os.environ.pop(key, None) + else: + os.environ[key] = original + + +def _register_cleanup(context: Context, fn: Any) -> None: + """Register a cleanup callable on the behave context.""" + if not hasattr(context, "_r2cont_cleanups"): + context._r2cont_cleanups = [] + context._r2cont_cleanups.append(fn) + + +def _run_cleanups(context: Context) -> None: + """Run all registered cleanups (called in after_scenario hook or manually).""" + for fn in getattr(context, "_r2cont_cleanups", []): + fn() + context._r2cont_cleanups = [] + + +# ------------------------------------------------------------------- +# Given: get_ai_provider setup +# ------------------------------------------------------------------- + + +@given("r2cont- the mock AI environment flag is disabled") +def step_disable_mock_ai(context: Context) -> None: + """Ensure CLEVERAGENTS_TESTING_USE_MOCK_AI is not set.""" + _save_env(context, "CLEVERAGENTS_TESTING_USE_MOCK_AI") + os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None) + _register_cleanup(context, lambda: _restore_env(context)) + + +@given("r2cont- a provider registry with no configured providers") +def step_registry_no_providers(context: Context) -> None: + """Create a mock ProviderRegistry that returns no configured providers.""" + mock_registry = MagicMock() + mock_registry.get_configured_providers.return_value = [] + context.r2cont_registry = mock_registry + + +@given("r2cont- a provider registry with configured providers") +def step_registry_with_providers(context: Context) -> None: + """Create a mock ProviderRegistry that reports configured providers.""" + mock_registry = MagicMock() + mock_registry.get_configured_providers.return_value = [MagicMock()] + mock_provider = MagicMock() + mock_registry.create_ai_provider.return_value = mock_provider + context.r2cont_registry = mock_registry + context.r2cont_expected_provider = mock_provider + + +@given("r2cont- the mock AI environment flag is enabled") +def step_enable_mock_ai(context: Context) -> None: + """Set the env flag to trigger mock AI loading.""" + _save_env(context, "CLEVERAGENTS_TESTING_USE_MOCK_AI") + os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true" + _register_cleanup(context, lambda: _restore_env(context)) + + +@given("r2cont- the features path is already on sys.path") +def step_features_path_on_syspath(context: Context) -> None: + """Ensure the features directory IS on sys.path before calling get_ai_provider.""" + from pathlib import Path as _Path + + # Compute the features path the same way container.py does: + # Path(__file__).parent.parent.parent.parent / "features" + # where __file__ is src/cleveragents/application/container.py + from cleveragents.application.container import __file__ as container_file + + feat_path = str(_Path(container_file).parent.parent.parent.parent / "features") + context.r2cont_features_path = feat_path + + if feat_path not in sys.path: + sys.path.insert(0, feat_path) + + # Record the count so we can verify no duplicates were added + context.r2cont_features_path_count_before = sys.path.count(feat_path) + + def cleanup() -> None: + # Remove any extra entries we may have added, restoring original count + while sys.path.count(feat_path) > context.r2cont_features_path_count_before: + sys.path.remove(feat_path) + + _register_cleanup(context, cleanup) + + +# ------------------------------------------------------------------- +# Given: get_database_url env var setup +# ------------------------------------------------------------------- + + +@given("r2cont- no database URL environment variables are set") +def step_clear_all_db_env_vars(context: Context) -> None: + """Remove both database URL env vars.""" + for key in ("CLEVERAGENTS_DATABASE_URL", "CLEVERAGENTS_TEST_DATABASE_URL"): + _save_env(context, key) + os.environ.pop(key, None) + _register_cleanup(context, lambda: _restore_env(context)) + + +@given('r2cont- the env var "{key}" is set to empty') +def step_set_env_var_empty(context: Context, key: str) -> None: + """Set a specific environment variable to the empty string.""" + _save_env(context, key) + os.environ[key] = "" + _register_cleanup(context, lambda: _restore_env(context)) + + +@given('r2cont- the env var "{key}" is set to "{value}"') +def step_set_env_var(context: Context, key: str, value: str) -> None: + """Set a specific environment variable.""" + _save_env(context, key) + os.environ[key] = value + _register_cleanup(context, lambda: _restore_env(context)) + + +@given('r2cont- the env var "{key}" is cleared') +def step_clear_env_var(context: Context, key: str) -> None: + """Clear a specific environment variable.""" + _save_env(context, key) + os.environ.pop(key, None) + _register_cleanup(context, lambda: _restore_env(context)) + + +# ------------------------------------------------------------------- +# Given: Container singleton setup +# ------------------------------------------------------------------- + + +@given("r2cont- the global container has been reset") +def step_reset_global_container(context: Context) -> None: + """Reset the global container singleton to None.""" + from cleveragents.application.container import reset_container + + reset_container() + _register_cleanup(context, reset_container) + + +# ------------------------------------------------------------------- +# When: get_ai_provider +# ------------------------------------------------------------------- + + +@when("r2cont- I call get_ai_provider with the mocked registry") +def step_call_get_ai_provider_mocked(context: Context) -> None: + """Call get_ai_provider passing the mock settings and registry.""" + from cleveragents.application.container import get_ai_provider + + mock_settings = MagicMock() + context.r2cont_ai_result = get_ai_provider( + settings=mock_settings, + provider_registry=context.r2cont_registry, + ) + + +@when("r2cont- I call get_ai_provider without registry args") +def step_call_get_ai_provider_default(context: Context) -> None: + """Call get_ai_provider using defaults (will trigger mock AI path).""" + from cleveragents.application.container import get_ai_provider + + context.r2cont_ai_result = get_ai_provider() + + +# ------------------------------------------------------------------- +# When: _build_project_resource_link_repo +# ------------------------------------------------------------------- + + +@when("r2cont- I build a project resource link repo with an in-memory database") +def step_build_project_resource_link_repo(context: Context) -> None: + """Call _build_project_resource_link_repo with sqlite memory URL.""" + from cleveragents.application.container import _build_project_resource_link_repo + + context.r2cont_link_repo = _build_project_resource_link_repo("sqlite:///:memory:") + + +# ------------------------------------------------------------------- +# When: get_database_url +# ------------------------------------------------------------------- + + +@when("r2cont- I call get_database_url") +def step_call_get_database_url(context: Context) -> None: + """Call get_database_url and store the result.""" + from cleveragents.application.container import get_database_url + + context.r2cont_db_url = get_database_url() + + +# ------------------------------------------------------------------- +# When: get_container +# ------------------------------------------------------------------- + + +@when("r2cont- I call get_container twice") +def step_call_get_container_twice(context: Context) -> None: + """Call get_container twice and store both references.""" + from cleveragents.application.container import get_container + + context.r2cont_container_first = get_container() + context.r2cont_container_second = get_container() + + +# ------------------------------------------------------------------- +# When: override_providers - non-existent name +# ------------------------------------------------------------------- + + +@when("r2cont- I override a provider with a non-existent name") +def step_override_nonexistent(context: Context) -> None: + """Call override_providers with a name that doesn't exist on Container.""" + from cleveragents.application.container import override_providers + + context.r2cont_override_error = None + try: + override_providers(this_provider_does_not_exist="some_value") + except Exception as exc: + context.r2cont_override_error = exc + + +# ------------------------------------------------------------------- +# When: override_providers - non-database_url provider +# ------------------------------------------------------------------- + + +@when("r2cont- I override the settings provider with a mock value") +def step_override_settings(context: Context) -> None: + """Override the 'settings' provider (not database_url) to hit the else branch.""" + from cleveragents.application.container import override_providers + + mock_settings = MagicMock() + mock_settings._r2cont_marker = "mocked_settings" + context.r2cont_mock_settings = mock_settings + override_providers(settings=mock_settings) + + +# ------------------------------------------------------------------- +# Then: get_ai_provider assertions +# ------------------------------------------------------------------- + + +@then("r2cont- the ai provider result should be None") +def step_ai_provider_none(context: Context) -> None: + """Assert get_ai_provider returned None.""" + assert context.r2cont_ai_result is None, ( + f"Expected None, got {context.r2cont_ai_result}" + ) + + +@then("r2cont- the ai provider result should not be None") +def step_ai_provider_not_none(context: Context) -> None: + """Assert get_ai_provider returned a provider instance.""" + assert context.r2cont_ai_result is not None, "Expected a provider, got None" + assert context.r2cont_ai_result is context.r2cont_expected_provider + + +@then("r2cont- the features path should appear exactly once on sys.path") +def step_features_path_no_duplicate(context: Context) -> None: + """Verify the features path was not duplicated on sys.path.""" + count = sys.path.count(context.r2cont_features_path) + assert count == context.r2cont_features_path_count_before, ( + f"Expected features path count {context.r2cont_features_path_count_before}, " + f"got {count}" + ) + _run_cleanups(context) + + +# ------------------------------------------------------------------- +# Then: _build_project_resource_link_repo assertions +# ------------------------------------------------------------------- + + +@then("r2cont- the result should be a ProjectResourceLinkRepository instance") +def step_verify_link_repo_type(context: Context) -> None: + """Assert the returned object is a ProjectResourceLinkRepository.""" + from cleveragents.infrastructure.database.repositories import ( + ProjectResourceLinkRepository, + ) + + assert isinstance(context.r2cont_link_repo, ProjectResourceLinkRepository), ( + f"Expected ProjectResourceLinkRepository, " + f"got {type(context.r2cont_link_repo).__name__}" + ) + + +# ------------------------------------------------------------------- +# Then: get_database_url assertions +# ------------------------------------------------------------------- + + +@then('r2cont- the database URL should start with "{prefix}"') +def step_db_url_starts_with(context: Context, prefix: str) -> None: + """Assert the database URL starts with the given prefix.""" + assert context.r2cont_db_url.startswith(prefix), ( + f"Expected URL starting with '{prefix}', got '{context.r2cont_db_url}'" + ) + _run_cleanups(context) + + +@then('r2cont- the database URL should be "{expected}"') +def step_db_url_exact(context: Context, expected: str) -> None: + """Assert the database URL matches exactly.""" + assert context.r2cont_db_url == expected, ( + f"Expected '{expected}', got '{context.r2cont_db_url}'" + ) + _run_cleanups(context) + + +# ------------------------------------------------------------------- +# Then: get_container assertions +# ------------------------------------------------------------------- + + +@then("r2cont- both calls should return the same container instance") +def step_same_container(context: Context) -> None: + """Assert both get_container calls returned the same object.""" + assert context.r2cont_container_first is context.r2cont_container_second, ( + "Expected the same container instance on both calls" + ) + _run_cleanups(context) + + +# ------------------------------------------------------------------- +# Then: override_providers assertions +# ------------------------------------------------------------------- + + +@then("r2cont- no error should have been raised") +def step_no_override_error(context: Context) -> None: + """Assert no exception was raised during override_providers.""" + assert context.r2cont_override_error is None, ( + f"Unexpected error: {context.r2cont_override_error}" + ) + _run_cleanups(context) + + +@then("r2cont- the settings provider should return the mock value") +def step_settings_overridden(context: Context) -> None: + """Assert the settings provider now returns our mock.""" + from cleveragents.application.container import get_container + + container = get_container() + result = container.settings() + assert result is context.r2cont_mock_settings, ( + f"Expected mocked settings, got {result}" + ) + _run_cleanups(context) diff --git a/features/steps/correction_service_coverage_r2_steps.py b/features/steps/correction_service_coverage_r2_steps.py new file mode 100644 index 000000000..5be5e25ad --- /dev/null +++ b/features/steps/correction_service_coverage_r2_steps.py @@ -0,0 +1,318 @@ +"""Step definitions for correction_service_coverage_r2.feature. + +Covers the exception-handling paths in ``execute_revert`` (lines 254-262) +and ``execute_append`` (lines 320-328) of +``cleveragents.application.services.correction_service.CorrectionService``. + +These paths are unreachable under normal operation because the try-block +code is straightforward. We use ``unittest.mock.patch`` to force +exceptions inside the try blocks so that the ``except Exception`` handlers +execute. + +All step text uses a ``r2corr-`` prefix to avoid collisions with existing +step definitions in correction_service_coverage_steps.py and +correction_service_new_coverage_steps.py. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from behave import given, then, when + +from cleveragents.application.services.correction_service import CorrectionService +from cleveragents.domain.models.core.correction import ( + CorrectionMode, + CorrectionStatus, +) + +# ------------------------------------------------------------------- +# Background +# ------------------------------------------------------------------- + + +@given("r2corr-a fresh correction service") +def step_r2_background(context: Any) -> None: + """Initialise a clean service and all context slots.""" + context.r2_svc = CorrectionService() + context.r2_cid = None + context.r2_revert_result = None + context.r2_append_result = None + context.r2_dispatch_result = None + context.r2_patch = None + + +# ------------------------------------------------------------------- +# Given - create corrections +# ------------------------------------------------------------------- + + +@given('r2corr-a stored revert correction for plan "{plan}" targeting "{target}"') +def step_r2_stored_revert(context: Any, plan: str, target: str) -> None: + req = context.r2_svc.request_correction( + plan_id=plan, + target_decision_id=target, + mode=CorrectionMode.REVERT, + ) + context.r2_cid = req.correction_id + + +@given('r2corr-a stored append correction for plan "{plan}" targeting "{target}"') +def step_r2_stored_append(context: Any, plan: str, target: str) -> None: + req = context.r2_svc.request_correction( + plan_id=plan, + target_decision_id=target, + mode=CorrectionMode.APPEND, + ) + context.r2_cid = req.correction_id + + +# ------------------------------------------------------------------- +# Given - monkeypatches to force exceptions +# ------------------------------------------------------------------- + + +@given('r2corr-analyze_impact is patched to raise RuntimeError "{msg}"') +def step_r2_patch_analyze_runtime(context: Any, msg: str) -> None: + """Replace ``analyze_impact`` with a function that raises RuntimeError.""" + original = context.r2_svc.analyze_impact + + def _boom(*args: Any, **kwargs: Any) -> None: + raise RuntimeError(msg) + + context.r2_svc.analyze_impact = _boom # type: ignore[assignment] + context.r2_original_analyze = original + + +@given('r2corr-analyze_impact is patched to raise ValueError "{msg}"') +def step_r2_patch_analyze_value(context: Any, msg: str) -> None: + """Replace ``analyze_impact`` with a function that raises ValueError.""" + + def _boom(*args: Any, **kwargs: Any) -> None: + raise ValueError(msg) + + context.r2_svc.analyze_impact = _boom # type: ignore[assignment] + + +@given('r2corr-analyze_impact is patched to raise TypeError "{msg}"') +def step_r2_patch_analyze_type(context: Any, msg: str) -> None: + """Replace ``analyze_impact`` with a function that raises TypeError.""" + + def _boom(*args: Any, **kwargs: Any) -> None: + raise TypeError(msg) + + context.r2_svc.analyze_impact = _boom # type: ignore[assignment] + + +@given('r2corr-ULID is patched to raise RuntimeError "{msg}"') +def step_r2_patch_ulid_runtime(context: Any, msg: str) -> None: + """Store patch info so the When step can apply it in the right scope.""" + context.r2_ulid_exc = RuntimeError(msg) + + +@given('r2corr-ULID is patched to raise ValueError "{msg}"') +def step_r2_patch_ulid_value(context: Any, msg: str) -> None: + context.r2_ulid_exc = ValueError(msg) + + +@given('r2corr-ULID is patched to raise OSError "{msg}"') +def step_r2_patch_ulid_os(context: Any, msg: str) -> None: + context.r2_ulid_exc = OSError(msg) + + +# ------------------------------------------------------------------- +# When - execute_revert +# ------------------------------------------------------------------- + + +@when("r2corr-I execute revert on the stored correction") +def step_r2_exec_revert(context: Any) -> None: + """Execute revert; analyze_impact is already patched on the instance.""" + context.r2_revert_result = context.r2_svc.execute_revert(context.r2_cid, {}) + + +# ------------------------------------------------------------------- +# When - execute_append +# ------------------------------------------------------------------- + + +@when("r2corr-I execute append on the stored correction") +def step_r2_exec_append(context: Any) -> None: + """Execute append with ULID patched to raise.""" + exc = getattr(context, "r2_ulid_exc", None) + if exc is not None: + with patch( + "cleveragents.application.services.correction_service.ULID", + side_effect=exc, + ): + context.r2_append_result = context.r2_svc.execute_append( + context.r2_cid, + ) + else: + context.r2_append_result = context.r2_svc.execute_append( + context.r2_cid, + ) + + +# ------------------------------------------------------------------- +# When - execute_correction (dispatch) +# ------------------------------------------------------------------- + + +@when("r2corr-I dispatch execute correction on the stored correction") +def step_r2_dispatch(context: Any) -> None: + """Dispatch execute_correction; patches are already active.""" + exc = getattr(context, "r2_ulid_exc", None) + if exc is not None: + with patch( + "cleveragents.application.services.correction_service.ULID", + side_effect=exc, + ): + context.r2_dispatch_result = context.r2_svc.execute_correction( + context.r2_cid, + ) + else: + context.r2_dispatch_result = context.r2_svc.execute_correction( + context.r2_cid, + {}, + ) + + +# ------------------------------------------------------------------- +# Then - revert result assertions +# ------------------------------------------------------------------- + + +@then('r2corr-the revert result status should be "{val}"') +def step_r2_revert_status(context: Any, val: str) -> None: + assert context.r2_revert_result is not None, "No revert result captured" + assert context.r2_revert_result.status.value == val, ( + f"Expected status '{val}', got '{context.r2_revert_result.status.value}'" + ) + + +@then('r2corr-the revert result error_message should contain "{fragment}"') +def step_r2_revert_error_msg(context: Any, fragment: str) -> None: + msg = context.r2_revert_result.error_message + assert msg is not None, "error_message is None" + assert fragment in msg, f"'{fragment}' not found in error_message: '{msg}'" + + +# ------------------------------------------------------------------- +# Then - append result assertions +# ------------------------------------------------------------------- + + +@then('r2corr-the append result status should be "{val}"') +def step_r2_append_status(context: Any, val: str) -> None: + assert context.r2_append_result is not None, "No append result captured" + assert context.r2_append_result.status.value == val, ( + f"Expected status '{val}', got '{context.r2_append_result.status.value}'" + ) + + +@then('r2corr-the append result error_message should contain "{fragment}"') +def step_r2_append_error_msg(context: Any, fragment: str) -> None: + msg = context.r2_append_result.error_message + assert msg is not None, "error_message is None" + assert fragment in msg, f"'{fragment}' not found in error_message: '{msg}'" + + +# ------------------------------------------------------------------- +# Then - dispatch result assertions +# ------------------------------------------------------------------- + + +@then('r2corr-the dispatch result status should be "{val}"') +def step_r2_dispatch_status(context: Any, val: str) -> None: + assert context.r2_dispatch_result is not None, "No dispatch result captured" + assert context.r2_dispatch_result.status.value == val, ( + f"Expected status '{val}', got '{context.r2_dispatch_result.status.value}'" + ) + + +@then('r2corr-the dispatch result error_message should contain "{fragment}"') +def step_r2_dispatch_error_msg(context: Any, fragment: str) -> None: + msg = context.r2_dispatch_result.error_message + assert msg is not None, "error_message is None" + assert fragment in msg, f"'{fragment}' not found in error_message: '{msg}'" + + +# ------------------------------------------------------------------- +# Then - stored correction status +# ------------------------------------------------------------------- + + +@then('r2corr-the stored correction status should be "{val}"') +def step_r2_stored_status(context: Any, val: str) -> None: + req = context.r2_svc.get_correction(context.r2_cid) + assert req.status.value == val, ( + f"Expected stored status '{val}', got '{req.status.value}'" + ) + + +# ------------------------------------------------------------------- +# Then - attempt assertions +# ------------------------------------------------------------------- + + +@then("r2corr-the first attempt should have success false") +def step_r2_attempt_fail(context: Any) -> None: + attempts = context.r2_svc.list_attempts(context.r2_cid) + assert len(attempts) > 0, "No attempts recorded" + assert attempts[0].success is False, ( + f"Expected success=False, got {attempts[0].success}" + ) + + +@then("r2corr-the first attempt details should contain error key") +def step_r2_attempt_error_key(context: Any) -> None: + attempts = context.r2_svc.list_attempts(context.r2_cid) + assert "error" in attempts[0].details, ( + f"Expected 'error' key in details, got {attempts[0].details}" + ) + + +@then('r2corr-the first attempt details error should contain "{fragment}"') +def step_r2_attempt_error_val(context: Any, fragment: str) -> None: + attempts = context.r2_svc.list_attempts(context.r2_cid) + error_val = attempts[0].details.get("error", "") + assert fragment in error_val, ( + f"'{fragment}' not found in attempt error: '{error_val}'" + ) + + +@then("r2corr-the first attempt completed_at should be set") +def step_r2_attempt_completed(context: Any) -> None: + attempts = context.r2_svc.list_attempts(context.r2_cid) + assert attempts[0].completed_at is not None, "completed_at is None" + + +@then("r2corr-the attempts for the stored correction should have {n:d} entry") +def step_r2_attempt_count(context: Any, n: int) -> None: + attempts = context.r2_svc.list_attempts(context.r2_cid) + assert len(attempts) == n, f"Expected {n} attempts, got {len(attempts)}" + + +# ------------------------------------------------------------------- +# Then - result stored in service +# ------------------------------------------------------------------- + + +@then("r2corr-the result should be stored in the service results dict") +def step_r2_result_stored(context: Any) -> None: + assert context.r2_cid in context.r2_svc._results, ( + f"correction_id {context.r2_cid} not in _results" + ) + stored = context.r2_svc._results[context.r2_cid] + assert stored.status == CorrectionStatus.FAILED + + +@then("r2corr-the append result should be stored in the service results dict") +def step_r2_append_result_stored(context: Any) -> None: + assert context.r2_cid in context.r2_svc._results, ( + f"correction_id {context.r2_cid} not in _results" + ) + stored = context.r2_svc._results[context.r2_cid] + assert stored.status == CorrectionStatus.FAILED diff --git a/features/steps/models_lifecycle_coverage_r2_steps.py b/features/steps/models_lifecycle_coverage_r2_steps.py new file mode 100644 index 000000000..bbf423883 --- /dev/null +++ b/features/steps/models_lifecycle_coverage_r2_steps.py @@ -0,0 +1,409 @@ +"""Step definitions for models_lifecycle_coverage_r2.feature. + +Provides branch coverage for ``LifecycleActionModel`` and +``LifecyclePlanModel`` conversion helpers and plan helper functions in +``cleveragents.infrastructure.database.models``. + +All step-text uses an ``r2mod-`` prefix to avoid collisions with +existing step definition files. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from behave import given, then, when +from sqlalchemy.orm import attributes as sa_attr + +from cleveragents.infrastructure.database.models import ( + ActionArgumentModel, + ActionInvariantModel, + LifecycleActionModel, + LifecyclePlanModel, + PlanArgumentModel, +) + +# =================================================================== +# Helpers +# =================================================================== + +_NOW_ISO: str = datetime.now(tz=UTC).isoformat() + +# Valid ULID constant for use in test models +_ULID_PLAN = "01ARZ3NDEKTSV4RRFFQ69G5FA2" + + +def _set_rel_none(model: Any, attr: str) -> None: + """Bypass SQLAlchemy's instrumented setter to force a relationship to None. + + This lets us test the ``or []`` fallback branches in ``to_domain()``. + """ + state = sa_attr.instance_state(model) + state.dict[attr] = None + + +def _make_action_model( + *, + arguments: list[ActionArgumentModel] | None = None, + invariants: list[ActionInvariantModel] | None = None, + tags_json: str | None = "[]", + inputs_schema_json: str | None = None, +) -> LifecycleActionModel: + """Create a minimal ``LifecycleActionModel``.""" + model = LifecycleActionModel( + namespaced_name="local/test-action", + namespace="local", + name="test-action", + description="Test action", + long_description=None, + definition_of_done="tests pass", + strategy_actor="local/strat", + execution_actor="local/exec", + review_actor=None, + apply_actor=None, + estimation_actor=None, + invariant_actor=None, + automation_profile=None, + reusable=True, + read_only=False, + inputs_schema_json=inputs_schema_json, + state="available", + created_by=None, + tags_json=tags_json, + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + ) + if arguments is not None: + model.arguments_rel = arguments + else: + model.arguments_rel = [] + if invariants is not None: + model.invariants_rel = invariants + else: + model.invariants_rel = [] + return model + + +def _make_plan_model( + *, + automation_profile: str | None = None, + sandbox_refs_json: str | None = None, + validation_summary_json: str | None = None, + tags_json: str | None = None, + action_name: str | None = None, + error_details_json: str | None = None, + error_message: str | None = None, + arguments: list[PlanArgumentModel] | None = None, +) -> LifecyclePlanModel: + """Create a minimal ``LifecyclePlanModel``.""" + model = LifecyclePlanModel( + plan_id=_ULID_PLAN, + parent_plan_id=None, + root_plan_id=None, + action_name=action_name or "", + namespaced_name="local/test-plan", + namespace="local", + phase="action", + processing_state="queued", + attempt=1, + description="Test plan", + definition_of_done=None, + strategy_actor=None, + execution_actor=None, + review_actor=None, + apply_actor=None, + estimation_actor=None, + invariant_actor=None, + automation_profile=automation_profile, + reusable=True, + read_only=False, + inputs_schema_json=None, + changeset_id=None, + sandbox_refs_json=sandbox_refs_json, + validation_summary_json=validation_summary_json, + decision_root_id=None, + error_message=error_message, + error_details_json=error_details_json, + created_by=None, + tags_json=tags_json or "[]", + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + completed_at=None, + strategize_started_at=None, + strategize_completed_at=None, + execute_started_at=None, + execute_completed_at=None, + apply_started_at=None, + applied_at=None, + ) + model.project_links_rel = [] + model.invariants_rel = [] + if arguments is not None: + model.arguments_rel = arguments + else: + model.arguments_rel = [] + return model + + +# =================================================================== +# LifecycleActionModel.to_domain() +# =================================================================== + + +@given("a r2mod-ActionModel with an argument having None defaults") +def step_action_arg_none_defaults(context: Any) -> None: + arg = ActionArgumentModel( + name="arg1", + arg_type="string", + requirement="required", + description="desc", + default_value_json=None, + min_value=None, + max_value=None, + validation_pattern=None, + position=0, + ) + context.r2_action_model = _make_action_model(arguments=[arg]) + + +@given("a r2mod-ActionModel with None tags_json and None inputs_schema_json") +def step_action_none_tags_inputs(context: Any) -> None: + context.r2_action_model = _make_action_model( + tags_json=None, inputs_schema_json=None + ) + + +@given("a r2mod-ActionModel with None rels") +def step_action_none_rels(context: Any) -> None: + model = _make_action_model() + _set_rel_none(model, "arguments_rel") + _set_rel_none(model, "invariants_rel") + context.r2_action_model = model + + +@when("I r2mod-convert the ActionModel to domain") +def step_action_to_domain(context: Any) -> None: + context.r2_action_domain = context.r2_action_model.to_domain() + + +@then("the r2mod-action first argument default_value should be None") +def step_action_arg_default_none(context: Any) -> None: + assert context.r2_action_domain.arguments[0].default_value is None + + +@then("the r2mod-action first argument min_value should be None") +def step_action_arg_min_none(context: Any) -> None: + assert context.r2_action_domain.arguments[0].min_value is None + + +@then("the r2mod-action first argument max_value should be None") +def step_action_arg_max_none(context: Any) -> None: + assert context.r2_action_domain.arguments[0].max_value is None + + +@then("the r2mod-action tags should be empty") +def step_action_tags_empty(context: Any) -> None: + assert context.r2_action_domain.tags == [] + + +@then("the r2mod-action inputs_schema should be None") +def step_action_inputs_none(context: Any) -> None: + assert context.r2_action_domain.inputs_schema is None + + +@then("the r2mod-action arguments should be empty") +def step_action_args_empty(context: Any) -> None: + assert context.r2_action_domain.arguments == [] + + +@then("the r2mod-action invariants should be empty") +def step_action_invs_empty(context: Any) -> None: + assert context.r2_action_domain.invariants == [] + + +# =================================================================== +# LifecyclePlanModel.to_domain() +# =================================================================== + + +@given("a r2mod-PlanModel with all optional fields set to None") +def step_plan_all_none(context: Any) -> None: + model = _make_plan_model( + automation_profile=None, + sandbox_refs_json=None, + validation_summary_json=None, + tags_json=None, + action_name="local/test-action", # required non-empty by Plan domain + error_details_json=None, + ) + _set_rel_none(model, "project_links_rel") + _set_rel_none(model, "invariants_rel") + _set_rel_none(model, "arguments_rel") + context.r2_plan_model = model + + +@given("a r2mod-PlanModel with None action_name") +def step_plan_none_action(context: Any) -> None: + context.r2_plan_model = _make_plan_model(action_name=None) + + +@when("I r2mod-attempt to convert the PlanModel to domain") +def step_plan_to_domain_attempt(context: Any) -> None: + try: + context.r2_plan_domain = context.r2_plan_model.to_domain() + context.r2_error = None + except Exception as exc: + context.r2_error = exc + + +@then('a r2mod-ValidationError should have been raised with "{fragment}"') +def step_r2_validation_error_fragment(context: Any, fragment: str) -> None: + assert context.r2_error is not None, "Expected an error but none was raised" + assert fragment in str(context.r2_error), ( + f"Expected '{fragment}' in error: {context.r2_error}" + ) + + +@given("a r2mod-PlanModel with an argument having None value_json") +def step_plan_arg_none_value(context: Any) -> None: + arg = PlanArgumentModel( + name="test_arg", + value_json=None, + value_type="string", + position=0, + ) + context.r2_plan_model = _make_plan_model( + arguments=[arg], action_name="local/test-action" + ) + + +@when("I r2mod-convert the PlanModel to domain") +def step_plan_to_domain(context: Any) -> None: + context.r2_plan_domain = context.r2_plan_model.to_domain() + + +@then("the r2mod-plan automation_profile should be None") +def step_plan_profile_none(context: Any) -> None: + assert context.r2_plan_domain.automation_profile is None + + +@then("the r2mod-plan validation_summary should be None") +def step_plan_validation_none(context: Any) -> None: + assert context.r2_plan_domain.validation_summary is None + + +@then("the r2mod-plan sandbox_refs should be empty") +def step_plan_sandbox_empty(context: Any) -> None: + assert context.r2_plan_domain.sandbox_refs == [] + + +@then("the r2mod-plan tags should be empty") +def step_plan_tags_empty(context: Any) -> None: + assert context.r2_plan_domain.tags == [] + + +@then("the r2mod-plan action_name should be empty string") +def step_plan_action_empty(context: Any) -> None: + assert context.r2_plan_domain.action_name == "" + + +@then("the r2mod-plan error_details should be None") +def step_plan_err_none(context: Any) -> None: + assert context.r2_plan_domain.error_details is None + + +@then('the r2mod-plan argument "{name}" should be None') +def step_plan_arg_value_none(context: Any, name: str) -> None: + assert context.r2_plan_domain.arguments[name] is None + + +# =================================================================== +# LifecycleActionModel.from_domain() +# =================================================================== + + +@when("I r2mod-create ActionModel from domain with None inputs_schema") +def step_action_from_domain_none_schema(context: Any) -> None: + from cleveragents.domain.models.core.action import Action, ActionState + from cleveragents.domain.models.core.plan import NamespacedName + + action = Action( + namespaced_name=NamespacedName(namespace="local", name="test-act"), + description="test", + definition_of_done="tests pass", + strategy_actor="local/strat", + execution_actor="local/exec", + inputs_schema=None, + state=ActionState.AVAILABLE, + ) + context.r2_created_action = LifecycleActionModel.from_domain(action) + + +@then("the r2mod-created ActionModel inputs_schema_json should be None") +def step_action_created_schema_none(context: Any) -> None: + assert context.r2_created_action.inputs_schema_json is None + + +@when("I r2mod-create ActionModel from domain with argument having None default") +def step_action_from_domain_arg_none_default(context: Any) -> None: + from cleveragents.domain.models.core.action import ( + Action, + ActionArgument, + ActionState, + ArgumentRequirement, + ArgumentType, + ) + from cleveragents.domain.models.core.plan import NamespacedName + + arg = ActionArgument( + name="myarg", + arg_type=ArgumentType.STRING, + requirement=ArgumentRequirement.OPTIONAL, + description="no default", + default_value=None, + ) + action = Action( + namespaced_name=NamespacedName(namespace="local", name="test-act"), + description="test", + definition_of_done="tests pass", + strategy_actor="local/strat", + execution_actor="local/exec", + arguments=[arg], + state=ActionState.AVAILABLE, + ) + context.r2_created_action = LifecycleActionModel.from_domain(action) + + +@then("the r2mod-created ActionModel first argument default_value_json should be None") +def step_action_created_arg_default_none(context: Any) -> None: + assert context.r2_created_action.arguments_rel[0].default_value_json is None + + +# =================================================================== +# LifecyclePlanModel helpers +# =================================================================== + + +@then("r2mod-_parse_iso with None should return None") +def step_parse_iso_none(context: Any) -> None: + assert LifecyclePlanModel._parse_iso(None) is None + + +@then("r2mod-_to_iso with None should return None") +def step_to_iso_none(context: Any) -> None: + assert LifecyclePlanModel._to_iso(None) is None + + +@then('r2mod-_parse_iso with "{iso}" should return a datetime') +def step_parse_iso_value(context: Any, iso: str) -> None: + result = LifecyclePlanModel._parse_iso(iso) + assert isinstance(result, datetime) + + +@then("r2mod-_to_iso with a datetime should return an ISO string") +def step_to_iso_value(context: Any) -> None: + now = datetime.now(tz=UTC) + result = LifecyclePlanModel._to_iso(now) + assert isinstance(result, str) + assert "T" in result diff --git a/features/steps/models_record_coverage_r2_steps.py b/features/steps/models_record_coverage_r2_steps.py new file mode 100644 index 000000000..ea13ac7bd --- /dev/null +++ b/features/steps/models_record_coverage_r2_steps.py @@ -0,0 +1,314 @@ +"""Step definitions for models_record_coverage_r2.feature. + +Provides branch coverage for ``SessionModel``, ``SessionMessageModel``, +``ToolModel``, and ``NamespacedProjectModel`` conversion helpers in +``cleveragents.infrastructure.database.models``. + +All step-text uses an ``r2mod-`` prefix to avoid collisions with +existing step definition files. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any + +from behave import given, then, when +from sqlalchemy.orm import attributes as sa_attr + +from cleveragents.infrastructure.database.models import ( + NamespacedProjectModel, + SessionMessageModel, + SessionModel, + ToolModel, +) + +# =================================================================== +# Helpers +# =================================================================== + +_NOW_ISO: str = datetime.now(tz=UTC).isoformat() + +# Valid ULID constants for use in test models +_ULID_SESS: str = "01HTEST0000000000000SESS01" # invalid; replaced below +_ULID_MSG1: str = "01HTEST0000000000000MSG001" + +# Actually valid ULIDs matching ^[0-9A-HJKMNP-TV-Z]{26}$ +_ULID_SESS = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +_ULID_MSG1 = "01ARZ3NDEKTSV4RRFFQ69G5FA1" + + +def _set_rel_none(model: Any, attr: str) -> None: + """Bypass SQLAlchemy's instrumented setter to force a relationship to None. + + This lets us test the ``or []`` fallback branches in ``to_domain()``. + """ + state = sa_attr.instance_state(model) + state.dict[attr] = None + + +# =================================================================== +# SessionModel.to_domain() +# =================================================================== + + +@given("a r2mod-SessionModel with all JSON fields as None") +def step_session_none_json(context: Any) -> None: + model = SessionModel( + session_id=_ULID_SESS, + actor_name=None, + namespace="local", + linked_plan_ids_json=None, + token_usage_json=None, + metadata_json=None, + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + ) + _set_rel_none(model, "messages_rel") + context.r2_session_model = model + + +@when("I r2mod-convert the SessionModel to domain") +def step_session_to_domain(context: Any) -> None: + context.r2_session_domain = context.r2_session_model.to_domain() + + +@then("the r2mod-session linked_plan_ids should be empty") +def step_session_plan_ids_empty(context: Any) -> None: + assert context.r2_session_domain.linked_plan_ids == [] + + +@then("the r2mod-session token_usage input_tokens should be 0") +def step_session_tokens_zero(context: Any) -> None: + assert context.r2_session_domain.token_usage.input_tokens == 0 + + +@then("the r2mod-session metadata should be empty") +def step_session_meta_empty(context: Any) -> None: + assert context.r2_session_domain.metadata == {} + + +@then("the r2mod-session messages should be empty") +def step_session_messages_empty(context: Any) -> None: + assert context.r2_session_domain.messages == [] + + +# =================================================================== +# SessionMessageModel.to_domain() +# =================================================================== + + +@given("a r2mod-SessionMessageModel with None metadata_json") +def step_msg_none_meta(context: Any) -> None: + context.r2_msg_model = SessionMessageModel( + message_id=_ULID_MSG1, + session_id=_ULID_SESS, + role="user", + content="hello", + sequence=0, + timestamp=_NOW_ISO, + metadata_json=None, + tool_call_id=None, + ) + + +@when("I r2mod-convert the SessionMessageModel to domain") +def step_msg_to_domain(context: Any) -> None: + context.r2_msg_domain = context.r2_msg_model.to_domain() + + +@then("the r2mod-message metadata should be empty") +def step_msg_meta_empty(context: Any) -> None: + assert context.r2_msg_domain.metadata == {} + + +# =================================================================== +# ToolModel.to_domain() / from_domain() +# =================================================================== + + +@given("a r2mod-ToolModel with no resource bindings") +def step_tool_no_bindings(context: Any) -> None: + model = ToolModel( + name="local/test-tool", + namespace="local", + short_name="test-tool", + description="A test tool", + tool_type="tool", + source="builtin", + input_schema_json=None, + output_schema_json=None, + capability_json=None, + lifecycle_json=None, + code=None, + mcp_server=None, + mcp_tool_name=None, + agent_skill_path=None, + timeout=300, + wraps=None, + transform=None, + mode=None, + argument_mapping_json=None, + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + ) + _set_rel_none(model, "resource_bindings_rel") + model.validation_attachments_rel = [] + context.r2_tool_model = model + + +@when("I r2mod-convert the ToolModel to domain") +def step_tool_to_domain(context: Any) -> None: + context.r2_tool_domain = context.r2_tool_model.to_domain() + + +@then("the r2mod-tool resource_bindings should be empty") +def step_tool_bindings_empty(context: Any) -> None: + assert context.r2_tool_domain["resource_bindings"] == [] + + +@when('I r2mod-create ToolModel from dict with name "{name}"') +def step_tool_from_dict(context: Any, name: str) -> None: + context.r2_created_tool = ToolModel.from_domain({"name": name, "description": "d"}) + + +@when('I r2mod-create ToolModel from object with name "{name}"') +def step_tool_from_obj(context: Any, name: str) -> None: + obj = SimpleNamespace(name=name, description="d") + context.r2_created_tool = ToolModel.from_domain(obj) + + +@when("I r2mod-create ToolModel from dict with no resource_bindings") +def step_tool_from_dict_no_bindings(context: Any) -> None: + context.r2_created_tool = ToolModel.from_domain( + {"name": "ns/t", "description": "d", "resource_bindings": None} + ) + + +@then("the r2mod-created ToolModel namespace should be empty string") +def step_tool_ns_empty(context: Any) -> None: + assert context.r2_created_tool.namespace == "" + + +@then('the r2mod-created ToolModel namespace should be "{ns}"') +def step_tool_ns_value(context: Any, ns: str) -> None: + assert context.r2_created_tool.namespace == ns + + +@then('the r2mod-created ToolModel short_name should be "{sn}"') +def step_tool_short_name(context: Any, sn: str) -> None: + assert context.r2_created_tool.short_name == sn + + +@then("the r2mod-created ToolModel resource_bindings_rel should be empty") +def step_tool_created_bindings_empty(context: Any) -> None: + assert len(context.r2_created_tool.resource_bindings_rel) == 0 + + +# =================================================================== +# NamespacedProjectModel.to_domain() +# =================================================================== + + +@given('a r2mod-ProjectModel with no context_policy_json and name "{name}"') +def step_project_no_policy(context: Any, name: str) -> None: + model = NamespacedProjectModel( + namespaced_name=name, + namespace="local", + description=None, + invariants_json=None, + automation_profile=None, + invariant_actor=None, + context_policy_json=None, + tags_json="[]", + created_by=None, + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + ) + model.resource_links = [] + context.r2_project_model = model + + +@given("a r2mod-ProjectModel with None resource_links") +def step_project_none_links(context: Any) -> None: + model = NamespacedProjectModel( + namespaced_name="local/proj", + namespace="local", + description=None, + invariants_json=None, + automation_profile=None, + invariant_actor=None, + context_policy_json=None, + tags_json="[]", + created_by=None, + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + ) + _set_rel_none(model, "resource_links") + context.r2_project_model = model + + +@when("I r2mod-convert the ProjectModel to domain") +def step_project_to_domain(context: Any) -> None: + context.r2_project_domain = context.r2_project_model.to_domain() + + +@then('the r2mod-project name should be "{name}"') +def step_project_name(context: Any, name: str) -> None: + assert context.r2_project_domain.name == name + + +@then("the r2mod-project linked_resources should be empty") +def step_project_links_empty(context: Any) -> None: + assert context.r2_project_domain.linked_resources == [] + + +# =================================================================== +# NamespacedProjectModel.from_domain() +# =================================================================== + + +@when("I r2mod-create ProjectModel from domain with None context_config") +def step_project_from_domain_none_config(context: Any) -> None: + obj = SimpleNamespace( + namespaced_name="local/proj", + namespace="local", + description="test", + context_config=None, + tags=[], + created_at=datetime.now(tz=UTC), + updated_at=datetime.now(tz=UTC), + ) + context.r2_created_project = NamespacedProjectModel.from_domain(obj) + + +@then("the r2mod-created ProjectModel context_policy_json should be None") +def step_project_created_policy_none(context: Any) -> None: + assert context.r2_created_project.context_policy_json is None + + +# =================================================================== +# SessionMessageModel.from_domain() +# =================================================================== + + +@when("I r2mod-create SessionMessageModel from domain with string role") +def step_msg_from_domain_string_role(context: Any) -> None: + """Test the branch where role has no .value attr (plain string).""" + obj = SimpleNamespace( + message_id=_ULID_MSG1, + session_id=_ULID_SESS, + role="user", # plain string, no .value + content="hello", + sequence=0, + timestamp=datetime.now(tz=UTC), + metadata={}, + tool_call_id=None, + ) + context.r2_created_msg = SessionMessageModel.from_domain(obj) + + +@then('the r2mod-created SessionMessageModel role should be "{role}"') +def step_msg_created_role(context: Any, role: str) -> None: + assert context.r2_created_msg.role == role diff --git a/features/steps/models_skill_coverage_r2_steps.py b/features/steps/models_skill_coverage_r2_steps.py new file mode 100644 index 000000000..d5c05895b --- /dev/null +++ b/features/steps/models_skill_coverage_r2_steps.py @@ -0,0 +1,401 @@ +"""Step definitions for models_skill_coverage_r2.feature. + +Provides branch coverage for the ``SkillModel.to_domain()`` / +``SkillModel.from_domain()`` conversion helpers in +``cleveragents.infrastructure.database.models``. + +All step-text uses an ``r2mod-`` prefix to avoid collisions with +existing step definition files. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any + +from behave import given, then, when +from sqlalchemy.orm import attributes as sa_attr + +from cleveragents.infrastructure.database.models import ( + SkillItemModel, + SkillModel, +) + +# =================================================================== +# Helpers +# =================================================================== + +_NOW_ISO: str = datetime.now(tz=UTC).isoformat() + + +def _set_rel_none(model: Any, attr: str) -> None: + """Bypass SQLAlchemy's instrumented setter to force a relationship to None. + + This lets us test the ``or []`` fallback branches in ``to_domain()``. + """ + state = sa_attr.instance_state(model) + state.dict[attr] = None + + +def _make_skill_model( + *, + items: list[SkillItemModel] | None = None, + metadata_json: str | None = None, +) -> SkillModel: + """Create a minimal ``SkillModel`` with controllable items/metadata.""" + model = SkillModel( + name="test/skill", + namespace="test", + short_name="skill", + description="A test skill", + version=None, + metadata_json=metadata_json, + created_at=_NOW_ISO, + updated_at=_NOW_ISO, + ) + if items is not None: + model.items_rel = items + else: + # Force None to test the ``or []`` fallback + _set_rel_none(model, "items_rel") + return model + + +def _make_skill_item( + *, + item_type: str, + item_name: str = "dummy", + item_config: str | None = None, + item_order: int = 0, +) -> SkillItemModel: + """Create a standalone ``SkillItemModel``.""" + return SkillItemModel( + item_type=item_type, + item_name=item_name, + item_config=item_config, + item_order=item_order, + created_at=_NOW_ISO, + ) + + +# =================================================================== +# SkillModel.to_domain() - Given +# =================================================================== + + +@given("a r2mod-SkillModel with no items and no metadata") +def step_skill_no_items(context: Any) -> None: + context.r2_skill_model = _make_skill_model(items=None, metadata_json=None) + + +@given('a r2mod-SkillModel with a tool_ref item named "{name}"') +def step_skill_tool_ref(context: Any, name: str) -> None: + item = _make_skill_item(item_type="tool_ref", item_name=name) + context.r2_skill_model = _make_skill_model(items=[item]) + + +@given('a r2mod-SkillModel with an include item "{name}" with config overrides') +def step_skill_include_with_config(context: Any, name: str) -> None: + config = json.dumps({"overrides": {"timeout": 60}}) + item = _make_skill_item(item_type="include", item_name=name, item_config=config) + context.r2_skill_model = _make_skill_model(items=[item]) + + +@given('a r2mod-SkillModel with an include item "{name}" without config') +def step_skill_include_no_config(context: Any, name: str) -> None: + item = _make_skill_item(item_type="include", item_name=name, item_config=None) + context.r2_skill_model = _make_skill_model(items=[item]) + + +@given("a r2mod-SkillModel with an inline_tool item without config") +def step_skill_inline_no_config(context: Any) -> None: + item = _make_skill_item(item_type="inline_tool", item_name="anon", item_config=None) + context.r2_skill_model = _make_skill_model(items=[item]) + + +@given("a r2mod-SkillModel with an mcp_source item without config") +def step_skill_mcp_no_config(context: Any) -> None: + item = _make_skill_item( + item_type="mcp_source", item_name="server1", item_config=None + ) + context.r2_skill_model = _make_skill_model(items=[item]) + + +@given('a r2mod-SkillModel with an agent_source item "{path}"') +def step_skill_agent_source(context: Any, path: str) -> None: + item = _make_skill_item(item_type="agent_source", item_name=path) + context.r2_skill_model = _make_skill_model(items=[item]) + + +@given("a r2mod-SkillModel with metadata_json containing overrides") +def step_skill_with_metadata(context: Any) -> None: + meta = json.dumps({"overrides": {"local/my-tool": {"timeout": 30}}}) + context.r2_skill_model = _make_skill_model(items=[], metadata_json=meta) + + +@given("a r2mod-SkillModel with inline_tool and mcp_source items with config") +def step_skill_inline_and_mcp_with_config(context: Any) -> None: + inline_config = json.dumps({"description": "inline desc", "source": "custom"}) + mcp_config = json.dumps({"server": "mcp-srv", "tools": None, "env": None}) + items = [ + _make_skill_item( + item_type="inline_tool", + item_name="inline desc", + item_config=inline_config, + item_order=0, + ), + _make_skill_item( + item_type="mcp_source", + item_name="mcp-srv", + item_config=mcp_config, + item_order=1, + ), + ] + context.r2_skill_model = _make_skill_model(items=items) + + +# =================================================================== +# SkillModel.to_domain() - When / Then +# =================================================================== + + +@when("I r2mod-convert the SkillModel to domain") +def step_skill_to_domain(context: Any) -> None: + context.r2_skill_domain = context.r2_skill_model.to_domain() + + +@then("the r2mod-skill tool_refs should be empty") +def step_skill_tool_refs_empty(context: Any) -> None: + assert context.r2_skill_domain.tool_refs == [] + + +@then('the r2mod-skill tool_refs should contain "{name}"') +def step_skill_tool_refs_contains(context: Any, name: str) -> None: + assert name in context.r2_skill_domain.tool_refs + + +@then("the r2mod-skill includes should be empty") +def step_skill_includes_empty(context: Any) -> None: + assert context.r2_skill_domain.includes == [] + + +@then("the r2mod-skill includes should have {n:d} entry") +def step_skill_includes_count(context: Any, n: int) -> None: + assert len(context.r2_skill_domain.includes) == n + + +@then('the r2mod-skill first include name should be "{name}"') +def step_skill_first_include_name(context: Any, name: str) -> None: + assert context.r2_skill_domain.includes[0].name == name + + +@then("the r2mod-skill first include overrides should not be None") +def step_skill_first_include_overrides_not_none(context: Any) -> None: + assert context.r2_skill_domain.includes[0].overrides is not None + + +@then("the r2mod-skill first include overrides should be None") +def step_skill_first_include_overrides_none(context: Any) -> None: + assert context.r2_skill_domain.includes[0].overrides is None + + +@then("the r2mod-skill anonymous_tools should be empty") +def step_skill_anon_empty(context: Any) -> None: + assert context.r2_skill_domain.anonymous_tools == [] + + +@then("the r2mod-skill anonymous_tools should have {n:d} entry") +def step_skill_anon_count(context: Any, n: int) -> None: + assert len(context.r2_skill_domain.anonymous_tools) == n + + +@then("the r2mod-skill mcp_servers should be empty") +def step_skill_mcp_empty(context: Any) -> None: + assert context.r2_skill_domain.mcp_servers == [] + + +@then("the r2mod-skill mcp_servers should have {n:d} entry") +def step_skill_mcp_count(context: Any, n: int) -> None: + assert len(context.r2_skill_domain.mcp_servers) == n + + +@then("the r2mod-skill agent_skills should be empty") +def step_skill_agents_empty(context: Any) -> None: + assert context.r2_skill_domain.agent_skills == [] + + +@then("the r2mod-skill agent_skills should have {n:d} entry") +def step_skill_agents_count(context: Any, n: int) -> None: + assert len(context.r2_skill_domain.agent_skills) == n + + +@then('the r2mod-skill first agent_skill path should be "{path}"') +def step_skill_first_agent_path(context: Any, path: str) -> None: + assert context.r2_skill_domain.agent_skills[0].path == path + + +@then("the r2mod-skill overrides should be empty") +def step_skill_overrides_empty(context: Any) -> None: + assert context.r2_skill_domain.overrides == {} + + +@then("the r2mod-skill overrides should not be empty") +def step_skill_overrides_not_empty(context: Any) -> None: + assert context.r2_skill_domain.overrides != {} + + +# =================================================================== +# SkillModel.from_domain() - When / Then +# =================================================================== + + +@when('I r2mod-attempt from_domain on SkillModel with name "{name}"') +def step_skill_from_domain_invalid(context: Any, name: str) -> None: + try: + SkillModel.from_domain({"name": name, "description": "d"}) + context.r2_error = None + except ValueError as exc: + context.r2_error = exc + + +@then("a r2mod-ValueError should have been raised") +def step_r2_valueerror(context: Any) -> None: + assert context.r2_error is not None + assert isinstance(context.r2_error, ValueError) + + +@when('I r2mod-create SkillModel from domain with name "{name}" and no overrides') +def step_skill_from_domain_no_overrides(context: Any, name: str) -> None: + context.r2_created_skill = SkillModel.from_domain( + {"name": name, "description": "d", "overrides": {}} + ) + + +@when('I r2mod-create SkillModel from domain with name "{name}" and overrides') +def step_skill_from_domain_with_overrides(context: Any, name: str) -> None: + context.r2_created_skill = SkillModel.from_domain( + { + "name": name, + "description": "d", + "overrides": {"local/tool": {"timeout": 10}}, + } + ) + + +@then("the r2mod-created SkillModel metadata_json should be None") +def step_skill_created_meta_none(context: Any) -> None: + assert context.r2_created_skill.metadata_json is None + + +@then('the r2mod-created SkillModel metadata_json should contain "overrides"') +def step_skill_created_meta_overrides(context: Any) -> None: + assert context.r2_created_skill.metadata_json is not None + assert "overrides" in context.r2_created_skill.metadata_json + + +@when("I r2mod-create SkillModel from domain with includes as strings") +def step_skill_from_domain_string_includes(context: Any) -> None: + context.r2_created_skill = SkillModel.from_domain( + { + "name": "ns/sk", + "description": "d", + "includes": ["included-skill"], + } + ) + + +@then("the r2mod-created SkillModel should have include items") +def step_skill_has_include_items(context: Any) -> None: + include_items = [ + i for i in context.r2_created_skill.items_rel if i.item_type == "include" + ] + assert len(include_items) > 0 + + +@when("I r2mod-create SkillModel from domain with anonymous_tools as dicts") +def step_skill_from_domain_anon_dicts(context: Any) -> None: + context.r2_created_skill = SkillModel.from_domain( + { + "name": "ns/sk", + "description": "d", + "anonymous_tools": [ + {"description": "dict tool", "source": "custom"}, + ], + } + ) + + +@then("the r2mod-created SkillModel should have inline_tool items") +def step_skill_has_inline_items(context: Any) -> None: + inline_items = [ + i for i in context.r2_created_skill.items_rel if i.item_type == "inline_tool" + ] + assert len(inline_items) > 0 + + +@when("I r2mod-create SkillModel from domain with mcp_servers as dicts") +def step_skill_from_domain_mcp_dicts(context: Any) -> None: + context.r2_created_skill = SkillModel.from_domain( + { + "name": "ns/sk", + "description": "d", + "mcp_servers": [ + {"server": "my-mcp", "tools": None, "env": None}, + ], + } + ) + + +@then("the r2mod-created SkillModel should have mcp_source items") +def step_skill_has_mcp_items(context: Any) -> None: + mcp_items = [ + i for i in context.r2_created_skill.items_rel if i.item_type == "mcp_source" + ] + assert len(mcp_items) > 0 + + +@when("I r2mod-create SkillModel from domain with agent_skills as strings") +def step_skill_from_domain_agent_strings(context: Any) -> None: + context.r2_created_skill = SkillModel.from_domain( + { + "name": "ns/sk", + "description": "d", + "agent_skills": ["path/to/agent"], + } + ) + + +@then("the r2mod-created SkillModel should have agent_source items") +def step_skill_has_agent_items(context: Any) -> None: + agent_items = [ + i for i in context.r2_created_skill.items_rel if i.item_type == "agent_source" + ] + assert len(agent_items) > 0 + + +@when("I r2mod-create SkillModel from domain with anonymous_tools as plain objects") +def step_skill_from_domain_anon_plain_obj(context: Any) -> None: + """Test the fallback else branch (not model_dump, not dict).""" + obj = SimpleNamespace(description="plain obj tool", source="custom") + context.r2_created_skill = SkillModel.from_domain( + { + "name": "ns/sk", + "description": "d", + "anonymous_tools": [obj], + } + ) + + +@when("I r2mod-create SkillModel from domain with mcp_servers as plain objects") +def step_skill_from_domain_mcp_plain_obj(context: Any) -> None: + """Test the fallback else branch (not model_dump, not dict).""" + obj = SimpleNamespace(server="my-mcp") + context.r2_created_skill = SkillModel.from_domain( + { + "name": "ns/sk", + "description": "d", + "mcp_servers": [obj], + } + ) diff --git a/features/steps/plan_cli_commands_r2_steps.py b/features/steps/plan_cli_commands_r2_steps.py new file mode 100644 index 000000000..ae3c652ba --- /dev/null +++ b/features/steps/plan_cli_commands_r2_steps.py @@ -0,0 +1,475 @@ +"""Step definitions for plan_cli_commands_r2.feature. + +Targets remaining partial branches in +``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 2 of 3. + +Covers: +- ``use_action`` argument parsing: int/float/bool/string, missing '=', + invalid automation profile, invalid actor overrides +- ``execute_plan`` auto-resolve: 0 plans / >1 plans / exactly 1 +- ``lifecycle_apply_plan`` auto-resolve: same subcases +- ``lifecycle_list_plans``: invalid phase/state, empty result, project truncation +- ``revert_plan``: invalid to-phase, non-rich format +- ``plan_status``: no plans, non-rich list, non-rich single plan +- ``correct_decision``: invalid mode, empty guidance +- ``plan_diff``: correction flag + +All step text uses the ``r2plan-`` prefix to avoid collisions. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from typer.testing import CliRunner + +from cleveragents.cli.commands import plan as plan_module +from cleveragents.cli.commands.plan import app as plan_app +from cleveragents.domain.models.core.plan import ( + AutomationProfileRef, + NamespacedName, + Plan, + PlanIdentity, + PlanInvariant, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F" +_runner = CliRunner() + + +def _ulid(suffix: str = "A1") -> str: + """Return a valid 26-char ULID for tests. + + ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U). + """ + # Map potentially invalid chars to valid Crockford Base32 + cleaned = ( + suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V") + ) + base = _ULID_BASE + cleaned + return base[:26] + + +def _make_plan( + *, + plan_id: str | None = None, + name: str = "local/r2-plan", + description: str = "Test plan for r2 coverage", + phase: PlanPhase = PlanPhase.STRATEGIZE, + processing_state: ProcessingState = ProcessingState.QUEUED, + project_links: list[ProjectLink] | None = None, + automation_profile: AutomationProfileRef | None = None, + invariants: list[PlanInvariant] | None = None, + validation_summary: dict[str, Any] | None = None, + error_message: str | None = None, + last_completed_step: int = -1, + last_checkpoint_id: str | None = None, + definition_of_done: str | None = None, + arguments: dict[str, Any] | None = None, + arguments_order: list[str] | None = None, + estimation_actor: str | None = None, + invariant_actor: str | None = None, + timestamps: PlanTimestamps | None = None, + action_name: str = "local/test-action", +) -> Plan: + if timestamps is None: + timestamps = PlanTimestamps( + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return Plan( + identity=PlanIdentity(plan_id=plan_id or _ulid("A1")), + namespaced_name=NamespacedName.parse(name), + action_name=action_name, + description=description, + definition_of_done=definition_of_done, + phase=phase, + processing_state=processing_state, + strategy_actor=None, + execution_actor=None, + project_links=project_links or [], + automation_profile=automation_profile, + invariants=invariants or [], + validation_summary=validation_summary, + error_message=error_message, + last_completed_step=last_completed_step, + last_checkpoint_id=last_checkpoint_id, + arguments=arguments or {}, + arguments_order=arguments_order or [], + estimation_actor=estimation_actor, + invariant_actor=invariant_actor, + timestamps=timestamps, + created_by=None, + reusable=True, + read_only=False, + ) + + +# --------------------------------------------------------------------------- +# Given steps - mocked lifecycle service (shared for CLI command scenarios) +# --------------------------------------------------------------------------- + + +@given("r2plan-a mocked lifecycle service") +def step_mocked_lifecycle(context: Any) -> None: + context.r2_mock_svc = MagicMock() + context.r2_cleanups = [] # list[Any] + + # Patch the lifecycle service getter + p = patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=context.r2_mock_svc, + ) + p.start() + context.r2_cleanups.append(p.stop) + + # Replace the module-level console with a wider one for table tests + from rich.console import Console as RichConsole + + wide_console = RichConsole(width=200) + original_console = plan_module.console + plan_module.console = wide_console + + def _restore_console() -> None: + plan_module.console = original_console + + context.r2_cleanups.append(_restore_console) + + # Default: get_action_by_name returns a mock action + action = MagicMock() + action.namespaced_name = "local/test" + context.r2_mock_svc.get_action_by_name.return_value = action + + # Default: use_action returns a plan (needed for use_action scenarios) + context.r2_use_plan = _make_plan() + context.r2_mock_svc.use_action.return_value = context.r2_use_plan + + # Register cleanup + if not hasattr(context, "_r2_after_scenario"): + + def _cleanup(ctx: Any) -> None: + for c in getattr(ctx, "r2_cleanups", []): + c() + + context.add_cleanup(_cleanup, context) + + +@given("r2plan-the service lists no complete strategize plans") +def step_no_strategize_plans(context: Any) -> None: + context.r2_mock_svc.list_plans.return_value = [] + + +@given("r2plan-the service lists multiple complete strategize plans") +def step_multi_strategize_plans(context: Any) -> None: + p1 = _make_plan( + plan_id=_ulid("B1"), + processing_state=ProcessingState.COMPLETE, + ) + p2 = _make_plan( + plan_id=_ulid("B2"), + processing_state=ProcessingState.COMPLETE, + ) + context.r2_mock_svc.list_plans.return_value = [p1, p2] + + +@given("r2plan-the service lists exactly one complete strategize plan") +def step_one_strategize_plan(context: Any) -> None: + p = _make_plan( + plan_id=_ulid("C1"), + processing_state=ProcessingState.COMPLETE, + ) + context.r2_mock_svc.list_plans.return_value = [p] + context.r2_mock_svc.execute_plan.return_value = p + + +@given("r2plan-the service lists no complete execute plans") +def step_no_execute_plans(context: Any) -> None: + context.r2_mock_svc.list_plans.return_value = [] + + +@given("r2plan-the service lists multiple complete execute plans") +def step_multi_execute_plans(context: Any) -> None: + p1 = _make_plan( + plan_id=_ulid("D1"), + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.COMPLETE, + ) + p2 = _make_plan( + plan_id=_ulid("D2"), + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.COMPLETE, + ) + context.r2_mock_svc.list_plans.return_value = [p1, p2] + + +@given("r2plan-the service lists exactly one complete execute plan") +def step_one_execute_plan(context: Any) -> None: + p = _make_plan( + plan_id=_ulid("E1"), + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.COMPLETE, + ) + context.r2_mock_svc.list_plans.return_value = [p] + context.r2_mock_svc.apply_plan.return_value = p + + +@given("r2plan-the service lists no plans") +def step_no_plans(context: Any) -> None: + context.r2_mock_svc.list_plans.return_value = [] + + +@given("r2plan-the service lists some plans") +def step_some_plans(context: Any) -> None: + plans = [_make_plan(plan_id=_ulid("F1"))] + context.r2_mock_svc.list_plans.return_value = plans + + +@given("r2plan-the service can get a plan by id") +def step_get_plan_by_id(context: Any) -> None: + plan = _make_plan(plan_id=_ulid("G1")) + context.r2_mock_svc.get_plan.return_value = plan + context.r2_plan_id = _ulid("G1") + + +@given("r2plan-the service can revert a plan") +def step_service_can_revert(context: Any) -> None: + plan = _make_plan(plan_id=_ulid("H1")) + context.r2_mock_svc.revert_plan.return_value = plan + + +@given("r2plan-the service lists a plan with 4 project links") +def step_plan_with_4_links(context: Any) -> None: + links = [ProjectLink(project_name=f"local/proj-{i}") for i in range(4)] + p = _make_plan(plan_id=_ulid("I1"), project_links=links) + context.r2_mock_svc.list_plans.return_value = [p] + + +# --------------------------------------------------------------------------- +# When steps - CLI use_action +# --------------------------------------------------------------------------- + + +@when('r2plan-I invoke use with action "{action}" and arg "{arg_str}"') +def step_invoke_use_with_arg(context: Any, action: str, arg_str: str) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["use", action, "--arg", arg_str], + ) + # Capture the arguments passed to use_action for inspection + if context.r2_mock_svc.use_action.called: + call_kwargs = context.r2_mock_svc.use_action.call_args + context.r2_parsed_args = ( + call_kwargs.kwargs.get("arguments", call_kwargs[1].get("arguments", {})) + if call_kwargs + else {} + ) + else: + context.r2_parsed_args = {} + + +@when('r2plan-I invoke use with action "{action}" and automation profile "{profile}"') +def step_invoke_use_with_profile(context: Any, action: str, profile: str) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["use", action, "--automation-profile", profile], + ) + + +@when('r2plan-I invoke use with action "{action}" and strategy actor "{actor}"') +def step_invoke_use_strategy_actor(context: Any, action: str, actor: str) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["use", action, "--strategy-actor", actor], + ) + + +@when('r2plan-I invoke use with action "{action}" and execution actor "{actor}"') +def step_invoke_use_execution_actor(context: Any, action: str, actor: str) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["use", action, "--execution-actor", actor], + ) + + +# --------------------------------------------------------------------------- +# When steps - CLI execute / lifecycle-apply +# --------------------------------------------------------------------------- + + +@when("r2plan-I invoke execute without plan_id") +def step_invoke_execute_no_id(context: Any) -> None: + context.r2_result = _runner.invoke(plan_app, ["execute"]) + + +@when("r2plan-I invoke lifecycle-apply without plan_id") +def step_invoke_apply_no_id(context: Any) -> None: + context.r2_result = _runner.invoke(plan_app, ["lifecycle-apply"]) + + +# --------------------------------------------------------------------------- +# When steps - CLI lifecycle-list +# --------------------------------------------------------------------------- + + +@when('r2plan-I invoke lifecycle-list with phase "{phase}"') +def step_invoke_list_phase(context: Any, phase: str) -> None: + context.r2_result = _runner.invoke(plan_app, ["lifecycle-list", "--phase", phase]) + + +@when('r2plan-I invoke lifecycle-list with state "{state}"') +def step_invoke_list_state(context: Any, state: str) -> None: + context.r2_result = _runner.invoke(plan_app, ["lifecycle-list", "--state", state]) + + +@when("r2plan-I invoke lifecycle-list") +def step_invoke_lifecycle_list(context: Any) -> None: + context.r2_result = _runner.invoke(plan_app, ["lifecycle-list"]) + + +# --------------------------------------------------------------------------- +# When steps - CLI revert +# --------------------------------------------------------------------------- + + +@when('r2plan-I invoke revert with plan "{pid}" and invalid phase "{phase}"') +def step_invoke_revert_invalid(context: Any, pid: str, phase: str) -> None: + context.r2_result = _runner.invoke(plan_app, ["revert", pid, "--to-phase", phase]) + + +@when('r2plan-I invoke revert with plan "{pid}" and format "{fmt}"') +def step_invoke_revert_json(context: Any, pid: str, fmt: str) -> None: + context.r2_result = _runner.invoke(plan_app, ["revert", pid, "--format", fmt]) + + +# --------------------------------------------------------------------------- +# When steps - CLI status +# --------------------------------------------------------------------------- + + +@when("r2plan-I invoke status without plan_id") +def step_invoke_status_no_id(context: Any) -> None: + context.r2_result = _runner.invoke(plan_app, ["status"]) + + +@when('r2plan-I invoke status without plan_id and format "{fmt}"') +def step_invoke_status_no_id_fmt(context: Any, fmt: str) -> None: + context.r2_result = _runner.invoke(plan_app, ["status", "--format", fmt]) + + +@when('r2plan-I invoke status with plan_id and format "{fmt}"') +def step_invoke_status_with_id_fmt(context: Any, fmt: str) -> None: + pid = getattr(context, "r2_plan_id", _ulid("G1")) + context.r2_result = _runner.invoke(plan_app, ["status", pid, "--format", fmt]) + + +# --------------------------------------------------------------------------- +# When steps - CLI correct +# --------------------------------------------------------------------------- + + +@when('r2plan-I invoke correct with mode "{mode}"') +def step_invoke_correct_bad_mode(context: Any, mode: str) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["correct", "DEC-001", "--mode", mode, "--guidance", "fix it", "--yes"], + ) + + +@when("r2plan-I invoke correct with empty guidance") +def step_invoke_correct_empty_guidance(context: Any) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["correct", "DEC-001", "--mode", "revert", "--guidance", "", "--yes"], + ) + + +# --------------------------------------------------------------------------- +# When steps - CLI diff +# --------------------------------------------------------------------------- + + +@when('r2plan-I invoke diff with plan "{pid}" and correction "{corr}"') +def step_invoke_diff_correction(context: Any, pid: str, corr: str) -> None: + context.r2_result = _runner.invoke( + plan_app, + ["diff", pid, "--correction", corr], + ) + + +# --------------------------------------------------------------------------- +# Then steps - CLI command assertions +# --------------------------------------------------------------------------- + + +@then("r2plan-the command should abort") +def step_cmd_abort(context: Any) -> None: + assert context.r2_result.exit_code != 0, ( + f"Expected abort but got exit code {context.r2_result.exit_code}.\n" + f"Output: {context.r2_result.output}" + ) + + +@then("r2plan-the command should succeed") +def step_cmd_succeed(context: Any) -> None: + assert context.r2_result.exit_code == 0, ( + f"Expected success but got exit code {context.r2_result.exit_code}.\n" + f"Output: {context.r2_result.output}" + ) + + +@then('r2plan-the output should contain "{text}"') +def step_cli_output_contains(context: Any, text: str) -> None: + output = context.r2_result.output + assert text in output, f"Expected '{text}' in output:\n{output}" + + +# -- use_action argument parsing assertions -- + + +@then('r2plan-the parsed arguments should have "{key}" as int {val:d}') +def step_arg_int(context: Any, key: str, val: int) -> None: + call_args = context.r2_mock_svc.use_action.call_args + args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {} + assert args.get(key) == val, f"Expected {key}={val}, got {args.get(key)}" + assert isinstance(args[key], int) + + +@then('r2plan-the parsed arguments should have "{key}" as float {val:g}') +def step_arg_float(context: Any, key: str, val: float) -> None: + call_args = context.r2_mock_svc.use_action.call_args + args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {} + assert args.get(key) == val, f"Expected {key}={val}, got {args.get(key)}" + assert isinstance(args[key], float) + + +@then('r2plan-the parsed arguments should have "{key}" as bool true') +def step_arg_bool_true(context: Any, key: str) -> None: + call_args = context.r2_mock_svc.use_action.call_args + args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {} + assert args.get(key) is True, f"Expected {key}=True, got {args.get(key)}" + + +@then('r2plan-the parsed arguments should have "{key}" as bool false') +def step_arg_bool_false(context: Any, key: str) -> None: + call_args = context.r2_mock_svc.use_action.call_args + args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {} + assert args.get(key) is False, f"Expected {key}=False, got {args.get(key)}" + + +@then('r2plan-the parsed arguments should have "{key}" as string "{val}"') +def step_arg_string(context: Any, key: str, val: str) -> None: + call_args = context.r2_mock_svc.use_action.call_args + args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {} + assert args.get(key) == val, f"Expected {key}='{val}', got {args.get(key)}" + assert isinstance(args[key], str) diff --git a/features/steps/plan_cli_legacy_r2_steps.py b/features/steps/plan_cli_legacy_r2_steps.py new file mode 100644 index 000000000..f7aeddd57 --- /dev/null +++ b/features/steps/plan_cli_legacy_r2_steps.py @@ -0,0 +1,442 @@ +"""Step definitions for plan_cli_legacy_r2.feature. + +Targets remaining partial branches in +``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 3 of 3. + +Covers: +- Legacy wrappers: no-project branch, continue_command prompt/no-prompt +- ``_resolve_active_plan_id``: no active plans, service error +- ``build_command``: None changes -> empty list +- ``list_command``: None plans -> empty list + +All step text uses the ``r2plan-`` prefix to avoid collisions. +""" + +from __future__ import annotations + +import warnings +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import typer +from behave import then, when +from typer.testing import CliRunner + +from cleveragents.cli.commands.plan import ( + _resolve_active_plan_id, +) +from cleveragents.core.exceptions import CleverAgentsError +from cleveragents.domain.models.core.plan import ( + AutomationProfileRef, + NamespacedName, + Plan, + PlanIdentity, + PlanInvariant, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F" +_runner = CliRunner() + + +def _ulid(suffix: str = "A1") -> str: + """Return a valid 26-char ULID for tests. + + ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U). + """ + # Map potentially invalid chars to valid Crockford Base32 + cleaned = ( + suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V") + ) + base = _ULID_BASE + cleaned + return base[:26] + + +def _make_plan( + *, + plan_id: str | None = None, + name: str = "local/r2-plan", + description: str = "Test plan for r2 coverage", + phase: PlanPhase = PlanPhase.STRATEGIZE, + processing_state: ProcessingState = ProcessingState.QUEUED, + project_links: list[ProjectLink] | None = None, + automation_profile: AutomationProfileRef | None = None, + invariants: list[PlanInvariant] | None = None, + validation_summary: dict[str, Any] | None = None, + error_message: str | None = None, + last_completed_step: int = -1, + last_checkpoint_id: str | None = None, + definition_of_done: str | None = None, + arguments: dict[str, Any] | None = None, + arguments_order: list[str] | None = None, + estimation_actor: str | None = None, + invariant_actor: str | None = None, + timestamps: PlanTimestamps | None = None, + action_name: str = "local/test-action", +) -> Plan: + if timestamps is None: + timestamps = PlanTimestamps( + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return Plan( + identity=PlanIdentity(plan_id=plan_id or _ulid("A1")), + namespaced_name=NamespacedName.parse(name), + action_name=action_name, + description=description, + definition_of_done=definition_of_done, + phase=phase, + processing_state=processing_state, + strategy_actor=None, + execution_actor=None, + project_links=project_links or [], + automation_profile=automation_profile, + invariants=invariants or [], + validation_summary=validation_summary, + error_message=error_message, + last_completed_step=last_completed_step, + last_checkpoint_id=last_checkpoint_id, + arguments=arguments or {}, + arguments_order=arguments_order or [], + estimation_actor=estimation_actor, + invariant_actor=invariant_actor, + timestamps=timestamps, + created_by=None, + reusable=True, + read_only=False, + ) + + +def _mock_container(project_exists: bool = True) -> MagicMock: + """Build a mock container for legacy wrapper tests.""" + container = MagicMock() + project_service = MagicMock() + plan_service = MagicMock() + if project_exists: + project_service.get_current_project.return_value = SimpleNamespace(name="proj") + else: + project_service.get_current_project.return_value = None + container.project_service.return_value = project_service + container.plan_service.return_value = plan_service + return container + + +# --------------------------------------------------------------------------- +# When steps - legacy programmatic wrappers (no project) +# --------------------------------------------------------------------------- + + +@when("r2plan-I call tell_command with no project") +def step_tell_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import tell_command + + tell_command("do stuff") + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call build_command with no project") +def step_build_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import build_command + + build_command() + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call apply_command with no project") +def step_apply_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import apply_command + + apply_command() + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call new_command with no project") +def step_new_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import new_command + + new_command("test") + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call current_command with no project") +def step_current_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import current_command + + current_command() + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call list_command with no project") +def step_list_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import list_command + + list_command() + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call cd_command with no project") +def step_cd_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import cd_command + + cd_command("some-plan") + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +@when("r2plan-I call continue_command with no project") +def step_continue_no_project(context: Any) -> None: + container = _mock_container(project_exists=False) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import continue_command + + continue_command() + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +# --------------------------------------------------------------------------- +# When steps - continue_command with prompt / no-prompt +# --------------------------------------------------------------------------- + + +@when('r2plan-I call continue_command with prompt "{prompt}"') +def step_continue_with_prompt(context: Any, prompt: str) -> None: + container = _mock_container(project_exists=True) + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + from cleveragents.cli.commands.plan import continue_command + + continue_command(prompt=prompt) + context.r2_plan_service = container.plan_service() + + +@when("r2plan-I call continue_command with no prompt and no current plan") +def step_continue_no_prompt_no_plan(context: Any) -> None: + container = _mock_container(project_exists=True) + plan_service = container.plan_service() + plan_service.get_current_plan.return_value = None + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + try: + from cleveragents.cli.commands.plan import continue_command + + continue_command(prompt=None) + context.r2_error = None + except CleverAgentsError as exc: + context.r2_error = exc + + +# --------------------------------------------------------------------------- +# When steps - build_command returns None / list_command returns None +# --------------------------------------------------------------------------- + + +@when("r2plan-I call build_command with build returning None") +def step_build_returns_none(context: Any) -> None: + container = _mock_container(project_exists=True) + plan_service = container.plan_service() + plan_service.build_plan.return_value = None + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + from cleveragents.cli.commands.plan import build_command + + context.r2_build_result = build_command() + + +@when("r2plan-I call list_command with list returning None") +def step_list_returns_none(context: Any) -> None: + container = _mock_container(project_exists=True) + plan_service = container.plan_service() + plan_service.list_plans.return_value = None + with ( + patch( + "cleveragents.application.container.get_container", return_value=container + ), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", DeprecationWarning) + from cleveragents.cli.commands.plan import list_command + + context.r2_list_result = list_command() + + +# --------------------------------------------------------------------------- +# When steps - _resolve_active_plan_id +# --------------------------------------------------------------------------- + + +@when("r2plan-I call _resolve_active_plan_id with no active plans") +def step_resolve_no_active(context: Any) -> None: + mock_svc = MagicMock() + # All plans are terminal + p = _make_plan(processing_state=ProcessingState.APPLIED) + mock_svc.list_plans.return_value = [p] + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=mock_svc, + ): + try: + _resolve_active_plan_id() + context.r2_error = None + except typer.Abort: + context.r2_error = typer.Abort() + + +@when("r2plan-I call _resolve_active_plan_id with service error") +def step_resolve_service_error(context: Any) -> None: + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + side_effect=CleverAgentsError("service unavailable"), + ): + try: + _resolve_active_plan_id() + context.r2_error = None + except typer.Abort: + context.r2_error = typer.Abort() + + +# --------------------------------------------------------------------------- +# Then steps - legacy wrapper assertions +# --------------------------------------------------------------------------- + + +@then('r2plan-a CleverAgentsError should be raised with message "{fragment}"') +def step_agents_error(context: Any, fragment: str) -> None: + assert context.r2_error is not None, "Expected CleverAgentsError but none raised" + assert isinstance(context.r2_error, CleverAgentsError), ( + f"Expected CleverAgentsError, got {type(context.r2_error).__name__}" + ) + assert fragment in context.r2_error.message, ( + f"Expected '{fragment}' in '{context.r2_error.message}'" + ) + + +@then("r2plan-the continue_plan service method should be called") +def step_continue_called(context: Any) -> None: + context.r2_plan_service.continue_plan.assert_called_once() + + +@then("r2plan-the build result should be an empty list") +def step_build_empty(context: Any) -> None: + assert context.r2_build_result == [], f"Expected [], got {context.r2_build_result}" + + +@then("r2plan-the list result should be an empty list") +def step_list_empty(context: Any) -> None: + assert context.r2_list_result == [], f"Expected [], got {context.r2_list_result}" + + +@then("r2plan-a typer Abort should be raised") +def step_typer_abort(context: Any) -> None: + assert context.r2_error is not None, "Expected typer.Abort but none raised" + assert isinstance(context.r2_error, typer.Abort), ( + f"Expected typer.Abort, got {type(context.r2_error).__name__}" + ) diff --git a/features/steps/plan_cli_spec_print_r2_steps.py b/features/steps/plan_cli_spec_print_r2_steps.py new file mode 100644 index 000000000..f1dd853e5 --- /dev/null +++ b/features/steps/plan_cli_spec_print_r2_steps.py @@ -0,0 +1,396 @@ +"""Step definitions for plan_cli_spec_print_r2.feature. + +Targets remaining partial branches in +``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 1 of 3. + +Covers: +- ``_plan_spec_dict``: project link alias/read_only, automation_profile, + invariants, validation_summary/dod, last_completed_step, last_checkpoint_id +- ``_print_lifecycle_plan``: definition_of_done, dod evaluation pass/fail, + arguments with/without order, automation profile, invariants, resume + metadata, project link alias/read_only, long description + +All step text uses the ``r2plan-`` prefix to avoid collisions. +""" + +from __future__ import annotations + +from datetime import datetime +from io import StringIO +from typing import Any +from unittest.mock import patch + +from behave import given, then, when +from typer.testing import CliRunner + +from cleveragents.cli.commands import plan as plan_module +from cleveragents.cli.commands.plan import ( + _plan_spec_dict, + _print_lifecycle_plan, +) +from cleveragents.domain.models.core.plan import ( + AutomationProfileProvenance, + AutomationProfileRef, + InvariantSource, + NamespacedName, + Plan, + PlanIdentity, + PlanInvariant, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F" +_runner = CliRunner() + + +def _ulid(suffix: str = "A1") -> str: + """Return a valid 26-char ULID for tests. + + ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U). + """ + # Map potentially invalid chars to valid Crockford Base32 + cleaned = ( + suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V") + ) + base = _ULID_BASE + cleaned + return base[:26] + + +def _make_plan( + *, + plan_id: str | None = None, + name: str = "local/r2-plan", + description: str = "Test plan for r2 coverage", + phase: PlanPhase = PlanPhase.STRATEGIZE, + processing_state: ProcessingState = ProcessingState.QUEUED, + project_links: list[ProjectLink] | None = None, + automation_profile: AutomationProfileRef | None = None, + invariants: list[PlanInvariant] | None = None, + validation_summary: dict[str, Any] | None = None, + error_message: str | None = None, + last_completed_step: int = -1, + last_checkpoint_id: str | None = None, + definition_of_done: str | None = None, + arguments: dict[str, Any] | None = None, + arguments_order: list[str] | None = None, + estimation_actor: str | None = None, + invariant_actor: str | None = None, + timestamps: PlanTimestamps | None = None, + action_name: str = "local/test-action", +) -> Plan: + if timestamps is None: + timestamps = PlanTimestamps( + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return Plan( + identity=PlanIdentity(plan_id=plan_id or _ulid("A1")), + namespaced_name=NamespacedName.parse(name), + action_name=action_name, + description=description, + definition_of_done=definition_of_done, + phase=phase, + processing_state=processing_state, + strategy_actor=None, + execution_actor=None, + project_links=project_links or [], + automation_profile=automation_profile, + invariants=invariants or [], + validation_summary=validation_summary, + error_message=error_message, + last_completed_step=last_completed_step, + last_checkpoint_id=last_checkpoint_id, + arguments=arguments or {}, + arguments_order=arguments_order or [], + estimation_actor=estimation_actor, + invariant_actor=invariant_actor, + timestamps=timestamps, + created_by=None, + reusable=True, + read_only=False, + ) + + +def _capture_print(plan: Any) -> str: + """Call _print_lifecycle_plan and capture the console output.""" + buf = StringIO() + from rich.console import Console as RichConsole + + test_console = RichConsole(file=buf, width=200, no_color=True) + with patch.object(plan_module, "console", test_console): + _print_lifecycle_plan(plan, title="R2 Test") + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given('r2plan-a v3 Plan with a project link that has alias "{alias}"') +def step_plan_link_alias(context: Any, alias: str) -> None: + link = ProjectLink(project_name="local/api", alias=alias) + context.r2_plan = _make_plan(project_links=[link]) + + +@given("r2plan-a v3 Plan with a project link that is read_only") +def step_plan_link_readonly(context: Any) -> None: + link = ProjectLink(project_name="local/docs", read_only=True) + context.r2_plan = _make_plan(project_links=[link]) + + +@given("r2plan-a v3 Plan with a plain project link") +def step_plan_link_plain(context: Any) -> None: + link = ProjectLink(project_name="local/plain") + context.r2_plan = _make_plan(project_links=[link]) + + +@given('r2plan-a v3 Plan with automation_profile "{profile}"') +def step_plan_with_automation_profile(context: Any, profile: str) -> None: + ap = AutomationProfileRef( + profile_name=profile, + provenance=AutomationProfileProvenance.PLAN, + ) + context.r2_plan = _make_plan(automation_profile=ap) + + +@given("r2plan-a v3 Plan without automation_profile") +def step_plan_without_automation_profile(context: Any) -> None: + context.r2_plan = _make_plan(automation_profile=None) + + +@given("r2plan-a v3 Plan with invariants") +def step_plan_with_invariants(context: Any) -> None: + invs = [ + PlanInvariant(text="No new warnings", source=InvariantSource.PLAN), + PlanInvariant(text="Coverage >= 80%", source=InvariantSource.ACTION), + ] + context.r2_plan = _make_plan(invariants=invs) + + +@given("r2plan-a v3 Plan without invariants") +def step_plan_without_invariants(context: Any) -> None: + context.r2_plan = _make_plan(invariants=[]) + + +@given("r2plan-a v3 Plan with dod validation summary") +def step_plan_with_dod_validation(context: Any) -> None: + vs = { + "dod_evaluated": True, + "dod_all_passed": True, + "required_passed": 3, + "required_failed": 0, + } + context.r2_plan = _make_plan(validation_summary=vs) + + +@given("r2plan-a v3 Plan without validation_summary") +def step_plan_without_validation_summary(context: Any) -> None: + context.r2_plan = _make_plan(validation_summary=None) + + +@given("r2plan-a v3 Plan with last_completed_step {n:d}") +def step_plan_with_step(context: Any, n: int) -> None: + context.r2_plan = _make_plan(last_completed_step=n) + + +@given("r2plan-a v3 Plan with last_completed_step default") +def step_plan_with_step_default(context: Any) -> None: + context.r2_plan = _make_plan(last_completed_step=-1) + + +@given('r2plan-a v3 Plan with last_checkpoint_id "{chk}"') +def step_plan_with_checkpoint(context: Any, chk: str) -> None: + context.r2_plan = _make_plan(last_checkpoint_id=chk) + + +@given("r2plan-a v3 Plan without last_checkpoint_id") +def step_plan_without_checkpoint(context: Any) -> None: + context.r2_plan = _make_plan(last_checkpoint_id=None) + + +@given('r2plan-a v3 Plan with definition_of_done "{dod}"') +def step_plan_with_dod(context: Any, dod: str) -> None: + context.r2_plan = _make_plan(definition_of_done=dod) + + +@given("r2plan-a v3 Plan with definition_of_done longer than 200 chars") +def step_plan_with_long_dod(context: Any) -> None: + dod = "x" * 250 + context.r2_plan = _make_plan(definition_of_done=dod) + + +@given("r2plan-a v3 Plan without definition_of_done") +def step_plan_without_dod(context: Any) -> None: + context.r2_plan = _make_plan(definition_of_done=None) + + +@given("r2plan-a v3 Plan with dod evaluated as passed") +def step_plan_dod_passed(context: Any) -> None: + vs = { + "dod_evaluated": True, + "dod_all_passed": True, + "required_passed": 5, + "required_failed": 0, + } + context.r2_plan = _make_plan(validation_summary=vs) + + +@given("r2plan-a v3 Plan with dod evaluated as failed") +def step_plan_dod_failed(context: Any) -> None: + vs = { + "dod_evaluated": True, + "dod_all_passed": False, + "required_passed": 2, + "required_failed": 3, + } + context.r2_plan = _make_plan(validation_summary=vs) + + +@given("r2plan-a v3 Plan with arguments and arguments_order") +def step_plan_with_args_order(context: Any) -> None: + context.r2_plan = _make_plan( + arguments={"target_coverage": 80, "format": "json"}, + arguments_order=["target_coverage", "format"], + ) + + +@given("r2plan-a v3 Plan with arguments but no arguments_order") +def step_plan_with_args_no_order(context: Any) -> None: + context.r2_plan = _make_plan( + arguments={"beta_key": "val", "alpha_key": "val2"}, + arguments_order=[], + ) + + +@given("r2plan-a v3 Plan with description longer than 200 chars") +def step_plan_with_long_description(context: Any) -> None: + context.r2_plan = _make_plan(description="D" * 250) + + +@given("r2plan-a v3 Plan with resume metadata") +def step_plan_with_resume_metadata(context: Any) -> None: + context.r2_plan = _make_plan( + last_completed_step=5, + last_checkpoint_id="01CHKPTRESUME000000000000", + ) + + +@given("r2plan-a v3 Plan with project link alias and read_only") +def step_plan_with_link_alias_readonly(context: Any) -> None: + link = ProjectLink(project_name="local/ref-data", alias="data", read_only=True) + context.r2_plan = _make_plan(project_links=[link]) + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +@when("r2plan-I call _plan_spec_dict") +def step_call_spec_dict(context: Any) -> None: + context.r2_spec = _plan_spec_dict(context.r2_plan) + + +@when("r2plan-I call _print_lifecycle_plan") +def step_call_print_plan(context: Any) -> None: + context.r2_output = _capture_print(context.r2_plan) + + +# --------------------------------------------------------------------------- +# Then steps - spec dict assertions +# --------------------------------------------------------------------------- + + +@then('r2plan-the spec dict project_links should include alias "{alias}"') +def step_spec_alias(context: Any, alias: str) -> None: + links = context.r2_spec["project_links"] + assert any(link.get("alias") == alias for link in links), ( + f"No link with alias '{alias}' in {links}" + ) + + +@then("r2plan-the spec dict project_links should include read_only true") +def step_spec_readonly(context: Any) -> None: + links = context.r2_spec["project_links"] + assert any(link.get("read_only") is True for link in links), ( + f"No link with read_only=True in {links}" + ) + + +@then("r2plan-the spec dict project_links should not include alias") +def step_spec_no_alias(context: Any) -> None: + links = context.r2_spec["project_links"] + assert all("alias" not in link for link in links), f"Unexpected alias in {links}" + + +@then("r2plan-the spec dict project_links should not include read_only") +def step_spec_no_readonly(context: Any) -> None: + links = context.r2_spec["project_links"] + assert all("read_only" not in link for link in links), ( + f"Unexpected read_only in {links}" + ) + + +@then('r2plan-the spec dict automation_profile should be "{profile}"') +def step_spec_profile(context: Any, profile: str) -> None: + assert context.r2_spec["automation_profile"] == profile, ( + f"Expected '{profile}', got '{context.r2_spec['automation_profile']}'" + ) + + +@then("r2plan-the spec dict automation_profile should be null") +def step_spec_profile_null(context: Any) -> None: + assert context.r2_spec["automation_profile"] is None + + +@then('r2plan-the spec dict should contain key "{key}"') +def step_spec_has_key(context: Any, key: str) -> None: + assert key in context.r2_spec, ( + f"Key '{key}' not in spec dict: {list(context.r2_spec.keys())}" + ) + + +@then('r2plan-the spec dict should not contain key "{key}"') +def step_spec_no_key(context: Any, key: str) -> None: + assert key not in context.r2_spec, f"Key '{key}' should not be in spec dict" + + +@then("r2plan-the spec dict invariants count should be {n:d}") +def step_spec_inv_count(context: Any, n: int) -> None: + actual = len(context.r2_spec["invariants"]) + assert actual == n, f"Expected {n} invariants, got {actual}" + + +@then("r2plan-the spec dict dod_evaluation all_passed should be true") +def step_spec_dod_passed(context: Any) -> None: + assert context.r2_spec["dod_evaluation"]["all_passed"] is True + + +@then("r2plan-the spec dict last_completed_step should be {n:d}") +def step_spec_step(context: Any, n: int) -> None: + assert context.r2_spec["last_completed_step"] == n + + +# -- print output assertions -- + + +@then('r2plan-the printed output should contain "{text}"') +def step_output_contains(context: Any, text: str) -> None: + assert text in context.r2_output, ( + f"Expected '{text}' in output:\n{context.r2_output}" + ) + + +@then('r2plan-the printed output should not contain "{text}"') +def step_output_not_contains(context: Any, text: str) -> None: + assert text not in context.r2_output, ( + f"Did not expect '{text}' in output:\n{context.r2_output}" + ) diff --git a/features/steps/plan_lifecycle_error_r2_steps.py b/features/steps/plan_lifecycle_error_r2_steps.py new file mode 100644 index 000000000..b88eabf5e --- /dev/null +++ b/features/steps/plan_lifecycle_error_r2_steps.py @@ -0,0 +1,448 @@ +"""Step definitions for plan_lifecycle_error_r2.feature. + +Targets partial branches in ``plan_lifecycle_service.py`` that are only +exercised in one direction (True-only or False-only): + +* **Line 100** — ``InvalidPhaseTransitionError.__init__``: + ``if not message:`` False branch (custom message provided). +* **Line 216** — ``_commit_plan``: + ``if self._persisted and self.unit_of_work is not None:`` True branch. +* **Line 327** — ``create_action``: + ``if self._persisted …`` True branch. +* **Line 364** — ``get_action`` persistence fallback: True branch + (action not in memory, persistence also returns ``None``). +* **Line 461** — ``archive_action``: + ``if self._persisted …`` True branch. +* **Line 570** — ``use_action``: + ``if self._persisted …`` True branch. +* **Line 607** — ``get_plan`` persistence fallback: True branch + (plan not in memory, persistence also returns ``None``). + +All step text uses the ``r2plc-`` prefix to avoid collisions with +existing step definitions. + +This file also contains shared steps used by both error and transition +feature files (Behave discovers all steps globally from the steps/ dir). +""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services.plan_lifecycle_service import ( + InvalidPhaseTransitionError, + PlanLifecycleService, +) +from cleveragents.core.exceptions import NotFoundError +from cleveragents.domain.models.core.action import ActionState +from cleveragents.domain.models.core.plan import ( + PlanPhase, + ProjectLink, +) + +# ------------------------------------------------------------------- +# Mock UoW builder +# ------------------------------------------------------------------- + + +def _build_mock_uow() -> tuple[MagicMock, MagicMock]: + """Build a mock UnitOfWork and return (uow, shared_ctx). + + The shared ``ctx`` mock is reused across all ``transaction()`` calls + so that assertions can inspect cumulative interactions. + """ + mock_uow = MagicMock() + mock_ctx = MagicMock() + + # Make persistence lookups return None by default (not found) + mock_ctx.actions.get_by_name.return_value = None + mock_ctx.lifecycle_plans.get.return_value = None + + @contextmanager + def _transaction() -> Generator[MagicMock]: + yield mock_ctx + + mock_uow.transaction = _transaction + return mock_uow, mock_ctx + + +# ------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------- + + +def _create_action(context: Context, name: str, **kwargs: Any) -> Any: + """Create an action through the service with sensible defaults.""" + defaults: dict[str, Any] = { + "name": name, + "description": f"R2 test action {name}", + "definition_of_done": "Tests pass", + "strategy_actor": "openai/gpt-4", + "execution_actor": "openai/gpt-4", + } + defaults.update(kwargs) + return context.r2_service.create_action(**defaults) + + +# ------------------------------------------------------------------- +# Background +# ------------------------------------------------------------------- + + +@given("r2plc-a fresh plan lifecycle service with mock UoW") +def step_r2_bg(context: Context) -> None: + """Create a PlanLifecycleService backed by a mock UoW.""" + settings = MagicMock() + mock_uow, mock_ctx = _build_mock_uow() + context.r2_uow = mock_uow + context.r2_ctx = mock_ctx + context.r2_service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow) + context.r2_plan = None + context.r2_error = None + context.r2_action = None + + +# =================================================================== +# InvalidPhaseTransitionError with custom message (line 100) +# =================================================================== + + +@when("r2plc-I construct InvalidPhaseTransitionError with a custom message") +def step_r2_construct_with_message(context: Context) -> None: + """Directly construct the exception with a custom message.""" + context.r2_error = InvalidPhaseTransitionError( + from_phase=PlanPhase.STRATEGIZE, + to_phase=PlanPhase.APPLY, + message="Custom: cannot go there", + ) + + +@when("r2plc-I construct InvalidPhaseTransitionError without a message") +def step_r2_construct_without_message(context: Context) -> None: + """Construct the exception without a message (default path).""" + context.r2_error = InvalidPhaseTransitionError( + from_phase=PlanPhase.STRATEGIZE, + to_phase=PlanPhase.EXECUTE, + ) + + +@then("r2plc-the error message should be the custom message") +def step_r2_check_custom_message(context: Context) -> None: + assert str(context.r2_error) == "Custom: cannot go there", ( + f"Expected custom message, got: {context.r2_error}" + ) + + +@then('r2plc-the error message should contain "Invalid phase transition"') +def step_r2_check_default_message(context: Context) -> None: + assert "Invalid phase transition" in str(context.r2_error), ( + f"Expected default message, got: {context.r2_error}" + ) + + +@then("r2plc-the from_phase should be STRATEGIZE") +def step_r2_check_from_strategize(context: Context) -> None: + err = context.r2_error + assert isinstance(err, InvalidPhaseTransitionError) + assert err.from_phase == PlanPhase.STRATEGIZE + + +@then("r2plc-the to_phase should be APPLY") +def step_r2_check_to_apply(context: Context) -> None: + err = context.r2_error + assert isinstance(err, InvalidPhaseTransitionError) + assert err.to_phase == PlanPhase.APPLY + + +@then("r2plc-the to_phase should be EXECUTE") +def step_r2_check_to_execute(context: Context) -> None: + err = context.r2_error + assert isinstance(err, InvalidPhaseTransitionError) + assert err.to_phase == PlanPhase.EXECUTE + + +# =================================================================== +# revert_plan with InvalidPhaseTransitionError (line 100 + 1245-1250) +# =================================================================== + + +@given("r2plc-a plan in STRATEGIZE phase") +def step_r2_plan_in_strategize(context: Context) -> None: + """Create an action and use it to produce a plan in STRATEGIZE/QUEUED.""" + # Reset the mock call tracking so we can check calls per-scenario + context.r2_ctx.reset_mock() + action = _create_action(context, f"local/r2-strat-{id(context)}") + plan = context.r2_service.use_action( + action_name=str(action.namespaced_name), + project_links=[ProjectLink(project_name="proj-r2")], + ) + context.r2_plan = plan + # Reset again after setup so assertions only see scenario-specific calls + context.r2_ctx.reset_mock() + + +@when("r2plc-I attempt to revert the plan to APPLY phase") +def step_r2_revert_to_apply(context: Context) -> None: + """Attempt to revert from STRATEGIZE to APPLY (invalid transition).""" + try: + context.r2_service.revert_plan( + context.r2_plan.identity.plan_id, + to_phase=PlanPhase.APPLY, + reason="testing invalid revert", + ) + context.r2_error = None + except InvalidPhaseTransitionError as exc: + context.r2_error = exc + + +@then("r2plc-an InvalidPhaseTransitionError should have been raised") +def step_r2_check_invalid_transition(context: Context) -> None: + assert context.r2_error is not None, ( + "Expected InvalidPhaseTransitionError but none raised" + ) + assert isinstance(context.r2_error, InvalidPhaseTransitionError), ( + f"Expected InvalidPhaseTransitionError, got {type(context.r2_error).__name__}" + ) + + +@then('r2plc-the caught error message should contain "Cannot revert"') +def step_r2_check_revert_message(context: Context) -> None: + assert "Cannot revert" in str(context.r2_error), ( + f"Expected 'Cannot revert' in message, got: {context.r2_error}" + ) + + +# =================================================================== +# _commit_plan in persisted mode (line 216 True) +# =================================================================== + + +@when("r2plc-I start strategize on the plan") +def step_r2_start_strategize(context: Context) -> None: + """Start strategize — calls _commit_plan internally.""" + context.r2_plan = context.r2_service.start_strategize( + context.r2_plan.identity.plan_id + ) + + +@then("r2plc-the mock UoW should have received a plan update call") +def step_r2_check_plan_update(context: Context) -> None: + """Verify the mock ctx received at least one lifecycle_plans.update call.""" + assert context.r2_ctx.lifecycle_plans.update.called, ( + "Expected lifecycle_plans.update() to be called on mock ctx, " + f"but it was not. Calls: {context.r2_ctx.mock_calls}" + ) + + +# =================================================================== +# fail_strategize in persisted mode (line 216 True) +# =================================================================== + + +@given("r2plc-a plan in STRATEGIZE PROCESSING state") +def step_r2_plan_strategize_processing(context: Context) -> None: + """Create a plan and advance it to STRATEGIZE/PROCESSING.""" + context.r2_ctx.reset_mock() + action = _create_action(context, f"local/r2-sp-{id(context)}") + plan = context.r2_service.use_action( + action_name=str(action.namespaced_name), + project_links=[ProjectLink(project_name="proj-r2")], + ) + context.r2_service.start_strategize(plan.identity.plan_id) + context.r2_plan = context.r2_service.get_plan(plan.identity.plan_id) + context.r2_ctx.reset_mock() + + +@when('r2plc-I fail the strategize with error "{msg}"') +def step_r2_fail_strategize(context: Context, msg: str) -> None: + context.r2_plan = context.r2_service.fail_strategize( + context.r2_plan.identity.plan_id, msg + ) + + +@then('r2plc-the plan processing state should be "{state}"') +def step_r2_check_processing_state(context: Context, state: str) -> None: + actual = context.r2_plan.processing_state.value + assert actual == state, f"Expected '{state}', got '{actual}'" + + +# =================================================================== +# create_action persisted mode (line 327 True) +# =================================================================== + + +@when('r2plc-I create an action "{name}" in persisted mode') +def step_r2_create_action_persisted(context: Context, name: str) -> None: + context.r2_ctx.reset_mock() + context.r2_action = _create_action(context, name) + + +@then("r2plc-the mock UoW should have received an action create call") +def step_r2_check_action_create(context: Context) -> None: + assert context.r2_ctx.actions.create.called, ( + "Expected actions.create() to be called on mock ctx, " + f"but it was not. Calls: {context.r2_ctx.mock_calls}" + ) + + +@then("r2plc-the action should also be in the in-memory cache") +def step_r2_check_action_in_cache(context: Context) -> None: + name = str(context.r2_action.namespaced_name) + assert name in context.r2_service._actions, ( + f"Expected '{name}' in _actions cache, " + f"got keys: {list(context.r2_service._actions.keys())}" + ) + + +# =================================================================== +# use_action persisted mode (line 570 True) +# =================================================================== + + +@given('r2plc-an action "{name}" exists') +def step_r2_action_exists(context: Context, name: str) -> None: + context.r2_ctx.reset_mock() + _create_action(context, name) + context.r2_ctx.reset_mock() + + +@when("r2plc-I use the action to create a plan in persisted mode") +def step_r2_use_action_persisted(context: Context) -> None: + context.r2_ctx.reset_mock() + action_name = "local/r2-use-persist" + context.r2_plan = context.r2_service.use_action( + action_name=action_name, + project_links=[ProjectLink(project_name="proj-r2-use")], + ) + + +@then("r2plc-the mock UoW should have received a plan create call") +def step_r2_check_plan_create(context: Context) -> None: + assert context.r2_ctx.lifecycle_plans.create.called, ( + "Expected lifecycle_plans.create() to be called on mock ctx, " + f"but it was not. Calls: {context.r2_ctx.mock_calls}" + ) + + +@then("r2plc-the plan should also be in the in-memory plan cache") +def step_r2_check_plan_in_cache(context: Context) -> None: + plan_id = context.r2_plan.identity.plan_id + assert plan_id in context.r2_service._plans, ( + f"Expected plan '{plan_id}' in _plans cache" + ) + + +# =================================================================== +# archive_action persisted mode (line 461 True) +# =================================================================== + + +@when('r2plc-I archive the action "{name}" in persisted mode') +def step_r2_archive_action_persisted(context: Context, name: str) -> None: + context.r2_ctx.reset_mock() + context.r2_action = context.r2_service.archive_action(name) + + +@then("r2plc-the mock UoW should have received an action update call") +def step_r2_check_action_update(context: Context) -> None: + assert context.r2_ctx.actions.update.called, ( + "Expected actions.update() to be called on mock ctx, " + f"but it was not. Calls: {context.r2_ctx.mock_calls}" + ) + + +@then("r2plc-the action state should be archived") +def step_r2_check_archived_state(context: Context) -> None: + assert context.r2_action.state == ActionState.ARCHIVED, ( + f"Expected ARCHIVED, got {context.r2_action.state}" + ) + + +# =================================================================== +# get_action persistence fallback → NotFoundError (line 364-370) +# =================================================================== + + +@when('r2plc-I attempt to get action "{name}" in persisted mode') +def step_r2_get_action_not_found(context: Context, name: str) -> None: + """Call get_action for an action not in memory or persistence.""" + try: + context.r2_service.get_action(name) + context.r2_error = None + except NotFoundError as exc: + context.r2_error = exc + + +@then("r2plc-a NotFoundError should have been raised for action") +def step_r2_check_action_not_found(context: Context) -> None: + assert context.r2_error is not None, "Expected NotFoundError but none raised" + assert isinstance(context.r2_error, NotFoundError), ( + f"Expected NotFoundError, got {type(context.r2_error).__name__}" + ) + + +# =================================================================== +# get_plan persistence fallback → NotFoundError (line 607-613) +# =================================================================== + + +@when('r2plc-I attempt to get plan "{plan_id}" in persisted mode') +def step_r2_get_plan_not_found(context: Context, plan_id: str) -> None: + """Call get_plan for a plan not in memory or persistence.""" + try: + context.r2_service.get_plan(plan_id) + context.r2_error = None + except NotFoundError as exc: + context.r2_error = exc + + +@then("r2plc-a NotFoundError should have been raised for plan") +def step_r2_check_plan_not_found(context: Context) -> None: + assert context.r2_error is not None, "Expected NotFoundError but none raised" + assert isinstance(context.r2_error, NotFoundError), ( + f"Expected NotFoundError, got {type(context.r2_error).__name__}" + ) + + +# =================================================================== +# update_error_details in persisted mode (line 216 True) +# =================================================================== + + +@when('r2plc-I update error details with key "{key}" value "{value}"') +def step_r2_update_error_details(context: Context, key: str, value: str) -> None: + context.r2_ctx.reset_mock() + context.r2_service.update_error_details( + context.r2_plan.identity.plan_id, + {key: value}, + ) + # Refresh the plan reference + context.r2_plan = context.r2_service.get_plan(context.r2_plan.identity.plan_id) + + +@then('r2plc-the plan error_details should contain key "{key}"') +def step_r2_check_error_details_key(context: Context, key: str) -> None: + details = context.r2_plan.error_details + assert details is not None, "error_details is None" + assert key in details, f"Expected key '{key}' in error_details, got: {details}" + + +@given("r2plc-a plan in STRATEGIZE phase with existing error_details") +def step_r2_plan_with_error_details(context: Context) -> None: + """Create a plan and manually set error_details.""" + context.r2_ctx.reset_mock() + action = _create_action(context, f"local/r2-errdet-{id(context)}") + plan = context.r2_service.use_action( + action_name=str(action.namespaced_name), + project_links=[ProjectLink(project_name="proj-r2-err")], + ) + # Manually set existing error_details + plan.error_details = {"original": "existing_value"} + context.r2_plan = plan + context.r2_ctx.reset_mock() diff --git a/features/steps/plan_lifecycle_transitions_r2_steps.py b/features/steps/plan_lifecycle_transitions_r2_steps.py new file mode 100644 index 000000000..02b2a739f --- /dev/null +++ b/features/steps/plan_lifecycle_transitions_r2_steps.py @@ -0,0 +1,208 @@ +"""Step definitions for plan_lifecycle_transitions_r2.feature. + +Targets lifecycle transition branches in ``plan_lifecycle_service.py``: + +* Non-reusable ``use_action`` archiving (line 576-577). +* ``execute_plan`` persisted mode (line 216 True via ``_commit_plan``). +* ``apply_plan`` persisted mode (line 216 True via ``_commit_plan``). +* ``cancel_plan`` persisted mode (line 216 True via ``_commit_plan``). +* ``pause_plan`` / ``resume_plan`` persisted mode. + +All step text uses the ``r2plc-`` prefix to avoid collisions with +existing step definitions. + +Shared steps (Background, common Given/Then) live in +``plan_lifecycle_error_r2_steps.py`` and are discovered globally by +Behave from the ``steps/`` directory. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.domain.models.core.action import ActionState +from cleveragents.domain.models.core.plan import ( + PlanPhase, + ProjectLink, +) + +# ------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------- + + +def _create_action(context: Context, name: str, **kwargs: Any) -> Any: + """Create an action through the service with sensible defaults.""" + defaults: dict[str, Any] = { + "name": name, + "description": f"R2 test action {name}", + "definition_of_done": "Tests pass", + "strategy_actor": "openai/gpt-4", + "execution_actor": "openai/gpt-4", + } + defaults.update(kwargs) + return context.r2_service.create_action(**defaults) + + +# =================================================================== +# use_action with non-reusable action (line 576-577) +# =================================================================== + + +@given('r2plc-a non-reusable action "{name}" exists') +def step_r2_non_reusable_action(context: Context, name: str) -> None: + context.r2_ctx.reset_mock() + _create_action(context, name, reusable=False) + context.r2_ctx.reset_mock() + + +@when("r2plc-I use the non-reusable action to create a plan") +def step_r2_use_non_reusable(context: Context) -> None: + context.r2_plan = context.r2_service.use_action( + action_name="local/r2-oneshot", + project_links=[ProjectLink(project_name="proj-r2-oneshot")], + ) + + +@then('r2plc-the action "{name}" should be archived') +def step_r2_check_non_reusable_archived(context: Context, name: str) -> None: + action = context.r2_service.get_action(name) + assert action.state == ActionState.ARCHIVED, ( + f"Expected action '{name}' to be ARCHIVED, got {action.state}" + ) + + +@then("r2plc-a plan should have been created from the action") +def step_r2_check_plan_created(context: Context) -> None: + assert context.r2_plan is not None, "Expected a plan to be created" + assert context.r2_plan.phase == PlanPhase.STRATEGIZE + + +# =================================================================== +# execute_plan persisted mode (line 216 True via _commit_plan) +# =================================================================== + + +@given("r2plc-a plan in STRATEGIZE COMPLETE state") +def step_r2_plan_strategize_complete(context: Context) -> None: + """Create a plan and advance to STRATEGIZE/COMPLETE.""" + context.r2_ctx.reset_mock() + action = _create_action(context, f"local/r2-exec-{id(context)}") + plan = context.r2_service.use_action( + action_name=str(action.namespaced_name), + project_links=[ProjectLink(project_name="proj-r2-exec")], + ) + context.r2_service.start_strategize(plan.identity.plan_id) + context.r2_service.complete_strategize(plan.identity.plan_id) + # complete_strategize calls auto_progress which may call execute_plan + # Re-fetch the plan to see its actual state + context.r2_plan = context.r2_service.get_plan(plan.identity.plan_id) + # If auto_progress already executed, the plan may be in EXECUTE. + # For testing execute_plan explicitly, we need the plan in STRATEGIZE/COMPLETE. + # The manual profile (default) has auto_execute=1.0 so auto_progress won't fire. + context.r2_ctx.reset_mock() + + +@when("r2plc-I call execute_plan in persisted mode") +def step_r2_execute_plan_persisted(context: Context) -> None: + context.r2_plan = context.r2_service.execute_plan(context.r2_plan.identity.plan_id) + + +@then("r2plc-the plan should be in EXECUTE phase") +def step_r2_check_execute_phase(context: Context) -> None: + assert context.r2_plan.phase == PlanPhase.EXECUTE, ( + f"Expected EXECUTE, got {context.r2_plan.phase}" + ) + + +# =================================================================== +# apply_plan persisted mode (line 216 True via _commit_plan) +# =================================================================== + + +@given("r2plc-a plan in EXECUTE COMPLETE state") +def step_r2_plan_execute_complete(context: Context) -> None: + """Create a plan and advance to EXECUTE/COMPLETE.""" + context.r2_ctx.reset_mock() + action = _create_action(context, f"local/r2-apply-{id(context)}") + plan = context.r2_service.use_action( + action_name=str(action.namespaced_name), + project_links=[ProjectLink(project_name="proj-r2-apply")], + ) + pid = plan.identity.plan_id + context.r2_service.start_strategize(pid) + context.r2_service.complete_strategize(pid) + context.r2_service.execute_plan(pid) + context.r2_service.start_execute(pid) + context.r2_service.complete_execute(pid) + context.r2_plan = context.r2_service.get_plan(pid) + context.r2_ctx.reset_mock() + + +@when("r2plc-I call apply_plan in persisted mode") +def step_r2_apply_plan_persisted(context: Context) -> None: + context.r2_plan = context.r2_service.apply_plan(context.r2_plan.identity.plan_id) + + +@then("r2plc-the plan should be in APPLY phase") +def step_r2_check_apply_phase(context: Context) -> None: + assert context.r2_plan.phase == PlanPhase.APPLY, ( + f"Expected APPLY, got {context.r2_plan.phase}" + ) + + +# =================================================================== +# cancel_plan persisted mode (line 216 True via _commit_plan) +# =================================================================== + + +@when('r2plc-I cancel the plan with reason "{reason}"') +def step_r2_cancel_plan(context: Context, reason: str) -> None: + context.r2_ctx.reset_mock() + context.r2_plan = context.r2_service.cancel_plan( + context.r2_plan.identity.plan_id, reason=reason + ) + + +# =================================================================== +# pause_plan persisted mode +# =================================================================== + + +@when("r2plc-I pause the plan") +def step_r2_pause_plan(context: Context) -> None: + context.r2_ctx.reset_mock() + context.r2_plan = context.r2_service.pause_plan(context.r2_plan.identity.plan_id) + + +@then("r2plc-the plan automation profile should be manual") +def step_r2_check_manual_profile(context: Context) -> None: + assert context.r2_plan.automation_profile is not None + assert context.r2_plan.automation_profile.profile_name == "manual", ( + f"Expected 'manual', got '{context.r2_plan.automation_profile.profile_name}'" + ) + + +# =================================================================== +# resume_plan persisted mode +# =================================================================== + + +@when('r2plc-I resume the plan with profile "{profile}"') +def step_r2_resume_plan(context: Context, profile: str) -> None: + context.r2_ctx.reset_mock() + context.r2_plan = context.r2_service.resume_plan( + context.r2_plan.identity.plan_id, + automation_profile=profile, + ) + + +@then('r2plc-the plan automation profile should be "{profile}"') +def step_r2_check_profile(context: Context, profile: str) -> None: + assert context.r2_plan.automation_profile is not None + assert context.r2_plan.automation_profile.profile_name == profile, ( + f"Expected '{profile}', got '{context.r2_plan.automation_profile.profile_name}'" + ) diff --git a/features/steps/skill_cli_coverage_r2_steps.py b/features/steps/skill_cli_coverage_r2_steps.py new file mode 100644 index 000000000..2e6976b85 --- /dev/null +++ b/features/steps/skill_cli_coverage_r2_steps.py @@ -0,0 +1,452 @@ +"""Step definitions for skill_cli_coverage_r2.feature. + +Targets partial branch coverage in +``cleveragents.cli.commands.skill`` (lines 74, 109, 112, 121, 155, +164, 169, and related conditional branches). + +All step text uses the ``r2skill-`` prefix to avoid collisions with +existing step definitions in skill_cli_steps.py and +skill_cli_coverage_steps.py. +""" + +from __future__ import annotations + +import json +import tempfile +from typing import Any + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.skill import ( + _get_skill_service, + _reset_skill_service, +) +from cleveragents.cli.commands.skill import app as skill_app +from cleveragents.domain.models.core.skill import ( + Skill, + SkillAgentSource, + SkillInclude, + SkillInlineTool, + SkillMcpSource, +) +from cleveragents.domain.models.core.tool import ToolCapability, ToolSource + +# ── helpers ───────────────────────────────────────────────── + + +def _make_skill( + name: str, + description: str = "test skill", + tool_refs: list[str] | None = None, + includes: list[SkillInclude] | None = None, + mcp_servers: list[SkillMcpSource] | None = None, + agent_skills: list[SkillAgentSource] | None = None, + anonymous_tools: list[SkillInlineTool] | None = None, +) -> Skill: + """Create a Skill domain object with optional components.""" + return Skill( + name=name, + description=description, + tool_refs=tool_refs or [], + includes=includes or [], + mcp_servers=mcp_servers or [], + agent_skills=agent_skills or [], + anonymous_tools=anonymous_tools or [], + ) + + +def _write_yaml(content: str) -> str: + """Write YAML content to a temp file and return the path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False, prefix="r2skill_" + ) as tmp: + tmp.write(content) + tmp.flush() + return tmp.name + + +# ── Background ────────────────────────────────────────────── + + +@given("r2skill- a reset skill CLI service") +def step_r2_background(context: Context) -> None: + """Reset module-level singleton and prepare runner/service.""" + _reset_skill_service() + context.r2_runner = CliRunner() + context.r2_service = _get_skill_service() + context.r2_result = None + context.r2_temp_paths = [] # list[str] + context.r2_second_temp = None # str | None + context.r2_service_a = None + context.r2_service_b = None + + +# ── Given steps ───────────────────────────────────────────── + + +@given('r2skill- a registered skill "{name}" with tool_refs') +def step_r2_register_skill_with_tool_refs(context: Context, name: str) -> None: + """Directly insert a skill with builtin tool_refs into the service.""" + skill = _make_skill(name=name, tool_refs=["builtin/read-file"]) + context.r2_service._skills[skill.name] = skill + + +@given('r2skill- the timestamps for "{name}" are removed') +def step_r2_remove_timestamps(context: Context, name: str) -> None: + """Remove created_at and updated_at for a skill to force None returns.""" + context.r2_service._created_at.pop(name, None) + context.r2_service._updated_at.pop(name, None) + + +@given('r2skill- a registered skill "{name}" with MCP server but no tool list') +def step_r2_register_mcp_no_tools(context: Context, name: str) -> None: + """Skill with an MCP server whose tools list is None.""" + skill = _make_skill( + name=name, + mcp_servers=[SkillMcpSource(server="test-mcp-server", tools=None)], + ) + context.r2_service._skills[skill.name] = skill + + +@given('r2skill- a temp YAML config for "{name}" with only tool_refs') +def step_r2_temp_yaml_tool_refs(context: Context, name: str) -> None: + """Create a temp YAML config with only tool references.""" + yaml_content = f"""\ +name: {name} +description: A test skill with tool refs +tools: + - name: builtin/read-file +""" + path = _write_yaml(yaml_content) + context.r2_temp_paths.append(path) + + +@given('r2skill- a temp YAML config for "{name}" with no tools') +def step_r2_temp_yaml_no_tools(context: Context, name: str) -> None: + """Create a temp YAML config with no tools/mcp/agent/inline at all.""" + yaml_content = f"""\ +name: {name} +description: A skill with absolutely no tool sources +""" + path = _write_yaml(yaml_content) + context.r2_temp_paths.append(path) + + +@given('r2skill- a temp YAML config for "{name}" with MCP and tools') +def step_r2_temp_yaml_mcp_with_tools(context: Context, name: str) -> None: + """Create a YAML config for a skill with only MCP servers (no builtin tools).""" + yaml_content = f"""\ +name: {name} +description: Skill with only MCP sources +mcp_servers: + - name: my-mcp + transport: stdio + tool_filter: + include: + - read + - write +""" + path = _write_yaml(yaml_content) + context.r2_temp_paths.append(path) + + +@given('r2skill- a temp YAML config for "{name}" with inline tools') +def step_r2_temp_yaml_inline(context: Context, name: str) -> None: + """Create a YAML config with inline (custom) tools only.""" + yaml_content = f"""\ +name: {name} +description: Skill with inline tools +inline_tools: + - name: my-custom-tool + description: A custom tool + source: custom + code: "print('hello')" +""" + path = _write_yaml(yaml_content) + context.r2_temp_paths.append(path) + + +@given('r2skill- the skill "{name}" is already registered via add') +def step_r2_pre_register_via_add(context: Context, name: str) -> None: + """Register a skill via the CLI add command (first invocation).""" + # Use the last temp path that was created + config_path = context.r2_temp_paths[-1] + result = context.r2_runner.invoke( + skill_app, ["add", "--config", config_path, "--format", "json"] + ) + assert result.exit_code == 0, f"Pre-registration failed: {result.output}" + + +@given('r2skill- a second temp YAML config for "{name}" with different tools') +def step_r2_second_yaml_different_tools(context: Context, name: str) -> None: + """Create a second YAML config for the same skill with different tools.""" + yaml_content = f"""\ +name: {name} +description: Updated skill with different tools +tools: + - name: builtin/write-file + - name: builtin/exec-command +""" + path = _write_yaml(yaml_content) + context.r2_second_temp = path + + +@given('r2skill- a duplicate temp YAML config for "{name}"') +def step_r2_duplicate_yaml(context: Context, name: str) -> None: + """Create a duplicate YAML config for the same skill name.""" + yaml_content = f"""\ +name: {name} +description: Duplicate skill +tools: + - name: builtin/read-file +""" + path = _write_yaml(yaml_content) + context.r2_second_temp = path + + +@given('r2skill- a registered skill "{name}" that includes "{included}"') +def step_r2_register_with_include(context: Context, name: str, included: str) -> None: + """Register a skill that includes another skill.""" + skill = _make_skill( + name=name, + tool_refs=["builtin/exec-command"], + includes=[SkillInclude(name=included)], + ) + context.r2_service._skills[skill.name] = skill + + +@given('r2skill- a registered skill "{name}" with anonymous inline tools') +def step_r2_register_inline_tools(context: Context, name: str) -> None: + """Register a skill with anonymous inline tools.""" + inline = SkillInlineTool( + description="An inline tool", + source=ToolSource.CUSTOM, + code="print('hello')", + capability=ToolCapability(read_only=True), + ) + skill = _make_skill( + name=name, + anonymous_tools=[inline], + ) + context.r2_service._skills[skill.name] = skill + + +@given('r2skill- a registered skill "{name}" with no tools at all') +def step_r2_register_no_tools(context: Context, name: str) -> None: + """Register a skill with no tool sources whatsoever.""" + skill = _make_skill(name=name) + context.r2_service._skills[skill.name] = skill + + +@given('r2skill- a registered skill "{name}" with MCP server and explicit tools') +def step_r2_register_mcp_explicit_tools(context: Context, name: str) -> None: + """Register a skill with an MCP server that has explicit tool names.""" + skill = _make_skill( + name=name, + mcp_servers=[SkillMcpSource(server="my-mcp-srv", tools=["tool-a", "tool-b"])], + ) + context.r2_service._skills[skill.name] = skill + + +@given('r2skill- a temp YAML config for "{name}" with MCP servers') +def step_r2_temp_yaml_mcp_servers(context: Context, name: str) -> None: + """Create a YAML config that defines MCP servers.""" + yaml_content = f"""\ +name: {name} +description: Skill with MCP servers +mcp_servers: + - name: mcp-panel-server + transport: stdio + tool_filter: + include: + - read-data +""" + path = _write_yaml(yaml_content) + context.r2_temp_paths.append(path) + + +# ── When steps ────────────────────────────────────────────── + + +@when("r2skill- I call _get_skill_service twice without resetting") +def step_r2_get_service_twice(context: Context) -> None: + """Call _get_skill_service twice to exercise the cached path.""" + # First call already done in Background via _get_skill_service() + # Do NOT reset — call again to hit the `if _service is None:` false branch. + context.r2_service_a = _get_skill_service() + context.r2_service_b = _get_skill_service() + + +@when('r2skill- I invoke show "{name}" with format "{fmt}"') +def step_r2_invoke_show(context: Context, name: str, fmt: str) -> None: + """Invoke the skill show command.""" + context.r2_result = context.r2_runner.invoke( + skill_app, ["show", name, "--format", fmt] + ) + + +@when("r2skill- I invoke add with the temp config in rich format") +def step_r2_invoke_add_rich(context: Context) -> None: + """Invoke skill add with the most recent temp config, rich format.""" + config_path = context.r2_temp_paths[-1] + context.r2_result = context.r2_runner.invoke( + skill_app, ["add", "--config", config_path] + ) + + +@when('r2skill- I invoke add with the temp config in format "{fmt}"') +def step_r2_invoke_add_fmt(context: Context, fmt: str) -> None: + """Invoke skill add with the most recent temp config and a specified format.""" + config_path = context.r2_temp_paths[-1] + context.r2_result = context.r2_runner.invoke( + skill_app, ["add", "--config", config_path, "--format", fmt] + ) + + +@when("r2skill- I invoke add with the second config and --update in rich format") +def step_r2_invoke_add_update_rich(context: Context) -> None: + """Invoke skill add --update with the second temp config.""" + assert context.r2_second_temp is not None + context.r2_result = context.r2_runner.invoke( + skill_app, ["add", "--config", context.r2_second_temp, "--update"] + ) + + +@when("r2skill- I invoke add with the duplicate config without update") +def step_r2_invoke_add_duplicate(context: Context) -> None: + """Invoke skill add with a duplicate name and no --update flag.""" + assert context.r2_second_temp is not None + context.r2_result = context.r2_runner.invoke( + skill_app, ["add", "--config", context.r2_second_temp] + ) + + +@when("r2skill- I invoke list in rich format") +def step_r2_invoke_list_rich(context: Context) -> None: + """Invoke skill list with rich format (the default).""" + context.r2_result = context.r2_runner.invoke(skill_app, ["list"]) + + +@when('r2skill- I invoke list in format "{fmt}"') +def step_r2_invoke_list_fmt(context: Context, fmt: str) -> None: + """Invoke skill list with a specified format.""" + context.r2_result = context.r2_runner.invoke(skill_app, ["list", "--format", fmt]) + + +@when('r2skill- I invoke remove "{name}" with --yes in rich format') +def step_r2_invoke_remove_yes_rich(context: Context, name: str) -> None: + """Invoke skill remove with --yes in rich format.""" + context.r2_result = context.r2_runner.invoke(skill_app, ["remove", name, "--yes"]) + + +@when('r2skill- I invoke remove "{name}" with --yes in format "{fmt}"') +def step_r2_invoke_remove_yes_fmt(context: Context, name: str, fmt: str) -> None: + """Invoke skill remove --yes with a specified format.""" + context.r2_result = context.r2_runner.invoke( + skill_app, ["remove", name, "--yes", "--format", fmt] + ) + + +@when('r2skill- I invoke tools "{name}" with format "{fmt}"') +def step_r2_invoke_tools_fmt(context: Context, name: str, fmt: str) -> None: + """Invoke skill tools with a specified format.""" + context.r2_result = context.r2_runner.invoke( + skill_app, ["tools", name, "--format", fmt] + ) + + +# ── Then steps ────────────────────────────────────────────── + + +@then("r2skill- both calls return the same SkillService object") +def step_r2_assert_same_service(context: Context) -> None: + """Assert the singleton returned the same object.""" + assert context.r2_service_a is context.r2_service_b, ( + "Expected the same SkillService instance but got different objects" + ) + + +@then("r2skill- the CLI exit code should be 0") +def step_r2_exit_code_0(context: Context) -> None: + """Assert CLI exited successfully.""" + assert context.r2_result is not None + assert context.r2_result.exit_code == 0, ( + f"Expected exit_code=0, got {context.r2_result.exit_code}\n" + f"Output: {context.r2_result.output}" + ) + + +@then("r2skill- the CLI exit code should not be 0") +def step_r2_exit_code_not_0(context: Context) -> None: + """Assert CLI exited with an error.""" + assert context.r2_result is not None + assert context.r2_result.exit_code != 0, ( + f"Expected non-zero exit code, got {context.r2_result.exit_code}\n" + f"Output: {context.r2_result.output}" + ) + + +@then("r2skill- the output should be valid JSON") +def step_r2_output_valid_json(context: Context) -> None: + """Assert the CLI output parses as valid JSON.""" + assert context.r2_result is not None + try: + context.r2_parsed_json = json.loads(context.r2_result.output) + except json.JSONDecodeError as e: + raise AssertionError( + f"Output is not valid JSON: {e}\nOutput: {context.r2_result.output}" + ) from e + + +@then('r2skill- the JSON output should not contain key "{key}"') +def step_r2_json_no_key(context: Context, key: str) -> None: + """Assert a key is absent from the parsed JSON dict.""" + data: dict[str, Any] = context.r2_parsed_json + assert key not in data, ( + f"Expected key '{key}' to be absent, but it was found in: {list(data.keys())}" + ) + + +@then('r2skill- the JSON output should contain key "{key}"') +def step_r2_json_has_key(context: Context, key: str) -> None: + """Assert a key is present in the parsed JSON dict.""" + data: Any = context.r2_parsed_json + if isinstance(data, list): + # Check first element + assert len(data) > 0, "JSON list is empty" + assert key in data[0], ( + f"Key '{key}' not found in first element: {list(data[0].keys())}" + ) + else: + assert key in data, f"Key '{key}' not found in: {list(data.keys())}" + + +@then('r2skill- the output should contain "{text}"') +def step_r2_output_contains(context: Context, text: str) -> None: + """Assert the CLI output contains the specified text.""" + assert context.r2_result is not None + assert text in context.r2_result.output, ( + f"Expected output to contain '{text}'\n" + f"Actual output: {context.r2_result.output}" + ) + + +@then('r2skill- the output should not contain "{text}"') +def step_r2_output_not_contains(context: Context, text: str) -> None: + """Assert the CLI output does not contain the specified text.""" + assert context.r2_result is not None + assert text not in context.r2_result.output, ( + f"Expected output NOT to contain '{text}'\n" + f"Actual output: {context.r2_result.output}" + ) + + +@then('r2skill- the JSON tool list should contain source "{source}"') +def step_r2_json_tool_list_source(context: Context, source: str) -> None: + """Assert the JSON tool list contains an entry with the given source.""" + data: Any = context.r2_parsed_json + assert isinstance(data, list), f"Expected list, got {type(data).__name__}" + sources = [entry.get("source", "") for entry in data] + assert source in sources, f"Expected source '{source}' in tool list, got: {sources}"