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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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')
4 changes: 4 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
Empty file added tests/__init__.py
Empty file.
37 changes: 37 additions & 0 deletions tests/test_critique.py
Original file line number Diff line number Diff line change
@@ -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")
73 changes: 73 additions & 0 deletions tests/test_llm_client.py
Original file line number Diff line number Diff line change
@@ -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) == ""
Loading