"""回归测试: P0-3 — LLM 解析失败不能产生假成功预测。 验证链路: 1. provider.py: JSON 解析失败时必须设置 error 2. predict.py: resp.parsed is None 时必须抛错,不能 fallback 到 {} 3. validation.py: 必填字段缺失时必须失败,不能静默给默认值 """ from __future__ import annotations from unittest.mock import MagicMock import pytest from src.llm.provider import LLMProvider, LLMResponse from src.llm.validation import validate_prediction_output class TestProviderJsonParseError: """P0-3 Part 1: provider.py JSON 解析失败必须设置 error。""" @pytest.mark.asyncio async def test_invalid_json_sets_error(self, monkeypatch): """LLM 返回非 JSON 内容时,error 必须非空。""" async def fake_post(*args, **kwargs): class FakeResp: status_code = 200 def raise_for_status(self): pass def json(self): return { "choices": [{"message": {"content": "我不确定,可能是平局"}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}, } return FakeResp() import httpx monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) p = LLMProvider(api_key="test", model="gpt-4o") resp = await p.chat("sys", "user", json_mode=True) # P0-3: JSON 解析失败必须设置 error assert resp.error is not None, "JSON 解析失败应设置 error" assert resp.parsed is None @pytest.mark.asyncio async def test_code_block_json_works(self, monkeypatch): """LLM 返回 ```json {...}}``` 时应成功解析。""" async def fake_post(*args, **kwargs): class FakeResp: status_code = 200 def raise_for_status(self): pass def json(self): return { "choices": [{"message": {"content": '```json\n{"pred_home_goals": 1.5, "pred_away_goals": 1.0, "pred_1x2": "1", "subjective_confidence": 0.7}\n```'}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}, } return FakeResp() import httpx monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) p = LLMProvider(api_key="test", model="gpt-4o") resp = await p.chat("sys", "user", json_mode=True) assert resp.error is None assert resp.parsed is not None assert resp.parsed["pred_1x2"] == "1" class TestValidationNoSilentDefaults: """P0-3 Part 3: validation.py 必填字段缺失时必须失败。""" def test_missing_pred_home_goals_raises(self): """缺少 pred_home_goals 必须报错,不能默认为 0。""" with pytest.raises((ValueError, KeyError)): validate_prediction_output({ "pred_away_goals": 1, "pred_1x2": "1", "subjective_confidence": 0.7, }) def test_missing_pred_1x2_raises(self): """缺少 pred_1x2 必须报错,不能默认为 X。""" with pytest.raises(ValueError, match="Missing required field: pred_1x2"): validate_prediction_output({ "pred_home_goals": 1, "pred_away_goals": 0, "subjective_confidence": 0.7, }) def test_missing_confidence_raises(self): """缺少 subjective_confidence 必须报错,不能默认为 0.5。""" with pytest.raises(ValueError, match="Missing required field: subjective_confidence"): validate_prediction_output({ "pred_home_goals": 1, "pred_away_goals": 0, "pred_1x2": "1", }) def test_empty_dict_raises(self): """空 dict 必须报错(不能产生 0-0 X 0.5 的假预测)。""" with pytest.raises((ValueError, KeyError)): validate_prediction_output({}) def test_valid_input_passes(self): """完整的合法输入应通过。""" result = validate_prediction_output({ "pred_home_goals": 1.5, "pred_away_goals": 1.0, "pred_1x2": "1", "subjective_confidence": 0.7, }) assert result.pred_home_goals == 2 # 1.5 → round → 2 assert result.pred_away_goals == 1 assert result.pred_1x2 == "1" assert result.subjective_confidence == 0.7