"""Daily preallocation scheduler tests (dcy-014 / REQ-005, issue #14). trading_date is derived in a configurable timezone (default KST) with an injected clock; timestamps stay UTC. Covers KST/UTC boundary, cross-day auto-create + idempotency, configurable TZ, and trigger-time gating. """ from __future__ import annotations from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo from trex_core.application.preallocation_scheduler import DailyPreallocationScheduler from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository KST = ZoneInfo("Asia/Seoul") UTC = timezone.utc def _wire(): repo = InMemorySlotRepository() svc = SlotService(repo) return repo, svc def _dates(repo): return sorted({s.trading_date for s in repo._slots.values()}) def _count(repo, d): return sum(1 for s in repo._slots.values() if s.trading_date == d) def test_trading_date_uses_kst_not_utc_at_midnight_boundary(): """S-1: 2026-06-08 15:30Z is already 2026-06-09 in KST.""" repo, svc = _wire() clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731 sched = DailyPreallocationScheduler( slot_service=svc, total_slots=10, tz=KST, clock=clock, ) td = sched.run_once() assert td == date(2026, 6, 9) # KST date, not UTC 6/8 assert _dates(repo) == [date(2026, 6, 9)] assert _count(repo, date(2026, 6, 9)) == 10 def test_timestamps_stay_utc(): """S-2: slot created_at/updated_at remain UTC even with KST trading_date.""" repo, svc = _wire() clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731 DailyPreallocationScheduler( slot_service=svc, total_slots=1, tz=KST, clock=clock, ).run_once() slot = next(iter(repo._slots.values())) assert slot.updated_at.utcoffset() == timedelta(0) # UTC def test_cross_day_boundary_auto_creates_next_day_idempotently(): """S-3: a day boundary creates next-day slots once; re-ticks add nothing.""" repo, svc = _wire() now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 2026-06-08 01:00 KST sched = DailyPreallocationScheduler( slot_service=svc, total_slots=10, tz=KST, clock=lambda: now["t"], ) sched.run_once() # boot -> 6/8 assert _dates(repo) == [date(2026, 6, 8)] now["t"] = datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # 2026-06-09 00:30 KST sched._maybe_tick() # boundary -> 6/9 assert _dates(repo) == [date(2026, 6, 8), date(2026, 6, 9)] now["t"] = datetime(2026, 6, 8, 16, 30, tzinfo=UTC) # still 6/9 KST sched._maybe_tick() # already fired today assert _count(repo, date(2026, 6, 9)) == 10 # no duplicates def test_configurable_timezone_changes_trading_date(): """S-4: same instant yields a different trading_date under a different TZ.""" repo, svc = _wire() clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731 DailyPreallocationScheduler( slot_service=svc, total_slots=1, tz=UTC, clock=clock, ).run_once() assert _dates(repo) == [date(2026, 6, 8)] # UTC date, not KST 6/9 def test_trigger_time_gates_next_day_creation(): """A configured trigger time defers next-day creation until reached.""" repo, svc = _wire() now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 6/8 01:00 KST sched = DailyPreallocationScheduler( slot_service=svc, total_slots=5, tz=KST, schedule_time=time(hour=7), clock=lambda: now["t"], ) sched.run_once() # boot -> 6/8 now["t"] = datetime(2026, 6, 8, 18, 0, tzinfo=UTC) # 6/9 03:00 KST (< 07:00) sched._maybe_tick() assert date(2026, 6, 9) not in _dates(repo) # not yet now["t"] = datetime(2026, 6, 8, 22, 30, tzinfo=UTC) # 6/9 07:30 KST (>= 07:00) sched._maybe_tick() assert date(2026, 6, 9) in _dates(repo) # created after trigger def test_idle_slots_created_for_trading_date(): """Created slots are idle (active only on buy admission).""" repo, svc = _wire() clock = lambda: datetime(2026, 6, 8, 1, 0, tzinfo=UTC) # noqa: E731 DailyPreallocationScheduler( slot_service=svc, total_slots=3, tz=KST, clock=clock, ).run_once() assert all(s.status is SlotState.IDLE for s in repo._slots.values())