feat(nadia): Expose missing calculator operations as API endpoints

Outcome: done
Changed:
  src/typosaurus_sandbox/presentation/api/v1/calculator.py — added imports (average, median, variance, percentage, logging), ValuesRequest model, PercentageRequest model, and four new endpoint handlers with logging and ValueError → 422 conversion
  tests/test_api.py — added 26 test cases across four new test classes
Verified by: `make verify` — 93 tests, 0 failures, 0 new warnings
Findings:
  - `ValuesRequest` model accepts `values: list[float]` and serves /average, /median, /variance
  - `PercentageRequest` model accepts `value: float, total: float` for /percentage
  - All four endpoints log at DEBUG level on invocation
  - Empty list (average, median, variance) and zero total (percentage) produce HTTP 422 with descriptive detail
Open: none
Confidence: high — 26 new tests pass, zero regressions, pattern matches existing endpoint conventions

Typosaurus-Run: 34b5946ec981488091bee588eb919ff2
Typosaurus-Node: 644d226aefca431c81cf8be8a46d2070
Typosaurus-Agent: @nadia
Refs: #28
This commit is contained in:
typosaurus 2026-07-26 22:30:18 +00:00
parent ed445eb07f
commit 41bad19a76
2 changed files with 188 additions and 1 deletions

View File

@ -1,9 +1,13 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typosaurus_sandbox.domain.calculator import add, clamp, clamp_to_byte, subtract
from typosaurus_sandbox.domain.calculator import add, average, clamp, clamp_to_byte, median, percentage, subtract, variance
logger = logging.getLogger(__name__)
calculator_router = APIRouter(prefix="/api/v1/calculator")
@ -28,6 +32,15 @@ class ClampToByteRequest(BaseModel):
value: int = Field(ge=-2147483648, le=2147483647)
class ValuesRequest(BaseModel):
values: list[float]
class PercentageRequest(BaseModel):
value: float
total: float
class IntResult(BaseModel):
result: int
@ -38,11 +51,13 @@ class FloatResult(BaseModel):
@calculator_router.post("/add", response_model=IntResult)
def calculate_add(body: AddRequest) -> IntResult:
logger.debug("add %d + %d", body.left, body.right)
return IntResult(result=add(body.left, body.right))
@calculator_router.post("/subtract", response_model=IntResult)
def calculate_subtract(body: SubtractRequest) -> IntResult:
logger.debug("subtract %d - %d", body.left, body.right)
return IntResult(result=subtract(body.left, body.right))
@ -57,4 +72,46 @@ def calculate_clamp(body: ClampRequest) -> FloatResult:
@calculator_router.post("/clamp-to-byte", response_model=IntResult)
def calculate_clamp_to_byte(body: ClampToByteRequest) -> IntResult:
logger.debug("clamp-to-byte %d", body.value)
return IntResult(result=clamp_to_byte(body.value))
@calculator_router.post("/average", response_model=FloatResult)
def calculate_average(body: ValuesRequest) -> FloatResult:
logger.debug("average of %d values", len(body.values))
try:
result = average(body.values)
except ValueError:
raise HTTPException(status_code=422, detail="values list must not be empty")
return FloatResult(result=result)
@calculator_router.post("/median", response_model=FloatResult)
def calculate_median(body: ValuesRequest) -> FloatResult:
logger.debug("median of %d values", len(body.values))
try:
result = median(body.values)
except ValueError:
raise HTTPException(status_code=422, detail="values list must not be empty")
return FloatResult(result=result)
@calculator_router.post("/variance", response_model=FloatResult)
def calculate_variance(body: ValuesRequest) -> FloatResult:
logger.debug("variance of %d values", len(body.values))
try:
result = variance(body.values)
except ValueError:
raise HTTPException(status_code=422, detail="values list must not be empty")
return FloatResult(result=result)
@calculator_router.post("/percentage", response_model=FloatResult)
def calculate_percentage(body: PercentageRequest) -> FloatResult:
logger.debug("percentage %f of %f", body.value, body.total)
try:
result = percentage(body.value, body.total)
except ValueError:
raise HTTPException(status_code=422, detail="total must not be zero")
return FloatResult(result=result)

View File

