"""Tests for ``alfred.agent.workflows.loader.WorkflowLoader``. Two layers of coverage: 1. **TestRealWorkflows** — Asserts on the YAML files that ship in the repo (``alfred/agent/workflows/``). These tests act as a structural contract: if a step id, tool name, or naming convention is renamed, the test surfaces the change immediately. They use the real loader with no monkeypatching. 2. **TestLoaderMechanics** — Loader behavior in isolation, using a monkeypatched workflows directory. Covers ``get`` / ``names`` / ``all``, YAML ``name`` precedence over filename, malformed-file resilience, deterministic ordering on name collision, and the empty-directory case. Current workflow naming convention is ``.`` (e.g. ``media.organize_media``), not the legacy bare ``organize_media``. """ import pytest import yaml from alfred.agent.workflows.loader import WorkflowLoader ORGANIZE_MEDIA = "media.organize_media" MANAGE_SUBTITLES = "media.manage_subtitles" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def workflows_dir(tmp_path): """A temp directory pre-populated with one valid workflow YAML.""" wf = { "name": "test_workflow", "description": "A test workflow", "tools": ["list_folder", "move_media"], "steps": [ {"id": "step1", "tool": "list_folder"}, ], } (tmp_path / "test_workflow.yaml").write_text(yaml.dump(wf)) return tmp_path @pytest.fixture def loader_from_dir(workflows_dir, monkeypatch): """WorkflowLoader pointed at our temp dir.""" import alfred.agent.workflows.loader as loader_module monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir) return WorkflowLoader() # --------------------------------------------------------------------------- # Real loader (loads actual YAML files from the repo) # --------------------------------------------------------------------------- class TestRealWorkflows: """Contract tests against the workflows shipped in ``alfred/agent/workflows``.""" def test_organize_media_loaded(self): loader = WorkflowLoader() assert ORGANIZE_MEDIA in loader.names() def test_manage_subtitles_loaded(self): loader = WorkflowLoader() assert MANAGE_SUBTITLES in loader.names() def test_organize_media_has_required_keys(self): loader = WorkflowLoader() wf = loader.get(ORGANIZE_MEDIA) assert wf is not None assert wf["name"] == ORGANIZE_MEDIA assert "steps" in wf assert "tools" in wf def test_organize_media_tools_list(self): loader = WorkflowLoader() wf = loader.get(ORGANIZE_MEDIA) tools = wf["tools"] # The four required tools that compose the move pipeline. for required in ( "list_folder", "move_to_destination", "manage_subtitles", "create_seed_links", ): assert required in tools, f"missing tool: {required}" # There is no single ``resolve_destination`` tool anymore — the # workflow declares the four media-type-specific resolvers. for resolver in ( "resolve_season_destination", "resolve_episode_destination", "resolve_movie_destination", "resolve_series_destination", ): assert resolver in tools, f"missing resolver: {resolver}" def test_organize_media_steps_order(self): loader = WorkflowLoader() wf = loader.get(ORGANIZE_MEDIA) step_ids = [s["id"] for s in wf["steps"]] # resolve_destination is the *step id* (not tool name) that fans # out to the four resolvers. assert step_ids.index("resolve_destination") < step_ids.index("move_file") assert step_ids.index("move_file") < step_ids.index("handle_subtitles") assert step_ids.index("ask_seeding") < step_ids.index("create_seed_links") def test_ask_seeding_has_yes_no_answers(self): loader = WorkflowLoader() wf = loader.get(ORGANIZE_MEDIA) ask_step = next(s for s in wf["steps"] if s["id"] == "ask_seeding") answers = ask_step["ask_user"]["answers"] # PyYAML parses bare yes/no as booleans, quoted as strings — normalize. answer_keys = {str(k).lower() for k in answers.keys()} assert "yes" in answer_keys assert "no" in answer_keys def test_naming_convention_present(self): loader = WorkflowLoader() wf = loader.get(ORGANIZE_MEDIA) assert "naming_convention" in wf assert "tv_show" in wf["naming_convention"] assert "movie" in wf["naming_convention"] # --------------------------------------------------------------------------- # WorkflowLoader mechanics (via monkeypatched dir) # --------------------------------------------------------------------------- class TestLoaderMechanics: """Loader behavior driven by YAML files in a temp directory.""" def test_get_returns_workflow(self, loader_from_dir): wf = loader_from_dir.get("test_workflow") assert wf is not None assert wf["name"] == "test_workflow" def test_get_returns_none_for_unknown(self, loader_from_dir): assert loader_from_dir.get("nonexistent") is None def test_names_returns_list(self, loader_from_dir): names = loader_from_dir.names() assert isinstance(names, list) assert "test_workflow" in names def test_all_returns_dict(self, loader_from_dir): all_wf = loader_from_dir.all() assert isinstance(all_wf, dict) assert "test_workflow" in all_wf def test_uses_yaml_name_field(self, tmp_path, monkeypatch): """Name from YAML content takes priority over filename stem.""" import alfred.agent.workflows.loader as loader_module monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) wf = {"name": "my_custom_name", "steps": []} (tmp_path / "completely_different_filename.yaml").write_text(yaml.dump(wf)) loader = WorkflowLoader() assert "my_custom_name" in loader.names() assert "completely_different_filename" not in loader.names() def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch): import alfred.agent.workflows.loader as loader_module monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) (tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []})) loader = WorkflowLoader() assert "my_workflow" in loader.names() def test_skips_malformed_yaml(self, tmp_path, monkeypatch): import alfred.agent.workflows.loader as loader_module monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) (tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []})) (tmp_path / "broken.yaml").write_text("key: [unclosed bracket") loader = WorkflowLoader() assert "valid" in loader.names() assert "broken" not in loader.names() def test_deterministic_load_order(self, tmp_path, monkeypatch): """Files loaded in sorted order — later file wins on name collision.""" import alfred.agent.workflows.loader as loader_module monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) (tmp_path / "a_workflow.yaml").write_text( yaml.dump({"name": "duplicate", "version": 1}) ) (tmp_path / "b_workflow.yaml").write_text( yaml.dump({"name": "duplicate", "version": 2}) ) loader = WorkflowLoader() # b_workflow loaded last → version 2 wins assert loader.get("duplicate")["version"] == 2 def test_empty_directory(self, tmp_path, monkeypatch): import alfred.agent.workflows.loader as loader_module monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) loader = WorkflowLoader() assert loader.names() == [] assert loader.all() == {}