test(contexts): add BDD tests for ScopeResolverRegistry and ScopeResolutionContext

- Add features/scope_chain_resolver.feature with 15 scenarios covering
  ScopeResolutionContext construction, ScopeResolverRegistry register/
  unregister/resolve/introspection, and all _discover_resolvers paths
  (load failure, outer exception, dict-style fallback)
- Add features/steps/scope_chain_resolver_steps.py with mock-based
  helpers to exercise the entry-point discovery branches

ISSUES CLOSED: #8867
This commit is contained in:
2026-06-04 20:28:48 -04:00
committed by Forgejo
parent e18851b172
commit c4ffc7facc
2 changed files with 388 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
@phase2 @acms @scope_chain_resolver
Feature: Pluggable scope chain resolution extension API
As a CleverAgents developer
I want a pluggable scope chain resolver registry with prioritized resolvers
So that the ACMS context assembly pipeline can resolve scope references via registered extensions
# ---------------------------------------------------------------------------
# ScopeResolutionContext
# ---------------------------------------------------------------------------
@scope_resolution_context
Scenario: Create ScopeResolutionContext with required scope only
When I create a ScopeResolutionContext with scope "issue:123"
Then the ScopeResolutionContext scope should be "issue:123"
And the ScopeResolutionContext metadata should be empty
And the ScopeResolutionContext resolved_fragments should be empty
@scope_resolution_context
Scenario: Create ScopeResolutionContext with all fields populated
When I create a full ScopeResolutionContext with scope "pr:456" and metadata key "repo" value "core" and resolved_fragment "frag1"
Then the ScopeResolutionContext scope should be "pr:456"
And the ScopeResolutionContext metadata key "repo" should be "core"
And the ScopeResolutionContext resolved_fragments should contain "frag1"
# ---------------------------------------------------------------------------
# ScopeResolverRegistry — resolution
# ---------------------------------------------------------------------------
@scope_resolver_registry
Scenario: Empty registry resolves to empty list
Given a fresh ScopeResolverRegistry
When I resolve scope "issue:999" with the registry
Then the resolution result should be empty
@scope_resolver_registry
Scenario: Register a resolver and resolve a matching scope
Given a fresh ScopeResolverRegistry
When I register a resolver named "test" with priority 0 that returns "frag_A" for scope "issue:1"
And I resolve scope "issue:1" with the registry
Then the resolution result should contain "frag_A"
@scope_resolver_registry
Scenario: Higher priority resolver wins over lower priority resolver
Given a fresh ScopeResolverRegistry
When I register a resolver named "low" with priority 1 that returns "low_frag" for scope "item:1"
And I register a resolver named "high" with priority 10 that returns "high_frag" for scope "item:1"
And I resolve scope "item:1" with the registry
Then the resolution result should contain "high_frag"
And the resolution result should not contain "low_frag"
@scope_resolver_registry
Scenario: First matching resolver wins and stops iteration
Given a fresh ScopeResolverRegistry
When I register a resolver named "alpha" with priority 5 that returns "alpha_frag" for scope "x:1"
And I register a resolver named "beta" with priority 3 that returns "beta_frag" for scope "x:1"
And I resolve scope "x:1" with the registry
Then the resolution result should contain "alpha_frag"
And the resolution result should not contain "beta_frag"
@scope_resolver_registry
Scenario: Resolve returns empty when no resolver matches the scope
Given a fresh ScopeResolverRegistry
When I register a null resolver named "no_match" with priority 0
And I resolve scope "unknown:1" with the registry
Then the resolution result should be empty
# ---------------------------------------------------------------------------
# ScopeResolverRegistry — register / unregister
# ---------------------------------------------------------------------------
@scope_resolver_registry @register
Scenario: Register a resolver with explicit priority stores it correctly
Given a fresh ScopeResolverRegistry
When I register a null resolver named "myresolver" with priority 42
Then the registry should contain a resolver named "myresolver"
And the resolver named "myresolver" should have priority 42
@scope_resolver_registry @unregister
Scenario: Unregister removes an existing resolver
Given a fresh ScopeResolverRegistry
When I register a null resolver named "to_remove" with priority 0
And I unregister the resolver named "to_remove"
Then the registry should not contain a resolver named "to_remove"
@scope_resolver_registry @unregister
Scenario: Unregister non-existent resolver is a safe no-op
Given a fresh ScopeResolverRegistry
When I unregister the resolver named "does_not_exist"
Then the registry should not contain a resolver named "does_not_exist"
# ---------------------------------------------------------------------------
# ScopeResolverRegistry — introspection
# ---------------------------------------------------------------------------
@scope_resolver_registry @introspection
Scenario: get_resolvers returns all registered resolvers
Given a fresh ScopeResolverRegistry
When I register a null resolver named "r1" with priority 1
And I register a null resolver named "r2" with priority 2
Then get_resolvers should return 2 entries
And get_resolvers should include key "r1"
And get_resolvers should include key "r2"
@scope_resolver_registry @introspection
Scenario: list_resolvers returns entries sorted by priority descending
Given a fresh ScopeResolverRegistry
When I register a null resolver named "low_pri" with priority 1
And I register a null resolver named "high_pri" with priority 9
And I register a null resolver named "mid_pri" with priority 5
Then list_resolvers should return 3 entries in priority order
| name | priority |
| high_pri | 9 |
| mid_pri | 5 |
| low_pri | 1 |
# ---------------------------------------------------------------------------
# ScopeResolverRegistry — _discover_resolvers coverage paths
# ---------------------------------------------------------------------------
@scope_resolver_registry @discover
Scenario: Discovery silently ignores entry point load failures
Given a ScopeResolverRegistry with a failing entry point
Then the scope resolver registry should be empty
@scope_resolver_registry @discover
Scenario: Discovery silently ignores outer entry_points exception
Given a ScopeResolverRegistry where entry_points raises an exception
Then the scope resolver registry should be empty
@scope_resolver_registry @discover
Scenario: Discovery uses dict-style fallback for older importlib_metadata API
Given a ScopeResolverRegistry with dict-style entry points returning no scope resolvers
Then the scope resolver registry should be empty
@@ -0,0 +1,255 @@
"""Step definitions for features/scope_chain_resolver.feature."""
from __future__ import annotations
import importlib.metadata as _importlib_metadata
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.contexts.scope_chain_resolver import (
ScopeResolutionContext,
ScopeResolverRegistry,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _StaticResolver:
"""Test resolver that returns a fixed fragment list for one matching scope."""
def __init__(self, target_scope: str, fragments: list[str]) -> None:
self._target = target_scope
self._fragments = fragments
def resolve(self, scope: str, context: ScopeResolutionContext) -> list[str]:
if scope == self._target:
return list(self._fragments)
return []
class _NullResolver:
"""Test resolver that always returns an empty list."""
def resolve(self, scope: str, context: ScopeResolutionContext) -> list[str]:
return []
def _make_resolution_ctx(scope: str = "test:0") -> ScopeResolutionContext:
return ScopeResolutionContext(scope=scope)
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("a fresh ScopeResolverRegistry")
def step_fresh_registry(context: Context) -> None:
context.registry = ScopeResolverRegistry()
@given("a ScopeResolverRegistry with a failing entry point")
def step_registry_failing_ep(context: Context) -> None:
bad_ep = MagicMock()
bad_ep.name = "bad"
bad_ep.load.side_effect = RuntimeError("simulated load failure")
mock_eps = MagicMock()
mock_eps.select.return_value = [bad_ep]
with patch.object(_importlib_metadata, "entry_points", return_value=mock_eps):
context.registry = ScopeResolverRegistry()
@given("a ScopeResolverRegistry where entry_points raises an exception")
def step_registry_ep_raises(context: Context) -> None:
with patch.object(
_importlib_metadata,
"entry_points",
side_effect=RuntimeError("simulated entry_points failure"),
):
context.registry = ScopeResolverRegistry()
@given("a ScopeResolverRegistry with dict-style entry points returning no scope resolvers")
def step_registry_dict_eps(context: Context) -> None:
# Simulate older importlib_metadata API: entry_points() returns a plain dict
# without a 'select' method, triggering the else-branch fallback.
mock_dict: dict[str, list[Any]] = {"other.group": []}
with patch.object(_importlib_metadata, "entry_points", return_value=mock_dict):
context.registry = ScopeResolverRegistry()
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I create a ScopeResolutionContext with scope "{scope}"')
def step_create_ctx_minimal(context: Context, scope: str) -> None:
context.resolution_ctx = ScopeResolutionContext(scope=scope)
@when(
'I create a full ScopeResolutionContext with scope "{scope}"'
' and metadata key "{key}" value "{value}"'
' and resolved_fragment "{frag}"'
)
def step_create_ctx_full(
context: Context, scope: str, key: str, value: str, frag: str
) -> None:
context.resolution_ctx = ScopeResolutionContext(
scope=scope,
metadata={key: value},
resolved_fragments=[frag],
)
@when(
'I register a resolver named "{name}" with priority {priority:d}'
' that returns "{fragment}" for scope "{target_scope}"'
)
def step_register_static_resolver(
context: Context, name: str, priority: int, fragment: str, target_scope: str
) -> None:
resolver = _StaticResolver(target_scope, [fragment])
context.registry.register(name, resolver, priority)
@when('I register a null resolver named "{name}" with priority {priority:d}')
def step_register_null_resolver(context: Context, name: str, priority: int) -> None:
context.registry.register(name, _NullResolver(), priority)
@when('I resolve scope "{scope}" with the registry')
def step_resolve_scope(context: Context, scope: str) -> None:
ctx = _make_resolution_ctx(scope)
context.resolution_result = context.registry.resolve(scope, ctx)
@when('I unregister the resolver named "{name}"')
def step_unregister(context: Context, name: str) -> None:
context.registry.unregister(name)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('the ScopeResolutionContext scope should be "{expected}"')
def step_check_ctx_scope(context: Context, expected: str) -> None:
assert context.resolution_ctx.scope == expected, (
f"Expected scope {expected!r}, got {context.resolution_ctx.scope!r}"
)
@then("the ScopeResolutionContext metadata should be empty")
def step_check_ctx_metadata_empty(context: Context) -> None:
assert context.resolution_ctx.metadata == {}
@then("the ScopeResolutionContext resolved_fragments should be empty")
def step_check_ctx_frags_empty(context: Context) -> None:
assert context.resolution_ctx.resolved_fragments == []
@then('the ScopeResolutionContext metadata key "{key}" should be "{expected}"')
def step_check_ctx_metadata_key(context: Context, key: str, expected: str) -> None:
assert context.resolution_ctx.metadata.get(key) == expected, (
f"Expected metadata[{key!r}]={expected!r}, got {context.resolution_ctx.metadata}"
)
@then('the ScopeResolutionContext resolved_fragments should contain "{frag}"')
def step_check_ctx_frags_contain(context: Context, frag: str) -> None:
assert frag in context.resolution_ctx.resolved_fragments, (
f"Expected {frag!r} in resolved_fragments {context.resolution_ctx.resolved_fragments}"
)
@then("the resolution result should be empty")
def step_result_empty(context: Context) -> None:
assert context.resolution_result == [], (
f"Expected empty result, got {context.resolution_result}"
)
@then('the resolution result should contain "{frag}"')
def step_result_contains(context: Context, frag: str) -> None:
assert frag in context.resolution_result, (
f"Expected {frag!r} in result {context.resolution_result}"
)
@then('the resolution result should not contain "{frag}"')
def step_result_not_contains(context: Context, frag: str) -> None:
assert frag not in context.resolution_result, (
f"Did not expect {frag!r} in result {context.resolution_result}"
)
@then('the registry should contain a resolver named "{name}"')
def step_registry_has_resolver(context: Context, name: str) -> None:
assert name in context.registry.get_resolvers(), (
f"Expected resolver {name!r} in registry, got {list(context.registry.get_resolvers())}"
)
@then('the registry should not contain a resolver named "{name}"')
def step_registry_no_resolver(context: Context, name: str) -> None:
assert name not in context.registry.get_resolvers(), (
f"Did not expect resolver {name!r} in registry"
)
@then('the resolver named "{name}" should have priority {expected:d}')
def step_resolver_priority(context: Context, name: str, expected: int) -> None:
resolvers = context.registry.get_resolvers()
assert name in resolvers, f"Resolver {name!r} not found"
_, priority = resolvers[name]
assert priority == expected, f"Expected priority {expected}, got {priority}"
@then("get_resolvers should return {count:d} entries")
def step_get_resolvers_count(context: Context, count: int) -> None:
actual = len(context.registry.get_resolvers())
assert actual == count, f"Expected {count} resolvers, got {actual}"
@then('get_resolvers should include key "{key}"')
def step_get_resolvers_has_key(context: Context, key: str) -> None:
assert key in context.registry.get_resolvers(), (
f"Expected key {key!r} in get_resolvers()"
)
@then("list_resolvers should return {count:d} entries in priority order")
def step_list_resolvers_count_and_order(context: Context, count: int) -> None:
result = context.registry.list_resolvers()
assert len(result) == count, f"Expected {count} entries, got {len(result)}: {result}"
if context.table:
for i, row in enumerate(context.table):
expected_name = row["name"]
expected_priority = int(row["priority"])
actual_name, actual_priority = result[i]
assert actual_name == expected_name, (
f"Index {i}: expected name {expected_name!r}, got {actual_name!r}"
)
assert actual_priority == expected_priority, (
f"Index {i}: expected priority {expected_priority}, got {actual_priority}"
)
@then("the scope resolver registry should be empty")
def step_registry_empty(context: Context) -> None:
assert context.registry.get_resolvers() == {}, (
f"Expected empty registry, got {list(context.registry.get_resolvers())}"
)