@@ -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" )
0 commit comments