"""Unit tests for the pure brewing logic in ``brewing.py``. These are the tests that matter most: they pin the gravity parsing, the ABV formula and the run-aggregation rules that the whole UI is built on. """ from __future__ import annotations from datetime import date import pytest from brewing import (ABV_FACTOR, Reading, Run, abv_percent, assumed_og_abv_percent, days_between, final_reading, first_reading, is_plausible_sg, is_active, last_reading, legacy_abv_percent, parse_date, parse_sg_points, run_summary, sg_points_to_decimal) # --------------------------------------------------------------- parsing # @pytest.mark.parametrize("raw,expected", [ ("1039", 1039), (1039, 1039), (1039.0, 1039), (" 1045 ", 1045), ("1.039", 1039), (1.045, 1045), ("None", None), ("", None), (None, None), ("abc", None), (True, None), ]) def test_parse_sg_points(raw, expected): assert parse_sg_points(raw) == expected def test_sg_points_to_decimal(): assert sg_points_to_decimal(1039) == 1.039 assert sg_points_to_decimal(1012) == 1.012 assert sg_points_to_decimal(None) is None @pytest.mark.parametrize("raw,expected", [ ("2026-11-08", date(2026, 11, 8)), (date(2026, 11, 8), date(2026, 11, 8)), ("08/11/2026", date(2026, 11, 8)), ("", None), (None, None), ("not a date", None), ]) def test_parse_date(raw, expected): assert parse_date(raw) == expected # ------------------------------------------------------------------- ABV # def test_abv_uses_the_standard_formula(): """ABV% = (OG - FG) * 131.25, i.e. points drop * 0.13125.""" # 1.050 -> 1.010 is a 40 point drop = 5.25% ABV. assert abv_percent(40) == 5.25 assert abv_percent(40) == pytest.approx(40 * ABV_FACTOR) assert abv_percent(0) == 0.0 assert abv_percent(None) is None def test_abv_matches_worked_examples(): # Session IPA: 1.050 -> 1.010 = 5.25% assert abv_percent(50 - 10) == 5.25 # 1.060 -> 1.012 = 6.29% assert abv_percent(60 - 12) == pytest.approx(6.29, abs=0.01) def test_legacy_formula_is_kept_for_comparison(): """The old formula is preserved verbatim so history can show the delta.""" assert legacy_abv_percent(1050, 1010) == round(((1050 - 1010) / 7.36) + 0.5, 2) # The legacy formula claims 0.5% even with no drop at all. assert legacy_abv_percent(1010, 1010) == 0.5 assert legacy_abv_percent(None, 1010) is None def test_legacy_formula_overstates_the_real_figure(): """The reason the history page compares the two at all.""" standard = abv_percent(50 - 10) legacy = legacy_abv_percent(1050, 1010) assert legacy > standard def test_assumed_og_abv(): assert assumed_og_abv_percent(1012) == pytest.approx(0.0) assert assumed_og_abv_percent(1010) == pytest.approx(0.26) # --------------------------------------------------------------- plausibility # @pytest.mark.parametrize("points,plausible", [ (1010, True), (990, True), (1200, True), (989, False), (1201, False), (900, False), (None, False), ]) def test_is_plausible_sg(points, plausible): assert is_plausible_sg(points) is plausible # --------------------------------------------------------------- readings # def _doc(run_id="1", beer_type="Pale Ale", sg="1045", date="2026-01-01", final="False", comment=""): return { "_id": f"id-{run_id}-{date}-{sg}", "beer_run_id": run_id, "beer_type": beer_type, "sg": sg, "date": date, "final_reading": final, "comment": comment, } def test_reading_from_doc_accepts_string_and_bool_flags(): assert Reading.from_doc(_doc(final="True")).final_reading is True assert Reading.from_doc(_doc(final="False")).final_reading is False assert Reading.from_doc(_doc(final=True)).final_reading is True assert Reading.from_doc(_doc(final=False)).final_reading is False def test_reading_from_doc_cleans_whitespace(): reading = Reading.from_doc(_doc(beer_type=" Pale Ale ", comment=" hops in ")) assert reading.beer_type == "Pale Ale" assert reading.comment == "hops in" def test_first_and_last_reading(): readings = [ Reading.from_doc(_doc(date="2026-03-01", sg="1050")), Reading.from_doc(_doc(date="2026-01-01", sg="1060")), Reading.from_doc(_doc(date="2026-02-01", sg="1040")), ] assert first_reading(readings).date == date(2026, 1, 1) assert last_reading(readings).date == date(2026, 3, 1) def test_days_between(): assert days_between(date(2026, 1, 1), date(2026, 1, 8)) == 7 # ------------------------------------------------------------------- runs # def test_run_orders_readings_chronologically(): run = Run.from_docs("1", [ _doc(date="2026-03-01", sg="1010"), _doc(date="2026-01-01", sg="1060"), ]) assert [r.date for r in run.readings] == [date(2026, 1, 1), date(2026, 3, 1)] def test_run_original_and_final_gravity(): run = Run.from_docs("1", [ _doc(date="2026-01-01", sg="1050"), _doc(date="2026-01-10", sg="1030"), _doc(date="2026-01-20", sg="1010", final="True"), ]) assert run.original_gravity() == 1050 assert run.final_gravity() == 1010 assert run.gravity_drop_points() == 40 assert run.abv_percent() == 5.25 def test_run_is_active_until_a_final_reading_exists(): active = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")]) assert active.is_active() is True assert is_active(active.readings) is True finished = Run.from_docs("2", [ _doc(run_id="2", date="2026-01-01", sg="1050"), _doc(run_id="2", date="2026-01-20", sg="1010", final="True"), ]) assert finished.is_active() is False assert final_reading(finished.readings) is not None def test_run_display_name_prefers_the_name_on_the_final_reading(): """The stored beer_type drifts across readings. The name on the final reading is the one the operator settled on.""" run = Run.from_docs("4", [ _doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"), _doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"), _doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"), ]) assert run.display_name() == "Nocturna Christmas beer" def test_run_display_name_falls_back_to_the_most_used_label(): """Without a final reading, the most-used name wins, breaking ties towards the most recent.""" run = Run.from_docs("5", [ _doc(date="2024-12-03", beer_type="Going Nowhere Fast"), _doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"), ]) # The final reading's name wins. assert run.display_name() == "Going Nowhere fast" run2 = Run.from_docs("6", [ _doc(date="2024-12-03", beer_type="Balter"), _doc(date="2024-12-06", beer_type="Pale ale blayer"), _doc(date="2024-12-10", beer_type="Balter"), ]) # No final reading; "Balter" was used twice. assert run2.display_name() == "Balter" def test_run_display_name_falls_back_to_the_run_number(): run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")]) assert run.display_name() == "Run 7" def test_run_comments_are_collected_oldest_first(): run = Run.from_docs("1", [ _doc(date="2026-01-01", comment="first"), _doc(date="2026-01-10", comment=""), _doc(date="2026-01-20", comment="last"), ]) assert run.comments() == ["first", "last"] def test_run_outliers_are_flagged(): run = Run.from_docs("1", [ _doc(date="2026-01-01", sg="1045"), _doc(date="2026-01-10", sg="900"), # implausible _doc(date="2026-01-20", sg="1010"), ]) assert [r.sg_points for r in run.outlier_readings()] == [900] def test_run_days_in_fermenter_for_a_finished_run(): run = Run.from_docs("1", [ _doc(date="2026-01-01", sg="1050"), _doc(date="2026-01-15", sg="1010", final="True"), ]) assert run.days_in_fermenter() == 14 def test_run_days_in_fermenter_for_an_active_run_uses_today(): run = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")]) assert run.days_in_fermenter(today=date(2026, 1, 11)) == 10 def test_projected_abv_is_never_negative(): """Regression: the legacy assumed-OG of 1012 produced negative alcohol whenever the current reading was above 1012 (a normal OG).""" run = Run.from_docs("20", [ _doc(run_id="20", beer_type="Out on patio", date="2026-11-08", sg="1039"), ]) projected = run.projected_abv_percent() # No OG is known for a single-reading run, so there is no estimate — # and certainly no negative one. assert projected is None run2 = Run.from_docs("20b", [ _doc(run_id="20b", date="2026-11-08", sg="1039"), _doc(run_id="20b", date="2026-11-15", sg="1020"), ]) assert run2.projected_abv_percent() >= 0.0 def test_projected_abv_uses_the_first_reading_as_the_og(): run = Run.from_docs("21", [ _doc(run_id="21", date="2026-11-08", sg="1045"), _doc(run_id="21", date="2026-11-15", sg="1030"), ]) # 15 point drop = 1.97% assert run.projected_abv_percent() == pytest.approx(1.97) def test_projected_abv_is_unknown_for_a_single_reading_with_no_og(): """One reading and no recorded OG: there is no reliable estimate, so the figure is None (the UI shows a dash) rather than a misleading number.""" run = Run.from_docs("22", [_doc(run_id="22", date="2026-11-08", sg="1005")]) assert run.projected_abv_percent() is None def test_projected_abv_uses_a_recorded_og_override(): """A run with a single reading can still be estimated once an OG has been recorded against it.""" run = Run.from_docs("23", [_doc(run_id="23", date="2026-11-08", sg="1010")]) assert run.projected_abv_percent(og_override=1045) == pytest.approx(4.59) def test_projected_abv_is_none_when_gravity_rises(): """Gravity rising during fermentation is an anomaly, not alcohol.""" run = Run.from_docs("24", [ _doc(run_id="24", date="2026-11-08", sg="1010"), _doc(run_id="24", date="2026-11-15", sg="1015"), ]) assert run.projected_abv_percent() is None def test_run_summary_shape(): run = Run.from_docs("9", [ _doc(run_id="9", beer_type="Pale Ale", date="2026-01-01", sg="1050"), _doc(run_id="9", beer_type="Pale Ale", date="2026-01-20", sg="1010", final="True", comment="Keghing"), ]) summary = run_summary(run) assert summary["beer_run_id"] == "9" assert summary["name"] == "Pale Ale" assert summary["active"] is False assert summary["abv"] == 5.25 assert summary["legacy_abv"] == legacy_abv_percent(1050, 1010) assert summary["og_points"] == 1050 assert summary["fg_points"] == 1010 assert summary["comments"] == ["Keghing"] assert len(summary["readings"]) == 2 assert summary["readings"][0]["date"] == "2026-01-01" def test_run_summary_handles_an_empty_run(): summary = run_summary(Run.from_docs("99", [])) assert summary["abv"] is None assert summary["reading_count"] == 0 assert summary["name"] == "Run 99" def test_point_scale_is_the_digits_after_the_decimal(): """In brewing convention 1.039 is 39 points. The stored 1039 is the full reading and must not be labelled as a point count.""" run = Run.from_docs("1", [ _doc(date="2026-01-01", sg="1039"), ]) summary = run_summary(run) assert summary["min_sg_decimal"] == "1.039" assert summary["min_sg_point_scale"] == "39" def test_point_scale_for_a_round_reading(): run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")]) summary = run_summary(run) assert summary["min_sg_point_scale"] == "10" assert summary["min_sg_decimal"] == "1.010"