"""P0-03 核心测试: Prediction 幂等指纹。 - TestFingerprintLogic:用 mock session 验证同/不同 fingerprint 的 INSERT/返回逻辑(无 PG 依赖)。 - TestFingerprintDeterminism:纯 hash 稳定性(无 PG 依赖)。 运行: pytest tests/test_p0_prediction_fingerprint.py -v """ from __future__ import annotations from unittest.mock import MagicMock import pytest from src.db.models import Prediction from src.llm.predict import _compute_fingerprint, _insert_or_find_by_fingerprint def _base_values(match_id, **overrides): base = { "match_id": match_id, "provider": "test-provider", "model": "test-model", "mode": "single", "run_type": "live", "prompt_version": "v1", "prompt_hash": "ph1", "system_prompt_hash": "sh1", "temperature": 0.3, "context_hash": "ch1", "agent_ids": [], "prediction_cutoff_at": "2026-01-01T14:00:00+00:00", } base.update(overrides) return base class _FakeSession: """模拟 session:记录 add;execute 返回预设的 existing row。""" def __init__(self, existing=None): self._existing = existing self.added: list = [] self.flushed = 0 def add(self, obj): self.added.append(obj) async def execute(self, stmt): existing = self._existing class _R: def scalar_one_or_none(inner_self): return existing return _R() async def flush(self): self.flushed += 1 async def refresh(self, obj): if getattr(obj, "id", None) is None: obj.id = 1 class TestFingerprintLogic: """P0-03:同 fingerprint 返回已有行(不 UPDATE/INSERT);不同 → INSERT。""" @pytest.mark.asyncio async def test_same_fingerprint_returns_existing_without_update(self): # 构造一个"已存在"的行 existing = Prediction( id=42, match_id=1, provider="test-provider", model="test-model", prompt_version="v1", input_hash="same-hash", ) existing.pred_home_goals = 2.0 existing.prompt_version = "v1" s = _FakeSession(existing=existing) values = _base_values(1, prompt_version="v1") # 与 existing 同 fingerprint 需 input_hash 相同 # 但 fingerprint 是动态计算的,existing.input_hash 需匹配。直接让 fake 返回 existing。 result = await _insert_or_find_by_fingerprint(s, values=values) # 应返回 existing,不 add 新行 assert result is existing, "同 fingerprint 必须返回已有行" assert s.added == [], "同 fingerprint 不应 INSERT" assert result.pred_home_goals == 2.0, "返回的应是已有行(字段不变)" @pytest.mark.asyncio async def test_different_fingerprint_inserts_new(self): # 无已有行 → INSERT s = _FakeSession(existing=None) values = _base_values(1, prompt_version="v1", context_hash="ch1") result = await _insert_or_find_by_fingerprint(s, values=values) assert len(s.added) == 1, "无已有行时应 INSERT" assert isinstance(s.added[0], Prediction) # input_hash 应被设为指纹 assert result.input_hash is not None and len(result.input_hash) == 64 # SHA-256 hex @pytest.mark.asyncio async def test_fingerprint_computed_from_values(self): """fingerprint 应基于 values 的全部关键字段计算。""" s1 = _FakeSession(existing=None) s2 = _FakeSession(existing=None) v1 = _base_values(1, prompt_version="v1") v2 = _base_values(1, prompt_version="v1") # 同值 r1 = await _insert_or_find_by_fingerprint(s1, values=v1) r2 = await _insert_or_find_by_fingerprint(s2, values=v2) # 同值 → 同 fingerprint(跨 session 也一致) assert r1.input_hash == r2.input_hash @pytest.mark.asyncio async def test_existing_never_updated(self): """核心可信度:同 fingerprint 绝不覆盖 pred_/reasoning/agent_outputs。""" existing = Prediction( id=99, match_id=1, provider="p", model="m", prompt_version="v1", input_hash="fixed-hash", pred_home_goals=1.0, pred_away_goals=0.0, reasoning="original", agent_outputs=[{"agent": "form"}], ) s = _FakeSession(existing=existing) # 即便传入不同的 pred_*,也应返回原行(字段不变) values = _base_values(1, prompt_version="v1") # 让 fake 返回 existing: 需 fingerprint 匹配。fake.execute 始终返回 existing。 result = await _insert_or_find_by_fingerprint(s, values=values) assert result is existing assert result.pred_home_goals == 1.0, "pred_home_goals 不应被覆盖" assert result.reasoning == "original", "reasoning 不应被覆盖" assert result.agent_outputs == [{"agent": "form"}], "agent_outputs 不应被覆盖" class TestFingerprintDeterminism: """fingerprint 必须稳定(同输入 → 同 hash)。""" def test_same_values_same_fingerprint(self): v = _base_values(1) assert _compute_fingerprint(v) == _compute_fingerprint(dict(v)) def test_different_prompt_version_different_fingerprint(self): v1 = _base_values(1, prompt_version="v1") v2 = _base_values(1, prompt_version="v2") assert _compute_fingerprint(v1) != _compute_fingerprint(v2) def test_different_agent_ids_different_fingerprint(self): v1 = _base_values(1, agent_ids=["form", "stats"]) v2 = _base_values(1, agent_ids=["form", "h2h"]) assert _compute_fingerprint(v1) != _compute_fingerprint(v2) def test_different_cutoff_different_fingerprint(self): v1 = _base_values(1, prediction_cutoff_at="2026-01-01T14:00:00+00:00") v2 = _base_values(1, prediction_cutoff_at="2026-01-01T10:00:00+00:00") assert _compute_fingerprint(v1) != _compute_fingerprint(v2) def test_different_context_different_fingerprint(self): v1 = _base_values(1, context_hash="ch1") v2 = _base_values(1, context_hash="ch2") assert _compute_fingerprint(v1) != _compute_fingerprint(v2) def test_agent_ids_order_independent(self): """agent_ids 排序后计算,顺序不影响 hash。""" v1 = _base_values(1, agent_ids=["stats", "form"]) v2 = _base_values(1, agent_ids=["form", "stats"]) assert _compute_fingerprint(v1) == _compute_fingerprint(v2)