Compare commits

...
5 Commits
Author SHA1 Message Date
hermes 21fcdf485c test: the advice in the table is a pop-out, not the text 2026-11-09 20:57:34 +10:00
hermes 7dd39190f1 fix(ui): mobile pop-out for the advice, and tables that fit a phone
The brewing advice is several hundred characters.  In a table cell it
pushed the table past the width of a phone, so the column was clipped
off the right edge and the advice could not be read without sideways
scrolling.

The advice is now a compact trigger in the cell and the text is in a
floating panel that opens on hover (desktop) or on tap (phone).

The panel is position:fixed and anchored by the script.  Absolutely
positioned inside the cell it was clipped by the table's own scroll
container, and the advice was sliced in half by the card's edge.  It
flips above the trigger when there is no room below.

The tables now reflow into stacked cards on a narrow screen, with each
value labelled by its column name.  A seven column table does not fit a
phone, and it used to scroll sideways inside its wrapper, so the
columns on the right were off the screen.

Also: the header wraps onto two rows so the nav tabs are not clipped,
the point scale says 'points' rather than 'pts', and the reading count
on the beer card does not orphan its word.
2026-11-09 20:57:34 +10:00
hermes 55b6068b3b feat(advice): LLM brewing advice on every observation (#14)
Build and Push Image / Build and push image (push) Successful in 9m8s
feat(advice): LLM brewing advice on every observation (#14)
2026-11-09 20:23:32 +10:00
hermes c821a02d6e fix(charts): leave the suspect and active runs out of the bar charts (#15)
Build and Push Image / Build and push image (push) Successful in 3m19s
fix(charts): leave the suspect and active runs out of the bar charts (#15)
2026-11-09 16:46:56 +10:00
hermes 794df0c12e fix(charts): leave the suspect and active runs out of the bar charts
The second review of PR #12 (after the suspect-run fix) found that the
two BAR charts were still plotting runs they should not have been, while
the curve chart and the tiles had been corrected.  The page was
contradicting itself.

The ABV chart plotted the suspect run
--------------------------------------
Run 12's impossible reading gives it an ABV of 18.64%.  The chart's y
axis went to 18.64, so the fifteen real bars were squashed into the
bottom ~45% of the plot - while the "Strongest 8.53%" tile on the same
page showed the correct figure.  The chart now leaves the suspect runs
out.

The attenuation chart plotted the active runs
----------------------------------------------
An active run's attenuation is measured against its CURRENT gravity, so
a brew that has just started shows a 0% bar.  That is the absence of a
result, not a result, and it sat directly above the table of finished
brews.  The chart now leaves the active runs out.

Verified against the live data:

    ABV chart bars:        bars=15 max=8.53   (was 16 bars, max 18.64)
    attenuation chart bars: bars=15 min=76.2   (was 17 bars, min 0)

A suspect active run no longer vanishes
---------------------------------------
The API flags suspect runs over EVERY run, so the charts leave them out.
But the analysis page only looked at the finished ones, so an active run
with a bad reading would be dropped from the charts and never listed in
"Readings that need a second look" - it would disappear with no
explanation anywhere.  The analysis page now lists suspect active runs
too.

The figures say what they are based on
--------------------------------------
- The analysis prose counted the non-suspect runs, not the ones that
  actually contributed a figure to the mean.  It now counts the latter.
- The history page's "Average ABV — across finished brews" was averaging
  15 of 16 without saying so, while the analysis page said "16 brews (15
  counted)".  It now says "across 15 of 16 finished brews".
- `/api/runs/<run_id>` carries the `suspect` and `active` flags that
  `/api/runs` does.

148 tests (up from 142).
2026-11-09 16:46:30 +10:00
11 changed files with 653 additions and 26 deletions
+262 -17
View File
@@ -480,6 +480,138 @@ table.data tbody tr:hover { background: var(--bg-raised-2); }
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
table.data .comment { color: var(--text-muted); font-size: .84rem; }
/* On a narrow screen the tables reflow into stacked cards.
A seven-column table does not fit a phone. It was left to scroll
sideways inside its wrapper, which meant the columns on the right were
off the screen and the brewer had to pan the table to see them.
Each row becomes a card: the header cell becomes a label above its
value, and the cells stack. */
@media (max-width: 720px) {
/* The header wraps onto two rows on a phone: the brand and the
sign-out on the first, the nav tabs on the second where they get
the full width and scroll sideways if they still do not fit.
They used to sit on one row with the brand and the sign-out, and
the last tab was cut off mid-word. */
.topbar__inner {
flex-wrap: wrap;
row-gap: .5rem;
}
.brand {
order: 1;
}
.topbar__logout {
order: 2;
margin-left: auto;
}
.nav {
order: 3;
flex: 1 0 100%;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
/* A fade at the right edge so a clipped tab reads as
scrollable rather than broken. */
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
}
.nav::-webkit-scrollbar {
display: none;
}
.nav__link {
flex-shrink: 0;
}
/* Tighter tabs, so all four fit on a phone without scrolling.
They still scroll if they have to. */
.nav__link {
padding: .35rem .55rem;
font-size: .8rem;
letter-spacing: .01em;
}
.table-wrap {
overflow-x: visible;
}
table.data,
table.data tbody,
table.data tr,
table.data td {
display: block;
width: auto;
}
table.data thead {
/* HIDDEN in the stacked layout. Each cell shows its column
name as a label, so the header row would be a duplicate - and
it was 230px of the first card. */
display: none;
}
table.data tr {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: .75rem;
padding: .25rem .1rem;
background: var(--bg-raised-2);
}
table.data tbody tr:hover {
background: var(--bg-raised-2);
}
table.data th {
display: block;
border-bottom: 1px solid var(--border);
}
table.data td {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: .5rem;
align-items: baseline;
padding: .45rem .7rem;
border-bottom: 1px solid rgba(255, 255, 255, .04);
text-align: left;
}
table.data td::before {
/* The column heading, shown as the row's label. */
content: attr(data-label);
font-size: .65rem;
font-weight: 650;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--text-faint);
}
table.data td.num {
text-align: left;
}
table.data td.right,
table.data td:empty {
grid-template-columns: 1fr;
}
table.data td.right::before {
content: none;
}
/* A cell with nothing in it must not leave a gap. */
table.data td:empty {
display: none;
}
}
/* ----------------------------------------------------------------- chips */
.chip {
@@ -595,8 +727,18 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
/* The three stats sit side by side, so their captions line up. One
caption ("needs 2nd reading") is two lines and the others are one,
which left the baselines ragged. */
.brew-card__stat {
display: flex;
flex-direction: column;
}
.brew-card__stat .stat__label { font-size: .68rem; }
.brew-card__stat .stat__value { font-size: 1.3rem; }
.brew-card__stat .stat__meta { margin-top: auto; }
.brew-card__spark { height: 46px; }
/* The "start a new beer" tile — fills the last grid slot and gives the
@@ -733,27 +875,130 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
color: var(--danger);
}
/* The advice in the table is a compact trigger that pops the full text
out on hover, focus or tap.
It used to be the text itself, clamped to four lines. The advice runs
to several hundred characters, and a cell that wide pushes the table
past the width of a phone, so the column ended up clipped off the
right edge and unreadable without sideways scrolling. */
.advice-cell {
max-width: 32rem;
position: relative;
width: 1%;
white-space: nowrap;
}
/* In the table the advice is clamped to a few lines - the full text is
on the run detail page, where there is room to read it. */
.advice-cell .advice {
margin-top: 0;
.advice-trigger {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .25rem .55rem;
border: 1px solid var(--amber);
border-radius: 999px;
background: var(--amber-dim);
color: var(--amber-text);
font: inherit;
font-size: .75rem;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
/* The clamped text opens up on hover, so the whole thing is readable
without leaving the page. */
.advice-cell:hover .advice,
.advice-cell:focus-within .advice {
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
.advice-trigger:hover,
.advice-trigger:focus-visible {
background: var(--amber);
color: var(--bg);
}
.advice-trigger--failed {
border-color: var(--danger);
background: var(--danger-dim);
color: var(--danger);
}
.advice-trigger__icon {
font-size: .85em;
}
/* The pop-out.
It is `position: fixed` and anchored to the trigger by the script,
not absolutely positioned inside the cell. The table is a scroll
container, and an absolutely positioned panel inside it was clipped
by the container's own overflow - the advice was sliced in half by
the card's edge and the bottom border was cut off.
Fixed positioning takes it out of the table entirely, so it floats
above the page and is never cut by a scroll container. */
.advice-pop {
position: fixed;
z-index: 200;
display: none;
width: min(30rem, 92vw);
max-height: 70vh;
overflow-y: auto;
padding: .8rem .9rem;
border: 1px solid var(--amber);
border-radius: 8px;
background: var(--bg-raised);
box-shadow: 0 12px 32px rgba(0, 0, 0, .55);
text-align: left;
white-space: normal;
}
.advice-pop__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .4rem;
}
.advice-pop__head .advice__label {
margin-bottom: 0;
}
.advice-pop__close {
border: 0;
background: none;
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 .15rem;
}
.advice-pop__close:hover {
color: var(--text);
}
.advice-pop__text {
margin: 0;
font-size: .8125rem;
line-height: 1.55;
color: var(--text);
}
/* The script opens and closes the panel, and places it. The CSS only
decides whether it is shown. */
.advice-pop.is-open {
display: block;
}
/* On a phone there is no hover, so the trigger is a button the script
toggles, and the panel is pinned to the middle of the screen so it is
readable whatever the table's width. */
@media (max-width: 720px) {
.advice-cell {
width: auto;
}
/* On a phone the panel is centred on the screen rather than
anchored to the trigger - there is not the room to float it
beside the cell, and the trigger may be near the edge. */
.advice-pop {
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%);
}
}
+176
View File
@@ -56,11 +56,187 @@
}
}
/**
* The brewing advice pop-out.
*
* On a desktop the panel opens on hover, which the CSS does. A
* phone has no hover, so the trigger is a button that toggles it -
* and it is a real button, so it works with a keyboard and with a
* screen reader.
*/
/**
* The brewing advice pop-out.
*
* The advice runs to several hundred characters. In a table cell it
* pushed the table past the width of a phone, so it is a compact
* trigger instead and the text is shown in a floating panel.
*
* The panel is position:fixed so it escapes the table's scroll
* container - absolutely positioned inside the cell it was clipped
* by the container and sliced in half by the card's edge.
*
* It opens on hover on a desktop, and on tap on a phone, which has
* no hover. The trigger is a real button, so it also works with a
* keyboard.
*/
function wireAdvicePopouts() {
var triggers = document.querySelectorAll(".advice-trigger");
for (var i = 0; i < triggers.length; i++) {
(function (trigger) {
var panel = trigger.parentNode.querySelector(".advice-pop");
if (!panel) { return; }
var closeTimer = null;
function isPhone() {
return window.matchMedia("(max-width: 720px)").matches;
}
function place() {
if (isPhone()) {
/* The CSS centres it on a phone - clear any
inline position left over from a desktop
resize. */
panel.style.top = "";
panel.style.left = "";
return;
}
var box = trigger.getBoundingClientRect();
var gap = 10;
var height = panel.offsetHeight;
var width = panel.offsetWidth;
var spaceBelow = window.innerHeight - box.bottom - gap;
var spaceAbove = box.top - gap;
/* Prefer below; flip above when there is not enough
room and there is more room above. */
var top;
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
top = box.bottom + gap;
} else {
top = Math.max(gap, box.top - height - gap);
}
/* Keep it on screen horizontally. */
var left = Math.min(box.left, window.innerWidth - width - gap);
left = Math.max(gap, left);
panel.style.top = Math.round(top) + "px";
panel.style.left = Math.round(left) + "px";
}
function open() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
panel.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
place();
}
function close() {
panel.classList.remove("is-open");
trigger.setAttribute("aria-expanded", "false");
}
function closeSoon() {
/* A short delay, so the mouse can travel from the
trigger into the panel without it closing. */
if (closeTimer) { clearTimeout(closeTimer); }
closeTimer = setTimeout(function () {
closeTimer = null;
if (!panel.matches(":hover") && !trigger.matches(":hover")) {
close();
}
}, 180);
}
trigger.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
if (panel.classList.contains("is-open")) {
close();
} else {
open();
}
});
trigger.addEventListener("mouseenter", function () {
if (!isPhone()) { open(); }
});
trigger.addEventListener("mouseleave", closeSoon);
panel.addEventListener("mouseenter", function () {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
});
panel.addEventListener("mouseleave", closeSoon);
var closeButton = panel.querySelector(".advice-pop__close");
if (closeButton) {
closeButton.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
close();
trigger.focus();
});
}
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && panel.classList.contains("is-open")) {
close();
trigger.focus();
}
});
document.addEventListener("click", function (event) {
if (!panel.contains(event.target) && event.target !== trigger) {
close();
}
});
window.addEventListener("resize", function () {
if (panel.classList.contains("is-open")) { place(); }
});
window.addEventListener("scroll", function () {
if (panel.classList.contains("is-open")) { close(); }
}, true);
})(triggers[i]);
}
}
function labelTableCells() {
var tables = document.querySelectorAll("table.data");
for (var t = 0; t < tables.length; t++) {
var headerRow = tables[t].querySelector("thead tr");
if (!headerRow) { continue; }
var labels = [];
var headers = headerRow.querySelectorAll("th");
for (var h = 0; h < headers.length; h++) {
labels.push(headers[h].textContent.trim());
}
var rows = tables[t].querySelectorAll("tbody tr");
for (var r = 0; r < rows.length; r++) {
var cells = rows[r].querySelectorAll("td");
for (var c = 0; c < cells.length && c < labels.length; c++) {
if (labels[c] && !cells[c].getAttribute("data-label")) {
cells[c].setAttribute("data-label", labels[c]);
}
}
}
}
}
function init() {
focusFirstEmpty();
defaultDateFields();
wireToggles();
wireConfirms();
wireAdvicePopouts();
labelTableCells();
}
if (document.readyState === "loading") {
+16 -2
View File
@@ -184,8 +184,16 @@
// Only finished brews have a measured ABV. An unfinished run
// has a projection, not a measurement, so it is not plotted
// here.
//
// A suspect run is left out too. Its ABV comes from an
// impossible reading (run 12 reads 18.64%), and plotting it
// stretched the y axis so that every real bar was squashed
// into the bottom of the plot - while the "Strongest" tile
// on the same page correctly showed 8.53%. The chart and
// the tile contradicted each other.
var completed = runs.filter(function (r) {
return r.abv !== null && r.abv !== undefined;
return !r.suspect &&
r.abv !== null && r.abv !== undefined;
});
if (!completed.length) { return; }
@@ -305,8 +313,14 @@
// A run with an impossible reading has no attenuation, and
// is flagged as suspect. Both are excluded, so one bad
// reading cannot become a bar in the chart.
//
// Active runs are excluded as well. Their attenuation is
// measured against their CURRENT gravity, so a brew that
// has just started shows a 0% bar - which is not a result,
// it is the absence of one. The chart is about finished
// brews, and it sits above the table of finished brews.
var done = runs.filter(function (r) {
return !r.suspect &&
return !r.suspect && !r.active &&
r.attenuation !== null && r.attenuation !== undefined;
});
if (!done.length) { return; }
+1 -1
View File
@@ -44,7 +44,7 @@
<div class="brew-card__meta">
Run {{ run.beer_run_id }}
{% if run.first_date %} &middot; since {{ run.first_date }}{% endif %}
{% if run.reading_count == 1 %} &middot; 1 reading{% else %} &middot; {{ run.reading_count }} readings{% endif %}
{% if run.reading_count == 1 %} &middot; 1&nbsp;reading{% else %} &middot; {{ run.reading_count }}&nbsp;readings{% endif %}
</div>
</div>
{% if run.tap_number is not none %}
+1 -1
View File
@@ -186,7 +186,7 @@
</div>
<div class="card__body">
<p>
Across {% if stats.suspect_count %}{{ stats.trusted_count }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages
Across {% if stats.suspect_count %}{{ stats.counted_attenuation }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
deviation of <strong>{{ stats.consistency.stdev }} percentage
points</strong> and a total range of {{ stats.consistency.spread }}
+18 -3
View File
@@ -95,7 +95,7 @@
<td class="num">
{% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg_decimal }}</span>
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
<div class="faint tiny">{{ run.min_sg_point_scale }} points</div>
{% else %}—{% endif %}
</td>
<td class="num">
@@ -112,9 +112,24 @@
<span class="faint">—</span>
{% endif %}
</td>
<td class="comment advice-cell">
<td class="advice-cell">
{% if run.advice %}
<span class="advice{% if not run.advice_ok %} advice--failed{% endif %}">{{ run.advice }}</span>
<button type="button"
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
aria-expanded="false">
<span class="advice-trigger__icon" aria-hidden="true">&#9998;</span>
<span class="advice-trigger__label">
{% if run.advice_ok %}Read the advice{% else %}Advice failed{% endif %}
</span>
</button>
<div class="advice-pop" role="tooltip">
<div class="advice-pop__head">
<span class="advice__label">Brewing advice</span>
<button type="button" class="advice-pop__close"
aria-label="Close">&times;</button>
</div>
<p class="advice-pop__text">{{ run.advice }}</p>
</div>
{% else %}
<span class="faint">Thinking…</span>
{% endif %}
+7 -1
View File
@@ -23,7 +23,13 @@
<div class="stat">
<div class="stat__label">Average ABV</div>
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
<div class="stat__meta">across finished brews</div>
<div class="stat__meta">
{% if stats.abv_counted < stats.abv_total %}
across {{ stats.abv_counted }} of {{ stats.abv_total }} finished brews
{% else %}
across finished brews
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Strongest</div>
+17
View File
@@ -55,6 +55,15 @@ def deep():
suspect = implausible_runs(finished)
suspect_ids = {s["beer_run_id"] for s in suspect}
# A run that is still fermenting can also have an impossible reading,
# and the API flags those too - so the charts leave them out. If they
# were not listed here they would disappear from the analysis page
# entirely, with no explanation anywhere.
active_suspect = implausible_runs([r for r in runs if r.is_active()])
if active_suspect:
suspect = suspect + active_suspect
suspect_ids |= {s["beer_run_id"] for s in active_suspect}
rows = []
for run in finished:
meta = metadata.get(run.beer_run_id, {})
@@ -111,6 +120,10 @@ def deep():
"suspect_count": len(suspect),
# How many runs the figures are actually based on.
"trusted_count": len(good),
# How many of those actually contributed a figure to the
# average - a run with no original gravity has no attenuation,
# so it is counted above but not in the mean.
"counted_attenuation": len(attenuations),
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
if attenuations else None,
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
@@ -278,5 +291,9 @@ def history():
"avg_abv": avg_abv,
"best": best,
"worst": worst,
# How many of the finished brews the ABV figures are based
# on, so the tile can say so.
"abv_counted": len(good_abv_rows),
"abv_total": len(completed),
},
)
+5
View File
@@ -338,6 +338,10 @@ def api_runs():
# The runs whose readings are physically impossible. They are flagged
# rather than dropped: the charts use the flag to leave them out of the
# averages and the curve envelope, but a caller can still see them.
#
# The flag is computed over EVERY run, not just the finished ones, so
# it agrees with what the charts do. An active run with a bad reading
# is still a run with a bad reading.
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
payload = []
@@ -368,6 +372,7 @@ def api_run(run_id: str):
# The same flag the list endpoint carries, so a consumer of the detail
# endpoint can tell an impossible run from a good one.
summary["suspect"] = bool(implausible_runs([run]))
summary["active"] = run.is_active()
return jsonify(summary)
+118
View File
@@ -508,3 +508,121 @@ def test_the_charts_fetch_the_run_payload_once():
"the run payload should be fetched once and shared"
)
assert "function fetchRuns()" in charts_js
# ------------------------------------------------- the chart exclusions #
def test_the_abv_chart_leaves_out_the_suspect_runs():
"""A suspect run's ABV comes from an impossible reading.
Run 12 reads 18.64%, and plotting it stretched the y axis so every
real bar was squashed into the bottom of the plot - while the
"Strongest" tile on the same page correctly showed 8.53%. The chart
and the tile contradicted each other.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
abv_chart = charts_js.split("function drawAbvChart")[1]
abv_chart = abv_chart.split("function drawMonthsChart")[0]
assert "!r.suspect" in abv_chart, (
"the ABV chart must leave the suspect runs out"
)
def test_the_attenuation_chart_leaves_out_the_active_runs():
"""An active run's attenuation is measured against its CURRENT gravity.
A brew that has just started shows a 0% bar - which is the absence of
a result, not a result. The chart sits above the table of finished
brews.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
att_chart = charts_js.split("function drawAttenuationChart")[1]
att_chart = att_chart.split("function drawCurveChart")[0]
assert "!r.active" in att_chart, (
"the attenuation chart must leave the active runs out"
)
def test_a_suspect_active_run_is_still_listed_for_review(logged_in, repo):
"""A bad reading in an active run must not make the run vanish.
The API flags suspect runs over EVERY run, so the charts leave them
out. If the analysis page only looked at the finished ones, an
active run with a bad reading would disappear with no explanation
anywhere.
"""
seed_readings(repo, [
("1", "Finished ale", "1050", "2026-01-01", False, ""),
("1", "Finished ale", "1010", "2026-01-10", True, ""),
# An ACTIVE run whose latest reading is below water.
("20", "Active brew", "1042", "2026-02-01", False, ""),
("20", "Active brew", "0900", "2026-02-10", False, "typo"),
])
response = logged_in.get("/analysis")
body = response.get_data(as_text=True)
assert "Readings that need a second look" in body
assert "0.900" in body
def test_the_history_page_says_how_many_brews_the_abv_figure_is_from(logged_in, repo):
"""The tile must not quietly average fewer brews than it claims."""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
("12", "Bad brew", "1042", "2026-01-01", False, ""),
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
])
response = logged_in.get("/history")
body = response.get_data(as_text=True)
assert "across 1 of 2 finished brews" in body
def test_the_analysis_prose_counts_the_brews_that_contributed(logged_in, repo):
"""The prose must not claim more brews than actually contributed.
The prose is rendered from the attenuation figures, so it counts the
brews that produced one - not the brews that were merely counted.
"""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
("2", "Good pale", "1042", "2026-02-01", False, ""),
("2", "Good pale", "1012", "2026-02-10", True, ""),
("12", "Bad brew", "1042", "2026-01-01", False, ""),
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
])
response = logged_in.get("/analysis")
body = " ".join(response.get_data(as_text=True).split())
# Three finished brews, two of which contributed an attenuation.
assert "Across 2 of 3 finished brews" in body
def test_the_run_detail_api_carries_the_same_flags(logged_in, repo):
"""A consumer of the detail endpoint can tell an impossible run from a
good one, and an active run from a finished one."""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
("20", "Active brew", "1042", "2026-02-01", False, ""),
])
finished = logged_in.get("/api/runs/1").get_json()
assert finished["suspect"] is False
assert finished["active"] is False
active = logged_in.get("/api/runs/20").get_json()
assert active["suspect"] is False
assert active["active"] is True
+32 -1
View File
@@ -995,6 +995,35 @@ def test_the_advice_is_shown_on_the_active_beers_page(client, repo):
assert "Brewing advice" in body
def test_the_advice_in_the_table_is_a_popout_not_the_text(client, repo):
"""The advice in the table is a trigger, and the text is in a panel.
The advice is several hundred characters. In a table cell it made
the table wider than a phone, so the column was clipped off the
right edge and the advice could not be read.
"""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Warm it to 21C and wait.", ok=True)
body = client.get("/brews").get_data(as_text=True)
# The trigger is a real button, so it works with a keyboard.
assert "advice-trigger" in body
assert 'class="advice-trigger' in body
# The text lives in the panel, not in the cell.
assert "advice-pop" in body
assert "advice-pop__text" in body
# The full advice is in the page - it is just not in the cell.
assert "Warm it to 21C and wait." in body
def test_a_failed_advice_is_marked_as_such(client, repo):
"""A generation failure reads differently from real advice."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
@@ -1008,7 +1037,9 @@ def test_a_failed_advice_is_marked_as_such(client, repo):
response = client.get("/brews")
body = response.get_data(as_text=True)
assert "advice--failed" in body
# A failed generation is marked differently from real advice.
assert "advice-trigger--failed" in body
assert "Advice failed" in body
def test_advice_is_not_generated_in_tests(client, repo):