A.2 TarotCard.image_filename + display_suit_name properties — TDD. Sprint A.2 of [[project-image-based-deck-face-rendering]]. Adds two per-card derived properties that consume the new DeckVariant.family field (locked in A.0) to translate canonical-Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES) into family-authentic filename slugs + UI labels per [[reference-card-image-naming-convention]] v2. DeckVariant gains the family-mapping tables + methods (suit_slug / suit_display / trump_category); TarotCard consumes them via image_filename + display_suit_name. Two mapping tables live on DeckVariant (single source of truth for per-family vocab): _SUIT_SLUG_BY_FAMILY (4 families × 4 suits = 16 entries: earthman is identity-mapped {BRANDS→brands, CROWNS→crowns, GRAILS→grails, BLADES→blades}; italian is {BRANDS→batons, CROWNS→coins, GRAILS→cups, BLADES→swords}; english is {BRANDS→wands, CROWNS→pentacles, GRAILS→cups, BLADES→swords}; playing is {BRANDS→clubs, CROWNS→diamonds, GRAILS→hearts, BLADES→spades}) and _TRUMP_CATEGORY_BY_FAMILY (earthman+italian use "trumps", english uses "majors" matching Modern Tarot's "Major Arcana", playing is None since 52-card decks have no trump category — jokers handled separately when a playing deck is seeded). DeckVariant.suit_slug(canonical) returns the filename slug; suit_display(canonical) returns capitalized UI label (via slug.capitalize()); trump_category is a property since it takes no per-card argument. TarotCard.image_filename branches on arcana: MAJOR returns <deck-slug>-<trump-category>-<NN>-<card-slug>.png (NN = zero-padded number per v2 convention, e.g. 00 for Il Matto; card-slug carries the italian name like "il-gobbo" or english like "the-fool"); MINOR/MIDDLE returns <deck-slug>-<suit-slug>-<NN>[-<court>].png where court suffix is "page"/"knight"/"queen"/"king" for ranks 11-14 (tarot family courts; playing-family's 3-court jack/queen/king deferred to playing-deck-seed sprint). display_suit_name returns capitalized family-authentic suit name ("Batons" for italian BRANDS, "Pentacles" for english CROWNS) or empty string for major arcana (no suit). Both properties are pure-derived — no schema migration needed, no DB writes; the template (Sprint A.3+) decides whether to render <img src=image_filename> based on deck.has_card_images. RWS deck's image_filename returns a path even though has_card_images=False (path is correct per convention; just no file exists at that path yet — once RWS images are sourced, flip the flag). 17 new ITs in CardImageFilenameA2Test cover: Minchiate trumps (Il Matto rank-00, Il Gobbo rank-11, Le Trombe rank-40, L'Acqua rank-21 w. apostrophe-restored slug); Minchiate minors (Ace of Batons pip-with-no-court-suffix, Ten of Coins, Page of Cups w. court suffix, King of Swords); RWS post-revocab (Ace of Cups uses english-family "cups" slug despite suit=GRAILS, The Fool uses "majors" category, King of Pentacles uses "pentacles" slug despite suit=CROWNS); Earthman identity-mapped (BRANDS→brands); display_suit_name across all 3 tarot families (italian BRANDS→"Batons", italian CROWNS→"Coins", english CROWNS→"Pentacles", earthman BRANDS→"Brands"); empty for majors. Tests: 17 new green; 1289/1289 IT+UT total green (63s; +17 from A.1's 1272). Out of scope: A.3 wires my_sign.html's first render branch (the visible-win first surface); A.4 builds card-deck icon + game_kit applet; A.5-A.8 DRY across my_sea + both billboard applets + room

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-24 23:37:16 -04:00
parent a4ac25605d
commit 91df482dd8
2 changed files with 208 additions and 0 deletions

View File

@@ -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 <img> 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: <deck-slug>-<suit-slug>-<NN>[-<court>].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

View File

@@ -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 <img>) ──
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, "")