OPEN SOURCE
software/tests/test_coverage_final.py
このサイトで読み、必要なファイルを保存できます。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
1"""Final coverage tests for remaining gaps."""23import asyncio4import json5import subprocess6import time7import pytest8from pathlib import Path9from tempfile import TemporaryDirectory10from unittest.mock import AsyncMock, MagicMock, patch1112from src.context.builder import ContextBuilder13from src.context.cross_references import CrossReference, CrossReferenceManager, ReferenceType14from src.context.glossary import GlossaryManager15from src.context.pericope_generator import PericopeGenerator16from src.data.loader import DataLoader17from src.data.storage import Storage18from src.evaluation.evaluator import TranslationEvaluator19from src.evaluation.storage import EvaluationStorage20from src.exceptions import (21ClaudeAPIError,22CLINotFoundError,23EvaluationError,24QualityThresholdError,25RateLimitError,26TranslationError,27)28from src.models.evaluation import EvaluationScore, VerseEvaluation29from src.models.verse import VerseReference30from src.pipeline.prompt import PromptBuilder31from src.pipeline.translator import Translator32from src.utils.retry import (33async_retry_with_backoff,34retry_with_backoff,35RetryConfig,36RetryState,37)383940@pytest.fixture41def temp_data_dir(tmp_path: Path) -> Path:42"""Create temporary data directory."""43sources_dir = tmp_path / "sources"44(sources_dir / "greek").mkdir(parents=True)45(sources_dir / "english" / "kjv").mkdir(parents=True)46(tmp_path / "metadata").mkdir(parents=True)47(tmp_path / "output").mkdir(parents=True)4849greek_data = {50"1:1": {"text": "Ἐν ἀρχῇ", "translit": "En archē"},51"1:2": {"text": "οὗτος ἦν", "translit": "houtos ēn"},52}53with (sources_dir / "greek" / "john.json").open("w") as f:54json.dump(greek_data, f)5556kjv_data = {"1:1": "In the beginning", "1:2": "The same was"}57with (sources_dir / "english" / "kjv" / "john.json").open("w") as f:58json.dump(kjv_data, f)5960return tmp_path616263class TestRetryDecoratorRateLimitRetryAfter:64"""Tests for retry decorator using rate limit retry_after."""6566def test_sync_retry_uses_rate_limit_retry_after(self) -> None:67"""Test sync retry decorator uses RateLimitError.retry_after."""68config = RetryConfig(69max_attempts=3,70base_delay=10.0, # Very long base delay71jitter=False,72retryable_exceptions=(RateLimitError,),73)7475call_count = 076times = []7778@retry_with_backoff(config)79def func_with_retry() -> str:80nonlocal call_count81call_count += 182times.append(time.time())83if call_count < 2:84# Short retry_after should override base_delay85raise RateLimitError("Rate limit", retry_after=0.001)86return "success"8788result = func_with_retry()89assert result == "success"90assert call_count == 291# Verify delay was short (not 10 seconds)92elapsed = times[1] - times[0]93assert elapsed < 1.09495@pytest.mark.asyncio96async def test_async_retry_uses_rate_limit_retry_after(self) -> None:97"""Test async retry decorator uses RateLimitError.retry_after."""98config = RetryConfig(99max_attempts=3,100base_delay=10.0, # Very long base delay101jitter=False,102retryable_exceptions=(RateLimitError,),103)104105call_count = 0106times = []107108@async_retry_with_backoff(config)109async def async_func_with_retry() -> str:110nonlocal call_count111call_count += 1112times.append(time.time())113if call_count < 2:114raise RateLimitError("Rate limit", retry_after=0.001)115return "async success"116117result = await async_func_with_retry()118assert result == "async success"119assert call_count == 2120elapsed = times[1] - times[0]121assert elapsed < 1.0122123124class TestRetryDecoratorEdgeCases:125"""Tests for edge cases in retry decorators."""126127def test_sync_retry_no_exception_captured(self) -> None:128"""Test sync retry handles no exception scenario."""129# This tests line 109 - but the code path is unreachable in normal operation130# since last_exception is always set when retrying. We test success case.131config = RetryConfig(max_attempts=1, retryable_exceptions=())132133@retry_with_backoff(config)134def always_succeeds() -> str:135return "success"136137result = always_succeeds()138assert result == "success"139140@pytest.mark.asyncio141async def test_async_retry_no_exception_captured(self) -> None:142"""Test async retry handles no exception scenario."""143config = RetryConfig(max_attempts=1, retryable_exceptions=())144145@async_retry_with_backoff(config)146async def async_always_succeeds() -> str:147return "async success"148149result = await async_always_succeeds()150assert result == "async success"151152153class TestRetryStateNonRateLimitWait:154"""Tests for RetryState wait with non-RateLimitError."""155156def test_wait_with_non_rate_limit_error(self) -> None:157"""Test wait() uses get_delay when not RateLimitError."""158config = RetryConfig(159max_attempts=3,160base_delay=0.001,161jitter=False,162retryable_exceptions=(ConnectionError,),163)164state = RetryState(config)165166# Trigger with non-RateLimitError167state.should_retry(ConnectionError("Connection failed"))168169start = time.time()170state.wait()171elapsed = time.time() - start172173assert elapsed < 1.0174assert state.attempt == 1175176@pytest.mark.asyncio177async def test_async_wait_with_non_rate_limit_error(self) -> None:178"""Test async_wait() uses get_delay when not RateLimitError."""179config = RetryConfig(180max_attempts=3,181base_delay=0.001,182jitter=False,183retryable_exceptions=(ConnectionError,),184)185state = RetryState(config)186187state.should_retry(ConnectionError("Connection failed"))188189start = time.time()190await state.async_wait()191elapsed = time.time() - start192193assert elapsed < 1.0194assert state.attempt == 1195196197class TestTranslatorTimeoutRetry:198"""Tests for translator timeout retry paths."""199200@patch("src.pipeline.translator.subprocess.run")201def test_call_claude_timeout_retry_logging(202self, mock_run: MagicMock, temp_data_dir: Path203) -> None:204"""Test sync Claude call logs and retries on timeout."""205loader = DataLoader(temp_data_dir)206storage = Storage(temp_data_dir)207glossary = GlossaryManager()208context_builder = ContextBuilder(loader, glossary)209prompt_builder = PromptBuilder(glossary)210211retry_config = RetryConfig(212max_attempts=3,213base_delay=0.001,214jitter=False,215retryable_exceptions=(ClaudeAPIError,), # Timeout is converted to ClaudeAPIError216)217translator = Translator(218context_builder,219prompt_builder,220storage,221retry_config=retry_config,222subprocess_timeout=1,223)224225call_count = 0226227def timeout_then_succeed(*args, **kwargs):228nonlocal call_count229call_count += 1230if call_count < 3:231raise subprocess.TimeoutExpired(cmd="claude", timeout=1)232return MagicMock(returncode=0, stdout="成功", stderr="")233234mock_run.side_effect = timeout_then_succeed235236result = translator._call_claude("system", "user")237assert result == "成功"238assert call_count == 3239240@patch("src.pipeline.translator.asyncio.create_subprocess_exec")241@pytest.mark.asyncio242async def test_call_claude_async_timeout_retry_logging(243self, mock_subprocess: AsyncMock, temp_data_dir: Path244) -> None:245"""Test async Claude call logs and retries on timeout."""246loader = DataLoader(temp_data_dir)247storage = Storage(temp_data_dir)248glossary = GlossaryManager()249context_builder = ContextBuilder(loader, glossary)250prompt_builder = PromptBuilder(glossary)251252retry_config = RetryConfig(253max_attempts=3,254base_delay=0.001,255jitter=False,256retryable_exceptions=(ClaudeAPIError,),257)258translator = Translator(259context_builder,260prompt_builder,261storage,262retry_config=retry_config,263)264265call_count = 0266267async def timeout_then_succeed(*args, **kwargs):268nonlocal call_count269call_count += 1270if call_count < 3:271raise asyncio.TimeoutError()272mock_process = AsyncMock()273mock_process.returncode = 0274mock_process.communicate.return_value = ("成功".encode(), b"")275return mock_process276277mock_subprocess.side_effect = timeout_then_succeed278279result = await translator._call_claude_async("system", "user")280assert result == "成功"281assert call_count == 3282283@patch("src.pipeline.translator.asyncio.create_subprocess_exec")284@pytest.mark.asyncio285async def test_call_claude_async_file_not_found(286self, mock_subprocess: AsyncMock, temp_data_dir: Path287) -> None:288"""Test async Claude call raises CLINotFoundError."""289loader = DataLoader(temp_data_dir)290storage = Storage(temp_data_dir)291glossary = GlossaryManager()292context_builder = ContextBuilder(loader, glossary)293prompt_builder = PromptBuilder(glossary)294295translator = Translator(296context_builder,297prompt_builder,298storage,299)300301mock_subprocess.side_effect = FileNotFoundError("claude not found")302303with pytest.raises(CLINotFoundError):304await translator._call_claude_async("system", "user")305306307class TestTranslatorQualityGate:308"""Tests for translator quality gate enforcement."""309310@patch("src.pipeline.translator.subprocess.run")311def test_quality_gate_overall_fails(312self, mock_run: MagicMock, temp_data_dir: Path313) -> None:314"""Test quality gate fails when overall score too low."""315from datetime import datetime316317loader = DataLoader(temp_data_dir)318storage = Storage(temp_data_dir)319glossary = GlossaryManager()320context_builder = ContextBuilder(loader, glossary)321prompt_builder = PromptBuilder(glossary)322323# Create evaluator mock without spec to allow arbitrary method calls324mock_evaluator = MagicMock()325mock_evaluator.build_verse_prompt.return_value = "eval prompt"326327# This evaluation will fail the overall score threshold328low_score_eval = VerseEvaluation(329verse_number=1,330evaluator_model="test",331timestamp=datetime.utcnow(),332scores={333"theological_accuracy": EvaluationScore(334dimension="theological_accuracy",335score=95.0,336confidence=90.0,337reasoning="Good",338),339"source_fidelity": EvaluationScore(340dimension="source_fidelity",341score=40.0,342confidence=90.0,343reasoning="Poor",344),345"naturalness": EvaluationScore(346dimension="naturalness",347score=40.0,348confidence=90.0,349reasoning="Poor",350),351"readability": EvaluationScore(352dimension="readability",353score=40.0,354confidence=90.0,355reasoning="Poor",356),357"glossary_consistency": EvaluationScore(358dimension="glossary_consistency",359score=40.0,360confidence=90.0,361reasoning="Poor",362),363"literary_quality": EvaluationScore(364dimension="literary_quality",365score=40.0,366confidence=90.0,367reasoning="Poor",368),369},370flags=[],371notes=None,372)373mock_evaluator.create_verse_evaluation.return_value = low_score_eval374375translator = Translator(376context_builder,377prompt_builder,378storage,379quality_threshold=70.0,380enforce_quality_gate=True,381auto_evaluate=True,382evaluator=mock_evaluator,383)384385# Mock translation call to succeed386mock_run.return_value = MagicMock(returncode=0, stdout="翻訳", stderr="")387388# Also need to mock the second _call_claude for evaluation389# The first call translates, second call evaluates390translation_response = "翻訳"391eval_json = json.dumps({392"theological_accuracy": {"score": 95, "confidence": 90, "reasoning": "Good"},393"source_fidelity": {"score": 40, "confidence": 90, "reasoning": "Poor"},394"naturalness": {"score": 40, "confidence": 90, "reasoning": "Poor"},395"readability": {"score": 40, "confidence": 90, "reasoning": "Poor"},396"glossary_consistency": {"score": 40, "confidence": 90, "reasoning": "Poor"},397"literary_quality": {"score": 40, "confidence": 90, "reasoning": "Poor"},398"style_adherence": {"score": 40, "confidence": 90, "reasoning": "Poor"},399"formality_level": {"score": 40, "confidence": 90, "reasoning": "Poor"},400"archaic_avoidance": {"score": 40, "confidence": 90, "reasoning": "Poor"},401})402403mock_run.side_effect = [404MagicMock(returncode=0, stdout=translation_response, stderr=""),405MagicMock(returncode=0, stdout=eval_json, stderr=""),406]407408ref = VerseReference(book="john", chapter=1, verse=1)409with pytest.raises(QualityThresholdError, match="failed quality gate"):410translator.translate_verse(ref, skip_existing=False)411412@patch("src.pipeline.translator.subprocess.run")413def test_quality_gate_theological_fails(414self, mock_run: MagicMock, temp_data_dir: Path415) -> None:416"""Test quality gate fails when theological accuracy too low."""417from datetime import datetime418419loader = DataLoader(temp_data_dir)420storage = Storage(temp_data_dir)421glossary = GlossaryManager()422context_builder = ContextBuilder(loader, glossary)423prompt_builder = PromptBuilder(glossary)424425mock_evaluator = MagicMock()426mock_evaluator.build_verse_prompt.return_value = "eval prompt"427428# Evaluation with low theological accuracy429low_theo_eval = VerseEvaluation(430verse_number=1,431evaluator_model="test",432timestamp=datetime.utcnow(),433scores={434"theological_accuracy": EvaluationScore(435dimension="theological_accuracy",436score=70.0, # Below 85% theological threshold437confidence=90.0,438reasoning="Concern",439),440"source_fidelity": EvaluationScore(441dimension="source_fidelity",442score=90.0,443confidence=90.0,444reasoning="Good",445),446"naturalness": EvaluationScore(447dimension="naturalness",448score=90.0,449confidence=90.0,450reasoning="Good",451),452"readability": EvaluationScore(453dimension="readability",454score=90.0,455confidence=90.0,456reasoning="Good",457),458"glossary_consistency": EvaluationScore(459dimension="glossary_consistency",460score=90.0,461confidence=90.0,462reasoning="Good",463),464"literary_quality": EvaluationScore(465dimension="literary_quality",466score=90.0,467confidence=90.0,468reasoning="Good",469),470},471flags=["theological_concern"],472notes=None,473)474mock_evaluator.create_verse_evaluation.return_value = low_theo_eval475476translator = Translator(477context_builder,478prompt_builder,479storage,480quality_threshold=70.0,481enforce_quality_gate=True,482auto_evaluate=True,483evaluator=mock_evaluator,484)485486translation_response = "翻訳"487eval_json = json.dumps({488"theological_accuracy": {"score": 70, "confidence": 90, "reasoning": "Concern"},489"source_fidelity": {"score": 90, "confidence": 90, "reasoning": "Good"},490"naturalness": {"score": 90, "confidence": 90, "reasoning": "Good"},491"readability": {"score": 90, "confidence": 90, "reasoning": "Good"},492"glossary_consistency": {"score": 90, "confidence": 90, "reasoning": "Good"},493"literary_quality": {"score": 90, "confidence": 90, "reasoning": "Good"},494"style_adherence": {"score": 90, "confidence": 90, "reasoning": "Good"},495"formality_level": {"score": 90, "confidence": 90, "reasoning": "Good"},496"archaic_avoidance": {"score": 90, "confidence": 90, "reasoning": "Good"},497})498499mock_run.side_effect = [500MagicMock(returncode=0, stdout=translation_response, stderr=""),501MagicMock(returncode=0, stdout=eval_json, stderr=""),502]503504ref = VerseReference(book="john", chapter=1, verse=1)505with pytest.raises(QualityThresholdError, match="theological accuracy"):506translator.translate_verse(ref, skip_existing=False)507508@patch("src.pipeline.translator.subprocess.run")509def test_evaluation_error_with_quality_gate_enforced(510self, mock_run: MagicMock, temp_data_dir: Path511) -> None:512"""Test evaluation error raises when quality gate enforced."""513loader = DataLoader(temp_data_dir)514storage = Storage(temp_data_dir)515glossary = GlossaryManager()516context_builder = ContextBuilder(loader, glossary)517prompt_builder = PromptBuilder(glossary)518519# Evaluator that throws an exception520mock_evaluator = MagicMock()521mock_evaluator.build_verse_prompt.side_effect = Exception("Evaluation failed")522523translator = Translator(524context_builder,525prompt_builder,526storage,527enforce_quality_gate=True,528auto_evaluate=True,529evaluator=mock_evaluator,530)531532mock_run.return_value = MagicMock(returncode=0, stdout="翻訳", stderr="")533534ref = VerseReference(book="john", chapter=1, verse=1)535with pytest.raises(EvaluationError, match="Evaluation failed"):536translator.translate_verse(ref, skip_existing=False)537538539class TestCrossReferenceManagerEdgeCases:540"""Tests for CrossReferenceManager edge cases."""541542def test_load_with_no_data_dir(self) -> None:543"""Test loading when data_dir is None."""544manager = CrossReferenceManager() # No data_dir545manager._load_cross_references() # Should return early546assert len(manager) == 0547548def test_save_with_no_data_dir_raises(self) -> None:549"""Test save raises ValueError when no data_dir."""550manager = CrossReferenceManager() # No data_dir551manager.add_reference(552CrossReference(553source_book="john",554source_chapter=1,555source_verse=1,556target_book="genesis",557target_chapter=1,558target_verse_start=1,559)560)561562with pytest.raises(ValueError, match="No data directory"):563manager.save()564565def test_find_references_to_verse_with_range(self) -> None:566"""Test finding references targeting a verse within a range."""567manager = CrossReferenceManager()568# Reference with a range target569manager.add_reference(570CrossReference(571source_book="matthew",572source_chapter=4,573source_verse=4,574target_book="deuteronomy",575target_chapter=8,576target_verse_start=3,577target_verse_end=5, # Range578)579)580581# Should find when querying start verse582found = manager.find_references_to_verse("deuteronomy", 8, 3)583assert len(found) == 1584585586class TestPericopeGeneratorEdgeCases:587"""Tests for PericopeGenerator edge cases."""588589def test_update_pericope_range_empty_verses(self) -> None:590"""Test _update_pericope_range returns early for empty verses."""591with TemporaryDirectory() as tmpdir:592data_dir = Path(tmpdir)593(data_dir / "sources" / "greek").mkdir(parents=True)594595generator = PericopeGenerator(data_dir)596597from src.models.pericope import Pericope598599pericope = Pericope(600id="test-1",601name="Test",602start_chapter=1,603start_verse=1,604end_chapter=1,605end_verse=1,606verses=[], # Empty607)608609# Should return early without error610generator._update_pericope_range(pericope)611# Verses still empty612assert pericope.verses == []613614def test_generate_and_save_loads_existing_file(self) -> None:615"""Test generate_and_save loads existing pericopes file."""616with TemporaryDirectory() as tmpdir:617data_dir = Path(tmpdir)618greek_dir = data_dir / "sources" / "greek"619greek_dir.mkdir(parents=True)620metadata_dir = data_dir / "metadata"621metadata_dir.mkdir(parents=True)622623# Create Greek data624greek_data = {625"1:1": {"text": "¶ Ἐν ἀρχῇ"},626"1:2": {"text": "οὗτος ἦν"},627}628with (greek_dir / "john.json").open("w") as f:629json.dump(greek_data, f)630631# Pre-existing pericopes file632existing = {633"matthew": {634"name": "matthew",635"pericopes": [],636"auto_generated": True,637}638}639output_path = metadata_dir / "pericopes_generated.json"640with output_path.open("w") as f:641json.dump(existing, f)642643generator = PericopeGenerator(data_dir)644index = generator.generate_and_save("john")645646# Should have merged with existing647with output_path.open("r") as f:648data = json.load(f)649assert "matthew" in data650assert "john" in data651652def test_generate_all_books_with_no_books(self) -> None:653"""Test generate_all_books scans directory when books is None."""654with TemporaryDirectory() as tmpdir:655data_dir = Path(tmpdir)656greek_dir = data_dir / "sources" / "greek"657greek_dir.mkdir(parents=True)658659# Create multiple book files with enough verses to form pericopes660for book in ["john", "mark"]:661greek_data = {662"1:1": {"text": "¶ Test verse 1"},663"1:2": {"text": "Test verse 2"},664"1:3": {"text": "Test verse 3"},665"1:4": {"text": "¶ Test verse 4"},666"1:5": {"text": "Test verse 5"},667}668with (greek_dir / f"{book}.json").open("w") as f:669json.dump(greek_data, f)670671generator = PericopeGenerator(data_dir)672result = generator.generate_all_books() # No books specified673674# Should find at least one book with pericopes675assert len(result) >= 1676677def test_generate_all_books_no_greek_dir(self) -> None:678"""Test generate_all_books with no Greek directory."""679with TemporaryDirectory() as tmpdir:680data_dir = Path(tmpdir)681# No greek directory created682683generator = PericopeGenerator(data_dir)684result = generator.generate_all_books() # No books685686assert result == {}687688def test_generate_all_books_empty_pericope_index(self) -> None:689"""Test generate_all_books skips empty pericope indexes."""690with TemporaryDirectory() as tmpdir:691data_dir = Path(tmpdir)692greek_dir = data_dir / "sources" / "greek"693greek_dir.mkdir(parents=True)694695# Create book with no paragraph markers696greek_data = {} # Empty book697with (greek_dir / "philemon.json").open("w") as f:698json.dump(greek_data, f)699700generator = PericopeGenerator(data_dir)701result = generator.generate_all_books(books=["philemon"])702703# Empty index should not be included704assert "philemon" not in result705706707class TestRetryOnRetryCallback:708"""Tests for on_retry callback in retry decorators."""709710def test_sync_on_retry_callback_called(self) -> None:711"""Test on_retry callback is called during sync retry."""712config = RetryConfig(713max_attempts=3,714base_delay=0.001,715jitter=False,716retryable_exceptions=(ValueError,),717)718719callback_calls = []720721def on_retry(exc: Exception, attempt: int) -> None:722callback_calls.append((str(exc), attempt))723724call_count = 0725726@retry_with_backoff(config, on_retry=on_retry)727def func_with_callback() -> str:728nonlocal call_count729call_count += 1730if call_count < 3:731raise ValueError(f"Attempt {call_count}")732return "done"733734result = func_with_callback()735assert result == "done"736assert len(callback_calls) == 2737assert callback_calls[0] == ("Attempt 1", 0)738assert callback_calls[1] == ("Attempt 2", 1)739740@pytest.mark.asyncio741async def test_async_on_retry_callback_called(self) -> None:742"""Test on_retry callback is called during async retry."""743config = RetryConfig(744max_attempts=3,745base_delay=0.001,746jitter=False,747retryable_exceptions=(ValueError,),748)749750callback_calls = []751752def on_retry(exc: Exception, attempt: int) -> None:753callback_calls.append((str(exc), attempt))754755call_count = 0756757@async_retry_with_backoff(config, on_retry=on_retry)758async def async_func_with_callback() -> str:759nonlocal call_count760call_count += 1761if call_count < 3:762raise ValueError(f"Attempt {call_count}")763return "async done"764765result = await async_func_with_callback()766assert result == "async done"767assert len(callback_calls) == 2768