Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions uk_bin_collection/tests/test_vale_of_white_horse_council.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Unit tests for the Vale of White Horse fortnightly roll-forward.

The council page publishes the current fortnight's pair of collections rather
than the next occurrence of each bin, so the scraper rolls a past date forward
by 14 days - except into a week with a bank holiday, when the council collects
on a different day and only the page knows which.
"""

from datetime import datetime as real_datetime
from types import SimpleNamespace
from unittest.mock import patch

from uk_bin_collection.uk_bin_collection.councils.ValeofWhiteHorseCouncil import (
CouncilClass,
)

MODULE_PATH = "uk_bin_collection.uk_bin_collection.councils.ValeofWhiteHorseCouncil"

PAGE = """
<html><body>
<div class="bintxt">It's rubbish week</div>
<div class="binextra"><span>{rubbish}</span><span>grey bin, small electrical items and food bin</span></div>
<div class="bintxt">It's recycling week</div>
<div class="binextra"><span>{recycling}</span><span>food bin, green bin, textiles and garden waste bin</span></div>
</body></html>
"""


def parse_fixture(rubbish: str, recycling: str, now: real_datetime) -> dict:
"""Run the parser against a page showing the given two panels, on `now`."""

class FrozenDatetime(real_datetime):
@classmethod
def now(cls, tz=None):
return now

response = SimpleNamespace(text=PAGE.format(rubbish=rubbish, recycling=recycling))
with patch(f"{MODULE_PATH}.requests.Session") as session, patch(
f"{MODULE_PATH}.datetime", FrozenDatetime
):
session.return_value.get.return_value = response
return CouncilClass().parse_data("", uprn="100120000000")


def test_a_bin_collected_earlier_this_fortnight_rolls_forward_14_days():
"""A past date in a holiday-free fortnight is advanced by 14 days."""
result = parse_fixture(
"Monday 3 August -", "Monday 10 August -", real_datetime(2026, 8, 8)
)

assert result == {
"bins": [
{"type": "Recycling", "collectionDate": "10/08/2026"},
{"type": "Rubbish", "collectionDate": "17/08/2026"},
]
}


def test_no_roll_forward_into_a_bank_holiday_week():
"""31 August 2026 is the late summer bank holiday, so 17 + 14 would be a
day the council does not collect: the bin is left out until the page shows
the real date."""
result = parse_fixture(
"Monday 17 August -", "Monday 24 August -", real_datetime(2026, 8, 22)
)

assert result == {"bins": [{"type": "Recycling", "collectionDate": "24/08/2026"}]}


def test_a_rescheduled_date_on_the_page_is_returned_as_is():
"""A date the page states outright is the source of truth, holiday or not."""
result = parse_fixture(
"Tuesday 1 September -", "Monday 7 September -", real_datetime(2026, 8, 30)
)

assert result["bins"] == [
{"type": "Rubbish", "collectionDate": "01/09/2026"},
{"type": "Recycling", "collectionDate": "07/09/2026"},
]


def test_no_roll_forward_into_the_january_slippage_weeks():
"""21 + 14 = 4 January 2027, a week with no bank holiday in it, but the
council's leaflet collects that Monday's bins on Wednesday 6 January:
Christmas slippage outlasts the holidays, so January is left to the page."""
result = parse_fixture(
"Monday 21 December -", "Monday 28 December -", real_datetime(2026, 12, 23)
)

assert result["bins"] == [{"type": "Recycling", "collectionDate": "28/12/2026"}]


def test_a_december_date_seen_in_january_belongs_to_last_year():
"""On 9 January 2027 the page still shows Monday 28 December, which is
28 December 2026, not a collection eleven months away. It rolls forward
into the slippage weeks and is left out; 4 January rolls to 18 January,
the first normal week, and is returned."""
result = parse_fixture(
"Monday 28 December -", "Monday 4 January -", real_datetime(2027, 1, 9)
)

assert result["bins"] == [{"type": "Recycling", "collectionDate": "18/01/2027"}]


def test_no_roll_forward_into_christmas_week():
"""14 + 14 = 28 December 2026, the observed Boxing Day holiday."""
result = parse_fixture(
"Monday 14 December -", "Monday 21 December -", real_datetime(2026, 12, 19)
)

assert result["bins"] == [{"type": "Recycling", "collectionDate": "21/12/2026"}]


def test_roll_forward_through_a_bank_holiday_week_to_a_normal_one():
"""A page still showing 17 August on 2 September rolls through the bank
holiday week to 14 September, which is right: the holiday shifts the
collections of its own week and the fortnight then resumes as usual."""
result = parse_fixture(
"Monday 17 August -", "Monday 24 August -", real_datetime(2026, 9, 2)
)

assert result["bins"] == [
{"type": "Recycling", "collectionDate": "07/09/2026"},
{"type": "Rubbish", "collectionDate": "14/09/2026"},
]
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@
from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass


def week_has_bank_holiday(collection_date) -> bool:
"""True if an English bank holiday falls Monday to Friday of that week.

A bank holiday shifts every collection in its week, not just the day
itself, so the whole working week is checked.
"""
monday = collection_date - timedelta(days=collection_date.weekday())
return any(
is_holiday(
datetime.combine(monday + timedelta(days=offset), datetime.min.time())
)
for offset in range(5)
)


def in_christmas_period(collection_date) -> bool:
"""True from 24 December to 17 January, when collections run late for
weeks after the last bank holiday.

The council's 2026/27 leaflet revises every collection from 25 December
to 15 January (two days late until 9 January, one day late the week
after), and 2025/26 returned to normal on 12 January. No bank holiday
falls in those January weeks, so the week check alone does not see them.
"""
return (collection_date.month == 12 and collection_date.day >= 24) or (
collection_date.month == 1 and collection_date.day <= 17
)


def collections_are_rescheduled(collection_date) -> bool:
"""True if the council will not collect on the usual day around this date."""
return week_has_bank_holiday(collection_date) or in_christmas_period(
collection_date
)


# import the wonderful Beautiful Soup and the URL grabber
class CouncilClass(AbstractGetBinDataClass):
"""
Expand Down Expand Up @@ -92,13 +128,30 @@ def parse_data(self, page: str, **kwargs) -> dict:
except Exception as ex:
raise ValueError(f"Error parsing bin data: {ex}")

# The page carries no year. In early January it still shows the
# December date of the current fortnight, which read as this
# year's December is eleven months away; a date that far ahead
# is last year's.
if bin_date - today > timedelta(days=180):
bin_date = bin_date.replace(year=bin_date.year - 1)

# The page publishes the current fortnight's pair rather than the
# next occurrence of each bin, so a bin collected earlier in this
# fortnight parses to a date already in the past - roll it
# forward by the fortnightly cycle (day-based, so it naturally
# crosses a year boundary too) until it's genuinely upcoming.
while bin_date < today:
bin_date += timedelta(days=14)
#
# Unless that lands where the council collects on a different
# day, which only the page knows: leave the bin out until the
# page catches up rather than publish a guess. Only where the
# date lands matters: a holiday shifts the collections of its
# own week and the fortnight then resumes on the usual day, so
# rolling through a holiday week to a later date is fine.
if bin_date < today:
while bin_date < today:
bin_date += timedelta(days=14)
if collections_are_rescheduled(bin_date):
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Build data dict for each entry
dict_data = {
Expand Down