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.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user