"""Behave step implementations for context strategy registry feature.""" from __future__ import annotations from types import MappingProxyType from behave import given, then, when from behave.runner import Context from pydantic import ValidationError from cleveragents.application.services.strategy_registry import ( StrategyNotFoundError, StrategyRegistrationError, StrategyRegistry, ) from cleveragents.domain.models.acms.crp import ( ContextFragment, ContextRequest, FragmentProvenance, ) from cleveragents.domain.models.acms.strategy import ( BackendSet, ContextStrategy, ContextStrategyResult, PlanContext, StrategyCapabilities, StrategyConfig, ) from cleveragents.domain.models.acms.strategy_stubs import ( BUILTIN_STRATEGY_CLASSES, DEFAULT_ENABLED_STRATEGIES, ) from cleveragents.domain.models.acms.stubs import ( InMemoryGraphBackend, InMemoryTextBackend, InMemoryVectorBackend, ) from cleveragents.domain.models.acms.temporal_stubs import InMemoryTemporalBackend # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _all_builtins() -> dict[str, ContextStrategy]: """Instantiate all built-in strategies keyed by name.""" instances: dict[str, ContextStrategy] = {} for cls in BUILTIN_STRATEGY_CLASSES: inst = cls() instances[inst.name] = inst return instances def _register_all_builtins( registry: StrategyRegistry, *, use_default_enabled: bool = False, ) -> None: """Register all built-in strategies in the registry.""" for cls in BUILTIN_STRATEGY_CLASSES: inst = cls() enabled = ( inst.name in DEFAULT_ENABLED_STRATEGIES if use_default_enabled else True ) registry.register( inst, config=StrategyConfig(enabled=enabled), is_builtin=True, ) if use_default_enabled: registry.set_enabled(list(DEFAULT_ENABLED_STRATEGIES)) # --------------------------------------------------------------------------- # Given # --------------------------------------------------------------------------- @given("all built-in strategies are instantiated") def step_given_all_builtins(context: Context) -> None: context.strategies = _all_builtins() @given("an empty strategy registry") def step_given_empty_registry(context: Context) -> None: context.registry = StrategyRegistry() @given('I register the "{name}" built-in strategy') def step_given_register_builtin(context: Context, name: str) -> None: strategies = _all_builtins() context.registry.register(strategies[name], is_builtin=True) @given("a registry with all built-in strategies") def step_given_registry_all(context: Context) -> None: context.registry = StrategyRegistry() _register_all_builtins(context.registry) @given("a registry with all built-in strategies using default enabled list") def step_given_registry_default_enabled(context: Context) -> None: context.registry = StrategyRegistry() _register_all_builtins(context.registry, use_default_enabled=True) @given("a BackendSet with text backend only") def step_given_backends_text(context: Context) -> None: context.backend_set = BackendSet(text=InMemoryTextBackend()) @given("a BackendSet with vector backend only") def step_given_backends_vector(context: Context) -> None: context.backend_set = BackendSet(vector=InMemoryVectorBackend()) @given("a BackendSet with graph backend only") def step_given_backends_graph(context: Context) -> None: context.backend_set = BackendSet(graph=InMemoryGraphBackend()) @given("a BackendSet with all backends") def step_given_backends_all(context: Context) -> None: context.backend_set = BackendSet( text=InMemoryTextBackend(), vector=InMemoryVectorBackend(), graph=InMemoryGraphBackend(), ) @given("a BackendSet with no backends") def step_given_backends_none(context: Context) -> None: context.backend_set = BackendSet() @given("a default ContextRequest") def step_given_default_request(context: Context) -> None: context.request = ContextRequest(query="test query") @given("a default PlanContext") def step_given_default_plan_context(context: Context) -> None: context.plan_context = PlanContext() @given("a ContextStrategyResult with unordered fragments") def step_given_unordered_result(context: Context) -> None: prov = FragmentProvenance(resource_uri="res://test") context.result = ContextStrategyResult( strategy_name="test", fragments=( ContextFragment( uko_node="uko:Bravo", content="b", detail_depth=1, token_count=10, relevance_score=0.5, provenance=prov, ), ContextFragment( uko_node="uko:Alpha", content="a", detail_depth=1, token_count=10, relevance_score=0.9, provenance=prov, ), ContextFragment( uko_node="uko:Alpha", content="a2", detail_depth=1, token_count=10, relevance_score=0.5, provenance=prov, ), ), total_fragments=3, tokens_used=30, ) @given("a ContextStrategyResult with no fragments") def step_given_empty_result(context: Context) -> None: context.result = ContextStrategyResult(strategy_name="empty") @given("a registry with a stale enabled list entry") def step_given_stale_enabled(context: Context) -> None: """Create a registry where the enabled list references a name not registered.""" context.registry = StrategyRegistry() _register_all_builtins(context.registry) # Use the public test helper to inject a stale entry context.registry.inject_stale_enabled_entry("ghost-strategy") @given("a registry with a strategy that declares no resource types") def step_given_no_resource_types(context: Context) -> None: context.registry = StrategyRegistry() class _NoResourceTypes: @property def name(self) -> str: return "no-resource-types" @property def capabilities(self) -> StrategyCapabilities: return StrategyCapabilities( uses_text=True, resource_types=(), quality_score=0.3, ) def can_handle(self, request: ContextRequest, backends: BackendSet) -> float: return 0.3 def assemble( self, request: ContextRequest, backends: BackendSet, budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: return [] def explain(self) -> str: return "test" context.registry.register(_NoResourceTypes()) @given("a registry with a strategy that declares no backend capabilities") def step_given_no_capabilities(context: Context) -> None: context.registry = StrategyRegistry() class _NoBackend: @property def name(self) -> str: return "no-backends" @property def capabilities(self) -> StrategyCapabilities: return StrategyCapabilities( uses_text=False, uses_vector=False, uses_graph=False, uses_temporal=False, resource_types=("*",), quality_score=0.1, ) def can_handle(self, request: ContextRequest, backends: BackendSet) -> float: return 0.1 def assemble( self, request: ContextRequest, backends: BackendSet, budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: return [] def explain(self) -> str: return "test" context.registry.register(_NoBackend()) # --------------------------------------------------------------------------- # When # --------------------------------------------------------------------------- @when('I register the "{name}" built-in strategy') def step_when_register_builtin(context: Context, name: str) -> None: strategies = _all_builtins() context.registry.register(strategies[name], is_builtin=True) @when("I register all 6 built-in strategies") def step_when_register_all(context: Context) -> None: _register_all_builtins(context.registry) @when('I attempt to register "{name}" again') def step_when_register_duplicate(context: Context, name: str) -> None: strategies = _all_builtins() try: context.registry.register(strategies[name], is_builtin=True) context.error = None except StrategyRegistrationError as exc: context.error = exc @when("I attempt to register a strategy with an empty name") def step_when_register_empty_name(context: Context) -> None: class _Empty: @property def name(self) -> str: return "" @property def capabilities(self) -> StrategyCapabilities: return StrategyCapabilities(uses_text=True, resource_types=("*",)) def can_handle(self, request: ContextRequest, backends: BackendSet) -> float: return 0.0 def assemble( self, request: ContextRequest, backends: BackendSet, budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: return [] def explain(self) -> str: return "" try: context.registry.register(_Empty()) context.error = None except StrategyRegistrationError as exc: context.error = exc @when('I get the strategy "{name}"') def step_when_get_strategy(context: Context, name: str) -> None: context.fetched_strategy = context.registry.get(name) @when('I attempt to get the strategy "{name}"') def step_when_get_nonexistent(context: Context, name: str) -> None: try: context.registry.get(name) context.error = None except StrategyNotFoundError as exc: context.error = exc @when('I set the enabled list to "{names}"') def step_when_set_enabled(context: Context, names: str) -> None: name_list = [n.strip().strip('"') for n in names.split(",")] context.registry.set_enabled(name_list) @when('I attempt to set the enabled list to "{names}"') def step_when_set_enabled_bad(context: Context, names: str) -> None: name_list = [n.strip().strip('"') for n in names.split(",")] try: context.registry.set_enabled(name_list) context.error = None except StrategyNotFoundError as exc: context.error = exc @when('I disable the strategy "{name}"') def step_when_disable(context: Context, name: str) -> None: context.registry.update_config(name, enabled=False) @when('I get the config for "{name}"') def step_when_get_config(context: Context, name: str) -> None: context.strategy_config = context.registry.get_config(name) @when('I update config for "{name}" with timeout_seconds {timeout:d}') def step_when_update_config(context: Context, name: str, timeout: int) -> None: context.registry.update_config(name, timeout_seconds=timeout) @when("each strategy assembles with budget {budget:d}") def step_when_assemble_all(context: Context, budget: int) -> None: context.assemble_results = {} for name, strategy in context.strategies.items(): result = strategy.assemble( context.request, context.backend_set, budget, context.plan_context, ) context.assemble_results[name] = result @when('I register a strategy from module "{module_path}"') def step_when_register_from_module(context: Context, module_path: str) -> None: # Use "simple-keyword" as the name since the scenario asserts on it. context.registry.register_from_module( name="simple-keyword", module_path=module_path, ) @when('I attempt to register a strategy from module "{module_path}"') def step_when_register_from_module_fail(context: Context, module_path: str) -> None: try: context.registry.register_from_module(name="test", module_path=module_path) context.error = None except StrategyRegistrationError as exc: context.error = exc @when('I get the entry for "{name}"') def step_when_get_entry(context: Context, name: str) -> None: context.fetched_entry = context.registry.get_entry(name) @when('I attempt to get the entry for "{name}"') def step_when_get_entry_fail(context: Context, name: str) -> None: try: context.registry.get_entry(name) context.error = None except StrategyNotFoundError as exc: context.error = exc @when('I attempt to unregister "{name}"') def step_when_unregister_fail(context: Context, name: str) -> None: try: context.registry.unregister(name) context.error = None except StrategyNotFoundError as exc: context.error = exc @when('I unregister "{name}"') def step_when_unregister(context: Context, name: str) -> None: context.registry.unregister(name) @when("I clear the registry") def step_when_clear(context: Context) -> None: context.registry.clear() @when("I validate the registry") def step_when_validate(context: Context) -> None: context.validation_warnings = context.registry.validate_registry() # --------------------------------------------------------------------------- # Then # --------------------------------------------------------------------------- @then("every strategy should satisfy the ContextStrategy protocol") def step_then_protocol(context: Context) -> None: for name, strategy in context.strategies.items(): assert isinstance(strategy, ContextStrategy), ( f"Strategy '{name}' does not satisfy ContextStrategy protocol" ) @then("every strategy should have a non-empty name") def step_then_nonempty_name(context: Context) -> None: for name, strategy in context.strategies.items(): assert strategy.name, f"Strategy has empty name (key={name})" @then("every strategy should have a capabilities object") def step_then_has_capabilities(context: Context) -> None: for name, strategy in context.strategies.items(): caps = strategy.capabilities assert isinstance(caps, StrategyCapabilities), ( f"Strategy '{name}' capabilities is not StrategyCapabilities" ) @then('the strategy "{name}" should have quality score {score:g}') def step_then_quality_score(context: Context, name: str, score: float) -> None: strategy = context.strategies[name] assert strategy.capabilities.quality_score == score, ( f"Expected {score}, got {strategy.capabilities.quality_score}" ) @then('the strategy registry should contain "{name}"') def step_then_contains(context: Context, name: str) -> None: assert context.registry.is_registered(name), ( f"'{name}' not in registry: {context.registry.list_all()}" ) @then('the strategy registry should not contain "{name}"') def step_then_not_contains(context: Context, name: str) -> None: assert not context.registry.is_registered(name), ( f"'{name}' unexpectedly in registry" ) @then("the registry size should be {size:d}") def step_then_size(context: Context, size: int) -> None: assert len(context.registry) == size, ( f"Expected {size}, got {len(context.registry)}" ) @then("the registry should contain {count:d} strategies") def step_then_count(context: Context, count: int) -> None: assert len(context.registry) == count, ( f"Expected {count}, got {len(context.registry)}" ) @then("a StrategyRegistrationError should be raised") def step_then_reg_error(context: Context) -> None: assert isinstance(context.error, StrategyRegistrationError), ( f"Expected StrategyRegistrationError, got {type(context.error)}" ) assert str(context.error), "Error message should not be empty" @then("a StrategyNotFoundError should be raised") def step_then_not_found_error(context: Context) -> None: assert isinstance(context.error, StrategyNotFoundError), ( f"Expected StrategyNotFoundError, got {type(context.error)}" ) assert str(context.error), "Error message should not be empty" @then('the strategy name should be "{name}"') def step_then_strategy_name(context: Context, name: str) -> None: assert context.fetched_strategy.name == name @then("the registry should list {count:d} strategies") def step_then_list_count(context: Context, count: int) -> None: all_names = context.registry.list_all() assert len(all_names) == count, ( f"Expected {count}, got {len(all_names)}: {all_names}" ) @then("the list should be sorted alphabetically") def step_then_sorted(context: Context) -> None: names = context.registry.list_all() assert names == sorted(names), f"Names not sorted: {names}" @then('the enabled strategies should be "{names}"') def step_then_enabled(context: Context, names: str) -> None: expected = [n.strip().strip('"') for n in names.split(",")] actual = context.registry.list_enabled() assert actual == expected, f"Expected {expected}, got {actual}" @then('the enabled strategies should not include "{name}"') def step_then_not_enabled(context: Context, name: str) -> None: enabled = context.registry.list_enabled() assert name not in enabled, f"'{name}' unexpectedly in enabled list: {enabled}" @then("the timeout_seconds should be {val:d}") def step_then_timeout(context: Context, val: int) -> None: assert context.strategy_config.timeout_seconds == val @then("the max_fragments should be {val:d}") def step_then_max_fragments(context: Context, val: int) -> None: assert context.strategy_config.max_fragments == val @then("the circuit_breaker_threshold should be {val:d}") def step_then_cb(context: Context, val: int) -> None: assert context.strategy_config.circuit_breaker_threshold == val @then('the config for "{name}" should have timeout_seconds {val:d}') def step_then_config_timeout(context: Context, name: str, val: int) -> None: config = context.registry.get_config(name) assert config.timeout_seconds == val, ( f"Expected {val}, got {config.timeout_seconds}" ) @then('"{name}" can_handle should return {score:g}') def step_then_can_handle(context: Context, name: str, score: float) -> None: strategies = _all_builtins() strategy = strategies[name] result = strategy.can_handle(context.request, context.backend_set) assert abs(result - score) < 1e-6, f"Expected {score}, got {result} for '{name}'" @then("every strategy should return an empty fragment list") def step_then_empty_assemble(context: Context) -> None: for name, result in context.assemble_results.items(): assert result == [], f"Strategy '{name}' returned non-empty: {result}" @then("the fragments should be sorted by relevance_score DESC then uko_node ASC") def step_then_sorted_fragments(context: Context) -> None: frags = context.result.fragments assert len(frags) == 3 # relevance DESC: 0.9 first, then 0.5s; within 0.5: Alpha < Bravo assert frags[0].relevance_score == 0.9 assert frags[0].uko_node == "uko:Alpha" assert frags[1].relevance_score == 0.5 assert frags[1].uko_node == "uko:Alpha" assert frags[2].relevance_score == 0.5 assert frags[2].uko_node == "uko:Bravo" @then("the total_fragments should be {val:d}") def step_then_total_fragments(context: Context, val: int) -> None: assert context.result.total_fragments == val @then("the tokens_used should be {val:d}") def step_then_tokens_used(context: Context, val: int) -> None: assert context.result.tokens_used == val @then('validation warnings should mention "{text}"') def step_then_validation_mentions(context: Context, text: str) -> None: combined = " ".join(context.validation_warnings) assert text in combined, ( f"Expected '{text}' in warnings: {context.validation_warnings}" ) @then('the entry name should be "{name}"') def step_then_entry_name(context: Context, name: str) -> None: assert context.fetched_entry.name == name @then("the entry should be marked as builtin") def step_then_entry_builtin(context: Context) -> None: assert context.fetched_entry.is_builtin, "Expected entry to be builtin" @then("the builtin list should contain {count:d} strategies") def step_then_builtin_count(context: Context, count: int) -> None: builtins = context.registry.list_builtin() assert len(builtins) == count, ( f"Expected {count} builtins, got {len(builtins)}: {builtins}" ) @then('"{name}" should be in the registry via contains') def step_then_contains_in(context: Context, name: str) -> None: assert name in context.registry, f"'{name}' not in registry via __contains__" @then('"{name}" should not be in the registry via contains') def step_then_not_contains_in(context: Context, name: str) -> None: assert name not in context.registry, ( f"'{name}' unexpectedly in registry via __contains__" ) @then("there should be no validation warnings") def step_then_no_warnings(context: Context) -> None: assert context.validation_warnings == [], ( f"Expected no warnings, got: {context.validation_warnings}" ) # --------------------------------------------------------------------------- # Boundary test steps (TEST-1) # --------------------------------------------------------------------------- @when("I attempt to create a StrategyConfig with {field} set to {value:d}") def step_when_invalid_config(context: Context, field: str, value: int) -> None: try: StrategyConfig.model_validate({field: value}) context.error = None except ValidationError as exc: context.error = exc @when("I attempt to create a StrategyCapabilities with quality_score {value:g}") def step_when_invalid_quality(context: Context, value: float) -> None: try: StrategyCapabilities(uses_text=True, quality_score=value) context.error = None except ValidationError as exc: context.error = exc @when("I create a StrategyCapabilities with quality_score {value:g}") def step_when_valid_quality(context: Context, value: float) -> None: context.created_capabilities = StrategyCapabilities( uses_text=True, quality_score=value, ) @then("a Pydantic ValidationError should be raised") def step_then_pydantic_validation_error(context: Context) -> None: assert context.error is not None, ( "Expected Pydantic ValidationError but none raised" ) assert isinstance(context.error, ValidationError), ( f"Expected pydantic.ValidationError, got {type(context.error).__name__}" ) @then("the quality_score should be {value:g}") def step_then_quality_value(context: Context, value: float) -> None: assert abs(context.created_capabilities.quality_score - value) < 1e-9, ( f"Expected {value}, got {context.created_capabilities.quality_score}" ) # --------------------------------------------------------------------------- # Bug regression steps # --------------------------------------------------------------------------- @when( 'I update config for "{name}" with max_workers {mw:d}' " and circuit_breaker_threshold {cb:d}" ) def step_when_update_mw_cb(context: Context, name: str, mw: int, cb: int) -> None: context.registry.update_config(name, max_workers=mw, circuit_breaker_threshold=cb) @then('the config for "{name}" should have max_workers {val:d}') def step_then_config_max_workers(context: Context, name: str, val: int) -> None: config = context.registry.get_config(name) assert config.max_workers == val, f"Expected {val}, got {config.max_workers}" @then('the config for "{name}" should have circuit_breaker_threshold {val:d}') def step_then_config_cb(context: Context, name: str, val: int) -> None: config = context.registry.get_config(name) assert config.circuit_breaker_threshold == val, ( f"Expected {val}, got {config.circuit_breaker_threshold}" ) @when("I attempt to register a non-protocol object") def step_when_register_non_protocol(context: Context) -> None: try: # Pass name explicitly so register() reaches the isinstance check context.registry.register("not-a-strategy", name="fake") # type: ignore[arg-type] context.error = None except StrategyRegistrationError as exc: context.error = exc @when("I attempt to register a non-protocol object without a name") def step_when_register_non_protocol_no_name(context: Context) -> None: try: # No name= override — verifies guard ordering: isinstance check # must happen before strategy.name access (bug 1.1 fix). context.registry.register(object()) # type: ignore[arg-type] context.error = None except StrategyRegistrationError as exc: context.error = exc except AttributeError as exc: # pragma: no cover # This would indicate the bug is still present context.error = exc @then("every strategy should return a non-empty explain string") def step_then_explain_nonempty(context: Context) -> None: for name, strategy in context.strategies.items(): explanation = strategy.explain() assert isinstance(explanation, str), ( f"Strategy '{name}' explain() returned {type(explanation)}, expected str" ) assert explanation.strip(), f"Strategy '{name}' returned empty explain string" @when('I register the "{name}" built-in strategy with enabled False') def step_when_register_builtin_disabled(context: Context, name: str) -> None: strategies = _all_builtins() context.registry.register( strategies[name], config=StrategyConfig(enabled=False), is_builtin=True, ) @when('I update config for "{name}" with enabled True') def step_when_update_enabled_true(context: Context, name: str) -> None: context.registry.update_config(name, enabled=True) @when('I update config for "{name}" with enabled False') def step_when_update_enabled_false(context: Context, name: str) -> None: context.registry.update_config(name, enabled=False) @then('the enabled strategies should be ""') def step_then_enabled_empty(context: Context) -> None: actual = context.registry.list_enabled() assert actual == [], f"Expected empty enabled list, got {actual}" @then('the enabled strategies should include "{name}"') def step_then_enabled_includes(context: Context, name: str) -> None: enabled = context.registry.list_enabled() assert name in enabled, f"'{name}' not in enabled list: {enabled}" @when('I register a strategy named "{name}" from module "{module_path}"') def step_when_register_from_module_with_name( context: Context, name: str, module_path: str ) -> None: context.registry.register_from_module(name=name, module_path=module_path) @when('I attempt to update config for "{name}" with timeout_seconds {value:d}') def step_when_update_config_bad_timeout( context: Context, name: str, value: int ) -> None: try: context.registry.update_config(name, timeout_seconds=value) context.error = None except ValidationError as exc: context.error = exc @when('I attempt to update config for "{name}" with max_fragments {value:d}') def step_when_update_config_bad_fragments( context: Context, name: str, value: int ) -> None: try: context.registry.update_config(name, max_fragments=value) context.error = None except ValidationError as exc: context.error = exc # --------------------------------------------------------------------------- # Thread safety steps # --------------------------------------------------------------------------- @when("10 threads concurrently register and query the registry") def step_when_concurrent_register_query(context: Context) -> None: """Exercise the registry from multiple threads simultaneously.""" import threading as _threading errors: list[Exception] = [] barrier = _threading.Barrier(10) def _worker(idx: int) -> None: try: barrier.wait(timeout=5) # Mix of reads and writes if idx % 3 == 0: # Write: register a unique strategy name = f"thread-strategy-{idx}" class _Stub: @property def name(self) -> str: return name @property def capabilities(self) -> StrategyCapabilities: return StrategyCapabilities( uses_text=True, resource_types=("*",), quality_score=0.1 ) def can_handle( self, request: ContextRequest, backends: BackendSet ) -> float: return 0.1 def assemble( self, request: ContextRequest, backends: BackendSet, budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: return [] def explain(self) -> str: return f"thread stub {idx}" context.registry.register(_Stub()) elif idx % 3 == 1: # Read: list enabled context.registry.list_enabled() else: # Read: list all context.registry.list_all() except Exception as exc: errors.append(exc) threads = [_threading.Thread(target=_worker, args=(i,)) for i in range(10)] for t in threads: t.start() for t in threads: t.join(timeout=10) context.thread_errors = errors @then("no thread should have raised an exception") def step_then_no_thread_errors(context: Context) -> None: assert not context.thread_errors, f"Thread errors: {context.thread_errors}" @then("the registry should contain at least {count:d} strategies") def step_then_at_least_count(context: Context, count: int) -> None: actual = len(context.registry) assert actual >= count, f"Expected at least {count}, got {actual}" @then("every thread-registered strategy should be present") def step_then_thread_strategies_present(context: Context) -> None: for idx in [0, 3, 6, 9]: name = f"thread-strategy-{idx}" assert context.registry.is_registered(name), ( f"Thread-registered '{name}' missing from registry" ) # --------------------------------------------------------------------------- # Temporal/cold-tier backend steps # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # update_config: resource_types and extra steps # --------------------------------------------------------------------------- @when('I update config for "{name}" with resource_types "{rt1}", "{rt2}"') def step_when_update_resource_types( context: Context, name: str, rt1: str, rt2: str ) -> None: context.registry.update_config(name, resource_types=(rt1, rt2)) @then('the config for "{name}" should have resource_types "{rt1}", "{rt2}"') def step_then_resource_types(context: Context, name: str, rt1: str, rt2: str) -> None: cfg = context.registry.get_config(name) assert cfg.resource_types == (rt1, rt2), ( f"Expected ({rt1!r}, {rt2!r}), got {cfg.resource_types}" ) @when('I update config for "{name}" with extra key "{key}" value "{val}"') def step_when_update_extra(context: Context, name: str, key: str, val: str) -> None: context.registry.update_config(name, extra={key: val}) @then('the config for "{name}" extra should contain key "{key}"') def step_then_extra_contains(context: Context, name: str, key: str) -> None: cfg = context.registry.get_config(name) assert key in cfg.extra, f"Key '{key}' not in extra: {cfg.extra}" @then('the config for "{name}" extra should be immutable') def step_then_extra_immutable(context: Context, name: str) -> None: cfg = context.registry.get_config(name) assert isinstance(cfg.extra, MappingProxyType), ( f"Expected MappingProxyType, got {type(cfg.extra).__name__}" ) try: cfg.extra["hack"] = "should fail" # type: ignore[index] raise AssertionError("MappingProxyType should reject item assignment") except TypeError: pass # Expected — MappingProxyType is immutable # --------------------------------------------------------------------------- # MappingProxyType coercion validator steps # --------------------------------------------------------------------------- @when("I create a StrategyConfig with extra as a plain dict") def step_when_config_plain_dict_extra(context: Context) -> None: context.created_config = StrategyConfig(extra={"key": "val"}) # type: ignore[arg-type] @then("the extra field should be a MappingProxyType") def step_then_extra_is_mapping_proxy(context: Context) -> None: assert isinstance(context.created_config.extra, MappingProxyType), ( f"Expected MappingProxyType, got {type(context.created_config.extra).__name__}" ) @when("I attempt to create a StrategyConfig with extra as an integer") def step_when_config_int_extra(context: Context) -> None: try: StrategyConfig(extra=42) # type: ignore[arg-type] context.error = None except ValidationError as exc: context.error = exc @when("I create a ContextStrategyResult with stats as a plain dict") def step_when_result_plain_dict_stats(context: Context) -> None: context.created_result = ContextStrategyResult( strategy_name="test", stats={"ops": 10}, # type: ignore[arg-type] ) @then("the stats field should be a MappingProxyType") def step_then_stats_is_mapping_proxy(context: Context) -> None: assert isinstance(context.created_result.stats, MappingProxyType), ( f"Expected MappingProxyType, got {type(context.created_result.stats).__name__}" ) @given("a BackendSet with graph and temporal backends") def step_given_backends_graph_temporal(context: Context) -> None: context.backend_set = BackendSet( graph=InMemoryGraphBackend(), temporal=InMemoryTemporalBackend(), ) @given("a BackendSet with temporal backend only") def step_given_backends_temporal_only(context: Context) -> None: context.backend_set = BackendSet( temporal=InMemoryTemporalBackend(), )