Skip to content
Open
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
26 changes: 22 additions & 4 deletions modules/naboj/builder/contexts/buildable.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import abc
import datetime
import zoneinfo
from pathlib import Path

from enschema import Schema
Expand All @@ -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
Expand Down Expand Up @@ -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'])
14 changes: 13 additions & 1 deletion modules/naboj/builder/contexts/hierarchy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import itertools
import zoneinfo

from enschema import And, Optional, Regex, Schema

Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
55 changes: 55 additions & 0 deletions modules/naboj/tests/test_naboj_venue.py
Original file line number Diff line number Diff line change
@@ -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')
Loading