from dataclasses import replace from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from zhixing_server.modules.sector_radar.domain.models import ( PublicationStatus, RadarPublication, SectorType, ) from zhixing_server.modules.sector_radar.domain.persistence import MembershipRecord from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot from zhixing_server.modules.sector_radar.infrastructure.memory import ( InMemorySectorRadarRepository, ) TARGET_DATE = date(2026, 8, 28) STARTED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) def make_running(publication_id: str, target_trade_date: date = TARGET_DATE) -> RadarPublication: return RadarPublication( publication_id=publication_id, target_trade_date=target_trade_date, status=PublicationStatus.RUNNING, source_version="tushare-pro-v1", universe_version="eastmoney-dc-v1", metric_versions=("zhixing_amount_net_bn_v1",), input_hash=None, coverage=Decimal(0), started_at=STARTED_AT, ) def finish( publication: RadarPublication, status: PublicationStatus, *, offset_minutes: int = 5, ) -> RadarPublication: return replace( publication, status=status, input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None, coverage=Decimal("1") if status is PublicationStatus.SUCCESS else Decimal("0.8"), finished_at=publication.started_at + timedelta(minutes=offset_minutes), error_summary=None if status is PublicationStatus.SUCCESS else "safe_error", ) def test_source_and_membership_revisions_are_idempotent_but_not_overwritable() -> None: repository = InMemorySectorRadarRepository() snapshot = build_source_snapshot( api_name="dc_member", params={"trade_date": "20260828"}, rows=( { "trade_date": "20260828", "ts_code": "BK0001.DC", "con_code": "000001.SZ", "name": "平安银行", }, ), target_trade_date=TARGET_DATE, observed_at=STARTED_AT, ) member = MembershipRecord( source_snapshot_id=snapshot.snapshot_id, trade_date=TARGET_DATE, sector_type=SectorType.CONCEPT, sector_code="BK0001.DC", sector_name="示例概念", stock_code="000001.SZ", stock_name="平安银行", ) assert repository.save_source_snapshots((snapshot,)).inserted == 1 assert repository.save_source_snapshots((snapshot,)).unchanged == 1 assert repository.save_memberships((member,)).inserted == 1 assert repository.save_memberships((member,)).unchanged == 1 with pytest.raises(ValueError, match="cannot change content"): repository.save_memberships((replace(member, stock_name="已改变"),)) def test_partial_and_failed_revisions_never_replace_last_good() -> None: repository = InMemorySectorRadarRepository() successful = make_running("success-a") partial = make_running("partial-b") failed = make_running("failed-c", TARGET_DATE + timedelta(days=1)) repository.create_publication(successful) repository.finish_publication(finish(successful, PublicationStatus.SUCCESS)) repository.create_publication(partial) repository.finish_publication(finish(partial, PublicationStatus.PARTIAL, offset_minutes=6)) repository.create_publication(failed) repository.finish_publication(finish(failed, PublicationStatus.FAILED, offset_minutes=7)) last_good = repository.get_last_good_publication() assert last_good is not None assert last_good.publication_id == "success-a" assert repository.list_successful_dates() == (TARGET_DATE,) def test_publication_identity_allows_sequential_same_date_revisions() -> None: repository = InMemorySectorRadarRepository() first = make_running("revision-a") second = make_running("revision-b") assert repository.create_publication(first).inserted == 1 with pytest.raises(ValueError, match="already has a running"): repository.create_publication(second) with pytest.raises(ValueError, match="terminal"): repository.finish_publication(first) repository.finish_publication(finish(first, PublicationStatus.FAILED)) assert repository.create_publication(second).inserted == 1 with pytest.raises(ValueError, match="running status"): repository.finish_publication(finish(first, PublicationStatus.SUCCESS))