diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 78a8c9c..510c2e8 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -234,6 +234,22 @@ class DeckVariant(models.Model): (PLAYING, "Playing card"), ] + # Per-family translation tables: canonical SUIT enum (Earthman vocab) → + # family-authentic display slug used in image filenames + UI labels. + # See [[reference-card-image-naming-convention]] v2. + _SUIT_SLUG_BY_FAMILY = { + EARTHMAN: {"BRANDS": "brands", "CROWNS": "crowns", "GRAILS": "grails", "BLADES": "blades"}, + ITALIAN: {"BRANDS": "batons", "CROWNS": "coins", "GRAILS": "cups", "BLADES": "swords"}, + ENGLISH: {"BRANDS": "wands", "CROWNS": "pentacles", "GRAILS": "cups", "BLADES": "swords"}, + PLAYING: {"BRANDS": "clubs", "CROWNS": "diamonds", "GRAILS": "hearts", "BLADES": "spades"}, + } + _TRUMP_CATEGORY_BY_FAMILY = { + EARTHMAN: "trumps", + ITALIAN: "trumps", + ENGLISH: "majors", + PLAYING: None, # 52-card decks: no trump category (jokers handled separately) + } + name = models.CharField(max_length=100, unique=True) slug = models.SlugField(unique=True) card_count = models.IntegerField() @@ -243,6 +259,20 @@ class DeckVariant(models.Model): has_card_images = models.BooleanField(default=True) is_polarized = models.BooleanField(default=False) + def suit_slug(self, canonical_suit): + """Map canonical SUIT enum → family-authentic filename slug. + e.g. ('italian', 'BRANDS') → 'batons'.""" + return self._SUIT_SLUG_BY_FAMILY[self.family][canonical_suit] + + def suit_display(self, canonical_suit): + """User-facing capitalized suit label, e.g. ('italian', 'BRANDS') → 'Batons'.""" + return self.suit_slug(canonical_suit).capitalize() + + @property + def trump_category(self): + """Filename-slug category for trump cards in this family.""" + return self._TRUMP_CATEGORY_BY_FAMILY[self.family] + @property def short_key(self): """First dash-separated word of slug — used as an HTML id component.""" @@ -452,6 +482,35 @@ class TarotCard(models.Model): self.BLADES: 'fa-gun', }.get(self.suit, '') + # Tarot-family courts: rank 11=page, 12=knight, 13=queen, 14=king. Playing + # family (3 courts: jack/queen/king at ranks 11-13) handled separately when + # a playing deck is seeded — Sprint A.2 covers tarot families only. + _COURT_NAME_BY_RANK = {11: "page", 12: "knight", 13: "queen", 14: "king"} + + @property + def image_filename(self): + """v2-convention filename per [[reference-card-image-naming-convention]]. + Always derives a path; the template decides whether to actually render + an based on `deck_variant.has_card_images`.""" + deck = self.deck_variant + if self.arcana == self.MAJOR: + return f"{deck.slug}-{deck.trump_category}-{self.number:02d}-{self.slug}.png" + # MINOR or MIDDLE: --[-].png + suit_slug = deck.suit_slug(self.suit) + rank = f"{self.number:02d}" + court = self._COURT_NAME_BY_RANK.get(self.number) + if court: + return f"{deck.slug}-{suit_slug}-{rank}-{court}.png" + return f"{deck.slug}-{suit_slug}-{rank}.png" + + @property + def display_suit_name(self): + """Family-authentic capitalized suit label (e.g. 'Batons' for italian + BRANDS, 'Pentacles' for english CROWNS). Empty for major arcana.""" + if not self.suit: + return "" + return self.deck_variant.suit_display(self.suit) + @property def cautions_json(self): import json diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 6145325..1d70f86 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -1149,3 +1149,152 @@ class MinchiateFiorentine1860SeedTest(TestCase): TarotCard.objects.filter(deck_variant=self.deck, arcana="MIDDLE").count(), 0, ) + + +class CardImageFilenameA2Test(TestCase): + """Sprint A.2 — `TarotCard.image_filename` + `display_suit_name` properties. + + `image_filename` derives a v2-convention-compliant filename string per + [[reference-card-image-naming-convention]] using `deck_variant.family` + to translate canonical Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES) + into the family-authentic display slug (batons/coins/cups/swords for + italian; wands/pentacles/cups/swords for english; brands/crowns/grails/blades + for earthman). `display_suit_name` returns the capitalized version for + user-facing labels (tooltip, stat-block, card title). + """ + + def setUp(self): + self.minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890") + self.rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + self.earthman = DeckVariant.objects.get(slug="earthman") + + # ── image_filename: Minchiate (italian family) trumps ─────────────────── + def test_filename_il_matto(self): + card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-matto") + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png", + ) + + def test_filename_il_gobbo(self): + card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-gobbo") + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-trumps-11-il-gobbo.png", + ) + + def test_filename_le_trombe(self): + card = TarotCard.objects.get(deck_variant=self.minchiate, slug="le-trombe") + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-trumps-40-le-trombe.png", + ) + + def test_filename_l_acqua_w_apostrophe_restored(self): + card = TarotCard.objects.get(deck_variant=self.minchiate, slug="l-acqua") + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-trumps-21-l-acqua.png", + ) + + # ── image_filename: Minchiate (italian family) minors ─────────────────── + def test_filename_ace_of_batons(self): + card = TarotCard.objects.get( + deck_variant=self.minchiate, suit="BRANDS", number=1, + ) + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-batons-01.png", + ) + + def test_filename_ten_of_coins(self): + card = TarotCard.objects.get( + deck_variant=self.minchiate, suit="CROWNS", number=10, + ) + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-coins-10.png", + ) + + def test_filename_page_of_cups_has_court_suffix(self): + card = TarotCard.objects.get( + deck_variant=self.minchiate, suit="GRAILS", number=11, + ) + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-cups-11-page.png", + ) + + def test_filename_king_of_swords_has_court_suffix(self): + card = TarotCard.objects.get( + deck_variant=self.minchiate, suit="BLADES", number=14, + ) + self.assertEqual( + card.image_filename, + "minchiate-fiorentine-1860-1890-swords-14-king.png", + ) + + # ── image_filename: RWS (english family) — derived path even when + # has_card_images=False (template decides whether to render the ) ── + def test_filename_rws_ace_of_cups_uses_english_suit_slug(self): + """Post-revocab, RWS Ace of Cups has suit=GRAILS but english-family + display slug 'cups'.""" + card = TarotCard.objects.get(deck_variant=self.rws, slug="ace-of-cups") + self.assertEqual( + card.image_filename, + "tarot-rider-waite-smith-cups-01.png", + ) + + def test_filename_rws_the_fool_uses_majors_category(self): + """English Tarot family uses 'majors' as the trump-category slug, + not 'trumps' (which is the Italian-Minchiate convention).""" + card = TarotCard.objects.get(deck_variant=self.rws, slug="the-fool") + self.assertEqual( + card.image_filename, + "tarot-rider-waite-smith-majors-00-the-fool.png", + ) + + def test_filename_rws_king_of_pentacles_uses_pentacles_slug(self): + """CROWNS canonical, english-family display 'pentacles'.""" + card = TarotCard.objects.get(deck_variant=self.rws, slug="king-of-pentacles") + self.assertEqual( + card.image_filename, + "tarot-rider-waite-smith-pentacles-14-king.png", + ) + + # ── image_filename: Earthman (earthman family) ────────────────────────── + def test_filename_earthman_uses_earthman_suit_slug(self): + """Earthman family is identity-mapped: BRANDS→brands etc. Returns + a path even though Earthman.has_card_images=False — the template + decides whether to USE the path.""" + card = TarotCard.objects.filter( + deck_variant=self.earthman, suit="BRANDS", + ).first() + self.assertTrue(card.image_filename.startswith("earthman-brands-")) + + # ── display_suit_name ─────────────────────────────────────────────────── + def test_display_suit_name_italian_brands_is_batons(self): + card = TarotCard.objects.get( + deck_variant=self.minchiate, suit="BRANDS", number=1, + ) + self.assertEqual(card.display_suit_name, "Batons") + + def test_display_suit_name_italian_crowns_is_coins(self): + card = TarotCard.objects.get( + deck_variant=self.minchiate, suit="CROWNS", number=1, + ) + self.assertEqual(card.display_suit_name, "Coins") + + def test_display_suit_name_english_crowns_is_pentacles(self): + card = TarotCard.objects.get(deck_variant=self.rws, slug="ace-of-pentacles") + self.assertEqual(card.display_suit_name, "Pentacles") + + def test_display_suit_name_earthman_brands_is_brands(self): + card = TarotCard.objects.filter( + deck_variant=self.earthman, suit="BRANDS", + ).first() + self.assertEqual(card.display_suit_name, "Brands") + + def test_display_suit_name_empty_for_major_arcana(self): + card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-matto") + self.assertEqual(card.display_suit_name, "")