Files
beer_data/tests/test_brewing.py
T
hermes 1fa9f1dcbe feat(active): add the active beers page, run metadata and tap numbers
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.

Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.

The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.

The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and the latest comment on a reading.

A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number
- record the OG or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.

Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.

Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
  name and the latest comment, and the run detail page lets you edit the
  metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
  beneath it (39 points), which is the way a brewer reads it. Labelling the
  stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
  written when the brew was finished, so the name the operator settled on -
  falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
  name or tap number can be deleted. `upsert_run_metadata_partial` is the
  non-clearing variant, used by the backfill.

Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
  page, so every chart was drawn twice. A test now asserts it is loaded
  exactly once per page.
- The chart library was never referenced by the base template, so all charts
  were silently dead. That was fixed in the previous PR.

Tests
-----
91 (up from 74). New coverage: the active page contents, the tap number, the
projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next free
tap number, the partial-update semantics, the history page, the outlier
list, and the point-scale formatting.

Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts into
`beer_runs` and skips any run whose name has already been set by hand.
2026-11-09 12:35:50 +10:00

345 lines
12 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"