"""Tests for Protocol Buffer serialization utilities.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone import pytest from src.protos.serialization import ( compute_proto_hash, deserialize_proto, serialize_proto, ) @dataclass class SimpleProto: """Simple test proto.""" name: str = "" value: int = 0 @dataclass class NestedProto: """Proto with nested dataclass.""" simple: SimpleProto = field(default_factory=SimpleProto) items: list[str] = field(default_factory=list) @dataclass class DateTimeProto: """Proto with datetime fields.""" created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime | None = None @dataclass class ComplexProto: """Proto with various field types.""" name: str = "" count: int = 0 score: float = 0.0 active: bool = False tags: list[str] = field(default_factory=list) metadata: dict = field(default_factory=dict) nested: SimpleProto = field(default_factory=SimpleProto) nested_list: list[SimpleProto] = field(default_factory=list) timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) optional_time: datetime | None = None class TestSerializeProto: """Tests for serialize_proto function.""" def test_serialize_simple(self) -> None: """Test serializing a simple proto.""" proto = SimpleProto(name="test", value=42) data = serialize_proto(proto) assert isinstance(data, bytes) assert len(data) > 0 def test_serialize_nested(self) -> None: """Test serializing a proto with nested dataclass.""" proto = NestedProto( simple=SimpleProto(name="nested", value=100), items=["a", "b", "c"], ) data = serialize_proto(proto) assert isinstance(data, bytes) assert len(data) > 0 def test_serialize_datetime(self) -> None: """Test serializing a proto with datetime fields.""" now = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) proto = DateTimeProto( created_at=now, updated_at=now, ) data = serialize_proto(proto) assert isinstance(data, bytes) def test_serialize_complex(self) -> None: """Test serializing a complex proto.""" proto = ComplexProto( name="complex", count=100, score=0.95, active=True, tags=["tag1", "tag2"], metadata={"key": "value"}, nested=SimpleProto(name="inner", value=50), nested_list=[ SimpleProto(name="item1", value=1), SimpleProto(name="item2", value=2), ], timestamp=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), optional_time=None, ) data = serialize_proto(proto) assert isinstance(data, bytes) def test_serialize_raises_for_non_dataclass(self) -> None: """Test serialize_proto raises TypeError for non-dataclass.""" with pytest.raises(TypeError, match="Expected dataclass instance"): serialize_proto({"not": "dataclass"}) def test_serialize_raises_for_class_type(self) -> None: """Test serialize_proto raises TypeError for class type.""" with pytest.raises(TypeError, match="Expected dataclass instance"): serialize_proto(SimpleProto) class TestDeserializeProto: """Tests for deserialize_proto function.""" def test_deserialize_simple(self) -> None: """Test deserializing a simple proto.""" original = SimpleProto(name="test", value=42) data = serialize_proto(original) result = deserialize_proto(data, SimpleProto) assert isinstance(result, SimpleProto) assert result.name == "test" assert result.value == 42 def test_deserialize_nested(self) -> None: """Test deserializing a proto with nested dataclass.""" original = NestedProto( simple=SimpleProto(name="nested", value=100), items=["a", "b", "c"], ) data = serialize_proto(original) result = deserialize_proto(data, NestedProto) assert isinstance(result, NestedProto) assert isinstance(result.simple, SimpleProto) assert result.simple.name == "nested" assert result.simple.value == 100 assert result.items == ["a", "b", "c"] def test_deserialize_datetime(self) -> None: """Test deserializing a proto with datetime fields.""" now = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) original = DateTimeProto( created_at=now, updated_at=now, ) data = serialize_proto(original) result = deserialize_proto(data, DateTimeProto) assert isinstance(result, DateTimeProto) assert result.created_at == now assert result.updated_at == now def test_deserialize_datetime_naive(self) -> None: """Test deserializing converts naive datetime to UTC.""" # Create a proto with a naive datetime proto = DateTimeProto( created_at=datetime(2024, 1, 1, 12, 0, 0), # Naive datetime updated_at=None, ) data = serialize_proto(proto) result = deserialize_proto(data, DateTimeProto) # Result should have timezone-aware datetime assert result.created_at.tzinfo is not None def test_deserialize_complex(self) -> None: """Test deserializing a complex proto.""" original = ComplexProto( name="complex", count=100, score=0.95, active=True, tags=["tag1", "tag2"], metadata={"key": "value"}, nested=SimpleProto(name="inner", value=50), nested_list=[ SimpleProto(name="item1", value=1), SimpleProto(name="item2", value=2), ], timestamp=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), optional_time=None, ) data = serialize_proto(original) result = deserialize_proto(data, ComplexProto) assert result.name == "complex" assert result.count == 100 assert result.score == 0.95 assert result.active is True assert result.tags == ["tag1", "tag2"] assert result.metadata == {"key": "value"} assert result.nested.name == "inner" assert len(result.nested_list) == 2 def test_deserialize_raises_for_non_dataclass(self) -> None: """Test deserialize_proto raises TypeError for non-dataclass type.""" data = serialize_proto(SimpleProto(name="test", value=1)) with pytest.raises(TypeError, match="Expected dataclass type"): deserialize_proto(data, dict) def test_deserialize_raises_for_invalid_data(self) -> None: """Test deserialize_proto raises ValueError for invalid data.""" with pytest.raises(ValueError, match="Failed to decode msgpack"): deserialize_proto(b"invalid msgpack data", SimpleProto) class TestRoundtripConversions: """Tests for roundtrip serialize/deserialize conversions.""" def test_simple_roundtrip(self) -> None: """Test simple proto roundtrip.""" original = SimpleProto(name="roundtrip", value=999) data = serialize_proto(original) result = deserialize_proto(data, SimpleProto) assert result.name == original.name assert result.value == original.value def test_empty_proto_roundtrip(self) -> None: """Test empty proto roundtrip.""" original = SimpleProto() data = serialize_proto(original) result = deserialize_proto(data, SimpleProto) assert result.name == "" assert result.value == 0 def test_unicode_roundtrip(self) -> None: """Test proto with unicode content roundtrip.""" original = SimpleProto(name="日本語テスト", value=42) data = serialize_proto(original) result = deserialize_proto(data, SimpleProto) assert result.name == "日本語テスト" def test_nested_list_roundtrip(self) -> None: """Test proto with nested list roundtrip.""" original = ComplexProto( nested_list=[ SimpleProto(name="first", value=1), SimpleProto(name="second", value=2), SimpleProto(name="third", value=3), ], ) data = serialize_proto(original) result = deserialize_proto(data, ComplexProto) assert len(result.nested_list) == 3 assert result.nested_list[0].name == "first" assert result.nested_list[2].value == 3 class TestComputeProtoHash: """Tests for compute_proto_hash function.""" def test_compute_hash_returns_string(self) -> None: """Test compute_proto_hash returns a hex string.""" proto = SimpleProto(name="test", value=42) hash_value = compute_proto_hash(proto) assert isinstance(hash_value, str) assert len(hash_value) == 64 # SHA256 produces 64 hex chars def test_same_proto_same_hash(self) -> None: """Test identical protos produce identical hashes.""" proto1 = SimpleProto(name="test", value=42) proto2 = SimpleProto(name="test", value=42) hash1 = compute_proto_hash(proto1) hash2 = compute_proto_hash(proto2) assert hash1 == hash2 def test_different_proto_different_hash(self) -> None: """Test different protos produce different hashes.""" proto1 = SimpleProto(name="test1", value=42) proto2 = SimpleProto(name="test2", value=42) hash1 = compute_proto_hash(proto1) hash2 = compute_proto_hash(proto2) assert hash1 != hash2 def test_hash_deterministic(self) -> None: """Test hash is deterministic across multiple calls.""" proto = ComplexProto( name="complex", count=100, tags=["a", "b", "c"], nested=SimpleProto(name="inner", value=50), ) hashes = [compute_proto_hash(proto) for _ in range(5)] assert all(h == hashes[0] for h in hashes) def test_hash_raises_for_non_dataclass(self) -> None: """Test compute_proto_hash raises TypeError for non-dataclass.""" with pytest.raises(TypeError, match="Expected dataclass instance"): compute_proto_hash({"not": "dataclass"}) class TestRealProtoTypes: """Tests with actual proto types from the application.""" def test_verse_reference_proto_roundtrip(self) -> None: """Test VerseReferenceProto serialization roundtrip.""" from src.protos.converters import VerseReferenceProto original = VerseReferenceProto(book="john", chapter=1, verse=1) data = serialize_proto(original) result = deserialize_proto(data, VerseReferenceProto) assert result.book == "john" assert result.chapter == 1 assert result.verse == 1 def test_translation_result_proto_roundtrip(self) -> None: """Test TranslationResultProto serialization roundtrip.""" from src.protos.converters import ( ConfidenceLevel, TranslationResultProto, VerseReferenceProto, ) original = TranslationResultProto( reference=VerseReferenceProto(book="john", chapter=1, verse=1), text="初めに言葉があった", text_with_inline_furigana="初め[はじめ]に言葉[ことば]があった", model="claude-3-opus", confidence=ConfidenceLevel.HIGH, confidence_score=0.95, input_hash="abc123", ) data = serialize_proto(original) result = deserialize_proto(data, TranslationResultProto) assert result.text == "初めに言葉があった" assert result.model == "claude-3-opus" assert result.confidence_score == 0.95 assert result.input_hash == "abc123" def test_translation_prompt_input_proto_roundtrip(self) -> None: """Test TranslationPromptInputProto serialization roundtrip.""" from src.protos.converters import ( TranslationPromptInputProto, VerseProto, VerseReferenceProto, ) original = TranslationPromptInputProto( target_verse=VerseProto( reference=VerseReferenceProto(book="john", chapter=1, verse=1), greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος", ), system_prompt="You are a translator", user_prompt="Translate this verse", model="claude-3-opus", ) data = serialize_proto(original) result = deserialize_proto(data, TranslationPromptInputProto) assert result.target_verse.greek_text == "Ἐν ἀρχῇ ἦν ὁ λόγος" assert result.system_prompt == "You are a translator" assert result.model == "claude-3-opus" def test_provenance_proto_roundtrip(self) -> None: """Test TranslationProvenanceProto serialization roundtrip.""" from datetime import datetime, timezone from src.protos.converters import TranslationProvenanceProto started = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) completed = datetime(2024, 1, 1, 12, 0, 30, tzinfo=timezone.utc) original = TranslationProvenanceProto( source_texts={"greek": "Greek text", "kjv": "KJV text"}, source_hashes={"greek": "hash1", "kjv": "hash2"}, model="claude-3-opus", prompt_version="v1.0", started_at=started, completed_at=completed, evaluation_score=85.5, ) data = serialize_proto(original) result = deserialize_proto(data, TranslationProvenanceProto) assert result.source_texts == {"greek": "Greek text", "kjv": "KJV text"} assert result.model == "claude-3-opus" assert result.started_at == started assert result.completed_at == completed assert result.evaluation_score == 85.5 class TestReconstructEdgeCases: """Tests for _reconstruct_dataclass edge branches.""" def test_reconstruct_rejects_non_dict(self) -> None: """Non-dict input raises ValueError.""" from src.protos.serialization import _reconstruct_dataclass with pytest.raises(ValueError, match="Expected dict"): _reconstruct_dataclass(["not", "a", "dict"], SimpleProto) def test_reconstruct_skips_unknown_fields(self) -> None: """Keys not present on the dataclass are ignored.""" from src.protos.serialization import _reconstruct_dataclass result = _reconstruct_dataclass( {"name": "x", "value": 1, "unknown_field": "dropped"}, SimpleProto ) assert result == SimpleProto(name="x", value=1) def test_reconstruct_unresolvable_string_annotation(self) -> None: """String annotations that can't be resolved pass values through.""" import typing as typing_mod from unittest.mock import patch from src.protos.serialization import _reconstruct_dataclass fake_hints = {"name": "SomeUnresolvableType", "value": int} with patch.object(typing_mod, "get_type_hints", return_value=fake_hints): result = _reconstruct_dataclass({"name": "kept", "value": 2}, SimpleProto) assert result.name == "kept" assert result.value == 2 def test_reconstruct_union_all_none_types(self) -> None: """Union whose non-None arms are exhausted leaves value as-is.""" from src.protos.serialization import _reconstruct_dataclass @dataclass class OnlyNoneProto: maybe: None = None result = _reconstruct_dataclass({"maybe": None}, OnlyNoneProto) assert result.maybe is None def test_reconstruct_bare_list_annotation(self) -> None: """list annotation without item args keeps the raw list.""" from src.protos.serialization import _reconstruct_dataclass @dataclass class BareListProto: items: list = field(default_factory=list) result = _reconstruct_dataclass({"items": [1, "two", 3.0]}, BareListProto) assert result.items == [1, "two", 3.0]