"""CampaignRepository 窄口径原子更新直测(架构保养候选 1)。 全行 update 逼着调用方承诺「持有最新完整快照」,巡检水位 / 取消 / 调度循环并发写入会互相覆盖。三个窄方法各自只动自己的列: touch_patrol_watermark(水位)、mark_cancelled(终态迁移)、 save_scheduler_state(调度进度)。 """ from datetime import datetime, timezone import pytest from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, CampaignSummary, SchedulerState from agenteval.storage.db import as_utc from agenteval.storage.repository import CampaignRepository from sqlmodel import Session, SQLModel, create_engine T0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) T1 = datetime(2026, 1, 1, 1, 0, 0, tzinfo=timezone.utc) @pytest.fixture() def db_session(tmp_path): from agenteval.storage.db import ( # noqa: F401 CampaignDB, EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB, ) engine = create_engine( f"sqlite:///{tmp_path / 'narrow.db'}", connect_args={"check_same_thread": False}, ) SQLModel.metadata.create_all(engine) session = Session(engine) try: yield session finally: session.close() engine.dispose() def _seed(db_session, status=CampaignStatus.RUNNING) -> Campaign: campaign = CampaignRepository(db_session).create(Campaign( name="cycle", target_id="t-1", window_seconds=3600, plan=[CampaignPlanEntry(scenario_id="s-a", offset_seconds=0, count=1)], status=status, started_at=T0, summary=CampaignSummary(scheduler=SchedulerState(spawned_indices=[0])), )) return campaign def test_touch_patrol_watermark_only_touches_watermark(db_session): campaign = _seed(db_session) CampaignRepository(db_session).touch_patrol_watermark(campaign.id, T1) fresh = CampaignRepository(db_session).get(campaign.id) assert as_utc(fresh.last_patrolled_at) == T1 # 其余列原样:窄口径不覆写并发写入的 status / summary assert fresh.status == CampaignStatus.RUNNING assert fresh.summary.scheduler.spawned_indices == [0] assert fresh.name == "cycle" and as_utc(fresh.started_at) == T0 def test_mark_cancelled_only_touches_status_and_completed_at(db_session): campaign = _seed(db_session) CampaignRepository(db_session).touch_patrol_watermark(campaign.id, T1) updated = CampaignRepository(db_session).mark_cancelled(campaign.id, T1) assert updated.status == CampaignStatus.CANCELLED assert as_utc(updated.completed_at) == T1 fresh = CampaignRepository(db_session).get(campaign.id) assert fresh.status == CampaignStatus.CANCELLED assert as_utc(fresh.completed_at) == T1 # 水位与调度进度不被终态迁移抹掉 assert as_utc(fresh.last_patrolled_at) == T1 assert fresh.summary.scheduler.spawned_indices == [0] def test_save_scheduler_state_only_touches_summary(db_session): campaign = _seed(db_session) CampaignRepository(db_session).touch_patrol_watermark(campaign.id, T1) new_summary = CampaignSummary(scheduler=SchedulerState(spawned_indices=[0, 1, 2])) CampaignRepository(db_session).save_scheduler_state(campaign.id, new_summary) fresh = CampaignRepository(db_session).get(campaign.id) assert fresh.summary.scheduler.spawned_indices == [0, 1, 2] # 状态与水位不被调度进度覆写 assert fresh.status == CampaignStatus.RUNNING assert as_utc(fresh.last_patrolled_at) == T1 def test_narrow_updates_ignore_missing_campaign(db_session): repo = CampaignRepository(db_session) repo.touch_patrol_watermark("missing", T1) repo.save_scheduler_state("missing", CampaignSummary()) assert repo.mark_cancelled("missing", T1) is None