diff --git a/TODO.md b/TODO.md index 33d4a3ad..7699211e 100644 --- a/TODO.md +++ b/TODO.md @@ -197,7 +197,7 @@ ### Testing Coverage -- [ ] **Increase unit test coverage for `core/` modules** +- [x] **Increase unit test coverage for `core/` modules** - `controller.py`, `evaluator.py`, `selection.py`, `version_database.py` - Target: meaningful coverage on core logic paths, not just line count - [x] **Add regression test for config validation** @@ -307,9 +307,9 @@ |----------|-------|------|-------| | 🔴 P0 | 11 | 11 | Original 5 complete; all 6 critical bugs from 2026-07-22 whole-repo review fixed (PRs #74, #76-#79) | | 🟠 P1 | 24 | 15 | Original safety/integration items done; +12 high-priority bugs from 2026-07-22 review; signal-handler init fix; safety.yaml created; monitoring dashboard auth+CORS fix | -| 🟡 P2 | 30 | 22 | Co-evolution loop gaps (8 items, 8 done) + existing P2 + 13 medium bugs from 2026-07-22 review + 4 latent collect->train bugs found closing the loop (1 fixed, 1 new HF-format gap resolved); provider_manager health-check await fix; workflow-agent private-API/event-loop fix; checkpoint save/restore test; trust_remote_code security fix | +| 🟡 P2 | 30 | 23 | Co-evolution loop gaps (8 items, 8 done) + existing P2 + 13 medium bugs from 2026-07-22 review + 4 latent collect->train bugs found closing the loop (1 fixed, 1 new HF-format gap resolved); provider_manager health-check await fix; workflow-agent private-API/event-loop fix; checkpoint save/restore test; trust_remote_code security fix | | 🟢 P3 | 24 | 16 | Makefile, pre-commit, Docker, ADRs, ADR refresh, CHANGELOG complete; +11 hygiene items from 2026-07-22 review; Ollama provider retry/backoff fix; local_models TTL cache; workspace prompt file conventions | -| **Total** | **89** | **64** | | +| **Total** | **89** | **65** | | > Update this table as you complete items. Recommended flow: P0 → P1 → P2 → P3. > diff --git a/tests/unit/evoseal/test_controller.py b/tests/unit/evoseal/test_controller.py new file mode 100644 index 00000000..5ba09c6c --- /dev/null +++ b/tests/unit/evoseal/test_controller.py @@ -0,0 +1,138 @@ +"""Unit tests for the Controller class in evoseal/core/controller.py.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from evoseal.core.controller import Controller + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def controller(): + mock_runner = MagicMock() + mock_evaluator = MagicMock() + return Controller(test_runner=mock_runner, evaluator=mock_evaluator) + + +# --- initialize --- + + +def test_initialize_sets_state_and_generation(controller): + config = {"max_generations": 10, "strategy": "default"} + controller.initialize(config) + assert controller.state["config"] == config + assert controller.state["generations"] == [] + assert controller.current_generation == 0 + + +def test_initialize_resets_generation_counter(controller): + controller.current_generation = 5 + controller.initialize({"k": "v"}) + assert controller.current_generation == 0 + + +# --- select_candidates --- + + +def test_select_candidates_returns_top_n(controller): + eval_results = [ + {"score": 0.3}, + {"score": 0.9}, + {"score": 0.5}, + {"score": 0.8}, + {"score": 0.1}, + {"score": 0.7}, + ] + selected = controller.select_candidates(eval_results) + assert len(selected) == 5 # default top 5 + scores = [r["score"] for r in selected] + assert scores == sorted(scores, reverse=True) + + +def test_select_candidates_handles_fewer_than_five(controller): + eval_results = [{"score": 0.5}, {"score": 0.9}] + selected = controller.select_candidates(eval_results) + assert len(selected) == 2 + + +def test_select_candidates_defaults_missing_score_to_zero(controller): + eval_results = [{"score": 0.8}, {}, {"score": 0.6}] + selected = controller.select_candidates(eval_results) + # The one without 'score' gets 0.0; top 3 should be 0.8, 0.6, 0.0 + assert len(selected) == 3 + assert selected[0]["score"] == 0.8 + assert selected[1]["score"] == 0.6 + assert selected[2].get("score", 0.0) == 0.0 + + +# --- run_generation --- + + +def test_run_generation_orchestrates_and_advances(controller): + controller.initialize({}) + controller.test_runner.run_tests.return_value = [ + {"pass_rate": 1.0, "coverage": 0.9, "quality": 0.8}, + ] + controller.evaluator.evaluate.return_value = [{"score": 0.95, "feedback": "ok"}] + + controller.run_generation() + + controller.test_runner.run_tests.assert_called_once_with(".") + controller.evaluator.evaluate.assert_called_once() + assert controller.current_generation == 1 + gen = controller.state["generations"][0] + assert gen["generation"] == 0 + assert gen["test_results"] == controller.test_runner.run_tests.return_value + assert gen["eval_results"] == controller.evaluator.evaluate.return_value + + +def test_run_generation_multiple(controller): + controller.initialize({}) + controller.test_runner.run_tests.return_value = [] + controller.evaluator.evaluate.return_value = [] + + controller.run_generation() + controller.run_generation() + + assert controller.current_generation == 2 + assert len(controller.state["generations"]) == 2 + + +# --- get_state --- + + +def test_get_state_empty_before_init(controller): + assert controller.get_state() == {} + + +def test_get_state_returns_current_state(controller): + controller.initialize({"foo": "bar"}) + state = controller.get_state() + assert state["config"]["foo"] == "bar" + + +# --- cli_interface --- + + +def test_cli_status(controller): + controller.initialize({"x": 1}) + result = controller.cli_interface("status") + assert result["config"]["x"] == 1 + + +def test_cli_run_generation(controller): + controller.initialize({}) + controller.test_runner.run_tests.return_value = [] + controller.evaluator.evaluate.return_value = [] + result = controller.cli_interface("run_generation") + assert result["msg"] == "Generation complete" + assert result["generation"] == 1 + + +def test_cli_unknown_command(controller): + result = controller.cli_interface("nonexistent") + assert "error" in result diff --git a/tests/unit/evoseal/test_evaluator_extended.py b/tests/unit/evoseal/test_evaluator_extended.py new file mode 100644 index 00000000..678370a0 --- /dev/null +++ b/tests/unit/evoseal/test_evaluator_extended.py @@ -0,0 +1,120 @@ +"""Extended unit tests for Evaluator. + +Covers edge cases: empty results, missing metrics, unknown strategy, +score bounds, and generate_feedback paths not covered by the original test_evaluator.py. +""" + +from __future__ import annotations + +import pytest + +from evoseal.core.evaluator import Evaluator + +pytestmark = pytest.mark.unit + + +def test_evaluate_empty_results(): + evaluator = Evaluator() + results = evaluator.evaluate([]) + assert results == [] + + +def test_evaluate_missing_metrics_default_to_zero(): + """Missing metric keys should default to 0.0 in the score calculation.""" + evaluator = Evaluator() + results = evaluator.evaluate([{}]) + assert len(results) == 1 + assert results[0]["score"] == 0.0 + + +def test_evaluate_partial_metrics(): + """Only pass_rate present; coverage and quality default to 0.""" + evaluator = Evaluator() + results = evaluator.evaluate([{"pass_rate": 1.0}]) + # score = 0.7 * 1.0 + 0.2 * 0.0 + 0.1 * 0.0 = 0.7 + assert abs(results[0]["score"] - 0.7) < 1e-6 + + +def test_evaluate_unknown_strategy_falls_back_to_default(): + """Unknown strategy falls back to default_strategy (no raise).""" + evaluator = Evaluator() + results = evaluator.evaluate( + [{"pass_rate": 1.0, "coverage": 1.0, "quality": 1.0}], strategy="nonexistent" + ) + assert len(results) == 1 + assert "score" in results[0] + + +def test_evaluate_all_metrics_perfect(): + evaluator = Evaluator() + results = evaluator.evaluate([{"pass_rate": 1.0, "coverage": 1.0, "quality": 1.0}]) + # score = 0.7 * 1.0 + 0.2 * 1.0 + 0.1 * 1.0 = 1.0 + assert abs(results[0]["score"] - 1.0) < 1e-6 + + +def test_evaluate_all_metrics_zero(): + evaluator = Evaluator() + results = evaluator.evaluate([{"pass_rate": 0.0, "coverage": 0.0, "quality": 0.0}]) + assert results[0]["score"] == 0.0 + + +def test_feedback_contains_all_metrics(): + evaluator = Evaluator() + result = evaluator.evaluate([{"pass_rate": 0.5, "coverage": 0.3, "quality": 0.4}])[0] + feedback = result["feedback"] + assert "pass_rate" in feedback + assert "coverage" in feedback + assert "quality" in feedback + + +def test_feedback_no_issues_when_perfect(): + evaluator = Evaluator() + result = evaluator.evaluate([{"pass_rate": 1.0, "coverage": 1.0, "quality": 1.0}])[0] + feedback = result["feedback"] + assert "Some tests failed" not in feedback + assert "Low coverage" not in feedback + assert "Code quality could be improved" not in feedback + + +def test_feedback_flags_low_coverage(): + evaluator = Evaluator() + result = evaluator.evaluate([{"pass_rate": 1.0, "coverage": 0.5, "quality": 1.0}])[0] + assert "Low coverage" in result["feedback"] + + +def test_feedback_flags_low_quality(): + evaluator = Evaluator() + result = evaluator.evaluate([{"pass_rate": 1.0, "coverage": 1.0, "quality": 0.3}])[0] + assert "Code quality could be improved" in result["feedback"] + + +def test_custom_default_weights(): + """Custom default_weights at construction time should be used when no weights arg.""" + evaluator = Evaluator(default_weights={"pass_rate": 1.0, "coverage": 0.0, "quality": 0.0}) + results = evaluator.evaluate([{"pass_rate": 0.5, "coverage": 1.0, "quality": 1.0}]) + assert abs(results[0]["score"] - 0.5) < 1e-6 + + +def test_add_strategy_overwrites_default(): + """Adding a strategy with name 'default' should replace the built-in.""" + evaluator = Evaluator() + + def always_one(result, weights): + return {"score": 1.0, "feedback": "always one", **result} + + evaluator.add_strategy("default", always_one) + results = evaluator.evaluate([{"pass_rate": 0.0}]) + assert results[0]["score"] == 1.0 + + +def test_evaluate_multiple_results(): + """Ensure each result is evaluated independently.""" + evaluator = Evaluator() + test_results = [ + {"pass_rate": 1.0, "coverage": 1.0, "quality": 1.0}, + {"pass_rate": 0.0, "coverage": 0.0, "quality": 0.0}, + {"pass_rate": 0.5, "coverage": 0.5, "quality": 0.5}, + ] + results = evaluator.evaluate(test_results) + assert len(results) == 3 + assert results[0]["score"] > results[2]["score"] > results[1]["score"] diff --git a/tests/unit/evoseal/test_selection_extended.py b/tests/unit/evoseal/test_selection_extended.py new file mode 100644 index 00000000..3ad15b3c --- /dev/null +++ b/tests/unit/evoseal/test_selection_extended.py @@ -0,0 +1,179 @@ +"""Extended unit tests for SelectionAlgorithm. + +Covers custom fitness_key, empty population, custom strategies, +elitism edge cases, and other paths not covered by the original test_selection.py. +""" + +from __future__ import annotations + +import random +import secrets + +import pytest + +from evoseal.core.selection import SelectionAlgorithm + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def population(): + return [ + {"id": f"v{i}", "eval_score": score} for i, score in enumerate([0.9, 0.8, 0.7, 0.6, 0.5]) + ] + + +@pytest.fixture(autouse=False) +def seeded_rng(monkeypatch): + """Replace secrets.SystemRandom with a seeded RNG for deterministic tests.""" + rng = random.Random(42) + monkeypatch.setattr(secrets, "SystemRandom", lambda: rng) + + +# --- Custom fitness_key --- + + +def test_tournament_custom_fitness_key(population, seeded_rng): + """Tournament selection should respect a custom fitness_key.""" + for ind in population: + ind["custom_score"] = 1.0 - ind["eval_score"] # invert scores + selector = SelectionAlgorithm() + selected = selector.select( + population, + num_selected=3, + strategy="tournament", + fitness_key="custom_score", + elitism=1, + ) + # v4 has the highest custom_score (0.5) so it is guaranteed as the elite. + assert selected[0]["id"] == "v4" + assert len(selected) == 3 + + +def test_roulette_custom_fitness_key(population): + """Roulette selection should respect a custom fitness_key.""" + for ind in population: + ind["custom_score"] = 1.0 - ind["eval_score"] + selector = SelectionAlgorithm() + selected = selector.select( + population, + num_selected=3, + strategy="roulette", + fitness_key="custom_score", + ) + assert len(selected) == 3 + + +# --- Empty / minimal population --- + + +def test_tournament_empty_population_raises(): + """Empty population raises IndexError — the algorithm cannot sample from nothing.""" + selector = SelectionAlgorithm() + with pytest.raises(IndexError): + selector.select([], num_selected=3, strategy="tournament") + + +def test_roulette_empty_population_raises(): + """Empty population raises IndexError — the algorithm cannot sample from nothing.""" + selector = SelectionAlgorithm() + with pytest.raises(IndexError): + selector.select([], num_selected=3, strategy="roulette") + + +def test_tournament_single_individual(): + pop = [{"id": "v0", "eval_score": 0.5}] + selector = SelectionAlgorithm() + selected = selector.select(pop, num_selected=1, strategy="tournament") + assert len(selected) == 1 + assert selected[0]["id"] == "v0" + + +def test_roulette_single_individual(): + pop = [{"id": "v0", "eval_score": 0.5}] + selector = SelectionAlgorithm() + selected = selector.select(pop, num_selected=1, strategy="roulette") + assert len(selected) == 1 + + +# --- Elitism edge cases --- + + +def test_elitism_larger_than_population(): + """When elitism >= population size, all individuals are elites.""" + pop = [{"id": "v0", "eval_score": 0.5}, {"id": "v1", "eval_score": 0.8}] + selector = SelectionAlgorithm() + selected = selector.select(pop, num_selected=2, strategy="tournament", elitism=5) + assert len(selected) == 2 + # Both should be present as elites + ids = {ind["id"] for ind in selected} + assert ids == {"v0", "v1"} + + +def test_elitism_zero(population): + """With elitism=0, no guaranteed top individuals.""" + selector = SelectionAlgorithm() + selected = selector.select(population, num_selected=3, strategy="tournament", elitism=0) + assert len(selected) == 3 + + +# --- num_selected edge cases --- + + +def test_num_selected_larger_than_population(population, seeded_rng): + """When num_selected > len(population), result is padded with repeats.""" + selector = SelectionAlgorithm() + selected = selector.select(population, num_selected=10, strategy="tournament") + assert len(selected) == 10 + population_ids = {ind["id"] for ind in population} + selected_ids = [ind["id"] for ind in selected] + assert set(selected_ids) <= population_ids + assert len(set(selected_ids)) < len(selected_ids) + + +def test_num_selected_zero(population): + selector = SelectionAlgorithm() + selected = selector.select(population, num_selected=0, strategy="tournament") + assert len(selected) == 0 + + +# --- Custom strategies --- + + +def test_custom_strategy(): + def top_one(pop, num_selected, **kw): + return [max(pop, key=lambda x: x.get("eval_score", 0))] + + selector = SelectionAlgorithm(strategies={"top_one": top_one}) + pop = [{"id": "v0", "eval_score": 0.3}, {"id": "v1", "eval_score": 0.9}] + selected = selector.select(pop, num_selected=1, strategy="top_one") + assert len(selected) == 1 + assert selected[0]["id"] == "v1" + + +# --- Negative / missing scores --- + + +def test_tournament_with_negative_scores(seeded_rng): + pop = [{"id": f"v{i}", "eval_score": s} for i, s in enumerate([-0.5, 0.0, 0.5])] + selector = SelectionAlgorithm() + selected = selector.select(pop, num_selected=2, strategy="tournament") + assert len(selected) == 2 + + +def test_roulette_with_negative_scores(seeded_rng): + """Negative scores are clamped to 0 in roulette selection.""" + pop = [{"id": f"v{i}", "eval_score": s} for i, s in enumerate([-0.5, 0.0, 0.5])] + selector = SelectionAlgorithm() + selected = selector.select(pop, num_selected=2, strategy="roulette") + assert len(selected) == 2 + assert all(s["id"] in {"v0", "v1", "v2"} for s in selected) + + +def test_tournament_missing_eval_score(seeded_rng): + """Missing eval_score defaults to 0.""" + pop = [{"id": "v0"}, {"id": "v1", "eval_score": 0.9}] + selector = SelectionAlgorithm() + selected = selector.select(pop, num_selected=1, strategy="tournament") + assert len(selected) == 1 + assert selected[0]["id"] == "v1" diff --git a/tests/unit/evoseal/test_version_database_extended.py b/tests/unit/evoseal/test_version_database_extended.py new file mode 100644 index 00000000..e3f00a62 --- /dev/null +++ b/tests/unit/evoseal/test_version_database_extended.py @@ -0,0 +1,302 @@ +"""Extended unit tests for VersionDatabase. + +Covers experiment tracking, get_best_variants, statistics, export/import, +and edge cases not covered by the original test_version_database.py. +""" + +from __future__ import annotations + +import json + +import pytest + +from evoseal.core.version_database import VersionDatabase + +pytestmark = pytest.mark.unit + +# --- Experiment tracking --- + + +def test_add_variant_with_experiment_id(): + db = VersionDatabase() + db.add_variant("v1", "code", {}, 0.8, experiment_id="exp1") + db.add_variant("v2", "code2", {}, 0.9, experiment_id="exp1") + db.add_variant("v3", "code3", {}, 0.7, experiment_id="exp2") + + assert db.get_experiment_variants("exp1") == ["v1", "v2"] + assert db.get_experiment_variants("exp2") == ["v3"] + assert db.get_experiment_variants("nonexistent") == [] + + +def test_get_variant_experiment(): + db = VersionDatabase() + db.add_variant("v1", "code", {}, 0.8, experiment_id="exp1") + assert db.get_variant_experiment("v1") == "exp1" + assert db.get_variant_experiment("nonexistent") is None + + +def test_add_variant_without_experiment_id(): + db = VersionDatabase() + db.add_variant("v1", "code", {}, 0.8) + assert db.get_variant_experiment("v1") is None + assert db.experiment_variants == {} + + +# --- get_variant_metadata --- + + +def test_get_variant_metadata(): + db = VersionDatabase() + db.add_variant("v1", "code", {}, 0.8, metadata={"author": "test"}) + assert db.get_variant_metadata("v1") == {"author": "test"} + + +def test_get_variant_metadata_nonexistent(): + db = VersionDatabase() + assert db.get_variant_metadata("missing") is None + + +def test_get_variant_metadata_no_metadata_set(): + db = VersionDatabase() + db.add_variant("v1", "code", {}, 0.8) + assert db.get_variant_metadata("v1") == {} + + +# --- get_best_variants --- + + +def test_get_best_variants_all(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5) + db.add_variant("v2", "B", {}, 0.9) + db.add_variant("v3", "C", {}, 0.7) + best = db.get_best_variants(limit=2) + assert len(best) == 2 + assert best[0]["eval_score"] == 0.9 + assert best[1]["eval_score"] == 0.7 + + +def test_get_best_variants_filtered_by_experiment(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5, experiment_id="exp1") + db.add_variant("v2", "B", {}, 0.9, experiment_id="exp2") + db.add_variant("v3", "C", {}, 0.7, experiment_id="exp1") + best = db.get_best_variants(experiment_id="exp1", limit=10) + assert len(best) == 2 + assert best[0]["eval_score"] == 0.7 + assert best[1]["eval_score"] == 0.5 + + +def test_get_best_variants_empty_db(): + db = VersionDatabase() + assert db.get_best_variants() == [] + + +def test_get_best_variants_limit_larger_than_db(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5) + best = db.get_best_variants(limit=100) + assert len(best) == 1 + + +# --- get_variant_statistics --- + + +def test_get_variant_statistics_basic(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.6) + db.add_variant("v2", "B", {}, 0.9) + db.add_variant("v3", "C", {}, 0.3) + stats = db.get_variant_statistics() + assert stats["total_variants"] == 3 + assert stats["best_score"] == 0.9 + assert stats["worst_score"] == 0.3 + assert abs(stats["average_score"] - 0.6) < 1e-6 + assert isinstance(stats["score_distribution"], dict) + + +def test_get_variant_statistics_empty(): + db = VersionDatabase() + stats = db.get_variant_statistics() + assert stats["total_variants"] == 0 + assert stats["best_score"] is None + assert stats["average_score"] is None + + +def test_get_variant_statistics_by_experiment(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5, experiment_id="exp1") + db.add_variant("v2", "B", {}, 0.9, experiment_id="exp2") + stats = db.get_variant_statistics(experiment_id="exp1") + assert stats["total_variants"] == 1 + assert stats["best_score"] == 0.5 + + +def test_get_variant_statistics_same_scores(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5) + db.add_variant("v2", "B", {}, 0.5) + stats = db.get_variant_statistics() + assert stats["best_score"] == 0.5 + assert stats["worst_score"] == 0.5 + # Distribution should handle min == max + dist = stats["score_distribution"] + assert len(dist) == 1 + + +# --- export / import --- + + +def test_export_variants_returns_json_string(): + db = VersionDatabase() + db.add_variant("v1", "code", {"passed": True}, 0.8) + json_str = db.export_variants() + data = json.loads(json_str) + assert "variants" in data + assert "v1" in data["variants"] + assert data["variants"]["v1"]["source"] == "code" + + +def test_export_variants_to_file(tmp_path): + db = VersionDatabase() + db.add_variant("v1", "code", {}, 0.8) + fpath = tmp_path / "export.json" + result = db.export_variants(file_path=fpath) + assert result is None + assert fpath.exists() + data = json.loads(fpath.read_text()) + assert "v1" in data["variants"] + + +def test_export_variants_filtered_by_experiment(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5, experiment_id="exp1") + db.add_variant("v2", "B", {}, 0.9, experiment_id="exp2") + json_str = db.export_variants(experiment_id="exp1") + data = json.loads(json_str) + assert "v1" in data["variants"] + assert "v2" not in data["variants"] + + +def test_import_variants_from_json_string(): + db = VersionDatabase() + export_data = { + "variants": { + "v1": { + "variant_id": "v1", + "source": "code", + "test_results": {}, + "eval_score": 0.8, + "parent_ids": [], + "metadata": {}, + "experiment_id": None, + } + }, + "lineage": {"v1": []}, + } + count = db.import_variants(json.dumps(export_data)) + assert count == 1 + assert db.get_variant("v1") is not None + assert db.get_variant("v1")["source"] == "code" + + +def test_import_variants_from_file(tmp_path): + db = VersionDatabase() + export_data = { + "variants": { + "v1": { + "variant_id": "v1", + "source": "code", + "test_results": {}, + "eval_score": 0.8, + "parent_ids": [], + "metadata": {}, + "experiment_id": None, + } + }, + "lineage": {}, + } + fpath = tmp_path / "import.json" + fpath.write_text(json.dumps(export_data)) + count = db.import_variants(str(fpath)) + assert count == 1 + + +def test_import_variants_skips_duplicates(): + db = VersionDatabase() + db.add_variant("v1", "original", {}, 0.5) + export_data = { + "variants": { + "v1": { + "variant_id": "v1", + "source": "imported", + "test_results": {}, + "eval_score": 0.9, + "parent_ids": [], + "metadata": {}, + "experiment_id": None, + } + }, + "lineage": {}, + } + count = db.import_variants(json.dumps(export_data)) + assert count == 0 + assert db.get_variant("v1")["source"] == "original" + + +def test_import_variants_with_experiment_tracking(): + db = VersionDatabase() + export_data = { + "variants": { + "v1": { + "variant_id": "v1", + "source": "code", + "test_results": {}, + "eval_score": 0.8, + "parent_ids": [], + "metadata": {}, + "experiment_id": "exp1", + } + }, + "lineage": {}, + } + db.import_variants(json.dumps(export_data)) + assert db.get_variant_experiment("v1") == "exp1" + assert "v1" in db.get_experiment_variants("exp1") + + +def test_import_variants_preserves_lineage(): + db = VersionDatabase() + export_data = { + "variants": { + "v1": {"variant_id": "v1", "source": "A", "test_results": {}, "eval_score": 0.5}, + "v2": {"variant_id": "v2", "source": "B", "test_results": {}, "eval_score": 0.8}, + }, + "lineage": {"v2": ["v1"]}, + } + db.import_variants(json.dumps(export_data)) + assert db.get_lineage("v2") == ["v1"] + + +# --- Edge cases --- + + +def test_query_variants_no_match(): + db = VersionDatabase() + db.add_variant("v1", "A", {}, 0.5) + assert db.query_variants({"source": "Z"}) == [] + + +def test_query_variants_empty_db(): + db = VersionDatabase() + assert db.query_variants({}) == [] + + +def test_get_lineage_nonexistent(): + db = VersionDatabase() + assert db.get_lineage("missing") == [] + + +def test_get_variant_nonexistent(): + db = VersionDatabase() + assert db.get_variant("missing") is None