"""Tests for the evaluation module.""" from __future__ import annotations import json import tempfile from datetime import datetime from pathlib import Path import pytest from src.models.evaluation import ( ANY_DIMENSION_THRESHOLD, CONFIDENCE_THRESHOLD, DIMENSIONS, SOURCE_FIDELITY_THRESHOLD, THEOLOGICAL_ACCURACY_THRESHOLD, BookEvaluationSummary, ChapterEvaluation, EvaluationScore, VerseEvaluation, ) from src.evaluation.evaluator import ( TranslationEvaluator, build_evaluation_prompt, parse_evaluation_response, ) from src.evaluation.storage import EvaluationStorage class TestEvaluationScore: """Tests for EvaluationScore model.""" def test_create_score(self) -> None: """Test creating an evaluation score.""" score = EvaluationScore( dimension="theological_accuracy", score=95.0, confidence=90.0, reasoning="Theological meaning is well preserved.", ) assert score.dimension == "theological_accuracy" assert score.score == 95.0 assert score.confidence == 90.0 assert "preserved" in score.reasoning def test_score_bounds(self) -> None: """Test score validation bounds.""" # Valid scores score = EvaluationScore( dimension="test", score=0.0, confidence=100.0, reasoning="test" ) assert score.score == 0.0 assert score.confidence == 100.0 # Invalid scores should raise with pytest.raises(ValueError): EvaluationScore( dimension="test", score=-1.0, confidence=50.0, reasoning="test" ) with pytest.raises(ValueError): EvaluationScore( dimension="test", score=101.0, confidence=50.0, reasoning="test" ) class TestVerseEvaluation: """Tests for VerseEvaluation model.""" def test_create_verse_evaluation(self) -> None: """Test creating a verse evaluation.""" scores = { "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=95.0, confidence=90.0, reasoning="Good", ), "source_fidelity": EvaluationScore( dimension="source_fidelity", score=88.0, confidence=85.0, reasoning="Good", ), } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test-model", scores=scores, ) assert evaluation.verse_number == 1 assert evaluation.evaluator_model == "test-model" assert len(evaluation.scores) == 2 def test_overall_score_calculation(self) -> None: """Test overall score is calculated correctly.""" scores = { "dim1": EvaluationScore( dimension="dim1", score=80.0, confidence=90.0, reasoning="test" ), "dim2": EvaluationScore( dimension="dim2", score=90.0, confidence=90.0, reasoning="test" ), } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) assert evaluation.overall_score == 85.0 def test_compute_flags_theological_concern(self) -> None: """Test flagging for theological accuracy below threshold.""" scores = { "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=THEOLOGICAL_ACCURACY_THRESHOLD - 1, confidence=90.0, reasoning="test", ), } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) flags = evaluation.compute_flags() assert "theological_concern" in flags def test_compute_flags_low_confidence(self) -> None: """Test flagging for low confidence on theology.""" scores = { "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=90.0, confidence=CONFIDENCE_THRESHOLD - 1, reasoning="test", ), } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) flags = evaluation.compute_flags() assert "low_confidence_theology" in flags def test_compute_flags_source_fidelity(self) -> None: """Test flagging for source fidelity below threshold.""" scores = { "source_fidelity": EvaluationScore( dimension="source_fidelity", score=SOURCE_FIDELITY_THRESHOLD - 1, confidence=90.0, reasoning="test", ), } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) flags = evaluation.compute_flags() assert "source_fidelity_concern" in flags def test_compute_flags_any_low_dimension(self) -> None: """Test flagging for any dimension below threshold.""" scores = { "naturalness": EvaluationScore( dimension="naturalness", score=ANY_DIMENSION_THRESHOLD - 1, confidence=90.0, reasoning="test", ), } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) flags = evaluation.compute_flags() assert "low_naturalness" in flags def test_no_flags_for_good_scores(self) -> None: """Test no flags when all scores are good.""" scores = { dim: EvaluationScore( dimension=dim, score=90.0, confidence=90.0, reasoning="test" ) for dim in DIMENSIONS } evaluation = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) flags = evaluation.compute_flags() assert len(flags) == 0 class TestChapterEvaluation: """Tests for ChapterEvaluation model.""" def test_create_chapter_evaluation(self) -> None: """Test creating a chapter evaluation.""" evaluation = ChapterEvaluation( book="titus", chapter=1, evaluator_model="test-model", ) assert evaluation.book == "titus" assert evaluation.chapter == 1 assert len(evaluation.verses) == 0 def test_aggregate_scores(self) -> None: """Test aggregate scores calculation.""" scores1 = { "dim1": EvaluationScore( dimension="dim1", score=80.0, confidence=90.0, reasoning="test" ), } scores2 = { "dim1": EvaluationScore( dimension="dim1", score=90.0, confidence=90.0, reasoning="test" ), } verse1 = VerseEvaluation(verse_number=1, evaluator_model="test", scores=scores1) verse2 = VerseEvaluation(verse_number=2, evaluator_model="test", scores=scores2) chapter = ChapterEvaluation( book="test", chapter=1, evaluator_model="test", verses={1: verse1, 2: verse2}, ) assert chapter.aggregate_scores["dim1"] == 85.0 def test_flagged_verses(self) -> None: """Test flagged verses list.""" scores_bad = { "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=50.0, confidence=90.0, reasoning="test", ), } scores_good = { "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=95.0, confidence=90.0, reasoning="test", ), } verse1 = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores_bad ) verse1.flags = verse1.compute_flags() verse2 = VerseEvaluation( verse_number=2, evaluator_model="test", scores=scores_good ) verse2.flags = verse2.compute_flags() chapter = ChapterEvaluation( book="test", chapter=1, evaluator_model="test", verses={1: verse1, 2: verse2}, ) assert 1 in chapter.flagged_verses assert 2 not in chapter.flagged_verses assert chapter.flagged_count == 1 class TestBuildEvaluationPrompt: """Tests for build_evaluation_prompt function.""" def test_basic_prompt(self) -> None: """Test building a basic evaluation prompt.""" prompt = build_evaluation_prompt( translation="初めに言葉があった。", greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος", kjv_text="In the beginning was the Word", reference_japanese=None, glossary_terms=None, ) assert "初めに言葉があった" in prompt assert "Ἐν ἀρχῇ" in prompt assert "In the beginning" in prompt assert "theological_accuracy" in prompt.lower() def test_prompt_with_glossary(self) -> None: """Test prompt includes glossary terms.""" prompt = build_evaluation_prompt( translation="test", greek_text="test", kjv_text=None, reference_japanese=None, glossary_terms=["言葉", "神"], ) assert "言葉" in prompt assert "神" in prompt def test_prompt_with_reference_japanese(self) -> None: """Test prompt includes reference Japanese.""" prompt = build_evaluation_prompt( translation="test", greek_text=None, kjv_text=None, reference_japanese="参考日本語訳", glossary_terms=None, ) assert "参考日本語訳" in prompt class TestParseEvaluationResponse: """Tests for parse_evaluation_response function.""" def test_parse_valid_json(self) -> None: """Test parsing valid JSON response.""" response = json.dumps( { dim: {"score": 90, "confidence": 85, "reasoning": "Good"} for dim in DIMENSIONS } ) scores = parse_evaluation_response(response) assert len(scores) == len(DIMENSIONS) assert all(dim in scores for dim in DIMENSIONS) assert scores["theological_accuracy"].score == 90 def test_parse_json_in_code_block(self) -> None: """Test parsing JSON wrapped in code block.""" json_content = json.dumps( { dim: {"score": 90, "confidence": 85, "reasoning": "Good"} for dim in DIMENSIONS } ) response = f"```json\n{json_content}\n```" scores = parse_evaluation_response(response) assert len(scores) == len(DIMENSIONS) def test_parse_invalid_json(self) -> None: """Test parsing invalid JSON raises error.""" with pytest.raises(ValueError, match="Failed to parse"): parse_evaluation_response("not valid json") def test_parse_missing_dimension(self) -> None: """Test parsing response with missing dimension raises error.""" response = json.dumps( {"theological_accuracy": {"score": 90, "confidence": 85, "reasoning": "Good"}} ) with pytest.raises(ValueError, match="Missing dimension"): parse_evaluation_response(response) class TestTranslationEvaluator: """Tests for TranslationEvaluator class.""" def test_create_evaluator(self) -> None: """Test creating an evaluator.""" evaluator = TranslationEvaluator(model_name="test-model") assert evaluator.model_name == "test-model" def test_create_verse_evaluation(self) -> None: """Test creating a verse evaluation from scores.""" evaluator = TranslationEvaluator() scores = { dim: EvaluationScore( dimension=dim, score=90.0, confidence=85.0, reasoning="Good" ) for dim in DIMENSIONS } evaluation = evaluator.create_verse_evaluation( verse_number=1, scores=scores, notes="Test note" ) assert evaluation.verse_number == 1 assert evaluation.notes == "Test note" assert len(evaluation.flags) == 0 # Good scores, no flags def test_create_chapter_evaluation(self) -> None: """Test creating a chapter evaluation.""" evaluator = TranslationEvaluator() scores = { dim: EvaluationScore( dimension=dim, score=90.0, confidence=85.0, reasoning="Good" ) for dim in DIMENSIONS } verse_eval = evaluator.create_verse_evaluation(verse_number=1, scores=scores) chapter_eval = evaluator.create_chapter_evaluation( book="titus", chapter=1, verse_evaluations={1: verse_eval} ) assert chapter_eval.book == "titus" assert chapter_eval.chapter == 1 assert len(chapter_eval.verses) == 1 class TestEvaluationStorage: """Tests for EvaluationStorage class.""" def test_save_and_load_chapter_evaluation(self) -> None: """Test saving and loading a chapter evaluation.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) # Create evaluation scores = { dim: EvaluationScore( dimension=dim, score=90.0, confidence=85.0, reasoning="Good" ) for dim in DIMENSIONS } verse_eval = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) chapter_eval = ChapterEvaluation( book="titus", chapter=1, evaluator_model="test", verses={1: verse_eval}, ) # Save path = storage.save_chapter_evaluation(chapter_eval) assert path.exists() # Load loaded = storage.load_chapter_evaluation("titus", 1) assert loaded is not None assert loaded.book == "titus" assert loaded.chapter == 1 assert len(loaded.verses) == 1 assert loaded.verses[1].scores["theological_accuracy"].score == 90.0 def test_load_nonexistent(self) -> None: """Test loading non-existent evaluation returns None.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) loaded = storage.load_chapter_evaluation("nonexistent", 1) assert loaded is None def test_is_chapter_evaluated(self) -> None: """Test checking if chapter is evaluated.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) assert not storage.is_chapter_evaluated("titus", 1) # Save evaluation chapter_eval = ChapterEvaluation( book="titus", chapter=1, evaluator_model="test", verses={} ) storage.save_chapter_evaluation(chapter_eval) assert storage.is_chapter_evaluated("titus", 1) def test_get_flagged_verses(self) -> None: """Test getting flagged verses for a book.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) # Create evaluation with flagged verse scores = { "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=50.0, confidence=90.0, reasoning="Issues", ), } verse_eval = VerseEvaluation( verse_number=5, evaluator_model="test", scores=scores ) verse_eval.flags = verse_eval.compute_flags() chapter_eval = ChapterEvaluation( book="titus", chapter=1, evaluator_model="test", verses={5: verse_eval}, ) storage.save_chapter_evaluation(chapter_eval) flagged = storage.get_flagged_verses("titus") assert len(flagged) == 1 assert flagged[0].chapter == 1 assert flagged[0].verse == 5 def test_get_book_summary(self) -> None: """Test getting book evaluation summary.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) # Save two chapter evaluations for chapter in [1, 2]: scores = { dim: EvaluationScore( dimension=dim, score=85.0 + chapter, confidence=90.0, reasoning="Good", ) for dim in DIMENSIONS } verse_eval = VerseEvaluation( verse_number=1, evaluator_model="test", scores=scores ) chapter_eval = ChapterEvaluation( book="titus", chapter=chapter, evaluator_model="test", verses={1: verse_eval}, ) storage.save_chapter_evaluation(chapter_eval) summary = storage.get_book_summary("titus") assert summary is not None assert summary.book == "titus" assert summary.total_chapters == 2 assert summary.total_verses == 2 assert summary.overall_score > 0 def test_get_book_summary_no_evaluations(self) -> None: """Test getting summary when no evaluations exist.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) summary = storage.get_book_summary("nonexistent") assert summary is None def test_get_book_summary_dir_exists_no_eval_files(self) -> None: """Test getting summary when book dir exists but has no eval files.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) # Create the book directory but don't add any eval files book_dir = Path(tmpdir) / "output" / "titus" book_dir.mkdir(parents=True) # Add a non-eval file (book_dir / "chapter_01.json").write_text("{}") summary = storage.get_book_summary("titus") assert summary is None def test_get_book_summary_with_flagged_verses(self) -> None: """Test getting book summary with flagged verses.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) # Create evaluation with low theological accuracy (will be flagged) scores = { dim: EvaluationScore( dimension=dim, score=95.0 if dim != "theological_accuracy" else 75.0, confidence=90.0, reasoning="Test", ) for dim in DIMENSIONS } verse_eval = VerseEvaluation( verse_number=5, evaluator_model="test", scores=scores, flags=["theological_concern"], # Explicitly set flag ) chapter_eval = ChapterEvaluation( book="titus", chapter=1, evaluator_model="test", verses={5: verse_eval}, ) storage.save_chapter_evaluation(chapter_eval) summary = storage.get_book_summary("titus") assert summary is not None assert len(summary.flagged_verses) == 1 assert "titus:1:5" in summary.flagged_verses class TestBookEvaluationSummary: """Tests for BookEvaluationSummary model.""" def test_create_summary(self) -> None: """Test creating a book evaluation summary.""" summary = BookEvaluationSummary( book="titus", total_chapters=3, total_verses=46, aggregate_scores={ "theological_accuracy": 92.5, "source_fidelity": 88.0, }, overall_score=90.3, flagged_verses=["titus:1:5", "titus:2:3"], ) assert summary.book == "titus" assert summary.total_chapters == 3 assert summary.total_verses == 46 assert len(summary.flagged_verses) == 2 class TestParseEvaluationResponseExtended: """Extended tests for parse_evaluation_response function.""" def test_parse_json_in_plain_code_block(self) -> None: """Test parsing JSON wrapped in plain code block (no json tag).""" json_content = json.dumps( { dim: {"score": 90, "confidence": 85, "reasoning": "Good"} for dim in DIMENSIONS } ) response = f"```\n{json_content}\n```" scores = parse_evaluation_response(response) assert len(scores) == len(DIMENSIONS) class TestEvaluationStorageExtended: """Extended tests for EvaluationStorage class.""" def test_serialize_datetime(self) -> None: """Test datetime serialization helper.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) dt = datetime(2024, 1, 15, 10, 30, 0) result = storage._serialize_datetime(dt) assert result == "2024-01-15T10:30:00" def test_serialize_datetime_invalid_type(self) -> None: """Test datetime serialization with invalid type.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) with pytest.raises(TypeError, match="not JSON serializable"): storage._serialize_datetime("not a datetime") def test_get_flagged_verses_empty_book(self) -> None: """Test getting flagged verses when book directory doesn't exist.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) flagged = storage.get_flagged_verses("nonexistent") assert flagged == [] def test_load_chapter_with_missing_fields(self) -> None: """Test loading chapter with some optional fields missing.""" with tempfile.TemporaryDirectory() as tmpdir: storage = EvaluationStorage(Path(tmpdir)) # Create minimal evaluation file output_dir = Path(tmpdir) / "output" / "titus" output_dir.mkdir(parents=True) eval_file = output_dir / "chapter_01_eval.json" data = { "book": "titus", "chapter": 1, "verses": { "1": { "scores": { dim: {"score": 90, "confidence": 85, "reasoning": "Good"} for dim in DIMENSIONS }, "overall_score": 90.0, "flags": [], } }, } with eval_file.open("w") as f: json.dump(data, f) # Load should work even with missing optional fields loaded = storage.load_chapter_evaluation("titus", 1) assert loaded is not None assert loaded.book == "titus" class TestTranslationEvaluatorExtended: """Extended tests for TranslationEvaluator class.""" def test_build_verse_prompt_with_context(self) -> None: """Test building verse prompt with full translation context.""" from src.models.verse import ( GlossaryTerm, TranslationContext, Verse, VerseReference, ) # Create a mock context ref = VerseReference(book="titus", chapter=1, verse=1) verse = Verse( reference=ref, greek_text="Παῦλος δοῦλος θεοῦ", greek_translit=None, hebrew_text=None, hebrew_translit=None, japanese_meiji=None, japanese_taisho="神の僕パウロ", english_kjv="Paul, a servant of God", english_asv=None, ) glossary = [ GlossaryTerm( original="θεός", transliteration="theos", japanese="神", english="God", notes=None, ) ] context = TranslationContext( target_verse=verse, preceding_verses=[], following_verses=[], chapter_summary=None, book_metadata=None, glossary_terms=glossary, ) evaluator = TranslationEvaluator() prompt = evaluator.build_verse_prompt("神の僕パウロから", context) assert "神の僕パウロから" in prompt assert "Παῦλος" in prompt assert "神" in prompt # Glossary term def test_build_verse_prompt_empty_glossary(self) -> None: """Test building verse prompt with empty glossary.""" from src.models.verse import TranslationContext, Verse, VerseReference ref = VerseReference(book="titus", chapter=1, verse=1) verse = Verse( reference=ref, greek_text="Test Greek", greek_translit=None, hebrew_text=None, hebrew_translit=None, japanese_meiji=None, japanese_taisho=None, english_kjv="Test English", english_asv=None, ) context = TranslationContext( target_verse=verse, preceding_verses=[], following_verses=[], chapter_summary=None, book_metadata=None, glossary_terms=[], # Empty glossary ) evaluator = TranslationEvaluator() prompt = evaluator.build_verse_prompt("テスト翻訳", context) assert "テスト翻訳" in prompt assert "GLOSSARY TERMS" not in prompt # Should not include glossary section