mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-25 22:46:31 +01:00
feat: add theme editorial quality system with scoring, linting, and comprehensive documentation
This commit is contained in:
parent
de8087d940
commit
f2882cc2e0
12 changed files with 3169 additions and 157 deletions
976
code/tests/test_theme_editorial_service.py
Normal file
976
code/tests/test_theme_editorial_service.py
Normal file
|
|
@ -0,0 +1,976 @@
|
|||
"""Tests for ThemeEditorialService (R12 M1).
|
||||
|
||||
Tests editorial quality scoring, validation, and metadata management
|
||||
following R9 testing standards.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from code.web.services.theme_editorial_service import (
|
||||
ThemeEditorialService,
|
||||
get_editorial_service,
|
||||
)
|
||||
from code.web.services.base import NotFoundError
|
||||
from code.type_definitions_theme_catalog import ThemeEntry
|
||||
|
||||
|
||||
class TestEditorialService:
|
||||
"""Test ThemeEditorialService initialization and singleton pattern."""
|
||||
|
||||
def test_service_initialization(self):
|
||||
"""Test service can be instantiated."""
|
||||
service = ThemeEditorialService()
|
||||
assert service is not None
|
||||
|
||||
def test_singleton_getter(self):
|
||||
"""Test get_editorial_service returns singleton."""
|
||||
service1 = get_editorial_service()
|
||||
service2 = get_editorial_service()
|
||||
assert service1 is service2
|
||||
|
||||
|
||||
class TestQualityScoring:
|
||||
"""Test editorial quality score calculation."""
|
||||
|
||||
def test_perfect_score(self):
|
||||
"""Test entry with all editorial fields gets high score."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='test-theme',
|
||||
theme='Test Theme',
|
||||
synergies=['Synergy1', 'Synergy2'],
|
||||
description='A comprehensive description of the theme strategy that exceeds fifty characters for bonus points.',
|
||||
example_commanders=['Commander 1', 'Commander 2', 'Commander 3', 'Commander 4'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3', 'Card 4', 'Card 5', 'Card 6'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
synergy_commanders=['Synergy Commander 1'],
|
||||
)
|
||||
score = service.calculate_quality_score(entry)
|
||||
assert score == 100, f"Expected perfect score 100, got {score}"
|
||||
|
||||
def test_minimal_score(self):
|
||||
"""Test entry with no editorial fields gets zero score."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='minimal-theme',
|
||||
theme='Minimal Theme',
|
||||
synergies=['Synergy1'],
|
||||
)
|
||||
score = service.calculate_quality_score(entry)
|
||||
assert score == 0, f"Expected score 0 for minimal entry, got {score}"
|
||||
|
||||
def test_partial_score_with_description_only(self):
|
||||
"""Test entry with only description gets appropriate score."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='desc-only',
|
||||
theme='Description Only',
|
||||
synergies=[],
|
||||
description='Short description.',
|
||||
)
|
||||
score = service.calculate_quality_score(entry)
|
||||
assert score == 20, f"Expected score 20 (description only), got {score}"
|
||||
|
||||
def test_description_length_bonus(self):
|
||||
"""Test bonus points for longer descriptions."""
|
||||
service = ThemeEditorialService()
|
||||
# Short description
|
||||
entry_short = ThemeEntry(
|
||||
id='short',
|
||||
theme='Short',
|
||||
synergies=[],
|
||||
description='Short.',
|
||||
)
|
||||
score_short = service.calculate_quality_score(entry_short)
|
||||
|
||||
# Long description
|
||||
entry_long = ThemeEntry(
|
||||
id='long',
|
||||
theme='Long',
|
||||
synergies=[],
|
||||
description='A much longer and more comprehensive description that exceeds fifty characters.',
|
||||
)
|
||||
score_long = service.calculate_quality_score(entry_long)
|
||||
|
||||
assert score_long > score_short, "Long description should score higher"
|
||||
assert score_long == 30, f"Expected 30 (20 base + 10 bonus), got {score_long}"
|
||||
|
||||
def test_commander_count_bonus(self):
|
||||
"""Test bonus for multiple example commanders."""
|
||||
service = ThemeEditorialService()
|
||||
# Few commanders
|
||||
entry_few = ThemeEntry(
|
||||
id='few',
|
||||
theme='Few',
|
||||
synergies=[],
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
)
|
||||
score_few = service.calculate_quality_score(entry_few)
|
||||
|
||||
# Many commanders
|
||||
entry_many = ThemeEntry(
|
||||
id='many',
|
||||
theme='Many',
|
||||
synergies=[],
|
||||
example_commanders=['Commander 1', 'Commander 2', 'Commander 3', 'Commander 4'],
|
||||
)
|
||||
score_many = service.calculate_quality_score(entry_many)
|
||||
|
||||
assert score_many > score_few, "More commanders should score higher"
|
||||
assert score_few == 15, f"Expected 15 (base), got {score_few}"
|
||||
assert score_many == 25, f"Expected 25 (15 base + 10 bonus), got {score_many}"
|
||||
|
||||
def test_card_count_bonus(self):
|
||||
"""Test bonus for multiple example cards."""
|
||||
service = ThemeEditorialService()
|
||||
# Few cards
|
||||
entry_few = ThemeEntry(
|
||||
id='few',
|
||||
theme='Few',
|
||||
synergies=[],
|
||||
example_cards=['Card 1', 'Card 2'],
|
||||
)
|
||||
score_few = service.calculate_quality_score(entry_few)
|
||||
|
||||
# Many cards
|
||||
entry_many = ThemeEntry(
|
||||
id='many',
|
||||
theme='Many',
|
||||
synergies=[],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3', 'Card 4', 'Card 5', 'Card 6'],
|
||||
)
|
||||
score_many = service.calculate_quality_score(entry_many)
|
||||
|
||||
assert score_many > score_few, "More cards should score higher"
|
||||
assert score_many == 25, f"Expected 25 (15 base + 10 bonus), got {score_many}"
|
||||
|
||||
|
||||
class TestQualityTiers:
|
||||
"""Test quality tier classification. (Updated for M2 heuristics thresholds)"""
|
||||
|
||||
def test_excellent_tier(self):
|
||||
"""Test excellent tier threshold (>=75 with M2 heuristics)."""
|
||||
service = ThemeEditorialService()
|
||||
assert service.get_quality_tier(100) == 'Excellent'
|
||||
assert service.get_quality_tier(75) == 'Excellent'
|
||||
|
||||
def test_good_tier(self):
|
||||
"""Test good tier threshold (60-74 with M2 heuristics)."""
|
||||
service = ThemeEditorialService()
|
||||
assert service.get_quality_tier(74) == 'Good'
|
||||
assert service.get_quality_tier(60) == 'Good'
|
||||
|
||||
def test_fair_tier(self):
|
||||
"""Test fair tier threshold (40-59 with M2 heuristics)."""
|
||||
service = ThemeEditorialService()
|
||||
assert service.get_quality_tier(59) == 'Fair'
|
||||
assert service.get_quality_tier(40) == 'Fair'
|
||||
|
||||
def test_poor_tier(self):
|
||||
"""Test poor tier threshold (<40)."""
|
||||
service = ThemeEditorialService()
|
||||
assert service.get_quality_tier(39) == 'Poor'
|
||||
assert service.get_quality_tier(0) == 'Poor'
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Test editorial field validation."""
|
||||
|
||||
def test_valid_entry_no_issues(self):
|
||||
"""Test fully valid entry returns empty issues list."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='valid',
|
||||
theme='Valid Theme',
|
||||
synergies=['Synergy1', 'Synergy2'],
|
||||
description='A proper description of the theme strategy with sufficient detail.',
|
||||
description_source='manual',
|
||||
example_commanders=['Commander 1', 'Commander 2', 'Commander 3'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3', 'Card 4'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert len(issues) == 0, f"Expected no issues, got {issues}"
|
||||
|
||||
def test_missing_deck_archetype(self):
|
||||
"""Test validation catches missing deck archetype."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='missing-arch',
|
||||
theme='Missing Archetype',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('deck_archetype' in issue.lower() for issue in issues)
|
||||
|
||||
def test_invalid_deck_archetype(self):
|
||||
"""Test validation catches invalid deck archetype."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='invalid-arch',
|
||||
theme='Invalid Archetype',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='InvalidArchetype', # Not in ALLOWED_DECK_ARCHETYPES
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('invalid deck_archetype' in issue.lower() for issue in issues)
|
||||
|
||||
def test_missing_popularity_bucket(self):
|
||||
"""Test validation catches missing popularity bucket."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='missing-pop',
|
||||
theme='Missing Popularity',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('popularity_bucket' in issue.lower() for issue in issues)
|
||||
|
||||
def test_insufficient_commanders(self):
|
||||
"""Test validation recommends minimum 2 commanders."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='few-cmdr',
|
||||
theme='Few Commanders',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
example_commanders=['Commander 1'], # Only 1
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('too few example_commanders' in issue.lower() for issue in issues)
|
||||
|
||||
def test_insufficient_cards(self):
|
||||
"""Test validation recommends minimum 3 cards."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='few-cards',
|
||||
theme='Few Cards',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2'], # Only 2
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('too few example_cards' in issue.lower() for issue in issues)
|
||||
|
||||
def test_missing_description(self):
|
||||
"""Test validation catches missing description."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='no-desc',
|
||||
theme='No Description',
|
||||
synergies=[],
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('description' in issue.lower() for issue in issues)
|
||||
|
||||
def test_generic_description_warning(self):
|
||||
"""Test validation flags generic auto-generated descriptions."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='generic',
|
||||
theme='Generic',
|
||||
synergies=[],
|
||||
description='Leverages something somehow.', # Generic template without synergies
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('fallback template' in issue.lower() for issue in issues)
|
||||
|
||||
|
||||
class TestDescriptionSource:
|
||||
"""Test description_source field validation and inference."""
|
||||
|
||||
def test_missing_description_source(self):
|
||||
"""Test validation catches missing description_source."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='no-source',
|
||||
theme='No Source',
|
||||
synergies=[],
|
||||
description='Has description but no source',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('description_source' in issue.lower() for issue in issues)
|
||||
|
||||
def test_generic_source_warning(self):
|
||||
"""Test warning for generic description source."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='generic-source',
|
||||
theme='Generic Source',
|
||||
synergies=[],
|
||||
description='Some description',
|
||||
description_source='generic',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
# Should have a warning about generic description source
|
||||
generic_warnings = [issue for issue in issues if 'generic' in issue.lower()]
|
||||
assert len(generic_warnings) > 0, f"Expected generic warning, got issues: {issues}"
|
||||
assert any('upgrad' in issue.lower() for issue in generic_warnings), f"Expected 'upgrad' in warning, got: {generic_warnings}"
|
||||
|
||||
def test_infer_rule_based_description(self):
|
||||
"""Test inference identifies rule-based descriptions."""
|
||||
service = ThemeEditorialService()
|
||||
desc = "Chains spells together. Synergies like Storm and Magecraft reinforce the plan."
|
||||
source = service.infer_description_source(desc)
|
||||
assert source == 'rule'
|
||||
|
||||
def test_infer_generic_description(self):
|
||||
"""Test inference identifies generic fallback descriptions."""
|
||||
service = ThemeEditorialService()
|
||||
desc = "Builds around this theme with various synergies."
|
||||
source = service.infer_description_source(desc)
|
||||
assert source == 'generic'
|
||||
|
||||
def test_infer_manual_description(self):
|
||||
"""Test inference identifies manual descriptions."""
|
||||
service = ThemeEditorialService()
|
||||
desc = "This unique strategy leverages multiple vectors of advantage."
|
||||
source = service.infer_description_source(desc)
|
||||
assert source == 'manual'
|
||||
|
||||
def test_manual_description_bonus(self):
|
||||
"""Test manual descriptions score higher than rule-based."""
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# Entry with rule-based description
|
||||
entry_rule = ThemeEntry(
|
||||
id='rule',
|
||||
theme='Rule',
|
||||
synergies=[],
|
||||
description='A good description',
|
||||
description_source='rule',
|
||||
)
|
||||
score_rule = service.calculate_quality_score(entry_rule)
|
||||
|
||||
# Entry with manual description
|
||||
entry_manual = ThemeEntry(
|
||||
id='manual',
|
||||
theme='Manual',
|
||||
synergies=[],
|
||||
description='A good description',
|
||||
description_source='manual',
|
||||
)
|
||||
score_manual = service.calculate_quality_score(entry_manual)
|
||||
|
||||
assert score_manual > score_rule, "Manual descriptions should score higher"
|
||||
|
||||
|
||||
class TestPopularityPinning:
|
||||
"""Test popularity_pinned field behavior."""
|
||||
|
||||
def test_pinned_without_bucket_error(self):
|
||||
"""Test error when popularity_pinned is True but bucket is missing."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='pinned-no-bucket',
|
||||
theme='Pinned No Bucket',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
description_source='manual',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_pinned=True, # Pinned but no bucket
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
assert any('popularity_pinned' in issue.lower() and 'missing' in issue.lower() for issue in issues)
|
||||
|
||||
def test_pinned_with_bucket_valid(self):
|
||||
"""Test valid entry with pinned popularity."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='pinned-valid',
|
||||
theme='Pinned Valid',
|
||||
synergies=[],
|
||||
description='Description',
|
||||
description_source='manual',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Rare',
|
||||
popularity_pinned=True,
|
||||
)
|
||||
issues = service.validate_editorial_fields(entry)
|
||||
# Should not have pinning-related issues
|
||||
assert not any('popularity_pinned' in issue.lower() for issue in issues)
|
||||
|
||||
|
||||
class TestPopularityCalculation:
|
||||
"""Test popularity bucket calculation."""
|
||||
|
||||
def test_rare_bucket(self):
|
||||
"""Test Rare bucket (lowest frequency)."""
|
||||
service = ThemeEditorialService()
|
||||
bucket = service.calculate_popularity_bucket(15, 20) # total 35, below 40
|
||||
assert bucket == 'Rare'
|
||||
|
||||
def test_niche_bucket(self):
|
||||
"""Test Niche bucket."""
|
||||
service = ThemeEditorialService()
|
||||
bucket = service.calculate_popularity_bucket(30, 40) # total 70, between 40-100
|
||||
assert bucket == 'Niche'
|
||||
|
||||
def test_uncommon_bucket(self):
|
||||
"""Test Uncommon bucket."""
|
||||
service = ThemeEditorialService()
|
||||
bucket = service.calculate_popularity_bucket(80, 80) # total 160, between 100-220
|
||||
assert bucket == 'Uncommon'
|
||||
|
||||
def test_common_bucket(self):
|
||||
"""Test Common bucket."""
|
||||
service = ThemeEditorialService()
|
||||
bucket = service.calculate_popularity_bucket(150, 150) # total 300, between 220-500
|
||||
assert bucket == 'Common'
|
||||
|
||||
def test_very_common_bucket(self):
|
||||
"""Test Very Common bucket (highest frequency)."""
|
||||
service = ThemeEditorialService()
|
||||
bucket = service.calculate_popularity_bucket(300, 300) # total 600, above 500
|
||||
assert bucket == 'Very Common'
|
||||
|
||||
def test_custom_boundaries(self):
|
||||
"""Test custom boundary values."""
|
||||
service = ThemeEditorialService()
|
||||
custom = [10, 20, 30, 40]
|
||||
bucket = service.calculate_popularity_bucket(15, 10, boundaries=custom) # total 25
|
||||
assert bucket == 'Uncommon' # Between 20 and 30 (third bucket)
|
||||
|
||||
|
||||
class TestArchetypeInference:
|
||||
"""Test deck archetype inference from theme names and synergies."""
|
||||
|
||||
def test_combo_inference(self):
|
||||
"""Test combo archetype inference."""
|
||||
service = ThemeEditorialService()
|
||||
archetype = service.infer_deck_archetype('Infinite Combo', ['Storm'])
|
||||
assert archetype == 'Combo'
|
||||
|
||||
def test_stax_inference(self):
|
||||
"""Test stax archetype inference."""
|
||||
service = ThemeEditorialService()
|
||||
archetype = service.infer_deck_archetype('Resource Denial', ['Stax', 'Tax'])
|
||||
assert archetype == 'Stax'
|
||||
|
||||
def test_voltron_inference(self):
|
||||
"""Test voltron archetype inference."""
|
||||
service = ThemeEditorialService()
|
||||
archetype = service.infer_deck_archetype('Auras Matter', ['Equipment', 'Voltron'])
|
||||
assert archetype == 'Voltron'
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
"""Test no match returns None."""
|
||||
service = ThemeEditorialService()
|
||||
archetype = service.infer_deck_archetype('Generic Theme', ['Synergy1', 'Synergy2'])
|
||||
assert archetype is None
|
||||
|
||||
|
||||
class TestDescriptionGeneration:
|
||||
"""Test description generation helpers."""
|
||||
|
||||
def test_basic_generation(self):
|
||||
"""Test basic template-based description generation."""
|
||||
service = ThemeEditorialService()
|
||||
desc = service.generate_description('Test Theme', ['Synergy1', 'Synergy2'])
|
||||
assert 'Test Theme' in desc
|
||||
assert 'Synergy1' in desc
|
||||
assert 'Synergy2' in desc
|
||||
|
||||
def test_single_synergy(self):
|
||||
"""Test description with single synergy."""
|
||||
service = ThemeEditorialService()
|
||||
desc = service.generate_description('Test', ['OnlySynergy'])
|
||||
assert 'OnlySynergy' in desc
|
||||
|
||||
def test_no_synergies(self):
|
||||
"""Test description with no synergies."""
|
||||
service = ThemeEditorialService()
|
||||
desc = service.generate_description('Test', [])
|
||||
assert 'core mechanics' in desc.lower()
|
||||
|
||||
def test_custom_template(self):
|
||||
"""Test custom description template."""
|
||||
service = ThemeEditorialService()
|
||||
template = 'Theme {theme} works with {synergies}.'
|
||||
desc = service.generate_description('TestTheme', ['Syn1', 'Syn2'], template=template)
|
||||
assert 'TestTheme' in desc
|
||||
assert 'Syn1' in desc
|
||||
|
||||
|
||||
class TestCatalogStatistics:
|
||||
"""Test catalog-wide statistics (integration test with real catalog)."""
|
||||
|
||||
def test_statistics_structure(self):
|
||||
"""Test statistics returns expected structure."""
|
||||
service = ThemeEditorialService()
|
||||
stats = service.get_catalog_statistics()
|
||||
|
||||
# Verify required keys
|
||||
assert 'total_themes' in stats
|
||||
assert 'complete_editorials' in stats
|
||||
assert 'missing_descriptions' in stats
|
||||
assert 'missing_examples' in stats
|
||||
assert 'quality_distribution' in stats
|
||||
assert 'average_quality_score' in stats
|
||||
assert 'completeness_percentage' in stats
|
||||
assert 'description_source_distribution' in stats
|
||||
assert 'pinned_popularity_count' in stats
|
||||
|
||||
# Verify quality distribution has all tiers
|
||||
quality_dist = stats['quality_distribution']
|
||||
assert 'Excellent' in quality_dist
|
||||
assert 'Good' in quality_dist
|
||||
assert 'Fair' in quality_dist
|
||||
assert 'Poor' in quality_dist
|
||||
|
||||
# Verify description source distribution has all types
|
||||
source_dist = stats['description_source_distribution']
|
||||
assert 'rule' in source_dist
|
||||
assert 'generic' in source_dist
|
||||
assert 'manual' in source_dist
|
||||
|
||||
# Verify reasonable values
|
||||
assert stats['total_themes'] > 0, "Should have at least some themes"
|
||||
assert 0 <= stats['completeness_percentage'] <= 100
|
||||
assert 0 <= stats['average_quality_score'] <= 100
|
||||
assert stats['pinned_popularity_count'] >= 0, "Pinned count cannot be negative"
|
||||
|
||||
def test_statistics_consistency(self):
|
||||
"""Test statistics internal consistency."""
|
||||
service = ThemeEditorialService()
|
||||
stats = service.get_catalog_statistics()
|
||||
|
||||
# Quality distribution sum should equal total themes
|
||||
quality_sum = sum(stats['quality_distribution'].values())
|
||||
assert quality_sum == stats['total_themes'], \
|
||||
f"Quality distribution sum ({quality_sum}) should equal total ({stats['total_themes']})"
|
||||
|
||||
|
||||
# Integration tests requiring actual theme catalog
|
||||
class TestThemeMetadataRetrieval:
|
||||
"""Test metadata retrieval from real catalog (integration tests)."""
|
||||
|
||||
def test_get_metadata_not_found(self):
|
||||
"""Test NotFoundError for non-existent theme."""
|
||||
service = ThemeEditorialService()
|
||||
with pytest.raises(NotFoundError):
|
||||
service.get_theme_metadata('NonExistentTheme99999')
|
||||
|
||||
def test_suggest_commanders_not_found(self):
|
||||
"""Test NotFoundError for non-existent theme in suggest_commanders."""
|
||||
service = ThemeEditorialService()
|
||||
with pytest.raises(NotFoundError):
|
||||
service.suggest_example_commanders('NonExistentTheme99999')
|
||||
|
||||
|
||||
# M2: Heuristics Loading Tests
|
||||
class TestHeuristicsLoading:
|
||||
"""Test M2 heuristics externalization functionality."""
|
||||
|
||||
def test_load_heuristics_success(self):
|
||||
"""Test heuristics file loads successfully."""
|
||||
service = ThemeEditorialService()
|
||||
heuristics = service.load_heuristics()
|
||||
assert isinstance(heuristics, dict)
|
||||
assert 'quality_thresholds' in heuristics
|
||||
assert 'generic_staple_cards' in heuristics
|
||||
|
||||
def test_heuristics_cached(self):
|
||||
"""Test heuristics are cached after first load."""
|
||||
service = ThemeEditorialService()
|
||||
h1 = service.load_heuristics()
|
||||
h2 = service.load_heuristics()
|
||||
assert h1 is h2 # Same object reference (cached)
|
||||
|
||||
def test_force_reload_bypasses_cache(self):
|
||||
"""Test force_reload parameter bypasses cache."""
|
||||
service = ThemeEditorialService()
|
||||
h1 = service.load_heuristics()
|
||||
h2 = service.load_heuristics(force_reload=True)
|
||||
assert isinstance(h2, dict)
|
||||
# Can't test object identity changes without modifying file
|
||||
|
||||
def test_heuristics_structure(self):
|
||||
"""Test heuristics contain expected keys."""
|
||||
service = ThemeEditorialService()
|
||||
heuristics = service.load_heuristics()
|
||||
|
||||
# Required top-level keys
|
||||
assert 'version' in heuristics
|
||||
assert 'quality_thresholds' in heuristics
|
||||
assert 'generic_staple_cards' in heuristics
|
||||
|
||||
# Quality thresholds structure
|
||||
thresholds = heuristics['quality_thresholds']
|
||||
assert 'excellent_min_score' in thresholds
|
||||
assert 'good_min_score' in thresholds
|
||||
assert 'fair_min_score' in thresholds
|
||||
assert 'manual_description_bonus' in thresholds
|
||||
assert 'rule_description_bonus' in thresholds
|
||||
assert 'generic_description_bonus' in thresholds
|
||||
|
||||
|
||||
class TestGenericCardDetection:
|
||||
"""Test M2 generic card identification functionality."""
|
||||
|
||||
def test_get_generic_staple_cards(self):
|
||||
"""Test generic staple cards list is retrieved."""
|
||||
service = ThemeEditorialService()
|
||||
generic_cards = service.get_generic_staple_cards()
|
||||
assert isinstance(generic_cards, list)
|
||||
# Should contain common staples
|
||||
assert 'Sol Ring' in generic_cards or len(generic_cards) == 0 # Allow empty for testing
|
||||
|
||||
def test_is_generic_card_sol_ring(self):
|
||||
"""Test Sol Ring is identified as generic."""
|
||||
service = ThemeEditorialService()
|
||||
# Only test if Sol Ring is in heuristics list
|
||||
if 'Sol Ring' in service.get_generic_staple_cards():
|
||||
assert service.is_generic_card('Sol Ring')
|
||||
|
||||
def test_is_generic_card_nongeneric(self):
|
||||
"""Test unique card is not identified as generic."""
|
||||
service = ThemeEditorialService()
|
||||
# Use a very specific card unlikely to be a staple
|
||||
assert not service.is_generic_card('Obscure Legendary Creature From 1995')
|
||||
|
||||
def test_quality_score_generic_penalty(self):
|
||||
"""Test quality score penalizes excessive generic cards."""
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# Entry with mostly generic cards
|
||||
generic_entry = ThemeEntry(
|
||||
id='generic-test',
|
||||
theme='Generic Test',
|
||||
synergies=['Synergy1'],
|
||||
description='A description.',
|
||||
description_source='manual',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=[
|
||||
'Sol Ring', 'Arcane Signet', 'Command Tower',
|
||||
'Lightning Greaves', 'Swiftfoot Boots', 'Counterspell'
|
||||
], # 6 cards, many likely generic
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
|
||||
# Entry with unique cards
|
||||
unique_entry = ThemeEntry(
|
||||
id='unique-test',
|
||||
theme='Unique Test',
|
||||
synergies=['Synergy1'],
|
||||
description='A description.',
|
||||
description_source='manual',
|
||||
example_commanders=['Commander 1', 'Commander 2'],
|
||||
example_cards=[
|
||||
'Unique Card 1', 'Unique Card 2', 'Unique Card 3',
|
||||
'Unique Card 4', 'Unique Card 5', 'Unique Card 6'
|
||||
],
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
|
||||
generic_score = service.calculate_quality_score(generic_entry)
|
||||
unique_score = service.calculate_quality_score(unique_entry)
|
||||
|
||||
# If heuristics loaded and has generic cards, unique should score higher
|
||||
if service.get_generic_staple_cards():
|
||||
assert unique_score >= generic_score
|
||||
|
||||
|
||||
class TestQualityTiersWithHeuristics:
|
||||
"""Test M2 quality tiers use external heuristics."""
|
||||
|
||||
def test_get_quality_tier_uses_heuristics(self):
|
||||
"""Test quality tier thresholds come from heuristics."""
|
||||
service = ThemeEditorialService()
|
||||
heuristics = service. load_heuristics()
|
||||
thresholds = heuristics.get('quality_thresholds', {})
|
||||
|
||||
excellent_min = thresholds.get('excellent_min_score', 75)
|
||||
good_min = thresholds.get('good_min_score', 60)
|
||||
fair_min = thresholds.get('fair_min_score', 40)
|
||||
|
||||
# Test boundary conditions
|
||||
assert service.get_quality_tier(excellent_min) == 'Excellent'
|
||||
assert service.get_quality_tier(good_min) == 'Good'
|
||||
assert service.get_quality_tier(fair_min) == 'Fair'
|
||||
assert service.get_quality_tier(fair_min - 1) == 'Poor'
|
||||
|
||||
|
||||
# M3: Card Uniqueness and Duplication Tests
|
||||
class TestGlobalCardFrequency:
|
||||
"""Test M3 global card frequency analysis."""
|
||||
|
||||
def test_calculate_global_card_frequency(self):
|
||||
"""Test global card frequency calculation."""
|
||||
service = ThemeEditorialService()
|
||||
freq = service.calculate_global_card_frequency()
|
||||
assert isinstance(freq, dict)
|
||||
# Should have some cards with frequencies
|
||||
if freq:
|
||||
assert all(isinstance(count, int) for count in freq.values())
|
||||
assert all(count > 0 for count in freq.values())
|
||||
|
||||
def test_frequency_counts_themes(self):
|
||||
"""Test frequency correctly counts theme appearances."""
|
||||
service = ThemeEditorialService()
|
||||
freq = service.calculate_global_card_frequency()
|
||||
# Any card should appear in at least 1 theme
|
||||
if freq:
|
||||
for card, count in freq.items():
|
||||
assert count >= 1, f"{card} has invalid count {count}"
|
||||
|
||||
|
||||
class TestUniquenessRatio:
|
||||
"""Test M3 uniqueness ratio calculation."""
|
||||
|
||||
def test_uniqueness_ratio_empty_cards(self):
|
||||
"""Test uniqueness ratio with no cards."""
|
||||
service = ThemeEditorialService()
|
||||
ratio = service.calculate_uniqueness_ratio([])
|
||||
assert ratio == 0.0
|
||||
|
||||
def test_uniqueness_ratio_all_unique(self):
|
||||
"""Test uniqueness ratio with all unique cards."""
|
||||
service = ThemeEditorialService()
|
||||
# Cards that don't exist should have 0 frequency = unique
|
||||
ratio = service.calculate_uniqueness_ratio(
|
||||
['Nonexistent Card A', 'Nonexistent Card B']
|
||||
)
|
||||
assert ratio == 1.0 # All unique
|
||||
|
||||
def test_uniqueness_ratio_custom_frequency(self):
|
||||
"""Test uniqueness ratio with custom frequency data."""
|
||||
service = ThemeEditorialService()
|
||||
# Simulate 100 themes total
|
||||
freq = {
|
||||
'Common Card': 80, # In 80% of themes (not unique)
|
||||
'Rare Card': 10, # In 10% of themes (unique)
|
||||
}
|
||||
ratio = service.calculate_uniqueness_ratio(
|
||||
['Common Card', 'Rare Card'],
|
||||
global_card_freq=freq,
|
||||
uniqueness_threshold=0.25 # <25% is unique
|
||||
)
|
||||
# Rare Card is unique (1 out of 2 cards)
|
||||
# Note: This test won't work perfectly without setting total_themes
|
||||
# Let's just verify it returns a value between 0 and 1
|
||||
assert 0.0 <= ratio <= 1.0
|
||||
|
||||
def test_uniqueness_ratio_threshold(self):
|
||||
"""Test uniqueness threshold parameter."""
|
||||
service = ThemeEditorialService()
|
||||
# With different thresholds, should get different results
|
||||
ratio_strict = service.calculate_uniqueness_ratio(
|
||||
['Test Card'],
|
||||
uniqueness_threshold=0.10 # Very strict (card in <10%)
|
||||
)
|
||||
ratio_lenient = service.calculate_uniqueness_ratio(
|
||||
['Test Card'],
|
||||
uniqueness_threshold=0.50 # Lenient (card in <50%)
|
||||
)
|
||||
# Both should be valid ratios
|
||||
assert 0.0 <= ratio_strict <= 1.0
|
||||
assert 0.0 <= ratio_lenient <= 1.0
|
||||
|
||||
|
||||
class TestDuplicationRatio:
|
||||
"""Test M3 duplication ratio calculation."""
|
||||
|
||||
def test_duplication_ratio_empty_cards(self):
|
||||
"""Test duplication ratio with no cards."""
|
||||
service = ThemeEditorialService()
|
||||
ratio = service.calculate_duplication_ratio([])
|
||||
assert ratio == 0.0
|
||||
|
||||
def test_duplication_ratio_all_unique(self):
|
||||
"""Test duplication ratio with all unique cards."""
|
||||
service = ThemeEditorialService()
|
||||
# Nonexistent cards have 0 frequency = not duplicated
|
||||
ratio = service.calculate_duplication_ratio(
|
||||
['Nonexistent Card A', 'Nonexistent Card B']
|
||||
)
|
||||
assert ratio == 0.0 # No duplication
|
||||
|
||||
def test_duplication_ratio_custom_frequency(self):
|
||||
"""Test duplication ratio with custom frequency data."""
|
||||
service = ThemeEditorialService()
|
||||
# This test would need mock index to work properly
|
||||
# Just verify it returns valid ratio
|
||||
ratio = service.calculate_duplication_ratio(
|
||||
['Test Card']
|
||||
)
|
||||
assert 0.0 <= ratio <= 1.0
|
||||
|
||||
def test_duplication_ratio_threshold(self):
|
||||
"""Test duplication threshold parameter."""
|
||||
service = ThemeEditorialService()
|
||||
ratio_strict = service.calculate_duplication_ratio(
|
||||
['Test Card'],
|
||||
duplication_threshold=0.50 # Card in >50% is duplicated
|
||||
)
|
||||
ratio_lenient = service.calculate_duplication_ratio(
|
||||
['Test Card'],
|
||||
duplication_threshold=0.30 # Card in >30% is duplicated
|
||||
)
|
||||
assert 0.0 <= ratio_strict <= 1.0
|
||||
assert 0.0 <= ratio_lenient <= 1.0
|
||||
|
||||
|
||||
class TestEnhancedQualityScoring:
|
||||
"""Test M3 enhanced quality scoring with uniqueness."""
|
||||
|
||||
def test_enhanced_score_structure(self):
|
||||
"""Test enhanced score returns tuple of tier and score."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='test',
|
||||
theme='Test',
|
||||
synergies=[],
|
||||
example_cards=['Card 1', 'Card 2', 'Card 3'],
|
||||
example_commanders=['Cmdr 1'],
|
||||
description='Test description.',
|
||||
description_source='manual',
|
||||
deck_archetype='Combo',
|
||||
popularity_bucket='Common',
|
||||
)
|
||||
tier, score = service.calculate_enhanced_quality_score(entry)
|
||||
assert tier in ['Excellent', 'Good', 'Fair', 'Poor']
|
||||
assert 0.0 <= score <= 1.0
|
||||
|
||||
def test_enhanced_score_many_cards(self):
|
||||
"""Test enhanced score rewards many example cards."""
|
||||
service = ThemeEditorialService()
|
||||
entry_many = ThemeEntry(
|
||||
id='many-cards',
|
||||
theme='Many Cards',
|
||||
synergies=[],
|
||||
example_cards=[f'Card {i}' for i in range(10)], # 10 cards
|
||||
example_commanders=['Cmdr 1'],
|
||||
description='Description.',
|
||||
description_source='manual',
|
||||
)
|
||||
entry_few = ThemeEntry(
|
||||
id='few-cards',
|
||||
theme='Few Cards',
|
||||
synergies=[],
|
||||
example_cards=['Card 1', 'Card 2'], # 2 cards
|
||||
example_commanders=['Cmdr 1'],
|
||||
description='Description.',
|
||||
description_source='manual',
|
||||
)
|
||||
tier_many, score_many = service.calculate_enhanced_quality_score(entry_many)
|
||||
tier_few, score_few = service.calculate_enhanced_quality_score(entry_few)
|
||||
assert score_many > score_few
|
||||
|
||||
def test_enhanced_score_manual_bonus(self):
|
||||
"""Test enhanced score rewards manual descriptions."""
|
||||
service = ThemeEditorialService()
|
||||
entry_manual = ThemeEntry(
|
||||
id='manual',
|
||||
theme='Manual',
|
||||
synergies=[],
|
||||
example_cards=['Card 1'],
|
||||
description='Description.',
|
||||
description_source='manual',
|
||||
)
|
||||
entry_generic = ThemeEntry(
|
||||
id='generic',
|
||||
theme='Generic',
|
||||
synergies=[],
|
||||
example_cards=['Card 1'],
|
||||
description='Description.',
|
||||
description_source='generic',
|
||||
)
|
||||
_, score_manual = service.calculate_enhanced_quality_score(entry_manual)
|
||||
_, score_generic = service.calculate_enhanced_quality_score(entry_generic)
|
||||
assert score_manual > score_generic
|
||||
|
||||
def test_enhanced_score_no_cards(self):
|
||||
"""Test enhanced score handles themes with no example cards."""
|
||||
service = ThemeEditorialService()
|
||||
entry = ThemeEntry(
|
||||
id='no-cards',
|
||||
theme='No Cards',
|
||||
synergies=[],
|
||||
description='Description.',
|
||||
description_source='manual',
|
||||
)
|
||||
tier, score = service.calculate_enhanced_quality_score(entry)
|
||||
assert tier == 'Poor' # Should be poor without cards
|
||||
assert score < 0.40
|
||||
|
||||
|
||||
class TestCatalogStatisticsEnhanced:
|
||||
"""Test M3 enhanced catalog statistics."""
|
||||
|
||||
def test_statistics_with_enhanced_scoring(self):
|
||||
"""Test catalog statistics with M3 enhanced scoring."""
|
||||
service = ThemeEditorialService()
|
||||
stats = service.get_catalog_statistics(use_enhanced_scoring=True)
|
||||
|
||||
# Should have all basic keys
|
||||
assert 'total_themes' in stats
|
||||
assert 'quality_distribution' in stats
|
||||
|
||||
# M3 keys should be present
|
||||
assert 'average_uniqueness_ratio' in stats
|
||||
assert 'average_duplication_ratio' in stats
|
||||
|
||||
# Ratios should be valid
|
||||
assert 0.0 <= stats['average_uniqueness_ratio'] <= 1.0
|
||||
assert 0.0 <= stats['average_duplication_ratio'] <= 1.0
|
||||
|
||||
def test_statistics_without_enhanced_scoring(self):
|
||||
"""Test catalog statistics without M3 features."""
|
||||
service = ThemeEditorialService()
|
||||
stats = service.get_catalog_statistics(use_enhanced_scoring=False)
|
||||
|
||||
# Basic keys should be present
|
||||
assert 'total_themes' in stats
|
||||
assert 'quality_distribution' in stats
|
||||
|
||||
# M3 keys should not be present
|
||||
assert 'average_uniqueness_ratio' not in stats
|
||||
assert 'average_duplication_ratio' not in stats
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
287
code/tests/test_theme_linter.py
Normal file
287
code/tests/test_theme_linter.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""Tests for M4 linter functionality in validate_theme_catalog.py"""
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from type_definitions_theme_catalog import ThemeYAMLFile, DescriptionSource
|
||||
from web.services.theme_editorial_service import ThemeEditorialService
|
||||
from web.services.theme_catalog_loader import load_index
|
||||
|
||||
|
||||
class TestLinterDuplicationChecks:
|
||||
"""Test M4 linter duplication ratio checks"""
|
||||
|
||||
def test_high_duplication_flagged(self):
|
||||
"""Themes with high duplication ratio should be flagged"""
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# Get actual total themes from catalog
|
||||
index = load_index()
|
||||
total_themes = len(index.slug_to_entry)
|
||||
|
||||
# Mock global frequency: Sol Ring in 60% of themes, Lightning Greaves in 50%
|
||||
# Use actual total to get realistic frequencies
|
||||
global_card_freq = {
|
||||
"Sol Ring": int(total_themes * 0.6),
|
||||
"Lightning Greaves": int(total_themes * 0.5),
|
||||
"Unique Card A": 5,
|
||||
"Unique Card B": 3
|
||||
}
|
||||
|
||||
# Theme with mostly generic cards (2/4 = 50% are generic)
|
||||
example_cards = ["Sol Ring", "Lightning Greaves", "Unique Card A", "Unique Card B"]
|
||||
|
||||
dup_ratio = service.calculate_duplication_ratio(
|
||||
example_cards=example_cards,
|
||||
global_card_freq=global_card_freq,
|
||||
duplication_threshold=0.4 # >40% = duplicated
|
||||
)
|
||||
|
||||
# Should flag: 2 out of 4 cards appear in >40% of themes
|
||||
assert dup_ratio == 0.5 # 50% duplication
|
||||
|
||||
def test_low_duplication_not_flagged(self):
|
||||
"""Themes with unique cards should not be flagged"""
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# All unique cards
|
||||
global_card_freq = {
|
||||
"Unique Card A": 5,
|
||||
"Unique Card B": 3,
|
||||
"Unique Card C": 8,
|
||||
"Unique Card D": 2
|
||||
}
|
||||
|
||||
example_cards = ["Unique Card A", "Unique Card B", "Unique Card C", "Unique Card D"]
|
||||
|
||||
dup_ratio = service.calculate_duplication_ratio(
|
||||
example_cards=example_cards,
|
||||
global_card_freq=global_card_freq,
|
||||
duplication_threshold=0.4
|
||||
)
|
||||
|
||||
assert dup_ratio == 0.0 # No duplication
|
||||
|
||||
def test_empty_cards_no_duplication(self):
|
||||
"""Empty example cards should return 0.0 duplication"""
|
||||
service = ThemeEditorialService()
|
||||
global_card_freq = {"Sol Ring": 60}
|
||||
|
||||
dup_ratio = service.calculate_duplication_ratio(
|
||||
example_cards=[],
|
||||
global_card_freq=global_card_freq,
|
||||
duplication_threshold=0.4
|
||||
)
|
||||
|
||||
assert dup_ratio == 0.0
|
||||
|
||||
|
||||
class TestLinterQualityScoring:
|
||||
"""Test M4 linter quality score checks"""
|
||||
|
||||
def test_low_quality_score_flagged(self):
|
||||
"""Themes with low quality scores should be flagged"""
|
||||
from type_definitions_theme_catalog import ThemeEntry
|
||||
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# Low quality theme: few cards, generic description, no uniqueness
|
||||
theme_entry = ThemeEntry(
|
||||
theme="Test Theme",
|
||||
example_cards=["Sol Ring", "Command Tower"], # Only 2 cards
|
||||
description_source="generic"
|
||||
)
|
||||
|
||||
global_card_freq = {
|
||||
"Sol Ring": 80, # Very common
|
||||
"Command Tower": 75 # Very common
|
||||
}
|
||||
|
||||
tier, score = service.calculate_enhanced_quality_score(
|
||||
theme_entry=theme_entry,
|
||||
global_card_freq=global_card_freq
|
||||
)
|
||||
|
||||
assert tier in ["Poor", "Fair"]
|
||||
assert score < 0.5 # Below typical threshold
|
||||
|
||||
def test_high_quality_score_not_flagged(self):
|
||||
"""Themes with high quality scores should not be flagged"""
|
||||
from type_definitions_theme_catalog import ThemeEntry
|
||||
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# High quality theme: many unique cards, manual description
|
||||
theme_entry = ThemeEntry(
|
||||
theme="Test Theme",
|
||||
example_cards=[f"Unique Card {i}" for i in range(10)], # 10 unique cards
|
||||
description_source="manual"
|
||||
)
|
||||
|
||||
global_card_freq = {f"Unique Card {i}": 2 for i in range(10)} # All rare
|
||||
|
||||
tier, score = service.calculate_enhanced_quality_score(
|
||||
theme_entry=theme_entry,
|
||||
global_card_freq=global_card_freq
|
||||
)
|
||||
|
||||
assert tier in ["Good", "Excellent"]
|
||||
assert score >= 0.6 # Above typical threshold
|
||||
|
||||
|
||||
class TestLinterSuggestions:
|
||||
"""Test M4 linter suggestion generation"""
|
||||
|
||||
def test_suggestions_for_few_cards(self):
|
||||
"""Should suggest adding more cards when count is low"""
|
||||
example_cards = ["Card A", "Card B", "Card C"] # Only 3 cards
|
||||
|
||||
suggestions = []
|
||||
if len(example_cards) < 5:
|
||||
suggestions.append("Add more example cards (target: 8+)")
|
||||
|
||||
assert len(suggestions) == 1
|
||||
assert "Add more example cards" in suggestions[0]
|
||||
|
||||
def test_suggestions_for_generic_description(self):
|
||||
"""Should suggest upgrading description when generic"""
|
||||
description_source = "generic"
|
||||
|
||||
suggestions = []
|
||||
if description_source == "generic":
|
||||
suggestions.append("Upgrade to manual or rule-based description")
|
||||
|
||||
assert len(suggestions) == 1
|
||||
assert "Upgrade to manual or rule-based" in suggestions[0]
|
||||
|
||||
def test_suggestions_for_generic_cards(self):
|
||||
"""Should suggest replacing generic cards when duplication high"""
|
||||
dup_ratio = 0.6 # 60% duplication
|
||||
|
||||
suggestions = []
|
||||
if dup_ratio > 0.4:
|
||||
suggestions.append("Replace generic staples with unique cards")
|
||||
|
||||
assert len(suggestions) == 1
|
||||
assert "Replace generic staples" in suggestions[0]
|
||||
|
||||
def test_multiple_suggestions_combined(self):
|
||||
"""Should provide multiple suggestions when multiple issues exist"""
|
||||
example_cards = ["Card A", "Card B"] # Few cards
|
||||
description_source = "generic"
|
||||
dup_ratio = 0.5 # High duplication
|
||||
|
||||
suggestions = []
|
||||
if len(example_cards) < 5:
|
||||
suggestions.append("Add more example cards (target: 8+)")
|
||||
if description_source == "generic":
|
||||
suggestions.append("Upgrade to manual or rule-based description")
|
||||
if dup_ratio > 0.4:
|
||||
suggestions.append("Replace generic staples with unique cards")
|
||||
|
||||
assert len(suggestions) == 3
|
||||
assert "Add more example cards" in suggestions[0]
|
||||
assert "Upgrade to manual or rule-based" in suggestions[1]
|
||||
assert "Replace generic staples" in suggestions[2]
|
||||
|
||||
|
||||
class TestLinterThresholds:
|
||||
"""Test M4 linter configurable thresholds"""
|
||||
|
||||
def test_duplication_threshold_configurable(self):
|
||||
"""Duplication threshold should be configurable"""
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# Get actual total themes from catalog
|
||||
index = load_index()
|
||||
total_themes = len(index.slug_to_entry)
|
||||
|
||||
# Sol Ring at 45% frequency
|
||||
global_card_freq = {
|
||||
"Sol Ring": int(total_themes * 0.45),
|
||||
"Unique Card": 5
|
||||
}
|
||||
|
||||
example_cards = ["Sol Ring", "Unique Card"]
|
||||
|
||||
# With threshold 0.5 (50%), Sol Ring not flagged
|
||||
dup_ratio_high = service.calculate_duplication_ratio(
|
||||
example_cards=example_cards,
|
||||
global_card_freq=global_card_freq,
|
||||
duplication_threshold=0.5
|
||||
)
|
||||
assert dup_ratio_high == 0.0 # 45% < 50%
|
||||
|
||||
# With threshold 0.4 (40%), Sol Ring IS flagged
|
||||
dup_ratio_low = service.calculate_duplication_ratio(
|
||||
example_cards=example_cards,
|
||||
global_card_freq=global_card_freq,
|
||||
duplication_threshold=0.4
|
||||
)
|
||||
assert dup_ratio_low == 0.5 # 45% > 40%, so 1/2 cards flagged
|
||||
|
||||
def test_quality_threshold_configurable(self):
|
||||
"""Quality threshold determines what gets flagged"""
|
||||
# Threshold 0.3 would flag scores < 0.3
|
||||
score_fair = 0.45
|
||||
|
||||
assert score_fair < 0.5 # Would be flagged with threshold 0.5
|
||||
assert score_fair >= 0.3 # Would NOT be flagged with threshold 0.3
|
||||
|
||||
|
||||
class TestLinterIntegration:
|
||||
"""Integration tests for linter with ThemeYAMLFile validation"""
|
||||
|
||||
def test_yaml_file_to_theme_entry_conversion(self):
|
||||
"""Should correctly convert ThemeYAMLFile to ThemeEntry for linting"""
|
||||
from type_definitions_theme_catalog import ThemeEntry
|
||||
|
||||
# Simulate a ThemeYAMLFile object
|
||||
yaml_data = {
|
||||
"id": "test-theme",
|
||||
"display_name": "Test Theme",
|
||||
"synergies": ["Synergy A", "Synergy B"],
|
||||
"example_cards": ["Card A", "Card B", "Card C"],
|
||||
"description_source": "manual",
|
||||
"description": "A test theme for linting"
|
||||
}
|
||||
|
||||
yaml_file = ThemeYAMLFile(**yaml_data)
|
||||
|
||||
# Convert to ThemeEntry for linting
|
||||
theme_entry = ThemeEntry(
|
||||
theme=yaml_file.display_name,
|
||||
example_cards=yaml_file.example_cards,
|
||||
description_source=yaml_file.description_source
|
||||
)
|
||||
|
||||
assert theme_entry.theme == "Test Theme"
|
||||
assert len(theme_entry.example_cards) == 3
|
||||
assert theme_entry.description_source == "manual"
|
||||
|
||||
def test_linter_handles_missing_optional_fields(self):
|
||||
"""Linter should handle themes with missing optional fields gracefully"""
|
||||
from type_definitions_theme_catalog import ThemeEntry
|
||||
|
||||
# Theme with minimal required fields
|
||||
theme_entry = ThemeEntry(
|
||||
theme="Minimal Theme",
|
||||
example_cards=["Card A"],
|
||||
description_source=None # Missing description_source
|
||||
)
|
||||
|
||||
service = ThemeEditorialService()
|
||||
|
||||
# Should not crash
|
||||
tier, score = service.calculate_enhanced_quality_score(
|
||||
theme_entry=theme_entry,
|
||||
global_card_freq={"Card A": 1}
|
||||
)
|
||||
|
||||
assert isinstance(tier, str)
|
||||
assert 0.0 <= score <= 1.0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Loading…
Add table
Add a link
Reference in a new issue