"""Step definitions for Skill Registry flattened tool persistence. Covers the m4_002 columns: ``flattened_tools_json``, ``includes_json``, ``capability_summary_json``, ``yaml_text``, ``flattening_hash`` and the related ``SkillRepository`` helper methods. """ from __future__ import annotations from collections.abc import Callable from typing import Any from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker from cleveragents.domain.models.core.skill import Skill from cleveragents.infrastructure.database.models import Base from cleveragents.infrastructure.database.repositories import ( DuplicateSkillError, SkillRepository, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _enable_fk_pragma(dbapi_conn: Any, _connection_record: Any) -> None: """Enable SQLite foreign key enforcement per connection.""" cursor = dbapi_conn.cursor() cursor.execute("PRAGMA foreign_keys=ON") cursor.close() def _factory(context: Context) -> Callable[[], Session]: return context.sp_session_factory # type: ignore[no-any-return] def _commit(context: Context) -> None: _factory(context)().commit() def _make_skill(name: str, description: str = "Test skill") -> Skill: return Skill(name=name, description=description) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a clean in-memory database with the skill persistence schema") def step_clean_db(context: Context) -> None: engine = create_engine("sqlite:///:memory:", echo=False) event.listen(engine, "connect", _enable_fk_pragma) Base.metadata.create_all(engine) shared_session: Session = sessionmaker(bind=engine)() def _sf() -> Session: return shared_session context.sp_engine = engine context.sp_session_factory = _sf context.sp_error: BaseException | None = None context.sp_recomputed_hash: str | None = None context.sp_listed: list[Skill] = [] @given("a skill persistence repository") def step_repo(context: Context) -> None: context.sp_repo = SkillRepository(session_factory=_factory(context)) # --------------------------------------------------------------------------- # Skill creation # --------------------------------------------------------------------------- @given('a skill "{name}" with description "{desc}"') def step_skill(context: Context, name: str, desc: str) -> None: context.sp_current_skill = _make_skill(name, desc) @when("the skill is created via the persistence repository") def step_create(context: Context) -> None: context.sp_repo.create(context.sp_current_skill) _commit(context) @given("the skill is created via the persistence repository") def step_create_given(context: Context) -> None: context.sp_repo.create(context.sp_current_skill) _commit(context) # --------------------------------------------------------------------------- # Flattened tools update # --------------------------------------------------------------------------- @when("the flattened tools are updated with tools json '{tools}' and hash \"{h}\"") def step_update_flat(context: Context, tools: str, h: str) -> None: name = context.sp_current_skill.name context.sp_repo.update_flattened_tools( name=name, flattened_tools_json=tools, includes_json="[]", capability_summary_json="{}", yaml_text="name: " + name, flattening_hash=h, ) _commit(context) @given("the flattened tools are updated with tools json '{tools}' and hash \"{h}\"") def step_update_flat_given(context: Context, tools: str, h: str) -> None: name = context.sp_current_skill.name context.sp_repo.update_flattened_tools( name=name, flattened_tools_json=tools, includes_json="[]", capability_summary_json="{}", yaml_text="name: " + name, flattening_hash=h, ) _commit(context) # --------------------------------------------------------------------------- # Assertions on stored fields # --------------------------------------------------------------------------- @then("the stored flattened tools json should be '{expected}'") def step_check_tools(context: Context, expected: str) -> None: data = context.sp_repo.get_flattened_tools(context.sp_current_skill.name) assert data["flattened_tools_json"] == expected, ( f"Expected {expected!r}, got {data['flattened_tools_json']!r}" ) @then('the stored flattening hash should be "{expected}"') def step_check_hash(context: Context, expected: str) -> None: data = context.sp_repo.get_flattened_tools(context.sp_current_skill.name) assert data["flattening_hash"] == expected, ( f"Expected {expected!r}, got {data['flattening_hash']!r}" ) # --------------------------------------------------------------------------- # Update & invalidation # --------------------------------------------------------------------------- @when('the skill "{name}" is updated with description "{desc}"') def step_update_skill(context: Context, name: str, desc: str) -> None: updated = _make_skill(name, desc) context.sp_repo.update(updated) _commit(context) @then('the stored flattened tools json for "{name}" should be null') def step_check_tools_null(context: Context, name: str) -> None: data = context.sp_repo.get_flattened_tools(name) assert data["flattened_tools_json"] is None, ( f"Expected None, got {data['flattened_tools_json']!r}" ) @then('the stored capability summary json for "{name}" should be null') def step_check_cap_null(context: Context, name: str) -> None: data = context.sp_repo.get_flattened_tools(name) assert data["capability_summary_json"] is None, ( f"Expected None, got {data['capability_summary_json']!r}" ) @then('the stored flattening hash for "{name}" should be null') def step_check_hash_null(context: Context, name: str) -> None: data = context.sp_repo.get_flattened_tools(name) assert data["flattening_hash"] is None, ( f"Expected None, got {data['flattening_hash']!r}" ) # --------------------------------------------------------------------------- # Hash-based staleness # --------------------------------------------------------------------------- @then('the skill "{name}" should not need refresh for hash "{h}"') def step_no_refresh(context: Context, name: str, h: str) -> None: assert not context.sp_repo.needs_refresh(name, h) @then('the skill "{name}" should need refresh for hash "{h}"') def step_needs_refresh(context: Context, name: str, h: str) -> None: assert context.sp_repo.needs_refresh(name, h) # --------------------------------------------------------------------------- # Recompute # --------------------------------------------------------------------------- @when('the flattening hash for "{name}" is recomputed from yaml "{yaml}"') def step_recompute(context: Context, name: str, yaml: str) -> None: context.sp_recomputed_hash = context.sp_repo.recompute_flattening_hash(name, yaml) _commit(context) @then('the stored flattening hash for "{name}" should not be null') def step_hash_not_null(context: Context, name: str) -> None: data = context.sp_repo.get_flattened_tools(name) assert data["flattening_hash"] is not None @then('the skill "{name}" should not need refresh for the recomputed hash') def step_no_refresh_recomputed(context: Context, name: str) -> None: h = context.sp_recomputed_hash assert h is not None, "Recomputed hash is None" assert not context.sp_repo.needs_refresh(name, h) # --------------------------------------------------------------------------- # Uniqueness constraint # --------------------------------------------------------------------------- @when('a second skill "{name}" with description "{desc}" is created') def step_create_duplicate(context: Context, name: str, desc: str) -> None: try: context.sp_repo.create(_make_skill(name, desc)) _commit(context) context.sp_error = None except DuplicateSkillError as exc: context.sp_error = exc except Exception as exc: cause = exc.__cause__ or exc if isinstance(cause, DuplicateSkillError): context.sp_error = cause else: context.sp_error = exc @then("the second creation should raise a duplicate error") def step_check_dup(context: Context) -> None: assert isinstance(context.sp_error, DuplicateSkillError), ( f"Expected DuplicateSkillError, got {type(context.sp_error)}" ) # --------------------------------------------------------------------------- # Namespace filtering # --------------------------------------------------------------------------- @when('skills are listed with namespace "{ns}"') def step_list_ns(context: Context, ns: str) -> None: context.sp_listed = context.sp_repo.list_all(namespace=ns) @then('the listed skills should contain "{name}"') def step_list_contains(context: Context, name: str) -> None: names = [s.name for s in context.sp_listed] assert name in names, f"{name} not in {names}" @then('the listed skills should not contain "{name}"') def step_list_not_contains(context: Context, name: str) -> None: names = [s.name for s in context.sp_listed] assert name not in names, f"{name} unexpectedly in {names}"