feat: add Budget Mode with price cache infrastructure and stale price warnings

This commit is contained in:
matt 2026-03-23 16:19:18 -07:00
parent 1aa8e4d7e8
commit ec23775205
42 changed files with 6976 additions and 2753 deletions

View file

@ -0,0 +1,379 @@
"""Tests for BudgetEvaluatorService - deck cost evaluation and alternatives."""
from __future__ import annotations
from typing import Dict, List, Optional
from unittest.mock import MagicMock, patch
import pytest
from code.web.services.budget_evaluator import BudgetEvaluatorService
from code.web.services.price_service import PriceService
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_price_service(prices: Dict[str, Optional[float]]) -> PriceService:
"""Return a PriceService stub that returns predefined prices."""
svc = MagicMock(spec=PriceService)
svc.get_price.side_effect = lambda name, region="usd", foil=False: prices.get(name)
svc.get_prices_batch.side_effect = lambda names, region="usd", foil=False: {
n: prices.get(n) for n in names
}
return svc
# Shared price table: {card_name: price}
KNOWN_PRICES: Dict[str, Optional[float]] = {
"Sol Ring": 2.00,
"Mana Crypt": 150.00,
"Lightning Bolt": 0.25,
"Arcane Signet": 1.50,
"Fellwar Stone": 1.00,
"Command Tower": 0.30,
"Swamp": 0.10,
"Island": 0.10,
"Craterhoof Behemoth": 30.00,
"Vampiric Tutor": 25.00,
"No Price Card": None,
}
@pytest.fixture
def price_svc():
return _make_price_service(KNOWN_PRICES)
@pytest.fixture
def evaluator(price_svc):
return BudgetEvaluatorService(price_service=price_svc)
# ---------------------------------------------------------------------------
# Tests: evaluate_deck — basic cases
# ---------------------------------------------------------------------------
def test_evaluate_under_budget(evaluator):
deck = ["Sol Ring", "Arcane Signet", "Command Tower"]
# 2.00 + 1.50 + 0.30 = 3.80 < 10.00
report = evaluator.evaluate_deck(deck, budget_total=10.0)
assert report["budget_status"] == "under"
assert report["total_price"] == pytest.approx(3.80)
assert report["overage"] == 0.0
def test_evaluate_soft_exceeded(evaluator):
deck = ["Sol Ring", "Mana Crypt", "Lightning Bolt"]
# 2.00 + 150.00 + 0.25 = 152.25 > 100.00
report = evaluator.evaluate_deck(deck, budget_total=100.0, mode="soft")
assert report["budget_status"] == "soft_exceeded"
assert report["overage"] == pytest.approx(52.25)
def test_evaluate_hard_exceeded(evaluator):
deck = ["Sol Ring", "Mana Crypt"]
report = evaluator.evaluate_deck(deck, budget_total=50.0, mode="hard")
assert report["budget_status"] == "hard_exceeded"
def test_evaluate_empty_deck(evaluator):
report = evaluator.evaluate_deck([], budget_total=100.0)
assert report["total_price"] == 0.0
assert report["budget_status"] == "under"
assert report["overage"] == 0.0
# ---------------------------------------------------------------------------
# Tests: card_ceiling enforcement
# ---------------------------------------------------------------------------
def test_card_ceiling_flags_expensive_card(evaluator):
deck = ["Sol Ring", "Mana Crypt", "Command Tower"]
report = evaluator.evaluate_deck(deck, budget_total=500.0, card_ceiling=10.0)
flagged = [e["card"] for e in report["over_budget_cards"]]
assert "Mana Crypt" in flagged
assert "Sol Ring" not in flagged
assert "Command Tower" not in flagged
def test_card_ceiling_not_triggered_under_cap(evaluator):
deck = ["Sol Ring", "Arcane Signet"]
report = evaluator.evaluate_deck(deck, budget_total=500.0, card_ceiling=5.0)
assert report["over_budget_cards"] == []
# ---------------------------------------------------------------------------
# Tests: include_cards are exempt from over_budget flagging
# ---------------------------------------------------------------------------
def test_include_cards_exempt_from_ceiling(evaluator):
deck = ["Mana Crypt", "Sol Ring"]
# Mana Crypt (150) is an include — should NOT appear in over_budget_cards
report = evaluator.evaluate_deck(
deck, budget_total=10.0, card_ceiling=10.0, include_cards=["Mana Crypt"]
)
flagged = [e["card"] for e in report["over_budget_cards"]]
assert "Mana Crypt" not in flagged
def test_include_budget_overage_reported(evaluator):
deck = ["Craterhoof Behemoth", "Lightning Bolt"]
report = evaluator.evaluate_deck(
deck, budget_total=50.0, include_cards=["Craterhoof Behemoth"]
)
assert report["include_budget_overage"] == pytest.approx(30.00)
def test_include_cards_counted_in_total_price(evaluator):
deck = ["Mana Crypt", "Sol Ring"]
report = evaluator.evaluate_deck(deck, budget_total=200.0, include_cards=["Mana Crypt"])
assert report["total_price"] == pytest.approx(152.00)
# ---------------------------------------------------------------------------
# Tests: missing price handling (legacy_fail_open)
# ---------------------------------------------------------------------------
def test_missing_price_fail_open_skips(evaluator):
deck = ["Sol Ring", "No Price Card"]
# No Price Card has no price → treated as 0 in calculation
report = evaluator.evaluate_deck(deck, budget_total=100.0, legacy_fail_open=True)
assert "No Price Card" in report["stale_prices"]
assert report["total_price"] == pytest.approx(2.00)
def test_missing_price_fail_closed_raises(evaluator):
deck = ["No Price Card"]
with pytest.raises(ValueError, match="No price data for"):
evaluator.evaluate_deck(deck, budget_total=100.0, legacy_fail_open=False)
# ---------------------------------------------------------------------------
# Tests: price_breakdown structure
# ---------------------------------------------------------------------------
def test_price_breakdown_contains_all_cards(evaluator):
deck = ["Sol Ring", "Lightning Bolt", "Swamp"]
report = evaluator.evaluate_deck(deck, budget_total=100.0)
names_in_breakdown = [e["card"] for e in report["price_breakdown"]]
for card in deck:
assert card in names_in_breakdown
def test_price_breakdown_flags_include(evaluator):
deck = ["Mana Crypt", "Sol Ring"]
report = evaluator.evaluate_deck(deck, budget_total=200.0, include_cards=["Mana Crypt"])
mc_entry = next(e for e in report["price_breakdown"] if e["card"] == "Mana Crypt")
assert mc_entry["is_include"] is True
sr_entry = next(e for e in report["price_breakdown"] if e["card"] == "Sol Ring")
assert sr_entry["is_include"] is False
# ---------------------------------------------------------------------------
# Tests: find_cheaper_alternatives
# ---------------------------------------------------------------------------
def test_cheaper_alternatives_respects_max_price():
"""Alternatives returned must all be ≤ max_price."""
# Build a card index stub with two alternatives
candidate_index = {
"ramp": [
{"name": "Arcane Signet", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
{"name": "Cursed Mirror", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
]
}
prices = {"Arcane Signet": 1.50, "Cursed Mirror": 8.00}
svc = _make_price_service(prices)
evaluator = BudgetEvaluatorService(price_service=svc)
with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \
patch("code.web.services.card_index.maybe_build_index"):
mock_pool.side_effect = lambda tag: candidate_index.get(tag, [])
results = evaluator.find_cheaper_alternatives("Mana Crypt", max_price=5.0, tags=["ramp"])
# Only Arcane Signet (1.50) should qualify; Cursed Mirror (8.00) exceeds max_price
names = [r["name"] for r in results]
assert "Arcane Signet" in names
assert "Cursed Mirror" not in names
def test_cheaper_alternatives_sorted_by_price():
"""Alternatives should be sorted cheapest first."""
candidates = [
{"name": "Card A", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
{"name": "Card B", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
{"name": "Card C", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
]
prices = {"Card A": 3.00, "Card B": 1.00, "Card C": 2.00}
svc = _make_price_service(prices)
evaluator = BudgetEvaluatorService(price_service=svc)
with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \
patch("code.web.services.card_index.maybe_build_index"):
mock_pool.return_value = candidates
results = evaluator.find_cheaper_alternatives("Mana Crypt", max_price=10.0, tags=["ramp"])
assert [r["name"] for r in results] == ["Card B", "Card C", "Card A"]
def test_cheaper_alternatives_empty_when_no_tags():
evaluator = BudgetEvaluatorService(price_service=_make_price_service({}))
with patch("code.web.services.card_index.maybe_build_index"), \
patch("code.web.services.card_index._CARD_INDEX", {}):
results = evaluator.find_cheaper_alternatives("Unknown Card", max_price=10.0)
assert results == []
def test_cheaper_alternatives_color_identity_filter():
"""Cards outside the commander's color identity must be excluded."""
candidates = [
# This card requires White (W) — not in Dimir (U/B)
{"name": "Swords to Plowshares", "tags": ["removal"], "color_identity": "W", "color_identity_list": ["W"], "mana_cost": "{W}", "rarity": ""},
{"name": "Doom Blade", "tags": ["removal"], "color_identity": "B", "color_identity_list": ["B"], "mana_cost": "{1}{B}", "rarity": ""},
]
prices = {"Swords to Plowshares": 1.00, "Doom Blade": 0.50}
svc = _make_price_service(prices)
evaluator = BudgetEvaluatorService(price_service=svc)
with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \
patch("code.web.services.card_index.maybe_build_index"):
mock_pool.return_value = candidates
results = evaluator.find_cheaper_alternatives(
"Vampiric Tutor", max_price=5.0,
color_identity=["U", "B"], tags=["removal"]
)
names = [r["name"] for r in results]
assert "Swords to Plowshares" not in names
assert "Doom Blade" in names
# ---------------------------------------------------------------------------
# Tests: calculate_tier_ceilings
# ---------------------------------------------------------------------------
def test_tier_ceilings_correct_fractions(evaluator):
ceilings = evaluator.calculate_tier_ceilings(100.0)
assert ceilings["S"] == pytest.approx(20.0)
assert ceilings["M"] == pytest.approx(10.0)
assert ceilings["L"] == pytest.approx(5.0)
def test_tier_ceilings_zero_budget(evaluator):
ceilings = evaluator.calculate_tier_ceilings(0.0)
assert all(v == 0.0 for v in ceilings.values())
# ---------------------------------------------------------------------------
# Tests: validation guards
# ---------------------------------------------------------------------------
def test_negative_budget_raises(evaluator):
with pytest.raises(Exception):
evaluator.evaluate_deck(["Sol Ring"], budget_total=-1.0)
def test_invalid_mode_raises(evaluator):
with pytest.raises(Exception):
evaluator.evaluate_deck(["Sol Ring"], budget_total=100.0, mode="turbo")
def test_negative_ceiling_raises(evaluator):
with pytest.raises(Exception):
evaluator.evaluate_deck(["Sol Ring"], budget_total=100.0, card_ceiling=-5.0)
# ---------------------------------------------------------------------------
# Tests: BudgetHardCapExceeded exception
# ---------------------------------------------------------------------------
def test_budget_hard_cap_exception_attributes():
from code.exceptions import BudgetHardCapExceeded
exc = BudgetHardCapExceeded(
total_price=200.0,
budget_total=150.0,
over_budget_cards=[{"card": "Mana Crypt", "price": 150.0}],
)
assert exc.overage == pytest.approx(50.0)
assert exc.total_price == 200.0
assert exc.budget_total == 150.0
assert len(exc.over_budget_cards) == 1
assert "BUDGET_HARD_CAP" in exc.code
# ---------------------------------------------------------------------------
# M8: Price chart helpers
# ---------------------------------------------------------------------------
from code.web.services.budget_evaluator import (
compute_price_category_breakdown,
compute_price_histogram,
CATEGORY_ORDER,
)
def test_category_breakdown_basic():
items = [
{"card": "Sol Ring", "price": 2.00, "tags": ["ramp", "mana rock"]},
{"card": "Arcane Signet","price": 1.50, "tags": ["ramp", "mana rock"]},
{"card": "Swords to Plowshares", "price": 3.00, "tags": ["spot removal", "removal"]},
{"card": "Forest", "price": 0.25, "tags": ["land"]},
]
result = compute_price_category_breakdown(items)
assert result["totals"]["Ramp"] == pytest.approx(3.50)
assert result["totals"]["Removal"] == pytest.approx(3.00)
assert result["totals"]["Land"] == pytest.approx(0.25)
assert result["total"] == pytest.approx(6.75)
assert result["order"] == CATEGORY_ORDER
def test_category_breakdown_unmatched_goes_to_other():
items = [{"card": "Thassa's Oracle", "price": 10.00, "tags": ["combo", "wincon"]}]
result = compute_price_category_breakdown(items)
assert result["totals"]["Synergy"] == pytest.approx(10.00)
# Specifically "combo" hits Synergy, not Other
def test_category_breakdown_no_price_skipped():
items = [
{"card": "Card A", "price": None, "tags": ["ramp"]},
{"card": "Card B", "price": 5.00, "tags": []},
]
result = compute_price_category_breakdown(items)
assert result["total"] == pytest.approx(5.00)
assert result["totals"]["Other"] == pytest.approx(5.00)
def test_histogram_10_bins():
items = [{"card": f"Card {i}", "price": float(i)} for i in range(1, 21)]
bins = compute_price_histogram(items)
assert len(bins) == 10
assert all("label" in b and "count" in b and "pct" in b and "color" in b for b in bins)
assert sum(b["count"] for b in bins) == 20
def test_histogram_all_same_price():
items = [{"card": f"Card {i}", "price": 1.00} for i in range(5)]
bins = compute_price_histogram(items)
assert len(bins) == 10
assert bins[0]["count"] == 5
assert all(b["count"] == 0 for b in bins[1:])
def test_histogram_fewer_than_2_returns_empty():
assert compute_price_histogram([]) == []
assert compute_price_histogram([{"card": "Solo", "price": 5.0}]) == []
def test_histogram_excludes_unpriced_cards():
items = [
{"card": "A", "price": 1.0},
{"card": "B", "price": None},
{"card": "C", "price": 3.0},
{"card": "D", "price": 5.0},
]
bins = compute_price_histogram(items)
assert sum(b["count"] for b in bins) == 3 # B excluded

View file

@ -8,7 +8,7 @@ This file consolidates tests from three source files:
Created: 2026-02-20
Consolidation Purpose: Centralize all export and metadata-related tests
Total Tests: 21 (4 commander metadata + 2 MDFC + 15 metadata partition)
Total Tests: 25 (4 commander metadata + 2 MDFC + 15 metadata partition + 4 price column)
"""
from __future__ import annotations
@ -502,5 +502,110 @@ class TestCSVCompatibility:
assert df_partitioned.loc[0, 'metadataTags'] == ['Applied: Cost Reduction']
# ============================================================================
# SECTION 5: PRICE COLUMN EXPORT TESTS (M7)
# Tests for price data in CSV exports
# ============================================================================
class _PriceBuilder(ReportingMixin):
"""Minimal builder with 3 cards for price export tests."""
def __init__(self) -> None:
self.card_library = {
"Sol Ring": {"Card Type": "Artifact", "Count": 1, "Mana Cost": "{1}", "Mana Value": "1", "Role": "Ramp", "Tags": []},
"Counterspell": {"Card Type": "Instant", "Count": 1, "Mana Cost": "{U}{U}", "Mana Value": "2", "Role": "Removal", "Tags": []},
"Tropical Island": {"Card Type": "Land", "Count": 1, "Mana Cost": "", "Mana Value": "0", "Role": "Land", "Tags": []},
}
self.output_func = lambda *_args, **_kwargs: None
self.commander_name = ""
self.primary_tag = "Spellslinger"
self.secondary_tag = None
self.tertiary_tag = None
self.selected_tags = ["Spellslinger"]
self.custom_export_base = "price_test"
def _suppress_cm(monkeypatch: pytest.MonkeyPatch) -> None:
stub = types.ModuleType("deck_builder.builder_utils")
stub.compute_color_source_matrix = lambda *_a, **_kw: {}
stub.multi_face_land_info = lambda *_a, **_kw: {}
monkeypatch.setitem(sys.modules, "deck_builder.builder_utils", stub)
def test_csv_price_column_present_no_lookup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Price column exists even when no price_lookup is provided; values are blank."""
_suppress_cm(monkeypatch)
b = _PriceBuilder()
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv"))
with path.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
assert "Price" in (reader.fieldnames or [])
rows = list(reader)
card_rows = [r for r in rows if r["Name"] and r["Name"] != "Total"]
assert all(r["Price"] == "" for r in card_rows)
# No summary row when no prices available
assert not any(r["Name"] == "Total" for r in rows)
def test_csv_price_column_populated_by_lookup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Price column is filled when a price_lookup callable is supplied."""
_suppress_cm(monkeypatch)
prices = {"Sol Ring": 1.50, "Counterspell": 2.00, "Tropical Island": 100.00}
def _lookup(names: list) -> dict:
return {n: prices.get(n) for n in names}
b = _PriceBuilder()
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup))
with path.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
rows = list(reader)
card_rows = {r["Name"]: r for r in rows if r["Name"] and r["Name"] != "Total"}
assert card_rows["Sol Ring"]["Price"] == "1.50"
assert card_rows["Counterspell"]["Price"] == "2.00"
assert card_rows["Tropical Island"]["Price"] == "100.00"
def test_csv_price_summary_row(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A Total row appears with the deck's summed price when prices are available."""
_suppress_cm(monkeypatch)
def _lookup(names: list) -> dict:
return {"Sol Ring": 1.50, "Counterspell": 2.00, "Tropical Island": 100.00}
b = _PriceBuilder()
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup))
with path.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
rows = list(reader)
total_rows = [r for r in rows if r["Name"] == "Total"]
assert len(total_rows) == 1
assert total_rows[0]["Price"] == "103.50"
def test_csv_price_blank_when_lookup_returns_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Cards with no price in the lookup get a blank Price cell, not an error."""
_suppress_cm(monkeypatch)
def _lookup(names: list) -> dict:
return {"Sol Ring": 1.50, "Counterspell": None, "Tropical Island": None}
b = _PriceBuilder()
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup))
with path.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
rows = list(reader)
card_rows = {r["Name"]: r for r in rows if r["Name"] and r["Name"] != "Total"}
assert card_rows["Sol Ring"]["Price"] == "1.50"
assert card_rows["Counterspell"]["Price"] == ""
assert card_rows["Tropical Island"]["Price"] == ""
# Total reflects only priced cards
total_rows = [r for r in rows if r["Name"] == "Total"]
assert total_rows[0]["Price"] == "1.50"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -0,0 +1,307 @@
"""Tests for PriceService - price lookup, caching, and batch operations."""
from __future__ import annotations
import json
import os
import time
import threading
from typing import Any, Dict
from unittest.mock import patch, MagicMock
import pytest
from code.web.services.price_service import PriceService
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_bulk_line(name: str, usd: str = None, eur: str = None, usd_foil: str = None) -> str:
"""Return a JSON line matching Scryfall bulk data format."""
card: Dict[str, Any] = {
"object": "card",
"name": name,
"prices": {
"usd": usd,
"usd_foil": usd_foil,
"eur": eur,
"eur_foil": None,
"tix": None,
},
}
return json.dumps(card)
def _write_bulk_data(path: str, cards: list) -> None:
"""Write a minimal Scryfall bulk data JSON array (one card per line)."""
with open(path, "w", encoding="utf-8") as fh:
fh.write("[\n")
for i, card in enumerate(cards):
suffix = "," if i < len(cards) - 1 else ""
fh.write(json.dumps(card) + suffix + "\n")
fh.write("]\n")
@pytest.fixture
def bulk_data_file(tmp_path):
"""Minimal Scryfall bulk data with known prices."""
cards = [
{"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.50", "usd_foil": "2.00", "eur": "0.40", "eur_foil": None, "tix": None}},
{"object": "card", "name": "Sol Ring", "prices": {"usd": "2.00", "usd_foil": "5.00", "eur": "1.80", "eur_foil": None, "tix": None}},
{"object": "card", "name": "Mana Crypt", "prices": {"usd": "150.00", "usd_foil": "300.00", "eur": "120.00", "eur_foil": None, "tix": None}},
# Card with no price
{"object": "card", "name": "Unpriced Card", "prices": {"usd": None, "usd_foil": None, "eur": None, "eur_foil": None, "tix": None}},
# Second printing of Lightning Bolt (cheaper)
{"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.25", "usd_foil": "1.00", "eur": "0.20", "eur_foil": None, "tix": None}},
# DFC card
{"object": "card", "name": "Delver of Secrets // Insectile Aberration", "prices": {"usd": "1.50", "usd_foil": "8.00", "eur": "1.20", "eur_foil": None, "tix": None}},
]
path = str(tmp_path / "scryfall_bulk_data.json")
_write_bulk_data(path, cards)
return path
@pytest.fixture
def price_svc(bulk_data_file, tmp_path):
"""PriceService pointed at the test bulk data, with a temporary cache path."""
cache_path = str(tmp_path / "prices_cache.json")
return PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600)
# ---------------------------------------------------------------------------
# Tests: single price lookup
# ---------------------------------------------------------------------------
def test_get_price_known_card(price_svc):
price = price_svc.get_price("Lightning Bolt")
# Should return the cheapest printing (0.25, not 0.50)
assert price == pytest.approx(0.25)
def test_get_price_case_insensitive(price_svc):
assert price_svc.get_price("lightning bolt") == price_svc.get_price("LIGHTNING BOLT")
def test_get_price_foil(price_svc):
foil = price_svc.get_price("Lightning Bolt", foil=True)
# Cheapest foil printing
assert foil == pytest.approx(1.00)
def test_get_price_eur_region(price_svc):
price = price_svc.get_price("Sol Ring", region="eur")
assert price == pytest.approx(1.80)
def test_get_price_unknown_card_returns_none(price_svc):
assert price_svc.get_price("Nonexistent Card Name XYZ") is None
def test_get_price_unpriced_card_returns_none(price_svc):
assert price_svc.get_price("Unpriced Card") is None
def test_get_price_expensive_card(price_svc):
assert price_svc.get_price("Mana Crypt") == pytest.approx(150.00)
# ---------------------------------------------------------------------------
# Tests: DFC card name indexing
# ---------------------------------------------------------------------------
def test_get_price_dfc_combined_name(price_svc):
price = price_svc.get_price("Delver of Secrets // Insectile Aberration")
assert price == pytest.approx(1.50)
def test_get_price_dfc_front_face_name(price_svc):
"""Front face name alone should resolve to the DFC price."""
price = price_svc.get_price("Delver of Secrets")
assert price == pytest.approx(1.50)
def test_get_price_dfc_back_face_name(price_svc):
"""Back face name alone should also resolve."""
price = price_svc.get_price("Insectile Aberration")
assert price == pytest.approx(1.50)
# ---------------------------------------------------------------------------
# Tests: batch lookup
# ---------------------------------------------------------------------------
def test_get_prices_batch_all_found(price_svc):
result = price_svc.get_prices_batch(["Lightning Bolt", "Sol Ring"])
assert result["Lightning Bolt"] == pytest.approx(0.25)
assert result["Sol Ring"] == pytest.approx(2.00)
def test_get_prices_batch_mixed_found_missing(price_svc):
result = price_svc.get_prices_batch(["Lightning Bolt", "Unknown Card"])
assert result["Lightning Bolt"] is not None
assert result["Unknown Card"] is None
def test_get_prices_batch_empty_list(price_svc):
assert price_svc.get_prices_batch([]) == {}
def test_get_prices_batch_preserves_original_case(price_svc):
result = price_svc.get_prices_batch(["LIGHTNING BOLT"])
# Key should match input case exactly
assert "LIGHTNING BOLT" in result
assert result["LIGHTNING BOLT"] == pytest.approx(0.25)
# ---------------------------------------------------------------------------
# Tests: cache persistence
# ---------------------------------------------------------------------------
def test_rebuild_writes_cache_file(price_svc, tmp_path):
# Trigger load → rebuild
price_svc.get_price("Sol Ring")
assert os.path.exists(price_svc._cache_path)
def test_cache_file_has_expected_structure(price_svc, tmp_path):
price_svc.get_price("Sol Ring")
with open(price_svc._cache_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
assert "prices" in data
assert "built_at" in data
assert "sol ring" in data["prices"]
def test_fresh_cache_loaded_without_rebuild(bulk_data_file, tmp_path):
"""Second PriceService instance should load from cache, not rebuild."""
cache_path = str(tmp_path / "prices_cache.json")
svc1 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path)
svc1.get_price("Sol Ring") # triggers rebuild → writes cache
rebuild_calls = []
svc2 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path)
orig_rebuild = svc2._rebuild_cache
def patched_rebuild():
rebuild_calls.append(1)
orig_rebuild()
svc2._rebuild_cache = patched_rebuild
svc2.get_price("Sol Ring") # should load from cache, not rebuild
assert rebuild_calls == [], "Second instance should not rebuild when cache is fresh"
def test_stale_cache_triggers_rebuild(bulk_data_file, tmp_path):
"""Cache older than TTL should trigger a rebuild."""
cache_path = str(tmp_path / "prices_cache.json")
# Write a valid but stale cache file
stale_data = {
"prices": {"sol ring": {"usd": 2.0}},
"built_at": time.time() - 99999, # very old
}
with open(cache_path, "w") as fh:
json.dump(stale_data, fh)
# Set mtime to old as well
old_time = time.time() - 99999
os.utime(cache_path, (old_time, old_time))
rebuild_calls = []
svc = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600)
orig_rebuild = svc._rebuild_cache
def patched_rebuild():
rebuild_calls.append(1)
orig_rebuild()
svc._rebuild_cache = patched_rebuild
svc.get_price("Sol Ring")
assert rebuild_calls == [1], "Stale cache should trigger a rebuild"
# ---------------------------------------------------------------------------
# Tests: cache stats / telemetry
# ---------------------------------------------------------------------------
def test_cache_stats_structure(price_svc):
price_svc.get_price("Sol Ring")
stats = price_svc.cache_stats()
assert "total_entries" in stats
assert "hit_count" in stats
assert "miss_count" in stats
assert "hit_rate" in stats
assert "loaded" in stats
assert stats["loaded"] is True
def test_cache_stats_hit_miss_counts(price_svc):
price_svc.get_price("Sol Ring") # hit
price_svc.get_price("Unknown Card") # miss
stats = price_svc.cache_stats()
assert stats["hit_count"] >= 1
assert stats["miss_count"] >= 1
def test_cache_stats_hit_rate_zero_before_load():
"""Before any lookups, hit_rate should be 0."""
svc = PriceService(bulk_data_path="/nonexistent", cache_path="/nonexistent/cache.json")
# Don't trigger _ensure_loaded - call cache_stats indirectly via a direct check
# We expect loaded=False and hit_rate=0
# Note: cache_stats calls _ensure_loaded, so bulk_data missing → cache remains empty
stats = svc.cache_stats()
assert stats["hit_rate"] == 0.0
# ---------------------------------------------------------------------------
# Tests: background refresh
# ---------------------------------------------------------------------------
def test_refresh_cache_background_starts_thread(price_svc):
price_svc.get_price("Sol Ring") # ensure loaded
price_svc.refresh_cache_background()
# Allow thread to start
time.sleep(0.05)
# Thread should have run (or be running)
assert price_svc._refresh_thread is not None
def test_refresh_cache_background_no_duplicate_threads(price_svc):
price_svc.get_price("Sol Ring")
price_svc.refresh_cache_background()
t1 = price_svc._refresh_thread
price_svc.refresh_cache_background() # second call while thread running
t2 = price_svc._refresh_thread
assert t1 is t2, "Should not spawn a second refresh thread"
# ---------------------------------------------------------------------------
# Tests: missing / corrupted bulk data
# ---------------------------------------------------------------------------
def test_missing_bulk_data_returns_none(tmp_path):
svc = PriceService(
bulk_data_path=str(tmp_path / "nonexistent.json"),
cache_path=str(tmp_path / "cache.json"),
)
assert svc.get_price("Sol Ring") is None
def test_corrupted_bulk_data_line_skipped(tmp_path):
"""Malformed JSON lines should be skipped without crashing."""
bulk_path = str(tmp_path / "bulk.json")
with open(bulk_path, "w") as fh:
fh.write("[\n")
fh.write('{"object":"card","name":"Sol Ring","prices":{"usd":"2.00","usd_foil":null,"eur":null,"eur_foil":null,"tix":null}}\n')
fh.write("NOT VALID JSON,,,,\n")
fh.write("]")
svc = PriceService(
bulk_data_path=bulk_path,
cache_path=str(tmp_path / "cache.json"),
)
# Should still find Sol Ring despite corrupted line
assert svc.get_price("Sol Ring") == pytest.approx(2.00)