From 72d9a57f502acc8e4b02b5f95e701559977c3112 Mon Sep 17 00:00:00 2001 From: 084intheImpala Date: Tue, 20 Oct 2026 20:37:22 +0100 Subject: [PATCH 1/2] A venue starts when its own meta says, not when the volume does `BuildableContextVenue` overwrote `venue.start` with the volume's start unconditionally, so every venue's instructions printed the volume's time. Lisboa is an hour behind the rest and its meta says 10:30; its instructions told teams to open their envelopes at 11:30. The fallback below the override already covers a venue with no `start:`, so dropping the override is the whole fix. The test reads every venue of phys/29 and fails on exactly lisboa-junior and lisboa-senior without it. Co-Authored-By: Claude Opus 5.5 --- modules/naboj/builder/contexts/buildable.py | 3 +- modules/naboj/tests/test_naboj_venue.py | 31 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 modules/naboj/tests/test_naboj_venue.py diff --git a/modules/naboj/builder/contexts/buildable.py b/modules/naboj/builder/contexts/buildable.py index daf5ba49..dee50adb 100644 --- a/modules/naboj/builder/contexts/buildable.py +++ b/modules/naboj/builder/contexts/buildable.py @@ -60,8 +60,7 @@ 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()) diff --git a/modules/naboj/tests/test_naboj_venue.py b/modules/naboj/tests/test_naboj_venue.py new file mode 100644 index 00000000..e98f3fb8 --- /dev/null +++ b/modules/naboj/tests/test_naboj_venue.py @@ -0,0 +1,31 @@ +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 pathlib + +import pytest +import yaml + +from modules.naboj.builder.contexts import BuildableContextVenue + +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')) + + +class TestTheStartTime: + @pytest.mark.parametrize('venue', VENUES) + def test_a_venue_starts_when_its_meta_says(self, venue): + """ + The instructions print `venue.start`, and a venue in another time zone starts at another + local time. It used to be overwritten with the volume's start unconditionally, so Lisboa, + an hour behind the rest, was told to open its envelopes at 11:30 instead of 10:30. + """ + meta = yaml.safe_load((VOLUME / 'venues' / venue / 'meta.yaml').read_text()) + context = BuildableContextVenue(pathlib.Path('source/naboj'), 'phys', 29, venue) + assert context.data['venue']['start'] == meta['start'] From 8af87e16c580c5f1ed00acac8a2905e9821891eb Mon Sep 17 00:00:00 2001 From: 084intheImpala Date: Wed, 21 Oct 2026 17:19:02 +0100 Subject: [PATCH 2/2] A venue in another time zone starts at the same moment, not at its own time Lisboa is an hour behind the rest, but it starts when everyone else does, so its meta should say which zone it is in rather than restate the start. The volume and each venue may now give a `timezone` (an IANA name; the volume's defaults to Europe/Bratislava), and a venue that gives no `start` takes the volume's, converted to its own clock on the competition's date. A venue that does give a `start` keeps it, for one that really starts at another moment. `venue.start` is still local minutes, so no template changes. Co-Authored-By: Claude Opus 5.5 --- modules/naboj/builder/contexts/buildable.py | 23 ++++++++++-- modules/naboj/builder/contexts/hierarchy.py | 14 +++++++- modules/naboj/tests/test_naboj_venue.py | 40 ++++++++++++++++----- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/modules/naboj/builder/contexts/buildable.py b/modules/naboj/builder/contexts/buildable.py index dee50adb..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 @@ -64,5 +81,7 @@ def populate(self, 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 index e98f3fb8..b72cb30b 100644 --- a/modules/naboj/tests/test_naboj_venue.py +++ b/modules/naboj/tests/test_naboj_venue.py @@ -4,12 +4,14 @@ 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') @@ -18,14 +20,36 @@ 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('venue', VENUES) - def test_a_venue_starts_when_its_meta_says(self, venue): + @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): """ - The instructions print `venue.start`, and a venue in another time zone starts at another - local time. It used to be overwritten with the volume's start unconditionally, so Lisboa, - an hour behind the rest, was told to open its envelopes at 11:30 instead of 10:30. + 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. """ - meta = yaml.safe_load((VOLUME / 'venues' / venue / 'meta.yaml').read_text()) - context = BuildableContextVenue(pathlib.Path('source/naboj'), 'phys', 29, venue) - assert context.data['venue']['start'] == meta['start'] + 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')