bread: refactor calculator into bread
- Rewrites the logic to calculate the ingredient for a bread. - Adapt the app accordingly. - Improves the tests. Co-Authored-by: iGor milhit <igor@milhit.ch>main
parent
d171af8d0b
commit
cd99b31e0a
|
|
@ -0,0 +1,91 @@
|
|||
"""Bread ingredients model and calculation logic."""
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
|
||||
class SourdoughRatio(BaseModel):
|
||||
"""
|
||||
Represents the flour:water ratio in the sourdough starter.
|
||||
|
||||
Example:
|
||||
- Ratio(1, 1) = 50% hydration (equal parts)
|
||||
- Ratio(1, 0.8) = 44.4% hydration
|
||||
- Ratio(1, 1.2) = 54.5% hydration
|
||||
"""
|
||||
flour_parts: float = Field(gt=0, description="Flour parts in starter (typically 1)")
|
||||
water_parts: float = Field(gt=0, description="Water parts in starter")
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def hydration_percentage(self) -> float:
|
||||
"""Calculate hydration % of the starter."""
|
||||
return (self.water_parts / self.flour_parts) * 100
|
||||
|
||||
|
||||
class BreadRecipe(BaseModel):
|
||||
"""
|
||||
Input parameters for bread recipe calculation.
|
||||
|
||||
All percentages follow baker's percentage (relative to total flour weight).
|
||||
"""
|
||||
# What the user provides
|
||||
total_flour: float = Field(gt=0, description="Total flour weight in grams")
|
||||
hydration: float = Field(
|
||||
ge=50, le=100, description="Total hydration % (water/flour)"
|
||||
)
|
||||
sourdough_percentage: float = Field(
|
||||
ge=0,
|
||||
le=50,
|
||||
description="Sourdough as % of total flour weight"
|
||||
)
|
||||
sourdough_ratio: SourdoughRatio = Field(
|
||||
description="Flour:water ratio in starter"
|
||||
)
|
||||
salt: float = Field(ge=0, le=5, description="Salt as % of total flour")
|
||||
|
||||
# What Pydantic calculates automatically
|
||||
@computed_field
|
||||
@property
|
||||
def sourdough_weight(self) -> float:
|
||||
"""Calculate total sourdough weight in grams."""
|
||||
return round(self.total_flour * (self.sourdough_percentage / 100), 2)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def water_in_sourdough(self) -> float:
|
||||
"""Calculate water content in the sourdough."""
|
||||
ratio = self.sourdough_ratio
|
||||
total_parts = ratio.flour_parts + ratio.water_parts
|
||||
water_fraction = self.sourdough_ratio.water_parts / total_parts
|
||||
return round(self.sourdough_weight * water_fraction, 2)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def flour_in_sourdough(self) -> float:
|
||||
"""Calculate flour content in the sourdough."""
|
||||
return round(self.sourdough_weight - self.water_in_sourdough, 2)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def total_water(self) -> float:
|
||||
"""Calculate total water needed in the recipe."""
|
||||
return round(self.total_flour * (self.hydration / 100), 2)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def water_to_add(self) -> float:
|
||||
"""Calculate water to add (excluding water in sourdough)."""
|
||||
return round(self.total_water - self.water_in_sourdough, 2)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def flour_to_add(self) -> float:
|
||||
"""Calculate flour to add (excluding flour in sourdough)."""
|
||||
return round(self.total_flour - self.flour_in_sourdough, 2)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def salt_weight(self) -> float:
|
||||
"""Calculate salt weight in grams."""
|
||||
return round(self.total_flour * (self.salt / 100), 2)
|
||||
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Results:
|
||||
flour: float
|
||||
water: float
|
||||
sourdough: float
|
||||
salt: float
|
||||
|
||||
def calculator(
|
||||
base_flour: float = 500.0,
|
||||
hydration: float = 60.0,
|
||||
sourdough_hydration: float = 50.0,
|
||||
salt_percent: float = 1.6,
|
||||
sourdough_percent: float = 30
|
||||
) -> Results:
|
||||
"""Calculate the quantities for a bread dough based on desired hydration,
|
||||
sourdough hydration and the desired quantity of flour."""
|
||||
|
||||
sourdough = base_flour * sourdough_percent / 100
|
||||
|
||||
water_in_sourdough = sourdough * sourdough_hydration / 100
|
||||
flour_in_sourdough = sourdough - water_in_sourdough
|
||||
|
||||
total_water = base_flour * hydration / 100
|
||||
water = total_water - water_in_sourdough
|
||||
|
||||
flour = base_flour - flour_in_sourdough
|
||||
|
||||
salt = base_flour * salt_percent / 100
|
||||
|
||||
return Results(
|
||||
flour = flour,
|
||||
water = water,
|
||||
sourdough = sourdough,
|
||||
salt = salt
|
||||
)
|
||||
|
||||
76
main.py
76
main.py
|
|
@ -1,13 +1,19 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from calculator import Results, calculator
|
||||
from config import get_settings
|
||||
from bread import BreadRecipe
|
||||
from config import Settings
|
||||
|
||||
app = FastAPI()
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="Dough Calculator API",
|
||||
description="""Calculate bread ingredient proportions
|
||||
based on baker's percentages""",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
|
|
@ -17,25 +23,45 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Hello World!"}
|
||||
@app.post("/calculate", summary="Calculate bread ingredients")
|
||||
def calculate_bread_ingredients(recipe: BreadRecipe):
|
||||
"""
|
||||
Calculate the exact quantities of ingredients needed for bread.
|
||||
|
||||
**Inputs (what the user provides):**
|
||||
- total_flour: Total flour weight desired (grams)
|
||||
- hydration: Total hydration percentage
|
||||
- sourdough_percentage: Sourdough as % of total flour
|
||||
- sourdough_ratio: Flour:water ratio in the starter
|
||||
- salt: Salt as % of total flour
|
||||
|
||||
**Outputs (what to use):**
|
||||
- flour_to_add: Flour to add (excluding flour in sourdough)
|
||||
- water_to_add: Water to add (excluding water in sourdough)
|
||||
- sourdough_weight: Total sourdough needed
|
||||
- salt_weight: Salt needed
|
||||
"""
|
||||
return {
|
||||
"ingredients_to_add": {
|
||||
"flour": recipe.flour_to_add,
|
||||
"water": recipe.water_to_add,
|
||||
"sourdough": recipe.sourdough_weight,
|
||||
"salt": recipe.salt_weight
|
||||
},
|
||||
"details": {
|
||||
"total_flour": recipe.total_flour,
|
||||
"total_water": recipe.total_water,
|
||||
"flour_in_sourdough": recipe.flour_in_sourdough,
|
||||
"water_in_sourdough": recipe.water_in_sourdough
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BreadParams(BaseModel):
|
||||
flour: float = 500.0
|
||||
hydration: float = 60.0
|
||||
sourdough_hydration: float = 50.0
|
||||
sourdough_percent: float = 30.0
|
||||
salt_percent: float = 1.6
|
||||
|
||||
@app.post("/calculate", response_model=Results)
|
||||
def bread_proportions(params: BreadParams):
|
||||
return calculator(
|
||||
base_flour=params.flour,
|
||||
hydration=params.hydration,
|
||||
sourdough_hydration=params.sourdough_hydration,
|
||||
sourdough_percent=params.sourdough_percent,
|
||||
salt_percent=params.salt_percent
|
||||
)
|
||||
|
||||
@app.get("/", summary="API information")
|
||||
def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"message": "Dough Calculator API",
|
||||
"docs": "/docs",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
"""Unit tests for bread calculation logic."""
|
||||
|
||||
import pytest
|
||||
|
||||
from bread import BreadRecipe, SourdoughRatio
|
||||
|
||||
|
||||
class TestSourdoughRatio:
|
||||
"""Test the SourdoughRatio model."""
|
||||
|
||||
def test_equal_parts_hydration(self):
|
||||
"""Test 1:1 ratio gives 100% hydration."""
|
||||
ratio = SourdoughRatio(flour_parts=1, water_parts=1)
|
||||
assert ratio.hydration_percentage == 100.0
|
||||
|
||||
def test_dry_starter_hydration(self):
|
||||
"""Test 1:0.5 ratio gives 50% hydration."""
|
||||
ratio = SourdoughRatio(flour_parts=1, water_parts=0.5)
|
||||
assert ratio.hydration_percentage == 50.0
|
||||
|
||||
def test_wet_starter_hydration(self):
|
||||
"""Test 1:1.2 ratio gives 120% hydration."""
|
||||
ratio = SourdoughRatio(flour_parts=1, water_parts=1.2)
|
||||
assert ratio.hydration_percentage == 120.0
|
||||
|
||||
def test_invalid_zero_flour(self):
|
||||
"""Test that zero flour parts is rejected."""
|
||||
with pytest.raises(ValueError):
|
||||
SourdoughRatio(flour_parts=0, water_parts=1)
|
||||
|
||||
|
||||
class TestBreadRecipe:
|
||||
"""Test the BreadRecipe calculations."""
|
||||
|
||||
def test_sourdough_weight_calculation(self):
|
||||
"""Test sourdough weight is correctly calculated."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=1000,
|
||||
hydration=70,
|
||||
sourdough_percentage=20,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
|
||||
salt=2
|
||||
)
|
||||
assert recipe.sourdough_weight == 200.0
|
||||
|
||||
def test_water_in_sourdough_equal_ratio(self):
|
||||
"""Test water calculation with 1:1 ratio."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=1000,
|
||||
hydration=70,
|
||||
sourdough_percentage=20,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
|
||||
salt=2
|
||||
)
|
||||
assert recipe.water_in_sourdough == 100.0
|
||||
assert recipe.flour_in_sourdough == 100.0
|
||||
|
||||
def test_water_in_sourdough_unequal_ratio(self):
|
||||
"""Test water calculation with 1:0.8 ratio."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=1000,
|
||||
hydration=70,
|
||||
sourdough_percentage=20,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=0.8),
|
||||
salt=2
|
||||
)
|
||||
# 200g sourdough with 1:0.8 ratio
|
||||
# Total parts = 1.8
|
||||
# Water = 200 * (0.8/1.8) = 88.89g
|
||||
# Flour = 200 * (1/1.8) = 111.11g
|
||||
assert recipe.water_in_sourdough == 88.89
|
||||
assert recipe.flour_in_sourdough == 111.11
|
||||
|
||||
def test_flour_to_add(self):
|
||||
"""Test flour to add calculation."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=500,
|
||||
hydration=70,
|
||||
sourdough_percentage=20,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
|
||||
salt=2
|
||||
)
|
||||
# 500g total - 50g in sourdough = 450g to add
|
||||
assert recipe.flour_to_add == 450.0
|
||||
|
||||
def test_water_to_add(self):
|
||||
"""Test water to add calculation."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=500,
|
||||
hydration=70,
|
||||
sourdough_percentage=20,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
|
||||
salt=2
|
||||
)
|
||||
# Total water = 500 * 70% = 350g
|
||||
# Water in sourdough = 50g
|
||||
# Water to add = 350 - 50 = 300g
|
||||
assert recipe.water_to_add == 300.0
|
||||
|
||||
def test_salt_weight(self):
|
||||
"""Test salt calculation."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=500,
|
||||
hydration=70,
|
||||
sourdough_percentage=20,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
|
||||
salt=2
|
||||
)
|
||||
assert recipe.salt_weight == 10.0
|
||||
|
||||
def test_no_sourdough_recipe(self):
|
||||
"""Test recipe without sourdough (0%)."""
|
||||
recipe = BreadRecipe(
|
||||
total_flour=500,
|
||||
hydration=70,
|
||||
sourdough_percentage=0,
|
||||
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
|
||||
salt=2
|
||||
)
|
||||
assert recipe.sourdough_weight == 0.0
|
||||
assert recipe.flour_to_add == 500.0
|
||||
assert recipe.water_to_add == 350.0
|
||||
|
|
@ -5,6 +5,7 @@ from main import app
|
|||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
"""Tests that CORS is set."""
|
||||
response = client.options(
|
||||
|
|
@ -16,43 +17,109 @@ def test_cors_headers():
|
|||
)
|
||||
assert "access-control-allow-origin" in response.headers
|
||||
|
||||
|
||||
def test_settings_injection():
|
||||
"""Tests that the settings are available."""
|
||||
settings = get_settings()
|
||||
assert settings is not None
|
||||
assert hasattr(settings, "cors_origins")
|
||||
|
||||
|
||||
def test_bread_proportions():
|
||||
"""Tests the /calculate endpoint."""
|
||||
"""Tests the /calculate endpoint returns correct structure."""
|
||||
response = client.post(
|
||||
"/calculate",
|
||||
json = {
|
||||
"flour": 500,
|
||||
"hydration": 63,
|
||||
"sourdough_hydration": 100,
|
||||
"salt_percent": 1.6
|
||||
json={
|
||||
"total_flour": 500,
|
||||
"hydration": 70,
|
||||
"sourdough_percentage": 20,
|
||||
"sourdough_ratio": {
|
||||
"flour_parts": 1,
|
||||
"water_parts": 1
|
||||
},
|
||||
"salt": 2
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "flour" in data
|
||||
assert "water" in data
|
||||
assert "sourdough" in data
|
||||
assert "salt" in data
|
||||
|
||||
# Check main structure
|
||||
assert "ingredients_to_add" in data
|
||||
assert "details" in data
|
||||
|
||||
# Check ingredients_to_add fields
|
||||
ingredients = data["ingredients_to_add"]
|
||||
assert "flour" in ingredients
|
||||
assert "water" in ingredients
|
||||
assert "sourdough" in ingredients
|
||||
assert "salt" in ingredients
|
||||
|
||||
|
||||
def test_bread_proportions_values():
|
||||
"""Tests the calculated values."""
|
||||
"""Tests the calculated values are correct."""
|
||||
response = client.post(
|
||||
"/calculate",
|
||||
json={
|
||||
"flour": 1000,
|
||||
"hydration": 62,
|
||||
"sourdough_hydration": 50,
|
||||
"sourdough_percent": 20
|
||||
"total_flour": 1000,
|
||||
"hydration": 70,
|
||||
"sourdough_percentage": 20,
|
||||
"sourdough_ratio": {
|
||||
"flour_parts": 1,
|
||||
"water_parts": 1
|
||||
},
|
||||
"salt": 2
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["flour"] == 900.0
|
||||
assert data["water"] == 520.0
|
||||
assert data["sourdough"] == 200.0
|
||||
assert data["salt"] == 16.0
|
||||
|
||||
ingredients = data["ingredients_to_add"]
|
||||
details = data["details"]
|
||||
|
||||
# Test calculated values
|
||||
assert ingredients["flour"] == 900.0 # 1000 - 100 (flour in sourdough)
|
||||
assert ingredients["water"] == 600.0 # 700 - 100 (water in sourdough)
|
||||
assert ingredients["sourdough"] == 200.0 # 1000 * 20%
|
||||
assert ingredients["salt"] == 20.0 # 1000 * 2%
|
||||
|
||||
# Test details
|
||||
assert details["total_flour"] == 1000
|
||||
assert details["total_water"] == 700.0 # 1000 * 70%
|
||||
assert details["flour_in_sourdough"] == 100.0
|
||||
assert details["water_in_sourdough"] == 100.0
|
||||
|
||||
|
||||
def test_validation_hydration_too_high():
|
||||
"""Tests that validation rejects hydration > 100%."""
|
||||
response = client.post(
|
||||
"/calculate",
|
||||
json={
|
||||
"total_flour": 500,
|
||||
"hydration": 150, # ❌ Too high
|
||||
"sourdough_percentage": 20,
|
||||
"sourdough_ratio": {
|
||||
"flour_parts": 1,
|
||||
"water_parts": 1
|
||||
},
|
||||
"salt": 2
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
|
||||
def test_validation_negative_flour():
|
||||
"""Tests that validation rejects negative flour."""
|
||||
response = client.post(
|
||||
"/calculate",
|
||||
json={
|
||||
"total_flour": -500, # ❌ Negative
|
||||
"hydration": 70,
|
||||
"sourdough_percentage": 20,
|
||||
"sourdough_ratio": {
|
||||
"flour_parts": 1,
|
||||
"water_parts": 1
|
||||
},
|
||||
"salt": 2
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
|
|
|||
Loading…
Reference in New Issue