From 022d36041cc67be408bd0c0411abd91792c7c8e6 Mon Sep 17 00:00:00 2001 From: Mike Shoup Date: Fri, 5 Jul 2019 09:28:38 -0600 Subject: [PATCH] Display recipe specs (#6) * Add Jinja filters for specifications * Include tests for Jinja filters * Javascript to handle form changes. * Add footer for displaying specs on recipe forms * Don't build docker on pull requests --- .drone.yml | 3 + src/humulus/app.py | 4 + src/humulus/filters.py | 118 +++++++++++++ src/humulus/static/recipes.js | 185 +++++++++++++++++++-- src/humulus/static/style.css | 4 + src/humulus/templates/_base.html | 1 + src/humulus/templates/recipes/_macros.html | 53 ++++-- src/humulus/templates/recipes/create.html | 12 +- src/humulus/templates/recipes/info.html | 29 +++- src/humulus/templates/recipes/update.html | 12 +- tests/conftest.py | 121 ++++++++++++++ tests/test_filters.py | 73 ++++++++ 12 files changed, 586 insertions(+), 29 deletions(-) create mode 100644 src/humulus/filters.py create mode 100644 tests/test_filters.py diff --git a/.drone.yml b/.drone.yml index a36e5d7..9193253 100644 --- a/.drone.yml +++ b/.drone.yml @@ -46,6 +46,9 @@ steps: when: branch: - master + event: + exclude: + - pull_request - name: docker-release image: plugins/docker diff --git a/src/humulus/app.py b/src/humulus/app.py index 171b6ba..5bf78c5 100644 --- a/src/humulus/app.py +++ b/src/humulus/app.py @@ -46,4 +46,8 @@ def create_app(test_config=None): from . import auth app.register_blueprint(auth.bp) + # Register custom filters + from . import filters + filters.create_filters(app) + return app diff --git a/src/humulus/filters.py b/src/humulus/filters.py new file mode 100644 index 0000000..cf178d4 --- /dev/null +++ b/src/humulus/filters.py @@ -0,0 +1,118 @@ +"""This module contains filters used in rendering of Jinja templates.""" + +# Copyright 2019 Mike Shoup +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + + +def recipe_og(recipe): + """Returns a recipe's Original Gravity""" + if 'fermentables' not in recipe: + return '0.000' + points = 0 + grain_points = 0 + # Loop through fermentables, adding up points + for fermentable in recipe['fermentables']: + if fermentable['type'] == 'Grain': + grain_points += ( + float(fermentable['amount']) * float(fermentable['ppg']) + ) + else: + points += ( + float(fermentable['amount']) * float(fermentable['ppg']) + ) + points += grain_points * float(recipe['efficiency']) / 100 + return '{:.3f}'.format( + round(1 + points / (1000 * float(recipe['volume'])), 3) + ) + + +def recipe_fg(recipe): + """Returns a recipe's final gravity""" + if 'yeast' not in recipe or 'fermentables' not in recipe: + return '0.000' + og = float(recipe_og(recipe)) + og_delta = 0.0 + # Adjust original gravity by removing nonfermentables (i.e., Lactose) + for fermentable in recipe['fermentables']: + if fermentable['type'] == 'Non-fermentable': + og_delta += ( + float(fermentable['amount']) * float(fermentable['ppg']) / + (1000 * float(recipe['volume'])) + ) + attenuation = ( + ( + float(recipe['yeast']['low_attenuation']) + + float(recipe['yeast']['high_attenuation']) + ) / 200 + ) + return '{:.3f}'.format( + round(1 + (og - 1 - og_delta)*(1 - attenuation) + og_delta, 3) + ) + + +def recipe_ibu(recipe): + """Return a recipe's IBU""" + if 'hops' not in recipe: + return '0' + bigness = 1.65 * 0.000125**(float(recipe_og(recipe)) - 1) + ibu = 0.0 + for h in recipe['hops']: + mgl = ( + float(h['alpha']) * float(h['amount']) * 7490.0 / + (float(recipe['volume']) * 100.0) + ) + btf = (1 - math.exp(-0.04 * float(h['duration']))) / 4.15 + ibu += bigness * btf * mgl + return '{:.0f}'.format(ibu) + + +def recipe_ibu_ratio(recipe): + """Return a recipe's IBU ratio""" + if 'fermentables' not in recipe or 'hops' not in recipe: + return '0' + if len(recipe['fermentables']) == 0: + return '0' # Otherwise a divide by zero error will occur + og = float(recipe_og(recipe)) + ibu = float(recipe_ibu(recipe)) + return '{:.2f}'.format(round(0.001 * ibu / (og - 1), 2)) + + +def recipe_abv(recipe): + """Return a recipe's finished ABV""" + if 'fermentables' not in recipe or 'yeast' not in recipe: + return '0' + og = float(recipe_og(recipe)) + fg = float(recipe_fg(recipe)) + return '{:.1f}'.format(round((og - fg) * 131.25, 1)) + + +def recipe_srm(recipe): + """Return a recipe's SRM""" + if 'fermentables' not in recipe: + return '0' + mcu = 0 + for f in recipe['fermentables']: + mcu += float(f['amount']) * float(f['color']) / float(recipe['volume']) + return '{:.0f}'.format(1.4922 * (mcu**0.6859)) + + +def create_filters(app): + app.add_template_filter(recipe_og) + app.add_template_filter(recipe_fg) + app.add_template_filter(recipe_ibu) + app.add_template_filter(recipe_ibu_ratio) + app.add_template_filter(recipe_abv) + app.add_template_filter(recipe_srm) diff --git a/src/humulus/static/recipes.js b/src/humulus/static/recipes.js index 2e057b2..c6daad4 100644 --- a/src/humulus/static/recipes.js +++ b/src/humulus/static/recipes.js @@ -13,6 +13,13 @@ See the License for the specific language governing permissions and limitations under the License. */ + +// unbinds and re-binds the change event +function rebindChangeEvents() { + $('.ingredient-field').unbind('change'); + $('.ingredient-field').change(displayAll); +} + // Correct all the indices for forms matching item. function adjustIndices(removedIndex, item) { var $forms = $(item); @@ -51,6 +58,7 @@ function removeForm($remButton, formClass, formsId) { var $fermsDiv = $(formsId); $fermsDiv.data('length', $fermsDiv.data('length') - 1); adjustIndices(removedIndex, formClass); + displayAll(); } // Remove a fermentable @@ -75,14 +83,14 @@ function addFerm() { // Name field '
' + `` + - `` + '
' + // End name field '
' + // Type field '
' + `` + - `` + '
' + // End amount field // PPG field '
' + `` + - `` + '
' + // End PPG field // Color field '
' + `` + - `` + '
' + // End PPG field '
' + @@ -118,10 +126,13 @@ function addFerm() { ''; $fermsDiv.append(newFerm); $fermsDiv.data('length', fermsLength + 1); + // Unbind click events and re-bind them. This is needed to prevent multiple click events $('.rem-ferm').unbind('click'); $('.rem-ferm').click(removeFerm); + rebindChangeEvents(); } +// Add a hop function addHop() { var $hopsDiv = $('#hops'); var hopsLength = $hopsDiv.data('length'); @@ -134,14 +145,14 @@ function addHop() { // Name field '
' + `` + - `` + '
' + // End name field '
' + // Usage field '
' + `` + - `` + '
' + // End alpha acid % field // Duration field '
' + `` + - `` + '
' + // End duration field // Amount field '
' + `` + - `` + '
' + // End amount field '
' + @@ -176,13 +187,167 @@ function addHop() { $hopsDiv.append(newHop); $hopsDiv.data('length', hopsLength + 1); + // Unbind click events and re-bind them. This is needed to prevent multiple click events $('.rem-hop').unbind('click'); $('.rem-hop').click(removeHop); + rebindChangeEvents(); +} + +// Calculate recipe's original gravity +function calculateOG() { + var fermsLength = $('#ferms').data('length'); + if (fermsLength == 0) { + return 0; + } + var points = 0; + var grain_points = 0; + for (var i = 0; i < fermsLength; i++) { + var ppg = parseFloat($(`#fermentables-${i}-ppg`).val()) || 0.0; + var amt = parseFloat($(`#fermentables-${i}-amount`).val()) || 0.0; + + // Check if type is grain + if ($(`#fermentables-${i}-type`).val() == 'Grain') { + grain_points += ppg * amt; + } else { + points += ppg * amt; + } + } + // Add grain_points to points, adjusting for efficiency + var efficiency = parseFloat($(`#efficiency`).val()) || 0; + var volume = parseFloat($(`#volume`).val()) || 0; + points += grain_points * efficiency / 100; + return 1 + points / (1000 * volume); +} + +// display OG +function displayOG() { + $('#estimated-og').text(calculateOG().toFixed(3)); +} + +// Calculate final gravity +function calculateFG() { + var fermsLength = $('#ferms').data('length'); + if (fermsLength == 0) { + return 0; + } + + var og = parseFloat(calculateOG()) || 0.0; + var og_delta = 0.0; + var volume = parseFloat($(`#volume`).val()) || 0; + for (var i = 0; i < fermsLength; i++) { + if ($(`#fermentables-${i}-type`).val() == 'Non-fermentable') { + var ppg = parseFloat($(`#fermentables-${i}-ppg`).val()) || 0.0; + var amt = parseFloat($(`#fermentables-${i}-amount`).val()) || 0.0; + og_delta += amt * ppg / (volume * 1000) + } + } + + var low_attenuation = parseFloat($('#yeast-low_attenuation').val()) || 0.0; + var high_attenuation = parseFloat($('#yeast-high_attenuation').val()) || 0.0; + var attenuation = (low_attenuation + high_attenuation) / 200; + return 1 + (og - 1 - og_delta)*(1 - attenuation) + og_delta; +} + +// Display FG +function displayFG() { + $('#estimated-fg').text(calculateFG().toFixed(3)); +} + +// Calculate ABV +function calculate_abv() { + return (calculateOG() - calculateFG()) * 131.25; +} + +// Display ABV +function displayABV() { + var abv = calculate_abv().toFixed(1); + $('#estimated-abv').text(`${abv} %/vol.`); +} + +// Calculate IBU +function calculateIBU() { + var hopsLength = $('#hops').data('length'); + if (hopsLength == 0) { + return 0; + } + + var bigness = 1.65 * Math.pow(0.000125, calculateOG() - 1); + var ibu = 0; + var volume = parseFloat($(`#volume`).val()) || 0; + for (var i = 0; i < hopsLength; i++) { + var use = $(`#hops-${i}-use`).val(); + if (use != 'Boil' && use != 'FWH') { + continue; + } + var alpha = parseFloat($(`#hops-${i}-alpha`).val()) || 0.0; + var amt = parseFloat($(`#hops-${i}-amount`).val()) || 0.0; + var duration = parseFloat($(`#hops-${i}-duration`).val()) || 0.0; + var mgl = alpha * amt * 7490 / (volume * 100) + var btf = (1 - Math.pow(Math.E, -0.04 * duration)) / 4.15 + ibu += bigness * btf * mgl; + } + return ibu; +} + +// Display IBU +function displayIBU() { + var ibu = calculateIBU().toFixed(); + $('#estimated-ibu').text(`${ibu} IBU`); +} + +// Calculate IBU Ratio +function calculateIBURatio() { + return 0.001 * calculateIBU() / (calculateOG() - 1) +} + +// Display IBU Ratio +function displayIBURatio() { + var iburatio = calculateIBURatio().toFixed(3); + $('#estimated-iburatio').text(`${iburatio} IBU/OG`); +} + +// Calculate SRM +function calculateSRM() { + var fermsLength = $('#ferms').data('length'); + if (fermsLength == 0) { + return 0; + } + var mcu = 0; + var volume = parseFloat($(`#volume`).val()) || 0; + for (var i = 0; i < fermsLength; i++) { + var color = parseFloat($(`#fermentables-${i}-color`).val()) || 0.0; + var amt = parseFloat($(`#fermentables-${i}-amount`).val()) || 0.0; + mcu += amt * color / volume; + } + return 1.4922 * Math.pow(mcu, 0.6859) +} + +// Display SRM +function displaySRM() { + var srm = calculateSRM().toFixed(); + $('#estimated-srm').text(`${srm} SRM`); +} + +// Display all specifications +function displayAll() { + displayOG(); + displayFG(); + displayABV(); + displayIBU(); + displayIBURatio(); + displaySRM(); } $(document).ready(function() { + // Register clicks $('#add-ferm').click(addFerm); $('.rem-ferm').click(removeFerm); $('#add-hop').click(addHop); $('.rem-hop').click(removeHop); + + // Register change events + rebindChangeEvents(); + + // Update specifications + displayAll(); }); diff --git a/src/humulus/static/style.css b/src/humulus/static/style.css index e874c3a..67646b2 100644 --- a/src/humulus/static/style.css +++ b/src/humulus/static/style.css @@ -31,3 +31,7 @@ body { padding-top: 5rem; } + +.above-footer { + padding-bottom: 12rem; +} diff --git a/src/humulus/templates/_base.html b/src/humulus/templates/_base.html index 3421471..4b0f264 100644 --- a/src/humulus/templates/_base.html +++ b/src/humulus/templates/_base.html @@ -65,6 +65,7 @@ {% block body %}{% endblock %} + diff --git a/src/humulus/templates/recipes/_macros.html b/src/humulus/templates/recipes/_macros.html index ec2a358..ae48f16 100644 --- a/src/humulus/templates/recipes/_macros.html +++ b/src/humulus/templates/recipes/_macros.html @@ -26,8 +26,8 @@ -#}
{{ render_field_with_errors(form.name) }}
-
{{ render_field_with_errors(form.efficiency) }}
-
{{ render_field_with_errors(form.volume) }}
+
{{ render_field_with_errors(form.efficiency, 'ingredient-field') }}
+
{{ render_field_with_errors(form.volume, 'ingredient-field') }}
{#- Fermentable Ingredients @@ -42,10 +42,10 @@
-
{{ render_field_with_errors(fermentable.form.type, 'form-control-sm') }}
-
{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm') }}
-
{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm') }}
-
{{ render_field_with_errors(fermentable.form.color, 'form-control-sm') }}
+
{{ render_field_with_errors(fermentable.form.type, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(fermentable.form.color, 'form-control-sm ingredient-field') }}
@@ -73,10 +73,10 @@
-
{{ render_field_with_errors(hop.form.use, 'form-control-sm') }}
-
{{ render_field_with_errors(hop.form.alpha, 'form-control-sm') }}
-
{{ render_field_with_errors(hop.form.duration, 'form-control-sm') }}
-
{{ render_field_with_errors(hop.form.amount, 'form-control-sm') }}
+
{{ render_field_with_errors(hop.form.use, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(hop.form.alpha, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(hop.form.duration, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(hop.form.amount, 'form-control-sm ingredient-field') }}
@@ -104,8 +104,8 @@
{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm ingredient-field') }}
+
{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm ingredient-field') }}
{{ render_field_with_errors(form.yeast.form.flocculation, 'form-control-sm') }}
{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}
{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}
@@ -128,3 +128,32 @@ {% endmacro %} + +{% macro render_footer() %} +
+
+
+
+
+
Original Gravity
+
+
Final Gravity
+
+
Alcohol
+
+
+
+
+
+
Bitterness
+
+
Bitterness Ratio
+
+
Color
+
+
+
+
+
+
+{% endmacro %} diff --git a/src/humulus/templates/recipes/create.html b/src/humulus/templates/recipes/create.html index 691c43f..0d074dd 100644 --- a/src/humulus/templates/recipes/create.html +++ b/src/humulus/templates/recipes/create.html @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. -#} -{% from "recipes/_macros.html" import render_recipe_form %} +{% from "recipes/_macros.html" import render_recipe_form, render_footer %} {% extends '_base.html' %} {% block title %}Create Recipe{% endblock %} @@ -21,5 +21,13 @@ {% block body %}

Create a new recipe

{{ render_recipe_form(form, url_for('recipes.create'), 'Create recipe') }} -Back to recipe list + +{% endblock %} + +{% block footer %} +{{ render_footer() }} {% endblock %} diff --git a/src/humulus/templates/recipes/info.html b/src/humulus/templates/recipes/info.html index 08b70b2..28df021 100644 --- a/src/humulus/templates/recipes/info.html +++ b/src/humulus/templates/recipes/info.html @@ -47,11 +47,34 @@
+

Estimated Specifications

+
+
+
+
Original Gravity
+
{{ recipe|recipe_og }}
+
Final Gravity
+
{{ recipe|recipe_fg }}
+
Alcohol
+
{{ recipe|recipe_abv }} %/vol.
+
+
+
+
+
Bitterness
+
{{ recipe|recipe_ibu }} IBU
+
Bitterness Ratio
+
{{ recipe|recipe_ibu_ratio }} IBU/OG
+
Color
+
{{ recipe|recipe_srm }} SRM
+
+
+
{#- Fermentables -#}

Fermentables

-
+
@@ -75,7 +98,7 @@ Hops -#}

Hops

-
+
@@ -102,7 +125,7 @@ -#} {% if 'yeast' in recipe %}

Yeast

-
+
diff --git a/src/humulus/templates/recipes/update.html b/src/humulus/templates/recipes/update.html index aea95bc..3995ea8 100644 --- a/src/humulus/templates/recipes/update.html +++ b/src/humulus/templates/recipes/update.html @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. -#} -{% from "recipes/_macros.html" import render_recipe_form %} +{% from "recipes/_macros.html" import render_recipe_form, render_footer %} {% extends '_base.html' %} {% block title %}Update | {{ form.name.data }}{% endblock %} @@ -21,5 +21,13 @@ {% block body %}

{{ form.name.data }}

{{ render_recipe_form(form, url_for('recipes.update', id=id, rev=rev), 'Update recipe') }} -Cancel + +{% endblock %} + +{% block footer %} +{{ render_footer() }} {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index a53c14c..917f4fd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -152,3 +152,124 @@ class AuthActions(object): @pytest.fixture def auth(client): return AuthActions(client) + + +@pytest.fixture +def sample_recipes(): + """These sample recipes are useful for testing filters.""" + return { + 'lager': { + 'efficiency': '72', + 'fermentables': [ + { + 'amount': '9.5', + 'color': '1.80', + 'name': 'Pale Malt, 2-row (Rahr) (US)', + 'ppg': '37.00', + 'type': 'Grain' + }, + { + 'amount': '1', + 'color': '0', + 'name': 'Corn Sugar (Dextrose)', + 'ppg': '46.00', + 'type': 'Sugar' + } + ], + 'hops': [ + { + 'alpha': '7.0', + 'amount': '1', + 'duration': '60', + 'name': 'Cluster (US)', + 'use': 'Boil' + }, + { + 'alpha': '2.8', + 'amount': '1', + 'duration': '10.00', + 'name': 'Saaz (CZ)', + 'use': 'Boil' + }, + { + 'alpha': '2.8', + 'amount': '1.0', + 'duration': '5', + 'name': 'Saaz (CZ)', + 'use': 'Dry-Hop' + } + ], + 'name': 'Lager', + 'notes': 'Test simple dry-hopped lager w/ sugar', + 'volume': '5.50', + 'yeast': { + 'abv_tolerance': '15.00', + 'code': 'WLP940', + 'flocculation': 'Medium', + 'high_attenuation': '78.00', + 'lab': 'White Labs', + 'low_attenuation': '70.00', + 'max_temperature': '55.00', + 'min_temperature': '50.00', + 'name': 'Mexican Lager', + 'type': 'Liquid' + } + }, + 'sweetstout': { + 'efficiency': '72', + 'fermentables': [ + { + 'amount': '2.75', + 'color': '3', + 'name': 'Pale Malt, 2-row (UK)', + 'ppg': '36.00', + 'type': 'Grain' + }, + { + 'amount': '0.25', + 'color': '450', + 'name': 'Chocolate Malt (UK)', + 'ppg': '34.00', + 'type': 'Grain' + }, + { + 'amount': '0.5', + 'color': '0', + 'name': 'Lactose', + 'ppg': '35.00', + 'type': 'Non-fermentable' + } + ], + 'hops': [ + { + 'alpha': '5.0', + 'amount': '0.5', + 'duration': '60', + 'name': 'East Kent Goldings (UK)', + 'use': 'Boil' + }, + { + 'alpha': '5.0', + 'amount': '0.5', + 'duration': '30', + 'name': 'East Kent Goldings (UK)', + 'use': 'Boil' + } + ], + 'name': 'Sweet Stout', + 'notes': 'Test stout w/ Lactose', + 'volume': '2.5', + 'yeast': { + 'abv_tolerance': '12.00', + 'code': '', + 'flocculation': 'High', + 'high_attenuation': '77.00', + 'lab': 'Danstar', + 'low_attenuation': '73.00', + 'max_temperature': '70.00', + 'min_temperature': '57.00', + 'name': 'Nottingham', + 'type': 'Dry' + } + } + } diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..14ecda5 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,73 @@ +# Copyright 2019 Mike Shoup +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from humulus.filters import * + + +def test_recipe_og(sample_recipes): + assert recipe_og(sample_recipes['lager']) == '1.054' + assert recipe_og(sample_recipes['sweetstout']) == '1.038' + # Remove fermentables, verify 0 is returned + sample_recipes['lager'].pop('fermentables') + assert recipe_og(sample_recipes['lager']) == '0.000' + + +def test_recipe_fg(sample_recipes): + assert recipe_fg(sample_recipes['lager']) == '1.014' + assert recipe_fg(sample_recipes['sweetstout']) == '1.015' + # Remove fermentables, verify 0 is returned + sample_recipes['lager'].pop('fermentables') + assert recipe_fg(sample_recipes['lager']) == '0.000' + # Remove yeast, verify 0 is returned + sample_recipes['sweetstout'].pop('yeast') + assert recipe_fg(sample_recipes['sweetstout']) == '0.000' + + +def test_recipe_ibu(sample_recipes): + assert recipe_ibu(sample_recipes['lager']) == '26' + assert recipe_ibu(sample_recipes['sweetstout']) == '34' + # Remove hops, verify 0 is returned + sample_recipes['lager'].pop('hops') + assert recipe_ibu(sample_recipes['lager']) == '0' + + +def test_recipe_ibu_ratio(sample_recipes): + assert recipe_ibu_ratio(sample_recipes['lager']) == '0.48' + assert recipe_ibu_ratio(sample_recipes['sweetstout']) == '0.89' + # Remove fermentables, verify 0 is returned + sample_recipes['lager'].pop('fermentables') + assert recipe_ibu_ratio(sample_recipes['lager']) == '0' + # Remove hops, verify 0 is returned + sample_recipes['sweetstout'].pop('hops') + assert recipe_ibu_ratio(sample_recipes['sweetstout']) == '0' + + +def test_recipe_abv(sample_recipes): + assert recipe_abv(sample_recipes['lager']) == '5.3' + assert recipe_abv(sample_recipes['sweetstout']) == '3.0' + # Remove fermentables, verify 0 is returned + sample_recipes['lager'].pop('fermentables') + assert recipe_abv(sample_recipes['lager']) == '0' + # Remove yeast, verify 0 is returned + sample_recipes['sweetstout'].pop('yeast') + assert recipe_abv(sample_recipes['sweetstout']) == '0' + + +def test_recipe_srm(sample_recipes): + assert recipe_srm(sample_recipes['lager']) == '3' + assert recipe_srm(sample_recipes['sweetstout']) == '21' + # Remove fermentables, verify 0 is returned + sample_recipes['lager'].pop('fermentables') + assert recipe_srm(sample_recipes['lager']) == '0'