f510c7b5a3
CI / lint (pull_request) Successful in 28s
CI / typecheck (pull_request) Successful in 57s
CI / security (pull_request) Successful in 54s
CI / quality (pull_request) Successful in 33s
CI / build (pull_request) Successful in 34s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Failing after 7m11s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 17m51s
CI / integration_tests (pull_request) Failing after 23m12s
CI / coverage (pull_request) Successful in 10m51s
CI / status-check (pull_request) Failing after 2s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m49s
Replace validate_assignment=True with frozen=True on Invariant,
InvariantViolation, InvariantEnforcementRecord, and InvariantSet models
to enforce the value-object immutability contract required by the spec.
Redesign InvariantService.remove_invariant() to use model_copy(update=
{'active': False}) instead of direct field mutation, since frozen models
raise ValidationError on assignment.
Fix invariant_reconciliation_actor_steps.py step that mutated
inv.non_overridable = True to construct the Invariant directly with
non_overridable=True at creation time.
Add BDD scenarios covering:
- Immutability contract: mutation raises error on all three models
- Hashability: frozen Pydantic v2 models are hashable
- Soft-delete copy-and-replace: removed invariant is a new object
All 266 scenarios pass. Lint and typecheck pass.
ISSUES CLOSED: #3116
675 lines
22 KiB
Python
675 lines
22 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 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 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):
|
|
# 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")
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ================================================================
|
|
# 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)}"
|