/ tests / test_commands.py
test_commands.py
  1  """Tests for mureo slash commands and workflow skill."""
  2  
  3  from __future__ import annotations
  4  
  5  import re
  6  from pathlib import Path
  7  
  8  import pytest
  9  
 10  # Project root and command directory
 11  PROJECT_ROOT = Path(__file__).parent.parent
 12  COMMANDS_DIR = PROJECT_ROOT / ".claude" / "commands"
 13  SKILL_FILE = PROJECT_ROOT / "skills" / "mureo-workflows" / "SKILL.md"
 14  
 15  # All expected command files
 16  EXPECTED_COMMANDS = [
 17      "onboard.md",
 18      "daily-check.md",
 19      "rescue.md",
 20      "search-term-cleanup.md",
 21      "creative-refresh.md",
 22      "budget-rebalance.md",
 23      "competitive-scan.md",
 24      "sync-state.md",
 25      "goal-review.md",
 26      "weekly-report.md",
 27      "learn.md",
 28  ]
 29  
 30  # Workflow commands (exclude utility commands like learn)
 31  WORKFLOW_COMMANDS = [c for c in EXPECTED_COMMANDS if c != "learn.md"]
 32  
 33  # Known MCP tool names extracted from SKILL.md files
 34  # Google Ads tools (82 tools from mureo-google-ads/SKILL.md)
 35  GOOGLE_ADS_TOOLS: set[str] = {
 36      "google_ads.campaigns.list",
 37      "google_ads.campaigns.get",
 38      "google_ads.campaigns.create",
 39      "google_ads.campaigns.update",
 40      "google_ads.campaigns.update_status",
 41      "google_ads.campaigns.diagnose",
 42      "google_ads.ad_groups.list",
 43      "google_ads.ad_groups.create",
 44      "google_ads.ad_groups.update",
 45      "google_ads.ads.list",
 46      "google_ads.ads.create",
 47      "google_ads.ads.update",
 48      "google_ads.ads.update_status",
 49      "google_ads.ads.policy_details",
 50      "google_ads.keywords.list",
 51      "google_ads.keywords.add",
 52      "google_ads.keywords.remove",
 53      "google_ads.keywords.suggest",
 54      "google_ads.keywords.diagnose",
 55      "google_ads.keywords.pause",
 56      "google_ads.keywords.audit",
 57      "google_ads.keywords.cross_adgroup_duplicates",
 58      "google_ads.negative_keywords.list",
 59      "google_ads.negative_keywords.add",
 60      "google_ads.negative_keywords.remove",
 61      "google_ads.negative_keywords.add_to_ad_group",
 62      "google_ads.negative_keywords.suggest",
 63      "google_ads.budget.get",
 64      "google_ads.budget.update",
 65      "google_ads.budget.create",
 66      "google_ads.accounts.list",
 67      "google_ads.search_terms.report",
 68      "google_ads.search_terms.review",
 69      "google_ads.search_terms.analyze",
 70      "google_ads.sitelinks.list",
 71      "google_ads.sitelinks.create",
 72      "google_ads.sitelinks.remove",
 73      "google_ads.callouts.list",
 74      "google_ads.callouts.create",
 75      "google_ads.callouts.remove",
 76      "google_ads.conversions.list",
 77      "google_ads.conversions.get",
 78      "google_ads.conversions.performance",
 79      "google_ads.conversions.create",
 80      "google_ads.conversions.update",
 81      "google_ads.conversions.remove",
 82      "google_ads.conversions.tag",
 83      "google_ads.recommendations.list",
 84      "google_ads.recommendations.apply",
 85      "google_ads.device_targeting.get",
 86      "google_ads.device_targeting.set",
 87      "google_ads.bid_adjustments.get",
 88      "google_ads.bid_adjustments.update",
 89      "google_ads.location_targeting.list",
 90      "google_ads.location_targeting.update",
 91      "google_ads.schedule_targeting.list",
 92      "google_ads.schedule_targeting.update",
 93      "google_ads.change_history.list",
 94      "google_ads.performance.report",
 95      "google_ads.performance.analyze",
 96      "google_ads.cost_increase.investigate",
 97      "google_ads.health_check.all",
 98      "google_ads.ad_performance.compare",
 99      "google_ads.ad_performance.report",
100      "google_ads.network_performance.report",
101      "google_ads.budget.efficiency",
102      "google_ads.budget.reallocation",
103      "google_ads.auction_insights.get",
104      "google_ads.auction_insights.analyze",
105      "google_ads.rsa_assets.analyze",
106      "google_ads.rsa_assets.audit",
107      "google_ads.cpc.detect_trend",
108      "google_ads.device.analyze",
109      "google_ads.btob.optimizations",
110      "google_ads.landing_page.analyze",
111      "google_ads.creative.research",
112      "google_ads.monitoring.delivery_goal",
113      "google_ads.monitoring.cpa_goal",
114      "google_ads.monitoring.cv_goal",
115      "google_ads.monitoring.zero_conversions",
116      "google_ads.capture.screenshot",
117      "google_ads.assets.upload_image",
118  }
119  
120  # Meta Ads tools (77 tools from mureo-meta-ads/SKILL.md)
121  META_ADS_TOOLS: set[str] = {
122      "meta_ads.campaigns.list",
123      "meta_ads.campaigns.get",
124      "meta_ads.campaigns.create",
125      "meta_ads.campaigns.update",
126      "meta_ads.campaigns.pause",
127      "meta_ads.campaigns.enable",
128      "meta_ads.ad_sets.list",
129      "meta_ads.ad_sets.get",
130      "meta_ads.ad_sets.create",
131      "meta_ads.ad_sets.update",
132      "meta_ads.ad_sets.pause",
133      "meta_ads.ad_sets.enable",
134      "meta_ads.ads.list",
135      "meta_ads.ads.get",
136      "meta_ads.ads.create",
137      "meta_ads.ads.update",
138      "meta_ads.ads.pause",
139      "meta_ads.ads.enable",
140      "meta_ads.insights.report",
141      "meta_ads.insights.breakdown",
142      "meta_ads.analysis.performance",
143      "meta_ads.analysis.audience",
144      "meta_ads.analysis.placements",
145      "meta_ads.analysis.cost",
146      "meta_ads.analysis.compare_ads",
147      "meta_ads.analysis.suggest_creative",
148      "meta_ads.audiences.list",
149      "meta_ads.audiences.get",
150      "meta_ads.audiences.create",
151      "meta_ads.audiences.delete",
152      "meta_ads.audiences.create_lookalike",
153      "meta_ads.pixels.list",
154      "meta_ads.pixels.get",
155      "meta_ads.pixels.stats",
156      "meta_ads.pixels.events",
157      "meta_ads.conversions.send",
158      "meta_ads.conversions.send_purchase",
159      "meta_ads.conversions.send_lead",
160      "meta_ads.creatives.list",
161      "meta_ads.creatives.create",
162      "meta_ads.creatives.create_dynamic",
163      "meta_ads.creatives.upload_image",
164      "meta_ads.creatives.create_carousel",
165      "meta_ads.creatives.create_collection",
166      "meta_ads.images.upload_file",
167      "meta_ads.catalogs.list",
168      "meta_ads.catalogs.get",
169      "meta_ads.catalogs.create",
170      "meta_ads.catalogs.delete",
171      "meta_ads.products.list",
172      "meta_ads.products.get",
173      "meta_ads.products.add",
174      "meta_ads.products.update",
175      "meta_ads.products.delete",
176      "meta_ads.feeds.list",
177      "meta_ads.feeds.create",
178      "meta_ads.lead_forms.list",
179      "meta_ads.lead_forms.get",
180      "meta_ads.lead_forms.create",
181      "meta_ads.leads.get",
182      "meta_ads.leads.get_by_ad",
183      "meta_ads.videos.upload",
184      "meta_ads.videos.upload_file",
185      "meta_ads.split_tests.list",
186      "meta_ads.split_tests.get",
187      "meta_ads.split_tests.create",
188      "meta_ads.split_tests.end",
189      "meta_ads.ad_rules.list",
190      "meta_ads.ad_rules.get",
191      "meta_ads.ad_rules.create",
192      "meta_ads.ad_rules.update",
193      "meta_ads.ad_rules.delete",
194      "meta_ads.page_posts.list",
195      "meta_ads.page_posts.boost",
196      "meta_ads.instagram.accounts",
197      "meta_ads.instagram.media",
198      "meta_ads.instagram.boost",
199  }
200  
201  ALL_KNOWN_TOOLS: set[str] = GOOGLE_ADS_TOOLS | META_ADS_TOOLS
202  
203  # Regex to extract tool names like `google_ads.xxx.yyy` and `meta_ads.xxx.yyy`
204  TOOL_NAME_PATTERN = re.compile(r"`((?:google_ads|meta_ads)\.\w+\.\w+)`")
205  
206  
207  def _extract_tool_references(content: str) -> set[str]:
208      """Extract MCP tool name references from markdown content."""
209      return set(TOOL_NAME_PATTERN.findall(content))
210  
211  
212  class TestCommandFilesExist:
213      """Verify all 10 command files exist in .claude/commands/."""
214  
215      @pytest.mark.unit
216      @pytest.mark.parametrize("filename", EXPECTED_COMMANDS)
217      def test_command_file_exists(self, filename: str) -> None:
218          filepath = COMMANDS_DIR / filename
219          assert filepath.exists(), f"Command file missing: {filepath}"
220  
221      @pytest.mark.unit
222      def test_commands_directory_exists(self) -> None:
223          assert COMMANDS_DIR.is_dir(), f"Commands directory missing: {COMMANDS_DIR}"
224  
225      @pytest.mark.unit
226      def test_exactly_ten_commands(self) -> None:
227          md_files = list(COMMANDS_DIR.glob("*.md"))
228          assert len(md_files) == len(EXPECTED_COMMANDS), (
229              f"Expected {len(EXPECTED_COMMANDS)} command files, "
230              f"found {len(md_files)}: {[f.name for f in md_files]}"
231          )
232  
233  
234  class TestCommandFilesValid:
235      """Verify each command file is valid (non-empty, starts with a description)."""
236  
237      @pytest.mark.unit
238      @pytest.mark.parametrize("filename", EXPECTED_COMMANDS)
239      def test_command_file_is_non_empty(self, filename: str) -> None:
240          filepath = COMMANDS_DIR / filename
241          content = filepath.read_text(encoding="utf-8")
242          assert len(content.strip()) > 0, f"Command file is empty: {filename}"
243  
244      @pytest.mark.unit
245      @pytest.mark.parametrize("filename", EXPECTED_COMMANDS)
246      def test_command_file_starts_with_description(self, filename: str) -> None:
247          filepath = COMMANDS_DIR / filename
248          content = filepath.read_text(encoding="utf-8").strip()
249          first_line = content.split("\n")[0].strip()
250          # First line should be a plain text description (not a heading or empty)
251          assert (
252              len(first_line) > 10
253          ), f"Command file {filename} first line too short: '{first_line}'"
254          assert not first_line.startswith(
255              "---"
256          ), f"Command file {filename} should not start with YAML frontmatter"
257  
258      @pytest.mark.unit
259      @pytest.mark.parametrize("filename", EXPECTED_COMMANDS)
260      def test_command_file_has_steps_section(self, filename: str) -> None:
261          filepath = COMMANDS_DIR / filename
262          content = filepath.read_text(encoding="utf-8")
263          assert (
264              "## Steps" in content or "## steps" in content
265          ), f"Command file {filename} missing ## Steps section"
266  
267  
268  class TestCommandToolReferences:
269      """Verify commands follow orchestration patterns (no hardcoded tool names)."""
270  
271      @pytest.mark.unit
272      @pytest.mark.parametrize("filename", EXPECTED_COMMANDS)
273      def test_no_hardcoded_tool_names(self, filename: str) -> None:
274          """Commands should use intent-based descriptions, not hardcoded tool names."""
275          filepath = COMMANDS_DIR / filename
276          content = filepath.read_text(encoding="utf-8")
277          referenced_tools = _extract_tool_references(content)
278  
279          assert len(referenced_tools) == 0, (
280              f"Command {filename} contains hardcoded tool names: {sorted(referenced_tools)}. "
281              f"Commands should use intent-based descriptions for platform-agnostic orchestration."
282          )
283  
284      @pytest.mark.unit
285      @pytest.mark.parametrize("filename", WORKFLOW_COMMANDS)
286      def test_command_has_platform_discovery(self, filename: str) -> None:
287          """Workflow commands should discover platforms or reference STATE.json platforms."""
288          filepath = COMMANDS_DIR / filename
289          content = filepath.read_text(encoding="utf-8").lower()
290          has_discovery = (
291              "discover platform" in content
292              or "discover available" in content
293              or "platforms" in content
294              or "platform" in content
295          )
296          assert (
297              has_discovery
298          ), f"Command {filename} does not include platform discovery pattern"
299  
300  
301  class TestWorkflowSkill:
302      """Verify the mureo-workflows SKILL.md exists and is well-formed."""
303  
304      @pytest.mark.unit
305      def test_skill_file_exists(self) -> None:
306          assert SKILL_FILE.exists(), f"Workflow skill missing: {SKILL_FILE}"
307  
308      @pytest.mark.unit
309      def test_skill_has_frontmatter(self) -> None:
310          content = SKILL_FILE.read_text(encoding="utf-8")
311          assert content.startswith("---"), "SKILL.md should start with YAML frontmatter"
312          # Should have closing frontmatter delimiter
313          parts = content.split("---", maxsplit=2)
314          assert len(parts) >= 3, "SKILL.md should have complete YAML frontmatter"
315  
316      @pytest.mark.unit
317      def test_skill_has_required_metadata(self) -> None:
318          content = SKILL_FILE.read_text(encoding="utf-8")
319          assert "name: mureo-workflows" in content
320          assert "version: 0.3.0" in content
321  
322      @pytest.mark.unit
323      def test_skill_has_operation_mode_matrix(self) -> None:
324          content = SKILL_FILE.read_text(encoding="utf-8")
325          modes = [
326              "ONBOARDING_LEARNING",
327              "EFFICIENCY_STABILIZE",
328              "TURNAROUND_RESCUE",
329              "SCALE_EXPANSION",
330              "COMPETITOR_DEFENSE",
331              "CREATIVE_TESTING",
332              "LTV_QUALITY_FOCUS",
333          ]
334          for mode in modes:
335              assert mode in content, f"SKILL.md missing operation mode: {mode}"
336  
337      @pytest.mark.unit
338      def test_skill_has_kpi_thresholds(self) -> None:
339          content = SKILL_FILE.read_text(encoding="utf-8")
340          kpis = ["CPA", "CVR", "Impression Share", "CTR", "Budget Utilization"]
341          for kpi in kpis:
342              assert kpi in content, f"SKILL.md missing KPI threshold: {kpi}"
343  
344      @pytest.mark.unit
345      def test_skill_has_command_reference(self) -> None:
346          content = SKILL_FILE.read_text(encoding="utf-8")
347          commands = [
348              "/onboard",
349              "/daily-check",
350              "/rescue",
351              "/search-term-cleanup",
352              "/creative-refresh",
353              "/budget-rebalance",
354              "/competitive-scan",
355              "/sync-state",
356          ]
357          for cmd in commands:
358              assert cmd in content, f"SKILL.md missing command reference: {cmd}"
359  
360  
361  class TestGoalAndCrossPlatformCommands:
362      """Verify goal-review and weekly-report commands are valid and well-formed."""
363  
364      @pytest.mark.unit
365      @pytest.mark.parametrize("filename", ["goal-review.md", "weekly-report.md"])
366      def test_new_command_exists(self, filename: str) -> None:
367          filepath = COMMANDS_DIR / filename
368          assert filepath.exists(), f"Command file missing: {filepath}"
369  
370      @pytest.mark.unit
371      @pytest.mark.parametrize("filename", ["goal-review.md", "weekly-report.md"])
372      def test_new_command_uses_orchestration_pattern(self, filename: str) -> None:
373          """Goal/weekly commands should follow orchestration pattern."""
374          filepath = COMMANDS_DIR / filename
375          content = filepath.read_text(encoding="utf-8")
376          # Should not have hardcoded tool names
377          referenced_tools = _extract_tool_references(content)
378          assert (
379              len(referenced_tools) == 0
380          ), f"Command {filename} contains hardcoded tool names: {sorted(referenced_tools)}"
381          # Should reference platform discovery
382          assert (
383              "platform" in content.lower()
384          ), f"Command {filename} does not reference platform discovery"
385  
386      @pytest.mark.unit
387      @pytest.mark.parametrize("filename", ["goal-review.md", "weekly-report.md"])
388      def test_new_command_has_steps_section(self, filename: str) -> None:
389          filepath = COMMANDS_DIR / filename
390          content = filepath.read_text(encoding="utf-8")
391          assert "## Steps" in content, f"Command {filename} missing ## Steps section"
392  
393      @pytest.mark.unit
394      def test_all_commands_reference_strategy_or_state(self) -> None:
395          """Verify workflow commands reference STRATEGY.md or STATE.json."""
396          for filename in WORKFLOW_COMMANDS:
397              filepath = COMMANDS_DIR / filename
398              content = filepath.read_text(encoding="utf-8")
399              has_strategy = "STRATEGY.md" in content
400              has_state = "STATE.json" in content
401              assert (
402                  has_strategy or has_state
403              ), f"Command {filename} does not reference STRATEGY.md or STATE.json"