diff --git a/CLAUDE.md b/CLAUDE.md index 9ad568a..faca2f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ Every one of these has already cost someone an hour: | Poll interval stays at 30 min; 15 min was measured and is marginal | `notes/polling.md` | | `startTime` is immutable and back-dated, so durations measure the outage | `notes/grading.md` § Does startTime drift | | Every measured window ends at the collection horizon, not at the build clock | `notes/grading.md` § What the clock knows | +| A month listed before any of its data exists says **no data yet**, never zeros: decided once by `model.month_watched` and shipped as `D.nodata`, so the county page and the app cannot disagree; nothing is counted or filed in it. It stays listed, because the month list follows the calendar | `notes/design-alignment.md` § A month the data has not reached says "no data yet" (2026-09-24) | | Stored and computed in UTC, shown in Dublin time: months, day cells and gate dates are cut at Dublin midnight (as UTC instants, never Dublin-zoned datetimes), and case records stay UTC with only what is printed converted (`render._local`, `local()` in site.html) | `notes/grading.md` § Months and days are Dublin's (2026-09-24) | | An outage still listed at the last poll is not judged on the charter, and a live fault runs to its last sighting, not to a passed estimate (planned works keep their schedule, issue #49) | `notes/grading.md` § An outage still listed; § A live fault runs to its last sighting (2026-09-24) | | Peak customers means the most off while the outage was live | `notes/grading.md` § The peak is the highest count | diff --git a/esb_site/__main__.py b/esb_site/__main__.py index 4974c89..3bc07cd 100644 --- a/esb_site/__main__.py +++ b/esb_site/__main__.py @@ -14,6 +14,12 @@ DEFAULT_OUT = "out/site" +def parse_now(value): + """UTC when it carries no offset, converted when it does.""" + when = datetime.fromisoformat(value) + return when.replace(tzinfo=UTC) if when.tzinfo is None else when.astimezone(UTC) + + def main(argv=None) -> int: parser = argparse.ArgumentParser( prog="esb_site", description="Build the static ESB outage status site." @@ -25,7 +31,7 @@ def main(argv=None) -> int: parser.add_argument( "--now", default=None, - help="override the build clock, as an ISO UTC timestamp (for reproducible builds)", + help="override the build clock, as an ISO timestamp, UTC unless it carries an offset", ) args = parser.parse_args(argv) @@ -38,11 +44,7 @@ def main(argv=None) -> int: ) return 1 - now = ( - datetime.fromisoformat(args.now).replace(tzinfo=UTC) - if args.now - else datetime.now(UTC) - ) + now = parse_now(args.now) if args.now else datetime.now(UTC) sa_index = model.SmallAreaIndex.load() outages, unplaced, until = model.load_outages(db_path, sa_index, now) diff --git a/esb_site/model.py b/esb_site/model.py index fb91c1e..38e339d 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -924,7 +924,13 @@ def load_outages(db_path, sa_index, now): segments=segments, ) ) - return label_repeats(merge_events(outages)), unplaced, until + # The record starts at the first poll: an event over before it + # overlaps no observed window, so nothing the site derives may count + # it, a repeat chain included. Dropped by the end the merge settles on + # (a sibling lingering a poll past a confirmed restore is the feed + # catching up) and before the chains are labelled. + events = [o for o in merge_events(outages) if o.end > COLLECTION_START] + return label_repeats(events), unplaced, until finally: conn.close() @@ -956,6 +962,21 @@ def observed_window(ym, until): return max(lo, COLLECTION_START), min(hi, until) +def overlaps(o, lo, hi): + """Whether outage `o` falls in the window [lo, hi). An end published past + the horizon passes a bare overlap test against the inverted window of a + month the data has not reached, so an empty window takes nothing.""" + return hi > lo and o.end > lo and o.start < hi + + +def month_watched(ym, until): + """Whether the collected data reaches into month `ym` at all. A build just + after the 1st, or one while the collector is down, lists a month before + any data for it exists, and its window is then empty or inverted.""" + lo, hi = observed_window(ym, until) + return hi > lo + + def days_gate(ym, until): """When month `ym` reaches MIN_GRADED_DAYS, or None once it has. @@ -994,7 +1015,7 @@ def county_month(outages, county, customers, ym, now, until): for o in outages: if o.county != county or not o.start or not o.end: continue - if o.end <= lo or o.start >= hi: + if not overlaps(o, lo, hi): continue cm = o.customer_minutes(lo, hi) if o.planned: diff --git a/esb_site/render.py b/esb_site/render.py index 7a1581d..90197de 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -16,6 +16,7 @@ from collections import defaultdict from datetime import UTC, datetime, timedelta from pathlib import Path +from urllib.parse import quote import statusui @@ -123,13 +124,9 @@ def build(outages, sa_index, now, until): """ months = model.month_list(model.COLLECTION_START, now) - # A county's record starts at the first poll: an outage restored before - # it overlaps no observed window, so the page never lists it and nothing - # derived from the county's list may count it either. by_county = defaultdict(list) for o in outages: - if o.end > model.COLLECTION_START: - by_county[o.county].append(o) + by_county[o.county].append(o) stats, national = {}, {} for county in sa_index.counties: @@ -156,7 +153,7 @@ def build(outages, sa_index, now, until): for ym in months: lo, hi = model.observed_window(ym, until) - live = [o for o in outages if o.start and o.end and o.end > lo and o.start < hi] + live = [o for o in outages if o.start and o.end and model.overlaps(o, lo, hi)] faults = [o for o in live if not o.planned] # Same gate as county_month: an outage still out has no restoration to # judge, and its elapsed time would score as a fast one. @@ -226,8 +223,10 @@ def entries(county, names): # reader's clock rather than the build's. STALE_AFTER travels with it, # so a page served from cache can still go stale. "observed_iso": f"{until:%Y-%m-%dT%H:%M:00Z}", - # the Dublin month the horizon falls in, for the "so far" wording - "observed_month": f"{model.local(until):%Y-%m}", + # the Dublin month the data last reaches, for the "so far" wording; + # a horizon on the stroke of midnight watched none of the new month + "observed_month": f"{model.local(until - timedelta(microseconds=1)):%Y-%m}", + "nodata": [ym for ym in months if not model.month_watched(ym, until)], "stale_hours": round(STALE_AFTER.total_seconds() / 3600), # Two dates at most, and the same for every county, so they sit here # rather than on every month of every county's row. @@ -305,7 +304,7 @@ def shard(outages, months, until): for o in sorted(outages, key=lambda o: o.start, reverse=True): record = None for ym, lo, hi in windows: - if o.end > lo and o.start < hi: + if model.overlaps(o, lo, hi): record = case_record(o) if record is None else record by_month[ym].append(record) return by_month @@ -452,7 +451,7 @@ def _update_line(row, key, planned=False): bits.append(f"{label}") if customers is not None: bits.append( - f"{customers:,} customers" + f"{customers:,} customer{'' if customers == 1 else 's'}" + (" still off" if kind == "update" else "") ) cls = ' class="key"' if key else "" @@ -500,7 +499,9 @@ def _daygate(months, until): Absent means graded on days; "" means a month that can never reach five. """ - gates = ((ym, model.days_gate(ym, until)) for ym in months) + gates = ( + (ym, model.days_gate(ym, until)) for ym in months if model.month_watched(ym, until) + ) return { ym: "" if when >= model.month_bounds(ym)[1] else f"{model.local(when):%-d %B}" for ym, when in gates @@ -514,6 +515,8 @@ def ungraded_reason(ym, faults, until): Three gates withhold it and naming the wrong one sends a reader after outages that are not the reason. Mirrored in site.html (ungradedReason). """ + if not model.month_watched(ym, until): + return f"There is no data yet for {month_label(ym)}" when = model.days_gate(ym, until) if when is not None: # past the month's end: it can never reach five days, so promise no date @@ -580,6 +583,8 @@ def _month_watched(ym, until): months are short, and a row of zeros for three hours of July reads as a quiet month rather than an absent collector. """ + if not model.month_watched(ym, until): + return "no data yet" lo, hi = model.month_bounds(ym) olo, ohi = model.observed_window(ym, until) bits = [] @@ -620,6 +625,13 @@ def _county_months_html(county, data, months, until): for ym in reversed(months): m = data["stats"][county][ym] watched = _month_watched(ym, until) + if not model.month_watched(ym, until): + rows.append( + f'{month_label(ym)}{watched}' + f"{_grade_chip(None, reason=ungraded_reason(ym, 0, until))}" + + "–" * 7 + "" + ) + continue rows.append( f'{month_label(ym)}' + (f'{watched}' if watched else "") @@ -764,7 +776,7 @@ def _spots_html(spots, since): f"
  • {html.escape(loc)}" '' f'{n} faults' - f'up to {peak:,} customers
  • ' + f'up to {peak:,} customer{"" if peak == 1 else "s"}' for loc, n, peak in spots ) return ( @@ -777,6 +789,14 @@ def _spots_html(spots, since): ) +def _cell(text): + """A text cell, defused: one opening with a formula character, even + behind spaces a spreadsheet trims, runs as a formula when opened.""" + if text and (text[0] in "\t\r" or text.lstrip()[:1] in ("=", "+", "-", "@")): + return "'" + text + return text + + def county_csv(outages): """One county's merged events as CSV, oldest first; the columns are CSV_COLUMNS. The site's own rows, so a reader gets what the page counts @@ -786,8 +806,8 @@ def county_csv(outages): out.writerow(CSV_COLUMNS) for o in sorted(outages, key=lambda o: (o.start, int(o.id))): out.writerow([ - " ".join(o.ids), o.county, o.town, o.esb_location, - "planned" if o.planned else "fault", model.reason_label(o.reason), + " ".join(o.ids), o.county, _cell(o.town), _cell(o.esb_location), + "planned" if o.planned else "fault", _cell(model.reason_label(o.reason)), o.customers, model.fmt_utc(o.start), model.fmt_utc(o.end), o.end_src, model.fmt_utc(o.est), model.fmt_utc(o.first_est), int(o.ongoing), round(o.customer_minutes(o.start, o.end)), @@ -917,7 +937,7 @@ def area_page(county, name, pop, events, nearby, data): faults = sum(1 for o in events if not o.planned) planned = len(events) - faults near = "".join( - f'
  • {html.escape(n)}' + f'
  • {html.escape(n)}' '' f'{_km_label(d)}' f'{"" if c == county else f"County {html.escape(c)}"}
  • ' @@ -952,7 +972,7 @@ def area_page(county, name, pop, events, nearby, data): '

    Elsewhere

    " ) # the record first, what the page holds last - truncation must not turn diff --git a/esb_site/site.html b/esb_site/site.html index 1d98393..d244384 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -267,6 +267,7 @@

    // counties' fault counts and so can use none of them. "" once the month has // five days. function dayGateReason(ym) { + if (noDataYet(ym)) return "There is no data yet for " + monthLabelLong(ym); var from = D.daygate && D.daygate[ym]; if (from === undefined) return ""; if (!from) return "Only part of " + monthLabelLong(ym) + " was watched, so it is not graded"; @@ -328,15 +329,16 @@

    }); rows.sort(function (a, b) { return a.name.localeCompare(b.name); }); + var none = noDataYet(curMonth); document.getElementById("list").innerHTML = rows.map(function (r) { var m = r.m, g = m ? m[1] : null; - return '
    ' + + return '
    ' + '
    ' + gradeChip(g, m ? m[4] : 0) + '' + esc(r.name) + "
    " + '
    ' + bar(m ? m[0] : "", curMonth) + "
    " + '
    ' + - "" + (m ? m[4] : 0) + "faults" + - "" + (m ? m[5] : 0) + "planned
    " + + "" + (none ? "–" : m ? m[4] : 0) + "faults" + + "" + (none ? "–" : m ? m[5] : 0) + "planned
    " + '
    ' + (m && m[2] != null ? m[2] + "%" : "–") + "
    restored in 4h
    " + '
    ›' + @@ -345,12 +347,14 @@

    var n = D.national[curMonth] || [0, 0, 0, 0, 0, null, null, 0]; var within = n[5]; - // "so far" only while the viewed month is still collecting - var partial = D.observed_month === curMonth; + // "so far" only while the viewed month is still collecting: the newest + // listed month, and one the data has reached + var partial = curMonth === D.months[D.months.length - 1] && !noDataYet(curMonth) && + D.observed_month === curMonth; var ungraded = ungradedNote(rows, curMonth); document.getElementById("natline").innerHTML = "" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ": " + - plural(n[1], "fault") + " and " + plural(n[2], "planned outage"); + (none ? "no data yet" : plural(n[1], "fault") + " and " + plural(n[2], "planned outage")); document.getElementById("basis").innerHTML = "Counties are graded on ESB Networks' own published aim: supply restored within " + @@ -377,13 +381,16 @@

    // the footer, and it already means faults alone. [fmtCml(n[0]), "customer minutes lost"] ].map(function (t) { - return '
    ' + esc(t[0]) + '
    ' + esc(t[1]) + "
    "; + return '
    ' + esc(none ? "–" : t[0]) + '
    ' + esc(t[1]) + "
    "; }).join(""); document.getElementById("legend").innerHTML = legendHtml(); } /* --- county -------------------------------------------------------------- */ +// A data.js cached from before `nodata` existed falls back to the month order. +function noDataYet(ym) { return D.nodata ? D.nodata.indexOf(ym) >= 0 : ym > D.observed_month; } + function renderCounty() { var el = document.getElementById("countyview"), c = curCounty; var m = (D.stats[c] || {})[curMonth]; @@ -418,15 +425,16 @@

    [m ? m[5] : 0, "planned outages"], [m ? num(m[6]) : 0, "customers hit by faults"] ].map(function (t) { - return '
    ' + esc(t[0]) + '
    ' + esc(t[1]) + "
    "; + return '
    ' + esc(noDataYet(curMonth) ? "–" : t[0]) + '
    ' + esc(t[1]) + "
    "; }).join("") + "
    "; var body; if (loading) body = '
    Loading outages…
    '; else if (failed) body = '
    Could not load the outages for ' + esc(c) + ". Try reloading the page.
    "; - else if (!cases.length) body = '
    No outages recorded in ' + - esc(c) + " in " + monthLabel(curMonth) + ".
    "; + else if (!cases.length) body = '
    ' + (noDataYet(curMonth) + ? "No data yet for " + monthLabelLong(curMonth) + : "No outages recorded in " + esc(c) + " in " + monthLabel(curMonth)) + ".
    "; else body = '
    ' + cases.map(caseHtml).join("") + "
    "; el.innerHTML = head + summary + body; @@ -469,7 +477,7 @@

    var bits = []; var label = (planned && PLANNED_ROW_LABEL[r[0]]) || ROW_LABEL[r[0]]; if (label) bits.push("" + label + ""); - if (r[2] != null) bits.push(num(r[2]) + " customers" + (r[0] === "update" ? " still off" : "")); + if (r[2] != null) bits.push(plural(r[2], "customer") + (r[0] === "update" ? " still off" : "")); return '
  • " + bits.join(" · ") + "
  • "; } @@ -508,7 +516,8 @@

    function route() { var m = location.hash.match(/^#county\/(.+)$/); - curCounty = m ? decodeURIComponent(m[1]) : null; + // a hand-edited or truncated link falls back to the overview, not a throw + try { curCounty = m ? decodeURIComponent(m[1]) : null; } catch (e) { curCounty = null; } if (curCounty && !D.stats[curCounty]) curCounty = null; if (curCounty) loadCounty(curCounty, render); render(); @@ -570,6 +579,12 @@

    bindDayCaption(); bindMonthReveal(); addEventListener("hashchange", route); + // The name travels in an attribute, not in an inline handler's JS string, + // where HTML escaping is undone before the script sees it. + document.getElementById("list").addEventListener("click", function (e) { + var row = e.target.closest(".row[data-county]"); + if (row) go(row.getAttribute("data-county")); + }); route(); } boot(); diff --git a/notes/design-alignment.md b/notes/design-alignment.md index 7ad7e4e..e5eed04 100644 --- a/notes/design-alignment.md +++ b/notes/design-alignment.md @@ -618,3 +618,28 @@ page and that is the unit a reader arrives at; 534 KB in 26 files, listed in the size report as "on request" and outside the budget. Written for every county, an empty one included, so the link cannot 404. Not in the sitemap: a CSV is not a page. + +## A month the data has not reached says "no data yet" - 2026-09-24 + +A build that runs just after Dublin midnight on the 1st, from data that stops +the night before, lists the new month with nothing in it. The county page's +month table printed the previous month's last day under it ("October 2026 / +to 30 Sep"), and the app headline read "October 2026: 0 faults and 0 planned +outages" with no "so far", as though the month had come and gone quiet. +Pushes land every six hours, so this is a few hours at the start of every +month, and longer whenever the collector is down across one. + +Owner's call: say **no data yet**. Whether a month is watched at all is +decided once, `model.month_watched`, and shipped as `D.nodata`: a first cut +let the app work it out again from `observed_month`, and the two disagreed on +a horizon landing exactly on Dublin midnight. The month table's caveat reads +"no data yet" and its figures are dashes; the gate sentence says "There is no +data yet for October 2026" rather than promising a grade date; the app puts it +in the headline, shows "–" in the tiles and the county rows' counts, and says +so where the county view would list outages. Nothing is counted or filed in +such a month, including an outage whose published restore time runs past the +horizon. Not covered: a whole month the collector missed reads as watched +once data resumes after it, the in-window gap grading.md already names. Rejected: +leaving the month out until data arrives, which would make the month list +depend on the collector rather than the calendar; `month_list` walks the +calendar on purpose, so a month is listed from its first instant. diff --git a/tests/test_site_areas.py b/tests/test_site_areas.py index 46a5d5e..19af48b 100644 --- a/tests/test_site_areas.py +++ b/tests/test_site_areas.py @@ -31,6 +31,31 @@ SLIGO_TOWN = {"c": "54.2697,-8.4771"} # Sligo, "Sligo": the town named for its county +class TestBeforeCollection(SiteModelCase): + def test_an_outage_over_before_the_first_poll_is_counted_nowhere(self): + # restored at 18:00 on 31 July and still in the feed at the first poll + self.observe( + detail("1", location="Rathfarnham", outageType="Restored", + startTime="31/07/2026 15:00", restoreTime="31/07/2026 18:00"), + datetime(2026, 7, 31, 21, 5, tzinfo=UTC), + ) + # and a repeat at the same spot after it, which must not read as leg 2 + self.observe( + detail("2", location="Rathfarnham", outageType="Restored", + startTime="31/07/2026 18:05", restoreTime="31/07/2026 23:00"), + datetime(2026, 7, 31, 21, 5, tzinfo=UTC), + ) + now = datetime(2026, 8, 20, tzinfo=UTC) + self.poll(now) + outages, _, index = self.load(now) + self.assertEqual([o.id for o in outages], ["2"]) + self.assertEqual(outages[0].chain, ()) + self.assertEqual([c for c, _ in render.area_index(outages, index)], ["Dublin"]) + self.assertEqual( + [len(events) for _, areas in render.area_index(outages, index) + for _, _, _, events in areas], [1]) + + class TestWhichAreasGetOne(unittest.TestCase): def test_a_named_place_gets_a_page(self): for code in ("19848", "01626", "02341-Cabra-Glasnevin", "04345"): @@ -277,7 +302,7 @@ def test_it_points_at_its_nearest_neighbours_with_distances(self): """The disclaimer made actionable: the reader's one-click check of where else their outage may have been filed.""" page = self.page("a/dublin/skerries.html") - self.assertIn('href="../dublin/cabra-glasnevin.html"', page) + self.assertIn('href="../../a/dublin/cabra-glasnevin.html"', page) self.assertRegex(page, r'\d+ km') def test_the_description_is_the_record_first(self): diff --git a/tests/test_site_county.py b/tests/test_site_county.py index bd169c1..cfa4314 100644 --- a/tests/test_site_county.py +++ b/tests/test_site_county.py @@ -359,6 +359,21 @@ def test_one_row_per_merged_event_with_every_id(self): self.assertEqual(rows[1]["location"], "Marino") self.assertEqual(tuple(rows[0]), render.CSV_COLUMNS) + def test_a_location_that_reads_as_a_formula_is_published_as_text(self): + self.observe(detail("1", location='=HYPERLINK("http://x","Ballina")', + outageType="Planned", plannedOutageReason=" @SUM(1)"), + datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) + outages, _, _ = self.load(datetime(2026, 8, 20, tzinfo=UTC)) + row = next(csv.DictReader(io.StringIO(render.county_csv(outages)))) + self.assertEqual(row["location"], '\'=HYPERLINK("http://x","Ballina")') + # an unmapped reason, behind a space a spreadsheet would trim + self.assertTrue(row["planned_reason"].startswith("'"), row["planned_reason"]) + + def test_a_formula_behind_whitespace_is_defused_too(self): + for text in (" =1+1", "\n=1+1", "\t@x"): + self.assertTrue(render._cell(text).startswith("'"), repr(text)) + self.assertEqual(render._cell("Ballina"), "Ballina") + def test_the_page_links_its_csv(self): self.observe(detail("1"), datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) self.poll(datetime(2026, 9, 1, tzinfo=UTC), n_listed=1) diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 500157e..0e3630b 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1556,10 +1556,49 @@ def test_a_timeline_row_prints_dublins_time(self): line = render._update_line(["update", "2026-08-24T14:15", 40], False) self.assertIn("", line) + def test_a_fault_spot_of_one_customer_is_singular(self): + card = render._spots_html([("Skerries Road", 2, 1)], "31 July 2026") + self.assertIn("up to 1 customer<", card) + + def test_one_customer_still_off_is_singular(self): + line = render._update_line(["update", "2026-08-24T14:15", 1], False) + self.assertIn("1 customer still off", line) + def test_the_caveat_names_dublins_day(self): until = datetime(2026, 9, 9, 23, 30, tzinfo=UTC) self.assertEqual(render._month_watched("2026-09", until), "to 10 Sep") + def test_a_month_the_data_has_not_reached_says_so(self): + # built at 00:20 on 1 October in Dublin, from data to 23:45 the night before + until = datetime(2026, 9, 30, 22, 45, tzinfo=UTC) + self.assertEqual(render._month_watched("2026-10", until), "no data yet") + self.assertEqual(render._month_watched("2026-09", until), "to 30 Sep") + + def test_the_app_reads_no_data_yet_from_the_payload_everywhere(self): + page = (Path(model.__file__).parent / "site.html").read_text() + self.assertIn("function noDataYet(ym) { return D.nodata ? D.nodata.indexOf(ym) >= 0", page) + # "so far" belongs to the newest listed month only, and only once data reaches it + self.assertIn("var partial = curMonth === D.months[D.months.length - 1] && " + "!noDataYet(curMonth)", page) + for surface in ( + '(none ? "no data yet" :', # national headline + 'esc(none ? "–" : t[0])', # national tiles + '(none ? "–" : m ? m[4] : 0)', # county rows + 'esc(noDataYet(curMonth) ? "–" : t[0])', # county tiles + '"No data yet for " + monthLabelLong(curMonth)', # county list + 'if (noDataYet(ym)) return "There is no data yet for "', # gate sentence + ): + self.assertIn(surface, page) + + def test_a_horizon_on_the_stroke_of_midnight_has_watched_none_of_the_month(self): + until = datetime(2026, 9, 30, 23, 0, tzinfo=UTC) # 00:00 on 1 October in Dublin + data = render.build([], model.SmallAreaIndex.load(), until, until)[0] + self.assertEqual((data["observed_month"], data["nodata"]), ("2026-09", ["2026-10"])) + self.assertEqual(render._month_watched("2026-10", until), "no data yet") + self.assertEqual( + render.ungraded_reason("2026-10", 0, until), "There is no data yet for October 2026" + ) + def test_the_horizon_is_shown_and_filed_in_dublin(self): until = datetime(2026, 8, 31, 23, 30, tzinfo=UTC) data = render.build([], model.SmallAreaIndex.load(), until, until)[0] @@ -1578,3 +1617,68 @@ def body(name): self.assertIn("when(local(r[1]))", body("updateLine")) self.assertIn('timeZone: "Europe/Dublin"', page) self.assertIn("D.observed_month === curMonth", page) + + +class TestAMonthTheDataHasNotReached(SiteModelCase): + def test_a_restore_published_past_the_horizon_is_not_counted_in_it(self): + # listed at 22:45 UTC on 30 Sep, with ESB naming a restore at 00:30 on + # 1 October, which is after the data stops + seen = datetime(2026, 9, 30, 22, 45, tzinfo=UTC) + self.observe(detail("1", outageType="Restored", startTime="30/09/2026 21:00", + restoreTime="01/10/2026 00:30"), seen) + self.poll(seen) + outages, _, index = self.load(datetime(2026, 10, 1, 0, 20, tzinfo=UTC)) + now = datetime(2026, 10, 1, 0, 20, tzinfo=UTC) + s = model.county_month(outages, "Dublin", index.customers["Dublin"], "2026-10", + now, self.until) + self.assertEqual((s["faults"], s["planned"]), (0, 0)) + data = render.build(outages, index, now, self.until)[0] + self.assertEqual(data["national"]["2026-10"][1:3], [0, 0]) + self.assertNotIn("2026-10", render.shard(outages, ["2026-09", "2026-10"], self.until)) + + +class TestTheCountyTableForAMonthWithNoData(unittest.TestCase): + def test_the_row_has_a_cell_per_column_and_shows_no_zeros(self): + import re + + until = datetime(2026, 9, 30, 22, 45, tzinfo=UTC) + now = datetime(2026, 9, 30, 23, 20, tzinfo=UTC) # 00:20 on 1 October + index = model.SmallAreaIndex.load() + data, _, months, _ = render.build([], index, now, until) + table = render._county_months_html("Dublin", data, months, until) + heads = len(re.findall(r'(.*?)", table.split("")[1]) + october = next(r for r in rows if "October 2026" in r) + for row in rows: + self.assertEqual(row.count("0<", october) + self.assertNotIn("2026-10", data["daygate"]) + + +class TestTheBuildClock(unittest.TestCase): + def test_an_offset_is_converted_not_dropped(self): + from esb_site.__main__ import parse_now + + expect = datetime(2026, 9, 24, 9, 0, tzinfo=UTC) + self.assertEqual(parse_now("2026-09-24T10:00:00+01:00"), expect) + self.assertEqual(parse_now("2026-09-24T09:00:00Z"), expect) + self.assertEqual(parse_now("2026-09-24T09:00:00"), expect) + + +class TestTheAppScript(unittest.TestCase): + """CI runs no JS, so what matters about these call sites is held as text.""" + + page = (Path(model.__file__).parent / "site.html").read_text() + + def test_no_name_is_spliced_into_an_inline_handler(self): + self.assertNotIn("onclick=\"go(\\''", self.page) + self.assertIn('data-county="\' + esc(r.name)', self.page) + # and something still listens, or no row opens its county + self.assertIn('getElementById("list").addEventListener("click"', self.page) + self.assertIn('go(row.getAttribute("data-county"))', self.page) + + def test_a_malformed_link_cannot_throw_out_of_route(self): + route = self.page.split("function route(", 1)[1].split("\nfunction ", 1)[0] + self.assertIn("try { curCounty = m ? decodeURIComponent(m[1])", route) diff --git a/tests/test_site_national.py b/tests/test_site_national.py index 61e72a4..84f6845 100644 --- a/tests/test_site_national.py +++ b/tests/test_site_national.py @@ -253,7 +253,8 @@ def test_the_payload_carries_no_per_outage_records(self): self.assertEqual( set(self.data), { - "generated", "observed", "observed_iso", "observed_month", "stale_hours", + "generated", "observed", "observed_iso", "observed_month", "nodata", + "stale_hours", "partial", "daygate", "compare", "start", "months", "esb", "counties", "customers", "stats", "national",