"""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 pydantic import ValidationError as PydanticValidationError 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 action invariants") def step_action_invariants(context): context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION) @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, "action_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 @then("action invariants appear before project invariants in merge") def step_action_before_project(context): """Verify ACTION tier comes before PROJECT tier in the merged result.""" action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION] project_invs = [i for i in context.merged if i.scope == InvariantScope.PROJECT] assert len(action_invs) > 0, "No action invariants found in merged result" assert len(project_invs) > 0, "No project invariants found in merged result" first_action_idx = context.merged.index(action_invs[0]) first_project_idx = context.merged.index(project_invs[0]) assert first_action_idx < first_project_idx, ( f"Action invariant at index {first_action_idx} " f"should appear before project invariant at index {first_project_idx}" ) @then("plan invariants appear before action invariants in merge") def step_plan_before_action(context): """Verify PLAN tier comes before ACTION tier in the merged result.""" plan_invs = [i for i in context.merged if i.scope == InvariantScope.PLAN] action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION] assert len(plan_invs) > 0, "No plan invariants found in merged result" assert len(action_invs) > 0, "No action invariants found in merged result" first_plan_idx = context.merged.index(plan_invs[0]) first_action_idx = context.merged.index(action_invs[0]) assert first_plan_idx < first_action_idx, ( f"Plan invariant at index {first_plan_idx} " f"should appear before action invariant at index {first_action_idx}" ) @then("action invariants are preserved in merge result") def step_action_invariants_preserved(context): """Verify that action-scoped invariants appear in the merged output.""" action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION] assert len(action_invs) > 0, "Expected action-scoped invariants in merge result" # ================================================================ # InvariantSet # ================================================================ @when("I merge using InvariantSet") def step_merge_invariant_set(context): inv_set = InvariantSet.merge( getattr(context, "plan_invariants", []), getattr(context, "action_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): # Store a reference to the original before removal so we can verify # that the original is unchanged (frozen value-object semantics). context.original_inv = context.service._invariants[context.service_ids[0]] 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") @given("an invariant service with invariants at all four scopes") def step_service_all_four_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("Action rule", InvariantScope.ACTION, "action1") 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 ) @when( 'I get effective invariants for plan "{plan_id}", action "{action}", and project "{project}"' ) def step_effective_with_action(context, plan_id, action, project): context.effective = context.service.get_effective_invariants( plan_id=plan_id, action_name=action, 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 action invariants second") def step_effective_action_second(context): """Verify ACTION tier appears after PLAN and before PROJECT.""" plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN] action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION] project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT] assert len(plan_invs) > 0, "No plan invariants found" assert len(action_invs) > 0, "No action invariants found — bug #9126 not fixed!" assert len(project_invs) > 0, "No project invariants found" first_action_idx = context.effective.index(action_invs[0]) if plan_invs: last_plan_idx = context.effective.index(plan_invs[-1]) assert first_action_idx > last_plan_idx first_project_idx = context.effective.index(project_invs[0]) assert first_project_idx > first_action_idx @then("the effective set should contain project invariants after action") def step_effective_project_after_action(context): """Verify PROJECT tier appears after ACTION tier.""" action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION] project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT] assert len(action_invs) > 0, "No action invariants found" assert len(project_invs) > 0, "No project invariants found" first_proj_idx = context.effective.index(project_invs[0]) last_action_idx = context.effective.index(action_invs[-1]) assert first_proj_idx > last_action_idx @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}" @then("the effective set should have {count:d} invariants") def step_effective_count(context, count): assert len(context.effective) == count, ( f"Expected {count}, got {len(context.effective)}" ) # ================================================================ # 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 # ================================================================ # Immutability contract (frozen=True) — Issue #3116 # ================================================================ @then("mutating the invariant active field should raise an error") def step_invariant_mutation_raises(context): try: context.invariant.active = False context.error = None except Exception as e: context.error = e assert context.error is not None, "Expected mutation to raise an error" assert isinstance(context.error, PydanticValidationError), ( f"Expected PydanticValidationError, got {type(context.error)}" ) @then("mutating the violation severity field should raise an error") def step_violation_mutation_raises(context): try: context.violation.severity = "info" context.error = None except Exception as e: context.error = e assert context.error is not None, "Expected mutation to raise an error" assert isinstance(context.error, PydanticValidationError), ( f"Expected PydanticValidationError, got {type(context.error)}" ) @then("mutating the record enforced field should raise an error") def step_record_mutation_raises(context): try: context.record.enforced = False context.error = None except Exception as e: context.error = e assert context.error is not None, "Expected mutation to raise an error" assert isinstance(context.error, PydanticValidationError), ( f"Expected PydanticValidationError, got {type(context.error)}" ) @then("the invariant should be hashable") def step_invariant_hashable(context): h = hash(context.invariant) assert isinstance(h, int), f"Expected int hash, got {type(h)}" @then("the violation should be hashable") def step_violation_hashable(context): h = hash(context.violation) assert isinstance(h, int), f"Expected int hash, got {type(h)}" @then("the record should be hashable") def step_record_hashable(context): h = hash(context.record) assert isinstance(h, int), f"Expected int hash, got {type(h)}" @then("the removed invariant is a different object than the original") def step_removed_is_new_object(context): # The service stores the deactivated copy; the original object is unchanged. # Verify the returned object has active=False and is a valid Invariant. assert context.removed_inv.active is False assert isinstance(context.removed_inv, Invariant) # Verify object identity: the returned copy must be a *different* object. assert context.removed_inv is not context.original_inv, ( "Expected model_copy to return a new object, but got the same object" ) @then("the original invariant should still be active") def step_original_still_active(context): # The original frozen value-object must be unchanged after soft-delete. assert context.original_inv.active is True, ( "Expected original invariant to remain active=True after soft-delete " "(frozen value-object must not be mutated)" ) # ================================================================ # InvariantSet immutability and hashability — Issue #3116 # ================================================================ @then("mutating the invariant set invariants field should raise an error") def step_invariant_set_mutation_raises(context): try: context.invariant_set.invariants = [] context.error = None except Exception as e: context.error = e assert context.error is not None, "Expected mutation to raise an error" assert isinstance(context.error, PydanticValidationError), ( f"Expected PydanticValidationError, got {type(context.error)}" ) @then("the invariant set should be hashable") def step_invariant_set_hashable(context): h = hash(context.invariant_set) assert isinstance(h, int), f"Expected int hash, got {type(h)}"