Files
python-tdd/src/apps/epic/tests/integrated/test_models.py
Disco DeDisco f036c8f461 game-views carousel: ATLAS/SCROLL/POST/CHAT/PULSE views in the room scroll pane — TDD
Unskips the 8 RED FTs from the prior commit (test_game_room_views.py) and lands the feature beneath them — the room's 2nd vertical snap pane becomes a horizontal scroll-snap carousel of five views, landing on SCROLL (2nd).

Carousel core: _room_views.html (5 .room-view panes) + _room_views_strip.html (root-level icon strip, outside the aperture so it clears the scroll card's fade mask + scroll clip); room-views.js owns the horizontal axis — goToView (authoritative active-state) + an IntersectionObserver backing native swipe; horizontal wheel (deltaX / shift+wheel) advances a view while vertical wheel stays for feed scroll; icon-click snaps; the strip shows only while the views pane is on screen (vertical IO mirrors room-scroll.js). SCROLL still wraps _room_scroll.html, so the existing binary y-snap + provenance feed + GAME ROOM ⇄ GAME SCROLL title reel behave unchanged.

Title reel: the .gr-swap reel gains the four extra view words; the active word is driven by data-active-view on the h2 (set by room-views.js), gated by .is-scroll (room-scroll.js) so ROOM shows at the hex.

POST view: a room-scoped game-table thread. New Post.room FK + KIND_ROOM_THREAD (mirrors Brief.room) + Room.get_thread_post(); epic:room_post AJAX endpoint appends a Line (seated-gamer-gated, dup-rejected) and returns the rendered line partial. _post_line.html extracted from post.html and shared by both surfaces + the endpoint. The composer appends OPTIMISTICALLY (synchronous line so the POST + ATLAS views reflect it the instant OK is clicked, no dependence on the round-trip), then reconciles with the server's authoritative @handle/timestamp render; a rejection rolls the optimistic line back.

ATLAS view: a live client-side merge of the SCROLL provenance rows + the POST thread rows, time-ordered, each row tagged data-source=provenance|post for end-of-sprint per-type styling. Rebuilds from the live DOM on activation + on every composer append. CHAT/PULSE are .room-view-stub placeholders (no backing model yet).

Burger Text sub-btn lights .active on the table (text_btn_active from epic.room_view, unset on every other _burger.html surface) → room-views.js binds its active click to the swipe machine: DOWN to the views pane, RIGHT to Post.

Coverage: 8 carousel FTs green; Jasmine RoomViewsSpec (atlas merge order/stability + row data-source); epic ITs (Room.get_thread_post, carousel markup, room_post endpoint 200/403/400/GET); 1636 ITs/UTs + the existing scroll FT green (no regression).

Gotcha logged: build FormData(form) BEFORE clearing the input on optimistic submit — clearing first captures an empty text field → 400 → the line silently rolls back.

[[project-room-game-views-carousel]] [[project-room-scroll-of-events]] [[project-room-title-scroll-reel-jun02]] [[feedback-jsonfield-exclude-sqlite-null]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:05:36 -04:00

1408 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import timedelta
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.db import IntegrityError
from apps.lyric.models import Token, User
from apps.epic.models import (
AspectType, Character, DeckVariant, GateSlot, HouseLabel, Planet, Room, RoomInvite,
SigReservation, Sign, TableSeat, TarotCard,
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
sig_seat_order, active_sig_seat,
)
class RoomCreationTest(TestCase):
def test_creating_a_room_generates_six_gate_slots(self):
owner = User.objects.create(email="founder@example.com")
room = Room.objects.create(name="Test Room", owner=owner)
self.assertEqual(GateSlot.objects.filter(room=room).count(), 6)
class RoomThreadPostTest(TestCase):
"""Room.get_thread_post — the single game-table Post backing the POST view
of the game-views carousel ([[project-room-game-views-carousel]])."""
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(name="Whataburgher", owner=self.owner)
def test_creates_room_thread_post_owned_by_room_owner(self):
from apps.billboard.models import Post
post = self.room.get_thread_post()
self.assertEqual(post.kind, Post.KIND_ROOM_THREAD)
self.assertEqual(post.room, self.room)
self.assertEqual(post.owner, self.owner)
self.assertEqual(post.title, "Whataburgher")
def test_is_idempotent_one_post_per_room(self):
first = self.room.get_thread_post()
second = self.room.get_thread_post()
self.assertEqual(first.pk, second.pk)
self.assertEqual(self.room.thread_posts.count(), 1)
def test_truncates_long_room_name_to_title_cap(self):
long_room = Room.objects.create(name="X" * 60, owner=self.owner)
post = long_room.get_thread_post()
self.assertEqual(len(post.title), 35)
class DebitTokenTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Test Room",
owner=self.owner,
renewal_period=timedelta(days=7)
)
self.slot = self.room.gate_slots.get(slot_number=1)
def test_debit_free_token_consumes_token_and_fills_slot(self):
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
debit_token(self.owner, self.slot, free_token)
self.assertFalse(Token.objects.filter(pk=free_token.pk).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
def test_debit_coin_does_not_consume_token(self):
coin_token = Token.objects.get(user=self.owner, token_type=Token.COIN)
debit_token(self.owner, self.slot, coin_token)
self.assertTrue(Token.objects.filter(pk=coin_token.pk).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
def test_debit_band_does_not_consume_or_unequip(self):
"""BAND mirrors PASS — fills the slot, but never deleted, never
gets `current_room` set, and stays equipped (debit_token's PASS
branch is the model). The wallet should keep showing the BAND
after the user enters a gate w. it."""
band = Token.objects.create(user=self.owner, token_type=Token.BAND)
self.owner.equipped_trinket = band
self.owner.save(update_fields=["equipped_trinket"])
debit_token(self.owner, self.slot, band)
self.assertTrue(Token.objects.filter(pk=band.pk).exists())
band.refresh_from_db()
self.assertIsNone(band.current_room_id)
self.owner.refresh_from_db()
self.assertEqual(self.owner.equipped_trinket_id, band.pk)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
self.assertEqual(self.slot.debited_token_type, Token.BAND)
def test_debit_fills_last_slot_and_opens_gate(self):
for i in range(2, 7):
gamer = User.objects.create(email=f"g{i}@test.io")
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
debit_token(self.owner, self.slot, free_token)
self.room.refresh_from_db()
self.assertEqual(self.room.gate_status, Room.OPEN)
class GateSlotCostCurrentTest(TestCase):
"""Seat-occupancy / renewal clock (sprint 2026-05-31). A filled seat's
token cost is "current" for `filled_at + renewal_period` (the cost-current
window), then sits in a renewal-grace window of equal length before its
occupant is auto-BYE'd. All derived from `filled_at` + `renewal_period`
only — uniform across token types, no new fields. A NULL `filled_at`
(ORM fixtures / RESERVED slots) reads as current / never-expired."""
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Test Room", owner=self.owner, renewal_period=timedelta(days=7),
)
self.slot = self.room.gate_slots.get(slot_number=1)
def _fill(self, filled_at):
self.slot.status = GateSlot.FILLED
self.slot.gamer = self.owner
self.slot.filled_at = filled_at
self.slot.save()
return self.slot
def test_cost_current_true_within_span(self):
self._fill(timezone.now())
self.assertTrue(self.slot.cost_current)
def test_cost_current_false_after_span(self):
self._fill(timezone.now() - timedelta(days=8))
self.assertFalse(self.slot.cost_current)
def test_cost_current_true_when_filled_at_null(self):
# RESERVED / ORM-built fixtures never expire (filled_at is None).
self.slot.status = GateSlot.FILLED
self.slot.filled_at = None
self.slot.save()
self.assertTrue(self.slot.cost_current)
def test_cost_current_until_equals_filled_plus_renewal_period(self):
self._fill(timezone.now())
self.assertEqual(
self.slot.cost_current_until, self.slot.filled_at + timedelta(days=7),
)
def test_in_renewal_grace_true_between_S_and_2S(self):
self._fill(timezone.now() - timedelta(days=8)) # past S=7d, before 2S=14d
self.assertTrue(self.slot.in_renewal_grace)
def test_in_renewal_grace_false_before_S(self):
self._fill(timezone.now())
self.assertFalse(self.slot.in_renewal_grace)
def test_in_renewal_grace_false_after_2S(self):
self._fill(timezone.now() - timedelta(days=15))
self.assertFalse(self.slot.in_renewal_grace)
def test_grace_expired_at_2S(self):
self._fill(timezone.now() - timedelta(days=15)) # past 2S=14d
self.assertTrue(self.slot.grace_expired)
def test_grace_expired_false_within_grace(self):
self._fill(timezone.now() - timedelta(days=8))
self.assertFalse(self.slot.grace_expired)
def test_grace_expired_false_for_null_filled_at(self):
self.slot.status = GateSlot.FILLED
self.slot.filled_at = None
self.slot.save()
self.assertFalse(self.slot.grace_expired)
def test_renewal_span_falls_back_to_7d_when_period_null(self):
self.room.renewal_period = None
self.room.save(update_fields=["renewal_period"])
self.slot.refresh_from_db()
self.assertEqual(self.slot.renewal_span, timedelta(days=7))
class CoinTokenInUseTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Dragon's Den",
owner=self.owner,
renewal_period=timedelta(days=7),
)
self.slot = self.room.gate_slots.get(slot_number=1)
self.coin = Token.objects.get(user=self.owner, token_type=Token.COIN)
debit_token(self.owner, self.slot, self.coin)
self.coin.refresh_from_db()
def test_coin_tooltip_expiry_shows_next_ready_date(self):
expected_date = self.coin.next_ready_at.strftime("%Y-%m-%d")
self.assertIn(expected_date, self.coin.tooltip_expiry())
def test_coin_tooltip_room_html_contains_anchor(self):
room_url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
html = self.coin.tooltip_room_html()
self.assertIn(f'href="{room_url}"', html)
self.assertIn(self.room.name, html)
class SelectTokenTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.other_room = Room.objects.create(name="Other Room", owner=self.user)
self.coin = Token.objects.get(user=self.user, token_type=Token.COIN)
def test_returns_coin_when_available(self):
token = select_token(self.user)
self.assertEqual(token.token_type, Token.COIN)
def test_returns_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
token = select_token(self.user)
self.assertEqual(token.token_type, Token.FREE)
def test_free_token_selection_is_fefo(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
soon = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=2),
)
Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=6),
)
token = select_token(self.user)
self.assertEqual(token.pk, soon.pk)
def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
token = select_token(self.user)
self.assertEqual(token.pk, tithe.pk)
def test_returns_none_when_all_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
token = select_token(self.user)
self.assertIsNone(token)
def test_returns_pass_for_staff(self):
"""PASS must be equipped to be picked — DON-ing it is the user's
opt-in to trinket use (parity w. COIN's auto-equip default)."""
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
self.user.equipped_trinket = pass_token
self.user.save(update_fields=["equipped_trinket"])
token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS)
def test_returns_band_when_equipped(self):
"""BAND, like PASS, must be equipped to be picked. Awarded-but-
DOFFed BAND stays in the wallet but doesn't auto-fire."""
band = Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = band
self.user.save(update_fields=["equipped_trinket"])
token = select_token(self.user)
self.assertEqual(token.pk, band.pk)
def test_pass_wins_when_equipped_over_band(self):
"""Equipped slot is the only trinket the picker considers — whichever
the user has DON-ed is the one that fires."""
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = pass_token
self.user.save(update_fields=["equipped_trinket"])
token = select_token(self.user)
self.assertEqual(token.pk, pass_token.pk)
class SelectTokenEquipGatedTest(TestCase):
"""The trinket slot is the user's opt-in to trinket-as-token use. A DOFFed
trinket (PASS/BAND/COIN) stays in the wallet but is invisible to the gate
picker — clicking the rails falls back to FREE (FEFO) → TITHE → None.
CARTE is never auto-picked even when equipped: it's opt-in via the kit-
bag click flow, which routes through `drop_token`'s explicit `token_id`
POST param (not `select_token`).
Bug 2026-05-21 (user-reported): no equipped trinket + only FREE/TITHE
available → "free for all" rails admit because the old flat-priority
chain still grabbed an owned-but-DOFFed COIN, advanced its current_room
silently, and never decremented anything visible. New semantics: the
equip slot gates trinket use entirely."""
def setUp(self):
self.user = User.objects.create(email="equip@test.io")
# Wipe auto-COIN + auto-FREE + the auto-equip; tests seed precisely.
self.user.tokens.all().delete()
self.user.refresh_from_db() # SET_NULL on equipped_trinket fired
def test_skips_unequipped_coin_and_returns_free(self):
Token.objects.create(user=self.user, token_type=Token.COIN)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.assertEqual(select_token(self.user).pk, free.pk)
def test_skips_unequipped_coin_and_returns_tithe_when_no_free(self):
Token.objects.create(user=self.user, token_type=Token.COIN)
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
self.assertEqual(select_token(self.user).pk, tithe.pk)
def test_returns_none_when_no_equip_and_no_consumables(self):
Token.objects.create(user=self.user, token_type=Token.COIN)
Token.objects.create(user=self.user, token_type=Token.BAND)
self.assertIsNone(select_token(self.user))
def test_carte_equipped_falls_through_to_free(self):
"""CARTE is opt-in via kit-bag's explicit click — never auto-picked
by select_token even when equipped (the rails fallback for an idle
CARTE-holder is FREE/TITHE, not CARTE itself)."""
carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.user.equipped_trinket = carte
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(select_token(self.user).pk, free.pk)
def test_equipped_coin_wins_over_unequipped_band(self):
"""Equip slot is exclusive — the DON-ed trinket is the only one the
picker considers among trinkets, regardless of priority rank."""
coin = Token.objects.create(user=self.user, token_type=Token.COIN)
Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = coin
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(select_token(self.user).pk, coin.pk)
def test_equipped_coin_in_use_elsewhere_falls_through_to_free(self):
"""Defensive: equipped + in-use COIN shouldn't occur (debit_token
auto-unequips on consumption) but if it does, treat as no-equip."""
other_room = Room.objects.create(name="Elsewhere", owner=self.user)
coin = Token.objects.create(
user=self.user, token_type=Token.COIN, current_room=other_room,
)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.user.equipped_trinket = coin
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(select_token(self.user).pk, free.pk)
def test_staff_with_unequipped_pass_falls_through_to_free(self):
"""Even staff must DON the PASS — the auto-equip on user creation
is the convenience default, NOT a special-case bypass of the rule."""
self.user.is_staff = True
self.user.save(update_fields=["is_staff"])
Token.objects.create(user=self.user, token_type=Token.PASS)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.assertEqual(select_token(self.user).pk, free.pk)
class RoomTableStatusTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_table_status_defaults_to_blank(self):
self.room.refresh_from_db()
self.assertFalse(self.room.table_status)
def test_room_has_role_select_constant(self):
self.assertEqual(Room.ROLE_SELECT, "ROLE_SELECT")
def test_room_has_sig_select_constant(self):
self.assertEqual(Room.SIG_SELECT, "SIG_SELECT")
def test_room_has_in_game_constant(self):
self.assertEqual(Room.IN_GAME, "IN_GAME")
def test_table_status_accepts_role_select(self):
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
class TableSeatModelTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_table_seat_can_be_created(self):
seat = TableSeat.objects.create(
room=self.room,
gamer=self.owner,
slot_number=1,
)
self.assertEqual(seat.slot_number, 1)
self.assertIsNone(seat.role)
self.assertFalse(seat.role_revealed)
self.assertIsNone(seat.seat_position)
def test_table_seat_role_choices_cover_all_six(self):
role_codes = [c[0] for c in TableSeat.ROLE_CHOICES]
for code in ["PC", "BC", "SC", "AC", "NC", "EC"]:
self.assertIn(code, role_codes)
def test_partner_map_pairs_are_mutual(self):
for a, b in [(TableSeat.PC, TableSeat.BC), (TableSeat.SC, TableSeat.AC), (TableSeat.NC, TableSeat.EC)]:
self.assertEqual(TableSeat.PARTNER_MAP[a], b)
self.assertEqual(TableSeat.PARTNER_MAP[b], a)
def test_room_table_seats_reverse_relation(self):
TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1)
self.assertEqual(self.room.table_seats.count(), 1)
class RoomInviteTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(name="Dragon's Den", owner=self.founder)
def test_founder_can_invite_by_email(self):
invite = RoomInvite.objects.create(
room=self.room,
inviter=self.founder,
invitee_email="friend@example.com",
)
self.assertEqual(invite.status, RoomInvite.PENDING)
def test_invited_room_appears_in_my_games_queryset(self):
friend = User.objects.create(email="friend@example.com")
RoomInvite.objects.create(
room=self.room,
inviter=self.founder,
invitee_email=friend.email,
)
rooms = Room.objects.filter(
Q(owner=friend) |
Q(gate_slots__gamer=friend) |
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
).distinct()
self.assertIn(self.room, rooms)
# ── Significator deck helpers ─────────────────────────────────────────────────
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
def _full_sig_room(name="Sig Room", role_order=None):
"""Return (room, gamers, earthman) with all 6 seats filled, roles assigned,
table_status=SIG_SELECT, and every gamer's equipped_deck set to Earthman.
Uses get_or_create for DeckVariant — migration data persists in TestCase."""
if role_order is None:
role_order = SIG_SEAT_ORDER[:]
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
owner = User.objects.create(email="founder@sig.io")
gamers = [owner]
for i in range(2, 7):
gamers.append(User.objects.create(email=f"g{i}@sig.io"))
for gamer in gamers:
gamer.equipped_deck = earthman
gamer.save(update_fields=["equipped_deck"])
room = Room.objects.create(name=name, owner=owner)
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
slot = room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
TableSeat.objects.create(
room=room, gamer=gamer, slot_number=i,
role=role, role_revealed=True,
)
room.table_status = Room.SIG_SELECT
room.save()
return room, gamers, earthman
class SigDeckCompositionTest(TestCase):
"""sig_deck_cards(room) returns exactly 36 cards with correct suit/arcana split."""
def setUp(self):
self.room, self.gamers, self.earthman = _full_sig_room()
def test_sig_deck_returns_36_cards(self):
cards = sig_deck_cards(self.room)
self.assertEqual(len(cards), 36)
def test_sc_ac_contribute_court_cards_of_blades_and_grails(self):
cards = sig_deck_cards(self.room)
sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")]
# M/J/Q/K × 2 suits × 2 roles = 16
self.assertEqual(len(sc_ac), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
cards = sig_deck_cards(self.room)
pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
self.assertEqual(len(pc_bc), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
def test_nc_ec_contribute_schiz_and_chancellor(self):
cards = sig_deck_cards(self.room)
major = [c for c in cards if c.arcana == "MAJOR"]
self.assertEqual(len(major), 4)
self.assertEqual(sorted(c.number for c in major), [0, 0, 1, 1])
def test_each_card_appears_twice_once_per_pile(self):
"""18 unique card specs × 2 (levity + gravity) = 36 total."""
cards = sig_deck_cards(self.room)
slugs = [c.slug for c in cards]
unique_slugs = set(slugs)
self.assertEqual(len(unique_slugs), 18)
self.assertTrue(all(slugs.count(s) == 2 for s in unique_slugs))
class SigSeatOrderTest(TestCase):
"""sig_seat_order() and active_sig_seat() return seats in PC→NC→EC→SC→AC→BC order."""
def setUp(self):
# Assign roles in reverse of canonical order to prove reordering works
self.room, self.gamers, _ = _full_sig_room(
name="Order Room",
role_order=["BC", "AC", "SC", "EC", "NC", "PC"],
)
def test_sig_seat_order_returns_canonical_role_sequence(self):
seats = sig_seat_order(self.room)
self.assertEqual([s.role for s in seats], SIG_SEAT_ORDER)
def test_active_sig_seat_is_first_seat_without_significator(self):
seat = active_sig_seat(self.room)
self.assertEqual(seat.role, "PC")
def test_active_sig_seat_advances_after_significator_set(self):
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
earthman = DeckVariant.objects.get(slug="earthman")
card = TarotCard.objects.filter(deck_variant=earthman, arcana="MINOR").first()
pc_seat.significator = card
pc_seat.save()
seat = active_sig_seat(self.room)
self.assertEqual(seat.role, "NC")
def test_active_sig_seat_is_none_when_all_chosen(self):
earthman = DeckVariant.objects.get(slug="earthman")
cards = list(TarotCard.objects.filter(deck_variant=earthman))
for i, seat in enumerate(TableSeat.objects.filter(room=self.room)):
seat.significator = cards[i]
seat.save()
self.assertIsNone(active_sig_seat(self.room))
class SigCardFieldTest(TestCase):
"""TableSeat.significator FK to TarotCard — default null, assignable."""
def setUp(self):
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
self.card = TarotCard.objects.get(
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11,
)
owner = User.objects.create(email="owner@test.io")
room = Room.objects.create(name="Field Test", owner=owner)
self.seat = TableSeat.objects.create(room=room, gamer=owner, slot_number=1, role="PC")
def test_significator_defaults_to_none(self):
self.assertIsNone(self.seat.significator)
def test_significator_can_be_assigned(self):
self.seat.significator = self.card
self.seat.save()
self.seat.refresh_from_db()
self.assertEqual(self.seat.significator, self.card)
def test_significator_nullable_on_delete(self):
self.seat.significator = self.card
self.seat.save()
self.card.delete()
self.seat.refresh_from_db()
self.assertIsNone(self.seat.significator)
# ── SigReservation model ──────────────────────────────────────────────────────
def _make_sig_card(deck_variant, suit, number):
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
card, _ = TarotCard.objects.get_or_create(
deck_variant=deck_variant,
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
defaults={
"arcana": "MINOR", "suit": suit, "number": number,
"name": f"{name_map[number]} of {suit.capitalize()}",
},
)
return card
class SigReservationModelTest(TestCase):
def setUp(self):
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
self.card = _make_sig_card(self.earthman, "BRANDS", 14)
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.owner, slot_number=1, role="PC"
)
def test_can_create_sig_reservation(self):
res = SigReservation.objects.create(
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
)
self.assertEqual(res.role, "PC")
self.assertEqual(res.polarity, "levity")
self.assertIsNotNone(res.reserved_at)
def test_one_reservation_per_gamer_per_room(self):
SigReservation.objects.create(
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
)
card2 = _make_sig_card(self.earthman, "GRAILS", 13)
with self.assertRaises(IntegrityError):
SigReservation.objects.create(
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
)
def test_same_card_blocked_within_same_polarity(self):
gamer2 = User.objects.create(email="nc@test.io")
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
SigReservation.objects.create(
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
)
with self.assertRaises(IntegrityError):
SigReservation.objects.create(
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
)
def test_same_card_allowed_across_polarity(self):
"""A gravity gamer may reserve the same card instance as a levity gamer
— each polarity has its own independent pile."""
gamer2 = User.objects.create(email="bc@test.io")
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
SigReservation.objects.create(
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
)
res2 = SigReservation.objects.create(
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
)
self.assertIsNotNone(res2.pk)
def test_deleting_reservation_clears_slot(self):
res = SigReservation.objects.create(
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
)
res.delete()
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
class SigCardHelperTest(TestCase):
"""levity_sig_cards() and gravity_sig_cards() return 16 courts by default;
Nomad/Schizo added when the user has the matching Note unlock.
Relies on the Earthman deck seeded by migrations (no manual card creation).
"""
def setUp(self):
from django.utils import timezone
self.earthman = DeckVariant.objects.get(slug="earthman")
self.owner = User.objects.create(email="founder@test.io")
TableSeat.objects.create(
room=Room.objects.create(name="Card Test", owner=self.owner),
gamer=self.owner, slot_number=1, role="PC",
deck_variant=self.earthman,
)
self.room = self.owner.table_seats.first().room
self._tz = timezone
def test_levity_sig_cards_returns_16_without_notes(self):
cards = levity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 16)
def test_gravity_sig_cards_returns_16_without_notes(self):
cards = gravity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 16)
def test_nomad_note_includes_nomad(self):
from apps.drama.models import Note
Note.objects.create(user=self.owner, slug="nomad", earned_at=self._tz.now())
cards = levity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 0 and c.arcana == "MAJOR" for c in cards))
def test_schizo_note_includes_schizo(self):
from apps.drama.models import Note
Note.objects.create(user=self.owner, slug="schizo", earned_at=self._tz.now())
cards = levity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))
def test_levity_and_gravity_share_same_card_objects(self):
"""Both piles draw from the same 18 TarotCard instances — visual distinction
comes from CSS polarity class, not separate card model records."""
levity = levity_sig_cards(self.room)
gravity = gravity_sig_cards(self.room)
self.assertEqual(
sorted(c.pk for c in levity),
sorted(c.pk for c in gravity),
)
def test_returns_empty_when_no_deck_on_seats_or_owner(self):
"""Falls back to empty list when neither seats nor owner have a deck."""
self.room.table_seats.update(deck_variant=None)
self.owner.equipped_deck = None
self.owner.save()
self.assertEqual(levity_sig_cards(self.room, self.owner), [])
self.assertEqual(gravity_sig_cards(self.room, self.owner), [])
class PersonalSigCardsTest(TestCase):
"""personal_sig_cards(user) — solo (room-less) sig pile sourced from
User.equipped_deck. Same 18-card pile + Note-unlock filtering as
levity_sig_cards / gravity_sig_cards (which route through a room)."""
def test_fresh_user_gets_16_cards_via_auto_equipped_earthman(self):
from apps.epic.models import personal_sig_cards
user = User.objects.create(email="solo@test.io")
# post_save signal auto-equips Earthman; no Schizo/Nomad notes yet,
# so Majors 0 and 1 are filtered out by _filter_major_unlocks.
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 16)
def test_falls_back_to_earthman_when_no_equipped_deck(self):
"""Sprint 4a-follow contract: instead of returning an empty pile when
the user has no equipped_deck (e.g. their deck is in-use as a
TableSeat.deck_variant in an active room), personal_sig_cards falls
back to the Earthman deck. The picker labels this "Earthman [Shabby
Paperboard]" via a Brief banner at the view layer."""
from apps.epic.models import personal_sig_cards
user = User.objects.create(email="dekless@test.io")
user.equipped_deck = None
user.save(update_fields=["equipped_deck"])
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 16)
# All cards should belong to the Earthman deck (the fallback)
self.assertTrue(all(c.deck_variant.slug == "earthman" for c in cards))
def test_schizo_note_unlocks_major_1(self):
from apps.drama.models import Note
from apps.epic.models import personal_sig_cards
from django.utils import timezone
user = User.objects.create(email="schizo@test.io")
Note.objects.create(user=user, slug="schizo", earned_at=timezone.now())
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))
class TarotCardCautionsTest(TestCase):
"""TarotCard.cautions JSONField — field existence and Schizo seed data."""
def setUp(self):
self.earthman = DeckVariant.objects.get(slug="earthman")
def test_cautions_field_saves_and_retrieves_list(self):
card = TarotCard.objects.create(
deck_variant=self.earthman,
arcana="MINOR",
suit="CROWNS",
number=99,
name="Test Card",
slug="test-card-cautions",
cautions=["First caution.", "Second caution."],
)
card.refresh_from_db()
self.assertEqual(card.cautions, ["First caution.", "Second caution."])
def test_cautions_defaults_to_empty_list(self):
card = TarotCard.objects.create(
deck_variant=self.earthman,
arcana="MINOR",
suit="CROWNS",
number=98,
name="Default Cautions Card",
slug="default-cautions-card",
)
self.assertEqual(card.cautions, [])
def test_schizo_has_4_cautions(self):
schizo = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MAJOR", number=1
)
self.assertEqual(len(schizo.cautions), 4)
def test_schizo_caution_references_the_pervert(self):
schizo = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MAJOR", number=1
)
self.assertIn("The Pervert", schizo.cautions[0])
def test_schizo_cautions_use_reverse_language(self):
schizo = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MAJOR", number=1
)
for caution in schizo.cautions:
self.assertIn("reverse", caution)
self.assertNotIn("transform", caution)
# ── SigReservation ready gate ─────────────────────────────────────────────────
class SigReservationReadyGateTest(TestCase):
"""SigReservation.ready and countdown_remaining fields."""
def setUp(self):
self.earthman = DeckVariant.objects.get(slug="earthman")
owner = User.objects.create(email="owner@test.io")
room = Room.objects.create(name="R", owner=owner)
card = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
)
self.res = SigReservation.objects.create(
room=room, gamer=owner, card=card, role="PC", polarity="levity"
)
def test_ready_defaults_to_false(self):
self.assertFalse(self.res.ready)
def test_countdown_remaining_defaults_to_none(self):
self.assertIsNone(self.res.countdown_remaining)
def test_ready_can_be_set_true(self):
self.res.ready = True
self.res.save()
self.res.refresh_from_db()
self.assertTrue(self.res.ready)
def test_countdown_remaining_can_be_saved(self):
self.res.countdown_remaining = 7
self.res.save()
self.res.refresh_from_db()
self.assertEqual(self.res.countdown_remaining, 7)
# ── Room SKY_SELECT status ────────────────────────────────────────────────────
class RoomSkySelectStatusTest(TestCase):
"""Room.SKY_SELECT constant and sig_select_started_at field."""
def setUp(self):
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="R", owner=owner)
def test_sky_select_constant_value(self):
self.assertEqual(Room.SKY_SELECT, "SKY_SELECT")
def test_sky_select_is_valid_table_status_choice(self):
choices = [c[0] for c in Room.TABLE_STATUS_CHOICES]
self.assertIn(Room.SKY_SELECT, choices)
def test_sig_select_started_at_defaults_to_none(self):
self.assertIsNone(self.room.sig_select_started_at)
def test_sig_select_started_at_can_be_set(self):
from django.utils import timezone
now = timezone.now()
self.room.sig_select_started_at = now
self.room.save()
self.room.refresh_from_db()
self.assertIsNotNone(self.room.sig_select_started_at)
# ── TarotDeck.draw / shuffle ──────────────────────────────────────────────────
class TarotDeckDrawTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="dealer@test.io")
self.room = Room.objects.create(name="R", owner=self.user)
def test_draw_raises_value_error_when_too_few_cards_remain(self):
from apps.epic.models import TarotDeck
deck_variant = DeckVariant.objects.first()
all_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True))
td = TarotDeck.objects.create(
room=self.room,
deck_variant=deck_variant,
drawn_card_ids=all_ids,
)
with self.assertRaises(ValueError):
td.draw(1)
def test_shuffle_resets_drawn_card_ids(self):
from apps.epic.models import TarotDeck
deck_variant = DeckVariant.objects.first()
some_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:3])
td = TarotDeck.objects.create(
room=self.room,
deck_variant=deck_variant,
drawn_card_ids=some_ids,
)
td.shuffle()
td.refresh_from_db()
self.assertEqual(td.drawn_card_ids, [])
def test_remaining_count_subtracts_drawn_from_total(self):
from apps.epic.models import TarotDeck
deck_variant = DeckVariant.objects.first()
td = TarotDeck.objects.create(
room=self.room,
deck_variant=deck_variant,
drawn_card_ids=[],
)
self.assertEqual(td.remaining_count, deck_variant.card_count)
td.drawn_card_ids = list(
TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:5]
)
td.save()
self.assertEqual(td.remaining_count, deck_variant.card_count - 5)
def test_remaining_count_zero_when_no_deck_variant(self):
from apps.epic.models import TarotDeck
td = TarotDeck.objects.create(room=self.room, deck_variant=None)
self.assertEqual(td.remaining_count, 0)
def test_draw_returns_n_tuples_of_card_and_bool(self):
from apps.epic.models import TarotDeck
deck_variant = DeckVariant.objects.first()
td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant)
drawn = td.draw(3)
self.assertEqual(len(drawn), 3)
for card, is_reversed in drawn:
self.assertIsInstance(card, TarotCard)
self.assertIsInstance(is_reversed, bool)
def test_draw_appends_card_ids_to_drawn_card_ids(self):
from apps.epic.models import TarotDeck
deck_variant = DeckVariant.objects.first()
td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant)
drawn = td.draw(4)
td.refresh_from_db()
self.assertEqual(len(td.drawn_card_ids), 4)
for card, _ in drawn:
self.assertIn(card.id, td.drawn_card_ids)
def test_draw_excludes_already_drawn_cards(self):
"""Subsequent draws never repeat cards from the existing drawn_card_ids."""
from apps.epic.models import TarotDeck
deck_variant = DeckVariant.objects.first()
td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant)
first = td.draw(5)
first_ids = {card.id for card, _ in first}
second = td.draw(5)
second_ids = {card.id for card, _ in second}
self.assertFalse(first_ids & second_ids)
# ── sig_deck_cards with no equipped deck ─────────────────────────────────────
class SigDeckCardsNoEquippedDeckTest(TestCase):
def test_returns_empty_list_when_owner_has_no_equipped_deck(self):
user = User.objects.create(email="nodeck@test.io")
user.equipped_deck = None
user.save(update_fields=["equipped_deck"])
room = Room.objects.create(name="R", owner=user)
self.assertEqual(sig_deck_cards(room), [])
# ── Astrology model __str__ methods ──────────────────────────────────────────
class AstrologyModelStrTest(TestCase):
def test_zodiac_sign_str(self):
sign = Sign.objects.first()
if sign is None:
self.skipTest("No Sign rows")
self.assertEqual(str(sign), sign.name)
def test_planet_str(self):
planet = Planet.objects.first()
if planet is None:
self.skipTest("No Planet rows")
self.assertEqual(str(planet), planet.name)
def test_aspect_type_str(self):
aspect = AspectType.objects.first()
if aspect is None:
self.skipTest("No AspectType rows")
self.assertEqual(str(aspect), aspect.name)
def test_house_label_str(self):
label = HouseLabel.objects.first()
if label is None:
self.skipTest("No HouseLabel rows")
self.assertIn(str(label.number), str(label))
# ── Character model ───────────────────────────────────────────────────────────
class CharacterModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="char@test.io")
self.room = Room.objects.create(name="R", owner=self.user)
self.seat = TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
def test_draft_str(self):
char = Character.objects.create(seat=self.seat)
self.assertIn("draft", str(char))
def test_confirmed_str(self):
char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
self.assertIn("confirmed", str(char))
def test_is_confirmed_false_for_draft(self):
char = Character.objects.create(seat=self.seat)
self.assertFalse(char.is_confirmed)
def test_is_confirmed_true_when_confirmed_at_set(self):
char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
self.assertTrue(char.is_confirmed)
def test_is_active_true_when_confirmed_and_not_retired(self):
char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
self.assertTrue(char.is_active)
def test_is_active_false_when_retired(self):
char = Character.objects.create(
seat=self.seat,
confirmed_at=timezone.now(),
retired_at=timezone.now(),
)
self.assertFalse(char.is_active)
class DeckSchemaA0Test(TestCase):
"""Sprint A.0 — DeckVariant gains has_card_images, family, is_polarized;
SUIT_CHOICES collapses to canonical Earthman vocab; existing
fiorentine-minchiate seed (which was actually RWS Tarot in disguise)
gets renamed to tarot-rider-waite-smith with revocabbed suits."""
def test_deck_variant_has_card_images_field(self):
f = DeckVariant._meta.get_field("has_card_images")
self.assertEqual(f.get_internal_type(), "BooleanField")
def test_deck_variant_family_field_has_expected_choices(self):
f = DeckVariant._meta.get_field("family")
choice_values = {c[0] for c in f.choices}
self.assertEqual(choice_values, {"earthman", "italian", "english", "playing"})
def test_deck_variant_is_polarized_field(self):
f = DeckVariant._meta.get_field("is_polarized")
self.assertEqual(f.get_internal_type(), "BooleanField")
def test_earthman_is_polarized(self):
earthman = DeckVariant.objects.get(slug="earthman")
self.assertTrue(earthman.is_polarized)
def test_earthman_family_is_earthman(self):
earthman = DeckVariant.objects.get(slug="earthman")
self.assertEqual(earthman.family, "earthman")
def test_earthman_has_card_images_false_until_artwork_painted(self):
earthman = DeckVariant.objects.get(slug="earthman")
self.assertFalse(earthman.has_card_images)
def test_rws_deck_renamed_from_fiorentine_minchiate(self):
self.assertFalse(
DeckVariant.objects.filter(slug="fiorentine-minchiate").exists(),
"fiorentine-minchiate slug should have been renamed to tarot-rider-waite-smith",
)
self.assertTrue(
DeckVariant.objects.filter(slug="tarot-rider-waite-smith").exists(),
)
def test_rws_deck_name_is_canonical(self):
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
self.assertEqual(rws.name, "Tarot (Rider-Waite-Smith)")
def test_rws_family_is_english(self):
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
self.assertEqual(rws.family, "english")
def test_rws_has_card_images_true(self):
"""Flipped 2026-05-28 by 0014 — the 79 resized + pngquant'd RWS
face PNGs are installed at `cards-faces/english/rider-waite-smith/`,
so the RWS variant joins Minchiate in image-rendering mode."""
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
self.assertTrue(rws.has_card_images)
def test_rws_is_polarized_false(self):
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
self.assertFalse(rws.is_polarized)
def test_rws_cards_use_canonical_earthman_suits(self):
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
suit_values = set(
TarotCard.objects.filter(deck_variant=rws, arcana="MINOR")
.values_list("suit", flat=True)
.distinct()
)
self.assertEqual(
suit_values,
{"BRANDS", "GRAILS", "BLADES", "CROWNS"},
"RWS suits should have been revocabbed: WANDS→BRANDS, CUPS→GRAILS, "
"SWORDS→BLADES, PENTACLES→CROWNS",
)
def test_rws_minor_card_count_preserved(self):
"""Revocab is a string swap on the suit field — count is invariant."""
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
for canonical_suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS"):
self.assertEqual(
TarotCard.objects.filter(
deck_variant=rws, arcana="MINOR", suit=canonical_suit
).count(),
14,
f"{canonical_suit} should have 14 cards in RWS",
)
def test_suit_choices_dropped_english_vocab(self):
choice_values = {c[0] for c in TarotCard.SUIT_CHOICES}
self.assertEqual(choice_values, {"BRANDS", "GRAILS", "BLADES", "CROWNS"})
self.assertNotIn("WANDS", choice_values)
self.assertNotIn("CUPS", choice_values)
self.assertNotIn("SWORDS", choice_values)
self.assertNotIn("PENTACLES", choice_values)
class MinchiateFiorentine1860SeedTest(TestCase):
"""Sprint A.1 — seed the actual Minchiate Fiorentine 1860-1890 deck.
97 cards: 40 numbered trumps + Il Matto (rank 0) + 56 minors (4 suits × 14)."""
DECK_SLUG = "minchiate-fiorentine-1860-1890"
def setUp(self):
self.deck = DeckVariant.objects.get(slug=self.DECK_SLUG)
def test_deck_exists_w_canonical_name(self):
self.assertEqual(self.deck.name, "Minchiate Fiorentine (18601890)")
def test_deck_family_is_italian(self):
self.assertEqual(self.deck.family, "italian")
def test_deck_has_card_images_true(self):
self.assertTrue(self.deck.has_card_images)
def test_deck_is_polarized_false(self):
self.assertFalse(self.deck.is_polarized)
def test_deck_card_count_97(self):
self.assertEqual(self.deck.card_count, 97)
def test_total_card_rows_match_declared_count(self):
self.assertEqual(
TarotCard.objects.filter(deck_variant=self.deck).count(), 97
)
def test_trump_count_is_41(self):
"""40 numbered (1-40) + Il Matto (rank 0) = 41 trumps."""
self.assertEqual(
TarotCard.objects.filter(deck_variant=self.deck, arcana="MAJOR").count(),
41,
)
def test_il_matto_at_rank_0(self):
il_matto = TarotCard.objects.get(deck_variant=self.deck, slug="il-matto")
self.assertEqual(il_matto.arcana, "MAJOR")
self.assertEqual(il_matto.number, 0)
self.assertEqual(il_matto.name, "Il Matto")
def test_il_gobbo_at_rank_11_w_hermit_correspondence(self):
il_gobbo = TarotCard.objects.get(deck_variant=self.deck, slug="il-gobbo")
self.assertEqual(il_gobbo.number, 11)
self.assertEqual(il_gobbo.correspondence, "The Hermit")
def test_trump_40_is_le_trombe(self):
le_trombe = TarotCard.objects.get(
deck_variant=self.deck, arcana="MAJOR", number=40,
)
self.assertEqual(le_trombe.name, "Le Trombe")
def test_papa_uno_through_cinque_are_majors_1_through_5(self):
papas = list(
TarotCard.objects.filter(deck_variant=self.deck, arcana="MAJOR")
.filter(number__in=[1, 2, 3, 4, 5])
.order_by("number")
.values_list("name", flat=True)
)
self.assertEqual(papas, ["Papa Uno", "Papa Due", "Papa Tre", "Papa Quattro", "Papa Cinque"])
def test_minors_count_56_w_canonical_suits_only(self):
minors_per_suit = {
suit: TarotCard.objects.filter(
deck_variant=self.deck, arcana="MINOR", suit=suit,
).count()
for suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS")
}
self.assertEqual(minors_per_suit, {"BRANDS": 14, "GRAILS": 14, "BLADES": 14, "CROWNS": 14})
def test_no_minor_uses_dropped_english_suit(self):
bad_suits = set(
TarotCard.objects.filter(deck_variant=self.deck, arcana="MINOR")
.values_list("suit", flat=True)
.distinct()
) - {"BRANDS", "GRAILS", "BLADES", "CROWNS"}
self.assertEqual(bad_suits, set())
def test_minor_court_ranks_11_through_14(self):
"""Page=11, Knight=12, Queen=13, King=14 per [[reference-card-image-naming-convention]]."""
for suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS"):
court_numbers = set(
TarotCard.objects.filter(
deck_variant=self.deck, arcana="MINOR", suit=suit, number__gte=11,
).values_list("number", flat=True)
)
self.assertEqual(court_numbers, {11, 12, 13, 14}, f"{suit} courts")
def test_page_of_batons_exists(self):
"""BRANDS is the canonical enum; Italian display 'Batons' lives in the card name."""
card = TarotCard.objects.get(
deck_variant=self.deck, suit="BRANDS", number=11,
)
self.assertEqual(card.name, "Page of Batons")
self.assertEqual(card.slug, "page-of-batons")
def test_king_of_coins_exists(self):
"""CROWNS canonical enum, 'Coins' Italian-family display."""
card = TarotCard.objects.get(
deck_variant=self.deck, suit="CROWNS", number=14,
)
self.assertEqual(card.name, "King of Coins")
def test_no_middle_arcana(self):
"""Minchiate has only MAJOR + MINOR; MIDDLE is Earthman-only."""
self.assertEqual(
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, "")