"""Unit tests for the pure campaign scheduling decision (no I/O).""" from datetime import datetime, timedelta, timezone from agenteval.evaluation.campaign_scheduler import ( TickAction, clock_offset, decide_schedule, decide_tick, elapsed_seconds, resolve_finalize, ) from agenteval.models import CampaignPlanEntry, CampaignStatus def _plan() -> list[CampaignPlanEntry]: return [ CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=2), CampaignPlanEntry(scenario_id="s-2", offset_seconds=3600, count=1), CampaignPlanEntry(scenario_id="s-3", offset_seconds=3600, count=3), ] # ── clock mapping ────────────────────────────────────────────────────────── def test_clock_offset_real_wall_clock(): assert clock_offset(elapsed_seconds=120.0, time_scale=1.0) == 120.0 def test_clock_offset_compressed(): # 1 real second maps to 3600 window seconds at 3600x. assert clock_offset(elapsed_seconds=1.0, time_scale=3600.0) == 3600.0 # ── due / not-due matrix ───────────────────────────────────────────────────── def test_nothing_due_before_first_offset(): # Only offset-0 entries exist at t<0? Use a plan whose earliest offset is 10. plan = [CampaignPlanEntry(scenario_id="s-1", offset_seconds=10, count=1)] decision = decide_schedule(plan=plan, window_seconds=100, clock_offset_seconds=5) assert decision.due == () assert decision.finished is False def test_offset_zero_entry_due_at_start(): decision = decide_schedule(plan=_plan(), window_seconds=7200, clock_offset_seconds=0) assert [d.index for d in decision.due] == [0] assert decision.due[0].entry.count == 2 def test_multiple_entries_due_at_same_offset(): decision = decide_schedule(plan=_plan(), window_seconds=7200, clock_offset_seconds=3600) # entry 0 (offset 0) plus entries 1 and 2 (offset 3600) are all due. assert [d.index for d in decision.due] == [0, 1, 2] def test_spawned_entries_excluded_idempotent(): decision = decide_schedule( plan=_plan(), window_seconds=7200, clock_offset_seconds=3600, spawned_indices=[0, 1], ) assert [d.index for d in decision.due] == [2] def test_all_spawned_yields_no_due(): decision = decide_schedule( plan=_plan(), window_seconds=7200, clock_offset_seconds=99999, spawned_indices=[0, 1, 2], ) assert decision.due == () # ── window end ─────────────────────────────────────────────────────────────── def test_not_finished_inside_window(): decision = decide_schedule(plan=_plan(), window_seconds=7200, clock_offset_seconds=7199) assert decision.finished is False def test_finished_at_window_end(): decision = decide_schedule(plan=_plan(), window_seconds=7200, clock_offset_seconds=7200) assert decision.finished is True # ── elapsed_seconds (pure, clock injected) ─────────────────────────────────── T0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) def test_elapsed_seconds_aware(): assert elapsed_seconds(now=T0 + timedelta(seconds=90), started_at=T0) == 90.0 def test_elapsed_seconds_tolerates_naive_started_at(): # SQLite hands back naive datetimes; treat them as UTC (AGENTS.md #2). naive = T0.replace(tzinfo=None) assert elapsed_seconds(now=T0 + timedelta(seconds=30), started_at=naive) == 30.0 def test_elapsed_seconds_negative_when_now_before_start(): assert elapsed_seconds(now=T0 - timedelta(seconds=5), started_at=T0) == -5.0 # ── decide_tick (lifecycle + due, one pure decision) ───────────────────────── def _tick(*, now, started_at, status, offset_plan=None, window=7200, scale=1.0, spawned=()): return decide_tick( now=now, started_at=started_at, status=status, window_seconds=window, time_scale=scale, plan=offset_plan if offset_plan is not None else _plan(), spawned_indices=spawned, ) def test_tick_stop_when_not_running(): d = _tick(now=T0 + timedelta(seconds=1), started_at=T0, status=CampaignStatus.CANCELLED) assert d.action is TickAction.STOP def test_tick_stop_when_no_started_at(): d = _tick(now=T0, started_at=None, status=CampaignStatus.RUNNING) assert d.action is TickAction.STOP def test_tick_continue_inside_window_reports_due_and_offset(): d = _tick(now=T0, started_at=T0, status=CampaignStatus.RUNNING) assert d.action is TickAction.CONTINUE assert d.finished is False assert d.offset == 0.0 assert [due.index for due in d.due] == [0] # only offset-0 entry due at t=0 def test_tick_complete_at_window_end(): d = _tick(now=T0 + timedelta(seconds=7200), started_at=T0, status=CampaignStatus.RUNNING) assert d.action is TickAction.COMPLETE assert d.finished is True def test_tick_time_scale_compresses_window(): # window 7200s at 7200x → 1 real second reaches the window end. d = _tick(now=T0 + timedelta(seconds=1), started_at=T0, status=CampaignStatus.RUNNING, scale=7200.0) assert d.offset == 7200.0 assert d.action is TickAction.COMPLETE # ── resolve_finalize (cancel-race guard, pure) ─────────────────────────────── def test_resolve_finalize_completes_when_still_running(): assert resolve_finalize(CampaignStatus.RUNNING) is TickAction.COMPLETE def test_resolve_finalize_stops_when_cancelled_meanwhile(): # Cancel landed between finish-detection and the write — do not overwrite it. assert resolve_finalize(CampaignStatus.CANCELLED) is TickAction.STOP