177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Contract tests for :class:`BaseControlCommandHandler` subclasses.
|
|
|
|
These tests pin down the contract that anything registered into the
|
|
control-command dispatch table must satisfy. When a future handler is
|
|
added (or the base interface drifts) these tests fail before integration
|
|
tests get a chance to surface a more confusing symptom.
|
|
|
|
Specifically:
|
|
|
|
- every subclass has a non-empty ``command_name`` starting with ``/``;
|
|
- every subclass implements an *async* ``handle`` returning ``str``;
|
|
- the global registry includes all six default handlers;
|
|
- the registry rejects empty ``command_name`` registrations.
|
|
"""
|
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
|
# pylint: disable=wrong-import-position,no-name-in-module,c-extension-no-member
|
|
# flake8: noqa: E402
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
from typing import get_type_hints
|
|
|
|
import pytest
|
|
|
|
from pineagents.runtime.commands import control as control_commands
|
|
from pineagents.runtime.commands.control.base import (
|
|
BaseControlCommandHandler,
|
|
)
|
|
|
|
|
|
_EXPECTED_DEFAULTS = {
|
|
"/approval",
|
|
"/approve",
|
|
"/deny",
|
|
"/model",
|
|
"/skills",
|
|
"/stop",
|
|
}
|
|
|
|
|
|
def _all_subclasses(cls: type) -> set[type]:
|
|
"""Return *cls* and all transitively-defined subclasses."""
|
|
direct = set(cls.__subclasses__())
|
|
nested: set[type] = set()
|
|
for sub in direct:
|
|
nested.update(_all_subclasses(sub))
|
|
return direct | nested
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discoverable subclasses
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _concrete_handlers() -> list[type[BaseControlCommandHandler]]:
|
|
"""Return all non-abstract subclasses of BaseControlCommandHandler."""
|
|
subs = _all_subclasses(BaseControlCommandHandler)
|
|
return [cls for cls in subs if not inspect.isabstract(cls)]
|
|
|
|
|
|
def test_at_least_six_concrete_handlers_are_discoverable():
|
|
# Sanity guard: if a refactor accidentally drops a default handler,
|
|
# this assertion catches it before the runtime dispatch does.
|
|
handlers = _concrete_handlers()
|
|
|
|
assert len(handlers) >= 6, (
|
|
"Expected at least 6 default control command handlers; "
|
|
f"found {len(handlers)}: "
|
|
f"{sorted(c.__name__ for c in handlers)}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"handler_cls",
|
|
_concrete_handlers(),
|
|
ids=lambda c: c.__name__,
|
|
)
|
|
def test_handler_has_non_empty_slash_command_name(handler_cls):
|
|
name = handler_cls.command_name
|
|
|
|
assert isinstance(name, str), (
|
|
f"{handler_cls.__name__}.command_name must be str, "
|
|
f"got {type(name).__name__}"
|
|
)
|
|
assert name, f"{handler_cls.__name__}.command_name is empty"
|
|
assert name.startswith("/"), (
|
|
f"{handler_cls.__name__}.command_name must start with '/', "
|
|
f"got {name!r}"
|
|
)
|
|
# No whitespace inside the command token.
|
|
assert " " not in name.strip(
|
|
"/",
|
|
), f"{handler_cls.__name__}.command_name has whitespace: {name!r}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"handler_cls",
|
|
_concrete_handlers(),
|
|
ids=lambda c: c.__name__,
|
|
)
|
|
def test_handler_handle_is_async_and_takes_context(handler_cls):
|
|
handle = getattr(handler_cls, "handle", None)
|
|
assert handle is not None, f"{handler_cls.__name__} missing handle()"
|
|
assert asyncio.iscoroutinefunction(
|
|
handle,
|
|
), f"{handler_cls.__name__}.handle must be async"
|
|
|
|
sig = inspect.signature(handle)
|
|
params = list(sig.parameters.values())
|
|
# (self, context) -> str
|
|
assert len(params) == 2, (
|
|
f"{handler_cls.__name__}.handle expected 2 params (self, context), "
|
|
f"got {[p.name for p in params]}"
|
|
)
|
|
|
|
# Return annotation should be ``str`` when present.
|
|
try:
|
|
hints = get_type_hints(handle)
|
|
except (NameError, TypeError):
|
|
hints = {}
|
|
if "return" in hints:
|
|
assert hints["return"] is str, (
|
|
f"{handler_cls.__name__}.handle should return str, "
|
|
f"annotated {hints['return']}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry expectations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_registry_contains_all_six_default_commands():
|
|
registered = set(control_commands._COMMAND_REGISTRY.keys())
|
|
|
|
missing = _EXPECTED_DEFAULTS - registered
|
|
assert not missing, f"Missing default control commands: {missing}"
|
|
|
|
|
|
@pytest.mark.parametrize("command", sorted(_EXPECTED_DEFAULTS))
|
|
def test_is_control_command_recognises_each_default(command):
|
|
assert control_commands.is_control_command(command) is True
|
|
# With trailing args, recognition still holds.
|
|
assert control_commands.is_control_command(f"{command} extra args") is True
|
|
|
|
|
|
def test_is_control_command_rejects_empty_and_non_command_input():
|
|
assert control_commands.is_control_command(None) is False
|
|
assert control_commands.is_control_command("") is False
|
|
assert control_commands.is_control_command("hello") is False
|
|
assert control_commands.is_control_command("/totally-bogus") is False
|
|
# Non-string defensively rejected.
|
|
bogus_input: object = 123
|
|
assert control_commands.is_control_command(bogus_input) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# register_command guard rails
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_register_command_rejects_empty_command_name():
|
|
class _Bad(BaseControlCommandHandler):
|
|
command_name = "" # invalid
|
|
|
|
async def handle(self, context): # noqa: D401
|
|
return "noop"
|
|
|
|
with pytest.raises(ValueError):
|
|
control_commands.register_command(_Bad())
|
|
|
|
|
|
def test_unregister_unknown_command_returns_false():
|
|
assert control_commands.unregister_command("/never-registered") is False
|