From 91df482dd8be361426d88291ca527a12c3961384 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 24 May 2026 23:37:16 -0400 Subject: [PATCH] =?UTF-8?q?A.2=20TarotCard.image=5Ffilename=20+=20display?= =?UTF-8?q?=5Fsuit=5Fname=20properties=20=E2=80=94=20TDD.=20Sprint=20A.2?= =?UTF-8?q?=20of=20[[project-image-based-deck-face-rendering]].=20Adds=20t?= =?UTF-8?q?wo=20per-card=20derived=20properties=20that=20consume=20the=20n?= =?UTF-8?q?ew=20`DeckVariant.family`=20field=20(locked=20in=20A.0)=20to=20?= =?UTF-8?q?translate=20canonical-Earthman=20SUIT=20enum=20(BRANDS/CROWNS/G?= =?UTF-8?q?RAILS/BLADES)=20into=20family-authentic=20filename=20slugs=20+?= =?UTF-8?q?=20UI=20labels=20per=20[[reference-card-image-naming-convention?= =?UTF-8?q?]]=20v2.=20`DeckVariant`=20gains=20the=20family-mapping=20table?= =?UTF-8?q?s=20+=20methods=20(`suit=5Fslug`=20/=20`suit=5Fdisplay`=20/=20`?= =?UTF-8?q?trump=5Fcategory`);=20`TarotCard`=20consumes=20them=20via=20`im?= =?UTF-8?q?age=5Ffilename`=20+=20`display=5Fsuit=5Fname`.=20Two=20mapping?= =?UTF-8?q?=20tables=20live=20on=20DeckVariant=20(single=20source=20of=20t?= =?UTF-8?q?ruth=20for=20per-family=20vocab):=20`=5FSUIT=5FSLUG=5FBY=5FFAMI?= =?UTF-8?q?LY`=20(4=20families=20=C3=97=204=20suits=20=3D=2016=20entries:?= =?UTF-8?q?=20earthman=20is=20identity-mapped=20{BRANDS=E2=86=92brands,=20?= =?UTF-8?q?CROWNS=E2=86=92crowns,=20GRAILS=E2=86=92grails,=20BLADES?= =?UTF-8?q?=E2=86=92blades};=20italian=20is=20{BRANDS=E2=86=92batons,=20CR?= =?UTF-8?q?OWNS=E2=86=92coins,=20GRAILS=E2=86=92cups,=20BLADES=E2=86=92swo?= =?UTF-8?q?rds};=20english=20is=20{BRANDS=E2=86=92wands,=20CROWNS=E2=86=92?= =?UTF-8?q?pentacles,=20GRAILS=E2=86=92cups,=20BLADES=E2=86=92swords};=20p?= =?UTF-8?q?laying=20is=20{BRANDS=E2=86=92clubs,=20CROWNS=E2=86=92diamonds,?= =?UTF-8?q?=20GRAILS=E2=86=92hearts,=20BLADES=E2=86=92spades})=20and=20`?= =?UTF-8?q?=5FTRUMP=5FCATEGORY=5FBY=5FFAMILY`=20(earthman+italian=20use=20?= =?UTF-8?q?"trumps",=20english=20uses=20"majors"=20matching=20Modern=20Tar?= =?UTF-8?q?ot's=20"Major=20Arcana",=20playing=20is=20None=20since=2052-car?= =?UTF-8?q?d=20decks=20have=20no=20trump=20category=20=E2=80=94=20jokers?= =?UTF-8?q?=20handled=20separately=20when=20a=20playing=20deck=20is=20seed?= =?UTF-8?q?ed).=20`DeckVariant.suit=5Fslug(canonical)`=20returns=20the=20f?= =?UTF-8?q?ilename=20slug;=20`suit=5Fdisplay(canonical)`=20returns=20capit?= =?UTF-8?q?alized=20UI=20label=20(via=20slug.capitalize());=20`trump=5Fcat?= =?UTF-8?q?egory`=20is=20a=20property=20since=20it=20takes=20no=20per-card?= =?UTF-8?q?=20argument.=20`TarotCard.image=5Ffilename`=20branches=20on=20a?= =?UTF-8?q?rcana:=20MAJOR=20returns=20`---.png`=20(NN=20=3D=20zero-padded=20number=20per=20v2?= =?UTF-8?q?=20convention,=20e.g.=2000=20for=20Il=20Matto;=20card-slug=20ca?= =?UTF-8?q?rries=20the=20italian=20name=20like=20"il-gobbo"=20or=20english?= =?UTF-8?q?=20like=20"the-fool");=20MINOR/MIDDLE=20returns=20`-?= =?UTF-8?q?-[-].png`=20where=20court=20suffix=20is?= =?UTF-8?q?=20"page"/"knight"/"queen"/"king"=20for=20ranks=2011-14=20(taro?= =?UTF-8?q?t=20family=20courts;=20playing-family's=203-court=20jack/queen/?= =?UTF-8?q?king=20deferred=20to=20playing-deck-seed=20sprint).=20`display?= =?UTF-8?q?=5Fsuit=5Fname`=20returns=20capitalized=20family-authentic=20su?= =?UTF-8?q?it=20name=20("Batons"=20for=20italian=20BRANDS,=20"Pentacles"?= =?UTF-8?q?=20for=20english=20CROWNS)=20or=20empty=20string=20for=20major?= =?UTF-8?q?=20arcana=20(no=20suit).=20Both=20properties=20are=20pure-deriv?= =?UTF-8?q?ed=20=E2=80=94=20no=20schema=20migration=20needed,=20no=20DB=20?= =?UTF-8?q?writes;=20the=20template=20(Sprint=20A.3+)=20decides=20whether?= =?UTF-8?q?=20to=20render=20=20based=20on=20?= =?UTF-8?q?`deck.has=5Fcard=5Fimages`.=20RWS=20deck's=20image=5Ffilename?= =?UTF-8?q?=20returns=20a=20path=20even=20though=20has=5Fcard=5Fimages=3DF?= =?UTF-8?q?alse=20(path=20is=20correct=20per=20convention;=20just=20no=20f?= =?UTF-8?q?ile=20exists=20at=20that=20path=20yet=20=E2=80=94=20once=20RWS?= =?UTF-8?q?=20images=20are=20sourced,=20flip=20the=20flag).=2017=20new=20I?= =?UTF-8?q?Ts=20in=20`CardImageFilenameA2Test`=20cover:=20Minchiate=20trum?= =?UTF-8?q?ps=20(Il=20Matto=20rank-00,=20Il=20Gobbo=20rank-11,=20Le=20Trom?= =?UTF-8?q?be=20rank-40,=20L'Acqua=20rank-21=20w.=20apostrophe-restored=20?= =?UTF-8?q?slug);=20Minchiate=20minors=20(Ace=20of=20Batons=20pip-with-no-?= =?UTF-8?q?court-suffix,=20Ten=20of=20Coins,=20Page=20of=20Cups=20w.=20cou?= =?UTF-8?q?rt=20suffix,=20King=20of=20Swords);=20RWS=20post-revocab=20(Ace?= =?UTF-8?q?=20of=20Cups=20uses=20english-family=20"cups"=20slug=20despite?= =?UTF-8?q?=20suit=3DGRAILS,=20The=20Fool=20uses=20"majors"=20category,=20?= =?UTF-8?q?King=20of=20Pentacles=20uses=20"pentacles"=20slug=20despite=20s?= =?UTF-8?q?uit=3DCROWNS);=20Earthman=20identity-mapped=20(BRANDS=E2=86=92b?= =?UTF-8?q?rands);=20display=5Fsuit=5Fname=20across=20all=203=20tarot=20fa?= =?UTF-8?q?milies=20(italian=20BRANDS=E2=86=92"Batons",=20italian=20CROWNS?= =?UTF-8?q?=E2=86=92"Coins",=20english=20CROWNS=E2=86=92"Pentacles",=20ear?= =?UTF-8?q?thman=20BRANDS=E2=86=92"Brands");=20empty=20for=20majors.=20Tes?= =?UTF-8?q?ts:=2017=20new=20green;=201289/1289=20IT+UT=20total=20green=20(?= =?UTF-8?q?63s;=20+17=20from=20A.1's=201272).=20Out=20of=20scope:=20A.3=20?= =?UTF-8?q?wires=20my=5Fsign.html's=20first=20render=20branch=20(the=20vis?= =?UTF-8?q?ible-win=20first=20surface);=20A.4=20builds=20card-deck=20icon?= =?UTF-8?q?=20+=20game=5Fkit=20applet;=20A.5-A.8=20DRY=20across=20my=5Fsea?= =?UTF-8?q?=20+=20both=20billboard=20applets=20+=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apps/epic/models.py | 59 +++++++ src/apps/epic/tests/integrated/test_models.py | 149 ++++++++++++++++++ 2 files changed, 208 insertions(+) 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, "")