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"