diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48730d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run unit tests + run: pytest + + - name: Compile check all Python files + run: python -m py_compile $(git ls-files '*.py') diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..1da1dcf --- /dev/null +++ b/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_critique.py b/tests/test_critique.py new file mode 100644 index 0000000..a7e71be --- /dev/null +++ b/tests/test_critique.py @@ -0,0 +1,37 @@ +import pytest +from pydantic import ValidationError + +from shared.critique import Critique + + +def test_valid_critique_round_trips_fields() -> None: + critique = Critique( + score=85, + issues=["missing docstring"], + verdict="pass", + reasoning="Clean and correct, one minor style nit.", + ) + assert critique.score == 85 + assert critique.issues == ["missing docstring"] + assert critique.verdict == "pass" + + +def test_issues_defaults_to_empty_list() -> None: + critique = Critique(score=100, verdict="pass", reasoning="No issues found.") + assert critique.issues == [] + + +@pytest.mark.parametrize("score", [-1, 101]) +def test_score_out_of_range_rejected(score: int) -> None: + with pytest.raises(ValidationError): + Critique(score=score, verdict="pass", reasoning="x") + + +def test_invalid_verdict_rejected() -> None: + with pytest.raises(ValidationError): + Critique(score=50, verdict="maybe", reasoning="x") + + +def test_missing_required_field_rejected() -> None: + with pytest.raises(ValidationError): + Critique(score=50, verdict="pass") diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py new file mode 100644 index 0000000..7b0ad6b --- /dev/null +++ b/tests/test_llm_client.py @@ -0,0 +1,73 @@ +from shared.llm_client import TokenBudget, Usage, extract_code_block + + +class TestUsage: + def test_total_tokens_sums_input_and_output(self) -> None: + usage = Usage(input_tokens=100, output_tokens=50, model="claude-sonnet-5") + assert usage.total_tokens == 150 + + def test_cost_usd_known_model(self) -> None: + # claude-sonnet-5: $2.00/1M in, $10.00/1M out + usage = Usage(input_tokens=1_000_000, output_tokens=1_000_000, model="claude-sonnet-5") + assert usage.cost_usd == 12.00 + + def test_cost_usd_unknown_model_is_zero(self) -> None: + usage = Usage(input_tokens=1_000_000, output_tokens=1_000_000, model="not-a-real-model") + assert usage.cost_usd == 0.0 + + +class TestTokenBudget: + def test_record_accumulates_spend_and_log(self) -> None: + budget = TokenBudget(max_total_tokens=1000) + budget.record(Usage(100, 50, "claude-sonnet-5")) + budget.record(Usage(200, 100, "claude-sonnet-5")) + + assert budget.spent_tokens == 450 + assert len(budget.usage_log) == 2 + + def test_exceeded_false_under_cap(self) -> None: + budget = TokenBudget(max_total_tokens=1000) + budget.record(Usage(100, 50, "claude-sonnet-5")) + assert budget.exceeded() is False + + def test_exceeded_true_at_or_over_cap(self) -> None: + budget = TokenBudget(max_total_tokens=100) + budget.record(Usage(80, 20, "claude-sonnet-5")) + assert budget.exceeded() is True + + def test_remaining_floors_at_zero(self) -> None: + budget = TokenBudget(max_total_tokens=100) + budget.record(Usage(80, 40, "claude-sonnet-5")) + assert budget.remaining() == 0 + + def test_total_cost_usd_sums_logged_usage(self) -> None: + budget = TokenBudget(max_total_tokens=10_000_000) + budget.record(Usage(1_000_000, 0, "claude-sonnet-5")) # $2.00 + budget.record(Usage(0, 1_000_000, "claude-haiku-4-5")) # $5.00 + assert budget.total_cost_usd == 7.00 + + +class TestExtractCodeBlock: + def test_fenced_block_with_language_tag(self) -> None: + text = "```python\ndef foo():\n pass\n```" + assert extract_code_block(text) == "def foo():\n pass" + + def test_fenced_block_without_language_tag(self) -> None: + text = "```\nhello\n```" + assert extract_code_block(text) == "hello" + + def test_fenced_block_surrounded_by_prose(self) -> None: + text = "Here's the code:\n```python\nx = 1\n```\nDone." + assert extract_code_block(text) == "x = 1" + + def test_falls_back_to_raw_text_when_unfenced(self) -> None: + text = " just some text, no fence " + assert extract_code_block(text) == "just some text, no fence" + + def test_returns_only_first_block_when_multiple(self) -> None: + text = "```\nfirst\n```\nsome text\n```\nsecond\n```" + assert extract_code_block(text) == "first" + + def test_empty_fenced_block(self) -> None: + text = "```\n\n```" + assert extract_code_block(text) == ""