mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat: add keyword normalization and protection grant detection, fix template syntax and polling issues
This commit is contained in:
parent
86ec68acb4
commit
06d8796316
17 changed files with 1692 additions and 611 deletions
182
code/tests/test_keyword_normalization.py
Normal file
182
code/tests/test_keyword_normalization.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""Tests for keyword normalization (M1 - Tagging Refinement)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from code.tagging import tag_utils, tag_constants
|
||||
|
||||
|
||||
class TestKeywordNormalization:
|
||||
"""Test suite for normalize_keywords function."""
|
||||
|
||||
def test_canonical_mappings(self):
|
||||
"""Test that variant keywords map to canonical forms."""
|
||||
raw = ['Commander Ninjutsu', 'Flying', 'Trample']
|
||||
allowlist = tag_constants.KEYWORD_ALLOWLIST
|
||||
frequency_map = {
|
||||
'Commander Ninjutsu': 2,
|
||||
'Flying': 100,
|
||||
'Trample': 50
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert 'Ninjutsu' in result
|
||||
assert 'Flying' in result
|
||||
assert 'Trample' in result
|
||||
assert 'Commander Ninjutsu' not in result
|
||||
|
||||
def test_singleton_pruning(self):
|
||||
"""Test that singleton keywords are pruned unless allowlisted."""
|
||||
raw = ['Allons-y!', 'Flying', 'Take 59 Flights of Stairs']
|
||||
allowlist = {'Flying'} # Only Flying is allowlisted
|
||||
frequency_map = {
|
||||
'Allons-y!': 1,
|
||||
'Flying': 100,
|
||||
'Take 59 Flights of Stairs': 1
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert 'Flying' in result
|
||||
assert 'Allons-y!' not in result
|
||||
assert 'Take 59 Flights of Stairs' not in result
|
||||
|
||||
def test_case_normalization(self):
|
||||
"""Test that keywords are normalized to proper case."""
|
||||
raw = ['flying', 'TRAMPLE', 'vigilance']
|
||||
allowlist = {'Flying', 'Trample', 'Vigilance'}
|
||||
frequency_map = {
|
||||
'flying': 100,
|
||||
'TRAMPLE': 50,
|
||||
'vigilance': 75
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
# Case normalization happens via the map
|
||||
# If not in map, original case is preserved
|
||||
assert len(result) == 3
|
||||
|
||||
def test_partner_exclusion(self):
|
||||
"""Test that partner keywords remain excluded."""
|
||||
raw = ['Partner', 'Flying', 'Trample']
|
||||
allowlist = {'Flying', 'Trample'}
|
||||
frequency_map = {
|
||||
'Partner': 50,
|
||||
'Flying': 100,
|
||||
'Trample': 50
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert 'Flying' in result
|
||||
assert 'Trample' in result
|
||||
assert 'Partner' not in result # Excluded
|
||||
assert 'partner' not in result
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test that empty input returns empty list."""
|
||||
result = tag_utils.normalize_keywords([], set(), {})
|
||||
assert result == []
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Test that whitespace is properly stripped."""
|
||||
raw = [' Flying ', 'Trample ', ' Vigilance']
|
||||
allowlist = {'Flying', 'Trample', 'Vigilance'}
|
||||
frequency_map = {
|
||||
'Flying': 100,
|
||||
'Trample': 50,
|
||||
'Vigilance': 75
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert 'Flying' in result
|
||||
assert 'Trample' in result
|
||||
assert 'Vigilance' in result
|
||||
|
||||
def test_deduplication(self):
|
||||
"""Test that duplicate keywords are deduplicated."""
|
||||
raw = ['Flying', 'Flying', 'Trample', 'Flying']
|
||||
allowlist = {'Flying', 'Trample'}
|
||||
frequency_map = {
|
||||
'Flying': 100,
|
||||
'Trample': 50
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert result.count('Flying') == 1
|
||||
assert result.count('Trample') == 1
|
||||
|
||||
def test_non_string_entries_skipped(self):
|
||||
"""Test that non-string entries are safely skipped."""
|
||||
raw = ['Flying', None, 123, 'Trample', '']
|
||||
allowlist = {'Flying', 'Trample'}
|
||||
frequency_map = {
|
||||
'Flying': 100,
|
||||
'Trample': 50
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert 'Flying' in result
|
||||
assert 'Trample' in result
|
||||
assert len(result) == 2
|
||||
|
||||
def test_invalid_input_raises_error(self):
|
||||
"""Test that non-iterable input raises ValueError."""
|
||||
with pytest.raises(ValueError, match="raw must be iterable"):
|
||||
tag_utils.normalize_keywords("not-a-list", set(), {})
|
||||
|
||||
def test_allowlist_preserves_singletons(self):
|
||||
"""Test that allowlisted keywords survive even if they're singletons."""
|
||||
raw = ['Myriad', 'Flying', 'Cascade']
|
||||
allowlist = {'Flying', 'Myriad', 'Cascade'} # All allowlisted
|
||||
frequency_map = {
|
||||
'Myriad': 1, # Singleton
|
||||
'Flying': 100,
|
||||
'Cascade': 1 # Singleton
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(raw, allowlist, frequency_map)
|
||||
|
||||
assert 'Myriad' in result # Preserved despite being singleton
|
||||
assert 'Flying' in result
|
||||
assert 'Cascade' in result # Preserved despite being singleton
|
||||
|
||||
|
||||
class TestKeywordIntegration:
|
||||
"""Integration tests for keyword normalization in tagging flow."""
|
||||
|
||||
def test_normalization_preserves_evergreen_keywords(self):
|
||||
"""Test that common evergreen keywords are always preserved."""
|
||||
evergreen = ['Flying', 'Trample', 'Vigilance', 'Haste', 'Deathtouch', 'Lifelink']
|
||||
allowlist = tag_constants.KEYWORD_ALLOWLIST
|
||||
frequency_map = {kw: 100 for kw in evergreen} # All common
|
||||
|
||||
result = tag_utils.normalize_keywords(evergreen, allowlist, frequency_map)
|
||||
|
||||
for kw in evergreen:
|
||||
assert kw in result
|
||||
|
||||
def test_crossover_keywords_pruned(self):
|
||||
"""Test that crossover-specific singletons are pruned."""
|
||||
crossover_singletons = [
|
||||
'Gae Bolg', # Final Fantasy
|
||||
'Psychic Defense', # Warhammer 40K
|
||||
'Allons-y!', # Doctor Who
|
||||
'Flying' # Evergreen (control)
|
||||
]
|
||||
allowlist = {'Flying'} # Only Flying allowed
|
||||
frequency_map = {
|
||||
'Gae Bolg': 1,
|
||||
'Psychic Defense': 1,
|
||||
'Allons-y!': 1,
|
||||
'Flying': 100
|
||||
}
|
||||
|
||||
result = tag_utils.normalize_keywords(crossover_singletons, allowlist, frequency_map)
|
||||
|
||||
assert result == ['Flying'] # Only evergreen survived
|
||||
169
code/tests/test_protection_grant_detection.py
Normal file
169
code/tests/test_protection_grant_detection.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""
|
||||
Tests for protection grant detection (M2).
|
||||
|
||||
Tests the ability to distinguish between cards that grant protection
|
||||
and cards that have inherent protection.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from code.tagging.protection_grant_detection import (
|
||||
is_granting_protection,
|
||||
categorize_protection_card
|
||||
)
|
||||
|
||||
|
||||
class TestGrantDetection:
|
||||
"""Test grant verb detection."""
|
||||
|
||||
def test_gains_hexproof(self):
|
||||
"""Cards with 'gains hexproof' should be detected as granting."""
|
||||
text = "Target creature gains hexproof until end of turn."
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
def test_gives_indestructible(self):
|
||||
"""Cards with 'gives indestructible' should be detected as granting."""
|
||||
text = "This creature gives target creature indestructible."
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
def test_creatures_you_control_have(self):
|
||||
"""Mass grant pattern should be detected."""
|
||||
text = "Creatures you control have hexproof."
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
def test_equipped_creature_gets(self):
|
||||
"""Equipment grant pattern should be detected."""
|
||||
text = "Equipped creature gets +2/+2 and has indestructible."
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
|
||||
class TestInherentDetection:
|
||||
"""Test inherent protection detection."""
|
||||
|
||||
def test_creature_with_hexproof_keyword(self):
|
||||
"""Creature with hexproof keyword should not be detected as granting."""
|
||||
text = "Hexproof (This creature can't be the target of spells or abilities.)"
|
||||
keywords = "Hexproof"
|
||||
assert not is_granting_protection(text, keywords)
|
||||
|
||||
def test_indestructible_artifact(self):
|
||||
"""Artifact with indestructible keyword should not be detected as granting."""
|
||||
text = "Indestructible"
|
||||
keywords = "Indestructible"
|
||||
assert not is_granting_protection(text, keywords)
|
||||
|
||||
def test_ward_creature(self):
|
||||
"""Creature with Ward should not be detected as granting (unless it grants to others)."""
|
||||
text = "Ward {2}"
|
||||
keywords = "Ward"
|
||||
assert not is_granting_protection(text, keywords)
|
||||
|
||||
|
||||
class TestMixedCases:
|
||||
"""Test cards that both grant and have protection."""
|
||||
|
||||
def test_creature_with_self_grant(self):
|
||||
"""Creature that grants itself protection should be detected."""
|
||||
text = "This creature gains indestructible until end of turn."
|
||||
keywords = ""
|
||||
assert is_granting_protection(text, keywords)
|
||||
|
||||
def test_equipment_with_inherent_and_grant(self):
|
||||
"""Equipment with indestructible that grants protection."""
|
||||
text = "Indestructible. Equipped creature has hexproof."
|
||||
keywords = "Indestructible"
|
||||
# Should be detected as granting because of "has hexproof"
|
||||
assert is_granting_protection(text, keywords)
|
||||
|
||||
|
||||
class TestExclusions:
|
||||
"""Test exclusion patterns."""
|
||||
|
||||
def test_cant_have_hexproof(self):
|
||||
"""Cards that prevent protection should not be tagged."""
|
||||
text = "Creatures your opponents control can't have hexproof."
|
||||
assert not is_granting_protection(text, "")
|
||||
|
||||
def test_loses_indestructible(self):
|
||||
"""Cards that remove protection should not be tagged."""
|
||||
text = "Target creature loses indestructible until end of turn."
|
||||
assert not is_granting_protection(text, "")
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and special patterns."""
|
||||
|
||||
def test_protection_from_color(self):
|
||||
"""Protection from [quality] in keywords without grant text."""
|
||||
text = "Protection from red"
|
||||
keywords = "Protection from red"
|
||||
assert not is_granting_protection(text, keywords)
|
||||
|
||||
def test_empty_text(self):
|
||||
"""Empty text should return False."""
|
||||
assert not is_granting_protection("", "")
|
||||
|
||||
def test_none_text(self):
|
||||
"""None text should return False."""
|
||||
assert not is_granting_protection(None, "")
|
||||
|
||||
|
||||
class TestCategorization:
|
||||
"""Test full card categorization."""
|
||||
|
||||
def test_shell_shield_is_grant(self):
|
||||
"""Shell Shield grants hexproof - should be Grant."""
|
||||
text = "Target creature gets +0/+3 and gains hexproof until end of turn."
|
||||
cat = categorize_protection_card("Shell Shield", text, "", "Instant")
|
||||
assert cat == "Grant"
|
||||
|
||||
def test_geist_of_saint_traft_is_mixed(self):
|
||||
"""Geist has hexproof and creates tokens - Mixed."""
|
||||
text = "Hexproof. Whenever this attacks, create a token."
|
||||
keywords = "Hexproof"
|
||||
cat = categorize_protection_card("Geist", text, keywords, "Creature")
|
||||
# Has hexproof keyword, so inherent
|
||||
assert cat in ("Inherent", "Mixed")
|
||||
|
||||
def test_darksteel_brute_is_inherent(self):
|
||||
"""Darksteel Brute has indestructible - should be Inherent."""
|
||||
text = "Indestructible"
|
||||
keywords = "Indestructible"
|
||||
cat = categorize_protection_card("Darksteel Brute", text, keywords, "Artifact")
|
||||
assert cat == "Inherent"
|
||||
|
||||
def test_scion_of_oona_is_grant(self):
|
||||
"""Scion of Oona grants shroud to other faeries - should be Grant."""
|
||||
text = "Other Faeries you control have shroud."
|
||||
keywords = "Flying, Flash"
|
||||
cat = categorize_protection_card("Scion of Oona", text, keywords, "Creature")
|
||||
assert cat == "Grant"
|
||||
|
||||
|
||||
class TestRealWorldCards:
|
||||
"""Test against actual card samples from baseline audit."""
|
||||
|
||||
def test_bulwark_ox(self):
|
||||
"""Bulwark Ox - grants hexproof and indestructible."""
|
||||
text = "Sacrifice: Creatures you control with counters gain hexproof and indestructible"
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
def test_bloodsworn_squire(self):
|
||||
"""Bloodsworn Squire - grants itself indestructible."""
|
||||
text = "This creature gains indestructible until end of turn"
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
def test_kaldra_compleat(self):
|
||||
"""Kaldra Compleat - equipment with indestructible that grants."""
|
||||
text = "Indestructible. Equipped creature gets +5/+5 and has indestructible"
|
||||
keywords = "Indestructible"
|
||||
assert is_granting_protection(text, keywords)
|
||||
|
||||
def test_ward_sliver(self):
|
||||
"""Ward Sliver - grants protection to all slivers."""
|
||||
text = "All Slivers have protection from the chosen color"
|
||||
assert is_granting_protection(text, "")
|
||||
|
||||
def test_rebbec(self):
|
||||
"""Rebbec - grants protection to artifacts."""
|
||||
text = "Artifacts you control have protection from each mana value"
|
||||
assert is_granting_protection(text, "")
|
||||
Loading…
Add table
Add a link
Reference in a new issue