test(persistence): add decision persistence suites #438
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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__}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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}'"
|
||||
)
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user