feat(active): add the active beers page, run metadata and tap numbers #5
@@ -5,6 +5,7 @@ jobs:
|
||||
test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -13,8 +14,14 @@ jobs:
|
||||
# build from GitHub, which this runner cannot reach reliably - the job
|
||||
# hung on it. The runner image already carries a usable python3, so
|
||||
# the tests use it directly.
|
||||
#
|
||||
# The version is checked against the container image's Python so the
|
||||
# tests run on the same interpreter the app ships in.
|
||||
- name: Show the Python version
|
||||
run: python3 --version
|
||||
run: |
|
||||
python3 --version
|
||||
python3 -c "import sys; assert sys.version_info[:2] >= (3, 11), \
|
||||
'the runner python is too old for the pinned requirements'"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -49,6 +56,10 @@ jobs:
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs: test
|
||||
if: gitea.ref == 'refs/heads/master'
|
||||
# A build that has not finished in 15 minutes is wedged — the qemu
|
||||
# multi-arch crash used to hang the buildkit pod for exactly this long.
|
||||
# A timeout stops it occupying a runner indefinitely.
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -110,6 +110,25 @@ def create_app(config: dict | None = None) -> Flask:
|
||||
# ------------------------------------------------------------------ #
|
||||
# Error handlers
|
||||
# ------------------------------------------------------------------ #
|
||||
@app.after_request
|
||||
def _set_security_headers(response):
|
||||
"""Add the standard security headers.
|
||||
|
||||
The app serves its own vendored JavaScript and CSS and loads nothing
|
||||
from a CDN, so a strict Content-Security-Policy of 'self' is enough
|
||||
and is genuinely enforceable.
|
||||
"""
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
||||
"form-action 'self'; frame-ancestors 'none'; base-uri 'self'",
|
||||
)
|
||||
return response
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _not_found(_exc):
|
||||
return render_template("error.html", code=404,
|
||||
|
||||
+14
-3
@@ -59,7 +59,9 @@ ABV_FACTOR = 0.13125
|
||||
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
||||
ASSUMED_ORIGINAL_GRAVITY = 1012
|
||||
|
||||
#: Plausible range for a hydrometer reading, in points.
|
||||
#: Plausible range for a hydrometer reading, in points. The upper bound is
|
||||
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
|
||||
#: still catching typos such as 900 or 1500.
|
||||
_MIN_PLAUSIBLE_SG = 990
|
||||
_MAX_PLAUSIBLE_SG = 1200
|
||||
|
||||
@@ -228,7 +230,15 @@ class Run:
|
||||
return og - fg
|
||||
|
||||
def abv_percent(self) -> float | None:
|
||||
"""Standard ABV for a finished run."""
|
||||
"""Standard ABV for a **finished** run.
|
||||
|
||||
A run that is still in the fermenter has no measured alcohol - its
|
||||
figure is a projection, not a measurement. Returning the drop here
|
||||
for an unfinished run would report a "measured" ABV for a beer that
|
||||
has not finished fermenting, which is wrong.
|
||||
"""
|
||||
if self.is_active():
|
||||
return None
|
||||
drop = self.gravity_drop_points()
|
||||
return abv_percent(drop) if drop is not None else None
|
||||
|
||||
@@ -448,7 +458,8 @@ def run_summary(run: Run, today: date | None = None,
|
||||
"drop_points": run.gravity_drop_points(),
|
||||
"abv": run.abv_percent(),
|
||||
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
||||
"legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()),
|
||||
"legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg())
|
||||
if not run.is_active() else None),
|
||||
"reading_count": len(run.readings),
|
||||
"first_date": first.date.isoformat() if (first and first.date) else None,
|
||||
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||
|
||||
@@ -44,6 +44,23 @@ class BeerRepository:
|
||||
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
||||
# shadow the runs() method on the instance.
|
||||
self.run_meta = self.db["beer_runs"]
|
||||
self._ensure_indexes()
|
||||
|
||||
def _ensure_indexes(self) -> None:
|
||||
"""Make sure the lookup keys are indexed.
|
||||
|
||||
Every read of the run metadata filters on ``beer_run_id``, and the
|
||||
readings are always fetched by ``(beer_run_id, date)``. Without the
|
||||
indexes those are collection scans.
|
||||
"""
|
||||
try:
|
||||
self.run_meta.create_index("beer_run_id", name="beer_run_id_1")
|
||||
self.readings.create_index(
|
||||
[("beer_run_id", 1), ("date", 1)], name="run_date_1"
|
||||
)
|
||||
except PyMongoError:
|
||||
# An index failure must not stop the app from serving.
|
||||
pass
|
||||
|
||||
# -- users -------------------------------------------------------------- #
|
||||
|
||||
|
||||
@@ -649,6 +649,12 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
||||
.row--between { justify-content: space-between; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Spacing utilities. These exist so the templates carry no inline
|
||||
`style=` attributes, which a strict Content-Security-Policy
|
||||
(`style-src 'self'`) would block. */
|
||||
.mb-lg { margin-bottom: 1.25rem; }
|
||||
.mt-lg { margin-top: 2rem; }
|
||||
|
||||
.empty {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -104,7 +104,10 @@
|
||||
function drawRunChart() {
|
||||
var canvas = document.getElementById("run-chart");
|
||||
if (!canvas) { return; }
|
||||
var runId = window.BREW_RUN_ID;
|
||||
// The run id comes from a data attribute rather than a global, so the
|
||||
// page needs no inline script (a strict CSP forbids inline scripts).
|
||||
var runId = canvas.getAttribute("data-run-id");
|
||||
if (!runId) { return; }
|
||||
|
||||
fetch("/api/runs/" + encodeURIComponent(runId), {
|
||||
headers: { "Accept": "application/json" }
|
||||
@@ -169,7 +172,12 @@
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
var completed = runs.filter(function (r) { return r.abv !== null && r.abv !== undefined; });
|
||||
// Only finished brews have a measured ABV. An unfinished run
|
||||
// has a projection, not a measurement, so it is not plotted
|
||||
// here.
|
||||
var completed = runs.filter(function (r) {
|
||||
return r.abv !== null && r.abv !== undefined;
|
||||
});
|
||||
if (!completed.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
@@ -223,6 +231,22 @@
|
||||
var months = Object.keys(counts).sort();
|
||||
if (!months.length) { return; }
|
||||
|
||||
// Include the months with no brews, with a zero. Leaving the
|
||||
// gaps out would space the busy months evenly on the axis and
|
||||
// make the chart read as continuous activity.
|
||||
var filled = [];
|
||||
var cursor = months[0];
|
||||
var last = months[months.length - 1];
|
||||
while (cursor <= last) {
|
||||
filled.push(cursor);
|
||||
var year = parseInt(cursor.slice(0, 4), 10);
|
||||
var month = parseInt(cursor.slice(5, 7), 10);
|
||||
month += 1;
|
||||
if (month > 12) { month = 1; year += 1; }
|
||||
cursor = year + "-" + (month < 10 ? "0" + month : month);
|
||||
}
|
||||
months = filled;
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--3" style="margin-bottom:1.25rem">
|
||||
<div class="grid grid--3 mb-lg">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Active brews</div>
|
||||
<div class="stat__value">{{ totals.count }}</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}{{ code }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="margin-top:2rem">
|
||||
<div class="card mt-lg">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<h1>{{ code }}</h1>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--4" style="margin-bottom:1.25rem">
|
||||
<div class="grid grid--4 mb-lg">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Total brews</div>
|
||||
<div class="stat__value">{{ stats.total_runs }}</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2" style="margin-bottom:1.25rem">
|
||||
<div class="grid grid--2 mb-lg">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Alcohol by brew</h2>
|
||||
@@ -59,7 +59,7 @@
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Brews over time</h2>
|
||||
<span class="card__hint">Runs started per month</span>
|
||||
<span class="card__hint">Runs started per month (active runs counted at their first reading)</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="months-chart" height="120"></canvas>
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
|
||||
{% if outliers %}
|
||||
<section class="card" style="margin-bottom:1.25rem">
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings worth a second look</h2>
|
||||
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
||||
@@ -102,7 +102,7 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card" style="margin-bottom:1.25rem">
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Every brew</h2>
|
||||
<span class="card__hint">{{ summaries | length }} runs</span>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--sidebar" style="margin-bottom:1.25rem">
|
||||
<div class="grid grid--sidebar mb-lg">
|
||||
<!-- ------------------------------------------------- run metadata -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
@@ -115,6 +115,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Gravity over time</h2>
|
||||
<span class="card__hint">Standard ABV: (OG − FG) × 131.25</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings</h2>
|
||||
|
||||
@@ -32,6 +32,27 @@ def login_required(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _month_range(first: str, last: str) -> list[str]:
|
||||
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
|
||||
|
||||
Used to include the months with no brews in the chart, so a quiet period
|
||||
shows as a gap rather than being compressed out of the axis.
|
||||
"""
|
||||
import calendar
|
||||
|
||||
year, month = int(first[:4]), int(first[5:7])
|
||||
end_year, end_month = int(last[:4]), int(last[5:7])
|
||||
|
||||
months = []
|
||||
while (year, month) <= (end_year, end_month):
|
||||
months.append(f"{year:04d}-{month:02d}")
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
return months
|
||||
|
||||
|
||||
def _fmt_days(value: float) -> str:
|
||||
"""Format a day count: a whole number stays whole, a fraction keeps one
|
||||
decimal place."""
|
||||
@@ -109,12 +130,20 @@ def history():
|
||||
abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00"))
|
||||
|
||||
# --- brews per month --------------------------------------------------- #
|
||||
# Every month between the first and the last brew is included, with zero
|
||||
# for the months with no brews. Leaving the gaps out would space the
|
||||
# remaining months evenly and make the chart read as continuous activity.
|
||||
per_month: dict[str, int] = defaultdict(int)
|
||||
for s in summaries:
|
||||
stamp = s["final_date"] or s["first_date"]
|
||||
if stamp:
|
||||
per_month[stamp[:7]] += 1
|
||||
months = sorted(per_month.items())
|
||||
|
||||
months = []
|
||||
if per_month:
|
||||
first, last = min(per_month), max(per_month)
|
||||
for month in _month_range(first, last):
|
||||
months.append((month, per_month.get(month, 0)))
|
||||
|
||||
# --- outliers ---------------------------------------------------------- #
|
||||
outliers = []
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import (Blueprint, flash, redirect, render_template, request,
|
||||
session, url_for)
|
||||
from flask import (Blueprint, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for)
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||
SubmitField, TextAreaField)
|
||||
@@ -50,7 +50,7 @@ class ReadingForm(FlaskForm):
|
||||
validators=[Optional(), Length(max=120)])
|
||||
Date = DateField("Reading date", validators=[DataRequired()])
|
||||
sg = IntegerField("Gravity reading (points)",
|
||||
validators=[DataRequired(), NumberRange(min=900, max=1300)])
|
||||
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
||||
final_reading = BooleanField("This is the final reading")
|
||||
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||
submit = SubmitField("Save reading")
|
||||
@@ -68,7 +68,7 @@ class RunMetaForm(FlaskForm):
|
||||
tap_number = IntegerField("Tap number",
|
||||
validators=[Optional(), NumberRange(min=1, max=99)])
|
||||
og_points = IntegerField("Original gravity (points)",
|
||||
validators=[Optional(), NumberRange(min=900, max=1300)])
|
||||
validators=[Optional(), NumberRange(min=900, max=1500)])
|
||||
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
||||
submit = SubmitField("Save run")
|
||||
|
||||
@@ -166,6 +166,45 @@ def run_detail(run_id: str):
|
||||
meta_form=meta_form, meta=meta)
|
||||
|
||||
|
||||
@bp.route("/api/runs")
|
||||
@login_required
|
||||
def api_runs():
|
||||
"""JSON feed of the run summaries, used by the charts on the analysis
|
||||
pages.
|
||||
|
||||
The charts fetch this rather than having the data inlined into the page,
|
||||
so one fetch serves every chart on a page.
|
||||
"""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
payload = []
|
||||
for run in runs:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
payload.append(summary)
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.route("/api/runs/<run_id>")
|
||||
@login_required
|
||||
def api_run(run_id: str):
|
||||
"""JSON detail of a single run."""
|
||||
run = repo().run(run_id)
|
||||
if run is None:
|
||||
return {"error": "not found"}, 404
|
||||
|
||||
meta = repo().run_metadata(run_id)
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||
@login_required
|
||||
def mark_final(run_id: str):
|
||||
|
||||
@@ -473,3 +473,165 @@ def test_partial_metadata_update_ignores_none(repo):
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Name"
|
||||
assert meta["tap_number"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------- security headers #
|
||||
|
||||
def test_security_headers_are_set(logged_in):
|
||||
"""The app serves only its own vendored assets, so a strict CSP of
|
||||
'self' is enforceable."""
|
||||
response = logged_in.get("/brews")
|
||||
headers = response.headers
|
||||
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert headers.get("X-Frame-Options") == "DENY"
|
||||
assert headers.get("Referrer-Policy") == "same-origin"
|
||||
|
||||
csp = headers.get("Content-Security-Policy")
|
||||
assert csp is not None
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
# No CDN is used, so nothing external should be allowed.
|
||||
assert "https://" not in csp
|
||||
|
||||
|
||||
def test_security_headers_are_set_on_the_login_page(client):
|
||||
response = client.get("/login")
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("Content-Security-Policy") is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ indexes #
|
||||
|
||||
def test_the_repository_creates_its_indexes(repo):
|
||||
"""The run metadata is always looked up by beer_run_id, and the readings
|
||||
by (beer_run_id, date) — both need an index."""
|
||||
index_names = repo.run_meta.index_information()
|
||||
assert "beer_run_id_1" in index_names
|
||||
|
||||
reading_indexes = repo.readings.index_information()
|
||||
assert "run_date_1" in reading_indexes
|
||||
|
||||
|
||||
def test_no_template_has_an_inline_script_or_style(client):
|
||||
"""The strict CSP (script-src 'self'; style-src 'self') blocks inline
|
||||
scripts and style attributes, so the templates must have neither."""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
template_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask", "templates",
|
||||
)
|
||||
|
||||
for path in glob.glob(os.path.join(template_dir, "*.html")):
|
||||
with open(path) as fh:
|
||||
body = fh.read()
|
||||
|
||||
# An inline <script> is one with content rather than a src attribute.
|
||||
for match in re.finditer(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
||||
body, re.S):
|
||||
assert match.group(1).strip() == "", (
|
||||
f"{os.path.basename(path)} has an inline <script>, which the "
|
||||
"CSP blocks"
|
||||
)
|
||||
|
||||
assert not re.search(r"\sstyle\s*=", body), (
|
||||
f"{os.path.basename(path)} has an inline style attribute, which "
|
||||
"the CSP blocks"
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- API #
|
||||
|
||||
def test_api_runs_returns_json(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/api/runs")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["abv"] == 5.25
|
||||
assert payload[0]["active"] is False
|
||||
|
||||
|
||||
def test_api_run_detail_returns_json(logged_in, repo):
|
||||
seed_readings(repo, [("11", "Cider", "1045", "2026-04-01", False, "first")])
|
||||
|
||||
response = logged_in.get("/api/runs/11")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["beer_run_id"] == "11"
|
||||
assert payload["name"] == "Cider"
|
||||
assert len(payload["readings"]) == 1
|
||||
|
||||
|
||||
def test_api_run_detail_404s_for_an_unknown_run(logged_in):
|
||||
response = logged_in.get("/api/runs/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_the_api_is_protected(client):
|
||||
"""The charts fetch from the API, so it must not be public."""
|
||||
for path in ("/api/runs", "/api/runs/1"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 302, path
|
||||
assert "/login" in response.headers["Location"], path
|
||||
|
||||
|
||||
def test_api_runs_includes_the_canonical_name_and_tap(logged_in, repo):
|
||||
seed_readings(repo, [("12", "Drifting Name", "1045", "2026-05-01", False, "")])
|
||||
repo.upsert_run_metadata("12", name="Canonical", tap_number=7)
|
||||
|
||||
payload = logged_in.get("/api/runs").get_json()
|
||||
entry = next(r for r in payload if r["beer_run_id"] == "12")
|
||||
assert entry["canonical_name"] == "Canonical"
|
||||
assert entry["tap_number"] == 7
|
||||
|
||||
|
||||
def test_the_months_chart_includes_the_gaps(logged_in, repo):
|
||||
"""Months with no brews must appear with a zero, or the chart spaces the
|
||||
busy months evenly and reads as continuous activity."""
|
||||
seed_readings(repo, [
|
||||
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||
])
|
||||
|
||||
from views.analysis import _month_range
|
||||
|
||||
months = _month_range("2026-01", "2026-06")
|
||||
assert months == ["2026-01", "2026-02", "2026-03", "2026-04",
|
||||
"2026-05", "2026-06"]
|
||||
|
||||
|
||||
def test_the_months_chart_counts_zero_for_a_quiet_month(logged_in, repo):
|
||||
"""The chart is fed by the API, so the quiet months are visible there."""
|
||||
seed_readings(repo, [
|
||||
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||
])
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
body = logged_in.get("/history").data.decode()
|
||||
# The page embeds the month labels for the chart.
|
||||
assert "2026-06" in body
|
||||
|
||||
|
||||
def test_month_range_covers_a_year_boundary():
|
||||
from views.analysis import _month_range
|
||||
|
||||
assert _month_range("2025-11", "2026-02") == [
|
||||
"2025-11", "2025-12", "2026-01", "2026-02",
|
||||
]
|
||||
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
||||
|
||||
@@ -342,3 +342,31 @@ def test_point_scale_for_a_round_reading():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user