forked from HAL9000/cleveragents-core
3a2b134f3c
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
402 lines
13 KiB
Python
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],
|
|
}
|
|
)
|