forked from cleveragents/cleveragents-core
564 lines
18 KiB
Python
564 lines
18 KiB
Python
"""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
|