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_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 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, "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), []) 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)