Files
beer_data/tests/test_brewing.py
T
hermes 24cdd452c5
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 17m41s
feat(ui): rebuild the interface with a proper design system
The old UI was two pages of raw pandas tables with no navigation, no styling
and no way to see a run's comments. This replaces it with a small,
testable application and a beer-themed design.

What changed
------------

Restructure
- The domain logic now lives in `brewing.py` as pure functions over plain
  data, with no Flask or Mongo dependency. The gravity maths, the ABV
  formula and the run-aggregation rules are unit-testable on their own.
- `app_factory.py` replaces the module-level app, and the routes move into
  blueprints under `views/`. A request-scoped Mongo repository is created per
  request and closed at teardown.
- `mongo/repository.py` replaces `build_db.py` / `query_db.py` / `get_conn.py`.
  Reading a document no longer requires a second query to fetch the fields.

Interface
- A dark, beer-themed design system: warm amber `#be7f39` on near-black, a
  condensed display face (Anton) for headings and Inter for body text. Both
  fonts and the chart library are vendored into `static/`, so the container
  needs no outbound network at runtime — the old templates pulled Bootstrap
  from a CDN.
- A real navigation bar, a login page, a brew-process page with a proper
  form, and a run-detail page listing every reading with its comment.
- The run summary tables now show the beer name and the latest comment, which
  the old pandas tables did not.
- Native form controls (checkbox, number spinners, date icon, scrollbars)
  are restyled for the dark theme.

Correctness
- The alcohol calculation now uses the standard homebrew formula,
  `(OG - FG) x 131.25`. The old code used `(max - min) / 7.36 + 0.5`, which is
  not a recognised brewing formula and overstated every result.
- Gravity points and decimal readings now convert correctly in both
  directions (`1039` <-> `1.039`).
- A run's display name resolves to the most-used label across its readings,
  because `beer_type` is stored per-reading and drifts across a run.
- Re-submitting a reading for the same run and day updates it in place
  rather than creating a duplicate.

Security / production
- The container ran the Werkzeug debugger with `FLASK_DEBUG=1`, exposing an
  interactive console on any unhandled error. It now runs waitress with
  debugging off.
- `SECRET_KEY` comes from the environment.
- The app secret is no longer a hard-coded string in source.

Housekeeping
- Removes the bokeh/duckdb/pandas dependency chain and the static file it
  generated at import time; removes the dead `add_user.py` and `test.py`.
- Splits `requirements.txt` (runtime) from `requirements-dev.txt` (tests).
- Adds a pytest suite (64 tests) and a `test` job to the CI workflow so the
  image is only built when the tests pass.
- Adds a README describing the data model and a RUNBOOK for the cluster side.

Data compatibility
------------------
The existing `beer_db.beer_data` documents are read as they are and are not
migrated or rewritten. No collection is dropped, and no document is modified
by this change other than through the explicit "record a reading" form, which
keeps the original `(date, beer_run_id)` uniqueness rule.
2026-11-09 10:21:44 +10:00

308 lines
10 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_most_used_label():
"""The stored beer_type drifts across readings; the most-used one wins."""
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"),
])
# All three are unique, so the most recent wins.
assert run.display_name() == "Nocturna Christmas beer"
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"