Files
beer_data/tests/test_brewing.py
T
hermes 369d802066
Build and Push Image / Unit tests (push) Successful in 1m11s
Build and Push Image / Build and push image (push) Skipped
fix: build the image on Python 3.14, and fix the review findings
The image build failed on master:

    ERROR: Ignored the following versions that require a different python
    version: 4.19.0 Requires-Python >=3.11
    ERROR: Could not find a version that satisfies the requirement
    pymongo==4.19.0

The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.

All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.

The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.

Review findings, all from the independent review of this PR
------------------------------------------------------------
- Mongo indexes: the run metadata is always looked up by `beer_run_id` and
  the readings by `(beer_run_id, date)`. Both are now indexed.
- Content-Security-Policy: the app serves only its own vendored assets, so
  a strict `default-src 'self'` policy is enforceable. Adding it required
  removing the inline `style=` attributes from the templates (they became
  `.mb-lg` / `.mt-lg` utility classes) and the one inline `<script> (the run
  id now travels as a `data-run-id` attribute). A test asserts no template
  has either.
- Job timeouts: the `test` and `build` jobs now have `timeout-minutes`, so
  a wedged build stops occupying a runner.
- OG range: raised from 1300 to 1500 points, so a strong specialty brew
  (a 1.150 wort is real) can be recorded.

Bugs found and fixed along the way
----------------------------------
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
  None for a run that is still in the fermenter — its figure is a projection,
  not a measurement. This was showing the two active runs in the "alcohol by
  brew" chart as if they were finished.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
  chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
  spaced the busy months evenly and made the chart read as continuous
  activity. The quiet months are now included with a zero.
- The run-detail chart was dropped when PR 1 was scoped down, leaving dead
  code behind. It is restored.

Tests: 105 (up from 91).
2026-11-09 11:22:03 +10:00

373 lines
13 KiB
Python

"""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"
def test_an_unfinished_run_has_no_measured_abv():
"""A run still in the fermenter has a projection, not a measurement.
Reporting a "measured" ABV for it would be wrong."""
run = Run.from_docs("20", [
_doc(run_id="20", date="2026-11-01", sg="1045"),
_doc(run_id="20", date="2026-11-08", sg="1039"),
])
assert run.is_active() is True
assert run.abv_percent() is None
assert run.projected_abv_percent() is not None
summary = run_summary(run)
assert summary["abv"] is None
assert summary["projected_abv"] is not None
assert summary["legacy_abv"] is None
def test_a_finished_run_still_has_a_measured_abv():
run = Run.from_docs("21", [
_doc(run_id="21", date="2026-11-01", sg="1050"),
_doc(run_id="21", date="2026-11-08", sg="1010", final="True"),
])
assert run.abv_percent() == 5.25
summary = run_summary(run)
assert summary["abv"] == 5.25
assert summary["legacy_abv"] is not None