#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ tests/test_code_review_claude_routing.py Phase 7 Frontier 升級:CodeReviewPipeline._openclaw_assess Claude 路由測試 驗收項目: - flag=false → Claude 路徑零觸發(regression 防護) - flag=true + Claude 可用 → 走 Claude,回傳 Claude content - flag=true + Claude 失敗 → fallback 進入 Gemini/Elephant 路徑 - flag=true + Claude 不可用(is_available=False)→ 跳過 Claude """ from __future__ import annotations import importlib import os import sys import types from unittest.mock import MagicMock import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ───────────────────────────────────────────────────────────────────────────── # 共用工具 # ───────────────────────────────────────────────────────────────────────────── def _reload_pipeline(): """重新載入 pipeline 模組(讓 module-level CODE_REVIEW_USE_CLAUDE flag 即時生效)""" import services.code_review_pipeline_service as svc_mod importlib.reload(svc_mod) return svc_mod def _make_pipeline(svc_mod, commit="abc12345"): return svc_mod.CodeReviewPipeline( commit_sha=commit, changed_files=["services/foo.py"], branch="main", deploy_type="sync", ) def _stub_anthropic(monkeypatch, svc_mod, *, available: bool, success: bool = True, content: str = "CLAUDE-RESULT", error: str = None): """注入假的 anthropic_service 全域單例到 services.anthropic_service。 目標:避免 monkeypatch sys.modules 失敗(pipeline 內是 from services.anthropic_service import anthropic_service) """ fake_resp = MagicMock() fake_resp.success = success fake_resp.content = content if success else "" fake_resp.input_tokens = 200 fake_resp.output_tokens = 100 fake_resp.cache_creation_tokens = 50 fake_resp.cache_read_tokens = 150 fake_resp.cache_hit = True fake_resp.error = error fake_svc = MagicMock() fake_svc.is_available.return_value = available fake_svc.generate.return_value = fake_resp # 動態造一個假 module 並寫入 sys.modules(覆蓋既有 import 結果) fake_module = types.ModuleType('services.anthropic_service') fake_module.anthropic_service = fake_svc monkeypatch.setitem(sys.modules, 'services.anthropic_service', fake_module) return fake_svc def _stub_logger(monkeypatch): """避免 log_ai_call 真寫 DB""" import services.ai_call_logger as logger_mod monkeypatch.setattr(logger_mod, '_write_to_db', lambda state: None) monkeypatch.setenv('AI_CALL_LOGGING_ENABLED', 'false') def _stub_gemini_and_elephant(monkeypatch, *, gemini_text: str = "GEMINI-RESULT", elephant_text: str = "ELEPHANT-RESULT", gemini_works: bool = True): """攔截 _openclaw_assess 內的 import google.generativeai / elephant_service。 pipeline 內是 lazy import,所以注入到 sys.modules 即可生效。 """ # 1) Fake google.generativeai fake_genai = types.ModuleType('google.generativeai') fake_types = types.SimpleNamespace(GenerationConfig=lambda **kw: MagicMock()) fake_genai.types = fake_types fake_genai.configure = lambda **kw: None if gemini_works: fake_resp = MagicMock() fake_resp.text = gemini_text fake_resp.usage_metadata = MagicMock(prompt_token_count=10, candidates_token_count=5) fake_model = MagicMock() fake_model.generate_content.return_value = fake_resp fake_genai.GenerativeModel = MagicMock(return_value=fake_model) else: fake_genai.GenerativeModel = MagicMock(side_effect=RuntimeError("gemini broken")) # google.generativeai 是子模組;注入它和父模組 fake_google = types.ModuleType('google') fake_google.generativeai = fake_genai monkeypatch.setitem(sys.modules, 'google', fake_google) monkeypatch.setitem(sys.modules, 'google.generativeai', fake_genai) # 2) Fake elephant_service fake_eresp = MagicMock(success=True, content=elephant_text, input_tokens=20, output_tokens=10, error=None) fake_elephant = MagicMock() fake_elephant.generate.return_value = fake_eresp fake_eservice_mod = types.ModuleType('services.elephant_service') fake_eservice_mod.elephant_service = fake_elephant monkeypatch.setitem(sys.modules, 'services.elephant_service', fake_eservice_mod) return fake_genai, fake_elephant # ───────────────────────────────────────────────────────────────────────────── # Tests # ───────────────────────────────────────────────────────────────────────────── def test_flag_false_uses_gemini(monkeypatch): """CODE_REVIEW_USE_CLAUDE=false(預設)→ Claude 路徑完全不觸發""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) svc_mod = _reload_pipeline() fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True) _stub_gemini_and_elephant(monkeypatch) pipeline = _make_pipeline(svc_mod) result = pipeline._openclaw_assess( files={"services/foo.py": "def x(): pass"}, findings=[], ) assert result == "GEMINI-RESULT" fake_claude.generate.assert_not_called() def test_flag_true_uses_claude(monkeypatch): """flag=true + Claude 可用 → 走 Claude,Gemini 不被呼叫""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) svc_mod = _reload_pipeline() fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True, success=True, content="CLAUDE-RESULT") fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch) pipeline = _make_pipeline(svc_mod) result = pipeline._openclaw_assess( files={"services/foo.py": "def x(): pass"}, findings=[{"severity": "HIGH", "file": "services/foo.py", "description": "x", "type": "bug"}], ) assert result == "CLAUDE-RESULT" fake_claude.generate.assert_called_once() # Gemini 不應被觸發(Claude 成功時短路) fake_genai.GenerativeModel.assert_not_called() fake_elephant.generate.assert_not_called() # 確認 Claude 呼叫參數正確 call_kwargs = fake_claude.generate.call_args.kwargs assert call_kwargs['cache_system'] is True # 啟用 ephemeral cache assert call_kwargs['temperature'] == 0.2 # code review 精確設定 assert call_kwargs['model'] == 'claude-opus-4-7' # 預設 Opus 4.7 assert call_kwargs['system_prompt'] # system prompt 有傳 assert 'commit ' + 'abc12345'[:8] in call_kwargs['prompt'].lower() or \ 'abc12345' in call_kwargs['prompt'] def test_flag_true_claude_fails_falls_back_to_gemini(monkeypatch): """Claude 回 success=False → fallback Gemini 接手""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) svc_mod = _reload_pipeline() fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True, success=False, error="RateLimitError: too many") fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch) pipeline = _make_pipeline(svc_mod) result = pipeline._openclaw_assess( files={"services/foo.py": "def x(): pass"}, findings=[], ) assert result == "GEMINI-RESULT" fake_claude.generate.assert_called_once() fake_genai.GenerativeModel.assert_called_once() def test_flag_true_claude_unavailable_uses_gemini(monkeypatch): """flag=true 但 is_available()=False → 跳 L1 直走 L2""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) svc_mod = _reload_pipeline() fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=False) fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch) pipeline = _make_pipeline(svc_mod) result = pipeline._openclaw_assess( files={"services/foo.py": "def x(): pass"}, findings=[], ) assert result == "GEMINI-RESULT" fake_claude.generate.assert_not_called() fake_genai.GenerativeModel.assert_called_once() def test_flag_true_full_fallback_chain(monkeypatch): """Claude 失敗 + Gemini 也失敗 → 最終 Elephant 接手""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) svc_mod = _reload_pipeline() fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True, success=False, error="claude down") fake_genai, fake_elephant = _stub_gemini_and_elephant( monkeypatch, gemini_works=False, ) pipeline = _make_pipeline(svc_mod) result = pipeline._openclaw_assess( files={"services/foo.py": "def x(): pass"}, findings=[], ) assert result == "ELEPHANT-RESULT" fake_claude.generate.assert_called_once() fake_elephant.generate.assert_called_once()