test(cli): preserve invariant scope coverage after rebase

This commit is contained in:
cleveragents-auto
2026-06-17 21:38:08 -04:00
committed by Forgejo
parent 5a711f9776
commit 26662836aa
4 changed files with 731 additions and 179 deletions
+160 -18
View File
@@ -1,25 +1,167 @@
Feature: Invariant CLI new coverage for scope resolution and conflict detection Feature: Invariant CLI commands coverage
As a developer
I want full test coverage for the invariant CLI commands module
So that all commands and helper functions are verified
The ``agents invariant`` CLI command group uses ``_resolve_scope()`` to # === _resolve_scope helper ===
resolve mutually-exclusive scope flags (``--global``, ``--project``,
``--plan``, ``--action``). This feature tests that resolver directly,
covering both add-command usage paths and the shared list path.
Background: Scenario: Resolve scope with --global flag returns GLOBAL scope
Given invariant service mock is configured When I resolve invariant scope with global flag set
Then the resolved invariant scope should be "global"
And the resolved invariant source name should be "system"
Scenario: Resolve invariant with global-only flag returns GLOBAL scope Scenario: Resolve scope with --project flag returns PROJECT scope
When I resolve invariant add scope with only global flag When I resolve invariant scope with project "myapp"
Then invariant add with global flag returns GLOBAL scope Then the resolved invariant scope should be "project"
And the resolved invariant source name should be "myapp"
Scenario: Resolve invariant with project-only flag returns PROJECT scope Scenario: Resolve scope with --plan flag returns PLAN scope
When I resolve invariant add scope with only project flag When I resolve invariant scope with plan "PLAN_01HX"
Then invariant add with project flag returns PROJECT scope Then the resolved invariant scope should be "plan"
And the resolved invariant source name should be "PLAN_01HX"
Scenario: Resolve scope with --action flag returns ACTION scope
When I resolve invariant scope with action "deploy-service"
Then the resolved invariant scope should be "action"
And the resolved invariant source name should be "deploy-service"
Scenario: Resolve scope with no flags defaults to GLOBAL scope
When I resolve invariant scope with no flags
Then the resolved invariant scope should be "global"
And the resolved invariant source name should be "system"
Scenario: Resolve scope with conflicting flags raises BadParameter Scenario: Resolve scope with conflicting flags raises BadParameter
When I resolve invariant add scope with --global and --project flags When I resolve invariant scope with global and project flags
Then invariant add rejects conflicting scope flags Then a BadParameter error should be raised for invariant scope
Scenario: List invariants rejects conflicting scope flags # === _invariant_dict helper ===
When I resolve invariant list with conflicting scoped flags
Then list invariants rejects conflicting scope flags Scenario: Invariant dict serializes an invariant correctly
Given a sample invariant with known fields for dict test
When I call invariant_dict on the sample invariant
Then the invariant dict should contain the correct id and text
And the invariant dict should contain scope and source_name
And the invariant dict should contain active and created_at ISO string
# === _get_service singleton ===
Scenario: Get service creates InvariantService lazily
When I call get_service with invariant module service reset to None
Then an InvariantService instance should be returned from get_service
And calling get_service again returns the same InvariantService instance
# === invariant add command ===
Scenario: Add invariant with --global flag via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--global" and text "Never delete prod data"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
And the invariant CLI output should contain "Never delete prod data"
Scenario: Add invariant with --project flag via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--project myapp" and text "All APIs need tests"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
Scenario: Add invariant without scope flag via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add without scope flags and text "Missing scope flag"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
Scenario: Add invariant with --format json via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--global --format json" and text "JSON constraint"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "JSON constraint"
Scenario: Add invariant handles CleverAgentsError
Given a mocked InvariantService that raises CleverAgentsError on add
When I invoke invariant add with "--global" and text "will fail"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "Error"
# === invariant list command ===
Scenario: List invariants with no results
Given a mocked InvariantService that returns empty invariant list
When I invoke invariant list with no filters
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "No invariants found"
Scenario: List invariants with results in rich format
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with no filters
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariants"
And the invariant CLI output should contain "Never delete prod"
Scenario: List invariants with --global filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--global"
Then the invariant CLI exit code should be 0
And the invariant service list was called with global scope
Scenario: List invariants with --project filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--project myapp"
Then the invariant CLI exit code should be 0
And the invariant service list was called with project scope and source "myapp"
Scenario: List invariants with --plan filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--plan PLAN_01HX"
Then the invariant CLI exit code should be 0
And the invariant service list was called with plan scope and source "PLAN_01HX"
Scenario: List invariants with --action filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--action deploy"
Then the invariant CLI exit code should be 0
And the invariant service list was called with action scope and source "deploy"
Scenario: List invariants with --effective flag
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--effective"
Then the invariant CLI exit code should be 0
And the invariant service list was called with effective true
Scenario: List invariants with regex filter matching
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with regex "prod"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Never delete prod"
Scenario: List invariants with invalid regex
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with regex "[invalid"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "Invalid regex"
Scenario: List invariants with --format json
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--format json"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Never delete prod"
# === invariant remove command ===
Scenario: Remove invariant with --yes flag
Given a mocked InvariantService for invariant CLI remove
When I invoke invariant remove with "--yes" for id "INV_01HX"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant removed"
Scenario: Remove invariant without --yes cancelled by user
Given a mocked InvariantService for invariant CLI remove
When I invoke invariant remove without --yes for id "INV_01HX" and answer no
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "Cancelled"
Scenario: Remove invariant raises NotFoundError
Given a mocked InvariantService that raises NotFoundError on remove
When I invoke invariant remove with "--yes" for id "MISSING_ID"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "not found"
+345 -103
View File
@@ -1,124 +1,366 @@
"""BDD step definitions for invariant CLI new coverage tests. """Step definitions for invariant_cli_new_coverage.feature.
This module provides Cucumber-style steps used in the BDD feature tests Tests all CLI commands and helper functions in
for the ``invariant`` CLI command group, covering scope resolution and cleveragents.cli.commands.invariant to bring coverage from 0% to high.
conflict detection via ``_resolve_scope()``.
Based on scenarios from ``features/invariant_cli_new_coverage.feature``.
""" """
from __future__ import annotations from __future__ import annotations
import sys from datetime import datetime
from typing import Annotated from unittest.mock import MagicMock, patch
import typer import typer
from behave import given, then, when # type: ignore[import-untyped] from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.invariant import (
_get_service,
_invariant_dict,
_resolve_scope,
app,
)
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
_runner = CliRunner()
_ULID = "01JTEST0000000000000000001"
_ULID2 = "01JTEST0000000000000000002"
_NOW = datetime(2025, 7, 1, 12, 0, 0)
@given("invariant service mock is configured") def _make_invariant(
def step_service_mock(context): *,
"""Prepare a mocked InvariantService for the scenario.""" inv_id: str = _ULID,
context.inv_service_mocked = True text: str = "Never delete prod data",
scope: InvariantScope = InvariantScope.GLOBAL,
source_name: str = "system",
@given("a global scope invariant exists") active: bool = True,
def step_global_invariant_exists(context): created_at: datetime = _NOW,
"""Ensure a GLOBAL-scoped invariant is in the service store.""" ) -> Invariant:
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope """Build a test Invariant with sensible defaults."""
return Invariant(
context._test_inv_ids = [] id=inv_id,
context._test_invariants = [ text=text,
Invariant( scope=scope,
id="01HZGLOBAL00000000000000A1", source_name=source_name,
text="Never delete production data", active=active,
scope=InvariantScope.GLOBAL, created_at=created_at,
source_name="system",
active=True,
created_at=context._now_override(),
) )
]
# --------------------------------------------------------------------------- def _patch_svc(context, svc):
# _resolve_scope step definitions (add command) """Patch the module-level _service and register cleanup."""
# --------------------------------------------------------------------------- patcher = patch("cleveragents.cli.commands.invariant._service", svc)
patcher.start()
context.add_cleanup(patcher.stop)
@when("I resolve invariant add scope with only global flag")
def step_resolve_global_only(context): # ================================================================
context.inv_add_global_ok = False # _resolve_scope helper steps
context.inv_add_global_scope = None # ================================================================
@when("I resolve invariant scope with global flag set")
def step_resolve_scope_global(context):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=True, project=None, plan=None, action=None
)
@when('I resolve invariant scope with project "{project}"')
def step_resolve_scope_project(context, project):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=project, plan=None, action=None
)
@when('I resolve invariant scope with plan "{plan}"')
def step_resolve_scope_plan(context, plan):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=None, plan=plan, action=None
)
@when('I resolve invariant scope with action "{action}"')
def step_resolve_scope_action(context, action):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=None, plan=None, action=action
)
@when("I resolve invariant scope with no flags")
def step_resolve_scope_default(context):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=None, plan=None, action=None
)
@when("I resolve invariant scope with global and project flags")
def step_resolve_scope_conflicting(context):
context.inv_bad_parameter_raised = False
try: try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
scope, source_name = _resolve_scope(is_global=True, project=None, plan=None, action=None)
context.inv_add_global_ok = True
context.inv_add_global_scope = scope
context.inv_add_global_source = source_name
except Exception as exc:
context.inv_add_error = str(exc)
@when("I resolve invariant add scope with only project flag")
def step_resolve_project_only(context):
context.inv_add_project_ok = False
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
scope, source_name = _resolve_scope(is_global=False, project="myapp", plan=None, action=None)
context.inv_add_project_ok = True
context.inv_add_project_scope = scope
context.inv_add_project_source = source_name
except Exception as exc:
context.inv_add_error = str(exc)
@when("I resolve invariant add scope with --global and --project flags")
def step_resolve_global_project(context):
context.inv_add_conflict_raised = False
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
_resolve_scope(is_global=True, project="myapp", plan=None, action=None) _resolve_scope(is_global=True, project="myapp", plan=None, action=None)
except typer.BadParameter: except typer.BadParameter:
context.inv_add_conflict_raised = True context.inv_bad_parameter_raised = True
@then("invariant add with global flag returns GLOBAL scope") @then('the resolved invariant scope should be "{scope}"')
def step_check_global_scope(context): def step_check_resolved_scope(context, scope):
assert context.inv_add_global_ok, "Expected _resolve_scope to succeed for --global" assert context.resolved_scope.value == scope, (
from cleveragents.domain.models.core.invariant import InvariantScope f"Expected scope '{scope}', got '{context.resolved_scope.value}'"
assert context.inv_add_global_scope == InvariantScope.GLOBAL
assert context.inv_add_global_source == "system"
@then("invariant add with project flag returns PROJECT scope")
def step_check_project_scope(context):
assert context.inv_add_project_ok, "Expected _resolve_scope to succeed for --project"
from cleveragents.domain.models.core.invariant import InvariantScope
assert context.inv_add_project_scope == InvariantScope.PROJECT
assert context.inv_add_project_source == "myapp"
@then("invariant add rejects conflicting scope flags")
def step_check_add_conflicting(context):
assert context.inv_add_conflict_raised, (
"Expected BadParameter for invariant add with conflicting scopes"
) )
# === list_invariants scope conflict detection === @then('the resolved invariant source name should be "{source}"')
def step_check_resolved_source(context, source):
@when("I resolve invariant list with conflicting scoped flags") assert context.resolved_source == source, (
def step_resolve_list_conflicting(context): f"Expected source '{source}', got '{context.resolved_source}'"
context.inv_list_bad_parameter_raised = False
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
_resolve_scope(is_global=True, project="myapp", plan=None, action=None)
except typer.BadParameter:
context.inv_list_bad_parameter_raised = True
@then("list invariants rejects conflicting scope flags")
def step_check_list_conflicting(context):
assert context.inv_list_bad_parameter_raised, (
"Expected BadParameter for list_invariants with conflicting scopes"
) )
@then("a BadParameter error should be raised for invariant scope")
def step_check_bad_parameter(context):
assert context.inv_bad_parameter_raised, "Expected typer.BadParameter to be raised"
# ================================================================
# _invariant_dict helper steps
# ================================================================
@given("a sample invariant with known fields for dict test")
def step_create_sample_invariant(context):
context.sample_inv = _make_invariant()
@when("I call invariant_dict on the sample invariant")
def step_call_invariant_dict(context):
context.inv_dict = _invariant_dict(context.sample_inv)
@then("the invariant dict should contain the correct id and text")
def step_check_dict_id_text(context):
d = context.inv_dict
assert d["id"] == _ULID
assert d["text"] == "Never delete prod data"
@then("the invariant dict should contain scope and source_name")
def step_check_dict_scope_source(context):
d = context.inv_dict
assert d["scope"] == "global"
assert d["source_name"] == "system"
@then("the invariant dict should contain active and created_at ISO string")
def step_check_dict_active_created(context):
d = context.inv_dict
assert d["active"] is True
assert d["created_at"] == _NOW.isoformat()
# ================================================================
# _get_service singleton steps
# ================================================================
@when("I call get_service with invariant module service reset to None")
def step_get_service_reset(context):
import cleveragents.cli.commands.invariant as mod
# Save original and reset
context._orig_inv_service = mod._service
mod._service = None
context.add_cleanup(lambda: setattr(mod, "_service", context._orig_inv_service))
context.first_inv_service = _get_service()
@then("an InvariantService instance should be returned from get_service")
def step_check_service_instance(context):
from cleveragents.application.services.invariant_service import InvariantService
assert isinstance(context.first_inv_service, InvariantService)
@then("calling get_service again returns the same InvariantService instance")
def step_check_service_singleton(context):
second = _get_service()
assert second is context.first_inv_service
# ================================================================
# invariant add command steps
# ================================================================
@given("a mocked InvariantService for invariant CLI add")
def step_mock_service_for_add(context):
svc = MagicMock()
def _fake_add(text, scope, source_name):
return _make_invariant(text=text, scope=scope, source_name=source_name)
svc.add_invariant.side_effect = _fake_add
svc.list_invariants.return_value = []
context.inv_mock_svc = svc
_patch_svc(context, svc)
@when('I invoke invariant add with "{flags}" and text "{text}"')
def step_run_add(context, flags, text):
args = ["add", *flags.split(), text]
context.inv_result = _runner.invoke(app, args)
@when('I invoke invariant add without scope flags and text "{text}"')
def step_run_add_no_flags(context, text):
context.inv_result = _runner.invoke(app, ["add", text])
@then("the invariant CLI exit code should be 0")
def step_check_exit_zero(context):
assert context.inv_result.exit_code == 0, (
f"Expected exit code 0, got {context.inv_result.exit_code}.\n"
f"Output: {context.inv_result.output}"
)
@then("the invariant CLI exit code should be non-zero")
def step_check_exit_nonzero(context):
assert context.inv_result.exit_code != 0, (
f"Expected non-zero exit code, got {context.inv_result.exit_code}.\n"
f"Output: {context.inv_result.output}"
)
@then('the invariant CLI output should contain "{text}"')
def step_check_output_contains(context, text):
assert text in context.inv_result.output, (
f"Expected '{text}' in output, got:\n{context.inv_result.output}"
)
@given("a mocked InvariantService that raises CleverAgentsError on add")
def step_mock_service_error_add(context):
svc = MagicMock()
svc.add_invariant.side_effect = CleverAgentsError("Service failure")
_patch_svc(context, svc)
# ================================================================
# invariant list command steps
# ================================================================
@given("a mocked InvariantService that returns empty invariant list")
def step_mock_service_empty_list(context):
svc = MagicMock()
svc.list_invariants.return_value = []
context.inv_mock_svc = svc
_patch_svc(context, svc)
@given("a mocked InvariantService that returns two invariants for list")
def step_mock_service_two_invariants(context):
svc = MagicMock()
inv1 = _make_invariant(inv_id=_ULID, text="Never delete prod data")
inv2 = _make_invariant(
inv_id=_ULID2,
text="All changes need review",
scope=InvariantScope.PROJECT,
source_name="myapp",
)
svc.list_invariants.return_value = [inv1, inv2]
context.inv_mock_svc = svc
_patch_svc(context, svc)
@when("I invoke invariant list with no filters")
def step_run_list_no_filters(context):
context.inv_result = _runner.invoke(app, ["list"])
@when('I invoke invariant list with flags "{flags}"')
def step_run_list_with_flags(context, flags):
args = ["list", *flags.split()]
context.inv_result = _runner.invoke(app, args)
@when('I invoke invariant list with regex "{pattern}"')
def step_run_list_with_regex(context, pattern):
context.inv_result = _runner.invoke(app, ["list", pattern])
@then("the invariant service list was called with global scope")
def step_check_list_global(context):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
assert call_kwargs is not None
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.GLOBAL
@then('the invariant service list was called with project scope and source "{source}"')
def step_check_list_project(context, source):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.PROJECT
assert kwargs.get("source_name") == source
@then('the invariant service list was called with plan scope and source "{source}"')
def step_check_list_plan(context, source):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.PLAN
assert kwargs.get("source_name") == source
@then('the invariant service list was called with action scope and source "{source}"')
def step_check_list_action(context, source):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.ACTION
assert kwargs.get("source_name") == source
@then("the invariant service list was called with effective true")
def step_check_list_effective(context):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("effective") is True
# ================================================================
# invariant remove command steps
# ================================================================
@given("a mocked InvariantService for invariant CLI remove")
def step_mock_service_for_remove(context):
svc = MagicMock()
mock_inv = _make_invariant(active=False)
svc.remove_invariant.return_value = mock_inv
context.inv_mock_svc = svc
_patch_svc(context, svc)
@when('I invoke invariant remove with "{flags}" for id "{inv_id}"')
def step_run_remove_with_flags(context, flags, inv_id):
args = ["remove", *flags.split(), inv_id]
context.inv_result = _runner.invoke(app, args)
@when('I invoke invariant remove without --yes for id "{inv_id}" and answer no')
def step_run_remove_no_confirm(context, inv_id):
context.inv_result = _runner.invoke(app, ["remove", inv_id], input="n\n")
@given("a mocked InvariantService that raises NotFoundError on remove")
def step_mock_service_not_found_remove(context):
svc = MagicMock()
svc.remove_invariant.side_effect = NotFoundError(
resource_type="invariant", resource_id="MISSING_ID"
)
_patch_svc(context, svc)
+154 -45
View File
@@ -1,90 +1,199 @@
"""Robot Framework helper: invariant CLI smoke tests via ``invariant_app``. """Helper script for invariant_cli.robot smoke tests.
This module wraps the Typer ``invariant_app`` with ``cli_runner.invoke`` and Each subcommand is a self-contained check that prints a sentinel on success.
provides a set of callable functions keyed by command name in ``COMMANDS``.
Usage::
python robot/helper_invariant_cli.py scope-conflict
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
from unittest.mock import patch, MagicMock from pathlib import Path
from unittest.mock import patch
from typer.testing import CliRunner _SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.cli.commands.invariant import invariant_app from typer.testing import CliRunner # noqa: E402
from cleveragents.application.services.invariant_service import ( # noqa: E402
InvariantService,
)
from cleveragents.cli.commands.invariant import app as invariant_app # noqa: E402
runner = CliRunner() runner = CliRunner()
def _fresh_service() -> MagicMock: def _fresh_service() -> InvariantService:
"""Return a mock InvariantService that always gives an empty list.""" """Create a fresh in-memory service for each test."""
svc = MagicMock() return InvariantService()
svc.list_invariants.return_value = []
svc.add_invariant.side_effect = Exception("Mocked add not tested here")
return svc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Smoke-test functions # Subcommands
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def scope_conflict() -> None:
"""Verify that ``invariant add --global --project`` rejects conflicting scopes.""" def add_global() -> None:
svc = _fresh_service() svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc): with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke( result = runner.invoke(
invariant_app, invariant_app, ["add", "--global", "Never delete production data"]
["add", "--global", "--project", "myapp", "Some constraint"],
) )
if result.exit_code != 0: if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-scope-conflict-ok") print("invariant-add-global-ok")
else: else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}") print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1) sys.exit(1)
def list_scope_conflict() -> None: def add_project() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app, ["add", "--project", "myapp", "All API changes need tests"]
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-project-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def add_plan() -> None:
svc = _fresh_service() svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc): with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke( result = runner.invoke(
invariant_app, invariant_app,
["list", "--global", "--project", "myapp"], ["add", "--plan", "01TESTPLANID00000000000000", "Use Python 3.13"],
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-plan-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def add_action() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app,
["add", "--action", "local/code-coverage", "Min 80% coverage"],
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-action-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def list_empty() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["list"])
if result.exit_code == 0 and "No invariants found" in result.stdout:
print("invariant-list-empty-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def list_filter() -> None:
svc = _fresh_service()
svc.add_invariant("Global rule", scope=_scope("global"), source_name="system")
svc.add_invariant("Project rule", scope=_scope("project"), source_name="myapp")
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["list", "--global"])
if result.exit_code == 0 and "Global rule" in result.stdout:
print("invariant-list-filter-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def remove() -> None:
svc = _fresh_service()
inv = svc.add_invariant(
"To be removed", scope=_scope("global"), source_name="system"
)
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["remove", "--yes", inv.id])
if result.exit_code == 0 and "Invariant removed" in result.stdout:
print("invariant-remove-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def scope_conflict() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app,
["add", "--global", "--project", "myapp", "Conflicting scopes"],
) )
# Should fail with a bad parameter error # Should fail with a bad parameter error
if result.exit_code != 0: if result.exit_code != 0:
print("invariant-list-scope-conflict-ok") print("invariant-scope-conflict-ok")
else: else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}") print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1) sys.exit(1)
def add_no_scope() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["add", "Missing scope invariant"])
output = result.output or ""
if result.exit_code == 0 and "Invariant added" in output:
print("invariant-add-no-scope-global-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def list_json() -> None:
svc = _fresh_service()
svc.add_invariant("JSON test rule", scope=_scope("global"), source_name="system")
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["list", "--format", "json"])
if result.exit_code == 0:
# Verify output is valid JSON-like
output = result.stdout.strip()
if "JSON test rule" in output:
print("invariant-list-json-ok")
return
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def _scope(name: str):
from cleveragents.domain.models.core.invariant import InvariantScope
return InvariantScope(name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Command registry # Dispatcher
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
COMMANDS = { COMMANDS = {
"add-global": add_global,
"add-project": add_project,
"add-plan": add_plan,
"add-action": add_action,
"list-empty": list_empty,
"list-filter": list_filter,
"remove": remove,
"scope-conflict": scope_conflict, "scope-conflict": scope_conflict,
"list-scope-conflict": list_scope_conflict, "add-no-scope": add_no_scope,
"list-json": list_json,
} }
def main() -> None:
"""Dispatch to the requested helper function."""
if len(sys.argv) < 2 :
print(f"Usage: python {__file__} <command>", file=sys.stderr)
print(f"Available commands: {', '.join(COMMANDS)}", file=sys.stderr)
sys.exit(1)
cmd_name = sys.argv[1]
if cmd_name not in COMMANDS:
print(f"Unknown command: {cmd_name}", file=sys.stderr)
sys.exit(1)
COMMANDS[cmd_name]()
if __name__ == "__main__": if __name__ == "__main__":
main() cmd = sys.argv[1] if len(sys.argv) > 1 else None
if cmd not in COMMANDS:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
sys.exit(2)
COMMANDS[cmd]()
+69 -10
View File
@@ -1,12 +1,71 @@
*** Test Cases *** *** Settings ***
Invariant Add Scope Conflict Rejected Documentation Smoke tests for Invariant CLI commands
[Documentation] Verify that ``invariant add --global --project`` rejects conflicting scopes Resource ${CURDIR}/common.resource
${result}= Run Process python${/}${WORKSPACE}/robot/helper_invariant_cli.py scope-conflict cwd=${WORKSPACE} Suite Setup Setup Test Environment
Should Be Equal As Integers ${result.rc} 0 Suite Teardown Cleanup Test Environment
Should Contain ${result.stdout} invariant-add-scope-conflict-ok
Invariant List Scope Conflict Rejected *** Variables ***
[Documentation] Verify that ``invariant list --global --project`` rejects conflicting scopes ${HELPER} ${CURDIR}/helper_invariant_cli.py
${result}= Run Process python${/}${WORKSPACE}/robot/helper_invariant_cli.py list-scope-conflict cwd=${WORKSPACE}
*** Test Cases ***
Invariant Add Global
[Documentation] Verify that ``invariant add --global`` creates a global invariant
${result}= Run Process ${PYTHON} ${HELPER} add-global cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0 Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-scope-conflict-ok Should Contain ${result.stdout} invariant-add-global-ok
Invariant Add Project
[Documentation] Verify that ``invariant add --project`` creates a project invariant
${result}= Run Process ${PYTHON} ${HELPER} add-project cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-project-ok
Invariant Add Plan
[Documentation] Verify that ``invariant add --plan`` creates a plan invariant
${result}= Run Process ${PYTHON} ${HELPER} add-plan cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-plan-ok
Invariant Add Action
[Documentation] Verify that ``invariant add --action`` creates an action invariant
${result}= Run Process ${PYTHON} ${HELPER} add-action cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-action-ok
Invariant List Empty
[Documentation] Verify that ``invariant list`` works when no invariants exist
${result}= Run Process ${PYTHON} ${HELPER} list-empty cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-empty-ok
Invariant List With Filter
[Documentation] Verify that ``invariant list --global`` filters by scope
${result}= Run Process ${PYTHON} ${HELPER} list-filter cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-filter-ok
Invariant Remove
[Documentation] Verify that ``invariant remove --yes`` soft-deletes
${result}= Run Process ${PYTHON} ${HELPER} remove cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-remove-ok
Invariant Scope Conflict Rejected
[Documentation] Verify that conflicting scope flags are rejected
${result}= Run Process ${PYTHON} ${HELPER} scope-conflict cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-scope-conflict-ok
Invariant Add Missing Scope Defaults Global
[Documentation] Verify that ``invariant add`` defaults to global when no scope flag is provided
${result}= Run Process ${PYTHON} ${HELPER} add-no-scope cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-no-scope-global-ok
Invariant List JSON Format
[Documentation] Verify that ``invariant list --format json`` outputs JSON
${result}= Run Process ${PYTHON} ${HELPER} list-json cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-json-ok