"""Tests for provenance tracking.""" from __future__ import annotations from datetime import datetime import pytest from src.orchestration.provenance import ( TranslationProvenance, compute_glossary_hash, compute_hash, compute_style_hash, ) class TestComputeHash: """Tests for compute_hash function.""" def test_hash_text(self) -> None: """Test hashing a text string.""" result = compute_hash("hello world") assert len(result) == 64 # SHA256 hex length assert result.isalnum() def test_hash_none(self) -> None: """Test hashing None returns empty string.""" result = compute_hash(None) assert result == "" def test_hash_empty_string(self) -> None: """Test hashing empty string returns empty string.""" result = compute_hash("") assert result == "" def test_hash_deterministic(self) -> None: """Test that hashing is deterministic.""" text = "test content" hash1 = compute_hash(text) hash2 = compute_hash(text) assert hash1 == hash2 def test_different_text_different_hash(self) -> None: """Test that different text produces different hash.""" hash1 = compute_hash("text1") hash2 = compute_hash("text2") assert hash1 != hash2 class TestComputeGlossaryHash: """Tests for compute_glossary_hash function.""" def test_hash_empty_list(self) -> None: """Test hashing empty list returns empty string.""" result = compute_glossary_hash([]) assert result == "" def test_hash_terms(self) -> None: """Test hashing glossary terms.""" terms = ["logos", "agape", "pneuma"] result = compute_glossary_hash(terms) assert len(result) == 64 def test_hash_order_independent(self) -> None: """Test that hash is order-independent (sorted internally).""" terms1 = ["beta", "alpha", "gamma"] terms2 = ["alpha", "beta", "gamma"] hash1 = compute_glossary_hash(terms1) hash2 = compute_glossary_hash(terms2) assert hash1 == hash2 class TestComputeStyleHash: """Tests for compute_style_hash function.""" def test_hash_empty_config(self) -> None: """Test hashing empty config returns empty string.""" result = compute_style_hash({}) assert result == "" def test_hash_config(self) -> None: """Test hashing style config.""" config = {"equivalence": "formal", "readability": "scholarly"} result = compute_style_hash(config) assert len(result) == 64 def test_hash_key_order_independent(self) -> None: """Test that hash is key-order independent.""" config1 = {"a": 1, "b": 2} config2 = {"b": 2, "a": 1} hash1 = compute_style_hash(config1) hash2 = compute_style_hash(config2) assert hash1 == hash2 class TestTranslationProvenance: """Tests for TranslationProvenance dataclass.""" def test_default_values(self) -> None: """Test default values for TranslationProvenance.""" prov = TranslationProvenance() assert prov.source_texts == {} assert prov.source_hashes == {} assert prov.preceding_verses == [] assert prov.following_verses == [] assert prov.pericope_id is None assert prov.style_config_hash == "" assert prov.glossary_hash == "" assert prov.prompt_version == "v1.0" assert prov.model == "" assert prov.system_prompt == "" assert prov.user_prompt == "" assert prov.tm_matches_used == [] assert prov.attempt_number == 1 assert prov.started_at is None assert prov.completed_at is None assert prov.evaluation_score is None assert prov.evaluation_flags == [] def test_compute_source_hashes(self) -> None: """Test computing source hashes.""" prov = TranslationProvenance( source_texts={ "greek": "Ἐν ἀρχῇ ἦν ὁ λόγος", "kjv": "In the beginning was the Word", } ) prov.compute_source_hashes() assert len(prov.source_hashes) == 2 assert "greek" in prov.source_hashes assert "kjv" in prov.source_hashes assert len(prov.source_hashes["greek"]) == 64 def test_to_dict(self) -> None: """Test converting provenance to dictionary.""" now = datetime.utcnow() prov = TranslationProvenance( model="test-model", started_at=now, completed_at=now, evaluation_score=85.5, evaluation_flags=["flag1"], ) result = prov.to_dict() assert result["model"] == "test-model" assert result["started_at"] == now.isoformat() assert result["completed_at"] == now.isoformat() assert result["evaluation_score"] == 85.5 assert result["evaluation_flags"] == ["flag1"] def test_from_dict(self) -> None: """Test creating provenance from dictionary.""" now = datetime.utcnow() data = { "model": "test-model", "prompt_version": "v2.0", "source_texts": {"greek": "text"}, "source_hashes": {"greek": "abc123"}, "started_at": now.isoformat(), "completed_at": now.isoformat(), "evaluation_score": 90.0, } prov = TranslationProvenance.from_dict(data) assert prov.model == "test-model" assert prov.prompt_version == "v2.0" assert prov.source_texts == {"greek": "text"} assert prov.source_hashes == {"greek": "abc123"} assert prov.evaluation_score == 90.0 def test_from_dict_missing_fields(self) -> None: """Test from_dict handles missing fields.""" prov = TranslationProvenance.from_dict({}) assert prov.model == "" assert prov.source_texts == {} def test_roundtrip(self) -> None: """Test that to_dict/from_dict roundtrip preserves data.""" now = datetime.utcnow() original = TranslationProvenance( source_texts={"greek": "text"}, source_hashes={"greek": "hash"}, preceding_verses=["john:1:1"], following_verses=["john:1:3"], pericope_id="john-1-prologue", model="test-model", system_prompt="system", user_prompt="user", tm_matches_used=[{"source": "a", "target": "b"}], attempt_number=2, started_at=now, completed_at=now, evaluation_score=80.0, evaluation_flags=["flag"], ) restored = TranslationProvenance.from_dict(original.to_dict()) assert restored.source_texts == original.source_texts assert restored.source_hashes == original.source_hashes assert restored.preceding_verses == original.preceding_verses assert restored.following_verses == original.following_verses assert restored.pericope_id == original.pericope_id assert restored.model == original.model assert restored.tm_matches_used == original.tm_matches_used assert restored.attempt_number == original.attempt_number assert restored.evaluation_score == original.evaluation_score def test_has_source_change_no_change(self) -> None: """Test has_source_change with identical hashes.""" prov1 = TranslationProvenance( source_hashes={"greek": "abc123", "kjv": "def456"} ) prov2 = TranslationProvenance( source_hashes={"greek": "abc123", "kjv": "def456"} ) assert prov1.has_source_change(prov2) is False def test_has_source_change_with_change(self) -> None: """Test has_source_change with different hashes.""" prov1 = TranslationProvenance( source_hashes={"greek": "abc123", "kjv": "def456"} ) prov2 = TranslationProvenance( source_hashes={"greek": "abc123", "kjv": "changed"} ) assert prov1.has_source_change(prov2) is True def test_has_source_change_missing_key(self) -> None: """Test has_source_change when other is missing a key.""" prov1 = TranslationProvenance( source_hashes={"greek": "abc123", "kjv": "def456"} ) prov2 = TranslationProvenance(source_hashes={"greek": "abc123"}) assert prov1.has_source_change(prov2) is True def test_get_inputs_summary(self) -> None: """Test get_inputs_summary.""" prov = TranslationProvenance( source_texts={"greek": "text", "kjv": "text"}, preceding_verses=["john:1:1", "john:1:2"], following_verses=["john:1:4"], pericope_id="john-1", tm_matches_used=[{"source": "a"}], model="test-model", attempt_number=2, ) summary = prov.get_inputs_summary() assert set(summary["sources"]) == {"greek", "kjv"} assert summary["preceding_context"] == 2 assert summary["following_context"] == 1 assert summary["pericope"] is True assert summary["tm_matches"] == 1 assert summary["model"] == "test-model" assert summary["attempt"] == 2