"""Tests for ``alfred.agent.registry`` — tool registration and JSON schema gen. Two suites: 1. **TestCreateToolFromFunction** — Unit-tests the schema extraction from a bare Python function: name resolution, docstring → description, required versus optional parameters, ``Optional[X]`` / ``X | None`` stripping, and the Python-to-JSON-Schema type mapping (``str/int/float/bool/list/dict`` → ``string/integer/number/boolean/array/object``). 2. **TestMakeTools** — Integration check on the live registry: every tool declared in ``make_tools(settings)`` is a real ``Tool`` instance with a callable ``func`` and a name matching its dict key, and a known core set of tools is always present. Resolver tests target the four media-typed resolvers (``resolve_movie_destination``, ``_season_``, ``_episode_``, ``_series_``), not the legacy unified ``resolve_destination`` which no longer exists. """ from alfred.agent.registry import Tool, _create_tool_from_function, make_tools from alfred.settings import settings # --------------------------------------------------------------------------- # _create_tool_from_function # --------------------------------------------------------------------------- class TestCreateToolFromFunction: def test_name_from_function(self): def my_tool(x: str) -> dict: """Does something.""" return {} tool = _create_tool_from_function(my_tool) assert tool.name == "my_tool" def test_description_from_docstring_first_line(self): def my_tool(x: str) -> dict: """First line description. More details here. """ return {} tool = _create_tool_from_function(my_tool) assert tool.description == "First line description." def test_description_fallback_to_name(self): def no_doc(x: str) -> dict: return {} tool = _create_tool_from_function(no_doc) assert tool.description == "no_doc" def test_required_params_without_default(self): def tool(a: str, b: int) -> dict: """Tool.""" return {} t = _create_tool_from_function(tool) assert "a" in t.parameters["required"] assert "b" in t.parameters["required"] def test_optional_params_not_required(self): def tool(a: str, b: str = "default") -> dict: """Tool.""" return {} t = _create_tool_from_function(tool) assert "a" in t.parameters["required"] assert "b" not in t.parameters["required"] def test_none_default_not_required(self): def tool(a: str, b: str | None = None) -> dict: """Tool.""" return {} t = _create_tool_from_function(tool) assert "b" not in t.parameters["required"] def test_type_mapping_str(self): def tool(x: str) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "string" def test_type_mapping_int(self): def tool(x: int) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "integer" def test_type_mapping_float(self): def tool(x: float) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "number" def test_type_mapping_bool(self): def tool(x: bool) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "boolean" def test_type_mapping_list(self): def tool(x: list) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "array" def test_type_mapping_dict(self): def tool(x: dict) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "object" def test_unknown_type_defaults_to_string(self): """Custom classes without a JSON-Schema mapping fall back to ``string``.""" class CustomType: pass def tool(x: CustomType) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "string" def test_optional_annotation_unwrapped(self): def tool(x: str | None = None) -> dict: """T.""" return {} t = _create_tool_from_function(tool) # ``str | None`` should unwrap to ``str``, not fall back to "string" # by accident — the mapping is intentional. assert t.parameters["properties"]["x"]["type"] == "string" def test_no_annotation_defaults_to_string(self): def tool(x) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "string" def test_self_param_excluded(self): class MyClass: def tool(self, x: str) -> dict: """T.""" return {} t = _create_tool_from_function(MyClass().tool) assert "self" not in t.parameters["properties"] def test_parameters_schema_structure(self): def tool(a: str, b: int = 0) -> dict: """T.""" return {} t = _create_tool_from_function(tool) assert t.parameters["type"] == "object" assert "properties" in t.parameters assert "required" in t.parameters def test_func_stored_on_tool(self): def tool(x: str) -> dict: """T.""" return {"x": x} t = _create_tool_from_function(tool) assert t.func("hello") == {"x": "hello"} # --------------------------------------------------------------------------- # make_tools # --------------------------------------------------------------------------- class TestMakeTools: def test_returns_dict(self): tools = make_tools(settings) assert isinstance(tools, dict) def test_all_expected_tools_present(self): """Core tool set that the agent needs to perform the end-to-end flow.""" tools = make_tools(settings) expected = { # Folder & filesystem "set_path_for_folder", "list_folder", "move_media", "move_to_destination", # Resolvers (one per media type — no unified resolve_destination) "resolve_season_destination", "resolve_episode_destination", "resolve_movie_destination", "resolve_series_destination", # Subtitles & seeding "manage_subtitles", "create_seed_links", "learn", # API "find_media_imdb_id", "find_torrent", "add_torrent_by_index", "add_torrent_to_qbittorrent", "get_torrent_by_index", # Conversation "set_language", } missing = expected - tools.keys() assert not missing, f"missing tools: {sorted(missing)}" def test_no_legacy_unified_resolver(self): """The single ``resolve_destination`` tool was replaced by four typed resolvers.""" tools = make_tools(settings) assert "resolve_destination" not in tools def test_each_tool_is_tool_instance(self): tools = make_tools(settings) for name, tool in tools.items(): assert isinstance(tool, Tool), f"{name} is not a Tool instance" def test_each_tool_has_callable_func(self): tools = make_tools(settings) for name, tool in tools.items(): assert callable(tool.func), f"{name}.func is not callable" def test_tool_name_matches_key(self): tools = make_tools(settings) for key, tool in tools.items(): assert tool.name == key def test_resolve_movie_destination_schema(self): tools = make_tools(settings) t = tools["resolve_movie_destination"] # Required args common to all movie resolutions. for required_arg in ("source_file", "tmdb_title", "tmdb_year"): assert required_arg in t.parameters["required"], ( f"resolve_movie_destination should require {required_arg}" ) # tmdb_year is typed as int. assert t.parameters["properties"]["tmdb_year"]["type"] == "integer" def test_resolve_episode_destination_schema(self): tools = make_tools(settings) t = tools["resolve_episode_destination"] required = t.parameters["required"] # An episode resolution needs at least the source file and the show # identification (title/year). Season/episode numbers also required. assert "source_file" in required assert "tmdb_title" in required def test_move_media_schema(self): tools = make_tools(settings) t = tools["move_media"] required = t.parameters["required"] assert "source" in required assert "destination" in required def test_create_seed_links_schema(self): tools = make_tools(settings) t = tools["create_seed_links"] required = t.parameters["required"] assert "library_file" in required assert "original_download_folder" in required def test_no_duplicate_tools(self): tools = make_tools(settings) # dict keys are unique by definition, but verify no name conflicts names = [t.name for t in tools.values()] assert len(names) == len(set(names))