183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
"""
|
|
Tests for claude_client.py — JSON parsing, payload building, slot parsing,
|
|
UNOWNED marker stripping, and CardEntry construction.
|
|
"""
|
|
import pytest
|
|
from app.services.ai.claude_client import (
|
|
_parse_json,
|
|
_parse_slot,
|
|
_build_payload,
|
|
CardEntry,
|
|
CutEntry,
|
|
DeckPayload,
|
|
UNOWNED_RE,
|
|
)
|
|
from app.models.deck import CardSlot
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseJson:
|
|
def test_valid_json(self):
|
|
text = '{"deck_name": "Test", "cards": []}'
|
|
result = _parse_json(text)
|
|
assert result["deck_name"] == "Test"
|
|
|
|
def test_strips_markdown_fences(self):
|
|
text = '''```json
|
|
{"deck_name": "Test", "cards": []}
|
|
```'''
|
|
result = _parse_json(text)
|
|
assert result["deck_name"] == "Test"
|
|
|
|
def test_strips_plain_fences(self):
|
|
text = '''```
|
|
{"deck_name": "Test", "cards": []}
|
|
```'''
|
|
result = _parse_json(text)
|
|
assert result["deck_name"] == "Test"
|
|
|
|
def test_raises_on_invalid_json(self):
|
|
with pytest.raises(ValueError, match="not valid JSON"):
|
|
_parse_json('{"broken: json}')
|
|
|
|
def test_raises_on_empty_string(self):
|
|
with pytest.raises(ValueError):
|
|
_parse_json("")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_slot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseSlot:
|
|
def test_valid_slots(self):
|
|
assert _parse_slot("creature") == CardSlot.CREATURE
|
|
assert _parse_slot("instant") == CardSlot.INSTANT
|
|
assert _parse_slot("sorcery") == CardSlot.SORCERY
|
|
assert _parse_slot("enchantment") == CardSlot.ENCHANTMENT
|
|
assert _parse_slot("artifact") == CardSlot.ARTIFACT
|
|
assert _parse_slot("planeswalker") == CardSlot.PLANESWALKER
|
|
assert _parse_slot("land") == CardSlot.LAND
|
|
assert _parse_slot("battle") == CardSlot.BATTLE
|
|
|
|
def test_plural_aliases(self):
|
|
assert _parse_slot("creatures") == CardSlot.CREATURE
|
|
assert _parse_slot("instants") == CardSlot.INSTANT
|
|
assert _parse_slot("sorceries") == CardSlot.SORCERY
|
|
assert _parse_slot("lands") == CardSlot.LAND
|
|
|
|
def test_case_insensitive(self):
|
|
assert _parse_slot("CREATURE") == CardSlot.CREATURE
|
|
assert _parse_slot("Land") == CardSlot.LAND
|
|
|
|
def test_unknown_defaults_to_creature(self):
|
|
assert _parse_slot("unknown_type") == CardSlot.CREATURE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CardEntry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCardEntry:
|
|
def test_basic_card(self):
|
|
entry = CardEntry({"name": "Sol Ring", "slot": "artifact", "quantity": 1})
|
|
assert entry.card_name == "Sol Ring"
|
|
assert entry.slot == CardSlot.ARTIFACT
|
|
assert entry.quantity == 1
|
|
assert entry.is_owned is True
|
|
|
|
def test_unowned_marker_stripped(self):
|
|
entry = CardEntry({"name": "Sol Ring [UNOWNED]", "slot": "artifact", "quantity": 1})
|
|
assert entry.card_name == "Sol Ring"
|
|
assert entry.is_owned is False
|
|
|
|
def test_unowned_marker_case_insensitive(self):
|
|
entry = CardEntry({"name": "Sol Ring [unowned]", "slot": "artifact", "quantity": 1})
|
|
assert entry.card_name == "Sol Ring"
|
|
assert entry.is_owned is False
|
|
|
|
def test_quantity_defaults_to_1(self):
|
|
entry = CardEntry({"name": "Forest", "slot": "land"})
|
|
assert entry.quantity == 1
|
|
|
|
def test_quantity_minimum_1(self):
|
|
entry = CardEntry({"name": "Forest", "slot": "land", "quantity": 0})
|
|
assert entry.quantity == 1
|
|
|
|
def test_reasoning_stored(self):
|
|
entry = CardEntry({"name": "Sol Ring", "slot": "artifact", "reasoning": "Best ramp"})
|
|
assert entry.reasoning == "Best ramp"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_payload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildPayload:
|
|
def _card(self, name="Sol Ring", slot="artifact"):
|
|
return {"name": name, "slot": slot, "quantity": 1, "reasoning": "Good card"}
|
|
|
|
def test_standard_cards_key(self):
|
|
data = {
|
|
"deck_name": "Test Deck",
|
|
"strategy_summary": "A test deck",
|
|
"cards": [self._card()]
|
|
}
|
|
payload = _build_payload(data)
|
|
assert payload.deck_name == "Test Deck"
|
|
assert payload.strategy_summary == "A test deck"
|
|
assert len(payload.cards) == 1
|
|
assert payload.cards[0].card_name == "Sol Ring"
|
|
|
|
def test_alternative_decklist_key(self):
|
|
data = {
|
|
"deck_name": "Test Deck",
|
|
"decklist": [self._card()]
|
|
}
|
|
payload = _build_payload(data)
|
|
assert len(payload.cards) == 1
|
|
|
|
def test_alternative_deck_key(self):
|
|
data = {
|
|
"deck_name": "Test Deck",
|
|
"deck": [self._card()]
|
|
}
|
|
payload = _build_payload(data)
|
|
assert len(payload.cards) == 1
|
|
|
|
def test_cards_key_takes_priority_over_deck(self):
|
|
data = {
|
|
"cards": [self._card("Sol Ring")],
|
|
"deck": [self._card("Black Lotus")],
|
|
}
|
|
payload = _build_payload(data)
|
|
assert payload.cards[0].card_name == "Sol Ring"
|
|
|
|
def test_default_deck_name(self):
|
|
payload = _build_payload({})
|
|
assert payload.deck_name == "Untitled Deck"
|
|
|
|
def test_skips_cards_without_name(self):
|
|
data = {"cards": [{"slot": "artifact"}, self._card()]}
|
|
payload = _build_payload(data)
|
|
assert len(payload.cards) == 1
|
|
|
|
def test_cuts_parsed(self):
|
|
data = {
|
|
"cards": [],
|
|
"cuts": [{"name": "Swamp", "reasoning": "Too slow"}]
|
|
}
|
|
payload = _build_payload(data)
|
|
assert len(payload.cuts) == 1
|
|
assert payload.cuts[0].card_name == "Swamp"
|
|
|
|
def test_empty_data(self):
|
|
payload = _build_payload({})
|
|
assert payload.deck_name == "Untitled Deck"
|
|
assert payload.strategy_summary == ""
|
|
assert payload.cards == []
|
|
assert payload.cuts == []
|