-
Breast - Waiting Times
-
-
Mean wait for treatment4 weeks
-
+
+
+
Cardiology
+
Typical wait8 weeks
+
Most seen within16 weeks
-
+
+
+
Breast Surgery
+
Typical wait5 weeks
+
+
+
-
+
\ No newline at end of file
diff --git a/tests/fixtures/trust_page_royal_berkshire.html b/tests/fixtures/trust_page_royal_berkshire.html
index 7fb53e2..bf6e7af 100644
--- a/tests/fixtures/trust_page_royal_berkshire.html
+++ b/tests/fixtures/trust_page_royal_berkshire.html
@@ -1,88 +1,53 @@
-
-
Royal Berkshire Hospital NHS Foundation Trust - My Planned Care NHS
+
+
+
Royal Berkshire Hospital NHS Foundation Trust
-
-
-
-
-
-
-
Please select the specialty you have been referred to.
+
+ Royal Berkshire Hospital NHS Foundation Trust
+ Please select the specialty you have been referred to or are under the
+ care of from the list below.
-
-
-
Breast - Waiting Times
-
- First Outpatient Appointment
- | Average waiting time for first outpatient appointment at this hospital for this specialty | n/a |
- | 8 in 10 patients will be seen for a first outpatient appointment at this hospital for this specialty within | n/a |
-
-
- Treatment
- | Average waiting time for treatment at this hospital for this specialty | 4 weeks |
- | 8 in 10 patients will be seen for treatment at this hospital for this specialty within | 7 weeks |
-
-
-
-
-
-
- - The waiting time information is updated each week.
- - This page was last updated on 7 August 2026.
-
-
-
-
-
-
- Breast
-
+
+ Breast Surgery
+ First Outpatient Appointment
+
+ | Average waiting time | 8 in 10 patients seen within |
+ | 2 weeks | 5 weeks |
+
+ Treatment
+
+ | Average waiting time | 8 in 10 patients seen within |
+ | 5 weeks | 13 weeks |
+
+
-
-
-
Cardiology - Waiting Times
-
- First Outpatient Appointment
- | Average waiting time for first outpatient appointment at this hospital for this specialty | n/a |
- | 8 in 10 patients will be seen for a first outpatient appointment at this hospital for this specialty within | n/a |
-
-
- Treatment
- | Average waiting time for treatment at this hospital for this specialty | 8 weeks |
- | 8 in 10 patients will be seen for treatment at this hospital for this specialty within | 14 weeks |
-
-
-
-
-
-
- - The waiting time information is updated each week.
- - This page was last updated on 7 August 2026.
-
-
-
-
-
-
- Cardiology
-
+
+ Cardiology
+ First Outpatient Appointment
+
+ | Average waiting time | 8 in 10 patients seen within |
+ | 3 weeks | 8 weeks |
+
+ Treatment
+
+ | Average waiting time | 8 in 10 patients seen within |
+ | 8 weeks | 16 weeks |
+
+
-
-
-
Paediatric Surgery - Waiting Times
-
This information is currently unavailable for this Provider's Specialty. Please check back later.
-
-
-
- Paediatric Surgery
-
-
-
-
+
+ Paediatric Surgery
+ The waiting time information for this specialty at this hospital is
+ currently unavailable.
+
+
+
-
-
+
\ No newline at end of file
diff --git a/tests/fixtures/trust_page_royal_berkshire_2026.html b/tests/fixtures/trust_page_royal_berkshire_2026.html
new file mode 100644
index 0000000..7b0ee69
--- /dev/null
+++ b/tests/fixtures/trust_page_royal_berkshire_2026.html
@@ -0,0 +1,82 @@
+
+
+
+
Royal Berkshire Hospital NHS Foundation Trust - My Planned Care NHS
+
+
+
+
+
+
+
+
+
+
Breast - Waiting Times
+
+ First Outpatient Appointment
+ | Average waiting time for first outpatient appointment at this hospital for this specialty | n/a |
+ | 8 in 10 patients will be seen for a first outpatient appointment at this hospital for this specialty within | n/a |
+
+
+ Treatment
+ | Average waiting time for treatment at this hospital for this specialty | 4 weeks |
+ | 8 in 10 patients will be seen for treatment at this hospital for this specialty within | 7 weeks |
+
+
+
+
+
+
+ - The waiting time information is updated each week.
+ - This page was last updated on 7 August 2026.
+
+
+
+
+
+
+
+
+
Cardiology - Waiting Times
+
+ First Outpatient Appointment
+ | Average waiting time for first outpatient appointment at this hospital for this specialty | n/a |
+ | 8 in 10 patients will be seen for a first outpatient appointment at this hospital for this specialty within | n/a |
+
+
+ Treatment
+ | Average waiting time for treatment at this hospital for this specialty | 8 weeks |
+ | 8 in 10 patients will be seen for treatment at this hospital for this specialty within | 14 weeks |
+
+
+
+
+
+
+ - The waiting time information is updated each week.
+ - This page was last updated on 7 August 2026.
+
+
+
+
+
+
+
+
+
Paediatric Surgery - Waiting Times
+
This information is currently unavailable for this Provider's Specialty. Please check back later.
+
+
+
+
+
+
+
+
diff --git a/tests/golden/royal_berkshire_2026_expected.json b/tests/golden/royal_berkshire_2026_expected.json
new file mode 100644
index 0000000..863b31e
--- /dev/null
+++ b/tests/golden/royal_berkshire_2026_expected.json
@@ -0,0 +1,42 @@
+[
+ {
+ "region": "South East",
+ "provider": "Royal Berkshire Hospital NHS Foundation Trust",
+ "specialty": "Breast",
+ "source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
+ "metric": "first_outpatient_appointment",
+ "average_wait_weeks": null,
+ "patients_seen_within_weeks": null,
+ "page_last_updated": "2026-08-07"
+ },
+ {
+ "region": "South East",
+ "provider": "Royal Berkshire Hospital NHS Foundation Trust",
+ "specialty": "Breast",
+ "source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
+ "metric": "treatment",
+ "average_wait_weeks": 4,
+ "patients_seen_within_weeks": 7,
+ "page_last_updated": "2026-08-07"
+ },
+ {
+ "region": "South East",
+ "provider": "Royal Berkshire Hospital NHS Foundation Trust",
+ "specialty": "Cardiology",
+ "source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
+ "metric": "first_outpatient_appointment",
+ "average_wait_weeks": null,
+ "patients_seen_within_weeks": null,
+ "page_last_updated": "2026-08-07"
+ },
+ {
+ "region": "South East",
+ "provider": "Royal Berkshire Hospital NHS Foundation Trust",
+ "specialty": "Cardiology",
+ "source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
+ "metric": "treatment",
+ "average_wait_weeks": 8,
+ "patients_seen_within_weeks": 14,
+ "page_last_updated": "2026-08-07"
+ }
+]
diff --git a/tests/golden/royal_berkshire_expected.json b/tests/golden/royal_berkshire_expected.json
index 174316f..7113251 100644
--- a/tests/golden/royal_berkshire_expected.json
+++ b/tests/golden/royal_berkshire_expected.json
@@ -1,22 +1,42 @@
[
{
+ "region": "South East",
"provider": "Royal Berkshire Hospital NHS Foundation Trust",
- "specialty": "Breast",
- "metric": "treatment",
- "average_wait": "4 weeks",
- "percentile_80": "7 weeks",
+ "specialty": "Breast Surgery",
+ "source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
+ "metric": "first_outpatient_appointment",
+ "average_wait_weeks": 2,
+ "patients_seen_within_weeks": 5,
+ "page_last_updated": "2026-01-26"
+ },
+ {
"region": "South East",
+ "provider": "Royal Berkshire Hospital NHS Foundation Trust",
+ "specialty": "Breast Surgery",
"source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
- "last_updated": "7 August 2026"
+ "metric": "treatment",
+ "average_wait_weeks": 5,
+ "patients_seen_within_weeks": 13,
+ "page_last_updated": "2026-01-26"
},
{
+ "region": "South East",
"provider": "Royal Berkshire Hospital NHS Foundation Trust",
"specialty": "Cardiology",
- "metric": "treatment",
- "average_wait": "8 weeks",
- "percentile_80": "14 weeks",
+ "source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
+ "metric": "first_outpatient_appointment",
+ "average_wait_weeks": 3,
+ "patients_seen_within_weeks": 8,
+ "page_last_updated": "2026-01-26"
+ },
+ {
"region": "South East",
+ "provider": "Royal Berkshire Hospital NHS Foundation Trust",
+ "specialty": "Cardiology",
"source_url": "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/",
- "last_updated": "7 August 2026"
+ "metric": "treatment",
+ "average_wait_weeks": 8,
+ "patients_seen_within_weeks": 16,
+ "page_last_updated": "2026-01-26"
}
-]
+]
\ No newline at end of file
diff --git a/tests/test_extraction.py b/tests/test_extraction.py
index 4c15d87..a157da9 100644
--- a/tests/test_extraction.py
+++ b/tests/test_extraction.py
@@ -1,66 +1,137 @@
-"""Offline tests for the extractor against the fixture and edge cases."""
+"""Pure-extraction tests, run against the characterisation fixtures.
+
+The keystone test proves the extractor reproduces the golden dataset
+exactly; the remaining tests pin the edge-case behaviour agreed in the
+domain model: missing data is a state (None / no record), never an error.
+The 2026 layout is covered by its own fixture and golden (added when the
+site drifted; the baseline fixture is unchanged per the conftest rule).
+"""
from __future__ import annotations
-from nhs_scraper.domain import Page
+from datetime import date
+
+from nhs_scraper.domain import Metric, Page
from nhs_scraper.pipeline.extract import extract_waiting_times
+REGION = "South East"
TRUST_URL = "https://www.myplannedcare.nhs.uk/seast/royal-berkshire/"
-def page(html: str) -> Page:
- return Page(url=TRUST_URL, html=html)
+def make_page(html: str, url: str = TRUST_URL) -> Page:
+ return Page(url=url, html=html)
class TestGoldenExtraction:
- def test_fixture_yields_golden_records(self, load_fixture, load_golden):
- records = extract_waiting_times(
- page(load_fixture("trust_page_royal_berkshire.html")), region="South East"
- )
+ def test_fixture_yields_exactly_the_golden_records(self, load_fixture, load_golden):
+ page = make_page(load_fixture("trust_page_royal_berkshire.html"))
+ records = extract_waiting_times(page, region=REGION)
- assert [r.to_dict() for r in records] == load_golden("royal_berkshire_expected.json")
+ expected = load_golden("royal_berkshire_expected.json")
+ assert [record.to_dict() for record in records] == expected
- def test_first_outpatient_na_rows_skipped(self, load_fixture):
- records = extract_waiting_times(
- page(load_fixture("trust_page_royal_berkshire.html")), region="South East"
- )
+ def test_metrics_parse_in_document_order(self, load_fixture):
+ page = make_page(load_fixture("trust_page_royal_berkshire.html"))
+ records = extract_waiting_times(page, region=REGION)
+
+ assert [r.metric for r in records] == [
+ Metric.FIRST_OUTPATIENT_APPOINTMENT,
+ Metric.TREATMENT,
+ Metric.FIRST_OUTPATIENT_APPOINTMENT,
+ Metric.TREATMENT,
+ ]
+
+ def test_records_carry_provenance(self, load_fixture):
+ page = make_page(load_fixture("trust_page_royal_berkshire.html"))
+ records = extract_waiting_times(page, region=REGION)
+
+ for record in records:
+ assert record.source_url == TRUST_URL
+ assert record.page_last_updated == date(2026, 1, 26)
+
+
+class TestLayout2026:
+ """The 2026 layout: holders + captions + n/a cells + new footer."""
+
+ def test_fixture_yields_exactly_the_2026_golden(self, load_fixture, load_golden):
+ page = make_page(load_fixture("trust_page_royal_berkshire_2026.html"))
+ records = extract_waiting_times(page, region=REGION)
+
+ expected = load_golden("royal_berkshire_2026_expected.json")
+ assert [record.to_dict() for record in records] == expected
+
+ def test_na_cells_yield_null_waits_not_absent_records(self, load_fixture):
+ page = make_page(load_fixture("trust_page_royal_berkshire_2026.html"))
+ records = extract_waiting_times(page, region=REGION)
- assert all(r.metric == "treatment" for r in records)
- assert len(records) == 2 # Breast + Cardiology; first-outpatient n/a
+ first_outpatient = [
+ r for r in records if r.metric is Metric.FIRST_OUTPATIENT_APPOINTMENT
+ ]
+ assert len(first_outpatient) == 2
+ assert all(r.average_wait_weeks is None for r in first_outpatient)
def test_unavailable_specialty_skipped(self, load_fixture):
- records = extract_waiting_times(
- page(load_fixture("trust_page_royal_berkshire.html")), region="South East"
- )
+ page = make_page(load_fixture("trust_page_royal_berkshire_2026.html"))
+ records = extract_waiting_times(page, region=REGION)
assert "Paediatric Surgery" not in {r.specialty for r in records}
- def test_footer_date_extracted(self, load_fixture):
- records = extract_waiting_times(
- page(load_fixture("trust_page_royal_berkshire.html")), region="South East"
- )
+ def test_footer_date_parsed(self, load_fixture):
+ page = make_page(load_fixture("trust_page_royal_berkshire_2026.html"))
+ records = extract_waiting_times(page, region=REGION)
- assert all(r.last_updated == "7 August 2026" for r in records)
+ assert all(r.page_last_updated == date(2026, 8, 7) for r in records)
class TestEdgeCases:
- def test_empty_page_yields_nothing(self):
- assert extract_waiting_times(page(""), "South East") == []
+ def test_unavailable_specialty_yields_no_records(self, load_fixture):
+ page = make_page(
+ load_fixture("specialty_unavailable.html"),
+ url="https://www.myplannedcare.nhs.uk/example/",
+ )
+ assert extract_waiting_times(page, region=REGION) == []
+
+ def test_page_without_provider_heading_yields_no_records(self):
+ html = "
"
+ page = make_page(html, url="https://www.myplannedcare.nhs.uk/x/")
+ assert extract_waiting_times(page, region=REGION) == []
+
+ def test_header_only_table_yields_record_with_none_waits(self):
+ html = (
+ "
Trust X
"
+ "ENT
Treatment
"
+ "| Average waiting time | "
+ "8 in 10 patients seen within |
|---|
"
+ ""
+ )
+ page = make_page(html, url="https://www.myplannedcare.nhs.uk/x/")
+ (record,) = extract_waiting_times(page, region=REGION)
+
+ assert record.average_wait_weeks is None
+ assert record.patients_seen_within_weeks is None
+
+ def test_unknown_metric_heading_is_ignored(self):
+ html = (
+ "
Trust X
"
+ "ENT
Cancelled operations
"
+ ""
+ ""
+ )
+ page = make_page(html, url="https://www.myplannedcare.nhs.uk/x/")
+ assert extract_waiting_times(page, region=REGION) == []
- def test_metric_from_caption(self):
+ def test_na_values_parse_as_none(self):
html = (
- "
"
- ""
- "
ENT - Waiting Times
"
- "
First Outpatient Appointment"
- "| Average waiting time for first outpatient appointment | "
- "5 weeks |
"
- "| 8 in 10 patients will be seen within | 9 weeks |
"
- "
"
+ "
Trust X
"
+ "ENT
"
+ "First Outpatient Appointment
"
+ "| Average waiting time | "
+ "8 in 10 patients seen within |
"
+ "| n/a | 6 weeks |
"
+ ""
)
- records = extract_waiting_times(page(html), "South East")
+ page = make_page(html, url="https://www.myplannedcare.nhs.uk/x/")
+ (record,) = extract_waiting_times(page, region=REGION)
- assert len(records) == 1
- assert records[0].metric == "first_outpatient"
- assert records[0].average_wait == "5 weeks"
- assert records[0].percentile_80 == "9 weeks"
+ assert record.average_wait_weeks is None
+ assert record.patients_seen_within_weeks == 6
diff --git a/tests/test_preflight.py b/tests/test_preflight.py
index 6b3fb34..74a2b5b 100644
--- a/tests/test_preflight.py
+++ b/tests/test_preflight.py
@@ -1,7 +1,8 @@
"""Offline tests for the pre-flight layout probe and its pipeline wiring.
The drifted fixture proves the probe catches a restructured site; the
-pipeline tests prove drift aborts *before* any crawl call is made.
+pipeline tests prove drift aborts *before* any crawl call is made. Both
+the legacy and the 2026 fixtures must pass the probe.
"""
from __future__ import annotations
@@ -21,10 +22,10 @@
ALL_FIVE_FAILURES = {
"no
provider heading found",
- "no specialty blocks found",
- "no recognised waiting-times table captions found",
+ "no recognised specialty blocks found",
+ "no recognised metric labels found",
"no waiting-time tables with 'Average waiting time' header found",
- "no 'This page was last updated on ...' footer found",
+ "no recognised last-updated footer found",
}
@@ -52,6 +53,14 @@ def test_known_good_fixture_passes(self, load_fixture):
assert result.ok
assert result.failures == ()
+ def test_2026_fixture_passes(self, load_fixture):
+ result = probe_layout(
+ make_page(load_fixture("trust_page_royal_berkshire_2026.html"))
+ )
+
+ assert result.ok
+ assert result.failures == ()
+
def test_drifted_fixture_reports_every_failure(self, load_fixture):
result = probe_layout(make_page(load_fixture("trust_page_drifted.html")))
@@ -60,25 +69,23 @@ def test_drifted_fixture_reports_every_failure(self, load_fixture):
def test_partial_drift_reports_single_failure(self, load_fixture):
html = load_fixture("trust_page_royal_berkshire.html").replace(
- "This page was last updated on 7 August 2026", "Updated 7 Aug 2026"
+ "Page last updated: 26/01/2026", "Updated 26 Jan 2026"
)
result = probe_layout(make_page(html))
assert not result.ok
- assert result.failures == ("no 'This page was last updated on ...' footer found",)
+ assert result.failures == ("no recognised last-updated footer found",)
def test_structurally_valid_but_unextractable_page_flagged(self):
- # Tables and captions present (structural checks pass) but every
- # cell is n/a, so the extractor yields nothing — end-to-end signal.
+ # Table present (structural check passes) but not a sibling of the
+ # h4, so the extractor yields nothing — the end-to-end signal fires.
html = (
- "
"
- ""
- "
ENT - Waiting Times
"
- "
Treatment"
- "| Average waiting time for treatment | n/a |
"
- "
"
- "- This page was last updated on 7 August 2026.
"
- ""
+ "
Trust X
"
+ "ENT
Treatment
"
+ "| Average waiting time |
"
+ "| 4 weeks |
"
+ ""
+ ""
)
result = probe_layout(make_page(html, url="https://www.myplannedcare.nhs.uk/x/"))
@@ -87,6 +94,16 @@ def test_structurally_valid_but_unextractable_page_flagged(self):
"extractor produced no records from a structurally valid page",
)
+ def test_unavailable_specialty_page_is_not_a_valid_canary(self, load_fixture):
+ # Documents the operational rule: canaries must be data-bearing pages.
+ result = probe_layout(
+ make_page(
+ load_fixture("specialty_unavailable.html"),
+ url="https://www.myplannedcare.nhs.uk/example/",
+ )
+ )
+ assert not result.ok
+
class TestPipelinePreflight:
def test_drift_aborts_before_any_crawl(self, load_fixture):
@@ -110,6 +127,15 @@ def test_good_layout_proceeds_to_golden_output(self, load_fixture, load_golden):
assert [record.to_dict() for record in result.records] == expected
assert backend.crawl_calls == [TRUST_URL]
+ def test_2026_layout_proceeds_to_2026_golden(self, load_fixture, load_golden):
+ page = make_page(load_fixture("trust_page_royal_berkshire_2026.html"))
+ backend = FakeBackend({TRUST_URL: [page]})
+
+ result = asyncio.run(run_pipeline(backend, [(TRUST_URL, "South East")]))
+
+ expected = load_golden("royal_berkshire_2026_expected.json")
+ assert [record.to_dict() for record in result.records] == expected
+
def test_preflight_disabled_preserves_lenient_behaviour(self, load_fixture):
drifted = make_page(load_fixture("trust_page_drifted.html"))
backend = FakeBackend({TRUST_URL: [drifted]})
@@ -155,4 +181,4 @@ def test_main_success_path_writes_csv(
assert exit_code == 0
assert output.exists()
- assert "2 records" in capsys.readouterr().out
+ assert "4 records" in capsys.readouterr().out