Extend the pure scheduler with elapsed_seconds (clock injected), decide_tick (offset + due + lifecycle action) and resolve_finalize (cancel-race guard), so the durable loop stops hand-coding elapsed/finished/status checks and only does I/O. Deletes the runner's private _elapsed_seconds and converges current_window_offset onto the one pure elapsed computation. The clock-skew tolerance and cancel-race guard are now unit-testable at the seam.
158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
"""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
|