"""Step definitions for invariant_models.feature. Tests the Invariant domain models, InvariantService, merge precedence, enforcement records, and violation models. """ from __future__ import annotations import re from behave import given, then, when # type: ignore[import-untyped] from cleveragents.application.services.invariant_service import InvariantService from cleveragents.core.exceptions import NotFoundError, ValidationError from cleveragents.domain.models.core.invariant import ( Invariant, InvariantEnforcementRecord, InvariantScope, InvariantSet, InvariantViolation, merge_invariants, ) # -- ULID pattern for validation -- ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$") # ================================================================ # Invariant Creation # ================================================================ @when('I create an invariant with text "{text}" scope "{scope}" and source "{source}"') def step_create_invariant(context, text, scope, source): context.invariant = Invariant( text=text, scope=InvariantScope(scope), source_name=source, ) @then("the invariant should be created") def step_invariant_created(context): assert context.invariant is not None @then('the invariant scope should be "{scope}"') def step_invariant_scope(context, scope): assert context.invariant.scope.value == scope @then('the invariant source name should be "{source}"') def step_invariant_source(context, source): assert context.invariant.source_name == source @then("the invariant should be active") def step_invariant_active(context): assert context.invariant.active is True @then("the invariant id should be a valid ULID") def step_invariant_ulid(context): assert ULID_RE.match(context.invariant.id), ( f"Expected ULID, got: {context.invariant.id}" ) @then('the invariant text should be "{text}"') def step_invariant_text(context, text): assert context.invariant.text == text @when("I try to create an invariant with empty text") def step_create_empty_text(context): try: Invariant(text="", scope=InvariantScope.GLOBAL, source_name="system") context.error = None except Exception as e: context.error = e @when("I try to create an invariant with whitespace-only text") def step_create_whitespace_text(context): try: Invariant(text=" ", scope=InvariantScope.GLOBAL, source_name="system") context.error = None except Exception as e: context.error = e # NOTE: "a validation error should be raised" and "the error should mention" # are already defined in features/steps/domain_models_steps.py and shared # across all features via Behave's global step registry. # ================================================================ # InvariantScope Enum # ================================================================ @then('InvariantScope should have values "{values}"') def step_scope_values(context, values): expected = {v.strip() for v in values.split(",")} actual = {s.value for s in InvariantScope} assert expected == actual, f"Expected {expected}, got {actual}" # ================================================================ # Merge Precedence # ================================================================ def _parse_invariant_table(context, scope: InvariantScope): """Parse a Behave table into a list of Invariant objects.""" invariants = [] if context.table: for row in context.table: text = row["text"].strip() source = row["source"].strip() if text: invariants.append( Invariant( text=text, scope=scope, source_name=source, ) ) return invariants @given("I have plan invariants") def step_plan_invariants(context): context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN) @given("I have project invariants") def step_project_invariants(context): context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT) @given("I have global invariants") def step_global_invariants(context): context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL) @given("I have plan invariants with an inactive entry") def step_plan_invariants_inactive(context): inv = Invariant( text="Inactive constraint", scope=InvariantScope.PLAN, source_name="plan1", active=False, ) context.plan_invariants = [inv] @when("I merge the invariants") def step_merge(context): context.merged = merge_invariants( getattr(context, "plan_invariants", []), getattr(context, "project_invariants", []), getattr(context, "global_invariants", []), ) @then("the merged set should have {count:d} invariants") def step_merged_count(context, count): assert len(context.merged) == count, f"Expected {count}, got {len(context.merged)}" @then('the merged invariant at index {idx:d} should have text "{text}"') def step_merged_text(context, idx, text): assert context.merged[idx].text == text @then('the merged invariant at index {idx:d} should have scope "{scope}"') def step_merged_scope(context, idx, scope): assert context.merged[idx].scope.value == scope # ================================================================ # InvariantSet # ================================================================ @when("I merge using InvariantSet") def step_merge_invariant_set(context): inv_set = InvariantSet.merge( getattr(context, "plan_invariants", []), getattr(context, "project_invariants", []), getattr(context, "global_invariants", []), ) context.invariant_set = inv_set @then("the invariant set should have {count:d} invariants") def step_set_count(context, count): assert len(context.invariant_set.invariants) == count # ================================================================ # Service: Add / List / Remove # ================================================================ @given("an invariant service") def step_service(context): context.service = InvariantService() @when( 'I add an invariant via service with text "{text}" scope "{scope}" source "{source}"' ) def step_service_add(context, text, scope, source): context.service_invariant = context.service.add_invariant( text=text, scope=InvariantScope(scope), source_name=source, ) @then("the service should return the created invariant") def step_service_created(context): assert context.service_invariant is not None @then('the service invariant text should be "{text}"') def step_service_text(context, text): assert context.service_invariant.text == text @when("I try to add an invariant via service with empty text") def step_service_add_empty_text(context): try: context.service.add_invariant( text="", scope=InvariantScope.GLOBAL, source_name="system" ) context.error = None except ValidationError as e: context.error = e @when("I try to add an invariant via service with empty source") def step_service_add_empty_source(context): try: context.service.add_invariant( text="Some text", scope=InvariantScope.GLOBAL, source_name="" ) context.error = None except ValidationError as e: context.error = e @then("a service validation error should be raised") def step_service_validation_error(context): assert context.error is not None assert isinstance(context.error, ValidationError) @given("an invariant service with {count:d} invariants") def step_service_with_n(context, count): context.service = InvariantService() context.service_ids = [] for i in range(count): inv = context.service.add_invariant( text=f"Constraint {i + 1}", scope=InvariantScope.GLOBAL, source_name="system", ) context.service_ids.append(inv.id) @when("I list all invariants via service") def step_service_list_all(context): context.service_results = context.service.list_invariants() @then("the service should return {count:d} invariants") def step_service_list_count(context, count): assert len(context.service_results) == count @given("an invariant service with mixed scopes") def step_service_mixed_scopes(context): context.service = InvariantService() context.service.add_invariant("G1", InvariantScope.GLOBAL, "system") context.service.add_invariant("G2", InvariantScope.GLOBAL, "system") context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp") @when('I list invariants via service with scope "{scope}"') def step_service_list_scope(context, scope): context.service_results = context.service.list_invariants( scope=InvariantScope(scope) ) @then('all returned invariants should have scope "{scope}"') def step_all_scope(context, scope): for inv in context.service_results: assert inv.scope.value == scope @given("an invariant service with mixed sources") def step_service_mixed_sources(context): context.service = InvariantService() context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp") context.service.add_invariant("P2", InvariantScope.PROJECT, "myapp") context.service.add_invariant("P3", InvariantScope.PROJECT, "other") @when('I list invariants via service with source "{source}"') def step_service_list_source(context, source): context.service_results = context.service.list_invariants(source_name=source) @then('all returned invariants should have source "{source}"') def step_all_source(context, source): for inv in context.service_results: assert inv.source_name == source @given("an invariant service with {count:d} invariant") def step_service_with_one(context, count): context.service = InvariantService() context.service_ids = [] for i in range(count): inv = context.service.add_invariant( text=f"Constraint {i + 1}", scope=InvariantScope.GLOBAL, source_name="system", ) context.service_ids.append(inv.id) @when("I remove the invariant via service") def step_service_remove(context): context.removed_inv = context.service.remove_invariant(context.service_ids[0]) @then("the invariant should be inactive") def step_invariant_inactive(context): assert context.removed_inv.active is False @then("listing active invariants should return {count:d}") def step_list_active(context, count): results = context.service.list_invariants() assert len(results) == count @when("I try to remove a non-existent invariant") def step_remove_nonexistent(context): try: context.service.remove_invariant("01ZZZZZZZZZZZZZZZZZZZZZZZZ") context.error = None except NotFoundError as e: context.error = e # NOTE: "a not found error should be raised" is already defined in # features/steps/plan_lifecycle_service_steps.py and shared globally. @when("I try to remove an invariant with empty ID") def step_remove_empty_id(context): try: context.service.remove_invariant("") context.error = None except ValidationError as e: context.error = e # ================================================================ # Effective Invariant Computation # ================================================================ @given("an invariant service with invariants at all scopes") def step_service_all_scopes(context): context.service = InvariantService() context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system") context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1") context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1") @when('I get effective invariants for plan "{plan_id}" and project "{project}"') def step_effective(context, plan_id, project): context.effective = context.service.get_effective_invariants( plan_id=plan_id, project_name=project ) @then("the effective set should contain plan invariants first") def step_effective_plan_first(context): plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN] if plan_invs: first_plan_idx = context.effective.index(plan_invs[0]) assert first_plan_idx == 0 @then("the effective set should contain project invariants second") def step_effective_project_second(context): proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT] plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN] if proj_invs: first_proj_idx = context.effective.index(proj_invs[0]) assert first_proj_idx >= len(plan_invs) @then("the effective set should contain global invariants last") def step_effective_global_last(context): glob_invs = [i for i in context.effective if i.scope == InvariantScope.GLOBAL] if glob_invs: last_glob_idx = context.effective.index(glob_invs[-1]) assert last_glob_idx == len(context.effective) - 1 @given("an invariant service with duplicate text across scopes") def step_service_duplicates(context): context.service = InvariantService() context.service.add_invariant("Log all changes", InvariantScope.GLOBAL, "system") context.service.add_invariant("Log all changes", InvariantScope.PROJECT, "proj1") context.service.add_invariant("Log all changes", InvariantScope.PLAN, "plan1") @then("the effective set should have no duplicates") def step_no_duplicates(context): texts = [inv.text.lower() for inv in context.effective] assert len(texts) == len(set(texts)), f"Duplicates found: {texts}" # ================================================================ # Enforcement # ================================================================ @when('I enforce invariants for plan "{plan_id}"') def step_enforce(context, plan_id): invs = context.service.list_invariants() context.enforcement_records = context.service.enforce_invariants( plan_id=plan_id, invariants=invs ) @then("{count:d} enforcement records should be created") def step_enforcement_count(context, count): assert len(context.enforcement_records) == count @then("each enforcement record should be marked as enforced") def step_enforcement_enforced(context): for rec in context.enforcement_records: assert rec.enforced is True @then("each enforcement record should have a decision ID") def step_enforcement_decision_id(context): for rec in context.enforcement_records: assert rec.decision_id != "" @when('I enforce invariants with actor response "{response}"') def step_enforce_with_response(context, response): invs = context.service.list_invariants() context.enforcement_records = context.service.enforce_invariants( plan_id="PLAN_XYZ", invariants=invs, actor_response=response ) @then('the enforcement record actor response should be "{response}"') def step_enforcement_response(context, response): assert context.enforcement_records[0].actor_response == response @when("I try to enforce invariants with empty plan ID") def step_enforce_empty_plan(context): try: context.service.enforce_invariants(plan_id="", invariants=[]) context.error = None except ValidationError as e: context.error = e # ================================================================ # InvariantViolation # ================================================================ @when('I create an invariant violation with severity "{severity}"') def step_create_violation(context, severity): try: context.violation = InvariantViolation( invariant_id="01TESTINVARIANTID000000000", violated_text="Some constraint was violated", severity=severity, ) context.error = None except Exception as e: context.error = e context.violation = None @then("the violation should be created") def step_violation_created(context): assert context.violation is not None @then('the invariant violation severity should be "{severity}"') def step_violation_severity(context, severity): assert context.violation.severity == severity @when('I try to create a violation with invalid severity "{severity}"') def step_create_invalid_violation(context, severity): try: InvariantViolation( invariant_id="01TESTINVARIANTID000000000", violated_text="Some constraint", severity=severity, ) context.error = None except Exception as e: context.error = e # ================================================================ # InvariantEnforcementRecord # ================================================================ @when("I create an enforcement record with enforced true") def step_create_record_true(context): context.record = InvariantEnforcementRecord( invariant_id="01TESTINVARIANTID000000000", enforced=True, actor_response="OK", decision_id="01TESTDECISIONID0000000000", ) @when("I create an enforcement record with enforced false") def step_create_record_false(context): context.record = InvariantEnforcementRecord( invariant_id="01TESTINVARIANTID000000000", enforced=False, ) @then("the record should be created") def step_record_created(context): assert context.record is not None @then("the record enforced flag should be true") def step_record_enforced_true(context): assert context.record.enforced is True @then("the record enforced flag should be false") def step_record_enforced_false(context): assert context.record.enforced is False