diff --git a/modules/naboj/builder/contexts/buildable.py b/modules/naboj/builder/contexts/buildable.py index daf5ba49..5d89c913 100644 --- a/modules/naboj/builder/contexts/buildable.py +++ b/modules/naboj/builder/contexts/buildable.py @@ -1,4 +1,6 @@ import abc +import datetime +import zoneinfo from pathlib import Path from enschema import Schema @@ -15,6 +17,21 @@ from .validators import NabojValidator +def local_start(date: datetime.date, start: int, source: str, target: str) -> int: + """ + The minute of the day in `target` at which `start` minutes into `date` in `source` falls. + Every venue begins at one moment; only the clock on the wall differs. The date matters, + because two zones need not change to summer time on the same day. + """ + moment = datetime.datetime.combine(date, datetime.time(start // 60, start % 60), + tzinfo=zoneinfo.ZoneInfo(source)) + local = moment.astimezone(zoneinfo.ZoneInfo(target)) + if local.date() != date: + raise ValueError(f"Starting at {start // 60:02d}:{start % 60:02d} in {source} " + f"puts {target} on {local.date()}, not on the competition's day") + return local.hour * 60 + local.minute + + class BuildableContextNaboj(BuildableFileSystemTreeContext, ContextNaboj, metaclass=abc.ABCMeta): _schema = ContextNaboj._schema _validator_class = NabojValidator @@ -60,10 +77,11 @@ def __init__(self, root, *args): def populate(self, competition, volume, venue): super().populate(competition, volume) self.adopt( - venue=ContextVenue(self.root, competition, volume, venue) - .override('start', self.data['volume']['start']), + venue=ContextVenue(self.root, competition, volume, venue), i18n=ContextI18nGlobal(self.root, competition) ).add(language=i18n.languages[self.data['venue']['language']].as_dict()) - if 'start' not in self.data['venue']: - self.data['venue']['start'] = self.data['volume']['start'] + volume, venue = self.data['volume'], self.data['venue'] + venue.setdefault('timezone', volume['timezone']) + if 'start' not in venue: + venue['start'] = local_start(volume['date'], volume['start'], volume['timezone'], venue['timezone']) diff --git a/modules/naboj/builder/contexts/hierarchy.py b/modules/naboj/builder/contexts/hierarchy.py index 86ca3d1b..0308d486 100644 --- a/modules/naboj/builder/contexts/hierarchy.py +++ b/modules/naboj/builder/contexts/hierarchy.py @@ -1,5 +1,6 @@ import datetime import itertools +import zoneinfo from enschema import And, Optional, Regex, Schema @@ -11,6 +12,11 @@ from .base import ContextNaboj +def valid_timezone(name: str) -> bool: + """ An IANA zone name, `Europe/Lisbon`; an offset like `+01:00` would be wrong half the year. """ + return isinstance(name, str) and name in zoneinfo.available_timezones() + + class ContextCompetition(ContextNaboj): _schema = Schema({ 'id': String, @@ -79,7 +85,11 @@ class ContextVenue(ContextNaboj): 'problems_modulo': [[ContextNaboj.problem]], Optional('orgs'): [And(str, len)], 'evaluators': int, - 'start': And(int, lambda x: 0 <= x < 1440), + # The venue's local start, for a venue that genuinely starts at another moment. A venue + # that merely sits in another time zone gives `timezone` instead and leaves this out, so + # that its start follows the volume's and cannot drift from it. + Optional('start'): And(int, lambda x: 0 <= x < 1440), + Optional('timezone'): valid_timezone, }) def populate(self, competition, volume, venue): @@ -122,6 +132,8 @@ class ContextVolume(ContextNaboj): 'constants': dict, 'table': int, 'start': And(int, lambda x: 0 <= x < 1440), + # The zone `start` is written in, and the one a venue without a `timezone` is in. + Optional('timezone', default='Europe/Bratislava'): valid_timezone, }) def populate(self, competition, volume): diff --git a/modules/naboj/tests/test_naboj_venue.py b/modules/naboj/tests/test_naboj_venue.py new file mode 100644 index 00000000..b72cb30b --- /dev/null +++ b/modules/naboj/tests/test_naboj_venue.py @@ -0,0 +1,55 @@ +r""" +What a venue's context says about the venue, asserted against the real metas. + +Here and not in `core/tests/` for the reason `test_naboj_page_layout.py` gives: it needs +`source/naboj/` checked out, and is skipped when it is not. +""" +import datetime +import pathlib + +import pytest +import yaml + +from modules.naboj.builder.contexts import BuildableContextVenue +from modules.naboj.builder.contexts.buildable import local_start + +pytestmark = pytest.mark.skipif(not pathlib.Path('source/naboj').is_dir(), + reason='source/naboj is not checked out') + +VOLUME = pathlib.Path('source/naboj/phys/29') +VENUES = sorted(path.parent.name for path in VOLUME.glob('venues/*/meta.yaml')) + + +def venue(name): + return BuildableContextVenue(pathlib.Path('source/naboj'), 'phys', 29, name).data['venue'] + + +class TestTheStartTime: + @pytest.mark.parametrize('name', [v for v in VENUES if not v.startswith('lisboa')]) + def test_a_venue_that_gives_a_start_keeps_it(self, name): + """ A venue that really starts at another moment says so, and the volume does not overrule it. """ + meta = yaml.safe_load((VOLUME / 'venues' / name / 'meta.yaml').read_text()) + assert venue(name)['start'] == meta['start'] + + @pytest.mark.parametrize('name', ['lisboa-junior', 'lisboa-senior']) + def test_a_venue_in_another_zone_starts_at_the_same_moment(self, name): + """ + Lisboa starts when everyone else does, an hour behind on the clock. Its meta gives the + zone and no start, so the instructions read 10:30 there and 11:30 everywhere else. + """ + assert venue(name)['timezone'] == 'Europe/Lisbon' + assert venue(name)['start'] == 10 * 60 + 30 + + +class TestLocalStart: + def test_the_same_zone_changes_nothing(self): + assert local_start(datetime.date(2026, 11, 13), 690, 'Europe/Bratislava', 'Europe/Bratislava') == 690 + + def test_the_date_decides_the_offset(self): + """ Kyiv is an hour ahead of Bratislava all year, but only because both switch on one day. """ + assert local_start(datetime.date(2026, 11, 13), 690, 'Europe/Bratislava', 'Europe/Kyiv') == 750 + assert local_start(datetime.date(2026, 7, 1), 690, 'Europe/Bratislava', 'Europe/Lisbon') == 630 + + def test_a_start_that_crosses_midnight_is_refused(self): + with pytest.raises(ValueError, match='not on the competition'): + local_start(datetime.date(2026, 11, 13), 30, 'Europe/Bratislava', 'Europe/Lisbon')