Skip to content

Commit 23d5aea

Browse files
fix(annual): fix wave 1 from final review — selector, compare gaps, corrupt years
- Point the run selector's form at ./annual_view instead of ./annual: since the page split, ./annual is the Configure form and drops the ?run= parameter entirely, so switching runs from the viewer's dropdown silently stranded the visitor on the config page with the results gone (critical). - Guard payback "years" in the compare table's per-cell renderer against non-numeric values, rendering a dash instead of letting "{:.1f}".format(years) raise and 500 the whole compare page for every run. - Add coverage: the selector's form action, unknown Solar/Battery/Cost/Saving compare figures rendering as a dash (not 0/n-a), a non-numeric payback "years" value, and html_annual_view actually rendering a stored run's own totals, selector and plan viewer round-tripped through real storage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 824c24e commit 23d5aea

2 files changed

Lines changed: 141 additions & 6 deletions

File tree

apps/predbat/tests/test_web_annual.py

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,19 @@ def test_web_annual_results(my_predbat):
12261226
print(" ERROR: the selected run should be marked in the dropdown")
12271227
failed = True
12281228

1229+
print("Test: the selector submits to the viewer, not the configure page")
1230+
# Before the page split, ./annual rendered the results and read ?run=; now it is
1231+
# the configuration form and ignores the parameter entirely. A selector that still
1232+
# posts to ./annual strands the visitor on the config page with the run dropped -
1233+
# the critical regression this test exists to catch.
1234+
selector_html = page._render_selector(runs, runs[0]["id"])
1235+
if 'action="./annual_view"' not in selector_html:
1236+
print(" ERROR: the run selector should submit to ./annual_view, got {!r}".format(selector_html))
1237+
failed = True
1238+
if 'action="./annual"' in selector_html:
1239+
print(" ERROR: the run selector must not submit to ./annual (the configure page), got {!r}".format(selector_html))
1240+
failed = True
1241+
12291242
print("Test: a download link is offered for the selected run")
12301243
if "annual_download?run=20260726-101500" not in html:
12311244
print(" ERROR: the selected run should be downloadable as JSON")
@@ -1363,6 +1376,46 @@ def test_web_annual_pages(my_predbat):
13631376
print(" ERROR: the viewer page must not render the configuration form")
13641377
failed = True
13651378

