"""Unit tests for SQLAlchemy ORM models in agentkit.memory.models. Covers the two public ORM model classes β€” EpisodeModel and ExperienceModel: table mapping, column types/constraints, Python-side default callables, explicit-value construction, and edge cases (empty/long/unicode values). Note: memory/models.py contains SQLAlchemy ORM models (not Pydantic), so Pydantic-specific scenarios (ValidationError / model_dump / model_validate) are replaced by ORM equivalents (column introspection + default callable invocation + attribute assignment). DB-dependent helpers (create_episodic_session_factory, create_experience_session_factory, ensure_episodic_table) are NOT tested here β€” they require a live PostgreSQL + asyncpg connection. """ import uuid from datetime import datetime, timezone from sqlalchemy import DateTime, Float, String, Text from sqlalchemy.dialects.postgresql import JSONB from agentkit.memory.models import EpisodeModel, ExperienceModel def _assert_uuid_default(id_default) -> None: """SQLAlchemy wraps Python-side default callables to receive an execution context; pass None for callables that don't inspect it.""" val = id_default(None) assert isinstance(val, str) uuid.UUID(val) # raises ValueError if not a valid uuid4 string def _assert_utc_default(ts_default) -> None: ts = ts_default(None) assert isinstance(ts, datetime) assert ts.tzinfo is not None assert ts.utcoffset() == timezone.utc.utcoffset(None) def _assert_unique_ids(id_default, n: int = 100) -> None: ids = {id_default(None) for _ in range(n)} assert len(ids) == n class TestEpisodeModel: def test_tablename(self): assert EpisodeModel.__tablename__ == "episodic_memories" def test_column_types(self): cols = EpisodeModel.__table__.c assert isinstance(cols.id.type, String) assert isinstance(cols.agent_name.type, String) assert isinstance(cols.task_type.type, String) assert isinstance(cols.input_summary.type, Text) assert isinstance(cols.output_summary.type, Text) assert isinstance(cols.outcome.type, String) assert isinstance(cols.quality_score.type, Float) assert isinstance(cols.reflection.type, Text) assert isinstance(cols.embedding.type, Text) assert isinstance(cols.metadata.type, JSONB) assert isinstance(cols.created_at.type, DateTime) def test_primary_key(self): assert EpisodeModel.__table__.c.id.primary_key is True def test_indexed_columns(self): cols = EpisodeModel.__table__.c assert cols.agent_name.index is True assert cols.task_type.index is True assert cols.created_at.index is True def test_nullable_columns(self): cols = EpisodeModel.__table__.c assert cols.embedding.nullable is True assert cols.metadata.nullable is True def test_metadata_attribute_maps_to_metadata_column(self): # Python attribute is `metadata_` (avoids clash with Base.metadata); # the underlying DB column name is "metadata". assert hasattr(EpisodeModel, "metadata_") assert EpisodeModel.__table__.c.metadata.name == "metadata" def test_scalar_defaults(self): cols = EpisodeModel.__table__.c assert cols.input_summary.default.arg == "" assert cols.output_summary.default.arg == "" assert cols.reflection.default.arg == "" assert cols.outcome.default.arg == "success" assert cols.quality_score.default.arg == 0.5 def test_callable_default_id_is_uuid_string(self): _assert_uuid_default(EpisodeModel.__table__.c.id.default.arg) def test_callable_default_created_at_is_utc(self): _assert_utc_default(EpisodeModel.__table__.c.created_at.default.arg) def test_default_id_is_unique_across_calls(self): _assert_unique_ids(EpisodeModel.__table__.c.id.default.arg) def test_construct_with_explicit_values(self): ts = datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc) m = EpisodeModel( id="ep-1", agent_name="agent-a", task_type="analysis", input_summary="in", output_summary="out", outcome="failure", quality_score=0.9, reflection="ref", embedding="[0.1, 0.2]", metadata_={"k": "v"}, created_at=ts, ) assert m.id == "ep-1" assert m.agent_name == "agent-a" assert m.task_type == "analysis" assert m.input_summary == "in" assert m.output_summary == "out" assert m.outcome == "failure" assert m.quality_score == 0.9 assert m.reflection == "ref" assert m.embedding == "[0.1, 0.2]" assert m.metadata_ == {"k": "v"} assert m.created_at == ts def test_construct_minimal_yields_none_attrs(self): # ORM Python-side defaults fire at flush (INSERT) time, not instantiation, # so unset attributes read back as None. m = EpisodeModel() assert m.id is None assert m.agent_name is None assert m.created_at is None assert m.metadata_ is None def test_edge_empty_embedding_and_text(self): m = EpisodeModel(embedding="", reflection="", input_summary="") assert m.embedding == "" assert m.reflection == "" assert m.input_summary == "" def test_edge_empty_vector_json(self): m = EpisodeModel(embedding="[]") assert m.embedding == "[]" def test_edge_long_text(self): long_text = "x" * 10_000 m = EpisodeModel(input_summary=long_text, output_summary=long_text, reflection=long_text) assert len(m.input_summary) == 10_000 assert len(m.output_summary) == 10_000 assert len(m.reflection) == 10_000 def test_edge_unicode_and_special_characters(self): m = EpisodeModel( input_summary="δΈ­ζ–‡ + emoji πŸš€ + quote \" ' \n\t", metadata_={"unicode": "δ½ ε₯½", "null": None, "nested": {"a": [1, 2]}}, ) assert "πŸš€" in m.input_summary assert "δΈ­ζ–‡" in m.input_summary assert m.metadata_["unicode"] == "δ½ ε₯½" assert m.metadata_["null"] is None assert m.metadata_["nested"] == {"a": [1, 2]} def test_edge_empty_metadata_dict(self): m = EpisodeModel(metadata_={}) assert m.metadata_ == {} class TestExperienceModel: def test_tablename(self): assert ExperienceModel.__tablename__ == "task_experiences" def test_column_types(self): cols = ExperienceModel.__table__.c assert isinstance(cols.id.type, String) assert isinstance(cols.task_type.type, String) assert isinstance(cols.goal.type, Text) assert isinstance(cols.steps_summary.type, Text) assert isinstance(cols.outcome.type, String) assert isinstance(cols.duration_seconds.type, Float) assert isinstance(cols.success_rate.type, Float) assert isinstance(cols.failure_reasons.type, JSONB) assert isinstance(cols.optimization_tips.type, JSONB) assert isinstance(cols.embedding.type, Text) assert isinstance(cols.created_at.type, DateTime) def test_primary_key_and_indexes(self): cols = ExperienceModel.__table__.c assert cols.id.primary_key is True assert cols.task_type.index is True assert cols.created_at.index is True def test_nullable_embedding(self): assert ExperienceModel.__table__.c.embedding.nullable is True def test_scalar_defaults(self): cols = ExperienceModel.__table__.c assert cols.goal.default.arg == "" assert cols.steps_summary.default.arg == "" assert cols.outcome.default.arg == "success" assert cols.duration_seconds.default.arg == 0.0 assert cols.success_rate.default.arg == 1.0 def test_callable_list_defaults(self): cols = ExperienceModel.__table__.c # default=list (the type itself, callable β†’ empty list when invoked) assert cols.failure_reasons.default.is_callable assert cols.optimization_tips.default.is_callable assert cols.failure_reasons.default.arg(None) == [] assert cols.optimization_tips.default.arg(None) == [] def test_callable_default_id_is_uuid_string(self): _assert_uuid_default(ExperienceModel.__table__.c.id.default.arg) def test_callable_default_created_at_is_utc(self): _assert_utc_default(ExperienceModel.__table__.c.created_at.default.arg) def test_default_id_is_unique_across_calls(self): _assert_unique_ids(ExperienceModel.__table__.c.id.default.arg) def test_construct_with_explicit_values(self): ts = datetime(2025, 6, 7, 8, 9, 10, tzinfo=timezone.utc) m = ExperienceModel( id="exp-1", task_type="build", goal="ship feature", steps_summary="step1; step2", outcome="partial", duration_seconds=42.5, success_rate=0.66, failure_reasons=["timeout", "oom"], optimization_tips=["retry", "cache"], embedding="[0.3, 0.4]", created_at=ts, ) assert m.id == "exp-1" assert m.task_type == "build" assert m.goal == "ship feature" assert m.steps_summary == "step1; step2" assert m.outcome == "partial" assert m.duration_seconds == 42.5 assert m.success_rate == 0.66 assert m.failure_reasons == ["timeout", "oom"] assert m.optimization_tips == ["retry", "cache"] assert m.embedding == "[0.3, 0.4]" assert m.created_at == ts def test_construct_minimal_yields_none_attrs(self): m = ExperienceModel() assert m.id is None assert m.task_type is None assert m.created_at is None assert m.failure_reasons is None assert m.optimization_tips is None def test_edge_empty_lists_and_embedding(self): m = ExperienceModel( failure_reasons=[], optimization_tips=[], embedding="", ) assert m.failure_reasons == [] assert m.optimization_tips == [] assert m.embedding == "" def test_edge_long_text(self): long_text = "y" * 10_000 m = ExperienceModel(goal=long_text, steps_summary=long_text) assert len(m.goal) == 10_000 assert len(m.steps_summary) == 10_000 def test_edge_unicode_and_special_characters(self): m = ExperienceModel( goal="δΈ­ζ–‡ 🎯 ' \" \n\t", failure_reasons=["ι”™θ――", "🚨"], optimization_tips=["δΌ˜εŒ–"], ) assert "🎯" in m.goal assert "δΈ­ζ–‡" in m.goal assert m.failure_reasons == ["ι”™θ――", "🚨"] assert m.optimization_tips == ["δΌ˜εŒ–"]