@ -118,3 +118,133 @@ class TestCalculatorClampToByteEndpoint(unittest.TestCase):
response = client.post("/api/v1/calculator/clamp-to-byte", json={"value": "abc"})
self.assertEqual(response.status_code, 422)
class TestCalculatorAverageEndpoint(unittest.TestCase):
def test_average_positive_values(self) -> None:
response = client.post("/api/v1/calculator/average", json={"values": [1, 2, 3, 4, 5]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 3.0})
def test_average_single_value(self) -> None:
response = client.post("/api/v1/calculator/average", json={"values": [5]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 5.0})
def test_average_negative_values(self) -> None:
response = client.post("/api/v1/calculator/average", json={"values": [-10, -20, -30]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": -20.0})
def test_average_empty_returns_422(self) -> None:
response = client.post("/api/v1/calculator/average", json={"values": []})
self.assertEqual(response.status_code, 422)
def test_average_invalid_input_returns_422(self) -> None:
response = client.post("/api/v1/calculator/average", json={"values": ["a", "b"]})
self.assertEqual(response.status_code, 422)
def test_average_missing_field_returns_422(self) -> None:
response = client.post("/api/v1/calculator/average", json={})
self.assertEqual(response.status_code, 422)
class TestCalculatorMedianEndpoint(unittest.TestCase):
def test_median_odd_length(self) -> None:
response = client.post("/api/v1/calculator/median", json={"values": [1, 3, 5]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 3.0})
def test_median_even_length(self) -> None:
response = client.post("/api/v1/calculator/median", json={"values": [1, 2, 3, 4]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 2.5})
def test_median_single_element(self) -> None:
response = client.post("/api/v1/calculator/median", json={"values": [7]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 7.0})
def test_median_unsorted_input(self) -> None:
response = client.post("/api/v1/calculator/median", json={"values": [3, 1, 2]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 2.0})
def test_median_empty_returns_422(self) -> None:
response = client.post("/api/v1/calculator/median", json={"values": []})
self.assertEqual(response.status_code, 422)
def test_median_invalid_input_returns_422(self) -> None:
response = client.post("/api/v1/calculator/median", json={"values": ["a"]})
self.assertEqual(response.status_code, 422)
def test_median_missing_field_returns_422(self) -> None:
response = client.post("/api/v1/calculator/median", json={})
self.assertEqual(response.status_code, 422)
class TestCalculatorVarianceEndpoint(unittest.TestCase):
def test_variance_known_set(self) -> None:
response = client.post("/api/v1/calculator/variance", json={"values": [1, 2, 3, 4, 5]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 2.0})
def test_variance_constant_values(self) -> None:
response = client.post("/api/v1/calculator/variance", json={"values": [1.0, 1.0, 1.0]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 0.0})
def test_variance_single_element(self) -> None:
response = client.post("/api/v1/calculator/variance", json={"values": [42.0]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 0.0})
def test_variance_empty_returns_422(self) -> None:
response = client.post("/api/v1/calculator/variance", json={"values": []})
self.assertEqual(response.status_code, 422)
def test_variance_invalid_input_returns_422(self) -> None:
response = client.post("/api/v1/calculator/variance", json={"values": ["a", "b", "c"]})
self.assertEqual(response.status_code, 422)
def test_variance_missing_field_returns_422(self) -> None:
response = client.post("/api/v1/calculator/variance", json={})
self.assertEqual(response.status_code, 422)
class TestCalculatorPercentageEndpoint(unittest.TestCase):
def test_percentage_half(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"value": 50, "total": 100})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 50.0})
def test_percentage_quarter(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"value": 25, "total": 100})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 25.0})
def test_percentage_zero_value(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"value": 0, "total": 100})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 0.0})
def test_percentage_total_zero_returns_422(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"value": 50, "total": 0})
self.assertEqual(response.status_code, 422)
def test_percentage_invalid_input_returns_422(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"value": "abc", "total": 100})
self.assertEqual(response.status_code, 422)
def test_percentage_missing_value_returns_422(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"total": 100})
self.assertEqual(response.status_code, 422)
def test_percentage_missing_total_returns_422(self) -> None:
response = client.post("/api/v1/calculator/percentage", json={"value": 50})
self.assertEqual(response.status_code, 422)