Files
python-tdd/src/apps/epic/tests/integrated/test_models.py

817 lines
33 KiB
Python
Raw Normal View History

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 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_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 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):
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS)
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, "WANDS", 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, "CUPS", 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), [])
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
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)
My Sign: Brief banner + Earthman [Shabby Cardstock] backup deck when no equipped — TDD User-reported gap on /billboard/my-sign/ — admin user's only deck was in-use as `TableSeat.deck_variant` in another room (Wonderbeard) → `equipped_deck` cleared → previous my-sign template showed "Equip a card deck first…" w. no actionable next step. User scoped fix: don't force equip, just nudge via a Brief banner "Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.", title "Default deck warning". NVM dismisses + picker proceeds against an Earthman card pile labeled in-copy as the temporary backup; FYI links to /gameboard/ (Game Kit equip). User-modified line text in template: "Paperboard" → "Cardstock" mid-session ; **helper fallback** (epic/models.py): `personal_sig_cards(user)` now falls back to `DeckVariant.objects.filter(slug='earthman').first()` when `user.equipped_deck` is None — same 16-or-18 card pile, just sourced from the canonical Earthman deck rather than the empty FK. No new DeckVariant row needed; "Shabby Cardstock" is purely UX framing (cards are the same TarotCard records the room sig-select uses). Preserves the existing helper signature so no callers had to change ; **view + template** (billboard/views.py + my_sign.html): view passes `no_equipped_deck` + `show_backup_intro_banner` flags. Template removes the old `{% if not equipped_deck %}` forced-equip branch — picker now renders unconditionally w. cards from the backup helper when no deck is equipped. Brief banner fires via `Brief.showBanner({...})` on DOMContentLoaded when `show_backup_intro_banner` is true — gets h2-overlay positioning + NVM behavior + portal styling for free (per [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement in note.js's `_alignToH2`). Added `<script src="note.js">` to my_sign.html since the page didn't load it before. Post-render JS tags the Brief w. a `.my-sign-intro-banner` class so FTs (and any future my-sign-specific styling) can distinguish this nudge from other Briefs on the page ; **TDD trail** — 4 new FTs in `MySignBackupDeckTest` (test_bill_my_sign.py): T1 banner renders w. "Default deck warning" title + "no deck is equipped" + "Shabby Cardstock" copy + both action btns visible; T2 picker still populates 16 cards from backup; T3 NVM click removes the banner from the DOM; T4 FYI href ends w. /gameboard/. Initial reds (`NoSuchElementException` on all 4) confirmed before implementation. Plus 1 new IT in `PersonalSigCardsTest` pinning the helper fallback (16 cards w. all `c.deck_variant.slug == "earthman"`) ; pre-existing change picked up: `static_src/scss/rootvars.scss` (user-modified mid-session) ; 1020 IT/UT green; 7 FTs green (3 picker happy-path + 4 backup deck) in 56s. Sprint 4a-follow complete — primary deferral from Sprint 4a (deck-source fallback UX) now landed. Unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/ when no sig set) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:49:49 -04:00
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."""
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
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"])
My Sign: Brief banner + Earthman [Shabby Cardstock] backup deck when no equipped — TDD User-reported gap on /billboard/my-sign/ — admin user's only deck was in-use as `TableSeat.deck_variant` in another room (Wonderbeard) → `equipped_deck` cleared → previous my-sign template showed "Equip a card deck first…" w. no actionable next step. User scoped fix: don't force equip, just nudge via a Brief banner "Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.", title "Default deck warning". NVM dismisses + picker proceeds against an Earthman card pile labeled in-copy as the temporary backup; FYI links to /gameboard/ (Game Kit equip). User-modified line text in template: "Paperboard" → "Cardstock" mid-session ; **helper fallback** (epic/models.py): `personal_sig_cards(user)` now falls back to `DeckVariant.objects.filter(slug='earthman').first()` when `user.equipped_deck` is None — same 16-or-18 card pile, just sourced from the canonical Earthman deck rather than the empty FK. No new DeckVariant row needed; "Shabby Cardstock" is purely UX framing (cards are the same TarotCard records the room sig-select uses). Preserves the existing helper signature so no callers had to change ; **view + template** (billboard/views.py + my_sign.html): view passes `no_equipped_deck` + `show_backup_intro_banner` flags. Template removes the old `{% if not equipped_deck %}` forced-equip branch — picker now renders unconditionally w. cards from the backup helper when no deck is equipped. Brief banner fires via `Brief.showBanner({...})` on DOMContentLoaded when `show_backup_intro_banner` is true — gets h2-overlay positioning + NVM behavior + portal styling for free (per [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement in note.js's `_alignToH2`). Added `<script src="note.js">` to my_sign.html since the page didn't load it before. Post-render JS tags the Brief w. a `.my-sign-intro-banner` class so FTs (and any future my-sign-specific styling) can distinguish this nudge from other Briefs on the page ; **TDD trail** — 4 new FTs in `MySignBackupDeckTest` (test_bill_my_sign.py): T1 banner renders w. "Default deck warning" title + "no deck is equipped" + "Shabby Cardstock" copy + both action btns visible; T2 picker still populates 16 cards from backup; T3 NVM click removes the banner from the DOM; T4 FYI href ends w. /gameboard/. Initial reds (`NoSuchElementException` on all 4) confirmed before implementation. Plus 1 new IT in `PersonalSigCardsTest` pinning the helper fallback (16 cards w. all `c.deck_variant.slug == "earthman"`) ; pre-existing change picked up: `static_src/scss/rootvars.scss` (user-modified mid-session) ; 1020 IT/UT green; 7 FTs green (3 picker happy-path + 4 backup deck) in 56s. Sprint 4a-follow complete — primary deferral from Sprint 4a (deck-source fallback UX) now landed. Unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/ when no sig set) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:49:49 -04:00
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))
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
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, [])
+52 IT/UT to close IT/UT-only coverage gaps (93% → 96%) — full suite 983 tests in 47s ; UTs in epic/tests/unit/test_models.py — `TarotCardEmanationForTest` (4) covers `emanation_for(polarity)` w. levity/gravity overrides + fallback to name_title for cards w.o a polarity split (cards 48-49 are the only polarity-split cards in the deck so this method is sparsely exercised by ITs); `TarotCardReversalForTest` (4) covers `reversal_for(polarity)` w. polarity-split + reversal_qualifier fallback + further fallthrough to emanation_for; `TarotCardNameSplitTest` (4) covers `name_group`/`name_title` colon-split parsing (prefix-w-colon / suffix / no-colon edge); `TarotCardCautionsJsonTest` (2) covers the `cautions_json` JSON serialiser ; UTs in epic/tests/unit/test_utils.py — `PlanetHouseFallbackTest` +1 happy-path test (degree=15 lands in house 1 w. sequential cusps) for the normal cusp-match branch alongside the existing pathological fallback test; `TopCapacitorsTest` (6) covers all `top_capacitors()` branches — empty dict / None / all-zero counts (the L56 `max(counts.values()) <= 0` fallback that was uncovered) / single-winner / tie-clockwise-order / enriched dict {"count":N} input shape ; ITs in epic/tests/integrated/test_models.py — `TarotDeckDrawTest` extended w. 5 tests for `remaining_count` (happy + no-deck-variant fallback to 0) + `draw()` happy-path (returns n tuples of (TarotCard, bool) / appends to drawn_card_ids / never repeats cards across consecutive draws); existing ValueError + shuffle tests preserved ; ITs in epic/tests/integrated/test_views.py — `SigEventRetractionTest` (4 tests) covers the three `data["retracted"] = True` paths that the FT `test_game_room_select_sig.py` walks transitively but no IT pins directly: sig_unready retracts prior SIG_READY (L937), sig_ready retracts prior SIG_UNREADY (L907), sig_reserve action=release while ready retracts prior SIG_READY + records fresh SIG_UNREADY (L823); `SigReserveInvalidCardIdTest` (1) covers `TarotCard.DoesNotExist` → 400 (L840-841) ; `SigSelectGravityContextTest` (3) covers the `user_polarity = 'gravity'` branch (L322) + the `gravity_sig_cards` lookup (L357) — all existing SIG_SELECT context tests use the founder-as-PC-levity setup so these branches sat uncovered; logs in as gamers[5] (BC role) + asserts user_polarity + sig_cards match `gravity_sig_cards()` output ; `SeaDeckViewTest` (7) mirrors the `test_game_room_select_sea.py` FT but isolates the JSON contract — covers 403 when unseated, empty halves when seat has no deck_variant (L1255-1256 early-out), two-halves shape, ~even split, card_dict keys (`id`/`name`/`arcana`/`corner_rank`/`suit_icon`/`name_group`/`name_title`/`reversed`/qualifiers), `reversed` field is bool, claimed-significator exclusion via `room.table_seats.exclude(significator__isnull=True)` ; ITs in dashboard/tests/integrated/test_views.py — `ProfileViewTest` +2 (reserved-handle "adman" rejection — L116-117: username stays unchanged + redirect to /); `KitBagViewTest` (3) covers the `kit_bag` view's panel render w. TITHE-sort branch (L169-175) + login guard ; ITs in dashboard/tests/integrated/test_sky_views.py — `SkyViewTest` +2 (saved birth datetime renders in user's `sky_birth_tz` via astimezone L300-306 — 16:00 UTC → 12:00 EDT; invalid-tz string triggers `ZoneInfoNotFoundError` → swallowed `pass` → UTC fallback at 16:00) ; ITs in gameboard/tests/integrated/test_views.py — `EquipTrinketViewTest` +2 (POST equips trinket + returns 204 — L83-85; non-owner POST returns 404 via `get_object_or_404`); `UnequipTrinketViewTest` +2 (POST clears matching equipped_trinket — L107-110; POST of non-matching token is a 204 no-op, the implicit `else` branch) ; .coveragerc omit gains `*/reset_staging_db.py` per user — mgmt cmd was the only 0%-stmt module that wasn't exercised by tests at all + we agreed it's deliberately untested staging-side code ; palette-monochrome-dark rebalance in rootvars.scss — --quiUser/--sixUser/--sepUser remapped to (secAg / quaAg / priPt) instead of (quaAg / terAg / secAg), shifting the secondary/subtle/deep-subtle anchors up the silver gradient so the palette reads more cleanly under the new sig-stage card colours from 3242873 ; uncovered remnants from earlier analysis intentionally left in place — consumers.py at 68% (channels-tag tests excluded; would need --tag=channels run), Carte Blanche slot navigation + sky_dice + tarot_deck preview view paths (the "bigger investments" tier from session triage; FT-covered + the IT setup is heavier than the immediate value), defensive `except` fallbacks that need contrived inputs to fire, and a handful of __str__s/`pass` branches not worth a test apiece — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 01:07:13 -04:00
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)