1379+
print("Test: the viewer renders a stored run's own figures, selector and plan viewer")
1380+
# The negative assertion above (no form) would still pass if html_annual_view
1381+
# stopped calling render_results altogether - that is precisely the hole finding
1382+
# #1 (the selector navigating to the wrong page) fell through: nothing drove the
1383+
# viewer against a real stored run. Round-tripped through save_run/load_run/
1384+
# list_runs, not a hand-built dict - a hand-built dict would stay green even if
1385+
# the viewer read plans straight off the document instead of the index's
1386+
# plan_index, a mistake already made twice on this branch.
1387+
view_storage = RaceStorage()
1388+
page._storage = lambda: view_storage
1389+
stored_view_results = copy.deepcopy(sample_run_results())
1390+
stored_view_results["months"][0]["plans"] = [{"day": "2025-01-15", "leg": "single", "scenarios": {"with_predbat": {"rows": [], "soc_max": 9.5}}}]
1391+
asyncio.run(save_run(view_storage, stored_view_results, {}, "20260728-view-check"))
1392+
stored_view_html = asyncio.run(page.html_annual_view(FakeRequest(query={"run": "20260728-view-check"}))).text
1393+
page._storage = lambda: None # restore the default so later tests in this function are unaffected
1394+
if "Annual totals for" not in stored_view_html:
1395+
print(" ERROR: the viewer should render the stored run's own totals heading")
1396+
failed = True
1397+
if "90.00" not in stored_view_html:
1398+
print(" ERROR: the viewer should render the stored run's own PV/battery saving (9000p = £90.00)")
1399+
failed = True
1400+
if "24.00" not in stored_view_html:
1401+
print(" ERROR: the viewer should render the stored run's own Predbat saving (2400p = £24.00)")
1402+
failed = True
1403+
if "annual-selector" not in stored_view_html:
1404+
print(" ERROR: the viewer should show the run selector for a stored run")
1405+
failed = True
1406+
if "20260728-view-check" not in stored_view_html:
1407+
print(" ERROR: the viewer should identify the selected run in the selector")
1408+
failed = True
1409+
if "annual-plan-viewer" not in stored_view_html:
1410+
print(" ERROR: a stored run with a plan_index should still render the plan viewer")
1411+
failed = True
1412+
if "2025-01-15" not in stored_view_html:
1413+
print(" ERROR: the plan viewer's day option should come from the stored run's plan_index")
1414+
failed = True
1415+
if "renderPlanTable" not in stored_view_html:
1416+
print(" ERROR: the plan viewer must use the existing plan renderer")
1417+
failed = True
1418+
13661419
print("Test: the nav marks the current page and disables the end arrows")
13671420
nav = page.render_nav("config")
13681421
if "annual-nav-current" not in nav:
@@ -1395,7 +1448,11 @@ def test_web_annual_pages(my_predbat):
13951448
runs = [
13961449
{
13971450
"id": "20260728-0900",
1398-
"label": "9.5 kWh battery, 5.6 kWp, Agile",
1451+
# Deliberately does not repeat "5.6"/"9.5" in the label: earlier versions of
1452+
# this test used a label like "9.5 kWh battery, 5.6 kWp, Agile", which meant
1453+
# the Solar/Battery assertions below were satisfied by the label text alone -
1454+
# deleting the Solar/Battery <td> cells outright would still have passed.
1455+
"label": "System Alpha, Agile",
13991456
"summary": {
14001457
"total_kwp": 5.6,
14011458
"battery_kwh": 9.5,
@@ -1409,7 +1466,7 @@ def test_web_annual_pages(my_predbat):
14091466
},
14101467
{
14111468
"id": "20260728-0800",
1412-
"label": "20 kWh battery, 12 kWp, Cosy",
1469+
"label": "System Beta, Cosy",
14131470
"summary": {
14141471
"total_kwp": 12.0,
14151472
"battery_kwh": 20.0,
@@ -1423,7 +1480,7 @@ def test_web_annual_pages(my_predbat):
14231480
},
14241481
]
14251482
table = page.render_compare(runs, "20260728-0900")
1426-
for expected in ["5.6", "9.5", "Agile", "13.6", "12", "20", "Cosy", "8.2"]:
1483+
for expected in ["5.6 kWp", "9.5 kWh", "Agile", "13.6", "12 kWp", "20 kWh", "Cosy", "8.2"]:
14271484
if expected not in table:
14281485
print(" ERROR: the compare table should show {}, got {}".format(expected, table))
14291486
failed = True
@@ -1442,8 +1499,8 @@ def test_web_annual_pages(my_predbat):
14421499
failed = True
14431500
else:
14441501
row_expectations = [
1445-
("5.6 kWp, Agile", ["5.6", "9.5", "Agile", "13.6"], ["Cosy", "8.2", "12 kWp"]),
1446-
("12 kWp, Cosy", ["12", "20", "Cosy", "8.2"], ["Agile", "13.6", "5.6 kWp"]),
1502+
("System Alpha, Agile", ["5.6 kWp", "9.5 kWh", "Agile", "13.6"], ["Cosy", "8.2", "12 kWp", "20 kWh"]),
1503+
("System Beta, Cosy", ["12 kWp", "20 kWh", "Cosy", "8.2"], ["Agile", "13.6", "5.6 kWp", "9.5 kWh"]),
14471504
]
14481505
for row, (label_fragment, must_contain, must_not_contain) in zip(data_rows, row_expectations):
14491506
if label_fragment not in row:
@@ -1472,6 +1529,40 @@ def test_web_annual_pages(my_predbat):
14721529
print(" ERROR: saving_vs_none_p=140000.0p should render as £1400.00 on the Cosy row, got {}".format(data_rows[1]))
14731530
failed = True
14741531

1532+
print("Test: unknown Solar/Battery/Cost/Saving figures render as a dash, never as 0 or n/a")
1533+
# _compare_number and _compare_money exist solely to turn a None summary figure
1534+
# into "-" rather than "0 kWp"/"n/a" - a None kWp reading as "a system with no
1535+
# panels" or a None saving reading as "n/a" are both worse than an honest "unknown".
1536+
# No other fixture in this file supplies None for these four fields, so replacing
1537+
# either helper with a plain formatter would have stayed green.
1538+
unknown_run = [
1539+
{
1540+
"id": "unknown-figures",
1541+
"label": "System Gamma, unknown figures",
1542+
"summary": {
1543+
"total_kwp": None,
1544+
"battery_kwh": None,
1545+
"tariff": "Agile",
1546+
"cost_with_predbat_p": None,
1547+
"saving_vs_none_p": None,
1548+
"payback_years": {"pv_only": 5.0, "pv_battery": 4.0, "pv_battery_predbat": 3.0},
1549+
"payback_reason": None,
1550+
"months_included": 12,
1551+
},
1552+
}
1553+
]
1554+
unknown_row = [row for row in re.findall(r"<tr[^>]*>.*?</tr>", page.render_compare(unknown_run, "unknown-figures"), re.S) if "<th>" not in row][0]
1555+
unknown_cells = re.findall(r"<td[^>]*>(.*?)</td>", unknown_row, re.S)
1556+
# Cell order: label, Solar, Battery, Tariff, Cost, Saving, then three payback cells.
1557+
for index, field in [(1, "total_kwp"), (2, "battery_kwh"), (4, "cost_with_predbat_p"), (5, "saving_vs_none_p")]:
1558+
cell = unknown_cells[index].strip()
1559+
if cell != "—":
1560+
print(" ERROR: an unknown {} should render as a dash, got {!r} in cell {}".format(field, cell, unknown_cells))
1561+
failed = True
1562+
if cell in ("0", "0 kWp", "0 kWh", "£0.00", "n/a"):
1563+
print(" ERROR: an unknown {} must not render as zero/n-a, got {!r}".format(field, cell))
1564+
failed = True
1565+
14751566
print("Test: a run whose payback was unavailable shows a dash and its reason, not a number")
14761567
unavailable = [
14771568
{
@@ -1518,6 +1609,44 @@ def test_web_annual_pages(my_predbat):
15181609
print(" ERROR: a non-paying-back run should say so")
15191610
failed = True
15201611

1612+
print("Test: a non-numeric payback 'years' renders as a dash instead of 500ing the whole compare page")
1613+
# build_summary type-guards cost_p but passes years through unchecked (finding #4);
1614+
# a hand-edited or corrupted stored document with years as a string must not raise
1615+
# out of "{:.1f} years".format(years) at render time - backfill_summaries' own
1616+
# try/except cannot catch a render-time raise, so one bad document would take down
1617+
# /annual_compare for every run.
1618+
corrupt_years = [
1619+
{
1620+
"id": "z",
1621+
"label": "corrupt",
1622+
"summary": {
1623+
"total_kwp": 5.0,
1624+
"battery_kwh": 9.0,
1625+
"tariff": "Agile",
1626+
"cost_with_predbat_p": 100.0,
1627+
"saving_vs_none_p": 50.0,
1628+
"payback_years": {"pv_only": "17.8", "pv_battery": None, "pv_battery_predbat": 9.0},
1629+
"payback_reason": None,
1630+
"months_included": 12,
1631+
},
1632+
}
1633+
]
1634+
try:
1635+
corrupt_html = page.render_compare(corrupt_years, "z")
1636+
except ValueError as error:
1637+
print(" ERROR: a non-numeric payback 'years' must not raise, got {}".format(error))
1638+
failed = True
1639+
else:
1640+
corrupt_row = [row for row in re.findall(r"<tr[^>]*>.*?</tr>", corrupt_html, re.S) if "<th>" not in row][0]
1641+
corrupt_cells = re.findall(r"<td[^>]*>(.*?)</td>", corrupt_row, re.S)
1642+
# Cell order: label, Solar, Battery, Tariff, Cost, Saving, pv_only, pv_battery, pv_battery_predbat.
1643+
if corrupt_cells[6].strip() != "—":
1644+
print(" ERROR: a non-numeric pv_only years should render as a dash, got {!r}".format(corrupt_cells[6]))
1645+
failed = True
1646+
if "17.8" in corrupt_cells[6]:
1647+
print(" ERROR: a non-numeric years must not be formatted as though it were a float, got {!r}".format(corrupt_cells[6]))
1648+
failed = True
1649+
15211650
print("Test: the compare table is horizontally scrollable rather than widening the page")
15221651
if "overflow-x" not in page.render_css():
15231652
print(" ERROR: a nine-column table needs its own scroll container")

apps/predbat/web_annual.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,7 @@ def _render_selector(self, runs, selected_id):
984984
"""Return the run selector, or nothing when there are no stored runs."""
985985
if not runs:
986986
return ""
987-
text = '<form action="./annual" method="get" class="annual-selector"><label for="run">Run</label><select id="run" name="run" onchange="this.form.submit()">\n'
987+
text = '<form action="./annual_view" method="get" class="annual-selector"><label for="run">Run</label><select id="run" name="run" onchange="this.form.submit()">\n'
988988
for run in runs:
989989
run_id = html.escape(str(run["id"]), quote=True)
990990
label = html.escape(str(run.get("label", run["id"])), quote=True)
@@ -1324,6 +1324,12 @@ def _render_payback_cell(payback_years, payback_reason, key):
13241324
years = payback_years.get(key)
13251325
if years is None:
13261326
return '<td class="annual-unavailable">does not pay back</td>\n'
1327+
# A non-numeric years figure (a corrupt stored document, say) is unknown, not
1328+
# "never pays back" - render the same dash used for a value that was never
1329+
# computed, rather than letting "{:.1f}".format() raise and 500 the whole
1330+
# compare page.
1331+
if not isinstance(years, (int, float)):
1332+
return '<td class="annual-unavailable">—</td>\n'
13271333
return "<td>{}</td>\n".format(html.escape("{:.1f} years".format(years), quote=True))
13281334

13291335
def render_nav(self, current):

0 commit comments

Comments
 (0)