"""Test doubles for UKO Indexer BDD and Robot tests. Provides in-memory content readers, failing backends, tracking lifecycle hooks, and event bus stubs used by ``features/steps/uko_indexer_steps.py``, ``robot/helper_uko_indexer.py``, and ``benchmarks/uko_indexer_bench.py``. """ from __future__ import annotations import threading from collections.abc import Callable from typing import Any from cleveragents.domain.models.acms.index_backends import ( IndexedDocument, SearchResult, ) from cleveragents.domain.models.acms.provenance import IndexResult from cleveragents.domain.models.core.resource import Resource from cleveragents.infrastructure.events.models import DomainEvent from cleveragents.infrastructure.events.types import EventType # --------------------------------------------------------------------------- # Content readers # --------------------------------------------------------------------------- class InMemoryContentReader: """Content reader that returns pre-configured content strings.""" def __init__(self) -> None: self._content: dict[str, str] = {} def set_content(self, resource_id: str, content: str) -> None: self._content[resource_id] = content def read_content(self, resource: Resource) -> str: if resource.resource_id in self._content: return self._content[resource.resource_id] raise OSError(f"No content for {resource.resource_id}") class FailingContentReader: """Content reader that always raises OSError.""" def read_content(self, resource: Resource) -> str: raise OSError("Simulated read failure") # --------------------------------------------------------------------------- # Lifecycle hooks # --------------------------------------------------------------------------- class TrackingLifecycleHook: """Lifecycle hook that records events for assertion.""" def __init__(self) -> None: self.indexed_events: list[IndexResult] = [] self.removed_events: list[tuple[str, str]] = [] self.error_events: list[tuple[str, str]] = [] def on_indexed(self, result: IndexResult) -> None: self.indexed_events.append(result) def on_removed(self, resource_id: str, project: str) -> None: self.removed_events.append((resource_id, project)) def on_error(self, resource_id: str, error: str) -> None: self.error_events.append((resource_id, error)) # --------------------------------------------------------------------------- # Failing backends (test doubles) # --------------------------------------------------------------------------- class FailingGraphBackend: """Graph backend that raises on add_triple.""" def add_triple( self, project: str, subject: str, predicate: str, obj: str, ) -> None: raise RuntimeError("Simulated graph failure") def remove_triples( self, project: str, subject: str | None, predicate: str | None, obj: str | None, ) -> None: pass def query(self, project: str, sparql: str) -> list[dict[str, str]]: return [] def triple_count(self, project: str | None = None) -> int: return 0 class FailingTextBackend: """Text backend that raises on index_document.""" def index_document( self, project: str, doc_id: str, content: str, metadata: dict[str, str], ) -> IndexedDocument: raise RuntimeError("Simulated text failure") def search( self, project: str, query: str, limit: int = 20, ) -> list[SearchResult]: return [] def remove_document(self, project: str, doc_id: str) -> None: pass def rebuild_index(self, project: str) -> None: pass @property def document_count(self) -> int: return 0 class FailingVectorBackend: """Vector backend that raises on index_embedding.""" def index_embedding( self, project: str, doc_id: str, embedding: list[float], metadata: dict[str, str], ) -> None: raise RuntimeError("Simulated vector failure") def search_similar( self, project: str, query_embedding: list[float], limit: int = 20, min_relevance: float = 0.0, ) -> list[SearchResult]: return [] def remove_embedding(self, project: str, doc_id: str) -> None: pass @property def embedding_count(self) -> int: return 0 # --------------------------------------------------------------------------- # Event bus stubs # --------------------------------------------------------------------------- class TrackingEventBus: """Minimal EventBus stub that records emitted events.""" def __init__(self) -> None: self.events: list[DomainEvent] = [] self._event = threading.Event() def emit(self, event: DomainEvent) -> None: self.events.append(event) self._event.set() def subscribe( self, event_type: EventType, handler: Callable[..., Any], ) -> None: _ = event_type, handler # Not needed for tests def wait(self, timeout: float = 2.0) -> bool: return self._event.wait(timeout=timeout) # --------------------------------------------------------------------------- # Backends that fail on removal (not on add) # --------------------------------------------------------------------------- class RemovalFailingGraphBackend: """Graph backend that works for add but fails on remove_triples.""" def __init__(self) -> None: self._triples: list[tuple[str, str, str, str]] = [] def add_triple(self, project: str, subject: str, predicate: str, obj: str) -> None: self._triples.append((project, subject, predicate, obj)) def remove_triples( self, project: str, subject: str | None, predicate: str | None, obj: str | None ) -> None: raise RuntimeError("Simulated graph removal failure") def query(self, project: str, sparql: str) -> list[dict[str, str]]: return [] def triple_count(self, project: str | None = None) -> int: return len(self._triples) class RemovalFailingTextBackend: """Text backend that works for add but fails on remove_document.""" def index_document( self, project: str, doc_id: str, content: str, metadata: dict[str, str] ) -> IndexedDocument: return IndexedDocument(project=project, doc_id=doc_id, char_count=len(content)) def search(self, project: str, query: str, limit: int = 20) -> list[SearchResult]: return [] def remove_document(self, project: str, doc_id: str) -> None: raise RuntimeError("Simulated text removal failure") def rebuild_index(self, project: str) -> None: pass @property def document_count(self) -> int: return 0 class RemovalFailingVectorBackend: """Vector backend that works for add but fails on remove_embedding.""" def index_embedding( self, project: str, doc_id: str, embedding: list[float], metadata: dict[str, str], ) -> None: pass def search_similar( self, project: str, query_embedding: list[float], limit: int = 20, min_relevance: float = 0.0, ) -> list[SearchResult]: return [] def remove_embedding(self, project: str, doc_id: str) -> None: raise RuntimeError("Simulated vector removal failure") @property def embedding_count(self) -> int: return 0 class SelectiveFailingGraphBackend: """Graph backend that fails only on specific predicates.""" def __init__(self, fail_on: set[str] | None = None) -> None: self._triples: list[tuple[str, str, str, str]] = [] self._fail_on = fail_on or set() def add_triple(self, project: str, subject: str, predicate: str, obj: str) -> None: if predicate in self._fail_on: raise RuntimeError(f"Simulated failure on {predicate}") self._triples.append((project, subject, predicate, obj)) def remove_triples( self, project: str, subject: str | None, predicate: str | None, obj: str | None ) -> None: pass def query(self, project: str, sparql: str) -> list[dict[str, str]]: return [] def triple_count(self, project: str | None = None) -> int: return len(self._triples)