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:
@@ -234,6 +234,22 @@ class DeckVariant(models.Model):
|
|||||||
(PLAYING, "Playing card"),
|
(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)
|
name = models.CharField(max_length=100, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
card_count = models.IntegerField()
|
card_count = models.IntegerField()
|
||||||
@@ -243,6 +259,20 @@ class DeckVariant(models.Model):
|
|||||||
has_card_images = models.BooleanField(default=True)
|
has_card_images = models.BooleanField(default=True)
|
||||||
is_polarized = models.BooleanField(default=False)
|
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
|
@property
|
||||||
def short_key(self):
|
def short_key(self):
|
||||||
"""First dash-separated word of slug — used as an HTML id component."""
|
"""First dash-separated word of slug — used as an HTML id component."""
|
||||||
@@ -452,6 +482,35 @@ class TarotCard(models.Model):
|
|||||||
self.BLADES: 'fa-gun',
|
self.BLADES: 'fa-gun',
|
||||||
}.get(self.suit, '')
|
}.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
|
@property
|
||||||
def cautions_json(self):
|
def cautions_json(self):
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1149,3 +1149,152 @@ class MinchiateFiorentine1860SeedTest(TestCase):
|
|||||||
TarotCard.objects.filter(deck_variant=self.deck, arcana="MIDDLE").count(),
|
TarotCard.objects.filter(deck_variant=self.deck, arcana="MIDDLE").count(),
|
||||||
0,
|
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, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user