import json import pytest import respx from httpx import Response from app.tarot import pipeline as p from app.models import TarotInterpretRequest def _valid_response_text(): return json.dumps({ "summary": "S", "cards": [ {"position": "과거", "card": "the-fool", "reversed": False, "interpretation": "i", "advice": "a", "evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}, {"position": "현재", "card": "the-lovers", "reversed": True, "interpretation": "i", "advice": "a", "evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}, {"position": "미래", "card": "ten-of-cups", "reversed": False, "interpretation": "i", "advice": "a", "evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}, ], "interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}], "advice": "A", "warning": None, "confidence": "medium", }) def _claude_resp(text, in_tok=100, out_tok=200): return { "content": [{"type": "text", "text": text}], "usage": {"input_tokens": in_tok, "output_tokens": out_tok}, } def _req(): return TarotInterpretRequest( spread_type="three_card", category="연애", question="Q", cards=[ {"position": "과거", "card_id": "the-fool", "reversed": False}, {"position": "현재", "card_id": "the-lovers", "reversed": True}, {"position": "미래", "card_id": "ten-of-cups", "reversed": False}, ], cards_reference="REFERENCE", context_meta={"major_minor_ratio": "2:1"}, ) @pytest.mark.asyncio async def test_interpret_happy_path(monkeypatch): monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test") with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text()))) out = await p.interpret(_req()) assert out["interpretation_json"]["confidence"] == "medium" assert out["tokens_in"] == 100 assert out["tokens_out"] == 200 assert out["reroll_count"] == 0 assert out["cost_usd"] > 0 @pytest.mark.asyncio async def test_interpret_codeblock_strip(monkeypatch): monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test") wrapped = "```json\n" + _valid_response_text() + "\n```" with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped))) out = await p.interpret(_req()) assert out["interpretation_json"]["summary"] == "S" @pytest.mark.asyncio async def test_interpret_reroll_on_validation_fail(monkeypatch): monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test") bad = json.loads(_valid_response_text()) bad["cards"][0]["evidence"]["card_meaning_used"] = "" bad_text = json.dumps(bad) with respx.mock(base_url="https://api.anthropic.com") as mock: route = mock.post("/v1/messages") route.side_effect = [ Response(200, json=_claude_resp(bad_text)), Response(200, json=_claude_resp(_valid_response_text())), ] out = await p.interpret(_req()) assert out["reroll_count"] == 1 assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k" @pytest.mark.asyncio async def test_interpret_raises_when_both_attempts_fail(monkeypatch): monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test") bad = json.loads(_valid_response_text()) bad["cards"][0]["evidence"]["card_meaning_used"] = "" bad_text = json.dumps(bad) with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text))) with pytest.raises(p.TarotError): await p.interpret(_req()) @pytest.mark.asyncio async def test_interpret_raises_when_api_key_missing(monkeypatch): monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "") with pytest.raises(p.TarotError): await p.interpret(_req()) def test_calc_cost(): assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0) assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0) assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)