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).
373 lines
13 KiB
Python
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
|