Files
temp/features/steps/config_cli_scope_coverage_steps.py
T
freemo 31472b5413 test(coverage): add Behave scenarios for 39 under-covered modules
Add Behave feature/step pairs that exercise uncovered branches across handlers, LSP, CLI, and service layers to reach the coverage gate.

ISSUES CLOSED: #1232
2026-03-31 21:47:12 +00:00

221 lines
8.2 KiB
Python

"""Step definitions for config_cli_scope_coverage.feature (cfcov3).
Targets uncovered lines in cleveragents/cli/commands/config.py:
- Lines 224-229: invalid --scope triggers ValueError → typer.BadParameter
- Lines 252-253: --scope global reads previous from config_data
- Lines 254-255: --scope project reads previous from read_project_config()
- Lines 257-259: --scope local reads previous from read_local_config(),
then calls svc.set_value() and sets scope_display
All step text uses a 'cfcov3' prefix to avoid collisions with other
step definition files.
"""
from __future__ import annotations
import contextlib
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.services.config_service import (
ConfigScope,
)
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import app as config_app
_runner = CliRunner()
# ---------------------------------------------------------------------------
# Helpers — isolated temp directory
# ---------------------------------------------------------------------------
def _setup_cfcov3_temp(context: Context) -> None:
"""Redirect _CONFIG_DIR/_CONFIG_PATH to a fresh temp directory."""
if not hasattr(context, "_cfcov3_tmpdir"):
context._cfcov3_tmpdir = tempfile.mkdtemp(prefix="cfcov3_")
tmp = Path(context._cfcov3_tmpdir)
context._cfcov3_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
context._cfcov3_path_patch = patch.object(
config_mod, "_CONFIG_PATH", tmp / "config.toml"
)
context._cfcov3_dir_patch.start()
context._cfcov3_path_patch.start()
context.add_cleanup(lambda: _teardown_cfcov3_temp(context))
def _teardown_cfcov3_temp(context: Context) -> None:
for attr in ("_cfcov3_dir_patch", "_cfcov3_path_patch"):
patcher = getattr(context, attr, None)
if patcher is not None:
with contextlib.suppress(RuntimeError):
patcher.stop()
tmpdir = getattr(context, "_cfcov3_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
def _build_mock_svc(
config_data: dict[str, Any] | None = None,
project_config: dict[str, Any] | None = None,
local_config: dict[str, Any] | None = None,
) -> MagicMock:
"""Build a MagicMock that behaves like ConfigService for config_set."""
mock_svc = MagicMock()
mock_svc.read_config.return_value = config_data if config_data is not None else {}
mock_svc.read_project_config.return_value = (
project_config if project_config is not None else {}
)
mock_svc.read_local_config.return_value = (
local_config if local_config is not None else {}
)
# validate_type should pass through the value, coercing to int when possible
def _validate_type(key: str, value: Any) -> Any:
try:
return int(value)
except (ValueError, TypeError):
return value
mock_svc.validate_type.side_effect = _validate_type
mock_svc.set_value.return_value = None
mock_svc.write_config.return_value = None
# resolve is called by _validate_key indirectly through _get_service,
# but _validate_key uses _REGISTRY directly — no mock needed for that.
return mock_svc
# ===================================================================
# Background
# ===================================================================
@given("a cfcov3 isolated temp config directory")
def step_cfcov3_temp_dir(context: Context) -> None:
_setup_cfcov3_temp(context)
# Reset per-scenario mock tracking attributes
context.cfcov3_result = None
context.cfcov3_mock_svc = None
# ===================================================================
# Lines 224-229: invalid scope
# ===================================================================
@when('I invoke cfcov3 config set "{key}" "{value}" with scope "{scope}"')
def step_cfcov3_set_with_scope(
context: Context, key: str, value: str, scope: str
) -> None:
# For valid scopes, mock _get_service to avoid real file I/O.
# For invalid scopes, the ValueError is raised before _get_service
# calls read/write, but _get_service and validate_type are still
# called before the scope parsing — so we mock regardless.
mock_svc = context.cfcov3_mock_svc or _build_mock_svc()
context.cfcov3_mock_svc = mock_svc
patcher = patch.object(config_mod, "_get_service", return_value=mock_svc)
patcher.start()
context.add_cleanup(patcher.stop)
context.cfcov3_result = _runner.invoke(
config_app, ["set", key, value, "--scope", scope]
)
# ===================================================================
# Assertions: exit code and output content
# ===================================================================
@then("the cfcov3 CLI result should have a non-zero exit code")
def step_cfcov3_nonzero_exit(context: Context) -> None:
assert context.cfcov3_result.exit_code != 0, (
f"Expected non-zero exit, got {context.cfcov3_result.exit_code}: "
f"{context.cfcov3_result.output}"
)
@then("the cfcov3 CLI result should have exit code 0")
def step_cfcov3_zero_exit(context: Context) -> None:
assert context.cfcov3_result.exit_code == 0, (
f"Expected exit 0, got {context.cfcov3_result.exit_code}: "
f"{context.cfcov3_result.output}"
)
@then('the cfcov3 CLI output should contain "{text}"')
def step_cfcov3_output_contains(context: Context, text: str) -> None:
output = context.cfcov3_result.output
assert text in output, f"Expected '{text}' in output:\n{output}"
# ===================================================================
# Lines 252-253: --scope global with pre-existing value
# ===================================================================
@given('the cfcov3 global config has "{key}" set to {value:d}')
def step_cfcov3_global_preset(context: Context, key: str, value: int) -> None:
mock_svc = _build_mock_svc(config_data={key: value})
context.cfcov3_mock_svc = mock_svc
@then("the cfcov3 set_value mock should have been called with scope GLOBAL")
def step_cfcov3_set_value_global(context: Context) -> None:
mock_svc = context.cfcov3_mock_svc
assert mock_svc is not None, "No mock service was set up"
mock_svc.set_value.assert_called_once()
call_kwargs = mock_svc.set_value.call_args
# set_value(normalized, coerced, scope=config_scope)
assert call_kwargs.kwargs.get("scope") == ConfigScope.GLOBAL or (
len(call_kwargs.args) >= 3 and call_kwargs.args[2] == ConfigScope.GLOBAL
), f"Expected scope=GLOBAL in set_value call: {call_kwargs}"
# ===================================================================
# Lines 254-255: --scope project reads from read_project_config
# ===================================================================
@given('the cfcov3 project config returns "{key}" as {value:d}')
def step_cfcov3_project_preset(context: Context, key: str, value: int) -> None:
mock_svc = _build_mock_svc(project_config={key: value})
context.cfcov3_mock_svc = mock_svc
@then("the cfcov3 read_project_config mock should have been called")
def step_cfcov3_read_project_called(context: Context) -> None:
mock_svc = context.cfcov3_mock_svc
assert mock_svc is not None, "No mock service was set up"
mock_svc.read_project_config.assert_called_once()
# ===================================================================
# Lines 257-259: --scope local reads from read_local_config
# ===================================================================
@given('the cfcov3 local config returns "{key}" as {value:d}')
def step_cfcov3_local_preset(context: Context, key: str, value: int) -> None:
mock_svc = _build_mock_svc(local_config={key: value})
context.cfcov3_mock_svc = mock_svc
@then("the cfcov3 read_local_config mock should have been called")
def step_cfcov3_read_local_called(context: Context) -> None:
mock_svc = context.cfcov3_mock_svc
assert mock_svc is not None, "No mock service was set up"
mock_svc.read_local_config.assert_called_once()