diff --git a/features/invariant_cli_new_coverage.feature b/features/invariant_cli_new_coverage.feature index bdf816660..fa753f642 100644 --- a/features/invariant_cli_new_coverage.feature +++ b/features/invariant_cli_new_coverage.feature @@ -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 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. + # === _resolve_scope helper === - Background: - Given invariant service mock is configured + Scenario: Resolve scope with --global flag returns GLOBAL scope + 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 - When I resolve invariant add scope with only global flag - Then invariant add with global flag returns GLOBAL scope + Scenario: Resolve scope with --project flag returns PROJECT scope + When I resolve invariant scope with project "myapp" + 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 - When I resolve invariant add scope with only project flag - Then invariant add with project flag returns PROJECT scope + Scenario: Resolve scope with --plan flag returns PLAN scope + When I resolve invariant scope with plan "PLAN_01HX" + 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 - When I resolve invariant add scope with --global and --project flags - Then invariant add rejects conflicting scope flags + When I resolve invariant scope with global and project flags + Then a BadParameter error should be raised for invariant scope - Scenario: List invariants rejects conflicting scope flags - When I resolve invariant list with conflicting scoped flags - Then list invariants rejects conflicting scope flags + # === _invariant_dict helper === + + 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" diff --git a/features/steps/invariant_cli_new_coverage_steps.py b/features/steps/invariant_cli_new_coverage_steps.py index bd92b644a..a5eed430a 100644 --- a/features/steps/invariant_cli_new_coverage_steps.py +++ b/features/steps/invariant_cli_new_coverage_steps.py @@ -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 -for the ``invariant`` CLI command group, covering scope resolution and -conflict detection via ``_resolve_scope()``. - -Based on scenarios from ``features/invariant_cli_new_coverage.feature``. +Tests all CLI commands and helper functions in +cleveragents.cli.commands.invariant to bring coverage from 0% to high. """ + from __future__ import annotations -import sys -from typing import Annotated +from datetime import datetime +from unittest.mock import MagicMock, patch 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 step_service_mock(context): - """Prepare a mocked InvariantService for the scenario.""" - context.inv_service_mocked = True - - -@given("a global scope invariant exists") -def step_global_invariant_exists(context): - """Ensure a GLOBAL-scoped invariant is in the service store.""" - from cleveragents.domain.models.core.invariant import Invariant, InvariantScope - - context._test_inv_ids = [] - context._test_invariants = [ - Invariant( - id="01HZGLOBAL00000000000000A1", - text="Never delete production data", - scope=InvariantScope.GLOBAL, - source_name="system", - active=True, - created_at=context._now_override(), - ) - ] - - -# --------------------------------------------------------------------------- -# _resolve_scope step definitions (add command) -# --------------------------------------------------------------------------- - -@when("I resolve invariant add scope with only global flag") -def step_resolve_global_only(context): - context.inv_add_global_ok = False - context.inv_add_global_scope = None - 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) - except typer.BadParameter: - context.inv_add_conflict_raised = True - - -@then("invariant add with global flag returns GLOBAL scope") -def step_check_global_scope(context): - assert context.inv_add_global_ok, "Expected _resolve_scope to succeed for --global" - from cleveragents.domain.models.core.invariant import InvariantScope - - 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" +def _make_invariant( + *, + inv_id: str = _ULID, + text: str = "Never delete prod data", + scope: InvariantScope = InvariantScope.GLOBAL, + source_name: str = "system", + active: bool = True, + created_at: datetime = _NOW, +) -> Invariant: + """Build a test Invariant with sensible defaults.""" + return Invariant( + id=inv_id, + text=text, + scope=scope, + source_name=source_name, + active=active, + created_at=created_at, ) -# === list_invariants scope conflict detection === +def _patch_svc(context, svc): + """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 list with conflicting scoped flags") -def step_resolve_list_conflicting(context): - context.inv_list_bad_parameter_raised = False + +# ================================================================ +# _resolve_scope helper steps +# ================================================================ + + +@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: - 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 + context.inv_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('the resolved invariant scope should be "{scope}"') +def step_check_resolved_scope(context, scope): + assert context.resolved_scope.value == scope, ( + f"Expected scope '{scope}', got '{context.resolved_scope.value}'" ) + + +@then('the resolved invariant source name should be "{source}"') +def step_check_resolved_source(context, source): + assert context.resolved_source == source, ( + f"Expected source '{source}', got '{context.resolved_source}'" + ) + + +@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) diff --git a/robot/helper_invariant_cli.py b/robot/helper_invariant_cli.py index 9e3577204..4b730834b 100644 --- a/robot/helper_invariant_cli.py +++ b/robot/helper_invariant_cli.py @@ -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 -provides a set of callable functions keyed by command name in ``COMMANDS``. - -Usage:: - - python robot/helper_invariant_cli.py scope-conflict +Each subcommand is a self-contained check that prints a sentinel on success. """ + from __future__ import annotations 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() -def _fresh_service() -> MagicMock: - """Return a mock InvariantService that always gives an empty list.""" - svc = MagicMock() - svc.list_invariants.return_value = [] - svc.add_invariant.side_effect = Exception("Mocked – add not tested here") - return svc +def _fresh_service() -> InvariantService: + """Create a fresh in-memory service for each test.""" + return InvariantService() # --------------------------------------------------------------------------- -# Smoke-test functions +# Subcommands # --------------------------------------------------------------------------- -def scope_conflict() -> None: - """Verify that ``invariant add --global --project`` rejects conflicting scopes.""" + +def add_global() -> None: svc = _fresh_service() with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc): result = runner.invoke( - invariant_app, - ["add", "--global", "--project", "myapp", "Some constraint"], + invariant_app, ["add", "--global", "Never delete production data"] ) - if result.exit_code != 0: - print("invariant-add-scope-conflict-ok") + if result.exit_code == 0 and "Invariant added" in result.stdout: + print("invariant-add-global-ok") else: print(f"FAIL: exit={result.exit_code} out={result.stdout}") 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() with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc): result = runner.invoke( 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 if result.exit_code != 0: - print("invariant-list-scope-conflict-ok") + print("invariant-scope-conflict-ok") else: print(f"FAIL: exit={result.exit_code} out={result.stdout}") 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 = { + "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, - "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__} ", 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__": - 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]() diff --git a/robot/invariant_cli.robot b/robot/invariant_cli.robot index f9fe56539..a53e0c1b0 100644 --- a/robot/invariant_cli.robot +++ b/robot/invariant_cli.robot @@ -1,12 +1,71 @@ -*** Test Cases *** -Invariant Add Scope Conflict Rejected - [Documentation] Verify that ``invariant add --global --project`` rejects conflicting scopes - ${result}= Run Process python${/}${WORKSPACE}/robot/helper_invariant_cli.py scope-conflict cwd=${WORKSPACE} - Should Be Equal As Integers ${result.rc} 0 - Should Contain ${result.stdout} invariant-add-scope-conflict-ok +*** Settings *** +Documentation Smoke tests for Invariant CLI commands +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment -Invariant List Scope Conflict Rejected - [Documentation] Verify that ``invariant list --global --project`` rejects conflicting scopes - ${result}= Run Process python${/}${WORKSPACE}/robot/helper_invariant_cli.py list-scope-conflict cwd=${WORKSPACE} +*** Variables *** +${HELPER} ${CURDIR}/helper_invariant_cli.py + +*** 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 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