Files
temp/features/steps/models_skill_coverage_r2_steps.py
freemo 3a2b134f3c test(coverage): add Behave scenarios for remaining under-tested modules
Added Behave BDD feature files and step definitions targeting coverage
gaps in six modules:

- container.py: exercise get_database_url env-var fallback, AI provider
  None path, cached container singleton, override_providers edge cases
  (lines 66-69, 125-130; branches at 51, 57, 82, 87, 256, 284-288)
- correction_service.py: exercise exception-handling paths in
  execute_revert and execute_append via monkeypatched analyze_impact
  and ULID failures (lines 254-262, 320-328)
- plan_lifecycle_service.py: exercise _persisted UoW commit paths,
  InvalidPhaseTransitionError custom message branch, non-reusable
  action archive, and error_details merge logic (branches at 100,
  216, 237, 327, 461, 570, 576, 607)
- plan.py (CLI): exercise spec-dict optional field branches,
  _print_lifecycle_plan conditional rendering, use_action argument
  parsing, auto-resolve paths, legacy wrappers, and validation
  error branches across 66 scenarios
- skill.py (CLI): exercise singleton cache, timestamp-absent show,
  no-tools MCP, add/remove/list/show format and error branches
  across 26 scenarios
- models.py (DB): exercise to_domain/from_domain None-field branches
  in SkillModel, SessionModel, ToolModel, LifecycleActionModel,
  LifecyclePlanModel, NamespacedProjectModel, and SessionMessageModel
  across 41 scenarios

All 302 features, 6503 scenarios, 28271 steps pass (nox -e unit_tests).

ISSUES CLOSED: #446
2026-02-25 19:38:43 -05:00

402 lines
13 KiB
Python

"""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],
}
)