Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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.
>
Expand Down
138 changes: 138 additions & 0 deletions tests/unit/evoseal/test_controller.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# --- 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
120 changes: 120 additions & 0 deletions tests/unit/evoseal/test_evaluator_extended.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading