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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from calculator import Results, calculator
|
from bread import BreadRecipe
|
||||||
from config import get_settings
|
from config import Settings
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI(
|
||||||
settings = get_settings()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|
@ -17,25 +23,45 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.post("/calculate", summary="Calculate bread ingredients")
|
||||||
async def root():
|
def calculate_bread_ingredients(recipe: BreadRecipe):
|
||||||
return {"message": "Hello World!"}
|
"""
|
||||||
|
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):
|
@app.get("/", summary="API information")
|
||||||
flour: float = 500.0
|
def root():
|
||||||
hydration: float = 60.0
|
"""Root endpoint with API information."""
|
||||||
sourdough_hydration: float = 50.0
|
return {
|
||||||
sourdough_percent: float = 30.0
|
"message": "Dough Calculator API",
|
||||||
salt_percent: float = 1.6
|
"docs": "/docs",
|
||||||
|
"version": "1.0.0"
|
||||||
@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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_cors_headers():
|
def test_cors_headers():
|
||||||
"""Tests that CORS is set."""
|
"""Tests that CORS is set."""
|
||||||
response = client.options(
|
response = client.options(
|
||||||
|
|
@ -16,43 +17,109 @@ def test_cors_headers():
|
||||||
)
|
)
|
||||||
assert "access-control-allow-origin" in response.headers
|
assert "access-control-allow-origin" in response.headers
|
||||||
|
|
||||||
|
|
||||||
def test_settings_injection():
|
def test_settings_injection():
|
||||||
"""Tests that the settings are available."""
|
"""Tests that the settings are available."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings is not None
|
assert settings is not None
|
||||||
assert hasattr(settings, "cors_origins")
|
assert hasattr(settings, "cors_origins")
|
||||||
|
|
||||||
|
|
||||||
def test_bread_proportions():
|
def test_bread_proportions():
|
||||||
"""Tests the /calculate endpoint."""
|
"""Tests the /calculate endpoint returns correct structure."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/calculate",
|
"/calculate",
|
||||||
json = {
|
json={
|
||||||
"flour": 500,
|
"total_flour": 500,
|
||||||
"hydration": 63,
|
"hydration": 70,
|
||||||
"sourdough_hydration": 100,
|
"sourdough_percentage": 20,
|
||||||
"salt_percent": 1.6
|
"sourdough_ratio": {
|
||||||
|
"flour_parts": 1,
|
||||||
|
"water_parts": 1
|
||||||
|
},
|
||||||
|
"salt": 2
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "flour" in data
|
|
||||||
assert "water" in data
|
# Check main structure
|
||||||
assert "sourdough" in data
|
assert "ingredients_to_add" in data
|
||||||
assert "salt" 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():
|
def test_bread_proportions_values():
|
||||||
"""Tests the calculated values."""
|
"""Tests the calculated values are correct."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/calculate",
|
"/calculate",
|
||||||
json={
|
json={
|
||||||
"flour": 1000,
|
"total_flour": 1000,
|
||||||
"hydration": 62,
|
"hydration": 70,
|
||||||
"sourdough_hydration": 50,
|
"sourdough_percentage": 20,
|
||||||
"sourdough_percent": 20
|
"sourdough_ratio": {
|
||||||
|
"flour_parts": 1,
|
||||||
|
"water_parts": 1
|
||||||
|
},
|
||||||
|
"salt": 2
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["flour"] == 900.0
|
|
||||||
assert data["water"] == 520.0
|
ingredients = data["ingredients_to_add"]
|
||||||
assert data["sourdough"] == 200.0
|
details = data["details"]
|
||||||
assert data["salt"] == 16.0
|
|
||||||
|
# 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