diff --git a/backend/tests/test_claude_client.py b/backend/tests/test_claude_client.py new file mode 100644 index 0000000..29f6669 --- /dev/null +++ b/backend/tests/test_claude_client.py @@ -0,0 +1,182 @@ +""" +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 == []