"""Tests for skillopt.types — Edit and Patch dataclass serialization.""" from __future__ import annotations import pytest from skillopt.types import Edit, Patch # ── Edit ──────────────────────────────────────────────────────────────────── class TestEditCreation: """Edit dataclass construction.""" def test_minimal_edit(self) -> None: e = Edit(op="append") assert e.op == "append" assert e.content == "" assert e.target == "" assert e.support_count is None assert e.source_type is None assert e.merge_level is None assert e.update_origin == "" assert e.update_target == "" def test_full_edit(self) -> None: e = Edit( op="replace", content="new content", target="old content", support_count=5, source_type="failure", merge_level=2, update_origin="reflect", update_target="skill", ) assert e.op == "replace" assert e.content == "new content" assert e.target == "old content" assert e.support_count == 5 assert e.source_type == "failure" assert e.merge_level == 2 assert e.update_origin == "reflect" assert e.update_target == "skill" def test_insert_after_op(self) -> None: e = Edit(op="insert_after", content="insertion", target="anchor") assert e.op == "insert_after" assert e.content == "insertion" assert e.target == "anchor" def test_delete_op(self) -> None: e = Edit(op="delete", target="thing_to_remove") assert e.op == "delete" assert e.target == "thing_to_remove" class TestEditRoundTrip: """Edit.to_dict() / Edit.from_dict() round-trip.""" def test_round_trip_minimal(self) -> None: e = Edit(op="append") d = e.to_dict() restored = Edit.from_dict(d) assert restored == e def test_round_trip_full(self) -> None: e = Edit( op="replace", content="new content", target="old content", support_count=3, source_type="success", merge_level=1, update_origin="meta_reflect", update_target="system_prompt", ) d = e.to_dict() restored = Edit.from_dict(d) assert restored == e def test_round_trip_delete_without_content(self) -> None: e = Edit(op="delete", target="obsolete_line") d = e.to_dict() restored = Edit.from_dict(d) assert restored == e def test_optional_fields_omitted_when_default(self) -> None: e = Edit(op="append") d = e.to_dict() assert d == {"op": "append", "content": ""} # support_count, source_type, etc. should be absent assert "support_count" not in d assert "source_type" not in d assert "merge_level" not in d assert "target" not in d assert "update_origin" not in d assert "update_target" not in d def test_from_dict_with_defaults(self) -> None: d = {"op": "replace", "content": "abc"} e = Edit.from_dict(d) assert e.op == "replace" assert e.content == "abc" assert e.target == "" assert e.support_count is None assert e.source_type is None def test_from_dict_with_extra_keys(self) -> None: """Extra keys in dict should be ignored.""" d = {"op": "append", "content": "", "unknown_field": 42} e = Edit.from_dict(d) assert e.op == "append" assert not hasattr(e, "unknown_field") class TestEditEdgeCases: """Edge cases around Edit.""" def test_support_count_zero(self) -> None: """0 is a valid support_count and should be serialized.""" e = Edit(op="append", support_count=0) d = e.to_dict() assert d["support_count"] == 0 restored = Edit.from_dict(d) assert restored.support_count == 0 def test_merge_level_zero(self) -> None: e = Edit(op="replace", merge_level=0) d = e.to_dict() assert d["merge_level"] == 0 restored = Edit.from_dict(d) assert restored.merge_level == 0 def test_empty_target_stays_empty(self) -> None: e = Edit(op="append", target="") d = e.to_dict() assert "target" not in d # ── Patch ─────────────────────────────────────────────────────────────────── class TestPatchCreation: """Patch dataclass construction.""" def test_empty_patch(self) -> None: p = Patch() assert p.edits == [] assert p.reasoning == "" assert p.ranking_details is None def test_patch_with_edits(self) -> None: edits = [ Edit(op="append", content="step 1"), Edit(op="append", content="step 2"), ] p = Patch(edits=edits, reasoning="Added two steps") assert len(p.edits) == 2 assert p.reasoning == "Added two steps" def test_patch_with_ranking_details(self) -> None: p = Patch(ranking_details={"score": 0.95, "rank": 1}) assert p.ranking_details == {"score": 0.95, "rank": 1} class TestPatchRoundTrip: """Patch.to_dict() / Patch.from_dict() round-trip.""" def test_round_trip_empty(self) -> None: p = Patch() d = p.to_dict() restored = Patch.from_dict(d) assert restored.edits == [] assert restored.reasoning == "" assert restored.ranking_details is None def test_round_trip_with_edits(self) -> None: edits = [ Edit(op="insert_after", content="new step", target="existing step"), Edit(op="replace", content="updated", target="old"), ] p = Patch(edits=edits, reasoning="Batch update") d = p.to_dict() restored = Patch.from_dict(d) assert len(restored.edits) == 2 for original, restored_edit in zip(p.edits, restored.edits): assert isinstance(restored_edit, Edit) assert original == restored_edit assert restored.reasoning == "Batch update" assert restored.ranking_details is None def test_round_trip_with_ranking_details(self) -> None: details = {"strategy": "rouge", "scores": [0.9, 0.8, 0.7]} p = Patch( edits=[Edit(op="append", content="a")], reasoning="selected best", ranking_details=details, ) d = p.to_dict() restored = Patch.from_dict(d) assert restored.ranking_details == details def test_to_dict_contains_reasoning_and_edits(self) -> None: p = Patch(edits=[Edit(op="append", content="test")], reasoning="reason") d = p.to_dict() assert "reasoning" in d assert "edits" in d assert isinstance(d["edits"], list) def test_from_dict_preserves_edit_order(self) -> None: edits = [ Edit(op="append", content="first"), Edit(op="insert_after", content="second", target="first"), Edit(op="append", content="third"), ] p = Patch(edits=edits, reasoning="ordered") d = p.to_dict() restored = Patch.from_dict(d) assert restored.edits[0].content == "first" assert restored.edits[1].content == "second" assert restored.edits[2].content == "third" class TestPatchEdgeCases: """Edge cases around Patch.""" def test_reasoning_empty_string(self) -> None: p = Patch(reasoning="") d = p.to_dict() assert d["reasoning"] == "" def test_zero_edits(self) -> None: """Patch with explicitly empty edit list.""" p = Patch(edits=[]) d = p.to_dict() assert d["edits"] == [] def test_nested_edit_from_dict_handles_dicts(self) -> None: """from_dict should accept dicts in the 'edits' list.""" d = { "reasoning": "test", "edits": [{"op": "append", "content": "hello"}], } p = Patch.from_dict(d) assert len(p.edits) == 1 assert isinstance(p.edits[0], Edit) assert p.edits[0].op == "append" assert p.edits[0].content == "hello"